From b87f6e5abb3b73cba7e08f5482df9d95521110f9 Mon Sep 17 00:00:00 2001 From: Brandur Date: Wed, 8 Nov 2023 12:47:21 -0800 Subject: [PATCH 01/43] Initial commit --- js/README.md | 8 ++++++++ js/index.js | 0 js/package-lock.json | 13 +++++++++++++ js/package.json | 19 +++++++++++++++++++ 4 files changed, 40 insertions(+) create mode 100644 js/README.md create mode 100644 js/index.js create mode 100644 js/package-lock.json create mode 100644 js/package.json diff --git a/js/README.md b/js/README.md new file mode 100644 index 000000000..6f59b4b72 --- /dev/null +++ b/js/README.md @@ -0,0 +1,8 @@ +# River JS bindings + +A future home for River's JS bindings. For now, the [NPM package is registered](https://www.npmjs.com/package/riverqueue), but nothing else is done. + +``` sh +$ npm install . +$ npm publish --access public +``` diff --git a/js/index.js b/js/index.js new file mode 100644 index 000000000..e69de29bb diff --git a/js/package-lock.json b/js/package-lock.json new file mode 100644 index 000000000..cb3a037b9 --- /dev/null +++ b/js/package-lock.json @@ -0,0 +1,13 @@ +{ + "name": "riverqueue", + "version": "0.0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "riverqueue", + "version": "0.0.0.0", + "license": "GPL-3.0" + } + } +} diff --git a/js/package.json b/js/package.json new file mode 100644 index 000000000..a9bc64faa --- /dev/null +++ b/js/package.json @@ -0,0 +1,19 @@ +{ + "name": "riverqueue", + "version": "0.0.1", + "description": "A fast job queue for Go.", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/riverqueue/river-js.git" + }, + "author": "Brandur Leach", + "license": "GPL-3.0", + "bugs": { + "url": "https://github.com/riverqueue/river-js/issues" + }, + "homepage": "https://github.com/riverqueue/river-js#readme" +} From 473b19815620f52c45e59833c73c822f722965c5 Mon Sep 17 00:00:00 2001 From: Brandur Date: Wed, 8 Nov 2023 13:30:26 -0800 Subject: [PATCH 02/43] License should've been LGPL, not GPL --- js/package-lock.json | 6 +++--- js/package.json | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/js/package-lock.json b/js/package-lock.json index cb3a037b9..b3eb3ca43 100644 --- a/js/package-lock.json +++ b/js/package-lock.json @@ -1,13 +1,13 @@ { "name": "riverqueue", - "version": "0.0.0.0", + "version": "0.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "riverqueue", - "version": "0.0.0.0", - "license": "GPL-3.0" + "version": "0.0.2", + "license": "LGPL3+" } } } diff --git a/js/package.json b/js/package.json index a9bc64faa..643076354 100644 --- a/js/package.json +++ b/js/package.json @@ -1,6 +1,6 @@ { "name": "riverqueue", - "version": "0.0.1", + "version": "0.0.2", "description": "A fast job queue for Go.", "main": "index.js", "scripts": { @@ -11,7 +11,7 @@ "url": "git+https://github.com/riverqueue/river-js.git" }, "author": "Brandur Leach", - "license": "GPL-3.0", + "license": "LGPL3+", "bugs": { "url": "https://github.com/riverqueue/river-js/issues" }, From c62a6628ae1b67eda510a2cc3cc88085b24070dc Mon Sep 17 00:00:00 2001 From: Brandur Date: Mon, 1 Jun 2026 06:31:56 -0700 Subject: [PATCH 03/43] Add insert-only River TypeScript package (#1) Add insert-only River TypeScript package, similar to what we have already for Python and Ruby. Like the other packages, it's split up into subpackages for each supported DB package/ORM, which so far is `prisma` and `node-postgres`. --- js/.github/dependabot.yml | 16 + js/.github/workflows/ci.yaml | 173 ++ js/.gitignore | 4 + js/.prettierrc | 5 + js/CHANGELOG.md | 14 + js/README.md | 8 - js/docs/README.md | 180 ++ js/docs/development.md | 70 + js/driver/pg/package.json | 48 + js/driver/pg/src/driver.integration.test.ts | 215 ++ js/driver/pg/src/driver.test.ts | 299 +++ js/driver/pg/src/driver.ts | 137 ++ js/driver/pg/src/index.ts | 1 + js/driver/pg/tsconfig.json | 8 + js/driver/prisma/package.json | 45 + .../prisma/src/driver.integration.test.ts | 201 ++ js/driver/prisma/src/driver.test.ts | 303 +++ js/driver/prisma/src/driver.ts | 146 ++ js/driver/prisma/src/index.ts | 2 + js/driver/prisma/tsconfig.json | 8 + js/eslint.config.js | 18 + js/examples/node-postgres/README.md | 35 + js/examples/node-postgres/package.json | 19 + js/examples/node-postgres/src/index.ts | 85 + js/examples/node-postgres/tsconfig.json | 14 + js/examples/prisma/README.md | 42 + js/examples/prisma/package.json | 19 + js/examples/prisma/prisma/schema.prisma | 8 + js/examples/prisma/src/index.ts | 85 + js/examples/prisma/tsconfig.json | 14 + js/index.js | 0 js/package-lock.json | 13 - js/package.json | 62 +- js/pnpm-lock.yaml | 2014 +++++++++++++++++ js/pnpm-workspace.yaml | 8 + js/src/client.test.ts | 268 +++ js/src/client.ts | 324 +++ js/src/driver.ts | 50 + js/src/index.ts | 23 + js/src/insert-opts.ts | 58 + js/src/job.ts | 148 ++ js/src/unique-bitmask.test.ts | 130 ++ js/src/unique-bitmask.ts | 45 + js/tsconfig.base.json | 14 + js/tsconfig.json | 8 + js/vitest.config.ts | 13 + js/vitest.integration.config.ts | 13 + 47 files changed, 5383 insertions(+), 30 deletions(-) create mode 100644 js/.github/dependabot.yml create mode 100644 js/.github/workflows/ci.yaml create mode 100644 js/.gitignore create mode 100644 js/.prettierrc create mode 100644 js/CHANGELOG.md delete mode 100644 js/README.md create mode 100644 js/docs/README.md create mode 100644 js/docs/development.md create mode 100644 js/driver/pg/package.json create mode 100644 js/driver/pg/src/driver.integration.test.ts create mode 100644 js/driver/pg/src/driver.test.ts create mode 100644 js/driver/pg/src/driver.ts create mode 100644 js/driver/pg/src/index.ts create mode 100644 js/driver/pg/tsconfig.json create mode 100644 js/driver/prisma/package.json create mode 100644 js/driver/prisma/src/driver.integration.test.ts create mode 100644 js/driver/prisma/src/driver.test.ts create mode 100644 js/driver/prisma/src/driver.ts create mode 100644 js/driver/prisma/src/index.ts create mode 100644 js/driver/prisma/tsconfig.json create mode 100644 js/eslint.config.js create mode 100644 js/examples/node-postgres/README.md create mode 100644 js/examples/node-postgres/package.json create mode 100644 js/examples/node-postgres/src/index.ts create mode 100644 js/examples/node-postgres/tsconfig.json create mode 100644 js/examples/prisma/README.md create mode 100644 js/examples/prisma/package.json create mode 100644 js/examples/prisma/prisma/schema.prisma create mode 100644 js/examples/prisma/src/index.ts create mode 100644 js/examples/prisma/tsconfig.json delete mode 100644 js/index.js delete mode 100644 js/package-lock.json create mode 100644 js/pnpm-lock.yaml create mode 100644 js/pnpm-workspace.yaml create mode 100644 js/src/client.test.ts create mode 100644 js/src/client.ts create mode 100644 js/src/driver.ts create mode 100644 js/src/index.ts create mode 100644 js/src/insert-opts.ts create mode 100644 js/src/job.ts create mode 100644 js/src/unique-bitmask.test.ts create mode 100644 js/src/unique-bitmask.ts create mode 100644 js/tsconfig.base.json create mode 100644 js/tsconfig.json create mode 100644 js/vitest.config.ts create mode 100644 js/vitest.integration.config.ts diff --git a/js/.github/dependabot.yml b/js/.github/dependabot.yml new file mode 100644 index 000000000..5c9bc9a26 --- /dev/null +++ b/js/.github/dependabot.yml @@ -0,0 +1,16 @@ +version: 2 +updates: + - cooldown: + default-days: 7 + directories: + - "**/*" + groups: + npm-dependencies: + update-types: + - "minor" + - "patch" + open-pull-requests-limit: 10 + package-ecosystem: "npm" + schedule: + interval: "monthly" + versioning-strategy: increase diff --git a/js/.github/workflows/ci.yaml b/js/.github/workflows/ci.yaml new file mode 100644 index 000000000..2248c1843 --- /dev/null +++ b/js/.github/workflows/ci.yaml @@ -0,0 +1,173 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +# Node versions to test against (last two LTS releases). +# Update these when new LTS versions are released. + +jobs: + build: + name: Build + runs-on: ubuntu-latest + + strategy: + matrix: &last_two_lts_node_versions + node-version: [22, 24] + + steps: + - uses: actions/checkout@v6 + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v6 + with: + cache: pnpm + node-version: ${{ matrix.node-version }} + + - run: pnpm install --frozen-lockfile + + - run: pnpm run build:all + + lint: + name: Lint + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - run: pnpm run lint + + - run: pnpm run fmt:check + + examples: + name: Examples + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:17 + 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 + + strategy: + matrix: *last_two_lts_node_versions + + env: + DATABASE_URL: postgres://postgres:postgres@localhost:5432/river_test?sslmode=disable + + steps: + - uses: actions/checkout@v6 + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v6 + with: + cache: pnpm + node-version: ${{ matrix.node-version }} + + - uses: actions/setup-go@v5 + with: + go-version: stable + cache: false + + - run: go install github.com/riverqueue/river/cmd/river@latest + + - run: river migrate-up --database-url "$DATABASE_URL" + + - run: pnpm install --frozen-lockfile + + - run: pnpm run build:all + + - run: cd examples/prisma && npx prisma generate + + - run: pnpm --filter='./examples/*' run build + + - run: pnpm --filter='./examples/*' run start + + test: + name: Test + runs-on: ubuntu-latest + + strategy: + matrix: *last_two_lts_node_versions + + steps: + - uses: actions/checkout@v6 + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v6 + with: + cache: pnpm + node-version: ${{ matrix.node-version }} + + - run: pnpm install --frozen-lockfile + + - run: pnpm run test + + test_integration: + name: Test (integration) + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:17 + 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 + + strategy: + matrix: *last_two_lts_node_versions + + env: + TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/river_test?sslmode=disable + + steps: + - uses: actions/checkout@v6 + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v6 + with: + cache: pnpm + node-version: ${{ matrix.node-version }} + + - uses: actions/setup-go@v5 + with: + go-version: stable + cache: false + + - run: go install github.com/riverqueue/river/cmd/river@latest + + - run: river migrate-up --database-url "$TEST_DATABASE_URL" + + - run: pnpm install --frozen-lockfile + + - run: pnpm run test:integration diff --git a/js/.gitignore b/js/.gitignore new file mode 100644 index 000000000..62ccde41c --- /dev/null +++ b/js/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.tsbuildinfo +.DS_Store 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..f831116ec --- /dev/null +++ b/js/CHANGELOG.md @@ -0,0 +1,14 @@ +# 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] + +## [0.1.0] - 2026-05-15 + +### 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/README.md b/js/README.md deleted file mode 100644 index 6f59b4b72..000000000 --- a/js/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# River JS bindings - -A future home for River's JS bindings. For now, the [NPM package is registered](https://www.npmjs.com/package/riverqueue), but nothing else is done. - -``` sh -$ npm install . -$ npm publish --access public -``` diff --git a/js/docs/README.md b/js/docs/README.md new file mode 100644 index 000000000..c4c0cf043 --- /dev/null +++ b/js/docs/README.md @@ -0,0 +1,180 @@ +# River TypeScript Client + +TypeScript client for [River](https://github.com/riverqueue/river), a fast and reliable background job framework for PostgreSQL. + +This is an **insert-only** client — it can enqueue jobs for processing, but jobs are executed by a River server written in Go. Both the client and server share the same PostgreSQL database. + +## Packages + +The project is structured as a monorepo with a core package and driver packages: + +| Package | Description | +|---------|-------------| +| [`riverqueue`](.) | Core client, types, and job insertion logic. | +| [`@riverqueue/driver-pg`](./driver/pg) | Driver for [node-postgres (`pg`)](https://node-postgres.com/). | +| [`@riverqueue/driver-prisma`](./driver/prisma) | Driver for [Prisma](https://www.prisma.io/). | + +Drivers are separate packages so that ORM/database libraries not in use don't become transitive dependencies. + +## Installation + +Install the core package along with the driver for your database library: + +```sh +# Using node-postgres (pg) +pnpm add riverqueue @riverqueue/driver-pg pg + +# Using Prisma +pnpm add riverqueue @riverqueue/driver-prisma +``` + +## Usage + +### Defining Job Args + +Job args must implement the `JobArgs` interface with a `kind` string that identifies the job type. Use `toJSON()` to control which fields are serialized as the job's args in the database: + +```typescript +import type { JobArgs } from "riverqueue"; + +class SortArgs implements JobArgs { + kind = "sort"; + + constructor(public strings: string[]) {} + + toJSON() { + return { strings: this.strings }; + } +} +``` + +For quick one-off jobs, use `JobArgsObject`: + +```typescript +import { JobArgsObject } from "riverqueue"; + +const args = new JobArgsObject("sort", { strings: ["whale", "tiger", "bear"] }); +``` + +### Inserting Jobs + +#### With node-postgres + +```typescript +import { Pool } from "pg"; +import { Client } from "riverqueue"; +import { PgDriver } from "@riverqueue/driver-pg"; + +const pool = new Pool({ connectionString: "postgres://localhost/mydb" }); +const client = new Client(new PgDriver(pool)); + +// Insert a single job +const result = await client.insert(new SortArgs(["whale", "tiger", "bear"])); +console.log(result.job.id); // inserted job ID + +// Insert with options +const result2 = await client.insert( + new SortArgs(["whale", "tiger", "bear"]), + { + queue: "high_priority", + priority: 2, + maxAttempts: 5, + } +); + +// Insert many jobs at once +const results = await client.insertMany([ + new SortArgs(["whale", "tiger"]), + new SortArgs(["bear", "fox"]), +]); +``` + +#### With Prisma + +```typescript +import { PrismaClient } from "@prisma/client"; +import { Client } from "riverqueue"; +import { PrismaDriver } from "@riverqueue/driver-prisma"; + +const prisma = new PrismaClient(); +const client = new Client(new PrismaDriver(prisma)); + +const result = await client.insert(new SortArgs(["whale", "tiger", "bear"])); +``` + +### Scheduled Jobs + +Schedule jobs to run at a future time: + +```typescript +await client.insert(new SortArgs(["whale", "tiger"]), { + scheduledAt: new Date(Date.now() + 60 * 60 * 1000), // 1 hour from now +}); +``` + +### Unique Jobs + +Unique jobs prevent duplicate insertions based on configurable criteria: + +```typescript +await client.insert(new SortArgs(["whale", "tiger"]), { + uniqueOpts: { + byArgs: true, // unique per args + byQueue: true, // unique per queue + byPeriod: 900, // unique within 15-minute windows + }, +}); +``` + +### Batch Inserts + +Use `insertMany` for efficient batch insertions: + +```typescript +import { InsertManyParams } from "riverqueue"; + +const results = await client.insertMany([ + // Raw job args use default options + new SortArgs(["whale", "tiger"]), + + // InsertManyParams pairs args with per-job options + new InsertManyParams(new SortArgs(["bear", "fox"]), { + queue: "high_priority", + maxAttempts: 10, + }), +]); +``` + +### Transactions + +#### With node-postgres + +```typescript +const poolClient = await pool.connect(); +try { + await poolClient.query("BEGIN"); + + await client.insert(new SortArgs(["whale"]), { tx: poolClient }); + await client.insert(new SortArgs(["tiger"]), { tx: poolClient }); + + await poolClient.query("COMMIT"); +} catch (e) { + await poolClient.query("ROLLBACK"); + throw e; +} finally { + poolClient.release(); +} +``` + +#### With Prisma + +```typescript +await prisma.$transaction(async (tx) => { + await client.insert(new SortArgs(["whale"]), { tx }); + await client.insert(new SortArgs(["tiger"]), { tx }); +}); +``` + +## Development + +See [developing River TypeScript](https://github.com/riverqueue/riverqueue-js/blob/master/docs/development.md). diff --git a/js/docs/development.md b/js/docs/development.md new file mode 100644 index 000000000..47b29d11d --- /dev/null +++ b/js/docs/development.md @@ -0,0 +1,70 @@ +# River TypeScript development + +## Setup + + pnpm install + +## Commands + +```sh +pnpm run build # Build the core package +pnpm run build:all # Build all packages (core + drivers) +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:integration # Run integration tests (requires database) +``` + +## Integration tests + +Integration tests run against a real PostgreSQL database with River's schema. Create the test database and apply migrations: + + createdb river_test + river migrate-up --database-url "postgres://localhost/river_test" --line main + +The `river` CLI can be installed with Go: + + go install github.com/riverqueue/river/cmd/river@latest + +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 + +## Releasing a new version + +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 + ``` + +2. Update version numbers in all `package.json` files: + + ```shell + pnpm --filter '*' exec -- npm version $VERSION --no-git-tag-version + npm version $VERSION --no-git-tag-version + ``` + +3. Prepare a PR with the changes, updating `CHANGELOG.md` with any necessary additions at the same time. Have it reviewed and merged. + +4. Upon merge, pull down the changes, tag, and push: + + ```shell + git checkout master && git pull --rebase + git tag v$VERSION -m "release v$VERSION" + git push --tags + ``` + +5. Publish packages to npm: + + ```shell + pnpm run build:all + pnpm publish + pnpm --filter '@riverqueue/*' publish --access public + ``` + +6. Cut a new GitHub release by visiting [new release](https://github.com/riverqueue/riverqueue-js/releases/new), selecting the new tag, and copying in the version's `CHANGELOG.md` content as the release body. diff --git a/js/driver/pg/package.json b/js/driver/pg/package.json new file mode 100644 index 000000000..59ac797b6 --- /dev/null +++ b/js/driver/pg/package.json @@ -0,0 +1,48 @@ +{ + "name": "@riverqueue/driver-pg", + "version": "0.1.0", + "description": "node-postgres (pg) driver for the riverqueue TypeScript client.", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "clean": "rm -rf dist", + "prepublishOnly": "pnpm run clean && pnpm run build" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/riverqueue/riverqueue-js.git", + "directory": "driver/pg" + }, + "authors": ["Brandur Leach", "Blake Gentry"], + "license": "LGPL-3.0-or-later", + "dependencies": { + "riverqueue": "workspace:*" + }, + "peerDependencies": { + "pg": ">=8.0.0" + }, + "devDependencies": { + "@types/pg": "^8.11.0", + "pg": "^8.13.0", + "typescript": "^5.8.0" + }, + "keywords": [ + "river", + "job-queue", + "postgresql", + "pg", + "node-postgres", + "driver" + ] +} 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..0fa355b5c --- /dev/null +++ b/js/driver/pg/src/driver.integration.test.ts @@ -0,0 +1,215 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import pg from "pg"; +import { Client, JobArgsObject } from "riverqueue"; +import type { JobArgs } from "riverqueue"; +import { PgDriver } from "./driver.js"; + +const TEST_DATABASE_URL = + process.env.TEST_DATABASE_URL || + "postgres://localhost:5432/river_test?sslmode=disable"; + +// Per-file random prefix so parallel test files don't interfere with each +// other's cleanup. +const filePrefix = `pg_${Math.random().toString(36).slice(2, 8)}`; + +describe("PgDriver integration", () => { + let pool: pg.Pool; + let client: Client; + + beforeAll(async () => { + pool = new pg.Pool({ connectionString: TEST_DATABASE_URL }); + client = new Client(new PgDriver(pool)); + }); + + afterAll(async () => { + await pool.end(); + }); + + afterEach(async () => { + await pool.query("DELETE FROM river_job WHERE kind LIKE $1", [ + `${filePrefix}%`, + ]); + }); + + it("inserts a job and returns it", async () => { + const result = await client.insert( + new JobArgsObject(`${filePrefix}_basic`, { key: "value" }) + ); + + expect(result.job.id).toBeGreaterThan(0); + expect(result.job.kind).toBe(`${filePrefix}_basic`); + expect(result.job.args).toEqual({ key: "value" }); + expect(result.job.state).toBe("available"); + expect(result.job.queue).toBe("default"); + expect(result.job.priority).toBe(1); + expect(result.job.maxAttempts).toBe(25); + expect(result.job.attempt).toBe(0); + expect(result.job.tags).toEqual([]); + expect(result.job.metadata).toEqual({}); + expect(result.job.createdAt).toBeInstanceOf(Date); + expect(result.job.scheduledAt).toBeInstanceOf(Date); + expect(result.job.attemptedAt).toBeNull(); + expect(result.job.attemptedBy).toBeNull(); + expect(result.job.errors).toBeNull(); + expect(result.job.finalizedAt).toBeNull(); + expect(result.uniqueSkippedAsDuplicated).toBe(false); + }); + + it("inserts with all options", async () => { + const future = new Date(Date.now() + 3_600_000); + + const result = await client.insert( + new JobArgsObject(`${filePrefix}_opts`, { n: 42 }), + { + maxAttempts: 5, + priority: 3, + queue: "high_priority", + scheduledAt: future, + tags: ["tag_one", "tag_two"], + } + ); + + expect(result.job.kind).toBe(`${filePrefix}_opts`); + expect(result.job.maxAttempts).toBe(5); + expect(result.job.priority).toBe(3); + expect(result.job.queue).toBe("high_priority"); + expect(result.job.state).toBe("scheduled"); + expect(result.job.tags).toEqual(["tag_one", "tag_two"]); + }); + + it("inserts many jobs", async () => { + const results = await client.insertMany([ + new JobArgsObject(`${filePrefix}_batch_a`, { i: 1 }), + new JobArgsObject(`${filePrefix}_batch_b`, { i: 2 }), + new JobArgsObject(`${filePrefix}_batch_c`, { i: 3 }), + ]); + + expect(results).toHaveLength(3); + + const ids = results.map((r) => r.job.id); + expect(new Set(ids).size).toBe(3); + + expect(results[0]!.job.kind).toBe(`${filePrefix}_batch_a`); + expect(results[1]!.job.kind).toBe(`${filePrefix}_batch_b`); + expect(results[2]!.job.kind).toBe(`${filePrefix}_batch_c`); + }); + + it("handles unique job insertion", async () => { + const uniqueOpts = { byArgs: true as const, byQueue: true as const }; + + const first = await client.insert( + new JobArgsObject(`${filePrefix}_unique`, { key: "same" }), + { uniqueOpts } + ); + expect(first.uniqueSkippedAsDuplicated).toBe(false); + expect(first.job.uniqueKey).not.toBeNull(); + + const second = await client.insert( + new JobArgsObject(`${filePrefix}_unique`, { key: "same" }), + { uniqueOpts } + ); + expect(second.uniqueSkippedAsDuplicated).toBe(true); + expect(second.job.id).toBe(first.job.id); + }); + + it("allows unique jobs with different args", async () => { + const uniqueOpts = { byArgs: true as const }; + + const first = await client.insert( + new JobArgsObject(`${filePrefix}_unique`, { key: "one" }), + { uniqueOpts } + ); + const second = await client.insert( + new JobArgsObject(`${filePrefix}_unique`, { key: "two" }), + { uniqueOpts } + ); + + expect(first.uniqueSkippedAsDuplicated).toBe(false); + expect(second.uniqueSkippedAsDuplicated).toBe(false); + expect(second.job.id).not.toBe(first.job.id); + }); + + it("uses custom class args with toJSON", async () => { + const kind = `${filePrefix}_email`; + + class EmailArgs implements JobArgs { + kind = kind; + constructor( + public to: string, + public subject: string + ) {} + toJSON() { + return { to: this.to, subject: this.subject }; + } + } + + const result = await client.insert( + new EmailArgs("user@example.com", "Hello") + ); + + expect(result.job.kind).toBe(kind); + expect(result.job.args).toEqual({ + to: "user@example.com", + subject: "Hello", + }); + }); + + it("verifies job exists in database after insert", async () => { + const result = await client.insert( + new JobArgsObject(`${filePrefix}_verify`, { data: "check" }) + ); + + const dbResult = await pool.query("SELECT * FROM river_job WHERE id = $1", [ + result.job.id, + ]); + expect(dbResult.rowCount).toBe(1); + expect(dbResult.rows[0].kind).toBe(`${filePrefix}_verify`); + expect(dbResult.rows[0].args).toEqual({ data: "check" }); + }); + + it("inserts within a transaction via tx option", async () => { + const poolClient = await pool.connect(); + try { + await poolClient.query("BEGIN"); + + await client.insert(new JobArgsObject(`${filePrefix}_tx_1`, {}), { + tx: poolClient, + }); + await client.insert(new JobArgsObject(`${filePrefix}_tx_2`, {}), { + tx: poolClient, + }); + + await poolClient.query("COMMIT"); + } finally { + poolClient.release(); + } + + const dbResult = await pool.query( + `SELECT kind FROM river_job WHERE kind LIKE '${filePrefix}_tx_%' ORDER BY kind` + ); + expect(dbResult.rows.map((r) => r.kind)).toEqual([ + `${filePrefix}_tx_1`, + `${filePrefix}_tx_2`, + ]); + }); + + it("rolls back transaction via tx option", async () => { + const poolClient = await pool.connect(); + try { + await poolClient.query("BEGIN"); + + await client.insert(new JobArgsObject(`${filePrefix}_rollback`, {}), { + tx: poolClient, + }); + + await poolClient.query("ROLLBACK"); + } finally { + poolClient.release(); + } + + const dbResult = await pool.query( + `SELECT * FROM river_job WHERE kind = '${filePrefix}_rollback'` + ); + expect(dbResult.rowCount).toBe(0); + }); +}); diff --git a/js/driver/pg/src/driver.test.ts b/js/driver/pg/src/driver.test.ts new file mode 100644 index 000000000..ea1b87bb3 --- /dev/null +++ b/js/driver/pg/src/driver.test.ts @@ -0,0 +1,299 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { PgDriver } from "./driver.js"; +import type { JobInsertParams } from "riverqueue"; + +// Simulates what pg returns for a river_job row. +function fakePgRow(overrides: Record = {}) { + return { + id: "42", // pg returns bigint as string + args: { strings: ["a", "b"] }, + attempt: 0, + attempted_at: null, + attempted_by: null, + created_at: new Date("2024-06-01T00:00:00Z"), + errors: null, + finalized_at: null, + kind: "sort", + max_attempts: 25, + metadata: {}, + priority: 1, + queue: "default", + scheduled_at: new Date("2024-06-01T00:00:00Z"), + state: "available", + tags: ["tag1", "tag2"], + unique_key: null, + unique_states: null, + unique_skipped_as_duplicate: false, + ...overrides, + }; +} + +function fakeInsertParams( + overrides: Partial = {} +): JobInsertParams { + return { + encodedArgs: '{"strings":["a","b"]}', + kind: "sort", + maxAttempts: 25, + priority: 1, + queue: "default", + scheduledAt: new Date("2024-06-01T00:00:00Z"), + state: "available", + tags: [], + uniqueKey: null, + uniqueStates: null, + ...overrides, + }; +} + +// Minimal mock matching the pg Pool/PoolClient query interface. +function mockPgClient() { + return { + capturedSql: "" as string, + capturedValues: [] as unknown[], + rowsToReturn: [] as Record[], + query: vi.fn(async function ( + this: { + capturedSql: string; + capturedValues: unknown[]; + rowsToReturn: Record[]; + }, + sql: string, + values: unknown[] + ) { + this.capturedSql = sql; + this.capturedValues = values; + return { rows: this.rowsToReturn, rowCount: this.rowsToReturn.length }; + }), + }; +} + +describe("PgDriver", () => { + let pgClient: ReturnType; + let driver: PgDriver; + + beforeEach(() => { + pgClient = mockPgClient(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + driver = new PgDriver(pgClient as any); + }); + + describe("jobInsertMany", () => { + it("returns empty array for empty params", async () => { + const results = await driver.jobInsertMany([]); + expect(results).toEqual([]); + expect(pgClient.query).not.toHaveBeenCalled(); + }); + + it("constructs correct SQL and parameters for single insert", async () => { + const scheduledAt = new Date("2024-06-01T12:00:00Z"); + pgClient.rowsToReturn = [fakePgRow()]; + + await driver.jobInsertMany([ + fakeInsertParams({ scheduledAt, tags: ["urgent"] }), + ]); + + expect(pgClient.query).toHaveBeenCalledOnce(); + const sql = pgClient.query.mock.calls[0]![0] as string; + const values = pgClient.query.mock.calls[0]![1] as unknown[]; + + expect(sql).toContain("INSERT INTO river_job"); + expect(sql).toContain("ON CONFLICT (unique_key)"); + expect(sql).toContain("river_job_state_in_bitmask"); + expect(sql).toContain("RETURNING"); + expect(sql).toContain("unique_skipped_as_duplicate"); + + // 10 params per row + expect(values).toHaveLength(10); + expect(values[0]).toBe('{"strings":["a","b"]}'); // encodedArgs + expect(values[1]).toBe("sort"); // kind + expect(values[2]).toBe(25); // maxAttempts + expect(values[3]).toBe(1); // priority + expect(values[4]).toBe("default"); // queue + expect(values[5]).toBe(scheduledAt); // scheduledAt + expect(values[6]).toBe("available"); // state + expect(values[7]).toEqual(["urgent"]); // tags + expect(values[8]).toBeNull(); // uniqueKey + expect(values[9]).toBeNull(); // uniqueStates + }); + + it("constructs correct parameters for batch insert", async () => { + pgClient.rowsToReturn = [fakePgRow(), fakePgRow({ id: "43" })]; + + await driver.jobInsertMany([ + fakeInsertParams({ kind: "job_a" }), + fakeInsertParams({ kind: "job_b" }), + ]); + + const sql = pgClient.query.mock.calls[0]![0] as string; + const values = pgClient.query.mock.calls[0]![1] as unknown[]; + + // Should have two VALUE clauses + expect(sql).toContain("$1::jsonb"); + expect(sql).toContain("$11::jsonb"); + expect(values).toHaveLength(20); + expect(values[1]).toBe("job_a"); + expect(values[11]).toBe("job_b"); + }); + + it("converts unique key to Buffer", async () => { + const uniqueKey = new Uint8Array([1, 2, 3, 4]); + pgClient.rowsToReturn = [fakePgRow()]; + + await driver.jobInsertMany([ + fakeInsertParams({ uniqueKey, uniqueStates: "11110101" }), + ]); + + const values = pgClient.query.mock.calls[0]![1] as unknown[]; + expect(Buffer.isBuffer(values[8])).toBe(true); + expect(values[9]).toBe("11110101"); + }); + + it("uses schema prefix in SQL when provided", async () => { + pgClient.rowsToReturn = [fakePgRow()]; + + await driver.jobInsertMany([fakeInsertParams()], { + schemaPrefix: '"custom".', + }); + + const sql = pgClient.query.mock.calls[0]![0] as string; + expect(sql).toContain('INSERT INTO "custom".river_job'); + expect(sql).toContain('"custom".river_job_state_in_bitmask'); + }); + + it("omits schema prefix when empty", async () => { + pgClient.rowsToReturn = [fakePgRow()]; + + await driver.jobInsertMany([fakeInsertParams()], { + schemaPrefix: "", + }); + + const sql = pgClient.query.mock.calls[0]![0] as string; + expect(sql).toContain("INSERT INTO river_job"); + expect(sql).not.toContain('".'); + }); + }); + + describe("jobInsert", () => { + it("delegates to jobInsertMany", async () => { + pgClient.rowsToReturn = [fakePgRow()]; + + const [job, skipped] = await driver.jobInsert(fakeInsertParams()); + + expect(pgClient.query).toHaveBeenCalledOnce(); + expect(job.kind).toBe("sort"); + expect(skipped).toBe(false); + }); + }); + + describe("row mapping", () => { + it("maps basic columns correctly", async () => { + pgClient.rowsToReturn = [fakePgRow()]; + + const [job] = await driver.jobInsert(fakeInsertParams()); + + expect(job.id).toBe(42); + expect(typeof job.id).toBe("number"); + expect(job.args).toEqual({ strings: ["a", "b"] }); + expect(job.attempt).toBe(0); + expect(job.kind).toBe("sort"); + expect(job.maxAttempts).toBe(25); + expect(job.metadata).toEqual({}); + expect(job.priority).toBe(1); + expect(job.queue).toBe("default"); + expect(job.state).toBe("available"); + expect(job.tags).toEqual(["tag1", "tag2"]); + expect(job.createdAt).toEqual(new Date("2024-06-01T00:00:00Z")); + expect(job.scheduledAt).toEqual(new Date("2024-06-01T00:00:00Z")); + }); + + it("maps null columns", async () => { + pgClient.rowsToReturn = [fakePgRow()]; + + const [job] = await driver.jobInsert(fakeInsertParams()); + + expect(job.attemptedAt).toBeNull(); + expect(job.attemptedBy).toBeNull(); + expect(job.errors).toBeNull(); + expect(job.finalizedAt).toBeNull(); + expect(job.uniqueKey).toBeNull(); + expect(job.uniqueStates).toBeNull(); + }); + + it("maps non-null optional columns", async () => { + pgClient.rowsToReturn = [ + fakePgRow({ + attempted_at: new Date("2024-06-01T01:00:00Z"), + attempted_by: ["worker-1"], + finalized_at: new Date("2024-06-01T02:00:00Z"), + }), + ]; + + const [job] = await driver.jobInsert(fakeInsertParams()); + + expect(job.attemptedAt).toEqual(new Date("2024-06-01T01:00:00Z")); + expect(job.attemptedBy).toEqual(["worker-1"]); + expect(job.finalizedAt).toEqual(new Date("2024-06-01T02:00:00Z")); + }); + + it("maps errors from jsonb array", async () => { + pgClient.rowsToReturn = [ + fakePgRow({ + errors: [ + { + at: "2024-06-01T01:00:00Z", + attempt: 1, + error: "something broke", + trace: "stack trace here", + }, + ], + }), + ]; + + const [job] = await driver.jobInsert(fakeInsertParams()); + + expect(job.errors).toHaveLength(1); + expect(job.errors![0]!.at).toEqual(new Date("2024-06-01T01:00:00Z")); + expect(job.errors![0]!.attempt).toBe(1); + expect(job.errors![0]!.error).toBe("something broke"); + expect(job.errors![0]!.trace).toBe("stack trace here"); + }); + + it("maps unique key from Buffer", async () => { + const buf = Buffer.from([0xde, 0xad, 0xbe, 0xef]); + pgClient.rowsToReturn = [fakePgRow({ unique_key: buf })]; + + const [job] = await driver.jobInsert(fakeInsertParams()); + + expect(job.uniqueKey).toBeInstanceOf(Uint8Array); + expect(job.uniqueKey).toEqual(new Uint8Array([0xde, 0xad, 0xbe, 0xef])); + }); + + it("maps unique states from bit string", async () => { + // "10000001" = available + scheduled + pgClient.rowsToReturn = [fakePgRow({ unique_states: "10000001" })]; + + const [job] = await driver.jobInsert(fakeInsertParams()); + + expect(job.uniqueStates).toEqual(["available", "scheduled"]); + }); + + it("reports unique_skipped_as_duplicate", async () => { + pgClient.rowsToReturn = [ + fakePgRow({ unique_skipped_as_duplicate: true }), + ]; + + const [, skipped] = await driver.jobInsert(fakeInsertParams()); + + expect(skipped).toBe(true); + }); + + it("defaults tags to empty array when null", async () => { + pgClient.rowsToReturn = [fakePgRow({ tags: null })]; + + const [job] = await driver.jobInsert(fakeInsertParams()); + + expect(job.tags).toEqual([]); + }); + }); +}); diff --git a/js/driver/pg/src/driver.ts b/js/driver/pg/src/driver.ts new file mode 100644 index 000000000..30c924b0b --- /dev/null +++ b/js/driver/pg/src/driver.ts @@ -0,0 +1,137 @@ +import type { Client as PgClient, Pool, PoolClient, QueryResultRow } from "pg"; +import type { + AttemptError, + Driver, + DriverOptions, + JobInsertParams, + JobRow, + JobState, +} from "riverqueue"; +import { uniqueBitmaskToStates } from "riverqueue"; + +/** + * A River driver for node-postgres (`pg`). + * + * import { Pool } from "pg"; + * import { Client } from "riverqueue"; + * import { PgDriver } from "@riverqueue/driver-pg"; + * + * const pool = new Pool({ connectionString: "postgres://..." }); + * const client = new Client(new PgDriver(pool)); + * + * For transactions, pass a `PoolClient` as the `tx` option: + * + * const poolClient = await pool.connect(); + * await poolClient.query("BEGIN"); + * await client.insert(args, { tx: poolClient }); + * await poolClient.query("COMMIT"); + * poolClient.release(); + */ +export class PgDriver implements Driver { + private client: Pool | PoolClient | PgClient; + + constructor(client: Pool | PoolClient | PgClient) { + this.client = client; + } + + async jobInsert( + params: JobInsertParams, + options?: DriverOptions + ): Promise<[JobRow, boolean]> { + const results = await this.jobInsertMany([params], options); + return results[0] as [JobRow, boolean]; + } + + async jobInsertMany( + params: JobInsertParams[], + options?: DriverOptions + ): Promise<[JobRow, boolean][]> { + if (params.length === 0) return []; + + const COLUMNS_PER_ROW = 10; + const values: unknown[] = []; + const valueClauses: string[] = []; + + for (let i = 0; i < params.length; i++) { + const p = params[i] as JobInsertParams; + const offset = i * COLUMNS_PER_ROW; + valueClauses.push( + `($${offset + 1}::jsonb, $${offset + 2}, $${offset + 3}, $${offset + 4}, ` + + `$${offset + 5}, $${offset + 6}::timestamptz, $${offset + 7}, ` + + `$${offset + 8}::text[], $${offset + 9}::bytea, $${offset + 10}::bit(8))` + ); + values.push( + p.encodedArgs, + p.kind, + p.maxAttempts, + p.priority, + p.queue, + p.scheduledAt, + p.state, + p.tags, + p.uniqueKey ? Buffer.from(p.uniqueKey) : null, + p.uniqueStates + ); + } + + const schemaPrefix = options?.schemaPrefix ?? ""; + const sql = ` + INSERT INTO ${schemaPrefix}river_job ( + args, kind, max_attempts, priority, + queue, scheduled_at, state, + tags, unique_key, unique_states + ) + VALUES ${valueClauses.join(", ")} + ON CONFLICT (unique_key) + WHERE unique_key IS NOT NULL + AND unique_states IS NOT NULL + AND ${schemaPrefix}river_job_state_in_bitmask(unique_states, state) + DO UPDATE SET kind = EXCLUDED.kind + RETURNING *, (xmax != 0) AS unique_skipped_as_duplicate + `; + + const queryable = options?.tx ?? this.client; + const result = await queryable.query(sql, values); + return result.rows.map((row: QueryResultRow) => this.toInsertResult(row)); + } + + private toInsertResult(row: QueryResultRow): [JobRow, boolean] { + return [this.toJobRow(row), row.unique_skipped_as_duplicate as boolean]; + } + + private toJobRow(row: QueryResultRow): JobRow { + return { + id: Number(row.id), + args: row.args as Record, + attempt: row.attempt as number, + attemptedAt: (row.attempted_at as Date) ?? null, + attemptedBy: (row.attempted_by as string[]) ?? null, + createdAt: row.created_at as Date, + errors: row.errors + ? (row.errors as Record[]).map( + (e): AttemptError => ({ + at: new Date(e.at as string), + attempt: e.attempt as number, + error: e.error as string, + trace: e.trace as string, + }) + ) + : null, + finalizedAt: (row.finalized_at as Date) ?? null, + kind: row.kind as string, + maxAttempts: row.max_attempts as number, + metadata: row.metadata as Record, + priority: row.priority as number, + queue: row.queue as string, + scheduledAt: row.scheduled_at as Date, + state: row.state as JobState, + tags: (row.tags as string[]) ?? [], + uniqueKey: row.unique_key + ? new Uint8Array(row.unique_key as Buffer) + : null, + uniqueStates: row.unique_states + ? uniqueBitmaskToStates(parseInt(row.unique_states as string, 2)) + : null, + }; + } +} diff --git a/js/driver/pg/src/index.ts b/js/driver/pg/src/index.ts new file mode 100644 index 000000000..ae4f28141 --- /dev/null +++ b/js/driver/pg/src/index.ts @@ -0,0 +1 @@ +export { PgDriver } from "./driver.js"; diff --git a/js/driver/pg/tsconfig.json b/js/driver/pg/tsconfig.json new file mode 100644 index 000000000..5285d28af --- /dev/null +++ b/js/driver/pg/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/js/driver/prisma/package.json b/js/driver/prisma/package.json new file mode 100644 index 000000000..0a87b5c4e --- /dev/null +++ b/js/driver/prisma/package.json @@ -0,0 +1,45 @@ +{ + "name": "@riverqueue/driver-prisma", + "version": "0.1.0", + "description": "Prisma driver for the riverqueue TypeScript client.", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "clean": "rm -rf dist", + "prepublishOnly": "pnpm run clean && pnpm run build" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/riverqueue/riverqueue-js.git", + "directory": "driver/prisma" + }, + "authors": ["Brandur Leach", "Blake Gentry"], + "license": "LGPL-3.0-or-later", + "dependencies": { + "riverqueue": "workspace:*" + }, + "peerDependencies": { + "@prisma/client": ">=5.0.0" + }, + "devDependencies": { + "typescript": "^5.8.0" + }, + "keywords": [ + "river", + "job-queue", + "postgresql", + "prisma", + "driver" + ] +} diff --git a/js/driver/prisma/src/driver.integration.test.ts b/js/driver/prisma/src/driver.integration.test.ts new file mode 100644 index 000000000..28602f6e8 --- /dev/null +++ b/js/driver/prisma/src/driver.integration.test.ts @@ -0,0 +1,201 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { Pool, PoolClient } from "pg"; +import { Client, JobArgsObject } from "riverqueue"; +import type { JobArgs } from "riverqueue"; +import { PrismaDriver } from "./driver.js"; +import type { PrismaClientLike } from "./driver.js"; + +const TEST_DATABASE_URL = + process.env.TEST_DATABASE_URL || + "postgres://localhost:5432/river_test?sslmode=disable"; + +// Per-file random prefix so parallel test files don't interfere with each +// other's cleanup. +const filePrefix = `prisma_${Math.random().toString(36).slice(2, 8)}`; + +// Adapts a pg Pool/PoolClient to the PrismaClientLike interface so the Prisma +// driver's actual SQL and row mapping can be tested against a real database +// without requiring a full Prisma setup. +class PgPrismaAdapter implements PrismaClientLike { + constructor(private pool: Pool | PoolClient) {} + + async $queryRawUnsafe( + sql: string, + ...values: unknown[] + ): Promise { + const result = await this.pool.query(sql, values); + return result.rows as T; + } +} + +describe("PrismaDriver integration", () => { + let pool: Pool; + let client: Client; + + beforeAll(async () => { + pool = new Pool({ connectionString: TEST_DATABASE_URL }); + client = new Client(new PrismaDriver(new PgPrismaAdapter(pool))); + }); + + afterAll(async () => { + await pool.end(); + }); + + afterEach(async () => { + await pool.query("DELETE FROM river_job WHERE kind LIKE $1", [ + `${filePrefix}%`, + ]); + }); + + it("inserts a job and returns it", async () => { + const result = await client.insert( + new JobArgsObject(`${filePrefix}_basic`, { key: "value" }) + ); + + expect(result.job.id).toBeGreaterThan(0); + expect(result.job.kind).toBe(`${filePrefix}_basic`); + expect(result.job.args).toEqual({ key: "value" }); + expect(result.job.state).toBe("available"); + expect(result.job.queue).toBe("default"); + expect(result.job.priority).toBe(1); + expect(result.job.maxAttempts).toBe(25); + expect(result.job.attempt).toBe(0); + expect(result.job.tags).toEqual([]); + expect(result.job.createdAt).toBeInstanceOf(Date); + expect(result.job.scheduledAt).toBeInstanceOf(Date); + expect(result.uniqueSkippedAsDuplicated).toBe(false); + }); + + it("inserts with all options", async () => { + const future = new Date(Date.now() + 3_600_000); + + const result = await client.insert( + new JobArgsObject(`${filePrefix}_opts`, { n: 42 }), + { + maxAttempts: 5, + priority: 3, + queue: "high_priority", + scheduledAt: future, + tags: ["tag_one", "tag_two"], + } + ); + + expect(result.job.kind).toBe(`${filePrefix}_opts`); + expect(result.job.maxAttempts).toBe(5); + expect(result.job.priority).toBe(3); + expect(result.job.queue).toBe("high_priority"); + expect(result.job.state).toBe("scheduled"); + expect(result.job.tags).toEqual(["tag_one", "tag_two"]); + }); + + it("inserts many jobs", async () => { + const results = await client.insertMany([ + new JobArgsObject(`${filePrefix}_batch_a`, { i: 1 }), + new JobArgsObject(`${filePrefix}_batch_b`, { i: 2 }), + new JobArgsObject(`${filePrefix}_batch_c`, { i: 3 }), + ]); + + expect(results).toHaveLength(3); + + const ids = results.map((r) => r.job.id); + expect(new Set(ids).size).toBe(3); + + expect(results[0]!.job.kind).toBe(`${filePrefix}_batch_a`); + expect(results[1]!.job.kind).toBe(`${filePrefix}_batch_b`); + expect(results[2]!.job.kind).toBe(`${filePrefix}_batch_c`); + }); + + it("handles unique job insertion", async () => { + const uniqueOpts = { byArgs: true as const, byQueue: true as const }; + + const first = await client.insert( + new JobArgsObject(`${filePrefix}_unique`, { key: "same" }), + { uniqueOpts } + ); + expect(first.uniqueSkippedAsDuplicated).toBe(false); + + const second = await client.insert( + new JobArgsObject(`${filePrefix}_unique`, { key: "same" }), + { uniqueOpts } + ); + expect(second.uniqueSkippedAsDuplicated).toBe(true); + expect(second.job.id).toBe(first.job.id); + }); + + it("uses custom class args with toJSON", async () => { + const kind = `${filePrefix}_notify`; + + class NotifyArgs implements JobArgs { + kind = kind; + constructor(public channel: string) {} + toJSON() { + return { channel: this.channel }; + } + } + + const result = await client.insert(new NotifyArgs("general")); + + expect(result.job.kind).toBe(kind); + expect(result.job.args).toEqual({ channel: "general" }); + }); + + it("verifies job exists in database after insert", async () => { + const result = await client.insert( + new JobArgsObject(`${filePrefix}_verify`, { data: "check" }) + ); + + const dbResult = await pool.query("SELECT * FROM river_job WHERE id = $1", [ + result.job.id, + ]); + expect(dbResult.rowCount).toBe(1); + expect(dbResult.rows[0].kind).toBe(`${filePrefix}_verify`); + }); + + it("inserts within a transaction via tx option", async () => { + const poolClient = await pool.connect(); + try { + await poolClient.query("BEGIN"); + const txAdapter = new PgPrismaAdapter(poolClient); + + await client.insert(new JobArgsObject(`${filePrefix}_tx_1`, {}), { + tx: txAdapter, + }); + await client.insert(new JobArgsObject(`${filePrefix}_tx_2`, {}), { + tx: txAdapter, + }); + + await poolClient.query("COMMIT"); + } finally { + poolClient.release(); + } + + const dbResult = await pool.query( + `SELECT kind FROM river_job WHERE kind LIKE '${filePrefix}_tx_%' ORDER BY kind` + ); + expect(dbResult.rows.map((r) => r.kind)).toEqual([ + `${filePrefix}_tx_1`, + `${filePrefix}_tx_2`, + ]); + }); + + it("rolls back transaction via tx option", async () => { + const poolClient = await pool.connect(); + try { + await poolClient.query("BEGIN"); + const txAdapter = new PgPrismaAdapter(poolClient); + + await client.insert(new JobArgsObject(`${filePrefix}_rollback`, {}), { + tx: txAdapter, + }); + + await poolClient.query("ROLLBACK"); + } finally { + poolClient.release(); + } + + const dbResult = await pool.query( + `SELECT * FROM river_job WHERE kind = '${filePrefix}_rollback'` + ); + expect(dbResult.rowCount).toBe(0); + }); +}); diff --git a/js/driver/prisma/src/driver.test.ts b/js/driver/prisma/src/driver.test.ts new file mode 100644 index 000000000..7ae9adc49 --- /dev/null +++ b/js/driver/prisma/src/driver.test.ts @@ -0,0 +1,303 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { PrismaDriver } from "./driver.js"; +import type { PrismaClientLike } from "./driver.js"; +import type { JobInsertParams } from "riverqueue"; + +// Simulates what Prisma returns for a river_job row. +// Key differences from pg: BigInt for id, Number for smallint. +function fakePrismaRow(overrides: Record = {}) { + return { + id: BigInt(42), // Prisma returns BigInt for bigint columns + args: { strings: ["a", "b"] }, + attempt: 0, + attempted_at: null, + attempted_by: null, + created_at: new Date("2024-06-01T00:00:00Z"), + errors: null, + finalized_at: null, + kind: "sort", + max_attempts: 25, + metadata: {}, + priority: 1, + queue: "default", + scheduled_at: new Date("2024-06-01T00:00:00Z"), + state: "available", + tags: ["tag1", "tag2"], + unique_key: null, + unique_states: null, + unique_skipped_as_duplicate: false, + ...overrides, + }; +} + +function fakeInsertParams( + overrides: Partial = {} +): JobInsertParams { + return { + encodedArgs: '{"strings":["a","b"]}', + kind: "sort", + maxAttempts: 25, + priority: 1, + queue: "default", + scheduledAt: new Date("2024-06-01T00:00:00Z"), + state: "available", + tags: [], + uniqueKey: null, + uniqueStates: null, + ...overrides, + }; +} + +function mockPrismaClient() { + const rowsToReturn: Record[] = []; + + const mock = { + rowsToReturn, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + $queryRawUnsafe: vi.fn(async (_sql: string, ..._values: unknown[]) => { + return mock.rowsToReturn; + }), + }; + return mock as typeof mock & PrismaClientLike; +} + +describe("PrismaDriver", () => { + let prisma: ReturnType; + let driver: PrismaDriver; + + beforeEach(() => { + prisma = mockPrismaClient(); + driver = new PrismaDriver(prisma); + }); + + describe("jobInsertMany", () => { + it("returns empty array for empty params", async () => { + const results = await driver.jobInsertMany([]); + expect(results).toEqual([]); + expect(prisma.$queryRawUnsafe).not.toHaveBeenCalled(); + }); + + it("constructs correct SQL and parameters", async () => { + prisma.rowsToReturn = [fakePrismaRow()]; + + await driver.jobInsertMany([fakeInsertParams({ tags: ["urgent"] })]); + + expect(prisma.$queryRawUnsafe).toHaveBeenCalledOnce(); + const sql = (prisma.$queryRawUnsafe as ReturnType).mock + .calls[0]![0] as string; + const values = ( + prisma.$queryRawUnsafe as ReturnType + ).mock.calls[0]!.slice(1) as unknown[]; + + expect(sql).toContain("INSERT INTO river_job"); + expect(sql).toContain("ON CONFLICT (unique_key)"); + expect(sql).toContain("RETURNING"); + + expect(values).toHaveLength(10); + expect(values[0]).toBe('{"strings":["a","b"]}'); + expect(values[1]).toBe("sort"); + expect(values[7]).toEqual(["urgent"]); + }); + + it("constructs correct parameters for batch insert", async () => { + prisma.rowsToReturn = [ + fakePrismaRow(), + fakePrismaRow({ id: BigInt(43) }), + ]; + + await driver.jobInsertMany([ + fakeInsertParams({ kind: "job_a" }), + fakeInsertParams({ kind: "job_b" }), + ]); + + const values = ( + prisma.$queryRawUnsafe as ReturnType + ).mock.calls[0]!.slice(1) as unknown[]; + + expect(values).toHaveLength(20); + expect(values[1]).toBe("job_a"); + expect(values[11]).toBe("job_b"); + }); + + it("converts unique key to Buffer", async () => { + const uniqueKey = new Uint8Array([1, 2, 3, 4]); + prisma.rowsToReturn = [fakePrismaRow()]; + + await driver.jobInsertMany([ + fakeInsertParams({ uniqueKey, uniqueStates: "11110101" }), + ]); + + const values = ( + prisma.$queryRawUnsafe as ReturnType + ).mock.calls[0]!.slice(1) as unknown[]; + + expect(Buffer.isBuffer(values[8])).toBe(true); + expect(values[9]).toBe("11110101"); + }); + + it("uses schema prefix in SQL when provided", async () => { + prisma.rowsToReturn = [fakePrismaRow()]; + + await driver.jobInsertMany([fakeInsertParams()], { + schemaPrefix: '"custom".', + }); + + const sql = (prisma.$queryRawUnsafe as ReturnType).mock + .calls[0]![0] as string; + expect(sql).toContain('INSERT INTO "custom".river_job'); + expect(sql).toContain('"custom".river_job_state_in_bitmask'); + }); + + it("schema-qualifies the river_job_state cast", async () => { + prisma.rowsToReturn = [fakePrismaRow()]; + + await driver.jobInsertMany([fakeInsertParams()], { + schemaPrefix: '"custom".', + }); + + const sql = (prisma.$queryRawUnsafe as ReturnType).mock + .calls[0]![0] as string; + + // The state parameter cast must be schema-qualified. Without it, + // Prisma inserts fail with 'type "river_job_state" does not exist' + // when the schema is not on search_path. + expect(sql).toContain('::"custom".river_job_state,'); + expect(sql).not.toMatch(/::river_job_state[^_]/); + }); + }); + + describe("jobInsert", () => { + it("delegates to jobInsertMany", async () => { + prisma.rowsToReturn = [fakePrismaRow()]; + + const [job, skipped] = await driver.jobInsert(fakeInsertParams()); + + expect(prisma.$queryRawUnsafe).toHaveBeenCalledOnce(); + expect(job.kind).toBe("sort"); + expect(skipped).toBe(false); + }); + }); + + describe("row mapping", () => { + it("converts BigInt id to number", async () => { + prisma.rowsToReturn = [fakePrismaRow({ id: BigInt(999) })]; + + const [job] = await driver.jobInsert(fakeInsertParams()); + + expect(job.id).toBe(999); + expect(typeof job.id).toBe("number"); + }); + + it("maps basic columns correctly", async () => { + prisma.rowsToReturn = [fakePrismaRow()]; + + const [job] = await driver.jobInsert(fakeInsertParams()); + + expect(job.id).toBe(42); + expect(job.args).toEqual({ strings: ["a", "b"] }); + expect(job.attempt).toBe(0); + expect(job.kind).toBe("sort"); + expect(job.maxAttempts).toBe(25); + expect(job.priority).toBe(1); + expect(job.queue).toBe("default"); + expect(job.state).toBe("available"); + expect(job.tags).toEqual(["tag1", "tag2"]); + }); + + it("maps null columns", async () => { + prisma.rowsToReturn = [fakePrismaRow()]; + + const [job] = await driver.jobInsert(fakeInsertParams()); + + expect(job.attemptedAt).toBeNull(); + expect(job.attemptedBy).toBeNull(); + expect(job.errors).toBeNull(); + expect(job.finalizedAt).toBeNull(); + expect(job.uniqueKey).toBeNull(); + expect(job.uniqueStates).toBeNull(); + }); + + it("maps non-null optional columns", async () => { + prisma.rowsToReturn = [ + fakePrismaRow({ + attempted_at: new Date("2024-06-01T01:00:00Z"), + attempted_by: ["worker-1"], + finalized_at: new Date("2024-06-01T02:00:00Z"), + }), + ]; + + const [job] = await driver.jobInsert(fakeInsertParams()); + + expect(job.attemptedAt).toEqual(new Date("2024-06-01T01:00:00Z")); + expect(job.attemptedBy).toEqual(["worker-1"]); + expect(job.finalizedAt).toEqual(new Date("2024-06-01T02:00:00Z")); + }); + + it("maps errors from jsonb array", async () => { + prisma.rowsToReturn = [ + fakePrismaRow({ + errors: [ + { + at: "2024-06-01T01:00:00Z", + attempt: 1, + error: "something broke", + trace: "stack trace here", + }, + ], + }), + ]; + + const [job] = await driver.jobInsert(fakeInsertParams()); + + expect(job.errors).toHaveLength(1); + expect(job.errors![0]!.at).toEqual(new Date("2024-06-01T01:00:00Z")); + expect(job.errors![0]!.attempt).toBe(1); + expect(job.errors![0]!.error).toBe("something broke"); + expect(job.errors![0]!.trace).toBe("stack trace here"); + }); + + it("maps unique key from Buffer", async () => { + const buf = Buffer.from([0xca, 0xfe]); + prisma.rowsToReturn = [fakePrismaRow({ unique_key: buf })]; + + const [job] = await driver.jobInsert(fakeInsertParams()); + + expect(job.uniqueKey).toBeInstanceOf(Uint8Array); + expect(job.uniqueKey).toEqual(new Uint8Array([0xca, 0xfe])); + }); + + it("maps unique states from bit string", async () => { + prisma.rowsToReturn = [fakePrismaRow({ unique_states: "11110101" })]; + + const [job] = await driver.jobInsert(fakeInsertParams()); + + // 11110101 = available, completed, pending, retryable, running, scheduled + expect(job.uniqueStates).toEqual([ + "available", + "completed", + "pending", + "retryable", + "running", + "scheduled", + ]); + }); + + it("reports unique_skipped_as_duplicate", async () => { + prisma.rowsToReturn = [ + fakePrismaRow({ unique_skipped_as_duplicate: true }), + ]; + + const [, skipped] = await driver.jobInsert(fakeInsertParams()); + + expect(skipped).toBe(true); + }); + + it("defaults tags to empty array when null", async () => { + prisma.rowsToReturn = [fakePrismaRow({ tags: null })]; + + const [job] = await driver.jobInsert(fakeInsertParams()); + + expect(job.tags).toEqual([]); + }); + }); +}); diff --git a/js/driver/prisma/src/driver.ts b/js/driver/prisma/src/driver.ts new file mode 100644 index 000000000..6fec1864b --- /dev/null +++ b/js/driver/prisma/src/driver.ts @@ -0,0 +1,146 @@ +import type { + AttemptError, + Driver, + DriverOptions, + JobInsertParams, + JobRow, + JobState, +} from "riverqueue"; +import { uniqueBitmaskToStates } from "riverqueue"; + +/** + * Minimal interface matching PrismaClient's raw query method. Using an + * interface avoids a direct import dependency on @prisma/client. + */ +export interface PrismaClientLike { + $queryRawUnsafe(query: string, ...values: unknown[]): Promise; +} + +/** + * A River driver for Prisma. + * + * import { PrismaClient } from "@prisma/client"; + * import { Client } from "riverqueue"; + * import { PrismaDriver } from "@riverqueue/driver-prisma"; + * + * const prisma = new PrismaClient(); + * const client = new Client(new PrismaDriver(prisma)); + * + * For transactions, pass the transaction client as the `tx` option: + * + * await prisma.$transaction(async (tx) => { + * await client.insert(args, { tx }); + * }); + */ +export class PrismaDriver implements Driver { + private prisma: PrismaClientLike; + + constructor(prisma: PrismaClientLike) { + this.prisma = prisma; + } + + async jobInsert( + params: JobInsertParams, + options?: DriverOptions + ): Promise<[JobRow, boolean]> { + const results = await this.jobInsertMany([params], options); + return results[0] as [JobRow, boolean]; + } + + async jobInsertMany( + params: JobInsertParams[], + options?: DriverOptions + ): Promise<[JobRow, boolean][]> { + if (params.length === 0) return []; + + const schemaPrefix = options?.schemaPrefix ?? ""; + const COLUMNS_PER_ROW = 10; + const values: unknown[] = []; + const valueClauses: string[] = []; + + for (let i = 0; i < params.length; i++) { + const p = params[i] as JobInsertParams; + const offset = i * COLUMNS_PER_ROW; + valueClauses.push( + `($${offset + 1}::jsonb, $${offset + 2}, $${offset + 3}, $${offset + 4}, ` + + `$${offset + 5}, $${offset + 6}::timestamptz, $${offset + 7}::${schemaPrefix}river_job_state, ` + + `$${offset + 8}::text[], $${offset + 9}::bytea, $${offset + 10}::bit(8))` + ); + values.push( + p.encodedArgs, + p.kind, + p.maxAttempts, + p.priority, + p.queue, + p.scheduledAt, + p.state, + p.tags, + p.uniqueKey ? Buffer.from(p.uniqueKey) : null, + p.uniqueStates + ); + } + + const sql = ` + INSERT INTO ${schemaPrefix}river_job ( + args, kind, max_attempts, priority, + queue, scheduled_at, state, + tags, unique_key, unique_states + ) + VALUES ${valueClauses.join(", ")} + ON CONFLICT (unique_key) + WHERE unique_key IS NOT NULL + AND unique_states IS NOT NULL + AND ${schemaPrefix}river_job_state_in_bitmask(unique_states, state) + DO UPDATE SET kind = EXCLUDED.kind + RETURNING *, (xmax != 0) AS unique_skipped_as_duplicate + `; + + const queryable = options?.tx ?? this.prisma; + const rows = await queryable.$queryRawUnsafe[]>( + sql, + ...values + ); + return rows.map((row) => this.toInsertResult(row)); + } + + private toInsertResult(row: Record): [JobRow, boolean] { + return [this.toJobRow(row), row.unique_skipped_as_duplicate as boolean]; + } + + private toJobRow(row: Record): JobRow { + return { + // Prisma returns BigInt for bigint columns. + id: Number(row.id), + args: row.args as Record, + attempt: Number(row.attempt), + attemptedAt: (row.attempted_at as Date) ?? null, + attemptedBy: (row.attempted_by as string[]) ?? null, + createdAt: row.created_at as Date, + errors: row.errors + ? (row.errors as Record[]).map( + (e): AttemptError => ({ + at: new Date(e.at as string), + attempt: e.attempt as number, + error: e.error as string, + trace: e.trace as string, + }) + ) + : null, + finalizedAt: (row.finalized_at as Date) ?? null, + kind: row.kind as string, + maxAttempts: Number(row.max_attempts), + metadata: row.metadata as Record, + priority: Number(row.priority), + queue: row.queue as string, + scheduledAt: row.scheduled_at as Date, + state: row.state as JobState, + tags: (row.tags as string[]) ?? [], + uniqueKey: row.unique_key + ? new Uint8Array(row.unique_key as Buffer) + : null, + uniqueStates: row.unique_states + ? uniqueBitmaskToStates(parseInt(row.unique_states as string, 2)) + : null, + }; + } +} diff --git a/js/driver/prisma/src/index.ts b/js/driver/prisma/src/index.ts new file mode 100644 index 000000000..afc60c98d --- /dev/null +++ b/js/driver/prisma/src/index.ts @@ -0,0 +1,2 @@ +export { PrismaDriver } from "./driver.js"; +export type { PrismaClientLike } from "./driver.js"; diff --git a/js/driver/prisma/tsconfig.json b/js/driver/prisma/tsconfig.json new file mode 100644 index 000000000..5285d28af --- /dev/null +++ b/js/driver/prisma/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/js/eslint.config.js b/js/eslint.config.js new file mode 100644 index 000000000..d90f7277f --- /dev/null +++ b/js/eslint.config.js @@ -0,0 +1,18 @@ +import eslint from "@eslint/js"; +import tseslint from "typescript-eslint"; +import eslintConfigPrettier from "eslint-config-prettier"; + +export default tseslint.config( + eslint.configs.recommended, + ...tseslint.configs.strict, + eslintConfigPrettier, + { + ignores: ["**/dist/"], + }, + { + files: ["**/*.test.ts"], + rules: { + "@typescript-eslint/no-non-null-assertion": "off", + }, + } +); diff --git a/js/examples/node-postgres/README.md b/js/examples/node-postgres/README.md new file mode 100644 index 000000000..f4b1a1c03 --- /dev/null +++ b/js/examples/node-postgres/README.md @@ -0,0 +1,35 @@ +# River TypeScript Example: node-postgres + +A minimal example demonstrating how to use the [River](https://github.com/riverqueue/riverqueue-js) TypeScript client with `node-postgres` (`pg`) to insert background jobs into PostgreSQL. + +The example defines two job types (`SortArgs` and `SendEmailArgs`) and shows single job insertion, insertion with scheduling options, and batch insertion. + +## Prerequisites + +- Node.js >= 18 +- pnpm +- PostgreSQL with [River's schema](https://riverqueue.com/docs) migrated + +## Setup + +From the repository root, install dependencies (this is a workspace project): + +```sh +pnpm install +``` + +Build the River packages and the example: + +```sh +pnpm run build:all +cd examples/node-postgres +pnpm run build +``` + +## Running + +```sh +DATABASE_URL=postgres://localhost:5432/river_dev pnpm start +``` + +If `DATABASE_URL` is not set, it defaults to `postgres://localhost:5432/river_dev`. diff --git a/js/examples/node-postgres/package.json b/js/examples/node-postgres/package.json new file mode 100644 index 000000000..9a56e453f --- /dev/null +++ b/js/examples/node-postgres/package.json @@ -0,0 +1,19 @@ +{ + "name": "riverqueue-example-node-postgres", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc", + "start": "node dist/index.js" + }, + "dependencies": { + "riverqueue": "workspace:*", + "@riverqueue/driver-pg": "workspace:*", + "pg": "^8.13.0" + }, + "devDependencies": { + "@types/pg": "^8.11.0", + "typescript": "^5.8.0" + } +} diff --git a/js/examples/node-postgres/src/index.ts b/js/examples/node-postgres/src/index.ts new file mode 100644 index 000000000..d57ae939b --- /dev/null +++ b/js/examples/node-postgres/src/index.ts @@ -0,0 +1,85 @@ +import { Pool } from "pg"; +import { Client, InsertManyParams } from "riverqueue"; +import type { JobArgs, InsertOpts } from "riverqueue"; +import { PgDriver } from "@riverqueue/driver-pg"; + +// Define a job that sorts strings. `kind` uniquely identifies the job type and +// must match the worker name on the Go side. +class SortArgs implements JobArgs { + kind = "sort"; + + strings: string[]; + + constructor(strings: string[]) { + this.strings = strings; + } + + toJSON() { + return { strings: this.strings }; + } +} + +// A job with default insert options baked in. +class SendEmailArgs implements JobArgs { + kind = "send_email"; + + insertOpts: InsertOpts = { + maxAttempts: 5, + queue: "email", + priority: 2, + }; + + to: string; + subject: string; + body: string; + + constructor(to: string, subject: string, body: string) { + this.to = to; + this.subject = subject; + this.body = body; + } + + toJSON() { + return { to: this.to, subject: this.subject, body: this.body }; + } +} + +async function main() { + const pool = new Pool({ + connectionString: + process.env.DATABASE_URL ?? "postgres://localhost:5432/river_dev", + }); + + const client = new Client(new PgDriver(pool)); + + // Insert a single job. + const sortResult = await client.insert( + new SortArgs(["whale", "tiger", "bear"]) + ); + console.log(`Inserted sort job with ID: ${sortResult.job.id}`); + + // Insert with options, scheduling for 1 hour in the future. + const emailResult = await client.insert( + new SendEmailArgs("user@example.com", "Hello", "Welcome aboard!"), + { scheduledAt: new Date(Date.now() + 60 * 60 * 1000) } + ); + console.log( + `Inserted email job with ID: ${emailResult.job.id}, scheduled for: ${emailResult.job.scheduledAt}` + ); + + // Insert many jobs at once. + const batchResults = await client.insertMany([ + new SortArgs(["alpha", "gamma", "beta"]), + new InsertManyParams(new SortArgs(["one", "two", "three"]), { + priority: 3, + }), + ]); + console.log(`Batch inserted ${batchResults.length} jobs`); + + await pool.end(); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/js/examples/node-postgres/tsconfig.json b/js/examples/node-postgres/tsconfig.json new file mode 100644 index 000000000..657f341e8 --- /dev/null +++ b/js/examples/node-postgres/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"] +} diff --git a/js/examples/prisma/README.md b/js/examples/prisma/README.md new file mode 100644 index 000000000..44f1976f7 --- /dev/null +++ b/js/examples/prisma/README.md @@ -0,0 +1,42 @@ +# River TypeScript Example: Prisma + +A minimal example demonstrating how to use the [River](https://github.com/riverqueue/riverqueue-js) TypeScript client with [Prisma](https://www.prisma.io/) to insert background jobs into PostgreSQL. + +The example defines two job types (`SortArgs` and `SendEmailArgs`) and shows single job insertion, insertion with scheduling options, and batch insertion. + +## Prerequisites + +- Node.js >= 18 +- pnpm +- PostgreSQL with [River's schema](https://riverqueue.com/docs) migrated + +## Setup + +From the repository root, install dependencies (this is a workspace project): + +```sh +pnpm install +``` + +Generate the Prisma client: + +```sh +cd examples/prisma +npx prisma generate +``` + +Build the River packages and the example: + +```sh +pnpm run build:all +cd examples/prisma +pnpm run build +``` + +## Running + +```sh +DATABASE_URL=postgres://localhost:5432/river_dev pnpm start +``` + +If `DATABASE_URL` is not set, it defaults to `postgres://localhost:5432/river_dev`. diff --git a/js/examples/prisma/package.json b/js/examples/prisma/package.json new file mode 100644 index 000000000..d3fe8daf0 --- /dev/null +++ b/js/examples/prisma/package.json @@ -0,0 +1,19 @@ +{ + "name": "riverqueue-example-prisma", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc", + "start": "node dist/index.js" + }, + "dependencies": { + "riverqueue": "workspace:*", + "@riverqueue/driver-prisma": "workspace:*", + "@prisma/client": "^6.0.0" + }, + "devDependencies": { + "prisma": "^6.0.0", + "typescript": "^5.8.0" + } +} diff --git a/js/examples/prisma/prisma/schema.prisma b/js/examples/prisma/prisma/schema.prisma new file mode 100644 index 000000000..cc10721b5 --- /dev/null +++ b/js/examples/prisma/prisma/schema.prisma @@ -0,0 +1,8 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} diff --git a/js/examples/prisma/src/index.ts b/js/examples/prisma/src/index.ts new file mode 100644 index 000000000..6b59c8f1e --- /dev/null +++ b/js/examples/prisma/src/index.ts @@ -0,0 +1,85 @@ +import { PrismaClient } from "@prisma/client"; +import { Client, InsertManyParams } from "riverqueue"; +import type { JobArgs, InsertOpts } from "riverqueue"; +import { PrismaDriver } from "@riverqueue/driver-prisma"; + +// Define a job that sorts strings. `kind` uniquely identifies the job type and +// must match the worker name on the Go side. +class SortArgs implements JobArgs { + kind = "sort"; + + strings: string[]; + + constructor(strings: string[]) { + this.strings = strings; + } + + toJSON() { + return { strings: this.strings }; + } +} + +// A job with default insert options baked in. +class SendEmailArgs implements JobArgs { + kind = "send_email"; + + insertOpts: InsertOpts = { + maxAttempts: 5, + queue: "email", + priority: 2, + }; + + to: string; + subject: string; + body: string; + + constructor(to: string, subject: string, body: string) { + this.to = to; + this.subject = subject; + this.body = body; + } + + toJSON() { + return { to: this.to, subject: this.subject, body: this.body }; + } +} + +async function main() { + const prisma = new PrismaClient({ + datasourceUrl: + process.env.DATABASE_URL ?? "postgres://localhost:5432/river_dev", + }); + + const client = new Client(new PrismaDriver(prisma)); + + // Insert a single job. + const sortResult = await client.insert( + new SortArgs(["whale", "tiger", "bear"]) + ); + console.log(`Inserted sort job with ID: ${sortResult.job.id}`); + + // Insert with options, scheduling for 1 hour in the future. + const emailResult = await client.insert( + new SendEmailArgs("user@example.com", "Hello", "Welcome aboard!"), + { scheduledAt: new Date(Date.now() + 60 * 60 * 1000) } + ); + console.log( + `Inserted email job with ID: ${emailResult.job.id}, scheduled for: ${emailResult.job.scheduledAt}` + ); + + // Insert many jobs at once. + const batchResults = await client.insertMany([ + new SortArgs(["alpha", "gamma", "beta"]), + new InsertManyParams(new SortArgs(["one", "two", "three"]), { + priority: 3, + }), + ]); + console.log(`Batch inserted ${batchResults.length} jobs`); + + await prisma.$disconnect(); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/js/examples/prisma/tsconfig.json b/js/examples/prisma/tsconfig.json new file mode 100644 index 000000000..657f341e8 --- /dev/null +++ b/js/examples/prisma/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"] +} diff --git a/js/index.js b/js/index.js deleted file mode 100644 index e69de29bb..000000000 diff --git a/js/package-lock.json b/js/package-lock.json deleted file mode 100644 index b3eb3ca43..000000000 --- a/js/package-lock.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "riverqueue", - "version": "0.0.2", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "riverqueue", - "version": "0.0.2", - "license": "LGPL3+" - } - } -} diff --git a/js/package.json b/js/package.json index 643076354..c9421caa4 100644 --- a/js/package.json +++ b/js/package.json @@ -1,19 +1,63 @@ { "name": "riverqueue", - "version": "0.0.2", - "description": "A fast job queue for Go.", - "main": "index.js", + "version": "0.1.0", + "description": "TypeScript client for River, a fast and reliable background job framework for PostgreSQL.", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "build": "tsc", + "build:all": "pnpm run build && pnpm --filter='./driver/*' run build", + "clean": "rm -rf dist", + "clean:all": "pnpm run clean && pnpm --filter='./driver/*' run clean", + "fmt": "prettier --write 'src/**/*.ts' 'driver/**/src/**/*.ts'", + "fmt:check": "prettier --check 'src/**/*.ts' 'driver/**/src/**/*.ts'", + "lint": "eslint 'src/**/*.ts' 'driver/**/src/**/*.ts'", + "lint:fix": "eslint --fix 'src/**/*.ts' 'driver/**/src/**/*.ts'", + "prepublishOnly": "pnpm run clean && pnpm run build", + "test": "vitest run --passWithNoTests", + "test:integration": "vitest run --passWithNoTests --config vitest.integration.config.ts" }, "repository": { "type": "git", - "url": "git+https://github.com/riverqueue/river-js.git" + "url": "git+https://github.com/riverqueue/riverqueue-js.git" }, - "author": "Brandur Leach", - "license": "LGPL3+", + "authors": [ + "Brandur Leach", + "Blake Gentry" + ], + "license": "LGPL-3.0-or-later", "bugs": { - "url": "https://github.com/riverqueue/river-js/issues" + "url": "https://github.com/riverqueue/riverqueue-js/issues" + }, + "homepage": "https://github.com/riverqueue/riverqueue-js#readme", + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^25.6.0", + "@types/pg": "^8.20.0", + "eslint": "^10.3.0", + "eslint-config-prettier": "^10.1.8", + "pg": "^8.20.0", + "prettier": "^3.8.3", + "typescript": "^5.8.0", + "typescript-eslint": "^8.59.2", + "vitest": "^4.1.5" }, - "homepage": "https://github.com/riverqueue/river-js#readme" + "packageManager": "pnpm@10.22.0", + "keywords": [ + "river", + "job-queue", + "background-jobs", + "postgresql", + "queue" + ] } diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml new file mode 100644 index 000000000..4738c9dd5 --- /dev/null +++ b/js/pnpm-lock.yaml @@ -0,0 +1,2014 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.3.0(jiti@2.7.0)) + '@types/node': + specifier: ^25.6.0 + version: 25.6.0 + '@types/pg': + specifier: ^8.20.0 + version: 8.20.0 + eslint: + specifier: ^10.3.0 + version: 10.3.0(jiti@2.7.0) + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@10.3.0(jiti@2.7.0)) + pg: + specifier: ^8.20.0 + version: 8.20.0 + prettier: + specifier: ^3.8.3 + version: 3.8.3 + typescript: + specifier: ^5.8.0 + version: 5.9.3 + typescript-eslint: + specifier: ^8.59.2 + version: 8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@5.9.3) + vitest: + specifier: ^4.1.5 + version: 4.1.5(@types/node@25.6.0)(vite@8.0.10(@types/node@25.6.0)(jiti@2.7.0)) + + driver/pg: + dependencies: + riverqueue: + specifier: workspace:* + version: link:../.. + devDependencies: + '@types/pg': + specifier: ^8.11.0 + version: 8.20.0 + pg: + specifier: ^8.13.0 + version: 8.20.0 + typescript: + specifier: ^5.8.0 + version: 5.9.3 + + driver/prisma: + dependencies: + '@prisma/client': + specifier: '>=5.0.0' + version: 7.8.0(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3) + riverqueue: + specifier: workspace:* + version: link:../.. + devDependencies: + typescript: + specifier: ^5.8.0 + version: 5.9.3 + + examples/node-postgres: + dependencies: + '@riverqueue/driver-pg': + specifier: workspace:* + version: link:../../driver/pg + pg: + specifier: ^8.13.0 + version: 8.20.0 + riverqueue: + specifier: workspace:* + version: link:../.. + devDependencies: + '@types/pg': + specifier: ^8.11.0 + version: 8.20.0 + typescript: + specifier: ^5.8.0 + version: 5.9.3 + + examples/prisma: + dependencies: + '@prisma/client': + specifier: ^6.0.0 + version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3) + '@riverqueue/driver-prisma': + specifier: workspace:* + version: link:../../driver/prisma + riverqueue: + specifier: workspace:* + version: link:../.. + devDependencies: + prisma: + specifier: ^6.0.0 + version: 6.19.3(typescript@5.9.3) + typescript: + specifier: ^5.8.0 + version: 5.9.3 + +packages: + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.5.5': + resolution: {integrity: sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.1': + resolution: {integrity: sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.127.0': + resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} + + '@prisma/client-runtime-utils@7.8.0': + resolution: {integrity: sha512-5NQZztQ0oY/ADFkmd9gPuweH5A1/CCY8YQPorLLO0Mu6a87mY5gsnDkzmFmIHs9NFaLnZojzgddFVN4RpKYrdw==} + + '@prisma/client@6.19.3': + resolution: {integrity: sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==} + engines: {node: '>=18.18'} + peerDependencies: + prisma: '*' + typescript: '>=5.1.0' + peerDependenciesMeta: + prisma: + optional: true + typescript: + optional: true + + '@prisma/client@7.8.0': + resolution: {integrity: sha512-HFp3Dawv/3sU3JtlPha90IB+48lS7zHiH4LKZPjmcE8YH5P9DOXGPvo8dqOtO7MqLDd1p2hOWMcFlRT1DMblHw==} + engines: {node: ^20.19 || ^22.12 || >=24.0} + peerDependencies: + prisma: '*' + typescript: '>=5.4.0' + peerDependenciesMeta: + prisma: + optional: true + typescript: + optional: true + + '@prisma/config@6.19.3': + resolution: {integrity: sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==} + + '@prisma/debug@6.19.3': + resolution: {integrity: sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==} + + '@prisma/engines-version@7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7': + resolution: {integrity: sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==} + + '@prisma/engines@6.19.3': + resolution: {integrity: sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==} + + '@prisma/fetch-engine@6.19.3': + resolution: {integrity: sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==} + + '@prisma/get-platform@6.19.3': + resolution: {integrity: sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==} + + '@rolldown/binding-android-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.17': + resolution: {integrity: sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.17': + resolution: {integrity: sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': + resolution: {integrity: sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': + resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': + resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': + resolution: {integrity: sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': + resolution: {integrity: sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': + resolution: {integrity: sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-rc.17': + resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@25.6.0': + resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} + + '@types/pg@8.20.0': + resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} + + '@typescript-eslint/eslint-plugin@8.59.2': + resolution: {integrity: sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.59.2 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.59.2': + resolution: {integrity: sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.59.2': + resolution: {integrity: sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.59.2': + resolution: {integrity: sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.59.2': + resolution: {integrity: sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.59.2': + resolution: {integrity: sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.59.2': + resolution: {integrity: sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.59.2': + resolution: {integrity: sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.59.2': + resolution: {integrity: sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.59.2': + resolution: {integrity: sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitest/expect@4.1.5': + resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==} + + '@vitest/mocker@4.1.5': + resolution: {integrity: sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.5': + resolution: {integrity: sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==} + + '@vitest/runner@4.1.5': + resolution: {integrity: sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==} + + '@vitest/snapshot@4.1.5': + resolution: {integrity: sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==} + + '@vitest/spy@4.1.5': + resolution: {integrity: sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==} + + '@vitest/utils@4.1.5': + resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + + c12@3.1.0: + resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==} + peerDependencies: + magicast: ^0.3.5 + peerDependenciesMeta: + magicast: + optional: true + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + + citty@0.2.2: + resolution: {integrity: sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge-ts@7.1.5: + resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} + engines: {node: '>=16.0.0'} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + effect@3.21.0: + resolution: {integrity: sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==} + + empathic@2.0.0: + resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} + engines: {node: '>=14'} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.3.0: + resolution: {integrity: sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + + fast-check@3.23.2: + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} + engines: {node: '>=8.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + giget@2.0.0: + resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} + hasBin: true + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + + nypm@0.6.6: + resolution: {integrity: sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q==} + engines: {node: '>=18'} + hasBin: true + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + + pg-cloudflare@1.3.0: + resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} + + pg-connection-string@2.12.0: + resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.13.0: + resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.13.0: + resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.20.0: + resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pkg-types@2.3.1: + resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + + postcss@8.5.14: + resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} + engines: {node: ^10 || ^12 || >=14} + + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.8.3: + resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} + engines: {node: '>=14'} + hasBin: true + + prisma@6.19.3: + resolution: {integrity: sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==} + engines: {node: '>=18.18'} + hasBin: true + peerDependencies: + typescript: '>=5.1.0' + peerDependenciesMeta: + typescript: + optional: true + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + + rc9@2.1.2: + resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + rolldown@1.0.0-rc.17: + resolution: {integrity: sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.1.2: + resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} + engines: {node: '>=18'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.59.2: + resolution: {integrity: sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.19.2: + resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + vite@8.0.10: + resolution: {integrity: sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.5: + resolution: {integrity: sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.5 + '@vitest/browser-preview': 4.1.5 + '@vitest/browser-webdriverio': 4.1.5 + '@vitest/coverage-istanbul': 4.1.5 + '@vitest/coverage-v8': 4.1.5 + '@vitest/ui': 4.1.5 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + +snapshots: + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@10.3.0(jiti@2.7.0))': + dependencies: + eslint: 10.3.0(jiti@2.7.0) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.5.5': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.3.0(jiti@2.7.0))': + optionalDependencies: + eslint: 10.3.0(jiti@2.7.0) + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.1': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@oxc-project/types@0.127.0': {} + + '@prisma/client-runtime-utils@7.8.0': {} + + '@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)': + optionalDependencies: + prisma: 6.19.3(typescript@5.9.3) + typescript: 5.9.3 + + '@prisma/client@7.8.0(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)': + dependencies: + '@prisma/client-runtime-utils': 7.8.0 + optionalDependencies: + prisma: 6.19.3(typescript@5.9.3) + typescript: 5.9.3 + + '@prisma/config@6.19.3': + dependencies: + c12: 3.1.0 + deepmerge-ts: 7.1.5 + effect: 3.21.0 + empathic: 2.0.0 + transitivePeerDependencies: + - magicast + + '@prisma/debug@6.19.3': {} + + '@prisma/engines-version@7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7': {} + + '@prisma/engines@6.19.3': + dependencies: + '@prisma/debug': 6.19.3 + '@prisma/engines-version': 7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7 + '@prisma/fetch-engine': 6.19.3 + '@prisma/get-platform': 6.19.3 + + '@prisma/fetch-engine@6.19.3': + dependencies: + '@prisma/debug': 6.19.3 + '@prisma/engines-version': 7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7 + '@prisma/get-platform': 6.19.3 + + '@prisma/get-platform@6.19.3': + dependencies: + '@prisma/debug': 6.19.3 + + '@rolldown/binding-android-arm64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': + optional: true + + '@rolldown/pluginutils@1.0.0-rc.17': {} + + '@standard-schema/spec@1.1.0': {} + + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.8': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@25.6.0': + dependencies: + undici-types: 7.19.2 + + '@types/pg@8.20.0': + dependencies: + '@types/node': 25.6.0 + pg-protocol: 1.13.0 + pg-types: 2.2.0 + + '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.3.0(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.59.2 + '@typescript-eslint/type-utils': 8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.2 + eslint: 10.3.0(jiti@2.7.0) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.59.2 + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.2 + debug: 4.4.3 + eslint: 10.3.0(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.59.2(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@5.9.3) + '@typescript-eslint/types': 8.59.2 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.59.2': + dependencies: + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/visitor-keys': 8.59.2 + + '@typescript-eslint/tsconfig-utils@8.59.2(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@5.9.3) + debug: 4.4.3 + eslint: 10.3.0(jiti@2.7.0) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.59.2': {} + + '@typescript-eslint/typescript-estree@8.59.2(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.59.2(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@5.9.3) + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/visitor-keys': 8.59.2 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.7.4 + tinyglobby: 0.2.16 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.3.0(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.59.2 + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + eslint: 10.3.0(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.59.2': + dependencies: + '@typescript-eslint/types': 8.59.2 + eslint-visitor-keys: 5.0.1 + + '@vitest/expect@4.1.5': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.5(vite@8.0.10(@types/node@25.6.0)(jiti@2.7.0))': + dependencies: + '@vitest/spy': 4.1.5 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.10(@types/node@25.6.0)(jiti@2.7.0) + + '@vitest/pretty-format@4.1.5': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.5': + dependencies: + '@vitest/utils': 4.1.5 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + '@vitest/utils': 4.1.5 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.5': {} + + '@vitest/utils@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + assertion-error@2.0.1: {} + + balanced-match@4.0.4: {} + + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + + c12@3.1.0: + dependencies: + chokidar: 4.0.3 + confbox: 0.2.4 + defu: 6.1.7 + dotenv: 16.6.1 + exsolve: 1.0.8 + giget: 2.0.0 + jiti: 2.7.0 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 1.0.0 + pkg-types: 2.3.1 + rc9: 2.1.2 + + chai@6.2.2: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + citty@0.1.6: + dependencies: + consola: 3.4.2 + + citty@0.2.2: {} + + confbox@0.2.4: {} + + consola@3.4.2: {} + + convert-source-map@2.0.0: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + deepmerge-ts@7.1.5: {} + + defu@6.1.7: {} + + destr@2.0.5: {} + + detect-libc@2.1.2: {} + + dotenv@16.6.1: {} + + effect@3.21.0: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 3.23.2 + + empathic@2.0.0: {} + + es-module-lexer@2.1.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-config-prettier@10.1.8(eslint@10.3.0(jiti@2.7.0)): + dependencies: + eslint: 10.3.0(jiti@2.7.0) + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.8 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.3.0(jiti@2.7.0): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.3.0(jiti@2.7.0)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.5.5 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.7.0 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + esutils@2.0.3: {} + + expect-type@1.3.0: {} + + exsolve@1.0.8: {} + + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + fsevents@2.3.3: + optional: true + + giget@2.0.0: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + defu: 6.1.7 + node-fetch-native: 1.6.7 + nypm: 0.6.6 + pathe: 2.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + imurmurhash@0.1.4: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + isexe@2.0.0: {} + + jiti@2.7.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.5 + + ms@2.1.3: {} + + nanoid@3.3.12: {} + + natural-compare@1.4.0: {} + + node-fetch-native@1.6.7: {} + + nypm@0.6.6: + dependencies: + citty: 0.2.2 + pathe: 2.0.3 + tinyexec: 1.1.2 + + obug@2.1.1: {} + + ohash@2.0.11: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + pathe@2.0.3: {} + + perfect-debounce@1.0.0: {} + + pg-cloudflare@1.3.0: + optional: true + + pg-connection-string@2.12.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.13.0(pg@8.20.0): + dependencies: + pg: 8.20.0 + + pg-protocol@1.13.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.20.0: + dependencies: + pg-connection-string: 2.12.0 + pg-pool: 3.13.0(pg@8.20.0) + pg-protocol: 1.13.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.3.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + pkg-types@2.3.1: + dependencies: + confbox: 0.2.4 + exsolve: 1.0.8 + pathe: 2.0.3 + + postcss@8.5.14: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + prelude-ls@1.2.1: {} + + prettier@3.8.3: {} + + prisma@6.19.3(typescript@5.9.3): + dependencies: + '@prisma/config': 6.19.3 + '@prisma/engines': 6.19.3 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - magicast + + punycode@2.3.1: {} + + pure-rand@6.1.0: {} + + rc9@2.1.2: + dependencies: + defu: 6.1.7 + destr: 2.0.5 + + readdirp@4.1.2: {} + + rolldown@1.0.0-rc.17: + dependencies: + '@oxc-project/types': 0.127.0 + '@rolldown/pluginutils': 1.0.0-rc.17 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.17 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.17 + '@rolldown/binding-darwin-x64': 1.0.0-rc.17 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.17 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.17 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.17 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.17 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.17 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.17 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17 + + semver@7.7.4: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + split2@4.2.0: {} + + stackback@0.0.2: {} + + std-env@4.1.0: {} + + tinybench@2.9.0: {} + + tinyexec@1.1.2: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + tslib@2.8.1: + optional: true + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.3.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@5.9.3) + eslint: 10.3.0(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + undici-types@7.19.2: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + vite@8.0.10(@types/node@25.6.0)(jiti@2.7.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.14 + rolldown: 1.0.0-rc.17 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 25.6.0 + fsevents: 2.3.3 + jiti: 2.7.0 + + vitest@4.1.5(@types/node@25.6.0)(vite@8.0.10(@types/node@25.6.0)(jiti@2.7.0)): + dependencies: + '@vitest/expect': 4.1.5 + '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.0)(jiti@2.7.0)) + '@vitest/pretty-format': 4.1.5 + '@vitest/runner': 4.1.5 + '@vitest/snapshot': 4.1.5 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.1.2 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 8.0.10(@types/node@25.6.0)(jiti@2.7.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.6.0 + transitivePeerDependencies: + - msw + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + xtend@4.0.2: {} + + yocto-queue@0.1.0: {} diff --git a/js/pnpm-workspace.yaml b/js/pnpm-workspace.yaml new file mode 100644 index 000000000..378da482f --- /dev/null +++ b/js/pnpm-workspace.yaml @@ -0,0 +1,8 @@ +packages: + - "driver/*" + - "examples/*" + +ignoredBuiltDependencies: + - "@prisma/client" + - "@prisma/engines" + - prisma diff --git a/js/src/client.test.ts b/js/src/client.test.ts new file mode 100644 index 000000000..8aee51fe0 --- /dev/null +++ b/js/src/client.test.ts @@ -0,0 +1,268 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { Client, InsertManyParams } from "./client.js"; +import type { Driver, DriverOptions, JobInsertParams } from "./driver.js"; +import type { JobArgs, JobRow } from "./job.js"; +import { + JOB_STATE_AVAILABLE, + JOB_STATE_SCHEDULED, + JobArgsObject, + MAX_ATTEMPTS_DEFAULT, + PRIORITY_DEFAULT, + QUEUE_DEFAULT, +} from "./job.js"; + +// Stub driver that records insert params instead of hitting a database. +class FakeDriver implements Driver { + insertedParams: JobInsertParams[] = []; + lastOptions?: DriverOptions; + + async jobInsert( + params: JobInsertParams, + options?: DriverOptions + ): Promise<[JobRow, boolean]> { + this.insertedParams.push(params); + this.lastOptions = options; + return [fakeJobRow(params), false]; + } + + async jobInsertMany( + params: JobInsertParams[], + options?: DriverOptions + ): Promise<[JobRow, boolean][]> { + this.insertedParams.push(...params); + this.lastOptions = options; + return params.map((p) => [fakeJobRow(p), false]); + } +} + +function fakeJobRow(params: JobInsertParams): JobRow { + return { + id: 1, + args: JSON.parse(params.encodedArgs) as Record, + attempt: 0, + attemptedAt: null, + attemptedBy: null, + createdAt: new Date(), + errors: null, + finalizedAt: null, + kind: params.kind, + maxAttempts: params.maxAttempts, + metadata: {}, + priority: params.priority, + queue: params.queue, + scheduledAt: params.scheduledAt, + state: params.state, + tags: params.tags, + uniqueKey: params.uniqueKey, + uniqueStates: null, + }; +} + +class SortArgs implements JobArgs { + kind = "sort"; + + constructor(public strings: string[]) {} + + toJSON() { + return { strings: this.strings }; + } +} + +describe("Client", () => { + let driver: FakeDriver; + let client: Client; + + beforeEach(() => { + driver = new FakeDriver(); + client = new Client(driver); + }); + + describe("insert", () => { + it("inserts a job with defaults", async () => { + const result = await client.insert(new SortArgs(["b", "a"])); + + expect(result.uniqueSkippedAsDuplicated).toBe(false); + expect(result.job.kind).toBe("sort"); + + const params = driver.insertedParams[0]!; + expect(params.kind).toBe("sort"); + expect(params.encodedArgs).toBe('{"strings":["b","a"]}'); + expect(params.maxAttempts).toBe(MAX_ATTEMPTS_DEFAULT); + expect(params.priority).toBe(PRIORITY_DEFAULT); + expect(params.queue).toBe(QUEUE_DEFAULT); + expect(params.state).toBe(JOB_STATE_AVAILABLE); + expect(params.tags).toEqual([]); + expect(params.uniqueKey).toBeNull(); + expect(params.uniqueStates).toBeNull(); + }); + + it("respects insert opts", async () => { + const future = new Date(Date.now() + 60_000); + await client.insert(new SortArgs(["a"]), { + maxAttempts: 5, + priority: 3, + queue: "high", + scheduledAt: future, + tags: ["tag1"], + }); + + const params = driver.insertedParams[0]!; + expect(params.maxAttempts).toBe(5); + expect(params.priority).toBe(3); + expect(params.queue).toBe("high"); + expect(params.scheduledAt).toBe(future); + expect(params.state).toBe(JOB_STATE_SCHEDULED); + expect(params.tags).toEqual(["tag1"]); + }); + + it("uses JobArgsObject", async () => { + await client.insert( + new JobArgsObject("email", { to: "user@example.com" }) + ); + + const params = driver.insertedParams[0]!; + expect(params.kind).toBe("email"); + expect(params.encodedArgs).toBe('{"to":"user@example.com"}'); + }); + + it("strips kind and insertOpts from args without toJSON", async () => { + const args: JobArgs = { + kind: "plain", + insertOpts: { maxAttempts: 3 }, + }; + // Add a data property at runtime + (args as unknown as Record).data = "hello"; + + await client.insert(args); + + const params = driver.insertedParams[0]!; + expect(JSON.parse(params.encodedArgs)).toEqual({ data: "hello" }); + expect(params.maxAttempts).toBe(3); + }); + }); + + describe("insert with uniqueOpts", () => { + it("generates unique key when constraints are set", async () => { + await client.insert(new SortArgs(["a"]), { + uniqueOpts: { byQueue: true }, + }); + + const params = driver.insertedParams[0]!; + expect(params.uniqueKey).not.toBeNull(); + expect(params.uniqueStates).not.toBeNull(); + }); + + it("does not generate unique key for empty uniqueOpts", async () => { + await client.insert(new SortArgs(["a"]), { + uniqueOpts: {}, + }); + + const params = driver.insertedParams[0]!; + expect(params.uniqueKey).toBeNull(); + expect(params.uniqueStates).toBeNull(); + }); + + it("does not generate unique key for args-level empty uniqueOpts", async () => { + class ArgsWithEmptyUnique implements JobArgs { + kind = "with_empty_unique"; + insertOpts = { uniqueOpts: {} }; + + toJSON() { + return {}; + } + } + + await client.insert(new ArgsWithEmptyUnique()); + + const params = driver.insertedParams[0]!; + expect(params.uniqueKey).toBeNull(); + expect(params.uniqueStates).toBeNull(); + }); + }); + + describe("insertMany", () => { + it("inserts multiple jobs", async () => { + const results = await client.insertMany([ + new SortArgs(["b"]), + new SortArgs(["a"]), + ]); + + expect(results).toHaveLength(2); + expect(driver.insertedParams).toHaveLength(2); + expect(driver.insertedParams[0]!.encodedArgs).toBe('{"strings":["b"]}'); + expect(driver.insertedParams[1]!.encodedArgs).toBe('{"strings":["a"]}'); + }); + + it("supports InsertManyParams with per-job opts", async () => { + await client.insertMany([ + new InsertManyParams(new SortArgs(["a"]), { maxAttempts: 5 }), + new SortArgs(["b"]), + ]); + + expect(driver.insertedParams[0]!.maxAttempts).toBe(5); + expect(driver.insertedParams[1]!.maxAttempts).toBe(MAX_ATTEMPTS_DEFAULT); + }); + }); + + describe("validation", () => { + it("rejects empty kind", async () => { + await expect(client.insert({ kind: "" })).rejects.toThrow( + "args must have a non-empty kind" + ); + }); + + it("rejects tags over 255 characters", async () => { + await expect( + client.insert(new SortArgs(["a"]), { tags: ["x".repeat(256)] }) + ).rejects.toThrow("255 characters"); + }); + + it("rejects tags with invalid characters", async () => { + await expect( + client.insert(new SortArgs(["a"]), { tags: ["bad tag!"] }) + ).rejects.toThrow("tag should match regex"); + }); + + it("rejects unique states missing required states", async () => { + await expect( + client.insert(new SortArgs(["a"]), { + uniqueOpts: { + byArgs: true, + byState: [JOB_STATE_AVAILABLE], + }, + }) + ).rejects.toThrow("byState should include required state"); + }); + + it("rejects invalid schema names", () => { + expect(() => new Client(driver, { schema: "bad schema" })).toThrow( + "invalid schema name" + ); + expect(() => new Client(driver, { schema: "has;semicolon" })).toThrow( + "invalid schema name" + ); + expect(() => new Client(driver, { schema: "1starts" })).toThrow( + "invalid schema name" + ); + }); + }); + + describe("schema", () => { + it("passes empty schema prefix by default", async () => { + await client.insert(new SortArgs(["a"])); + expect(driver.lastOptions?.schemaPrefix).toBe(""); + }); + + it("passes schema prefix when configured", async () => { + const schemaClient = new Client(driver, { schema: "private" }); + await schemaClient.insert(new SortArgs(["a"])); + expect(driver.lastOptions?.schemaPrefix).toBe('"private".'); + }); + + it("passes schema prefix to insertMany", async () => { + const schemaClient = new Client(driver, { schema: "custom" }); + await schemaClient.insertMany([new SortArgs(["a"])]); + expect(driver.lastOptions?.schemaPrefix).toBe('"custom".'); + }); + }); +}); diff --git a/js/src/client.ts b/js/src/client.ts new file mode 100644 index 000000000..9d9d16fd9 --- /dev/null +++ b/js/src/client.ts @@ -0,0 +1,324 @@ +import { createHash } from "node:crypto"; + +import type { Driver, DriverOptions, JobInsertParams } from "./driver.js"; +import type { InsertOpts, UniqueOpts } from "./insert-opts.js"; +import type { JobArgs, JobRow, JobState } from "./job.js"; +import { + JOB_STATE_AVAILABLE, + JOB_STATE_COMPLETED, + JOB_STATE_PENDING, + JOB_STATE_RETRYABLE, + JOB_STATE_RUNNING, + JOB_STATE_SCHEDULED, + MAX_ATTEMPTS_DEFAULT, + PRIORITY_DEFAULT, + QUEUE_DEFAULT, +} from "./job.js"; +import { uniqueBitmaskFromStates } from "./unique-bitmask.js"; + +const TAG_RE = /^\w[\w-]+\w$/; + +const DEFAULT_UNIQUE_STATES: JobState[] = [ + JOB_STATE_AVAILABLE, + JOB_STATE_COMPLETED, + JOB_STATE_PENDING, + JOB_STATE_RETRYABLE, + JOB_STATE_RUNNING, + JOB_STATE_SCHEDULED, +]; + +const REQUIRED_UNIQUE_STATES: JobState[] = [ + JOB_STATE_AVAILABLE, + JOB_STATE_PENDING, + JOB_STATE_RUNNING, + JOB_STATE_SCHEDULED, +]; + +/** Result of a single job insertion. */ +export interface InsertResult { + /** The inserted job row (or existing row if unique-skipped). */ + job: JobRow; + + /** True if insertion was skipped due to an existing unique job. */ + uniqueSkippedAsDuplicated: boolean; +} + +/** + * Pairs job args with per-job insertion options for use with `insertMany`. + * + * Example: + * + * await client.insertMany([ + * new InsertManyParams(new SortArgs(["b"]), { maxAttempts: 5 }), + * new SortArgs(["a"]), // raw job args use defaults + * ]); + */ +export class InsertManyParams { + readonly args: JobArgs; + readonly insertOpts?: InsertOpts; + + constructor(args: JobArgs, insertOpts?: InsertOpts) { + this.args = args; + this.insertOpts = insertOpts; + } +} + +/** Options for constructing a River Client. */ +export interface ClientOpts { + /** + * A non-default PostgreSQL schema where River tables are located. All + * table references in database queries will use this as a prefix. + * + * Defaults to empty, which causes queries to use the Postgres `search_path`. + */ + schema?: string; +} + +const SCHEMA_NAME_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/; + +/** + * Client for River that inserts jobs. Unlike the Go River client, this one + * can only insert jobs — job execution is handled by a Go River server. + * + * Used in conjunction with a driver: + * + * import { Client } from "riverqueue"; + * import { PgDriver } from "@riverqueue/driver-pg"; + * + * const client = new Client(new PgDriver(pool)); + * await client.insert(new SortArgs(["whale", "tiger"])); + * + * To use a non-default schema: + * + * const client = new Client(new PgDriver(pool), { schema: "private" }); + */ +export class Client { + private driver: Driver; + private schemaPrefix: string; // differs from `schema` in that it's the full prefix used in queries (e.g. `"my_schema".` or "") + + constructor(driver: Driver, opts?: ClientOpts) { + this.driver = driver; + + if (opts?.schema) { + if (!SCHEMA_NAME_RE.test(opts.schema)) { + throw new Error( + `invalid schema name: ${JSON.stringify(opts.schema)} (must match ${SCHEMA_NAME_RE})` + ); + } + this.schemaPrefix = `"${opts.schema}".`; + } else { + this.schemaPrefix = ""; + } + } + + /** + * Insert a single job for work. Options include standard insertion options + * and an optional `tx` for running within a transaction. + */ + async insert( + args: JobArgs, + opts?: InsertOpts & { tx?: TTx } + ): Promise { + const params = this.makeInsertParams(args, opts ?? {}); + const [job, uniqueSkipped] = await this.driver.jobInsert( + params, + this.driverOptions(opts?.tx) + ); + return { job, uniqueSkippedAsDuplicated: uniqueSkipped }; + } + + /** + * Insert many jobs in a single batch operation. Accepts an array of + * `JobArgs` or `InsertManyParams` (which pairs args with per-job options). + * Pass `tx` to run the entire batch within a transaction. + */ + async insertMany( + args: (JobArgs | InsertManyParams)[], + opts?: { tx?: TTx } + ): Promise { + const allParams = args.map((arg) => { + if (arg instanceof InsertManyParams) { + return this.makeInsertParams(arg.args, arg.insertOpts || {}); + } + return this.makeInsertParams(arg, {}); + }); + + const results = await this.driver.jobInsertMany( + allParams, + this.driverOptions(opts?.tx) + ); + return results.map(([job, uniqueSkipped]) => ({ + job, + uniqueSkippedAsDuplicated: uniqueSkipped, + })); + } + + private driverOptions(tx?: TTx): DriverOptions { + return { schemaPrefix: this.schemaPrefix, tx }; + } + + private makeInsertParams( + args: JobArgs, + insertOpts: InsertOpts + ): JobInsertParams { + if (!args.kind) { + throw new Error("args must have a non-empty kind"); + } + + const encodedArgs = this.encodeArgs(args); + + const argsInsertOpts: InsertOpts = args.insertOpts || {}; + + const scheduledAt = insertOpts.scheduledAt || argsInsertOpts.scheduledAt; + + const params: JobInsertParams = { + encodedArgs, + kind: args.kind, + maxAttempts: + insertOpts.maxAttempts || + argsInsertOpts.maxAttempts || + MAX_ATTEMPTS_DEFAULT, + priority: + insertOpts.priority || argsInsertOpts.priority || PRIORITY_DEFAULT, + queue: insertOpts.queue || argsInsertOpts.queue || QUEUE_DEFAULT, + scheduledAt: scheduledAt || new Date(), + state: scheduledAt ? JOB_STATE_SCHEDULED : JOB_STATE_AVAILABLE, + tags: this.validateTags(insertOpts.tags || argsInsertOpts.tags || []), + uniqueKey: null, + uniqueStates: null, + }; + + const uniqueOpts = insertOpts.uniqueOpts || argsInsertOpts.uniqueOpts; + if (uniqueOpts && this.hasUniqueConstraints(uniqueOpts)) { + const [uniqueKey, uniqueStates] = this.makeUniqueKeyAndBitmask( + params, + uniqueOpts + ); + params.uniqueKey = uniqueKey; + params.uniqueStates = uniqueStates; + } + + return params; + } + + private hasUniqueConstraints(uniqueOpts: UniqueOpts): boolean { + return !!( + uniqueOpts.byArgs || + uniqueOpts.byPeriod || + uniqueOpts.byQueue || + uniqueOpts.byState || + uniqueOpts.excludeKind + ); + } + + private encodeArgs(args: JobArgs): string { + // If toJSON() is defined, JSON.stringify will call it automatically, + // giving the implementation full control over serialization. + const argsAny = args as unknown as Record; + if (typeof argsAny.toJSON === "function") { + return JSON.stringify(args); + } + + // Otherwise, serialize all properties except non-data fields. + const obj = { ...argsAny }; + delete obj.kind; + delete obj.insertOpts; + return JSON.stringify(obj); + } + + private makeUniqueKeyAndBitmask( + params: JobInsertParams, + uniqueOpts: UniqueOpts + ): [Uint8Array, string] { + // It's extremely important here that this unique key format and algorithm + // match the one in the main River library _exactly_. Don't change them + // unless they're updated everywhere. + let uniqueKeyStr = ""; + + if (!uniqueOpts.excludeKind) { + uniqueKeyStr += `&kind=${params.kind}`; + } + + if (uniqueOpts.byArgs) { + const parsedArgs = JSON.parse(params.encodedArgs) as Record< + string, + unknown + >; + let filteredArgs: Record; + + if (Array.isArray(uniqueOpts.byArgs)) { + filteredArgs = {}; + for (const key of uniqueOpts.byArgs) { + if (key in parsedArgs) { + filteredArgs[key] = parsedArgs[key]; + } + } + } else { + filteredArgs = parsedArgs; + } + + // Sort keys for deterministic output matching other River clients. + const sortedArgs: Record = {}; + for (const key of Object.keys(filteredArgs).sort()) { + sortedArgs[key] = filteredArgs[key]; + } + uniqueKeyStr += `&args=${JSON.stringify(sortedArgs)}`; + } + + if (uniqueOpts.byPeriod) { + const lowerBound = this.truncateTime( + params.scheduledAt, + uniqueOpts.byPeriod + ); + uniqueKeyStr += `&period=${this.formatTimeUTC(lowerBound)}`; + } + + if (uniqueOpts.byQueue) { + uniqueKeyStr += `&queue=${params.queue}`; + } + + const uniqueKey = createHash("sha256").update(uniqueKeyStr).digest(); + const states = this.validateUniqueStates( + uniqueOpts.byState || DEFAULT_UNIQUE_STATES + ); + const uniqueStates = uniqueBitmaskFromStates(states); + + return [new Uint8Array(uniqueKey), uniqueStates]; + } + + private truncateTime(time: Date, intervalSeconds: number): Date { + const epochSeconds = time.getTime() / 1000; + return new Date( + Math.floor(epochSeconds / intervalSeconds) * intervalSeconds * 1000 + ); + } + + private formatTimeUTC(date: Date): string { + const pad = (n: number) => n.toString().padStart(2, "0"); + return ( + `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}` + + `T${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}Z` + ); + } + + private validateTags(tags: string[]): string[] { + for (const tag of tags) { + if (tag.length > 255) { + throw new Error("tags should be 255 characters or less"); + } + if (!TAG_RE.test(tag)) { + throw new Error(`tag should match regex ${TAG_RE}`); + } + } + return tags; + } + + private validateUniqueStates(states: JobState[]): JobState[] { + for (const required of REQUIRED_UNIQUE_STATES) { + if (!states.includes(required)) { + throw new Error(`byState should include required state '${required}'`); + } + } + return states; + } +} diff --git a/js/src/driver.ts b/js/src/driver.ts new file mode 100644 index 000000000..1be96413e --- /dev/null +++ b/js/src/driver.ts @@ -0,0 +1,50 @@ +import type { JobRow, JobState } from "./job.js"; + +/** + * Internal insert parameters sent to drivers. This interface is meant for + * driver implementations and is subject to change. + */ +export interface JobInsertParams { + encodedArgs: string; + kind: string; + maxAttempts: number; + priority: number; + queue: string; + scheduledAt: Date; + state: JobState; + tags: string[]; + uniqueKey: Uint8Array | null; + /** Bitmask string like "10110001" representing states for uniqueness. */ + uniqueStates: string | null; +} + +/** + * Interface that database drivers must implement. River drivers translate + * the generic insert params into database-specific operations. + */ +/** + * Interface that database drivers must implement. The TTx type parameter + * represents the driver-specific transaction type (e.g. PoolClient for pg, + * PrismaClientLike for Prisma). + */ +export interface Driver { + /** Insert a single job. */ + jobInsert( + params: JobInsertParams, + options?: DriverOptions + ): Promise<[JobRow, boolean]>; + + /** Insert multiple jobs in a single batch operation. */ + jobInsertMany( + params: JobInsertParams[], + options?: DriverOptions + ): Promise<[JobRow, boolean][]>; +} + +/** Options passed from the Client to drivers on each operation. */ +export interface DriverOptions { + /** Schema-qualified table prefix (e.g. `"my_schema".`), or empty string for default. */ + schemaPrefix: string; + /** Optional transaction to run the operation within. */ + tx?: TTx; +} diff --git a/js/src/index.ts b/js/src/index.ts new file mode 100644 index 000000000..79b0d0b8d --- /dev/null +++ b/js/src/index.ts @@ -0,0 +1,23 @@ +export { Client, InsertManyParams } from "./client.js"; +export type { ClientOpts, InsertResult } from "./client.js"; +export type { Driver, DriverOptions, JobInsertParams } from "./driver.js"; +export type { InsertOpts, UniqueOpts } from "./insert-opts.js"; +export { + JOB_STATE_AVAILABLE, + JOB_STATE_CANCELLED, + JOB_STATE_COMPLETED, + JOB_STATE_DISCARDED, + JOB_STATE_PENDING, + JOB_STATE_RETRYABLE, + JOB_STATE_RUNNING, + JOB_STATE_SCHEDULED, + JobArgsObject, + MAX_ATTEMPTS_DEFAULT, + PRIORITY_DEFAULT, + QUEUE_DEFAULT, +} from "./job.js"; +export type { AttemptError, JobArgs, JobRow, JobState } from "./job.js"; +export { + uniqueBitmaskFromStates, + uniqueBitmaskToStates, +} from "./unique-bitmask.js"; diff --git a/js/src/insert-opts.ts b/js/src/insert-opts.ts new file mode 100644 index 000000000..878affbcd --- /dev/null +++ b/js/src/insert-opts.ts @@ -0,0 +1,58 @@ +import type { JobState } from "./job.js"; + +/** + * Options for job insertion. Can be provided via `insertOpts` on job args + * (as defaults for all jobs of that kind) or passed directly to `insert` / + * `insertMany` (which take precedence over args-level defaults). + */ +export interface InsertOpts { + /** Maximum total attempts (including retries) before discarding. */ + maxAttempts?: number; + + /** Priority 1 (highest) to 4 (lowest). Defaults to PRIORITY_DEFAULT. */ + priority?: number; + + /** Queue name. Defaults to QUEUE_DEFAULT. */ + queue?: string; + + /** Schedule the job for a future time instead of running immediately. */ + scheduledAt?: Date; + + /** Arbitrary tags for grouping and categorizing jobs. */ + tags?: string[]; + + /** Options for unique job constraints. */ + uniqueOpts?: UniqueOpts; +} + +/** + * Parameters for unique job constraints. Each enabled property adds a + * dimension to the uniqueness check. With no properties set, no uniqueness + * is enforced. + */ +export interface UniqueOpts { + /** + * Enforce uniqueness by encoded args. Set `true` for all args, or an + * array of specific field names to consider. + */ + byArgs?: boolean | string[]; + + /** + * Enforce uniqueness within a time period (in seconds). Time is rounded + * down to the nearest multiple of the period. + */ + byPeriod?: number; + + /** Enforce uniqueness per queue. */ + byQueue?: boolean; + + /** + * Job states to consider for uniqueness. Defaults to available, completed, + * pending, retryable, running, and scheduled. The states available, + * pending, running, and scheduled are always required. + */ + byState?: JobState[]; + + /** Exclude job kind from the uniqueness check. */ + excludeKind?: boolean; +} diff --git a/js/src/job.ts b/js/src/job.ts new file mode 100644 index 000000000..c793eba8a --- /dev/null +++ b/js/src/job.ts @@ -0,0 +1,148 @@ +import type { InsertOpts } from "./insert-opts.js"; + +// Job states matching the River database enum. +export const JOB_STATE_AVAILABLE = "available" as const; +export const JOB_STATE_CANCELLED = "cancelled" as const; +export const JOB_STATE_COMPLETED = "completed" as const; +export const JOB_STATE_DISCARDED = "discarded" as const; +export const JOB_STATE_PENDING = "pending" as const; +export const JOB_STATE_RETRYABLE = "retryable" as const; +export const JOB_STATE_RUNNING = "running" as const; +export const JOB_STATE_SCHEDULED = "scheduled" as const; + +export type JobState = + | typeof JOB_STATE_AVAILABLE + | typeof JOB_STATE_CANCELLED + | typeof JOB_STATE_COMPLETED + | typeof JOB_STATE_DISCARDED + | typeof JOB_STATE_PENDING + | typeof JOB_STATE_RETRYABLE + | typeof JOB_STATE_RUNNING + | typeof JOB_STATE_SCHEDULED; + +/** Default number of maximum attempts for a job. */ +export const MAX_ATTEMPTS_DEFAULT = 25; + +/** Default priority for a job. */ +export const PRIORITY_DEFAULT = 1; + +/** Default queue for a job. */ +export const QUEUE_DEFAULT = "default"; + +/** + * Interface for job args. Implementations must provide a `kind` string that + * uniquely identifies the job type in the database. + * + * Implementations should define a `toJSON()` method to control which fields + * are serialized as the job's args. If `toJSON()` is not defined, all + * properties except `kind` and `insertOpts` are serialized. + * + * They may optionally provide `insertOpts` to set default insertion options + * for all jobs of this kind. + * + * Example: + * + * class SortArgs implements JobArgs { + * kind = "sort"; + * + * constructor(public strings: string[]) {} + * + * toJSON() { + * return { strings: this.strings }; + * } + * } + */ +export interface JobArgs { + kind: string; + insertOpts?: InsertOpts; +} + +/** + * Provides a way to create job args from a plain object for quick insertion + * without defining a class. + * + * Example: + * + * const args = new JobArgsObject("sort", { strings: ["whale", "tiger"] }); + * await client.insert(args); + */ +export class JobArgsObject implements JobArgs { + readonly kind: string; + private readonly obj: Record; + + constructor(kind: string, obj: Record) { + if (!kind) throw new Error("kind is required"); + if (!obj) throw new Error("obj is required"); + this.kind = kind; + this.obj = obj; + } + + toJSON(): Record { + return this.obj; + } +} + +/** A failed job work attempt containing information about the error. */ +export interface AttemptError { + at: Date; + attempt: number; + error: string; + trace: string; +} + +/** Contains the properties of a job that are persisted to the database. */ +export interface JobRow { + /** ID of the job, generated by a Postgres sequence. */ + id: number; + + /** The job's args as an object decoded from JSON. */ + args: Record; + + /** The attempt number of the job. Jobs are inserted at 0. */ + attempt: number; + + /** The time that the job was last worked. */ + attemptedAt: Date | null; + + /** The set of worker IDs that have worked this job. */ + attemptedBy: string[] | null; + + /** When the job record was created. */ + createdAt: Date; + + /** Errors from previous work attempts, ordered earliest to latest. */ + errors: AttemptError[] | null; + + /** When the job was finalized (completed successfully or discarded). */ + finalizedAt: Date | null; + + /** Kind uniquely identifies the type of job and which worker should work it. */ + kind: string; + + /** The maximum number of attempts before the job is discarded. */ + maxAttempts: number; + + /** Arbitrary metadata associated with the job. */ + metadata: Record; + + /** Priority of the job, 1 (highest) to 4 (lowest). */ + priority: number; + + /** The queue where the job will be worked. */ + queue: string; + + /** When the job is scheduled to become available for work. */ + scheduledAt: Date; + + /** The current state of the job. */ + state: JobState; + + /** Arbitrary tags for grouping and categorizing jobs. */ + tags: string[]; + + /** Unique key for the job, generated by objing unique opts configuration. */ + uniqueKey: Uint8Array | null; + + /** States considered for uniqueness checks. */ + uniqueStates: JobState[] | null; +} diff --git a/js/src/unique-bitmask.test.ts b/js/src/unique-bitmask.test.ts new file mode 100644 index 000000000..a715886e9 --- /dev/null +++ b/js/src/unique-bitmask.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect } from "vitest"; +import { + uniqueBitmaskFromStates, + uniqueBitmaskToStates, +} from "./unique-bitmask.js"; +import type { JobState } from "./job.js"; +import { + JOB_STATE_AVAILABLE, + JOB_STATE_CANCELLED, + JOB_STATE_COMPLETED, + JOB_STATE_DISCARDED, + JOB_STATE_PENDING, + JOB_STATE_RETRYABLE, + JOB_STATE_RUNNING, + JOB_STATE_SCHEDULED, +} from "./job.js"; + +describe("uniqueBitmaskFromStates", () => { + it("produces correct bitmask for individual states", () => { + // Bit positions (in the 8-char string, left to right): + // 0=scheduled, 1=running, 2=retryable, 3=pending, + // 4=discarded, 5=completed, 6=cancelled, 7=available + expect(uniqueBitmaskFromStates([JOB_STATE_AVAILABLE])).toBe("00000001"); + expect(uniqueBitmaskFromStates([JOB_STATE_CANCELLED])).toBe("00000010"); + expect(uniqueBitmaskFromStates([JOB_STATE_COMPLETED])).toBe("00000100"); + expect(uniqueBitmaskFromStates([JOB_STATE_DISCARDED])).toBe("00001000"); + expect(uniqueBitmaskFromStates([JOB_STATE_PENDING])).toBe("00010000"); + expect(uniqueBitmaskFromStates([JOB_STATE_RETRYABLE])).toBe("00100000"); + expect(uniqueBitmaskFromStates([JOB_STATE_RUNNING])).toBe("01000000"); + expect(uniqueBitmaskFromStates([JOB_STATE_SCHEDULED])).toBe("10000000"); + }); + + it("combines multiple states", () => { + expect( + uniqueBitmaskFromStates([JOB_STATE_AVAILABLE, JOB_STATE_SCHEDULED]) + ).toBe("10000001"); + + expect( + uniqueBitmaskFromStates([ + JOB_STATE_AVAILABLE, + JOB_STATE_RUNNING, + JOB_STATE_SCHEDULED, + ]) + ).toBe("11000001"); + }); + + it("produces correct bitmask for default unique states", () => { + // Default: available, completed, pending, retryable, running, scheduled + const defaults: JobState[] = [ + JOB_STATE_AVAILABLE, + JOB_STATE_COMPLETED, + JOB_STATE_PENDING, + JOB_STATE_RETRYABLE, + JOB_STATE_RUNNING, + JOB_STATE_SCHEDULED, + ]; + expect(uniqueBitmaskFromStates(defaults)).toBe("11110101"); + }); + + it("produces correct bitmask for all states", () => { + const all: JobState[] = [ + JOB_STATE_AVAILABLE, + JOB_STATE_CANCELLED, + JOB_STATE_COMPLETED, + JOB_STATE_DISCARDED, + JOB_STATE_PENDING, + JOB_STATE_RETRYABLE, + JOB_STATE_RUNNING, + JOB_STATE_SCHEDULED, + ]; + expect(uniqueBitmaskFromStates(all)).toBe("11111111"); + }); + + it("returns all zeros for empty array", () => { + expect(uniqueBitmaskFromStates([])).toBe("00000000"); + }); +}); + +describe("uniqueBitmaskToStates", () => { + it("decodes individual bits", () => { + expect(uniqueBitmaskToStates(0b00000001)).toEqual([JOB_STATE_AVAILABLE]); + expect(uniqueBitmaskToStates(0b10000000)).toEqual([JOB_STATE_SCHEDULED]); + }); + + it("decodes combined bitmask", () => { + // available + scheduled + expect(uniqueBitmaskToStates(0b10000001)).toEqual( + [JOB_STATE_AVAILABLE, JOB_STATE_SCHEDULED].sort() + ); + }); + + it("returns empty array for zero", () => { + expect(uniqueBitmaskToStates(0)).toEqual([]); + }); + + it("returns sorted states", () => { + const states = uniqueBitmaskToStates(0b11111111); + const sorted = [...states].sort(); + expect(states).toEqual(sorted); + }); +}); + +describe("round-trip", () => { + it("fromStates then toStates returns original states sorted", () => { + const states: JobState[] = [ + JOB_STATE_RUNNING, + JOB_STATE_AVAILABLE, + JOB_STATE_PENDING, + ]; + const bitmask = uniqueBitmaskFromStates(states); + const result = uniqueBitmaskToStates(parseInt(bitmask, 2)); + expect(result).toEqual([...states].sort()); + }); + + it("round-trips all states", () => { + const all: JobState[] = [ + JOB_STATE_AVAILABLE, + JOB_STATE_CANCELLED, + JOB_STATE_COMPLETED, + JOB_STATE_DISCARDED, + JOB_STATE_PENDING, + JOB_STATE_RETRYABLE, + JOB_STATE_RUNNING, + JOB_STATE_SCHEDULED, + ]; + const bitmask = uniqueBitmaskFromStates(all); + const result = uniqueBitmaskToStates(parseInt(bitmask, 2)); + expect(result).toEqual([...all].sort()); + }); +}); diff --git a/js/src/unique-bitmask.ts b/js/src/unique-bitmask.ts new file mode 100644 index 000000000..40130dd0e --- /dev/null +++ b/js/src/unique-bitmask.ts @@ -0,0 +1,45 @@ +import type { JobState } from "./job.js"; +import { + JOB_STATE_AVAILABLE, + JOB_STATE_CANCELLED, + JOB_STATE_COMPLETED, + JOB_STATE_DISCARDED, + JOB_STATE_PENDING, + JOB_STATE_RETRYABLE, + JOB_STATE_RUNNING, + JOB_STATE_SCHEDULED, +} from "./job.js"; + +const JOB_STATE_BIT_POSITIONS: Record = { + [JOB_STATE_AVAILABLE]: 7, + [JOB_STATE_CANCELLED]: 6, + [JOB_STATE_COMPLETED]: 5, + [JOB_STATE_DISCARDED]: 4, + [JOB_STATE_PENDING]: 3, + [JOB_STATE_RETRYABLE]: 2, + [JOB_STATE_RUNNING]: 1, + [JOB_STATE_SCHEDULED]: 0, +}; + +/** Convert an array of job states to an 8-bit bitmask string. */ +export function uniqueBitmaskFromStates(states: JobState[]): string { + let val = 0; + for (const state of states) { + const bitIndex = JOB_STATE_BIT_POSITIONS[state]; + const bitPosition = 7 - (bitIndex % 8); + val |= 1 << bitPosition; + } + return val.toString(2).padStart(8, "0"); +} + +/** Convert a bitmask integer to an array of job states. */ +export function uniqueBitmaskToStates(mask: number): JobState[] { + const states: JobState[] = []; + for (const [state, bitIndex] of Object.entries(JOB_STATE_BIT_POSITIONS)) { + const bitPosition = 7 - (bitIndex % 8); + if ((mask & (1 << bitPosition)) !== 0) { + states.push(state as JobState); + } + } + return states.sort(); +} diff --git a/js/tsconfig.base.json b/js/tsconfig.base.json new file mode 100644 index 000000000..6ba30f4f3 --- /dev/null +++ b/js/tsconfig.base.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + } +} diff --git a/js/tsconfig.json b/js/tsconfig.json new file mode 100644 index 000000000..c7b7b35a0 --- /dev/null +++ b/js/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/js/vitest.config.ts b/js/vitest.config.ts new file mode 100644 index 000000000..42641b483 --- /dev/null +++ b/js/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vitest/config"; +import path from "node:path"; + +export default defineConfig({ + resolve: { + alias: { + riverqueue: path.resolve(import.meta.dirname, "src/index.ts"), + }, + }, + test: { + exclude: ["**/node_modules/**", "**/dist/**", "**/*.integration.test.ts"], + }, +}); diff --git a/js/vitest.integration.config.ts b/js/vitest.integration.config.ts new file mode 100644 index 000000000..c7ce1992d --- /dev/null +++ b/js/vitest.integration.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vitest/config"; +import path from "node:path"; + +export default defineConfig({ + resolve: { + alias: { + riverqueue: path.resolve(import.meta.dirname, "src/index.ts"), + }, + }, + test: { + include: ["**/*.integration.test.ts"], + }, +}); From 7436ecb80e6a6c507214d432e67923aa8fcc96f7 Mon Sep 17 00:00:00 2001 From: Brandur Date: Mon, 1 Jun 2026 06:44:45 -0700 Subject: [PATCH 04/43] Deduplicate by unique key from inside batch (#2) Follows up #1 with a little forgotten code review feedback I forgot to push. It's currently possible for multiple insertions that have the same unique properties to be in the same batch, which will cause Postgres to fail with an error saying that the row's already been updated once and can't be updated again. Here, deduplicate inside the batch to handle this case. --- js/driver/pg/src/driver.integration.test.ts | 27 ++++++++++- js/src/client.test.ts | 32 +++++++++++++ js/src/client.ts | 52 ++++++++++++++++++--- 3 files changed, 104 insertions(+), 7 deletions(-) diff --git a/js/driver/pg/src/driver.integration.test.ts b/js/driver/pg/src/driver.integration.test.ts index 0fa355b5c..022fcb681 100644 --- a/js/driver/pg/src/driver.integration.test.ts +++ b/js/driver/pg/src/driver.integration.test.ts @@ -1,6 +1,6 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import pg from "pg"; -import { Client, JobArgsObject } from "riverqueue"; +import { Client, InsertManyParams, JobArgsObject } from "riverqueue"; import type { JobArgs } from "riverqueue"; import { PgDriver } from "./driver.js"; @@ -112,6 +112,31 @@ describe("PgDriver integration", () => { expect(second.job.id).toBe(first.job.id); }); + it("deduplicates batch with duplicate unique keys", async () => { + const uniqueOpts = { byArgs: true as const }; + const results = await client.insertMany([ + new InsertManyParams( + new JobArgsObject(`${filePrefix}_batch_uniq`, { key: "same" }), + { uniqueOpts } + ), + new InsertManyParams( + new JobArgsObject(`${filePrefix}_batch_uniq`, { key: "same" }), + { uniqueOpts } + ), + new InsertManyParams( + new JobArgsObject(`${filePrefix}_batch_uniq`, { key: "different" }), + { uniqueOpts } + ), + ]); + + expect(results).toHaveLength(3); + expect(results[0]!.uniqueSkippedAsDuplicated).toBe(false); + expect(results[1]!.uniqueSkippedAsDuplicated).toBe(true); + expect(results[2]!.uniqueSkippedAsDuplicated).toBe(false); + expect(results[1]!.job.id).toBe(results[0]!.job.id); + expect(results[2]!.job.id).not.toBe(results[0]!.job.id); + }); + it("allows unique jobs with different args", async () => { const uniqueOpts = { byArgs: true as const }; diff --git a/js/src/client.test.ts b/js/src/client.test.ts index 8aee51fe0..95c5ef5c8 100644 --- a/js/src/client.test.ts +++ b/js/src/client.test.ts @@ -202,6 +202,38 @@ describe("Client", () => { expect(driver.insertedParams[0]!.maxAttempts).toBe(5); expect(driver.insertedParams[1]!.maxAttempts).toBe(MAX_ATTEMPTS_DEFAULT); }); + + it("deduplicates jobs with the same unique key in a batch", async () => { + const uniqueOpts = { byArgs: true as const }; + const results = await client.insertMany([ + new InsertManyParams(new SortArgs(["same"]), { uniqueOpts }), + new InsertManyParams(new SortArgs(["same"]), { uniqueOpts }), + new InsertManyParams(new SortArgs(["different"]), { uniqueOpts }), + ]); + + // Only two jobs sent to the driver (first "same" + "different"). + expect(driver.insertedParams).toHaveLength(2); + + // All three results returned in original order. + expect(results).toHaveLength(3); + expect(results[0]!.uniqueSkippedAsDuplicated).toBe(false); + expect(results[1]!.uniqueSkippedAsDuplicated).toBe(true); // batch dup + expect(results[2]!.uniqueSkippedAsDuplicated).toBe(false); + + // The duplicate returns the same job as the first occurrence. + expect(results[1]!.job.id).toBe(results[0]!.job.id); + }); + + it("does not deduplicate jobs without unique keys", async () => { + const results = await client.insertMany([ + new SortArgs(["a"]), + new SortArgs(["a"]), + ]); + + // Both sent to the driver (no unique constraints). + expect(driver.insertedParams).toHaveLength(2); + expect(results).toHaveLength(2); + }); }); describe("validation", () => { diff --git a/js/src/client.ts b/js/src/client.ts index 9d9d16fd9..e246f6c20 100644 --- a/js/src/client.ts +++ b/js/src/client.ts @@ -143,14 +143,54 @@ export class Client { return this.makeInsertParams(arg, {}); }); - const results = await this.driver.jobInsertMany( - allParams, + // Deduplicate by unique key within the batch. PostgreSQL aborts a + // multi-row INSERT ... ON CONFLICT DO UPDATE if two rows conflict on + // the same unique key, so we must only send the first occurrence to the + // database and mark subsequent duplicates ourselves. + const { dedupedParams, resultMapping } = + this.deduplicateByUniqueKey(allParams); + + const dbResults = await this.driver.jobInsertMany( + dedupedParams, this.driverOptions(opts?.tx) ); - return results.map(([job, uniqueSkipped]) => ({ - job, - uniqueSkippedAsDuplicated: uniqueSkipped, - })); + + return resultMapping.map((mapping) => { + if ("duplicateOf" in mapping) { + const [job] = dbResults[mapping.duplicateOf] as [JobRow, boolean]; + return { job, uniqueSkippedAsDuplicated: true }; + } + const [job, uniqueSkipped] = dbResults[mapping.index] as [ + JobRow, + boolean, + ]; + return { job, uniqueSkippedAsDuplicated: uniqueSkipped }; + }); + } + + private deduplicateByUniqueKey(params: JobInsertParams[]): { + dedupedParams: JobInsertParams[]; + resultMapping: ({ index: number } | { duplicateOf: number })[]; + } { + const uniqueKeyToIndex = new Map(); + const dedupedParams: JobInsertParams[] = []; + const resultMapping: ({ index: number } | { duplicateOf: number })[] = []; + + for (const p of params) { + if (p.uniqueKey) { + const hexKey = Buffer.from(p.uniqueKey).toString("hex"); + const existing = uniqueKeyToIndex.get(hexKey); + if (existing !== undefined) { + resultMapping.push({ duplicateOf: existing }); + continue; + } + uniqueKeyToIndex.set(hexKey, dedupedParams.length); + } + resultMapping.push({ index: dedupedParams.length }); + dedupedParams.push(p); + } + + return { dedupedParams, resultMapping }; } private driverOptions(tx?: TTx): DriverOptions { From e65e81099ca563c76b7a92ad8414af01980c3b8e Mon Sep 17 00:00:00 2001 From: Brandur Date: Mon, 1 Jun 2026 07:06:50 -0700 Subject: [PATCH 05/43] Prepare release 0.1.0 (#13) Prepare initial release 0.1.0 and update release instructions while we're at it (these were prospective and hadn't actually been run before). --- js/CHANGELOG.md | 2 +- js/docs/development.md | 39 +++++++++++++++++++++++++++++---------- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/js/CHANGELOG.md b/js/CHANGELOG.md index f831116ec..1bc163e2e 100644 --- a/js/CHANGELOG.md +++ b/js/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [0.1.0] - 2026-05-15 +## [0.1.0] - 2026-06-01 ### Added diff --git a/js/docs/development.md b/js/docs/development.md index 47b29d11d..74b85ee02 100644 --- a/js/docs/development.md +++ b/js/docs/development.md @@ -35,36 +35,55 @@ By default, tests connect to `postgres://localhost:5432/river_test`. Override wi ## Releasing a new version +The publishable packages are the root `riverqueue` package and the driver +packages under `driver/*`. The packages under `examples/*` are private examples +and should stay at `0.0.0`. + 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 version numbers in the publishable `package.json` files: + + ```shell + pnpm version $VERSION --no-git-tag-version + pnpm --filter './driver/*' exec npm version $VERSION --no-git-tag-version ``` -2. Update version numbers in all `package.json` files: +3. Update `CHANGELOG.md` by moving the release notes from `Unreleased` into a + heading for the new version. + +4. Optional: Verify the release locally. Notably, changes must be committed for + this to work. ```shell - pnpm --filter '*' exec -- npm version $VERSION --no-git-tag-version - npm version $VERSION --no-git-tag-version + pnpm publish --dry-run + pnpm --filter './driver/*' publish --dry-run --access public ``` -3. Prepare a PR with the changes, updating `CHANGELOG.md` with any necessary additions at the same time. Have it reviewed and merged. +5. Prepare a PR with the version and changelog changes. Have it reviewed and + merged. -4. Upon merge, pull down the changes, tag, and push: +6. Upon merge, pull down the changes, tag, and push: ```shell git checkout master && git pull --rebase git tag v$VERSION -m "release v$VERSION" - git push --tags + git push origin v$VERSION ``` -5. Publish packages to npm: +7. Publish packages to npm. Publish the root package first because the driver + packages depend on it: ```shell - pnpm run build:all pnpm publish - pnpm --filter '@riverqueue/*' publish --access public + pnpm --filter './driver/*' publish --access public ``` -6. Cut a new GitHub release by visiting [new release](https://github.com/riverqueue/riverqueue-js/releases/new), selecting the new tag, and copying in the version's `CHANGELOG.md` content as the release body. +8. Cut a new GitHub release by visiting [new release](https://github.com/riverqueue/riverqueue-js/releases/new), + selecting the new tag, and copying in the version's `CHANGELOG.md` content + as the release body. From 59d60c89327429c3143b633d1c58b47f728595e8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 5 Jul 2026 20:47:59 -0500 Subject: [PATCH 06/43] Bump the npm-dependencies group across 3 directories with 5 updates (#14) | Package | From | To | | --- | --- | --- | | [eslint](https://github.com/eslint/eslint) | `10.3.0` | `10.5.0` | | [pg](https://github.com/brianc/node-postgres/tree/HEAD/packages/pg) | `8.20.0` | `8.22.0` | | [prettier](https://github.com/prettier/prettier) | `3.8.3` | `3.8.4` | | [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.59.2` | `8.62.0` | | [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.5` | `4.1.9` | Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- js/driver/pg/package.json | 2 +- js/examples/node-postgres/package.json | 2 +- js/package.json | 10 +- js/pnpm-lock.yaml | 460 +++++++++++++------------ 4 files changed, 240 insertions(+), 234 deletions(-) diff --git a/js/driver/pg/package.json b/js/driver/pg/package.json index 59ac797b6..389dc3c9a 100644 --- a/js/driver/pg/package.json +++ b/js/driver/pg/package.json @@ -34,7 +34,7 @@ }, "devDependencies": { "@types/pg": "^8.11.0", - "pg": "^8.13.0", + "pg": "^8.22.0", "typescript": "^5.8.0" }, "keywords": [ diff --git a/js/examples/node-postgres/package.json b/js/examples/node-postgres/package.json index 9a56e453f..e0851d1af 100644 --- a/js/examples/node-postgres/package.json +++ b/js/examples/node-postgres/package.json @@ -10,7 +10,7 @@ "dependencies": { "riverqueue": "workspace:*", "@riverqueue/driver-pg": "workspace:*", - "pg": "^8.13.0" + "pg": "^8.22.0" }, "devDependencies": { "@types/pg": "^8.11.0", diff --git a/js/package.json b/js/package.json index c9421caa4..3387671fe 100644 --- a/js/package.json +++ b/js/package.json @@ -44,13 +44,13 @@ "@eslint/js": "^10.0.1", "@types/node": "^25.6.0", "@types/pg": "^8.20.0", - "eslint": "^10.3.0", + "eslint": "^10.5.0", "eslint-config-prettier": "^10.1.8", - "pg": "^8.20.0", - "prettier": "^3.8.3", + "pg": "^8.22.0", + "prettier": "^3.8.4", "typescript": "^5.8.0", - "typescript-eslint": "^8.59.2", - "vitest": "^4.1.5" + "typescript-eslint": "^8.62.0", + "vitest": "^4.1.9" }, "packageManager": "pnpm@10.22.0", "keywords": [ diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml index 4738c9dd5..0f9e8c4a9 100644 --- a/js/pnpm-lock.yaml +++ b/js/pnpm-lock.yaml @@ -10,7 +10,7 @@ importers: devDependencies: '@eslint/js': specifier: ^10.0.1 - version: 10.0.1(eslint@10.3.0(jiti@2.7.0)) + version: 10.0.1(eslint@10.5.0(jiti@2.7.0)) '@types/node': specifier: ^25.6.0 version: 25.6.0 @@ -18,26 +18,26 @@ importers: specifier: ^8.20.0 version: 8.20.0 eslint: - specifier: ^10.3.0 - version: 10.3.0(jiti@2.7.0) + specifier: ^10.5.0 + version: 10.5.0(jiti@2.7.0) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@10.3.0(jiti@2.7.0)) + version: 10.1.8(eslint@10.5.0(jiti@2.7.0)) pg: - specifier: ^8.20.0 - version: 8.20.0 + specifier: ^8.22.0 + version: 8.22.0 prettier: - specifier: ^3.8.3 - version: 3.8.3 + specifier: ^3.8.4 + version: 3.8.4 typescript: specifier: ^5.8.0 version: 5.9.3 typescript-eslint: - specifier: ^8.59.2 - version: 8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@5.9.3) + specifier: ^8.62.0 + version: 8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3) vitest: - specifier: ^4.1.5 - version: 4.1.5(@types/node@25.6.0)(vite@8.0.10(@types/node@25.6.0)(jiti@2.7.0)) + specifier: ^4.1.9 + version: 4.1.9(@types/node@25.6.0)(vite@8.0.10(@types/node@25.6.0)(jiti@2.7.0)) driver/pg: dependencies: @@ -49,8 +49,8 @@ importers: specifier: ^8.11.0 version: 8.20.0 pg: - specifier: ^8.13.0 - version: 8.20.0 + specifier: ^8.22.0 + version: 8.22.0 typescript: specifier: ^5.8.0 version: 5.9.3 @@ -74,8 +74,8 @@ importers: specifier: workspace:* version: link:../../driver/pg pg: - specifier: ^8.13.0 - version: 8.20.0 + specifier: ^8.22.0 + version: 8.22.0 riverqueue: specifier: workspace:* version: link:../.. @@ -131,8 +131,8 @@ packages: resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/config-helpers@0.5.5': - resolution: {integrity: sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==} + '@eslint/config-helpers@0.6.0': + resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} '@eslint/core@1.2.1': @@ -152,8 +152,8 @@ packages: resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/plugin-kit@0.7.1': - resolution: {integrity: sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==} + '@eslint/plugin-kit@0.7.2': + resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} '@humanfs/core@0.19.2': @@ -179,8 +179,8 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@napi-rs/wasm-runtime@1.1.4': - resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} peerDependencies: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 @@ -328,8 +328,8 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@tybys/wasm-util@0.10.2': - resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -340,8 +340,8 @@ packages: '@types/esrecurse@4.3.1': resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -352,70 +352,70 @@ packages: '@types/pg@8.20.0': resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} - '@typescript-eslint/eslint-plugin@8.59.2': - resolution: {integrity: sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==} + '@typescript-eslint/eslint-plugin@8.62.0': + resolution: {integrity: sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.59.2 + '@typescript-eslint/parser': ^8.62.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.59.2': - resolution: {integrity: sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==} + '@typescript-eslint/parser@8.62.0': + resolution: {integrity: sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.59.2': - resolution: {integrity: sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==} + '@typescript-eslint/project-service@8.62.0': + resolution: {integrity: sha512-wexnCqiTg7BOGtbLDftYpRWlmLq4xfoMd7BKFR6Y75sZS3QmRKLdN3yWLhmIYgqMmP/OXWpj3H8odkb5nGURCQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.59.2': - resolution: {integrity: sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==} + '@typescript-eslint/scope-manager@8.62.0': + resolution: {integrity: sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.59.2': - resolution: {integrity: sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==} + '@typescript-eslint/tsconfig-utils@8.62.0': + resolution: {integrity: sha512-y2GAdB6ykaXUvuspbYnizQc4oDDz0Tz/Yc7iWrXf9mx8vm/L/0vLHCe0tS2boG96Zy+DivnVDQ9ZUEWoHqqx1g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.59.2': - resolution: {integrity: sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==} + '@typescript-eslint/type-utils@8.62.0': + resolution: {integrity: sha512-+g5O3j0w2ldzC86Pv6fvbO/xhAonbJFIdf/MKQ1d30gndlsVzUOE83ldfSE15Qrl9fhFjK6AovHs5Wpp6vx86w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.59.2': - resolution: {integrity: sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==} + '@typescript-eslint/types@8.62.0': + resolution: {integrity: sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.59.2': - resolution: {integrity: sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==} + '@typescript-eslint/typescript-estree@8.62.0': + resolution: {integrity: sha512-+hVbNxtW64pIcZWDPGbyaKF7vp2IBTVY5ma1blwwksrjdsbdqqEKvJWMGbBofei4F6Dovx1M0RJgoFeNu2279A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.59.2': - resolution: {integrity: sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==} + '@typescript-eslint/utils@8.62.0': + resolution: {integrity: sha512-82r66fi9zYwZ+mTq3vKgwjbZ1PVk/DJzrXFLpG6RnBbdvH8TEGVHIs9H4d2drhkOzf0syZuD/OZvvlu6GDbP4g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.59.2': - resolution: {integrity: sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==} + '@typescript-eslint/visitor-keys@8.62.0': + resolution: {integrity: sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@vitest/expect@4.1.5': - resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==} + '@vitest/expect@4.1.9': + resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} - '@vitest/mocker@4.1.5': - resolution: {integrity: sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==} + '@vitest/mocker@4.1.9': + resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -425,28 +425,28 @@ packages: vite: optional: true - '@vitest/pretty-format@4.1.5': - resolution: {integrity: sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==} + '@vitest/pretty-format@4.1.9': + resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} - '@vitest/runner@4.1.5': - resolution: {integrity: sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==} + '@vitest/runner@4.1.9': + resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} - '@vitest/snapshot@4.1.5': - resolution: {integrity: sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==} + '@vitest/snapshot@4.1.9': + resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} - '@vitest/spy@4.1.5': - resolution: {integrity: sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==} + '@vitest/spy@4.1.9': + resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} - '@vitest/utils@4.1.5': - resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==} + '@vitest/utils@4.1.9': + resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn@8.16.0: - resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} engines: {node: '>=0.4.0'} hasBin: true @@ -461,8 +461,8 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - brace-expansion@5.0.5: - resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + brace-expansion@5.0.7: + resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} engines: {node: 18 || 20 || >=22} c12@3.1.0: @@ -538,8 +538,8 @@ packages: resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} engines: {node: '>=14'} - es-module-lexer@2.1.0: - resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + es-module-lexer@2.2.0: + resolution: {integrity: sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==} escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} @@ -563,8 +563,8 @@ packages: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@10.3.0: - resolution: {integrity: sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==} + eslint@10.5.0: + resolution: {integrity: sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} hasBin: true peerDependencies: @@ -596,8 +596,8 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} - expect-type@1.3.0: - resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} exsolve@1.0.8: @@ -780,8 +780,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.12: - resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -796,8 +796,9 @@ packages: engines: {node: '>=18'} hasBin: true - obug@2.1.1: - resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} ohash@2.0.11: resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} @@ -828,30 +829,33 @@ packages: perfect-debounce@1.0.0: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} - pg-cloudflare@1.3.0: - resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} + pg-cloudflare@1.4.0: + resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} - pg-connection-string@2.12.0: - resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==} + pg-connection-string@2.14.0: + resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==} pg-int8@1.0.1: resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} engines: {node: '>=4.0.0'} - pg-pool@3.13.0: - resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==} + pg-pool@3.14.0: + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} peerDependencies: pg: '>=8.0' pg-protocol@1.13.0: resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==} + pg-protocol@1.15.0: + resolution: {integrity: sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==} + pg-types@2.2.0: resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} engines: {node: '>=4'} - pg@8.20.0: - resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==} + pg@8.22.0: + resolution: {integrity: sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==} engines: {node: '>= 16.0.0'} peerDependencies: pg-native: '>=3.0.1' @@ -872,8 +876,8 @@ packages: pkg-types@2.3.1: resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} - postcss@8.5.14: - resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} + postcss@8.5.16: + resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} engines: {node: ^10 || ^12 || >=14} postgres-array@2.0.0: @@ -896,8 +900,8 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - prettier@3.8.3: - resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} + prettier@3.8.4: + resolution: {integrity: sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==} engines: {node: '>=14'} hasBin: true @@ -930,8 +934,8 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} hasBin: true @@ -963,12 +967,12 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - tinyexec@1.1.2: - resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} engines: {node: '>=18'} - tinyglobby@0.2.16: - resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} tinyrainbow@3.1.0: @@ -988,8 +992,8 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - typescript-eslint@8.59.2: - resolution: {integrity: sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ==} + typescript-eslint@8.62.0: + resolution: {integrity: sha512-8QxXi+ZACKX0kaqO4gY8kn0RSD9gFfaHDWwjqtEN48aWCBkX4MJaufWN+c3BzlrXLOxfywDL8CaoqUwcRq4j4Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -1049,20 +1053,20 @@ packages: yaml: optional: true - vitest@4.1.5: - resolution: {integrity: sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==} + vitest@4.1.9: + resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.5 - '@vitest/browser-preview': 4.1.5 - '@vitest/browser-webdriverio': 4.1.5 - '@vitest/coverage-istanbul': 4.1.5 - '@vitest/coverage-v8': 4.1.5 - '@vitest/ui': 4.1.5 + '@vitest/browser-playwright': 4.1.9 + '@vitest/browser-preview': 4.1.9 + '@vitest/browser-webdriverio': 4.1.9 + '@vitest/coverage-istanbul': 4.1.9 + '@vitest/coverage-v8': 4.1.9 + '@vitest/ui': 4.1.9 happy-dom: '*' jsdom: '*' vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -1130,9 +1134,9 @@ snapshots: tslib: 2.8.1 optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@10.3.0(jiti@2.7.0))': + '@eslint-community/eslint-utils@4.9.1(eslint@10.5.0(jiti@2.7.0))': dependencies: - eslint: 10.3.0(jiti@2.7.0) + eslint: 10.5.0(jiti@2.7.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -1145,7 +1149,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@eslint/config-helpers@0.5.5': + '@eslint/config-helpers@0.6.0': dependencies: '@eslint/core': 1.2.1 @@ -1153,13 +1157,13 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/js@10.0.1(eslint@10.3.0(jiti@2.7.0))': + '@eslint/js@10.0.1(eslint@10.5.0(jiti@2.7.0))': optionalDependencies: - eslint: 10.3.0(jiti@2.7.0) + eslint: 10.5.0(jiti@2.7.0) '@eslint/object-schema@3.0.5': {} - '@eslint/plugin-kit@0.7.1': + '@eslint/plugin-kit@0.7.2': dependencies: '@eslint/core': 1.2.1 levn: 0.4.1 @@ -1182,11 +1186,11 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} - '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 - '@tybys/wasm-util': 0.10.2 + '@tybys/wasm-util': 0.10.3 optional: true '@oxc-project/types@0.127.0': {} @@ -1275,7 +1279,7 @@ snapshots: dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': @@ -1288,7 +1292,7 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@tybys/wasm-util@0.10.2': + '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 optional: true @@ -1302,7 +1306,7 @@ snapshots: '@types/esrecurse@4.3.1': {} - '@types/estree@1.0.8': {} + '@types/estree@1.0.9': {} '@types/json-schema@7.0.15': {} @@ -1316,15 +1320,15 @@ snapshots: pg-protocol: 1.13.0 pg-types: 2.2.0 - '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.3.0(jiti@2.7.0))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.62.0(@typescript-eslint/parser@8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.59.2 - '@typescript-eslint/type-utils': 8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/utils': 8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.59.2 - eslint: 10.3.0(jiti@2.7.0) + '@typescript-eslint/parser': 8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.62.0 + '@typescript-eslint/type-utils': 8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/utils': 8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.62.0 + eslint: 10.5.0(jiti@2.7.0) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@5.9.3) @@ -1332,127 +1336,127 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@5.9.3)': + '@typescript-eslint/parser@8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.59.2 - '@typescript-eslint/types': 8.59.2 - '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.59.2 + '@typescript-eslint/scope-manager': 8.62.0 + '@typescript-eslint/types': 8.62.0 + '@typescript-eslint/typescript-estree': 8.62.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.62.0 debug: 4.4.3 - eslint: 10.3.0(jiti@2.7.0) + eslint: 10.5.0(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.59.2(typescript@5.9.3)': + '@typescript-eslint/project-service@8.62.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@5.9.3) - '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/tsconfig-utils': 8.62.0(typescript@5.9.3) + '@typescript-eslint/types': 8.62.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.59.2': + '@typescript-eslint/scope-manager@8.62.0': dependencies: - '@typescript-eslint/types': 8.59.2 - '@typescript-eslint/visitor-keys': 8.59.2 + '@typescript-eslint/types': 8.62.0 + '@typescript-eslint/visitor-keys': 8.62.0 - '@typescript-eslint/tsconfig-utils@8.59.2(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.62.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.59.2 - '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) - '@typescript-eslint/utils': 8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/types': 8.62.0 + '@typescript-eslint/typescript-estree': 8.62.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3) debug: 4.4.3 - eslint: 10.3.0(jiti@2.7.0) + eslint: 10.5.0(jiti@2.7.0) ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.59.2': {} + '@typescript-eslint/types@8.62.0': {} - '@typescript-eslint/typescript-estree@8.59.2(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.62.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.59.2(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@5.9.3) - '@typescript-eslint/types': 8.59.2 - '@typescript-eslint/visitor-keys': 8.59.2 + '@typescript-eslint/project-service': 8.62.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.62.0(typescript@5.9.3) + '@typescript-eslint/types': 8.62.0 + '@typescript-eslint/visitor-keys': 8.62.0 debug: 4.4.3 minimatch: 10.2.5 - semver: 7.7.4 - tinyglobby: 0.2.16 + semver: 7.8.5 + tinyglobby: 0.2.17 ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@5.9.3)': + '@typescript-eslint/utils@8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.3.0(jiti@2.7.0)) - '@typescript-eslint/scope-manager': 8.59.2 - '@typescript-eslint/types': 8.59.2 - '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) - eslint: 10.3.0(jiti@2.7.0) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.62.0 + '@typescript-eslint/types': 8.62.0 + '@typescript-eslint/typescript-estree': 8.62.0(typescript@5.9.3) + eslint: 10.5.0(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.59.2': + '@typescript-eslint/visitor-keys@8.62.0': dependencies: - '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/types': 8.62.0 eslint-visitor-keys: 5.0.1 - '@vitest/expect@4.1.5': + '@vitest/expect@4.1.9': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.5 - '@vitest/utils': 4.1.5 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.5(vite@8.0.10(@types/node@25.6.0)(jiti@2.7.0))': + '@vitest/mocker@4.1.9(vite@8.0.10(@types/node@25.6.0)(jiti@2.7.0))': dependencies: - '@vitest/spy': 4.1.5 + '@vitest/spy': 4.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: vite: 8.0.10(@types/node@25.6.0)(jiti@2.7.0) - '@vitest/pretty-format@4.1.5': + '@vitest/pretty-format@4.1.9': dependencies: tinyrainbow: 3.1.0 - '@vitest/runner@4.1.5': + '@vitest/runner@4.1.9': dependencies: - '@vitest/utils': 4.1.5 + '@vitest/utils': 4.1.9 pathe: 2.0.3 - '@vitest/snapshot@4.1.5': + '@vitest/snapshot@4.1.9': dependencies: - '@vitest/pretty-format': 4.1.5 - '@vitest/utils': 4.1.5 + '@vitest/pretty-format': 4.1.9 + '@vitest/utils': 4.1.9 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.1.5': {} + '@vitest/spy@4.1.9': {} - '@vitest/utils@4.1.5': + '@vitest/utils@4.1.9': dependencies: - '@vitest/pretty-format': 4.1.5 + '@vitest/pretty-format': 4.1.9 convert-source-map: 2.0.0 tinyrainbow: 3.1.0 - acorn-jsx@5.3.2(acorn@8.16.0): + acorn-jsx@5.3.2(acorn@8.17.0): dependencies: - acorn: 8.16.0 + acorn: 8.17.0 - acorn@8.16.0: {} + acorn@8.17.0: {} ajv@6.15.0: dependencies: @@ -1465,7 +1469,7 @@ snapshots: balanced-match@4.0.4: {} - brace-expansion@5.0.5: + brace-expansion@5.0.7: dependencies: balanced-match: 4.0.4 @@ -1531,18 +1535,18 @@ snapshots: empathic@2.0.0: {} - es-module-lexer@2.1.0: {} + es-module-lexer@2.2.0: {} escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.8(eslint@10.3.0(jiti@2.7.0)): + eslint-config-prettier@10.1.8(eslint@10.5.0(jiti@2.7.0)): dependencies: - eslint: 10.3.0(jiti@2.7.0) + eslint: 10.5.0(jiti@2.7.0) eslint-scope@9.1.2: dependencies: '@types/esrecurse': 4.3.1 - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 esrecurse: 4.3.0 estraverse: 5.3.0 @@ -1550,18 +1554,18 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.3.0(jiti@2.7.0): + eslint@10.5.0(jiti@2.7.0): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.3.0(jiti@2.7.0)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.7.0)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.23.5 - '@eslint/config-helpers': 0.5.5 + '@eslint/config-helpers': 0.6.0 '@eslint/core': 1.2.1 - '@eslint/plugin-kit': 0.7.1 + '@eslint/plugin-kit': 0.7.2 '@humanfs/node': 0.16.8 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 ajv: 6.15.0 cross-spawn: 7.0.6 debug: 4.4.3 @@ -1589,8 +1593,8 @@ snapshots: espree@11.2.0: dependencies: - acorn: 8.16.0 - acorn-jsx: 5.3.2(acorn@8.16.0) + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) eslint-visitor-keys: 5.0.1 esquery@1.7.0: @@ -1605,11 +1609,11 @@ snapshots: estree-walker@3.0.3: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 esutils@2.0.3: {} - expect-type@1.3.0: {} + expect-type@1.4.0: {} exsolve@1.0.8: {} @@ -1749,11 +1753,11 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.5 + brace-expansion: 5.0.7 ms@2.1.3: {} - nanoid@3.3.12: {} + nanoid@3.3.15: {} natural-compare@1.4.0: {} @@ -1763,9 +1767,9 @@ snapshots: dependencies: citty: 0.2.2 pathe: 2.0.3 - tinyexec: 1.1.2 + tinyexec: 1.2.4 - obug@2.1.1: {} + obug@2.1.3: {} ohash@2.0.11: {} @@ -1794,19 +1798,21 @@ snapshots: perfect-debounce@1.0.0: {} - pg-cloudflare@1.3.0: + pg-cloudflare@1.4.0: optional: true - pg-connection-string@2.12.0: {} + pg-connection-string@2.14.0: {} pg-int8@1.0.1: {} - pg-pool@3.13.0(pg@8.20.0): + pg-pool@3.14.0(pg@8.22.0): dependencies: - pg: 8.20.0 + pg: 8.22.0 pg-protocol@1.13.0: {} + pg-protocol@1.15.0: {} + pg-types@2.2.0: dependencies: pg-int8: 1.0.1 @@ -1815,15 +1821,15 @@ snapshots: postgres-date: 1.0.7 postgres-interval: 1.2.0 - pg@8.20.0: + pg@8.22.0: dependencies: - pg-connection-string: 2.12.0 - pg-pool: 3.13.0(pg@8.20.0) - pg-protocol: 1.13.0 + pg-connection-string: 2.14.0 + pg-pool: 3.14.0(pg@8.22.0) + pg-protocol: 1.15.0 pg-types: 2.2.0 pgpass: 1.0.5 optionalDependencies: - pg-cloudflare: 1.3.0 + pg-cloudflare: 1.4.0 pgpass@1.0.5: dependencies: @@ -1839,9 +1845,9 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 - postcss@8.5.14: + postcss@8.5.16: dependencies: - nanoid: 3.3.12 + nanoid: 3.3.15 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -1857,7 +1863,7 @@ snapshots: prelude-ls@1.2.1: {} - prettier@3.8.3: {} + prettier@3.8.4: {} prisma@6.19.3(typescript@5.9.3): dependencies: @@ -1900,7 +1906,7 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17 '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17 - semver@7.7.4: {} + semver@7.8.5: {} shebang-command@2.0.0: dependencies: @@ -1920,9 +1926,9 @@ snapshots: tinybench@2.9.0: {} - tinyexec@1.1.2: {} + tinyexec@1.2.4: {} - tinyglobby@0.2.16: + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 @@ -1940,13 +1946,13 @@ snapshots: dependencies: prelude-ls: 1.2.1 - typescript-eslint@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@5.9.3): + typescript-eslint@8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.3.0(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/parser': 8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) - '@typescript-eslint/utils': 8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@5.9.3) - eslint: 10.3.0(jiti@2.7.0) + '@typescript-eslint/eslint-plugin': 8.62.0(@typescript-eslint/parser@8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.62.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3) + eslint: 10.5.0(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -1963,33 +1969,33 @@ snapshots: dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 - postcss: 8.5.14 + postcss: 8.5.16 rolldown: 1.0.0-rc.17 - tinyglobby: 0.2.16 + tinyglobby: 0.2.17 optionalDependencies: '@types/node': 25.6.0 fsevents: 2.3.3 jiti: 2.7.0 - vitest@4.1.5(@types/node@25.6.0)(vite@8.0.10(@types/node@25.6.0)(jiti@2.7.0)): - dependencies: - '@vitest/expect': 4.1.5 - '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.0)(jiti@2.7.0)) - '@vitest/pretty-format': 4.1.5 - '@vitest/runner': 4.1.5 - '@vitest/snapshot': 4.1.5 - '@vitest/spy': 4.1.5 - '@vitest/utils': 4.1.5 - es-module-lexer: 2.1.0 - expect-type: 1.3.0 + vitest@4.1.9(@types/node@25.6.0)(vite@8.0.10(@types/node@25.6.0)(jiti@2.7.0)): + dependencies: + '@vitest/expect': 4.1.9 + '@vitest/mocker': 4.1.9(vite@8.0.10(@types/node@25.6.0)(jiti@2.7.0)) + '@vitest/pretty-format': 4.1.9 + '@vitest/runner': 4.1.9 + '@vitest/snapshot': 4.1.9 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + es-module-lexer: 2.2.0 + expect-type: 1.4.0 magic-string: 0.30.21 - obug: 2.1.1 + obug: 2.1.3 pathe: 2.0.3 picomatch: 4.0.4 std-env: 4.1.0 tinybench: 2.9.0 - tinyexec: 1.1.2 - tinyglobby: 0.2.16 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 tinyrainbow: 3.1.0 vite: 8.0.10(@types/node@25.6.0)(jiti@2.7.0) why-is-node-running: 2.3.0 From 33c30c671423be0394f48ea61bfcc5035dd1473d Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sat, 1 Aug 2026 10:26:15 -0500 Subject: [PATCH 07/43] Group Dependabot updates by dependency type (#24) --- js/.github/dependabot.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/js/.github/dependabot.yml b/js/.github/dependabot.yml index 5c9bc9a26..acbbec358 100644 --- a/js/.github/dependabot.yml +++ b/js/.github/dependabot.yml @@ -5,10 +5,14 @@ updates: directories: - "**/*" groups: - npm-dependencies: - update-types: - - "minor" - - "patch" + development-dependencies: + dependency-type: "development" + patterns: + - "*" + production-dependencies: + dependency-type: "production" + patterns: + - "*" open-pull-requests-limit: 10 package-ecosystem: "npm" schedule: From 81f52003b3276ab08739d7b13b0502be0e6dcf74 Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sat, 1 Aug 2026 12:51:35 -0500 Subject: [PATCH 08/43] fix Dependabot pnpm workspace updates (#27) --- js/.github/dependabot.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/js/.github/dependabot.yml b/js/.github/dependabot.yml index acbbec358..ec96a92f3 100644 --- a/js/.github/dependabot.yml +++ b/js/.github/dependabot.yml @@ -2,8 +2,7 @@ version: 2 updates: - cooldown: default-days: 7 - directories: - - "**/*" + directory: "/" groups: development-dependencies: dependency-type: "development" From 9e74cdba6c2c2004ac44ccda58f3ded0c7bd854c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:25:03 -0500 Subject: [PATCH 09/43] Bump five development dependencies (#28) --- js/driver/pg/src/driver.ts | 14 +- js/driver/prisma/src/driver.ts | 14 +- js/package.json | 10 +- js/pnpm-lock.yaml | 451 +++++++++++++++++---------------- 4 files changed, 246 insertions(+), 243 deletions(-) diff --git a/js/driver/pg/src/driver.ts b/js/driver/pg/src/driver.ts index 30c924b0b..0ed2d6c5c 100644 --- a/js/driver/pg/src/driver.ts +++ b/js/driver/pg/src/driver.ts @@ -108,14 +108,12 @@ export class PgDriver implements Driver { attemptedBy: (row.attempted_by as string[]) ?? null, createdAt: row.created_at as Date, errors: row.errors - ? (row.errors as Record[]).map( - (e): AttemptError => ({ - at: new Date(e.at as string), - attempt: e.attempt as number, - error: e.error as string, - trace: e.trace as string, - }) - ) + ? (row.errors as Record[]).map((e): AttemptError => ({ + at: new Date(e.at as string), + attempt: e.attempt as number, + error: e.error as string, + trace: e.trace as string, + })) : null, finalizedAt: (row.finalized_at as Date) ?? null, kind: row.kind as string, diff --git a/js/driver/prisma/src/driver.ts b/js/driver/prisma/src/driver.ts index 6fec1864b..565e4033e 100644 --- a/js/driver/prisma/src/driver.ts +++ b/js/driver/prisma/src/driver.ts @@ -117,14 +117,12 @@ export class PrismaDriver implements Driver { attemptedBy: (row.attempted_by as string[]) ?? null, createdAt: row.created_at as Date, errors: row.errors - ? (row.errors as Record[]).map( - (e): AttemptError => ({ - at: new Date(e.at as string), - attempt: e.attempt as number, - error: e.error as string, - trace: e.trace as string, - }) - ) + ? (row.errors as Record[]).map((e): AttemptError => ({ + at: new Date(e.at as string), + attempt: e.attempt as number, + error: e.error as string, + trace: e.trace as string, + })) : null, finalizedAt: (row.finalized_at as Date) ?? null, kind: row.kind as string, diff --git a/js/package.json b/js/package.json index 3387671fe..25b837a36 100644 --- a/js/package.json +++ b/js/package.json @@ -42,15 +42,15 @@ "homepage": "https://github.com/riverqueue/riverqueue-js#readme", "devDependencies": { "@eslint/js": "^10.0.1", - "@types/node": "^25.6.0", + "@types/node": "^26.1.1", "@types/pg": "^8.20.0", - "eslint": "^10.5.0", + "eslint": "^10.8.0", "eslint-config-prettier": "^10.1.8", "pg": "^8.22.0", - "prettier": "^3.8.4", + "prettier": "^3.9.6", "typescript": "^5.8.0", - "typescript-eslint": "^8.62.0", - "vitest": "^4.1.9" + "typescript-eslint": "^8.65.0", + "vitest": "^4.1.10" }, "packageManager": "pnpm@10.22.0", "keywords": [ diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml index 0f9e8c4a9..5f77d7284 100644 --- a/js/pnpm-lock.yaml +++ b/js/pnpm-lock.yaml @@ -10,34 +10,34 @@ importers: devDependencies: '@eslint/js': specifier: ^10.0.1 - version: 10.0.1(eslint@10.5.0(jiti@2.7.0)) + version: 10.0.1(eslint@10.8.0(jiti@2.7.0)) '@types/node': - specifier: ^25.6.0 - version: 25.6.0 + specifier: ^26.1.1 + version: 26.1.1 '@types/pg': specifier: ^8.20.0 version: 8.20.0 eslint: - specifier: ^10.5.0 - version: 10.5.0(jiti@2.7.0) + specifier: ^10.8.0 + version: 10.8.0(jiti@2.7.0) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@10.5.0(jiti@2.7.0)) + version: 10.1.8(eslint@10.8.0(jiti@2.7.0)) pg: specifier: ^8.22.0 version: 8.22.0 prettier: - specifier: ^3.8.4 - version: 3.8.4 + specifier: ^3.9.6 + version: 3.9.6 typescript: specifier: ^5.8.0 version: 5.9.3 typescript-eslint: - specifier: ^8.62.0 - version: 8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3) + specifier: ^8.65.0 + version: 8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3) vitest: - specifier: ^4.1.9 - version: 4.1.9(@types/node@25.6.0)(vite@8.0.10(@types/node@25.6.0)(jiti@2.7.0)) + specifier: ^4.1.10 + version: 4.1.10(@types/node@26.1.1)(vite@8.0.10(@types/node@26.1.1)(jiti@2.7.0)) driver/pg: dependencies: @@ -131,8 +131,8 @@ packages: resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/config-helpers@0.6.0': - resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} + '@eslint/config-helpers@0.7.0': + resolution: {integrity: sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} '@eslint/core@1.2.1': @@ -179,11 +179,12 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@napi-rs/wasm-runtime@1.1.6': - resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + '@napi-rs/wasm-runtime@1.2.2': + resolution: {integrity: sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} peerDependencies: - '@emnapi/core': ^1.7.1 - '@emnapi/runtime': ^1.7.1 + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.3 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.3 '@oxc-project/types@0.127.0': resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} @@ -346,76 +347,76 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/node@25.6.0': - resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} + '@types/node@26.1.1': + resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} '@types/pg@8.20.0': resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} - '@typescript-eslint/eslint-plugin@8.62.0': - resolution: {integrity: sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==} + '@typescript-eslint/eslint-plugin@8.65.0': + resolution: {integrity: sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.62.0 + '@typescript-eslint/parser': ^8.65.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.62.0': - resolution: {integrity: sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==} + '@typescript-eslint/parser@8.65.0': + resolution: {integrity: sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.62.0': - resolution: {integrity: sha512-wexnCqiTg7BOGtbLDftYpRWlmLq4xfoMd7BKFR6Y75sZS3QmRKLdN3yWLhmIYgqMmP/OXWpj3H8odkb5nGURCQ==} + '@typescript-eslint/project-service@8.65.0': + resolution: {integrity: sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.62.0': - resolution: {integrity: sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA==} + '@typescript-eslint/scope-manager@8.65.0': + resolution: {integrity: sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.62.0': - resolution: {integrity: sha512-y2GAdB6ykaXUvuspbYnizQc4oDDz0Tz/Yc7iWrXf9mx8vm/L/0vLHCe0tS2boG96Zy+DivnVDQ9ZUEWoHqqx1g==} + '@typescript-eslint/tsconfig-utils@8.65.0': + resolution: {integrity: sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.62.0': - resolution: {integrity: sha512-+g5O3j0w2ldzC86Pv6fvbO/xhAonbJFIdf/MKQ1d30gndlsVzUOE83ldfSE15Qrl9fhFjK6AovHs5Wpp6vx86w==} + '@typescript-eslint/type-utils@8.65.0': + resolution: {integrity: sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.62.0': - resolution: {integrity: sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==} + '@typescript-eslint/types@8.65.0': + resolution: {integrity: sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.62.0': - resolution: {integrity: sha512-+hVbNxtW64pIcZWDPGbyaKF7vp2IBTVY5ma1blwwksrjdsbdqqEKvJWMGbBofei4F6Dovx1M0RJgoFeNu2279A==} + '@typescript-eslint/typescript-estree@8.65.0': + resolution: {integrity: sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.62.0': - resolution: {integrity: sha512-82r66fi9zYwZ+mTq3vKgwjbZ1PVk/DJzrXFLpG6RnBbdvH8TEGVHIs9H4d2drhkOzf0syZuD/OZvvlu6GDbP4g==} + '@typescript-eslint/utils@8.65.0': + resolution: {integrity: sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.62.0': - resolution: {integrity: sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ==} + '@typescript-eslint/visitor-keys@8.65.0': + resolution: {integrity: sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@vitest/expect@4.1.9': - resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} - '@vitest/mocker@4.1.9': - resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -425,20 +426,20 @@ packages: vite: optional: true - '@vitest/pretty-format@4.1.9': - resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} - '@vitest/runner@4.1.9': - resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} - '@vitest/snapshot@4.1.9': - resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} - '@vitest/spy@4.1.9': - resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} - '@vitest/utils@4.1.9': - resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} @@ -461,9 +462,9 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - brace-expansion@5.0.7: - resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} - engines: {node: 18 || 20 || >=22} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} c12@3.1.0: resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==} @@ -563,8 +564,8 @@ packages: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@10.5.0: - resolution: {integrity: sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==} + eslint@10.8.0: + resolution: {integrity: sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} hasBin: true peerDependencies: @@ -696,74 +697,74 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - lightningcss-android-arm64@1.32.0: - resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [android] - lightningcss-darwin-arm64@1.32.0: - resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] - lightningcss-darwin-x64@1.32.0: - resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] - lightningcss-freebsd-x64@1.32.0: - resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] - lightningcss-linux-arm-gnueabihf@1.32.0: - resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] - lightningcss-linux-arm64-gnu@1.32.0: - resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - lightningcss-linux-arm64-musl@1.32.0: - resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - lightningcss-linux-x64-gnu@1.32.0: - resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - lightningcss-linux-x64-musl@1.32.0: - resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - lightningcss-win32-arm64-msvc@1.32.0: - resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] - lightningcss-win32-x64-msvc@1.32.0: - resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] - lightningcss@1.32.0: - resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} locate-path@6.0.0: @@ -780,8 +781,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.15: - resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -873,11 +874,15 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + pkg-types@2.3.1: resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} - postcss@8.5.16: - resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} + postcss@8.5.25: + resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} engines: {node: ^10 || ^12 || >=14} postgres-array@2.0.0: @@ -900,8 +905,8 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - prettier@3.8.4: - resolution: {integrity: sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==} + prettier@3.9.6: + resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} engines: {node: '>=14'} hasBin: true @@ -992,8 +997,8 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - typescript-eslint@8.62.0: - resolution: {integrity: sha512-8QxXi+ZACKX0kaqO4gY8kn0RSD9gFfaHDWwjqtEN48aWCBkX4MJaufWN+c3BzlrXLOxfywDL8CaoqUwcRq4j4Q==} + typescript-eslint@8.65.0: + resolution: {integrity: sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -1004,8 +1009,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - undici-types@7.19.2: - resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -1053,20 +1058,20 @@ packages: yaml: optional: true - vitest@4.1.9: - resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==} + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.9 - '@vitest/browser-preview': 4.1.9 - '@vitest/browser-webdriverio': 4.1.9 - '@vitest/coverage-istanbul': 4.1.9 - '@vitest/coverage-v8': 4.1.9 - '@vitest/ui': 4.1.9 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 happy-dom: '*' jsdom: '*' vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -1134,9 +1139,9 @@ snapshots: tslib: 2.8.1 optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@10.5.0(jiti@2.7.0))': + '@eslint-community/eslint-utils@4.9.1(eslint@10.8.0(jiti@2.7.0))': dependencies: - eslint: 10.5.0(jiti@2.7.0) + eslint: 10.8.0(jiti@2.7.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -1149,7 +1154,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@eslint/config-helpers@0.6.0': + '@eslint/config-helpers@0.7.0': dependencies: '@eslint/core': 1.2.1 @@ -1157,9 +1162,9 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/js@10.0.1(eslint@10.5.0(jiti@2.7.0))': + '@eslint/js@10.0.1(eslint@10.8.0(jiti@2.7.0))': optionalDependencies: - eslint: 10.5.0(jiti@2.7.0) + eslint: 10.8.0(jiti@2.7.0) '@eslint/object-schema@3.0.5': {} @@ -1186,7 +1191,7 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 @@ -1279,7 +1284,7 @@ snapshots: dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': @@ -1310,25 +1315,25 @@ snapshots: '@types/json-schema@7.0.15': {} - '@types/node@25.6.0': + '@types/node@26.1.1': dependencies: - undici-types: 7.19.2 + undici-types: 8.3.0 '@types/pg@8.20.0': dependencies: - '@types/node': 25.6.0 + '@types/node': 26.1.1 pg-protocol: 1.13.0 pg-types: 2.2.0 - '@typescript-eslint/eslint-plugin@8.62.0(@typescript-eslint/parser@8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.62.0 - '@typescript-eslint/type-utils': 8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/utils': 8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.62.0 - eslint: 10.5.0(jiti@2.7.0) + '@typescript-eslint/parser': 8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.65.0 + '@typescript-eslint/type-utils': 8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/utils': 8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.65.0 + eslint: 10.8.0(jiti@2.7.0) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@5.9.3) @@ -1336,56 +1341,56 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3)': + '@typescript-eslint/parser@8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.62.0 - '@typescript-eslint/types': 8.62.0 - '@typescript-eslint/typescript-estree': 8.62.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.62.0 + '@typescript-eslint/scope-manager': 8.65.0 + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.65.0 debug: 4.4.3 - eslint: 10.5.0(jiti@2.7.0) + eslint: 10.8.0(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.62.0(typescript@5.9.3)': + '@typescript-eslint/project-service@8.65.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.62.0(typescript@5.9.3) - '@typescript-eslint/types': 8.62.0 + '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@5.9.3) + '@typescript-eslint/types': 8.65.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.62.0': + '@typescript-eslint/scope-manager@8.65.0': dependencies: - '@typescript-eslint/types': 8.62.0 - '@typescript-eslint/visitor-keys': 8.62.0 + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/visitor-keys': 8.65.0 - '@typescript-eslint/tsconfig-utils@8.62.0(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.65.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.62.0 - '@typescript-eslint/typescript-estree': 8.62.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3) debug: 4.4.3 - eslint: 10.5.0(jiti@2.7.0) + eslint: 10.8.0(jiti@2.7.0) ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.62.0': {} + '@typescript-eslint/types@8.65.0': {} - '@typescript-eslint/typescript-estree@8.62.0(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.65.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.62.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.62.0(typescript@5.9.3) - '@typescript-eslint/types': 8.62.0 - '@typescript-eslint/visitor-keys': 8.62.0 + '@typescript-eslint/project-service': 8.65.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@5.9.3) + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/visitor-keys': 8.65.0 debug: 4.4.3 minimatch: 10.2.5 semver: 7.8.5 @@ -1395,60 +1400,60 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3)': + '@typescript-eslint/utils@8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.7.0)) - '@typescript-eslint/scope-manager': 8.62.0 - '@typescript-eslint/types': 8.62.0 - '@typescript-eslint/typescript-estree': 8.62.0(typescript@5.9.3) - eslint: 10.5.0(jiti@2.7.0) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.8.0(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.65.0 + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) + eslint: 10.8.0(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.62.0': + '@typescript-eslint/visitor-keys@8.65.0': dependencies: - '@typescript-eslint/types': 8.62.0 + '@typescript-eslint/types': 8.65.0 eslint-visitor-keys: 5.0.1 - '@vitest/expect@4.1.9': + '@vitest/expect@4.1.10': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.9 - '@vitest/utils': 4.1.9 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.9(vite@8.0.10(@types/node@25.6.0)(jiti@2.7.0))': + '@vitest/mocker@4.1.10(vite@8.0.10(@types/node@26.1.1)(jiti@2.7.0))': dependencies: - '@vitest/spy': 4.1.9 + '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.10(@types/node@25.6.0)(jiti@2.7.0) + vite: 8.0.10(@types/node@26.1.1)(jiti@2.7.0) - '@vitest/pretty-format@4.1.9': + '@vitest/pretty-format@4.1.10': dependencies: tinyrainbow: 3.1.0 - '@vitest/runner@4.1.9': + '@vitest/runner@4.1.10': dependencies: - '@vitest/utils': 4.1.9 + '@vitest/utils': 4.1.10 pathe: 2.0.3 - '@vitest/snapshot@4.1.9': + '@vitest/snapshot@4.1.10': dependencies: - '@vitest/pretty-format': 4.1.9 - '@vitest/utils': 4.1.9 + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.1.9': {} + '@vitest/spy@4.1.10': {} - '@vitest/utils@4.1.9': + '@vitest/utils@4.1.10': dependencies: - '@vitest/pretty-format': 4.1.9 + '@vitest/pretty-format': 4.1.10 convert-source-map: 2.0.0 tinyrainbow: 3.1.0 @@ -1469,7 +1474,7 @@ snapshots: balanced-match@4.0.4: {} - brace-expansion@5.0.7: + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -1539,9 +1544,9 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.8(eslint@10.5.0(jiti@2.7.0)): + eslint-config-prettier@10.1.8(eslint@10.8.0(jiti@2.7.0)): dependencies: - eslint: 10.5.0(jiti@2.7.0) + eslint: 10.8.0(jiti@2.7.0) eslint-scope@9.1.2: dependencies: @@ -1554,12 +1559,12 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.5.0(jiti@2.7.0): + eslint@10.8.0(jiti@2.7.0): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.7.0)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.8.0(jiti@2.7.0)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.23.5 - '@eslint/config-helpers': 0.6.0 + '@eslint/config-helpers': 0.7.0 '@eslint/core': 1.2.1 '@eslint/plugin-kit': 0.7.2 '@humanfs/node': 0.16.8 @@ -1694,54 +1699,54 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - lightningcss-android-arm64@1.32.0: + lightningcss-android-arm64@1.33.0: optional: true - lightningcss-darwin-arm64@1.32.0: + lightningcss-darwin-arm64@1.33.0: optional: true - lightningcss-darwin-x64@1.32.0: + lightningcss-darwin-x64@1.33.0: optional: true - lightningcss-freebsd-x64@1.32.0: + lightningcss-freebsd-x64@1.33.0: optional: true - lightningcss-linux-arm-gnueabihf@1.32.0: + lightningcss-linux-arm-gnueabihf@1.33.0: optional: true - lightningcss-linux-arm64-gnu@1.32.0: + lightningcss-linux-arm64-gnu@1.33.0: optional: true - lightningcss-linux-arm64-musl@1.32.0: + lightningcss-linux-arm64-musl@1.33.0: optional: true - lightningcss-linux-x64-gnu@1.32.0: + lightningcss-linux-x64-gnu@1.33.0: optional: true - lightningcss-linux-x64-musl@1.32.0: + lightningcss-linux-x64-musl@1.33.0: optional: true - lightningcss-win32-arm64-msvc@1.32.0: + lightningcss-win32-arm64-msvc@1.33.0: optional: true - lightningcss-win32-x64-msvc@1.32.0: + lightningcss-win32-x64-msvc@1.33.0: optional: true - lightningcss@1.32.0: + lightningcss@1.33.0: dependencies: detect-libc: 2.1.2 optionalDependencies: - lightningcss-android-arm64: 1.32.0 - lightningcss-darwin-arm64: 1.32.0 - lightningcss-darwin-x64: 1.32.0 - lightningcss-freebsd-x64: 1.32.0 - lightningcss-linux-arm-gnueabihf: 1.32.0 - lightningcss-linux-arm64-gnu: 1.32.0 - lightningcss-linux-arm64-musl: 1.32.0 - lightningcss-linux-x64-gnu: 1.32.0 - lightningcss-linux-x64-musl: 1.32.0 - lightningcss-win32-arm64-msvc: 1.32.0 - lightningcss-win32-x64-msvc: 1.32.0 + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 locate-path@6.0.0: dependencies: @@ -1753,11 +1758,11 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.7 + brace-expansion: 5.0.9 ms@2.1.3: {} - nanoid@3.3.15: {} + nanoid@3.3.16: {} natural-compare@1.4.0: {} @@ -1839,15 +1844,17 @@ snapshots: picomatch@4.0.4: {} + picomatch@4.0.5: {} + pkg-types@2.3.1: dependencies: confbox: 0.2.4 exsolve: 1.0.8 pathe: 2.0.3 - postcss@8.5.16: + postcss@8.5.25: dependencies: - nanoid: 3.3.15 + nanoid: 3.3.16 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -1863,7 +1870,7 @@ snapshots: prelude-ls@1.2.1: {} - prettier@3.8.4: {} + prettier@3.9.6: {} prisma@6.19.3(typescript@5.9.3): dependencies: @@ -1946,46 +1953,46 @@ snapshots: dependencies: prelude-ls: 1.2.1 - typescript-eslint@8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3): + typescript-eslint@8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.62.0(@typescript-eslint/parser@8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/parser': 8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.62.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@5.9.3) - eslint: 10.5.0(jiti@2.7.0) + '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3) + eslint: 10.8.0(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color typescript@5.9.3: {} - undici-types@7.19.2: {} + undici-types@8.3.0: {} uri-js@4.4.1: dependencies: punycode: 2.3.1 - vite@8.0.10(@types/node@25.6.0)(jiti@2.7.0): + vite@8.0.10(@types/node@26.1.1)(jiti@2.7.0): dependencies: - lightningcss: 1.32.0 - picomatch: 4.0.4 - postcss: 8.5.16 + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.25 rolldown: 1.0.0-rc.17 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 25.6.0 + '@types/node': 26.1.1 fsevents: 2.3.3 jiti: 2.7.0 - vitest@4.1.9(@types/node@25.6.0)(vite@8.0.10(@types/node@25.6.0)(jiti@2.7.0)): + vitest@4.1.10(@types/node@26.1.1)(vite@8.0.10(@types/node@26.1.1)(jiti@2.7.0)): dependencies: - '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(vite@8.0.10(@types/node@25.6.0)(jiti@2.7.0)) - '@vitest/pretty-format': 4.1.9 - '@vitest/runner': 4.1.9 - '@vitest/snapshot': 4.1.9 - '@vitest/spy': 4.1.9 - '@vitest/utils': 4.1.9 + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.0.10(@types/node@26.1.1)(jiti@2.7.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 es-module-lexer: 2.2.0 expect-type: 1.4.0 magic-string: 0.30.21 @@ -1997,10 +2004,10 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.0.10(@types/node@25.6.0)(jiti@2.7.0) + vite: 8.0.10(@types/node@26.1.1)(jiti@2.7.0) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 25.6.0 + '@types/node': 26.1.1 transitivePeerDependencies: - msw From bc8e6f4168001c62e2c42877885b08babb5bc51b Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sat, 1 Aug 2026 14:15:50 -0500 Subject: [PATCH 10/43] require Vite security fixes (#30) Vitest's optional Vite peer resolves to 8.0.10, which is affected by Windows path-handling advisories. Because Vite is not a direct dependency, pnpm does not advance that peer resolution independently. Require Vite ^8.0.16 as a development dependency and regenerate the lockfile. This establishes the fixed release as the compatibility floor, lets routine updates select later Vite 8 releases, and updates the matching Rolldown native artifacts. --- js/package.json | 1 + js/pnpm-lock.yaml | 167 +++++++++++++++++++++++----------------------- 2 files changed, 86 insertions(+), 82 deletions(-) diff --git a/js/package.json b/js/package.json index 25b837a36..2e5072ecd 100644 --- a/js/package.json +++ b/js/package.json @@ -50,6 +50,7 @@ "prettier": "^3.9.6", "typescript": "^5.8.0", "typescript-eslint": "^8.65.0", + "vite": "^8.0.16", "vitest": "^4.1.10" }, "packageManager": "pnpm@10.22.0", diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml index 5f77d7284..d1c7e2409 100644 --- a/js/pnpm-lock.yaml +++ b/js/pnpm-lock.yaml @@ -35,9 +35,12 @@ importers: typescript-eslint: specifier: ^8.65.0 version: 8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3) + vite: + specifier: ^8.0.16 + version: 8.0.16(@types/node@26.1.1)(jiti@2.7.0) vitest: specifier: ^4.1.10 - version: 4.1.10(@types/node@26.1.1)(vite@8.0.10(@types/node@26.1.1)(jiti@2.7.0)) + version: 4.1.10(@types/node@26.1.1)(vite@8.0.16(@types/node@26.1.1)(jiti@2.7.0)) driver/pg: dependencies: @@ -186,8 +189,8 @@ packages: '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.3 '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.3 - '@oxc-project/types@0.127.0': - resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} + '@oxc-project/types@0.133.0': + resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} '@prisma/client-runtime-utils@7.8.0': resolution: {integrity: sha512-5NQZztQ0oY/ADFkmd9gPuweH5A1/CCY8YQPorLLO0Mu6a87mY5gsnDkzmFmIHs9NFaLnZojzgddFVN4RpKYrdw==} @@ -234,97 +237,97 @@ packages: '@prisma/get-platform@6.19.3': resolution: {integrity: sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==} - '@rolldown/binding-android-arm64@1.0.0-rc.17': - resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==} + '@rolldown/binding-android-arm64@1.0.3': + resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.0.0-rc.17': - resolution: {integrity: sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==} + '@rolldown/binding-darwin-arm64@1.0.3': + resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.0.0-rc.17': - resolution: {integrity: sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==} + '@rolldown/binding-darwin-x64@1.0.3': + resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.0.0-rc.17': - resolution: {integrity: sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==} + '@rolldown/binding-freebsd-x64@1.0.3': + resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': - resolution: {integrity: sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==} + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': - resolution: {integrity: sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==} + '@rolldown/binding-linux-arm64-gnu@1.0.3': + resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': - resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} + '@rolldown/binding-linux-arm64-musl@1.0.3': + resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': - resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': - resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} + '@rolldown/binding-linux-s390x-gnu@1.0.3': + resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': - resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} + '@rolldown/binding-linux-x64-gnu@1.0.3': + resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': - resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} + '@rolldown/binding-linux-x64-musl@1.0.3': + resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': - resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} + '@rolldown/binding-openharmony-arm64@1.0.3': + resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': - resolution: {integrity: sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==} + '@rolldown/binding-wasm32-wasi@1.0.3': + resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': - resolution: {integrity: sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==} + '@rolldown/binding-win32-arm64-msvc@1.0.3': + resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': - resolution: {integrity: sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==} + '@rolldown/binding-win32-x64-msvc@1.0.3': + resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@rolldown/pluginutils@1.0.0-rc.17': - resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==} + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -934,8 +937,8 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} - rolldown@1.0.0-rc.17: - resolution: {integrity: sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==} + rolldown@1.0.3: + resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -1015,13 +1018,13 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - vite@8.0.10: - resolution: {integrity: sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==} + vite@8.0.16: + resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.1.0 + '@vitejs/devtools': ^0.1.18 esbuild: ^0.27.0 || ^0.28.0 jiti: '>=1.21.0' less: ^4.0.0 @@ -1198,7 +1201,7 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@oxc-project/types@0.127.0': {} + '@oxc-project/types@0.133.0': {} '@prisma/client-runtime-utils@7.8.0': {} @@ -1244,56 +1247,56 @@ snapshots: dependencies: '@prisma/debug': 6.19.3 - '@rolldown/binding-android-arm64@1.0.0-rc.17': + '@rolldown/binding-android-arm64@1.0.3': optional: true - '@rolldown/binding-darwin-arm64@1.0.0-rc.17': + '@rolldown/binding-darwin-arm64@1.0.3': optional: true - '@rolldown/binding-darwin-x64@1.0.0-rc.17': + '@rolldown/binding-darwin-x64@1.0.3': optional: true - '@rolldown/binding-freebsd-x64@1.0.0-rc.17': + '@rolldown/binding-freebsd-x64@1.0.3': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': optional: true - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': + '@rolldown/binding-linux-arm64-gnu@1.0.3': optional: true - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': + '@rolldown/binding-linux-arm64-musl@1.0.3': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': + '@rolldown/binding-linux-ppc64-gnu@1.0.3': optional: true - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': + '@rolldown/binding-linux-s390x-gnu@1.0.3': optional: true - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': + '@rolldown/binding-linux-x64-gnu@1.0.3': optional: true - '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': + '@rolldown/binding-linux-x64-musl@1.0.3': optional: true - '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': + '@rolldown/binding-openharmony-arm64@1.0.3': optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': + '@rolldown/binding-wasm32-wasi@1.0.3': dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': + '@rolldown/binding-win32-arm64-msvc@1.0.3': optional: true - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': + '@rolldown/binding-win32-x64-msvc@1.0.3': optional: true - '@rolldown/pluginutils@1.0.0-rc.17': {} + '@rolldown/pluginutils@1.0.1': {} '@standard-schema/spec@1.1.0': {} @@ -1425,13 +1428,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.10(vite@8.0.10(@types/node@26.1.1)(jiti@2.7.0))': + '@vitest/mocker@4.1.10(vite@8.0.16(@types/node@26.1.1)(jiti@2.7.0))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.10(@types/node@26.1.1)(jiti@2.7.0) + vite: 8.0.16(@types/node@26.1.1)(jiti@2.7.0) '@vitest/pretty-format@4.1.10': dependencies: @@ -1892,26 +1895,26 @@ snapshots: readdirp@4.1.2: {} - rolldown@1.0.0-rc.17: + rolldown@1.0.3: dependencies: - '@oxc-project/types': 0.127.0 - '@rolldown/pluginutils': 1.0.0-rc.17 + '@oxc-project/types': 0.133.0 + '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-rc.17 - '@rolldown/binding-darwin-arm64': 1.0.0-rc.17 - '@rolldown/binding-darwin-x64': 1.0.0-rc.17 - '@rolldown/binding-freebsd-x64': 1.0.0-rc.17 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.17 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.17 - '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.17 - '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.17 - '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.17 - '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.17 - '@rolldown/binding-linux-x64-musl': 1.0.0-rc.17 - '@rolldown/binding-openharmony-arm64': 1.0.0-rc.17 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.17 - '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17 - '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17 + '@rolldown/binding-android-arm64': 1.0.3 + '@rolldown/binding-darwin-arm64': 1.0.3 + '@rolldown/binding-darwin-x64': 1.0.3 + '@rolldown/binding-freebsd-x64': 1.0.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.3 + '@rolldown/binding-linux-arm64-gnu': 1.0.3 + '@rolldown/binding-linux-arm64-musl': 1.0.3 + '@rolldown/binding-linux-ppc64-gnu': 1.0.3 + '@rolldown/binding-linux-s390x-gnu': 1.0.3 + '@rolldown/binding-linux-x64-gnu': 1.0.3 + '@rolldown/binding-linux-x64-musl': 1.0.3 + '@rolldown/binding-openharmony-arm64': 1.0.3 + '@rolldown/binding-wasm32-wasi': 1.0.3 + '@rolldown/binding-win32-arm64-msvc': 1.0.3 + '@rolldown/binding-win32-x64-msvc': 1.0.3 semver@7.8.5: {} @@ -1972,22 +1975,22 @@ snapshots: dependencies: punycode: 2.3.1 - vite@8.0.10(@types/node@26.1.1)(jiti@2.7.0): + vite@8.0.16(@types/node@26.1.1)(jiti@2.7.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 postcss: 8.5.25 - rolldown: 1.0.0-rc.17 + rolldown: 1.0.3 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 26.1.1 fsevents: 2.3.3 jiti: 2.7.0 - vitest@4.1.10(@types/node@26.1.1)(vite@8.0.10(@types/node@26.1.1)(jiti@2.7.0)): + vitest@4.1.10(@types/node@26.1.1)(vite@8.0.16(@types/node@26.1.1)(jiti@2.7.0)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.0.10(@types/node@26.1.1)(jiti@2.7.0)) + '@vitest/mocker': 4.1.10(vite@8.0.16(@types/node@26.1.1)(jiti@2.7.0)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -2004,7 +2007,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.0.10(@types/node@26.1.1)(jiti@2.7.0) + vite: 8.0.16(@types/node@26.1.1)(jiti@2.7.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 26.1.1 From 7354a179484426b588aac031f953077b25b786ae Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sat, 1 Aug 2026 14:28:53 -0500 Subject: [PATCH 11/43] Migrate Prisma example to v7 (#31) --- js/.github/workflows/ci.yaml | 2 - js/.gitignore | 1 + js/CHANGELOG.md | 4 + js/driver/prisma/package.json | 2 + js/driver/prisma/src/driver.ts | 8 +- js/examples/prisma/README.md | 10 +- js/examples/prisma/package.json | 7 +- js/examples/prisma/prisma.config.ts | 11 + js/examples/prisma/prisma/schema.prisma | 7 +- js/examples/prisma/src/index.ts | 11 +- js/pnpm-lock.yaml | 1076 ++++++++++++++++++++--- 11 files changed, 986 insertions(+), 153 deletions(-) create mode 100644 js/examples/prisma/prisma.config.ts diff --git a/js/.github/workflows/ci.yaml b/js/.github/workflows/ci.yaml index 2248c1843..5ab499ca2 100644 --- a/js/.github/workflows/ci.yaml +++ b/js/.github/workflows/ci.yaml @@ -98,8 +98,6 @@ jobs: - run: pnpm run build:all - - run: cd examples/prisma && npx prisma generate - - run: pnpm --filter='./examples/*' run build - run: pnpm --filter='./examples/*' run start diff --git a/js/.gitignore b/js/.gitignore index 62ccde41c..3a716ffdb 100644 --- a/js/.gitignore +++ b/js/.gitignore @@ -1,4 +1,5 @@ node_modules/ dist/ +examples/prisma/src/generated/prisma/ *.tsbuildinfo .DS_Store diff --git a/js/CHANGELOG.md b/js/CHANGELOG.md index 1bc163e2e..64542ba07 100644 --- a/js/CHANGELOG.md +++ b/js/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- 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). + ## [0.1.0] - 2026-06-01 ### Added diff --git a/js/driver/prisma/package.json b/js/driver/prisma/package.json index 0a87b5c4e..a40e9fe1b 100644 --- a/js/driver/prisma/package.json +++ b/js/driver/prisma/package.json @@ -33,6 +33,8 @@ "@prisma/client": ">=5.0.0" }, "devDependencies": { + "@prisma/client": "7.9.0", + "prisma": "7.9.0", "typescript": "^5.8.0" }, "keywords": [ diff --git a/js/driver/prisma/src/driver.ts b/js/driver/prisma/src/driver.ts index 565e4033e..4bc68ccfc 100644 --- a/js/driver/prisma/src/driver.ts +++ b/js/driver/prisma/src/driver.ts @@ -19,11 +19,15 @@ export interface PrismaClientLike { /** * A River driver for Prisma. * - * import { PrismaClient } from "@prisma/client"; + * import { PrismaPg } from "@prisma/adapter-pg"; * import { Client } from "riverqueue"; * import { PrismaDriver } from "@riverqueue/driver-prisma"; + * import { PrismaClient } from "./generated/prisma/client.js"; * - * const prisma = new PrismaClient(); + * const adapter = new PrismaPg({ + * connectionString: process.env.DATABASE_URL, + * }); + * const prisma = new PrismaClient({ adapter }); * const client = new Client(new PrismaDriver(prisma)); * * For transactions, pass the transaction client as the `tx` option: diff --git a/js/examples/prisma/README.md b/js/examples/prisma/README.md index 44f1976f7..575a34a46 100644 --- a/js/examples/prisma/README.md +++ b/js/examples/prisma/README.md @@ -6,7 +6,7 @@ The example defines two job types (`SortArgs` and `SendEmailArgs`) and shows sin ## Prerequisites -- Node.js >= 18 +- Node.js ^20.19, ^22.12, or >= 24 - pnpm - PostgreSQL with [River's schema](https://riverqueue.com/docs) migrated @@ -21,18 +21,18 @@ pnpm install Generate the Prisma client: ```sh -cd examples/prisma -npx prisma generate +pnpm --dir examples/prisma run generate ``` Build the River packages and the example: ```sh pnpm run build:all -cd examples/prisma -pnpm run build +pnpm --filter=riverqueue-example-prisma run build ``` +The example build regenerates the Prisma client automatically. + ## Running ```sh diff --git a/js/examples/prisma/package.json b/js/examples/prisma/package.json index d3fe8daf0..c0246e5b0 100644 --- a/js/examples/prisma/package.json +++ b/js/examples/prisma/package.json @@ -4,16 +4,19 @@ "private": true, "type": "module", "scripts": { + "generate": "prisma generate", + "prebuild": "prisma generate", "build": "tsc", "start": "node dist/index.js" }, "dependencies": { "riverqueue": "workspace:*", "@riverqueue/driver-prisma": "workspace:*", - "@prisma/client": "^6.0.0" + "@prisma/adapter-pg": "7.9.0", + "@prisma/client": "7.9.0" }, "devDependencies": { - "prisma": "^6.0.0", + "prisma": "7.9.0", "typescript": "^5.8.0" } } diff --git a/js/examples/prisma/prisma.config.ts b/js/examples/prisma/prisma.config.ts new file mode 100644 index 000000000..2c7a33ca9 --- /dev/null +++ b/js/examples/prisma/prisma.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "prisma/config"; + +const databaseUrl = + process.env.DATABASE_URL ?? "postgres://localhost:5432/river_dev"; + +export default defineConfig({ + schema: "prisma/schema.prisma", + datasource: { + url: databaseUrl, + }, +}); diff --git a/js/examples/prisma/prisma/schema.prisma b/js/examples/prisma/prisma/schema.prisma index cc10721b5..05820fd54 100644 --- a/js/examples/prisma/prisma/schema.prisma +++ b/js/examples/prisma/prisma/schema.prisma @@ -1,8 +1,11 @@ generator client { - provider = "prisma-client-js" + provider = "prisma-client" + output = "../src/generated/prisma" + moduleFormat = "esm" + generatedFileExtension = "ts" + importFileExtension = "js" } datasource db { provider = "postgresql" - url = env("DATABASE_URL") } diff --git a/js/examples/prisma/src/index.ts b/js/examples/prisma/src/index.ts index 6b59c8f1e..2c2d423bf 100644 --- a/js/examples/prisma/src/index.ts +++ b/js/examples/prisma/src/index.ts @@ -1,7 +1,8 @@ -import { PrismaClient } from "@prisma/client"; +import { PrismaPg } from "@prisma/adapter-pg"; import { Client, InsertManyParams } from "riverqueue"; import type { JobArgs, InsertOpts } from "riverqueue"; import { PrismaDriver } from "@riverqueue/driver-prisma"; +import { PrismaClient } from "./generated/prisma/client.js"; // Define a job that sorts strings. `kind` uniquely identifies the job type and // must match the worker name on the Go side. @@ -45,10 +46,10 @@ class SendEmailArgs implements JobArgs { } async function main() { - const prisma = new PrismaClient({ - datasourceUrl: - process.env.DATABASE_URL ?? "postgres://localhost:5432/river_dev", - }); + const connectionString = + process.env.DATABASE_URL ?? "postgres://localhost:5432/river_dev"; + const adapter = new PrismaPg({ connectionString }); + const prisma = new PrismaClient({ adapter }); const client = new Client(new PrismaDriver(prisma)); diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml index d1c7e2409..483bb1490 100644 --- a/js/pnpm-lock.yaml +++ b/js/pnpm-lock.yaml @@ -60,13 +60,16 @@ importers: driver/prisma: dependencies: - '@prisma/client': - specifier: '>=5.0.0' - version: 7.8.0(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3) riverqueue: specifier: workspace:* version: link:../.. devDependencies: + '@prisma/client': + specifier: 7.9.0 + version: 7.9.0(prisma@7.9.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3))(typescript@5.9.3) + prisma: + specifier: 7.9.0 + version: 7.9.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3) typescript: specifier: ^5.8.0 version: 5.9.3 @@ -92,9 +95,12 @@ importers: examples/prisma: dependencies: + '@prisma/adapter-pg': + specifier: 7.9.0 + version: 7.9.0 '@prisma/client': - specifier: ^6.0.0 - version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3) + specifier: 7.9.0 + version: 7.9.0(prisma@7.9.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3))(typescript@5.9.3) '@riverqueue/driver-prisma': specifier: workspace:* version: link:../../driver/prisma @@ -103,14 +109,28 @@ importers: version: link:../.. devDependencies: prisma: - specifier: ^6.0.0 - version: 6.19.3(typescript@5.9.3) + specifier: 7.9.0 + version: 7.9.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3) typescript: specifier: ^5.8.0 version: 5.9.3 packages: + '@electric-sql/pglite-socket@0.1.3': + resolution: {integrity: sha512-LAciWM0M1dCL8hlsxu2venbVZcdxema0BtDfpWYVqr+Y468UADw0pFWidhKw1M8sfJ8rdLT71tjMmnirf/IZRQ==} + hasBin: true + peerDependencies: + '@electric-sql/pglite': 0.4.3 + + '@electric-sql/pglite-tools@0.3.3': + resolution: {integrity: sha512-AlzLJTRJ8+UFgK8CmxIpyIpJ0+YaFw02IiOSdYrqxwPXdSyeIShz8aa9Tq+tYFXdPwcaMp/Fc80mQZ1dkOQ/wg==} + peerDependencies: + '@electric-sql/pglite': 0.4.3 + + '@electric-sql/pglite@0.4.3': + resolution: {integrity: sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ==} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -192,50 +212,142 @@ packages: '@oxc-project/types@0.133.0': resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} - '@prisma/client-runtime-utils@7.8.0': - resolution: {integrity: sha512-5NQZztQ0oY/ADFkmd9gPuweH5A1/CCY8YQPorLLO0Mu6a87mY5gsnDkzmFmIHs9NFaLnZojzgddFVN4RpKYrdw==} + '@prisma/adapter-pg@7.9.0': + resolution: {integrity: sha512-kPYuFvNTlqnaFf2UpXBBG3ycTT3PL76uSZtLFBEwDytjMMUW8ZHrsb9cSNIarzdPW5EXWmBOeOq9/MVjMtbWkA==} - '@prisma/client@6.19.3': - resolution: {integrity: sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==} - engines: {node: '>=18.18'} + '@prisma/client-runtime-utils@7.9.0': + resolution: {integrity: sha512-kMVmS4ZEy3xlkca+TfxOEm/ToVVlOS2x1Tc6/wIRf/HfczBqENtSPcKszy4ZpFNzjJ8SRKvlU5V0rrpoFw2KOg==} + + '@prisma/client@7.9.0': + resolution: {integrity: sha512-BTG/mB+WL/1sD2gWwdNc2uuVJjNNBgCDlPFdjco6jJArgbg4IAChtzVeW4debFa/NKBbsGedCjET316sjllWTQ==} + engines: {node: ^20.19 || ^22.12 || >=24.0} peerDependencies: prisma: '*' - typescript: '>=5.1.0' + typescript: '>=5.4.0' peerDependenciesMeta: prisma: optional: true typescript: optional: true - '@prisma/client@7.8.0': - resolution: {integrity: sha512-HFp3Dawv/3sU3JtlPha90IB+48lS7zHiH4LKZPjmcE8YH5P9DOXGPvo8dqOtO7MqLDd1p2hOWMcFlRT1DMblHw==} - engines: {node: ^20.19 || ^22.12 || >=24.0} + '@prisma/config@7.9.0': + resolution: {integrity: sha512-CsoK2mhl0u+N4/8V+XroQMOUNIic4isqD+E2HBG8l1yGEKo62CFDu3FHo0FdwItjl6XkW+omA1STSzeN1DAXlg==} + + '@prisma/debug@7.2.0': + resolution: {integrity: sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==} + + '@prisma/debug@7.9.0': + resolution: {integrity: sha512-i0KdVQuKUE6N9NloHs+sUNAk2c9svR3myBndQbA3BoeoArsSpwtNgTdHZL+wBtCLCcdS2OOC/PKhgTe36jkF5A==} + + '@prisma/dev@0.24.14': + resolution: {integrity: sha512-NhFO49O2JPTdzYiLHvceQn/HiwmcKF/iGV39ko3CpYsoGqS3rz3ko6gzuxFSIeHNwNJeuNcDexyyGeTO3DW80A==} + + '@prisma/driver-adapter-utils@7.9.0': + resolution: {integrity: sha512-fFXujitfMyjk3kOd1Tbs5FXBm6i2OWwEhaP5lHgkUM99jHpPEQwCWj+z/WKPFq6EDMThE1zGzSlVegtR0Pmu2w==} + + '@prisma/engines-version@7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad': + resolution: {integrity: sha512-2BsPPFksz3CQUXG6af3rVCtJKg6+JJGJTtfgu2fU8DdXhOfkBjulCq8mwybCd6ge0/jhZq2kOtLAbmUDMyI1nA==} + + '@prisma/engines@7.9.0': + resolution: {integrity: sha512-lDWJp/pgSWCLfYsupmmNo96jfsbQnH1yjia8XVM2Kh8nRZhD0bQU2jCHuy3ZTPMLR3apRD3k145ybENalAYjYw==} + + '@prisma/fetch-engine@7.9.0': + resolution: {integrity: sha512-F0XlIgjbE3EywRVR/HpCerNI/dxo40vK66tHcWpsWYwH/Jk9+FsICEzATeMsZ7bdnpZz93hkD4sAb5rKLsCCpA==} + + '@prisma/get-platform@7.2.0': + resolution: {integrity: sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==} + + '@prisma/get-platform@7.9.0': + resolution: {integrity: sha512-4awv6ATdgrHdLms0XKikCyfArn8BrUHZfqg0mtCKrI4+WJe24nmpsdwsypM9ozd03wa846AngY+zSbnngkMrXQ==} + + '@prisma/query-plan-executor@7.2.0': + resolution: {integrity: sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==} + + '@prisma/streams-local@0.1.11': + resolution: {integrity: sha512-0TcebL559MByKqTJ+SsrFIEg228iw8UCVRFckzgfRSiJqczhs+MuAgWOF9lnOIV/IVqvu+KMnFTH0eDeTQMpUg==} + engines: {bun: '>=1.2.0', node: '>=22.0.0'} + + '@prisma/studio-core@0.33.0': + resolution: {integrity: sha512-V2fX/nKEymNTrHXwfP26PGjoLStO35Ogu+ex7CFJbLrMYEcZxxZpiSNOs7px23Hk5mzLWvM5RsqG6Ka+rha+wg==} + engines: {node: ^20.19 || ^22.12 || >=24.0, pnpm: '8'} peerDependencies: - prisma: '*' - typescript: '>=5.4.0' + '@types/react': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: - prisma: - optional: true - typescript: + '@types/react': optional: true - '@prisma/config@6.19.3': - resolution: {integrity: sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==} + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true - '@prisma/debug@6.19.3': - resolution: {integrity: sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==} + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true - '@prisma/engines-version@7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7': - resolution: {integrity: sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==} + '@radix-ui/react-toggle@1.1.10': + resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true - '@prisma/engines@6.19.3': - resolution: {integrity: sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==} + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true - '@prisma/fetch-engine@6.19.3': - resolution: {integrity: sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==} + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true - '@prisma/get-platform@6.19.3': - resolution: {integrity: sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==} + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true '@rolldown/binding-android-arm64@1.0.3': resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} @@ -338,6 +450,39 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-array@3.0.3': + resolution: {integrity: sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ==} + + '@types/d3-color@3.1.0': + resolution: {integrity: sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==} + + '@types/d3-delaunay@6.0.1': + resolution: {integrity: sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==} + + '@types/d3-format@3.0.1': + resolution: {integrity: sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==} + + '@types/d3-geo@3.1.0': + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + + '@types/d3-interpolate@3.0.1': + resolution: {integrity: sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.2': + resolution: {integrity: sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==} + + '@types/d3-shape@3.1.7': + resolution: {integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==} + + '@types/d3-time-format@2.1.0': + resolution: {integrity: sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA==} + + '@types/d3-time@3.0.0': + resolution: {integrity: sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -347,15 +492,24 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/lodash@4.17.25': + resolution: {integrity: sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==} + '@types/node@26.1.1': resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} '@types/pg@8.20.0': resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + '@typescript-eslint/eslint-plugin@8.65.0': resolution: {integrity: sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -415,6 +569,41 @@ packages: resolution: {integrity: sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@visx/curve@4.0.1-alpha.0': + resolution: {integrity: sha512-jRu61Uz274pV1zyioXmboyrLutYbnKsgjj4njSGCnhdXj5GkZvZbg+ThDb6oOzoAnJOBRLz4rzPlWvNJOzuVMg==} + + '@visx/event@4.0.1-alpha.0': + resolution: {integrity: sha512-EQqCMSv/s8NbFjo+hz3FKsvvYfP+2QslsFJ/24/O5l/W+7UC6J6aAvO0ujVwrTwdYbuQ+vhxKi1xdPdKR/qj1g==} + + '@visx/grid@4.0.1-alpha.0': + resolution: {integrity: sha512-rycutGmTHO+znNdPumheWMglm7YfpffvRwUkVy5zy4WoORIuKTMkDxwnOzHG2xMxU3EE/YCd37xFV5AxA30yeg==} + peerDependencies: + react: ^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0 + + '@visx/group@4.0.1-alpha.0': + resolution: {integrity: sha512-V19l7iQ7jccBv8kao/EByuI6o4xtxzzLV9nqVI1hRvmdzTVsuLpqlwzYCZUXJaTVvUWf8s4D2SQFjGkj/Nw+0w==} + peerDependencies: + react: ^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0 + + '@visx/point@4.0.1-alpha.0': + resolution: {integrity: sha512-ijTfr/Nx09f03vIj9nyTr3z4Xth4Y75427UaogJh6dnIRLMEFHQOwNu791sbfiNj0a+ZXuaE32h0vKrFe4/8Qg==} + + '@visx/responsive@4.0.1-alpha.0': + resolution: {integrity: sha512-o+1zGywQZY0+yOx3Iw87wc4bbPJRr/HnIukTwfOz4UVyj9pB1OQNVHB7OORO1+LBHJceWpB31co/ZV9KHncKrA==} + peerDependencies: + react: ^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0 + + '@visx/scale@4.0.1-alpha.0': + resolution: {integrity: sha512-nzjeE87vFSAXGWFiiNfBpNLAf0Q8Qmf6syvKLjqNi4kGZkdhbUll3E/59YsgWXmjM8+llPLWzGsP+JPvo5eq1A==} + + '@visx/shape@4.0.1-alpha.0': + resolution: {integrity: sha512-62QeiVNmPlterQGwhkEDcbq7M0MqY0lBsK5QKXtM9ZoPZWkuGV3aykA3+Xu20B2FAvyJq4LqJzBc7Sxr+EAdbA==} + peerDependencies: + react: ^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0 + + '@visx/vendor@4.0.0-alpha.0': + resolution: {integrity: sha512-6I+MuqXBcv9jnlcVowHoHKSdk9gXTWkHLKyqBwRWg7LY6A3Ei8SHfubpqGV5rBUSppxMq2RszPJUS6w+H0YgmQ==} + '@vitest/expect@4.1.10': resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} @@ -457,22 +646,32 @@ packages: ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + aws-ssl-profiles@1.1.2: + resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} + engines: {node: '>= 6.0.0'} + balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + better-result@2.10.0: + resolution: {integrity: sha512-oQhh0y1qo2/ZKdAAEvHZAqKKiHOFU5k/bW96fE2ScgQOVkJRiHwB+nOS1SgFsYqRlxMDWvefXi9Q3px7QvgNDw==} + brace-expansion@5.0.9: resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} engines: {node: 20 || >=22} - c12@3.1.0: - resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==} + c12@3.3.4: + resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==} peerDependencies: - magicast: ^0.3.5 + magicast: '*' peerDependenciesMeta: magicast: optional: true @@ -481,23 +680,16 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} - chokidar@4.0.3: - resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} - engines: {node: '>= 14.16.0'} + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} - citty@0.1.6: - resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} - - citty@0.2.2: - resolution: {integrity: sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==} + classnames@2.5.1: + resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} confbox@0.2.4: resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} - consola@3.4.2: - resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} - engines: {node: ^14.18.0 || >=16.10.0} - convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -505,6 +697,57 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + d3-array@3.2.1: + resolution: {integrity: sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==} + engines: {node: '>=12'} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.2: + resolution: {integrity: sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ==} + engines: {node: '>=12'} + + d3-format@3.1.0: + resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==} + engines: {node: '>=12'} + + d3-geo@3.1.0: + resolution: {integrity: sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -524,6 +767,13 @@ packages: defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} @@ -531,17 +781,24 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - dotenv@16.6.1: - resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} - effect@3.21.0: - resolution: {integrity: sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==} + effect@3.20.0: + resolution: {integrity: sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==} + + elkjs@0.11.1: + resolution: {integrity: sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==} empathic@2.0.0: resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} engines: {node: '>=14'} + env-paths@3.0.0: + resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + es-module-lexer@2.2.0: resolution: {integrity: sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==} @@ -611,6 +868,9 @@ packages: resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} engines: {node: '>=8.0.0'} + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -620,6 +880,12 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + + fast-uri@3.1.5: + resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -633,6 +899,10 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + find-my-way@9.6.0: + resolution: {integrity: sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==} + engines: {node: '>=20'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -644,19 +914,42 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - giget@2.0.0: - resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} + generate-function@2.3.1: + resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} + + get-port-please@3.2.0: + resolution: {integrity: sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==} + + giget@3.3.1: + resolution: {integrity: sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg==} hasBin: true glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + grammex@3.1.13: + resolution: {integrity: sha512-LnPnhOBLEJEVKS8WFDVaA397L9Kq55Q9oSITJiVLHVdhAclfUkWzQv74KhvZHKL2Q09Pb1XdsrOsZ4LfTFFTEg==} + + graphmatch@1.1.1: + resolution: {integrity: sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -669,6 +962,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -677,6 +974,9 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-property@1.0.2: + resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -690,6 +990,9 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -774,6 +1077,16 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + + lru.min@1.1.4: + resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} + engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -784,6 +1097,14 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + mysql2@3.15.3: + resolution: {integrity: sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==} + engines: {node: '>= 8.0'} + + named-placeholders@1.1.6: + resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} + engines: {node: '>=8.0.0'} + nanoid@3.3.16: resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -792,14 +1113,6 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - node-fetch-native@1.6.7: - resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} - - nypm@0.6.6: - resolution: {integrity: sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q==} - engines: {node: '>=18'} - hasBin: true - obug@2.1.3: resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} engines: {node: '>=12.20.0'} @@ -830,8 +1143,8 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - perfect-debounce@1.0.0: - resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} pg-cloudflare@1.4.0: resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} @@ -892,6 +1205,10 @@ packages: resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} engines: {node: '>=4'} + postgres-array@3.0.4: + resolution: {integrity: sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==} + engines: {node: '>=12'} + postgres-bytea@1.0.1: resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} engines: {node: '>=0.10.0'} @@ -904,6 +1221,10 @@ packages: resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} engines: {node: '>=0.10.0'} + postgres@3.4.7: + resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==} + engines: {node: '>=12'} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -913,16 +1234,22 @@ packages: engines: {node: '>=14'} hasBin: true - prisma@6.19.3: - resolution: {integrity: sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==} - engines: {node: '>=18.18'} + prisma@7.9.0: + resolution: {integrity: sha512-isQTJEK4pyOlAVzm6kBUDjzgdsgs0A/snpB38ycTHeOHW34qfepP+ClQltgDXqjZBnXALhEtE4duh9L3tN5fHw==} + engines: {node: ^20.19 || ^22.12 || >=24.0} hasBin: true peerDependencies: - typescript: '>=5.1.0' + better-sqlite3: '>=9.0.0' + typescript: '>=5.4.0' peerDependenciesMeta: + better-sqlite3: + optional: true typescript: optional: true + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -930,23 +1257,63 @@ packages: pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} - rc9@2.1.2: - resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + rc9@3.0.1: + resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==} + + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} - readdirp@4.1.2: - resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} - engines: {node: '>= 14.18.0'} + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + + remeda@2.33.4: + resolution: {integrity: sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} rolldown@1.0.3: resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + safe-regex2@5.1.1: + resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} + hasBin: true + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} hasBin: true + seq-queue@0.0.5: + resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -958,6 +1325,13 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -966,9 +1340,16 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + sqlstring@2.3.3: + resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} + engines: {node: '>= 0.6'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} @@ -1018,6 +1399,14 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + valibot@1.2.0: + resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + vite@8.0.16: resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1124,8 +1513,21 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zeptomatch@2.1.0: + resolution: {integrity: sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==} + snapshots: + '@electric-sql/pglite-socket@0.1.3(@electric-sql/pglite@0.4.3)': + dependencies: + '@electric-sql/pglite': 0.4.3 + + '@electric-sql/pglite-tools@0.3.3(@electric-sql/pglite@0.4.3)': + dependencies: + '@electric-sql/pglite': 0.4.3 + + '@electric-sql/pglite@0.4.3': {} + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -1203,49 +1605,165 @@ snapshots: '@oxc-project/types@0.133.0': {} - '@prisma/client-runtime-utils@7.8.0': {} + '@prisma/adapter-pg@7.9.0': + dependencies: + '@prisma/driver-adapter-utils': 7.9.0 + '@types/pg': 8.20.0 + pg: 8.22.0 + postgres-array: 3.0.4 + transitivePeerDependencies: + - pg-native - '@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)': - optionalDependencies: - prisma: 6.19.3(typescript@5.9.3) - typescript: 5.9.3 + '@prisma/client-runtime-utils@7.9.0': {} - '@prisma/client@7.8.0(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)': + '@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3))(typescript@5.9.3)': dependencies: - '@prisma/client-runtime-utils': 7.8.0 + '@prisma/client-runtime-utils': 7.9.0 optionalDependencies: - prisma: 6.19.3(typescript@5.9.3) + prisma: 7.9.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3) typescript: 5.9.3 - '@prisma/config@6.19.3': + '@prisma/config@7.9.0': dependencies: - c12: 3.1.0 + c12: 3.3.4 deepmerge-ts: 7.1.5 - effect: 3.21.0 + effect: 3.20.0 empathic: 2.0.0 transitivePeerDependencies: - magicast - '@prisma/debug@6.19.3': {} + '@prisma/debug@7.2.0': {} + + '@prisma/debug@7.9.0': {} + + '@prisma/dev@0.24.14(typescript@5.9.3)': + dependencies: + '@electric-sql/pglite': 0.4.3 + '@electric-sql/pglite-socket': 0.1.3(@electric-sql/pglite@0.4.3) + '@electric-sql/pglite-tools': 0.3.3(@electric-sql/pglite@0.4.3) + '@prisma/get-platform': 7.2.0 + '@prisma/query-plan-executor': 7.2.0 + '@prisma/streams-local': 0.1.11 + find-my-way: 9.6.0 + foreground-child: 3.3.1 + get-port-please: 3.2.0 + pathe: 2.0.3 + proper-lockfile: 4.1.2 + remeda: 2.33.4 + std-env: 3.10.0 + valibot: 1.2.0(typescript@5.9.3) + zeptomatch: 2.1.0 + transitivePeerDependencies: + - typescript + + '@prisma/driver-adapter-utils@7.9.0': + dependencies: + '@prisma/debug': 7.9.0 + + '@prisma/engines-version@7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad': {} + + '@prisma/engines@7.9.0': + dependencies: + '@prisma/debug': 7.9.0 + '@prisma/engines-version': 7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad + '@prisma/fetch-engine': 7.9.0 + '@prisma/get-platform': 7.9.0 + + '@prisma/fetch-engine@7.9.0': + dependencies: + '@prisma/debug': 7.9.0 + '@prisma/engines-version': 7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad + '@prisma/get-platform': 7.9.0 + + '@prisma/get-platform@7.2.0': + dependencies: + '@prisma/debug': 7.2.0 + + '@prisma/get-platform@7.9.0': + dependencies: + '@prisma/debug': 7.9.0 + + '@prisma/query-plan-executor@7.2.0': {} + + '@prisma/streams-local@0.1.11': + dependencies: + ajv: 8.20.0 + better-result: 2.10.0 + env-paths: 3.0.0 + proper-lockfile: 4.1.2 + + '@prisma/studio-core@0.33.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-toggle': 1.1.10(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@types/react': 19.2.18 + '@visx/curve': 4.0.1-alpha.0 + '@visx/event': 4.0.1-alpha.0 + '@visx/grid': 4.0.1-alpha.0(react@19.2.8) + '@visx/group': 4.0.1-alpha.0(react@19.2.8) + '@visx/responsive': 4.0.1-alpha.0(react@19.2.8) + '@visx/scale': 4.0.1-alpha.0 + '@visx/shape': 4.0.1-alpha.0(react@19.2.8) + d3-array: 3.2.4 + d3-shape: 3.2.0 + elkjs: 0.11.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + transitivePeerDependencies: + - '@types/react-dom' + + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.18)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 - '@prisma/engines-version@7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7': {} + '@radix-ui/react-primitive@2.1.3(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 - '@prisma/engines@6.19.3': + '@radix-ui/react-slot@1.2.3(@types/react@19.2.18)(react@19.2.8)': dependencies: - '@prisma/debug': 6.19.3 - '@prisma/engines-version': 7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7 - '@prisma/fetch-engine': 6.19.3 - '@prisma/get-platform': 6.19.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-toggle@1.1.10(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.18)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 - '@prisma/fetch-engine@6.19.3': + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.18)(react@19.2.8)': dependencies: - '@prisma/debug': 6.19.3 - '@prisma/engines-version': 7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7 - '@prisma/get-platform': 6.19.3 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 - '@prisma/get-platform@6.19.3': + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.18)(react@19.2.8)': dependencies: - '@prisma/debug': 6.19.3 + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 '@rolldown/binding-android-arm64@1.0.3': optional: true @@ -1310,14 +1828,48 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/d3-array@3.0.3': {} + + '@types/d3-color@3.1.0': {} + + '@types/d3-delaunay@6.0.1': {} + + '@types/d3-format@3.0.1': {} + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-interpolate@3.0.1': + dependencies: + '@types/d3-color': 3.1.0 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.2': + dependencies: + '@types/d3-time': 3.0.0 + + '@types/d3-shape@3.1.7': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@2.1.0': {} + + '@types/d3-time@3.0.0': {} + '@types/deep-eql@4.0.2': {} '@types/esrecurse@4.3.1': {} '@types/estree@1.0.9': {} + '@types/geojson@7946.0.16': {} + '@types/json-schema@7.0.15': {} + '@types/lodash@4.17.25': {} + '@types/node@26.1.1': dependencies: undici-types: 8.3.0 @@ -1328,6 +1880,10 @@ snapshots: pg-protocol: 1.13.0 pg-types: 2.2.0 + '@types/react@19.2.18': + dependencies: + csstype: 3.2.3 + '@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -1419,6 +1975,83 @@ snapshots: '@typescript-eslint/types': 8.65.0 eslint-visitor-keys: 5.0.1 + '@visx/curve@4.0.1-alpha.0': + dependencies: + '@visx/vendor': 4.0.0-alpha.0 + + '@visx/event@4.0.1-alpha.0': + dependencies: + '@types/react': 19.2.18 + '@visx/point': 4.0.1-alpha.0 + + '@visx/grid@4.0.1-alpha.0(react@19.2.8)': + dependencies: + '@types/react': 19.2.18 + '@visx/curve': 4.0.1-alpha.0 + '@visx/group': 4.0.1-alpha.0(react@19.2.8) + '@visx/point': 4.0.1-alpha.0 + '@visx/scale': 4.0.1-alpha.0 + '@visx/shape': 4.0.1-alpha.0(react@19.2.8) + classnames: 2.5.1 + react: 19.2.8 + + '@visx/group@4.0.1-alpha.0(react@19.2.8)': + dependencies: + '@types/react': 19.2.18 + classnames: 2.5.1 + react: 19.2.8 + + '@visx/point@4.0.1-alpha.0': {} + + '@visx/responsive@4.0.1-alpha.0(react@19.2.8)': + dependencies: + '@types/lodash': 4.17.25 + '@types/react': 19.2.18 + lodash: 4.18.1 + react: 19.2.8 + + '@visx/scale@4.0.1-alpha.0': + dependencies: + '@visx/vendor': 4.0.0-alpha.0 + + '@visx/shape@4.0.1-alpha.0(react@19.2.8)': + dependencies: + '@types/lodash': 4.17.25 + '@types/react': 19.2.18 + '@visx/curve': 4.0.1-alpha.0 + '@visx/group': 4.0.1-alpha.0(react@19.2.8) + '@visx/scale': 4.0.1-alpha.0 + '@visx/vendor': 4.0.0-alpha.0 + classnames: 2.5.1 + lodash: 4.18.1 + react: 19.2.8 + + '@visx/vendor@4.0.0-alpha.0': + dependencies: + '@types/d3-array': 3.0.3 + '@types/d3-color': 3.1.0 + '@types/d3-delaunay': 6.0.1 + '@types/d3-format': 3.0.1 + '@types/d3-geo': 3.1.0 + '@types/d3-interpolate': 3.0.1 + '@types/d3-path': 3.1.1 + '@types/d3-scale': 4.0.2 + '@types/d3-shape': 3.1.7 + '@types/d3-time': 3.0.0 + '@types/d3-time-format': 2.1.0 + d3-array: 3.2.1 + d3-color: 3.1.0 + d3-delaunay: 6.0.2 + d3-format: 3.1.0 + d3-geo: 3.1.0 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + internmap: 2.0.3 + '@vitest/expect@4.1.10': dependencies: '@standard-schema/spec': 1.1.0 @@ -1473,45 +2106,50 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.5 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + assertion-error@2.0.1: {} + aws-ssl-profiles@1.1.2: {} + balanced-match@4.0.4: {} + better-result@2.10.0: {} + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 - c12@3.1.0: + c12@3.3.4: dependencies: - chokidar: 4.0.3 + chokidar: 5.0.0 confbox: 0.2.4 defu: 6.1.7 - dotenv: 16.6.1 + dotenv: 17.4.2 exsolve: 1.0.8 - giget: 2.0.0 + giget: 3.3.1 jiti: 2.7.0 ohash: 2.0.11 pathe: 2.0.3 - perfect-debounce: 1.0.0 + perfect-debounce: 2.1.0 pkg-types: 2.3.1 - rc9: 2.1.2 + rc9: 3.0.1 chai@6.2.2: {} - chokidar@4.0.3: + chokidar@5.0.0: dependencies: - readdirp: 4.1.2 + readdirp: 5.0.0 - citty@0.1.6: - dependencies: - consola: 3.4.2 - - citty@0.2.2: {} + classnames@2.5.1: {} confbox@0.2.4: {} - consola@3.4.2: {} - convert-source-map@2.0.0: {} cross-spawn@7.0.6: @@ -1520,6 +2158,54 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + csstype@3.2.3: {} + + d3-array@3.2.1: + dependencies: + internmap: 2.0.3 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-delaunay@6.0.2: + dependencies: + delaunator: 5.1.0 + + d3-format@3.1.0: {} + + d3-geo@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.0 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + debug@4.4.3: dependencies: ms: 2.1.3 @@ -1530,19 +2216,29 @@ snapshots: defu@6.1.7: {} + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + + denque@2.1.0: {} + destr@2.0.5: {} detect-libc@2.1.2: {} - dotenv@16.6.1: {} + dotenv@17.4.2: {} - effect@3.21.0: + effect@3.20.0: dependencies: '@standard-schema/spec': 1.1.0 fast-check: 3.23.2 + elkjs@0.11.1: {} + empathic@2.0.0: {} + env-paths@3.0.0: {} + es-module-lexer@2.2.0: {} escape-string-regexp@4.0.0: {} @@ -1629,12 +2325,20 @@ snapshots: dependencies: pure-rand: 6.1.0 + fast-decode-uri-component@1.0.1: {} + fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + + fast-uri@3.1.5: {} + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -1643,6 +2347,12 @@ snapshots: dependencies: flat-cache: 4.0.1 + find-my-way@9.6.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.1.1 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -1655,34 +2365,52 @@ snapshots: flatted@3.4.2: {} + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + fsevents@2.3.3: optional: true - giget@2.0.0: + generate-function@2.3.1: dependencies: - citty: 0.1.6 - consola: 3.4.2 - defu: 6.1.7 - node-fetch-native: 1.6.7 - nypm: 0.6.6 - pathe: 2.0.3 + is-property: 1.0.2 + + get-port-please@3.2.0: {} + + giget@3.3.1: {} glob-parent@6.0.2: dependencies: is-glob: 4.0.3 + graceful-fs@4.2.11: {} + + grammex@3.1.13: {} + + graphmatch@1.1.1: {} + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + ignore@5.3.2: {} ignore@7.0.5: {} imurmurhash@0.1.4: {} + internmap@2.0.3: {} + is-extglob@2.1.1: {} is-glob@4.0.3: dependencies: is-extglob: 2.1.1 + is-property@1.0.2: {} + isexe@2.0.0: {} jiti@2.7.0: {} @@ -1691,6 +2419,8 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} keyv@4.5.4: @@ -1755,6 +2485,12 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash@4.18.1: {} + + long@5.3.2: {} + + lru.min@1.1.4: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1765,17 +2501,25 @@ snapshots: ms@2.1.3: {} - nanoid@3.3.16: {} + mysql2@3.15.3: + dependencies: + aws-ssl-profiles: 1.1.2 + denque: 2.1.0 + generate-function: 2.3.1 + iconv-lite: 0.7.3 + long: 5.3.2 + lru.min: 1.1.4 + named-placeholders: 1.1.6 + seq-queue: 0.0.5 + sqlstring: 2.3.3 - natural-compare@1.4.0: {} + named-placeholders@1.1.6: + dependencies: + lru.min: 1.1.4 - node-fetch-native@1.6.7: {} + nanoid@3.3.16: {} - nypm@0.6.6: - dependencies: - citty: 0.2.2 - pathe: 2.0.3 - tinyexec: 1.2.4 + natural-compare@1.4.0: {} obug@2.1.3: {} @@ -1804,7 +2548,7 @@ snapshots: pathe@2.0.3: {} - perfect-debounce@1.0.0: {} + perfect-debounce@2.1.0: {} pg-cloudflare@1.4.0: optional: true @@ -1863,6 +2607,8 @@ snapshots: postgres-array@2.0.0: {} + postgres-array@3.0.4: {} + postgres-bytea@1.0.1: {} postgres-date@1.0.7: {} @@ -1871,29 +2617,62 @@ snapshots: dependencies: xtend: 4.0.2 + postgres@3.4.7: {} + prelude-ls@1.2.1: {} prettier@3.9.6: {} - prisma@6.19.3(typescript@5.9.3): + prisma@7.9.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3): dependencies: - '@prisma/config': 6.19.3 - '@prisma/engines': 6.19.3 + '@prisma/config': 7.9.0 + '@prisma/dev': 0.24.14(typescript@5.9.3) + '@prisma/engines': 7.9.0 + '@prisma/studio-core': 0.33.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + mysql2: 3.15.3 + postgres: 3.4.7 optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' - magicast + - react + - react-dom + + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 punycode@2.3.1: {} pure-rand@6.1.0: {} - rc9@2.1.2: + rc9@3.0.1: dependencies: defu: 6.1.7 destr: 2.0.5 - readdirp@4.1.2: {} + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react@19.2.8: {} + + readdirp@5.0.0: {} + + remeda@2.33.4: {} + + require-from-string@2.0.2: {} + + ret@0.5.0: {} + + retry@0.12.0: {} + + robust-predicates@3.0.3: {} rolldown@1.0.3: dependencies: @@ -1916,8 +2695,18 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.3 '@rolldown/binding-win32-x64-msvc': 1.0.3 + safe-regex2@5.1.1: + dependencies: + ret: 0.5.0 + + safer-buffer@2.1.2: {} + + scheduler@0.27.0: {} + semver@7.8.5: {} + seq-queue@0.0.5: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -1926,12 +2715,20 @@ snapshots: siginfo@2.0.0: {} + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + source-map-js@1.2.1: {} split2@4.2.0: {} + sqlstring@2.3.3: {} + stackback@0.0.2: {} + std-env@3.10.0: {} + std-env@4.1.0: {} tinybench@2.9.0: {} @@ -1975,6 +2772,10 @@ snapshots: dependencies: punycode: 2.3.1 + valibot@1.2.0(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + vite@8.0.16(@types/node@26.1.1)(jiti@2.7.0): dependencies: lightningcss: 1.33.0 @@ -2028,3 +2829,8 @@ snapshots: xtend@4.0.2: {} yocto-queue@0.1.0: {} + + zeptomatch@2.1.0: + dependencies: + grammex: 3.1.13 + graphmatch: 1.1.1 From 6d350a0925841d673604e9ff682306f6992c358d Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sat, 1 Aug 2026 16:19:36 -0500 Subject: [PATCH 12/43] bump Prisma to 7.9.1 (#32) Prisma 7.9.0 pins its internal `@prisma/dev` package to versions that depend on vulnerable `find-my-way` 9.6.0 and `valibot` 1.2.0. Dependabot cannot update those exact transitives independently. Bump the Prisma CLI, client, and PostgreSQL adapter packages together to 7.9.1. Its `@prisma/dev` 0.24.17 dependency resolves `find-my-way` 9.7.0 and `valibot` 1.4.2 while preserving package alignment across both Prisma workspaces. --- js/driver/prisma/package.json | 4 +- js/examples/prisma/package.json | 6 +- js/pnpm-lock.yaml | 128 ++++++++++++++++---------------- 3 files changed, 69 insertions(+), 69 deletions(-) diff --git a/js/driver/prisma/package.json b/js/driver/prisma/package.json index a40e9fe1b..cfe987746 100644 --- a/js/driver/prisma/package.json +++ b/js/driver/prisma/package.json @@ -33,8 +33,8 @@ "@prisma/client": ">=5.0.0" }, "devDependencies": { - "@prisma/client": "7.9.0", - "prisma": "7.9.0", + "@prisma/client": "7.9.1", + "prisma": "7.9.1", "typescript": "^5.8.0" }, "keywords": [ diff --git a/js/examples/prisma/package.json b/js/examples/prisma/package.json index c0246e5b0..134b83aa7 100644 --- a/js/examples/prisma/package.json +++ b/js/examples/prisma/package.json @@ -12,11 +12,11 @@ "dependencies": { "riverqueue": "workspace:*", "@riverqueue/driver-prisma": "workspace:*", - "@prisma/adapter-pg": "7.9.0", - "@prisma/client": "7.9.0" + "@prisma/adapter-pg": "7.9.1", + "@prisma/client": "7.9.1" }, "devDependencies": { - "prisma": "7.9.0", + "prisma": "7.9.1", "typescript": "^5.8.0" } } diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml index 483bb1490..d42f78144 100644 --- a/js/pnpm-lock.yaml +++ b/js/pnpm-lock.yaml @@ -65,11 +65,11 @@ importers: version: link:../.. devDependencies: '@prisma/client': - specifier: 7.9.0 - version: 7.9.0(prisma@7.9.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3))(typescript@5.9.3) + specifier: 7.9.1 + version: 7.9.1(prisma@7.9.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3))(typescript@5.9.3) prisma: - specifier: 7.9.0 - version: 7.9.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3) + specifier: 7.9.1 + version: 7.9.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3) typescript: specifier: ^5.8.0 version: 5.9.3 @@ -96,11 +96,11 @@ importers: examples/prisma: dependencies: '@prisma/adapter-pg': - specifier: 7.9.0 - version: 7.9.0 + specifier: 7.9.1 + version: 7.9.1 '@prisma/client': - specifier: 7.9.0 - version: 7.9.0(prisma@7.9.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3))(typescript@5.9.3) + specifier: 7.9.1 + version: 7.9.1(prisma@7.9.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3))(typescript@5.9.3) '@riverqueue/driver-prisma': specifier: workspace:* version: link:../../driver/prisma @@ -109,8 +109,8 @@ importers: version: link:../.. devDependencies: prisma: - specifier: 7.9.0 - version: 7.9.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3) + specifier: 7.9.1 + version: 7.9.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3) typescript: specifier: ^5.8.0 version: 5.9.3 @@ -212,14 +212,14 @@ packages: '@oxc-project/types@0.133.0': resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} - '@prisma/adapter-pg@7.9.0': - resolution: {integrity: sha512-kPYuFvNTlqnaFf2UpXBBG3ycTT3PL76uSZtLFBEwDytjMMUW8ZHrsb9cSNIarzdPW5EXWmBOeOq9/MVjMtbWkA==} + '@prisma/adapter-pg@7.9.1': + resolution: {integrity: sha512-Ho2RK1KanQxLNSC0sR5bpiiVep10sWPLXCcxK+KXfI/Q69TMRbiafSvLPv3V9snimX72rMCqGlyJ4sBO4lKTAw==} - '@prisma/client-runtime-utils@7.9.0': - resolution: {integrity: sha512-kMVmS4ZEy3xlkca+TfxOEm/ToVVlOS2x1Tc6/wIRf/HfczBqENtSPcKszy4ZpFNzjJ8SRKvlU5V0rrpoFw2KOg==} + '@prisma/client-runtime-utils@7.9.1': + resolution: {integrity: sha512-mVIBGYdO5CFmK0HvjxrtfIyQQcPdb88pSCeVQriVQPVZyDovIWblpHfOgcS8QO187j3QF0ePArH8qPhp0AU2vg==} - '@prisma/client@7.9.0': - resolution: {integrity: sha512-BTG/mB+WL/1sD2gWwdNc2uuVJjNNBgCDlPFdjco6jJArgbg4IAChtzVeW4debFa/NKBbsGedCjET316sjllWTQ==} + '@prisma/client@7.9.1': + resolution: {integrity: sha512-+xgrh2EhJVF79wC0yX5G4PI1Rdcm7Qn/nekNQ+t/O153wtNggruHal+fXHSa0QE+Tp/Cw5wvxeCEhZZ59xGm8Q==} engines: {node: ^20.19 || ^22.12 || >=24.0} peerDependencies: prisma: '*' @@ -230,35 +230,35 @@ packages: typescript: optional: true - '@prisma/config@7.9.0': - resolution: {integrity: sha512-CsoK2mhl0u+N4/8V+XroQMOUNIic4isqD+E2HBG8l1yGEKo62CFDu3FHo0FdwItjl6XkW+omA1STSzeN1DAXlg==} + '@prisma/config@7.9.1': + resolution: {integrity: sha512-4znKhxTmXmuPye9Z6pbIyYb5VZlkZ05qG1L6Dr4g+7oTwc6V50Bs9XirFBDdjWt+H/AabMn9aUnxBcvj8z05aA==} '@prisma/debug@7.2.0': resolution: {integrity: sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==} - '@prisma/debug@7.9.0': - resolution: {integrity: sha512-i0KdVQuKUE6N9NloHs+sUNAk2c9svR3myBndQbA3BoeoArsSpwtNgTdHZL+wBtCLCcdS2OOC/PKhgTe36jkF5A==} + '@prisma/debug@7.9.1': + resolution: {integrity: sha512-/cpVZ4itxtcgB8GHBvZtcmuEjq+lWsLrRJxFMbwZrT1RIdtuKmUm7PPGo/wzfbYpBrk+9WmmBE8CHJw2rybKDQ==} - '@prisma/dev@0.24.14': - resolution: {integrity: sha512-NhFO49O2JPTdzYiLHvceQn/HiwmcKF/iGV39ko3CpYsoGqS3rz3ko6gzuxFSIeHNwNJeuNcDexyyGeTO3DW80A==} + '@prisma/dev@0.24.17': + resolution: {integrity: sha512-UvdZzmpFwknnfreh6Jije84ekkYGPYEJhXG1tFzCsCfQyzJifrOo38eZc0qajzvaC6OLUOrN9ML5XfCnEZL9DA==} - '@prisma/driver-adapter-utils@7.9.0': - resolution: {integrity: sha512-fFXujitfMyjk3kOd1Tbs5FXBm6i2OWwEhaP5lHgkUM99jHpPEQwCWj+z/WKPFq6EDMThE1zGzSlVegtR0Pmu2w==} + '@prisma/driver-adapter-utils@7.9.1': + resolution: {integrity: sha512-vmHehG7nn/heW32DXXpp13DxxAxVVe6n250oEt3dOL2E/4bt3olktKZN0mzSuxMMronyMSkbeW2uCOn3F4g8RQ==} '@prisma/engines-version@7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad': resolution: {integrity: sha512-2BsPPFksz3CQUXG6af3rVCtJKg6+JJGJTtfgu2fU8DdXhOfkBjulCq8mwybCd6ge0/jhZq2kOtLAbmUDMyI1nA==} - '@prisma/engines@7.9.0': - resolution: {integrity: sha512-lDWJp/pgSWCLfYsupmmNo96jfsbQnH1yjia8XVM2Kh8nRZhD0bQU2jCHuy3ZTPMLR3apRD3k145ybENalAYjYw==} + '@prisma/engines@7.9.1': + resolution: {integrity: sha512-UprXSMNXx2NF5ow4pqaQtE8OuBz6K78B0wc0tn2L28G5r933iWp1DR9Do2qWrsNvvFIP3x6mpEWnQtckMO0Uhg==} - '@prisma/fetch-engine@7.9.0': - resolution: {integrity: sha512-F0XlIgjbE3EywRVR/HpCerNI/dxo40vK66tHcWpsWYwH/Jk9+FsICEzATeMsZ7bdnpZz93hkD4sAb5rKLsCCpA==} + '@prisma/fetch-engine@7.9.1': + resolution: {integrity: sha512-9DwxrNTeT25Orbu9CWh0CZvVlyY1lmscpbaeLZcOnuR7zcuFrt91YSmmOfIm7zJ08YOZ6mVzURKwLoMwEBcK8w==} '@prisma/get-platform@7.2.0': resolution: {integrity: sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==} - '@prisma/get-platform@7.9.0': - resolution: {integrity: sha512-4awv6ATdgrHdLms0XKikCyfArn8BrUHZfqg0mtCKrI4+WJe24nmpsdwsypM9ozd03wa846AngY+zSbnngkMrXQ==} + '@prisma/get-platform@7.9.1': + resolution: {integrity: sha512-PK8R60YZRQvYxBrGG9i7l2/rFyzy+2MuI1dKtmtrCqPH8YpiJx/MfiC7LRzX5786rZDEv7BngcjfIJW4/9ADuw==} '@prisma/query-plan-executor@7.2.0': resolution: {integrity: sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==} @@ -899,8 +899,8 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} - find-my-way@9.6.0: - resolution: {integrity: sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==} + find-my-way@9.7.0: + resolution: {integrity: sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==} engines: {node: '>=20'} find-up@5.0.0: @@ -1234,8 +1234,8 @@ packages: engines: {node: '>=14'} hasBin: true - prisma@7.9.0: - resolution: {integrity: sha512-isQTJEK4pyOlAVzm6kBUDjzgdsgs0A/snpB38ycTHeOHW34qfepP+ClQltgDXqjZBnXALhEtE4duh9L3tN5fHw==} + prisma@7.9.1: + resolution: {integrity: sha512-aPqePoZIqwlAchbgbFDO/wHqGB+7H1nj9gaM+OsL9h77S5S3TnLd9BgD3LnoeDikULo7cl2HSUrEyQ55Z7DYbg==} engines: {node: ^20.19 || ^22.12 || >=24.0} hasBin: true peerDependencies: @@ -1399,8 +1399,8 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - valibot@1.2.0: - resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==} + valibot@1.4.2: + resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==} peerDependencies: typescript: '>=5' peerDependenciesMeta: @@ -1605,25 +1605,25 @@ snapshots: '@oxc-project/types@0.133.0': {} - '@prisma/adapter-pg@7.9.0': + '@prisma/adapter-pg@7.9.1': dependencies: - '@prisma/driver-adapter-utils': 7.9.0 + '@prisma/driver-adapter-utils': 7.9.1 '@types/pg': 8.20.0 pg: 8.22.0 postgres-array: 3.0.4 transitivePeerDependencies: - pg-native - '@prisma/client-runtime-utils@7.9.0': {} + '@prisma/client-runtime-utils@7.9.1': {} - '@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3))(typescript@5.9.3)': + '@prisma/client@7.9.1(prisma@7.9.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3))(typescript@5.9.3)': dependencies: - '@prisma/client-runtime-utils': 7.9.0 + '@prisma/client-runtime-utils': 7.9.1 optionalDependencies: - prisma: 7.9.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3) + prisma: 7.9.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3) typescript: 5.9.3 - '@prisma/config@7.9.0': + '@prisma/config@7.9.1': dependencies: c12: 3.3.4 deepmerge-ts: 7.1.5 @@ -1634,9 +1634,9 @@ snapshots: '@prisma/debug@7.2.0': {} - '@prisma/debug@7.9.0': {} + '@prisma/debug@7.9.1': {} - '@prisma/dev@0.24.14(typescript@5.9.3)': + '@prisma/dev@0.24.17(typescript@5.9.3)': dependencies: '@electric-sql/pglite': 0.4.3 '@electric-sql/pglite-socket': 0.1.3(@electric-sql/pglite@0.4.3) @@ -1644,44 +1644,44 @@ snapshots: '@prisma/get-platform': 7.2.0 '@prisma/query-plan-executor': 7.2.0 '@prisma/streams-local': 0.1.11 - find-my-way: 9.6.0 + find-my-way: 9.7.0 foreground-child: 3.3.1 get-port-please: 3.2.0 pathe: 2.0.3 proper-lockfile: 4.1.2 remeda: 2.33.4 std-env: 3.10.0 - valibot: 1.2.0(typescript@5.9.3) + valibot: 1.4.2(typescript@5.9.3) zeptomatch: 2.1.0 transitivePeerDependencies: - typescript - '@prisma/driver-adapter-utils@7.9.0': + '@prisma/driver-adapter-utils@7.9.1': dependencies: - '@prisma/debug': 7.9.0 + '@prisma/debug': 7.9.1 '@prisma/engines-version@7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad': {} - '@prisma/engines@7.9.0': + '@prisma/engines@7.9.1': dependencies: - '@prisma/debug': 7.9.0 + '@prisma/debug': 7.9.1 '@prisma/engines-version': 7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad - '@prisma/fetch-engine': 7.9.0 - '@prisma/get-platform': 7.9.0 + '@prisma/fetch-engine': 7.9.1 + '@prisma/get-platform': 7.9.1 - '@prisma/fetch-engine@7.9.0': + '@prisma/fetch-engine@7.9.1': dependencies: - '@prisma/debug': 7.9.0 + '@prisma/debug': 7.9.1 '@prisma/engines-version': 7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad - '@prisma/get-platform': 7.9.0 + '@prisma/get-platform': 7.9.1 '@prisma/get-platform@7.2.0': dependencies: '@prisma/debug': 7.2.0 - '@prisma/get-platform@7.9.0': + '@prisma/get-platform@7.9.1': dependencies: - '@prisma/debug': 7.9.0 + '@prisma/debug': 7.9.1 '@prisma/query-plan-executor@7.2.0': {} @@ -2347,7 +2347,7 @@ snapshots: dependencies: flat-cache: 4.0.1 - find-my-way@9.6.0: + find-my-way@9.7.0: dependencies: fast-deep-equal: 3.1.3 fast-querystring: 1.1.2 @@ -2623,11 +2623,11 @@ snapshots: prettier@3.9.6: {} - prisma@7.9.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3): + prisma@7.9.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3): dependencies: - '@prisma/config': 7.9.0 - '@prisma/dev': 0.24.14(typescript@5.9.3) - '@prisma/engines': 7.9.0 + '@prisma/config': 7.9.1 + '@prisma/dev': 0.24.17(typescript@5.9.3) + '@prisma/engines': 7.9.1 '@prisma/studio-core': 0.33.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) mysql2: 3.15.3 postgres: 3.4.7 @@ -2772,7 +2772,7 @@ snapshots: dependencies: punycode: 2.3.1 - valibot@1.2.0(typescript@5.9.3): + valibot@1.4.2(typescript@5.9.3): optionalDependencies: typescript: 5.9.3 From 5db3fa345c1446c2b773c7c2977d7cfe0f2c0e72 Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Mon, 5 Oct 2026 14:29:08 -0500 Subject: [PATCH 13/43] wire the JavaScript workspace into River The JavaScript packages now live in `js/` of the River repository. Point each package's `repository` at River with the package's `directory`, and its `bugs` and `homepage` at River too, and update documentation links that pointed at the old repository. Add a stub `go.mod` in `js/`. Go excludes nested modules from a module's zip, so the module that the Go proxy serves for `github.com/riverqueue/river` carries no JavaScript, and `go test ./...`, `go vet ./...`, and golangci-lint skip the workspace and its `node_modules`. Run the JavaScript workflow from River's `.github`, in `js/`, when the workspace, River's migrations, or the workflow changes, migrating its test databases with this revision's River CLI. Dependabot updates the workspace's npm dependencies from River's configuration. --- .github/dependabot.yml | 17 +++++++ .../ci.yaml => .github/workflows/js.yaml | 50 ++++++++++++++----- js/.github/dependabot.yml | 19 ------- js/docs/README.md | 2 +- js/driver/pg/package.json | 4 +- js/driver/prisma/package.json | 4 +- js/examples/node-postgres/README.md | 2 +- js/examples/prisma/README.md | 2 +- js/go.mod | 6 +++ js/package.json | 7 +-- 10 files changed, 71 insertions(+), 42 deletions(-) rename js/.github/workflows/ci.yaml => .github/workflows/js.yaml (70%) delete mode 100644 js/.github/dependabot.yml create mode 100644 js/go.mod diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 387f924b9..293fc54d3 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -30,3 +30,20 @@ updates: - "patch" schedule: interval: "weekly" + - package-ecosystem: "npm" + directory: "/js" + cooldown: + default-days: 7 + groups: + development-dependencies: + dependency-type: "development" + patterns: + - "*" + production-dependencies: + dependency-type: "production" + patterns: + - "*" + open-pull-requests-limit: 10 + schedule: + interval: "monthly" + versioning-strategy: increase diff --git a/js/.github/workflows/ci.yaml b/.github/workflows/js.yaml similarity index 70% rename from js/.github/workflows/ci.yaml rename to .github/workflows/js.yaml index 5ab499ca2..525e39f23 100644 --- a/js/.github/workflows/ci.yaml +++ b/.github/workflows/js.yaml @@ -1,13 +1,24 @@ -name: CI +name: JavaScript +# Runs for changes to the JavaScript workspace, the canonical migrations its +# examples and integration tests run against, and this workflow. on: push: branches: [master] + paths: &paths + - ".github/workflows/js.yaml" + - "js/**" + - "riverdriver/**/*.sql" pull_request: + paths: *paths # Node versions to test against (last two LTS releases). # Update these when new LTS versions are released. +defaults: + run: + working-directory: js + jobs: build: name: Build @@ -21,10 +32,13 @@ jobs: - uses: actions/checkout@v6 - 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: ${{ matrix.node-version }} - run: pnpm install --frozen-lockfile @@ -39,11 +53,14 @@ jobs: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v6 + with: + package_json_file: js/package.json - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm + cache-dependency-path: js/pnpm-lock.yaml - run: pnpm install --frozen-lockfile @@ -79,20 +96,22 @@ jobs: - uses: actions/checkout@v6 - 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: ${{ matrix.node-version }} - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: - go-version: stable - cache: false + go-version: "1.27" - - run: go install github.com/riverqueue/river/cmd/river@latest - - - run: river migrate-up --database-url "$DATABASE_URL" + # Migrate with this revision's River CLI and migrations. + - run: go run . migrate-up --database-url "$DATABASE_URL" + working-directory: cmd/river - run: pnpm install --frozen-lockfile @@ -113,10 +132,13 @@ jobs: - uses: actions/checkout@v6 - 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: ${{ matrix.node-version }} - run: pnpm install --frozen-lockfile @@ -151,20 +173,22 @@ jobs: - uses: actions/checkout@v6 - 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: ${{ matrix.node-version }} - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: - go-version: stable - cache: false - - - run: go install github.com/riverqueue/river/cmd/river@latest + go-version: "1.27" - - run: river migrate-up --database-url "$TEST_DATABASE_URL" + # Migrate with this revision's River CLI and migrations. + - run: go run . migrate-up --database-url "$TEST_DATABASE_URL" + working-directory: cmd/river - run: pnpm install --frozen-lockfile diff --git a/js/.github/dependabot.yml b/js/.github/dependabot.yml deleted file mode 100644 index ec96a92f3..000000000 --- a/js/.github/dependabot.yml +++ /dev/null @@ -1,19 +0,0 @@ -version: 2 -updates: - - cooldown: - default-days: 7 - directory: "/" - groups: - development-dependencies: - dependency-type: "development" - patterns: - - "*" - production-dependencies: - dependency-type: "production" - patterns: - - "*" - open-pull-requests-limit: 10 - package-ecosystem: "npm" - schedule: - interval: "monthly" - versioning-strategy: increase diff --git a/js/docs/README.md b/js/docs/README.md index c4c0cf043..cad81cb2a 100644 --- a/js/docs/README.md +++ b/js/docs/README.md @@ -177,4 +177,4 @@ await prisma.$transaction(async (tx) => { ## Development -See [developing River TypeScript](https://github.com/riverqueue/riverqueue-js/blob/master/docs/development.md). +See [developing River TypeScript](https://github.com/riverqueue/river/blob/master/js/docs/development.md). diff --git a/js/driver/pg/package.json b/js/driver/pg/package.json index 389dc3c9a..c528a14a8 100644 --- a/js/driver/pg/package.json +++ b/js/driver/pg/package.json @@ -21,8 +21,8 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/riverqueue/riverqueue-js.git", - "directory": "driver/pg" + "url": "git+https://github.com/riverqueue/river.git", + "directory": "js/driver/pg" }, "authors": ["Brandur Leach", "Blake Gentry"], "license": "LGPL-3.0-or-later", diff --git a/js/driver/prisma/package.json b/js/driver/prisma/package.json index cfe987746..44bf2a7ae 100644 --- a/js/driver/prisma/package.json +++ b/js/driver/prisma/package.json @@ -21,8 +21,8 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/riverqueue/riverqueue-js.git", - "directory": "driver/prisma" + "url": "git+https://github.com/riverqueue/river.git", + "directory": "js/driver/prisma" }, "authors": ["Brandur Leach", "Blake Gentry"], "license": "LGPL-3.0-or-later", diff --git a/js/examples/node-postgres/README.md b/js/examples/node-postgres/README.md index f4b1a1c03..1b83f5cdd 100644 --- a/js/examples/node-postgres/README.md +++ b/js/examples/node-postgres/README.md @@ -1,6 +1,6 @@ # River TypeScript Example: node-postgres -A minimal example demonstrating how to use the [River](https://github.com/riverqueue/riverqueue-js) TypeScript client with `node-postgres` (`pg`) to insert background jobs into PostgreSQL. +A minimal example demonstrating how to use the [River](https://github.com/riverqueue/river/tree/master/js) TypeScript client with `node-postgres` (`pg`) to insert background jobs into PostgreSQL. The example defines two job types (`SortArgs` and `SendEmailArgs`) and shows single job insertion, insertion with scheduling options, and batch insertion. diff --git a/js/examples/prisma/README.md b/js/examples/prisma/README.md index 575a34a46..54776821d 100644 --- a/js/examples/prisma/README.md +++ b/js/examples/prisma/README.md @@ -1,6 +1,6 @@ # River TypeScript Example: Prisma -A minimal example demonstrating how to use the [River](https://github.com/riverqueue/riverqueue-js) TypeScript client with [Prisma](https://www.prisma.io/) to insert background jobs into PostgreSQL. +A minimal example demonstrating how to use the [River](https://github.com/riverqueue/river/tree/master/js) TypeScript client with [Prisma](https://www.prisma.io/) to insert background jobs into PostgreSQL. The example defines two job types (`SortArgs` and `SendEmailArgs`) and shows single job insertion, insertion with scheduling options, and batch insertion. diff --git a/js/go.mod b/js/go.mod new file mode 100644 index 000000000..0dd5f486d --- /dev/null +++ b/js/go.mod @@ -0,0 +1,6 @@ +// This module exists only to keep the JavaScript workspace out of River's Go +// module: Go excludes nested modules from the module zip that the Go proxy +// serves, and `go test ./...`, `go vet ./...`, and golangci-lint don't descend +// into them (or into `node_modules`). It contains no Go code. Don't tag it: +// a `js/vX.Y.Z` tag would be read as a version of this module. +module github.com/riverqueue/river/js diff --git a/js/package.json b/js/package.json index 2e5072ecd..54f4081a1 100644 --- a/js/package.json +++ b/js/package.json @@ -29,7 +29,8 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/riverqueue/riverqueue-js.git" + "url": "git+https://github.com/riverqueue/river.git", + "directory": "js" }, "authors": [ "Brandur Leach", @@ -37,9 +38,9 @@ ], "license": "LGPL-3.0-or-later", "bugs": { - "url": "https://github.com/riverqueue/riverqueue-js/issues" + "url": "https://github.com/riverqueue/river/issues" }, - "homepage": "https://github.com/riverqueue/riverqueue-js#readme", + "homepage": "https://github.com/riverqueue/river/tree/master/js#readme", "devDependencies": { "@eslint/js": "^10.0.1", "@types/node": "^26.1.1", From 1fcb482b5edcc41958ea07e647a4d09060af900a Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sun, 4 Oct 2026 17:09:35 -0500 Subject: [PATCH 14/43] build for Node 26 with TypeScript 6 Target ES2025 with the `ESNext.Temporal` lib on Node 26, whose official builds enable `Temporal`, and build every package with TypeScript 6. The runtime added on top of this uses `Temporal` for durations and instants throughout. Exclude test files from the published build, and add a separate program that typechecks sources and tests together via `typecheck:tests`. Give Vitest a shared setup file that attributes escaped rejections and uncaught exceptions to the test that caused them, fails files that leave new event-loop handles open, and pins fast-check's seed so property tests replay identically. `test:coverage` adds an opt-in V8 coverage report. Pin transitive `deepmerge-ts`, `mysql2`, and `nanoid` versions to clear current advisories in Prisma's dependency tree. --- js/.gitignore | 1 + js/.node-version | 1 + js/driver/pg/package.json | 2 +- js/driver/prisma/package.json | 2 +- js/package.json | 30 +- js/pnpm-lock.yaml | 535 +++++++++++++++++++++++--------- js/scripts/vitest-setup.mjs | 123 ++++++++ js/tsconfig.base.json | 13 +- js/tsconfig.json | 1 + js/tsconfig.tests.json | 18 ++ js/vitest.config.ts | 45 ++- js/vitest.integration.config.ts | 24 +- 12 files changed, 622 insertions(+), 173 deletions(-) create mode 100644 js/.node-version create mode 100644 js/scripts/vitest-setup.mjs create mode 100644 js/tsconfig.tests.json diff --git a/js/.gitignore b/js/.gitignore index 3a716ffdb..96d82e92c 100644 --- a/js/.gitignore +++ b/js/.gitignore @@ -1,4 +1,5 @@ node_modules/ +coverage/ dist/ examples/prisma/src/generated/prisma/ *.tsbuildinfo 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/driver/pg/package.json b/js/driver/pg/package.json index c528a14a8..8cb3e7b6e 100644 --- a/js/driver/pg/package.json +++ b/js/driver/pg/package.json @@ -35,7 +35,7 @@ "devDependencies": { "@types/pg": "^8.11.0", "pg": "^8.22.0", - "typescript": "^5.8.0" + "typescript": "^6.0.3" }, "keywords": [ "river", diff --git a/js/driver/prisma/package.json b/js/driver/prisma/package.json index 44bf2a7ae..c2ceb5983 100644 --- a/js/driver/prisma/package.json +++ b/js/driver/prisma/package.json @@ -35,7 +35,7 @@ "devDependencies": { "@prisma/client": "7.9.1", "prisma": "7.9.1", - "typescript": "^5.8.0" + "typescript": "^6.0.3" }, "keywords": [ "river", diff --git a/js/package.json b/js/package.json index 54f4081a1..c7a8194b5 100644 --- a/js/package.json +++ b/js/package.json @@ -11,21 +11,26 @@ "import": "./dist/index.js" } }, + "engines": { + "node": ">=26" + }, "files": [ "dist" ], "scripts": { - "build": "tsc", + "build": "node node_modules/typescript/bin/tsc", "build:all": "pnpm run build && pnpm --filter='./driver/*' run build", "clean": "rm -rf dist", "clean:all": "pnpm run clean && pnpm --filter='./driver/*' run clean", - "fmt": "prettier --write 'src/**/*.ts' 'driver/**/src/**/*.ts'", - "fmt:check": "prettier --check 'src/**/*.ts' 'driver/**/src/**/*.ts'", - "lint": "eslint 'src/**/*.ts' 'driver/**/src/**/*.ts'", - "lint:fix": "eslint --fix 'src/**/*.ts' 'driver/**/src/**/*.ts'", + "fmt": "prettier --write 'src/**/*.ts' 'driver/**/src/**/*.ts' 'scripts/**/*.mjs'", + "fmt:check": "prettier --check 'src/**/*.ts' 'driver/**/src/**/*.ts' 'scripts/**/*.mjs'", + "lint": "eslint 'src/**/*.ts' 'driver/**/src/**/*.ts' 'scripts/**/*.mjs'", + "lint:fix": "eslint --fix 'src/**/*.ts' 'driver/**/src/**/*.ts' 'scripts/**/*.mjs'", "prepublishOnly": "pnpm run clean && pnpm run build", "test": "vitest run --passWithNoTests", - "test:integration": "vitest run --passWithNoTests --config vitest.integration.config.ts" + "test:coverage": "vitest run --coverage", + "test:integration": "vitest run --passWithNoTests --config vitest.integration.config.ts", + "typecheck:tests": "node node_modules/typescript/bin/tsc -p tsconfig.tests.json" }, "repository": { "type": "git", @@ -45,16 +50,25 @@ "@eslint/js": "^10.0.1", "@types/node": "^26.1.1", "@types/pg": "^8.20.0", + "@vitest/coverage-v8": "^4.1.11", "eslint": "^10.8.0", "eslint-config-prettier": "^10.1.8", + "fast-check": "^4.10.2", "pg": "^8.22.0", "prettier": "^3.9.6", - "typescript": "^5.8.0", + "typescript": "^6.0.3", "typescript-eslint": "^8.65.0", "vite": "^8.0.16", - "vitest": "^4.1.10" + "vitest": "^4.1.11" }, "packageManager": "pnpm@10.22.0", + "pnpm": { + "overrides": { + "deepmerge-ts": "8.0.1", + "mysql2": "3.24.4", + "nanoid": "3.3.18" + } + }, "keywords": [ "river", "job-queue", diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml index d42f78144..99ba43b62 100644 --- a/js/pnpm-lock.yaml +++ b/js/pnpm-lock.yaml @@ -4,6 +4,11 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + deepmerge-ts: 8.0.1 + mysql2: 3.24.4 + nanoid: 3.3.18 + importers: .: @@ -17,12 +22,18 @@ importers: '@types/pg': specifier: ^8.20.0 version: 8.20.0 + '@vitest/coverage-v8': + specifier: ^4.1.11 + version: 4.1.11(vitest@4.1.11) eslint: specifier: ^10.8.0 version: 10.8.0(jiti@2.7.0) eslint-config-prettier: specifier: ^10.1.8 version: 10.1.8(eslint@10.8.0(jiti@2.7.0)) + fast-check: + specifier: ^4.10.2 + version: 4.10.2 pg: specifier: ^8.22.0 version: 8.22.0 @@ -30,17 +41,17 @@ importers: specifier: ^3.9.6 version: 3.9.6 typescript: - specifier: ^5.8.0 - version: 5.9.3 + specifier: ^6.0.3 + version: 6.0.3 typescript-eslint: specifier: ^8.65.0 - version: 8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3) + version: 8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) vite: specifier: ^8.0.16 - version: 8.0.16(@types/node@26.1.1)(jiti@2.7.0) + version: 8.0.16(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.1) vitest: - specifier: ^4.1.10 - version: 4.1.10(@types/node@26.1.1)(vite@8.0.16(@types/node@26.1.1)(jiti@2.7.0)) + specifier: ^4.1.11 + version: 4.1.11(@types/node@26.1.1)(@vitest/coverage-v8@4.1.11)(vite@8.0.16(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.1)) driver/pg: dependencies: @@ -55,8 +66,8 @@ importers: specifier: ^8.22.0 version: 8.22.0 typescript: - specifier: ^5.8.0 - version: 5.9.3 + specifier: ^6.0.3 + version: 6.0.3 driver/prisma: dependencies: @@ -66,13 +77,13 @@ importers: devDependencies: '@prisma/client': specifier: 7.9.1 - version: 7.9.1(prisma@7.9.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3))(typescript@5.9.3) + version: 7.9.1(prisma@7.9.1(@types/node@26.1.1)(@types/react@19.2.18)(magicast@0.5.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3) prisma: specifier: 7.9.1 - version: 7.9.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3) + version: 7.9.1(@types/node@26.1.1)(@types/react@19.2.18)(magicast@0.5.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3) typescript: - specifier: ^5.8.0 - version: 5.9.3 + specifier: ^6.0.3 + version: 6.0.3 examples/node-postgres: dependencies: @@ -100,7 +111,7 @@ importers: version: 7.9.1 '@prisma/client': specifier: 7.9.1 - version: 7.9.1(prisma@7.9.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3))(typescript@5.9.3) + version: 7.9.1(prisma@7.9.1(@types/node@26.1.1)(@types/react@19.2.18)(magicast@0.5.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3))(typescript@5.9.3) '@riverqueue/driver-prisma': specifier: workspace:* version: link:../../driver/prisma @@ -110,13 +121,34 @@ importers: devDependencies: prisma: specifier: 7.9.1 - version: 7.9.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3) + version: 7.9.1(@types/node@26.1.1)(@types/react@19.2.18)(magicast@0.5.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3) typescript: specifier: ^5.8.0 version: 5.9.3 packages: + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.9': + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@electric-sql/pglite-socket@0.1.3': resolution: {integrity: sha512-LAciWM0M1dCL8hlsxu2venbVZcdxema0BtDfpWYVqr+Y468UADw0pFWidhKw1M8sfJ8rdLT71tjMmnirf/IZRQ==} hasBin: true @@ -199,9 +231,16 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@napi-rs/wasm-runtime@1.2.2': resolution: {integrity: sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} @@ -604,11 +643,20 @@ packages: '@visx/vendor@4.0.0-alpha.0': resolution: {integrity: sha512-6I+MuqXBcv9jnlcVowHoHKSdk9gXTWkHLKyqBwRWg7LY6A3Ei8SHfubpqGV5rBUSppxMq2RszPJUS6w+H0YgmQ==} - '@vitest/expect@4.1.10': - resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + '@vitest/coverage-v8@4.1.11': + resolution: {integrity: sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==} + peerDependencies: + '@vitest/browser': 4.1.11 + vitest: 4.1.11 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@4.1.11': + resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} - '@vitest/mocker@4.1.10': - resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + '@vitest/mocker@4.1.11': + resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -618,20 +666,20 @@ packages: vite: optional: true - '@vitest/pretty-format@4.1.10': - resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + '@vitest/pretty-format@4.1.11': + resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} - '@vitest/runner@4.1.10': - resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + '@vitest/runner@4.1.11': + resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} - '@vitest/snapshot@4.1.10': - resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + '@vitest/snapshot@4.1.11': + resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} - '@vitest/spy@4.1.10': - resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + '@vitest/spy@4.1.11': + resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} - '@vitest/utils@4.1.10': - resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + '@vitest/utils@4.1.11': + resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} @@ -653,6 +701,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@1.0.7: + resolution: {integrity: sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==} + aws-ssl-profiles@1.1.2: resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} engines: {node: '>= 6.0.0'} @@ -760,8 +811,8 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - deepmerge-ts@7.1.5: - resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} + deepmerge-ts@8.0.1: + resolution: {integrity: sha512-szCXE7YLCvLKR9bFPJcvsezOShdalctSvrgN/LM/QGUEPZQajwjmsMObZ6/DuANT5lxzM/wtO8Feubwdkz8myA==} engines: {node: '>=16.0.0'} defu@6.1.7: @@ -770,10 +821,6 @@ packages: delaunator@5.1.0: resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} - denque@2.1.0: - resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} - engines: {node: '>=0.10'} - destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} @@ -868,6 +915,10 @@ packages: resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} engines: {node: '>=8.0.0'} + fast-check@4.10.2: + resolution: {integrity: sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw==} + engines: {node: '>=12.17.0'} + fast-decode-uri-component@1.0.1: resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} @@ -883,8 +934,8 @@ packages: fast-querystring@1.1.2: resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} - fast-uri@3.1.5: - resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} @@ -946,6 +997,13 @@ packages: graphmatch@1.1.1: resolution: {integrity: sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==} + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + iconv-lite@0.7.3: resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} @@ -980,10 +1038,25 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -1090,6 +1163,13 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.5: + resolution: {integrity: sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -1097,16 +1177,18 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - mysql2@3.15.3: - resolution: {integrity: sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==} + mysql2@3.24.4: + resolution: {integrity: sha512-A2olluVlj0mvgyIRRISMEzXc51m+21mRtcMVjJyIpt2GG98+XrC9m9HzsqcMsX2LcnfccJvY5NB22g8fENBnOA==} engines: {node: '>= 8.0'} + peerDependencies: + '@types/node': '>= 8' named-placeholders@1.1.6: resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} engines: {node: '>=8.0.0'} - nanoid@3.3.16: - resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -1186,14 +1268,14 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} - engines: {node: '>=12'} - picomatch@4.0.5: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + pkg-types@2.3.1: resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} @@ -1257,6 +1339,9 @@ packages: pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + pure-rand@8.4.2: + resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} + rc9@3.0.1: resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==} @@ -1311,9 +1396,6 @@ packages: engines: {node: '>=10'} hasBin: true - seq-queue@0.0.5: - resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==} - shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1340,9 +1422,9 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} - sqlstring@2.3.3: - resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} - engines: {node: '>= 0.6'} + sql-escaper@1.5.2: + resolution: {integrity: sha512-6CKD38c31SENivxOADeMNLdukOnUxUcflKtzVWzace7Riv1v7cAEym5Cx9Q7gYZ3ezIJI7ZpqecQq8cqNeSSFg==} + engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'} stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -1353,11 +1435,15 @@ packages: std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - tinyexec@1.2.4: - resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} engines: {node: '>=18'} tinyglobby@0.2.17: @@ -1393,6 +1479,11 @@ packages: engines: {node: '>=14.17'} hasBin: true + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} @@ -1450,20 +1541,20 @@ packages: yaml: optional: true - vitest@4.1.10: - resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + vitest@4.1.11: + resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.10 - '@vitest/browser-preview': 4.1.10 - '@vitest/browser-webdriverio': 4.1.10 - '@vitest/coverage-istanbul': 4.1.10 - '@vitest/coverage-v8': 4.1.10 - '@vitest/ui': 4.1.10 + '@vitest/browser-playwright': 4.1.11 + '@vitest/browser-preview': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + '@vitest/coverage-istanbul': 4.1.11 + '@vitest/coverage-v8': 4.1.11 + '@vitest/ui': 4.1.11 happy-dom: '*' jsdom: '*' vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -1509,6 +1600,11 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + yaml@2.9.1: + resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} + engines: {node: '>= 14.6'} + hasBin: true + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -1518,6 +1614,21 @@ packages: snapshots: + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.9': + dependencies: + '@babel/types': 7.29.8 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@1.0.2': {} + '@electric-sql/pglite-socket@0.1.3(@electric-sql/pglite@0.4.3)': dependencies: '@electric-sql/pglite': 0.4.3 @@ -1594,8 +1705,15 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -1616,17 +1734,24 @@ snapshots: '@prisma/client-runtime-utils@7.9.1': {} - '@prisma/client@7.9.1(prisma@7.9.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3))(typescript@5.9.3)': + '@prisma/client@7.9.1(prisma@7.9.1(@types/node@26.1.1)(@types/react@19.2.18)(magicast@0.5.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3))(typescript@5.9.3)': dependencies: '@prisma/client-runtime-utils': 7.9.1 optionalDependencies: - prisma: 7.9.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3) + prisma: 7.9.1(@types/node@26.1.1)(@types/react@19.2.18)(magicast@0.5.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3) typescript: 5.9.3 - '@prisma/config@7.9.1': + '@prisma/client@7.9.1(prisma@7.9.1(@types/node@26.1.1)(@types/react@19.2.18)(magicast@0.5.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3)': + dependencies: + '@prisma/client-runtime-utils': 7.9.1 + optionalDependencies: + prisma: 7.9.1(@types/node@26.1.1)(@types/react@19.2.18)(magicast@0.5.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3) + typescript: 6.0.3 + + '@prisma/config@7.9.1(magicast@0.5.5)': dependencies: - c12: 3.3.4 - deepmerge-ts: 7.1.5 + c12: 3.3.4(magicast@0.5.5) + deepmerge-ts: 8.0.1 effect: 3.20.0 empathic: 2.0.0 transitivePeerDependencies: @@ -1656,6 +1781,26 @@ snapshots: transitivePeerDependencies: - typescript + '@prisma/dev@0.24.17(typescript@6.0.3)': + dependencies: + '@electric-sql/pglite': 0.4.3 + '@electric-sql/pglite-socket': 0.1.3(@electric-sql/pglite@0.4.3) + '@electric-sql/pglite-tools': 0.3.3(@electric-sql/pglite@0.4.3) + '@prisma/get-platform': 7.2.0 + '@prisma/query-plan-executor': 7.2.0 + '@prisma/streams-local': 0.1.11 + find-my-way: 9.7.0 + foreground-child: 3.3.1 + get-port-please: 3.2.0 + pathe: 2.0.3 + proper-lockfile: 4.1.2 + remeda: 2.33.4 + std-env: 3.10.0 + valibot: 1.4.2(typescript@6.0.3) + zeptomatch: 2.1.0 + transitivePeerDependencies: + - typescript + '@prisma/driver-adapter-utils@7.9.1': dependencies: '@prisma/debug': 7.9.1 @@ -1884,40 +2029,40 @@ snapshots: dependencies: csstype: 3.2.3 - '@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.65.0 - '@typescript-eslint/type-utils': 8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/utils': 8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/utils': 8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.65.0 eslint: 10.8.0(jiti@2.7.0) ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3)': + '@typescript-eslint/parser@8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@typescript-eslint/scope-manager': 8.65.0 '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.65.0(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.65.0 debug: 4.4.3 eslint: 10.8.0(jiti@2.7.0) - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.65.0(typescript@5.9.3)': + '@typescript-eslint/project-service@8.65.0(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@6.0.3) '@typescript-eslint/types': 8.65.0 debug: 4.4.3 - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -1926,47 +2071,47 @@ snapshots: '@typescript-eslint/types': 8.65.0 '@typescript-eslint/visitor-keys': 8.65.0 - '@typescript-eslint/tsconfig-utils@8.65.0(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.65.0(typescript@6.0.3)': dependencies: - typescript: 5.9.3 + typescript: 6.0.3 - '@typescript-eslint/type-utils@8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.65.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) debug: 4.4.3 eslint: 10.8.0(jiti@2.7.0) - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color '@typescript-eslint/types@8.65.0': {} - '@typescript-eslint/typescript-estree@8.65.0(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.65.0(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.65.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@5.9.3) + '@typescript-eslint/project-service': 8.65.0(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@6.0.3) '@typescript-eslint/types': 8.65.0 '@typescript-eslint/visitor-keys': 8.65.0 debug: 4.4.3 minimatch: 10.2.5 semver: 7.8.5 tinyglobby: 0.2.17 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3)': + '@typescript-eslint/utils@8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@10.8.0(jiti@2.7.0)) '@typescript-eslint/scope-manager': 8.65.0 '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.65.0(typescript@6.0.3) eslint: 10.8.0(jiti@2.7.0) - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -2052,44 +2197,58 @@ snapshots: d3-time-format: 4.1.0 internmap: 2.0.3 - '@vitest/expect@4.1.10': + '@vitest/coverage-v8@4.1.11(vitest@4.1.11)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.11 + ast-v8-to-istanbul: 1.0.7 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.5 + obug: 2.1.3 + std-env: 4.1.0 + tinyrainbow: 3.1.0 + vitest: 4.1.11(@types/node@26.1.1)(@vitest/coverage-v8@4.1.11)(vite@8.0.16(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.1)) + + '@vitest/expect@4.1.11': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.10 - '@vitest/utils': 4.1.10 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.10(vite@8.0.16(@types/node@26.1.1)(jiti@2.7.0))': + '@vitest/mocker@4.1.11(vite@8.0.16(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.1))': dependencies: - '@vitest/spy': 4.1.10 + '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.16(@types/node@26.1.1)(jiti@2.7.0) + vite: 8.0.16(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.1) - '@vitest/pretty-format@4.1.10': + '@vitest/pretty-format@4.1.11': dependencies: tinyrainbow: 3.1.0 - '@vitest/runner@4.1.10': + '@vitest/runner@4.1.11': dependencies: - '@vitest/utils': 4.1.10 + '@vitest/utils': 4.1.11 pathe: 2.0.3 - '@vitest/snapshot@4.1.10': + '@vitest/snapshot@4.1.11': dependencies: - '@vitest/pretty-format': 4.1.10 - '@vitest/utils': 4.1.10 + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.1.10': {} + '@vitest/spy@4.1.11': {} - '@vitest/utils@4.1.10': + '@vitest/utils@4.1.11': dependencies: - '@vitest/pretty-format': 4.1.10 + '@vitest/pretty-format': 4.1.11 convert-source-map: 2.0.0 tinyrainbow: 3.1.0 @@ -2109,12 +2268,18 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.5 + fast-uri: 3.1.8 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 assertion-error@2.0.1: {} + ast-v8-to-istanbul@1.0.7: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + aws-ssl-profiles@1.1.2: {} balanced-match@4.0.4: {} @@ -2125,7 +2290,7 @@ snapshots: dependencies: balanced-match: 4.0.4 - c12@3.3.4: + c12@3.3.4(magicast@0.5.5): dependencies: chokidar: 5.0.0 confbox: 0.2.4 @@ -2139,6 +2304,8 @@ snapshots: perfect-debounce: 2.1.0 pkg-types: 2.3.1 rc9: 3.0.1 + optionalDependencies: + magicast: 0.5.5 chai@6.2.2: {} @@ -2212,7 +2379,7 @@ snapshots: deep-is@0.1.4: {} - deepmerge-ts@7.1.5: {} + deepmerge-ts@8.0.1: {} defu@6.1.7: {} @@ -2220,8 +2387,6 @@ snapshots: dependencies: robust-predicates: 3.0.3 - denque@2.1.0: {} - destr@2.0.5: {} detect-libc@2.1.2: {} @@ -2325,6 +2490,10 @@ snapshots: dependencies: pure-rand: 6.1.0 + fast-check@4.10.2: + dependencies: + pure-rand: 8.4.2 + fast-decode-uri-component@1.0.1: {} fast-deep-equal@3.1.3: {} @@ -2337,11 +2506,11 @@ snapshots: dependencies: fast-decode-uri-component: 1.0.1 - fast-uri@3.1.5: {} + fast-uri@3.1.8: {} - fdir@6.5.0(picomatch@4.0.4): + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: - picomatch: 4.0.4 + picomatch: 4.0.7 file-entry-cache@8.0.0: dependencies: @@ -2391,6 +2560,10 @@ snapshots: graphmatch@1.1.1: {} + has-flag@4.0.0: {} + + html-escaper@2.0.2: {} + iconv-lite@0.7.3: dependencies: safer-buffer: 2.1.2 @@ -2413,8 +2586,23 @@ snapshots: isexe@2.0.0: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + jiti@2.7.0: {} + js-tokens@10.0.0: {} + json-buffer@3.0.1: {} json-schema-traverse@0.4.1: {} @@ -2495,29 +2683,38 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.5: + dependencies: + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + minimatch@10.2.5: dependencies: brace-expansion: 5.0.9 ms@2.1.3: {} - mysql2@3.15.3: + mysql2@3.24.4(@types/node@26.1.1): dependencies: + '@types/node': 26.1.1 aws-ssl-profiles: 1.1.2 - denque: 2.1.0 generate-function: 2.3.1 iconv-lite: 0.7.3 long: 5.3.2 lru.min: 1.1.4 named-placeholders: 1.1.6 - seq-queue: 0.0.5 - sqlstring: 2.3.3 + sql-escaper: 1.5.2 named-placeholders@1.1.6: dependencies: lru.min: 1.1.4 - nanoid@3.3.16: {} + nanoid@3.3.18: {} natural-compare@1.4.0: {} @@ -2589,10 +2786,10 @@ snapshots: picocolors@1.1.1: {} - picomatch@4.0.4: {} - picomatch@4.0.5: {} + picomatch@4.0.7: {} + pkg-types@2.3.1: dependencies: confbox: 0.2.4 @@ -2601,7 +2798,7 @@ snapshots: postcss@8.5.25: dependencies: - nanoid: 3.3.16 + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -2623,17 +2820,36 @@ snapshots: prettier@3.9.6: {} - prisma@7.9.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3): + prisma@7.9.1(@types/node@26.1.1)(@types/react@19.2.18)(magicast@0.5.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3): dependencies: - '@prisma/config': 7.9.1 + '@prisma/config': 7.9.1(magicast@0.5.5) '@prisma/dev': 0.24.17(typescript@5.9.3) '@prisma/engines': 7.9.1 '@prisma/studio-core': 0.33.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - mysql2: 3.15.3 + mysql2: 3.24.4(@types/node@26.1.1) postgres: 3.4.7 optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: + - '@types/node' + - '@types/react' + - '@types/react-dom' + - magicast + - react + - react-dom + + prisma@7.9.1(@types/node@26.1.1)(@types/react@19.2.18)(magicast@0.5.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3): + dependencies: + '@prisma/config': 7.9.1(magicast@0.5.5) + '@prisma/dev': 0.24.17(typescript@6.0.3) + '@prisma/engines': 7.9.1 + '@prisma/studio-core': 0.33.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + mysql2: 3.24.4(@types/node@26.1.1) + postgres: 3.4.7 + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - '@types/node' - '@types/react' - '@types/react-dom' - magicast @@ -2650,6 +2866,8 @@ snapshots: pure-rand@6.1.0: {} + pure-rand@8.4.2: {} + rc9@3.0.1: dependencies: defu: 6.1.7 @@ -2705,8 +2923,6 @@ snapshots: semver@7.8.5: {} - seq-queue@0.0.5: {} - shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -2723,7 +2939,7 @@ snapshots: split2@4.2.0: {} - sqlstring@2.3.3: {} + sql-escaper@1.5.2: {} stackback@0.0.2: {} @@ -2731,20 +2947,24 @@ snapshots: std-env@4.1.0: {} + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + tinybench@2.9.0: {} - tinyexec@1.2.4: {} + tinyexec@1.3.0: {} tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 tinyrainbow@3.1.0: {} - ts-api-utils@2.5.0(typescript@5.9.3): + ts-api-utils@2.5.0(typescript@6.0.3): dependencies: - typescript: 5.9.3 + typescript: 6.0.3 tslib@2.8.1: optional: true @@ -2753,19 +2973,21 @@ snapshots: dependencies: prelude-ls: 1.2.1 - typescript-eslint@8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3): + typescript-eslint@8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/parser': 8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.65.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) eslint: 10.8.0(jiti@2.7.0) - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - supports-color typescript@5.9.3: {} + typescript@6.0.3: {} + undici-types@8.3.0: {} uri-js@4.4.1: @@ -2776,7 +2998,11 @@ snapshots: optionalDependencies: typescript: 5.9.3 - vite@8.0.16(@types/node@26.1.1)(jiti@2.7.0): + valibot@1.4.2(typescript@6.0.3): + optionalDependencies: + typescript: 6.0.3 + + vite@8.0.16(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.1): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 @@ -2787,31 +3013,33 @@ snapshots: '@types/node': 26.1.1 fsevents: 2.3.3 jiti: 2.7.0 + yaml: 2.9.1 - vitest@4.1.10(@types/node@26.1.1)(vite@8.0.16(@types/node@26.1.1)(jiti@2.7.0)): + vitest@4.1.11(@types/node@26.1.1)(@vitest/coverage-v8@4.1.11)(vite@8.0.16(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.1)): dependencies: - '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.0.16(@types/node@26.1.1)(jiti@2.7.0)) - '@vitest/pretty-format': 4.1.10 - '@vitest/runner': 4.1.10 - '@vitest/snapshot': 4.1.10 - '@vitest/spy': 4.1.10 - '@vitest/utils': 4.1.10 + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@8.0.16(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.1)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 es-module-lexer: 2.2.0 expect-type: 1.4.0 magic-string: 0.30.21 obug: 2.1.3 pathe: 2.0.3 - picomatch: 4.0.4 + picomatch: 4.0.5 std-env: 4.1.0 tinybench: 2.9.0 - tinyexec: 1.2.4 + tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.0.16(@types/node@26.1.1)(jiti@2.7.0) + vite: 8.0.16(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 26.1.1 + '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) transitivePeerDependencies: - msw @@ -2828,6 +3056,9 @@ snapshots: xtend@4.0.2: {} + yaml@2.9.1: + optional: true + yocto-queue@0.1.0: {} zeptomatch@2.1.0: diff --git a/js/scripts/vitest-setup.mjs b/js/scripts/vitest-setup.mjs new file mode 100644 index 000000000..44af8d667 --- /dev/null +++ b/js/scripts/vitest-setup.mjs @@ -0,0 +1,123 @@ +// Shared Vitest setup for every unit and integration test file. +// +// It turns asynchronous failures that escape a test into failures of the test +// that produced them, fails a file that leaves new event-loop handles open, +// and pins fast-check's seed so property tests replay identically. +import { performance } from "node:perf_hooks"; +import process from "node:process"; +import { + setImmediate as nextMacrotask, + setTimeout as sleep, +} from "node:timers/promises"; + +import fc from "fast-check"; +import { afterAll, afterEach, beforeAll } from "vitest"; + +// Property tests are deterministic by default. Set FAST_CHECK_SEED to an +// integer to replay a reported failure or explore another region of the +// input space; FAST_CHECK_SEED=random picks a fresh seed per run. fast-check +// prints the seed and shrink path of every failure. +const DEFAULT_FAST_CHECK_SEED = 0x5eed; +const seedSetting = process.env.FAST_CHECK_SEED; +if (seedSetting !== "random") { + const seed = + seedSetting === undefined || seedSetting === "" + ? DEFAULT_FAST_CHECK_SEED + : Number(seedSetting); + if (!Number.isSafeInteger(seed)) { + throw new Error(`FAST_CHECK_SEED must be an integer or "random"`); + } + fc.configureGlobal({ ...fc.readConfigureGlobal(), seed }); +} + +// Vitest reports stray errors for the whole run without failing the test +// that caused them, and loses errors raised after a file's last test. +// Attribute them to the running test (or the file) instead. +const escapedErrors = []; +const recordEscapedError = (kind) => (reason) => { + escapedErrors.push({ kind, reason }); +}; +const onUncaughtException = recordEscapedError("uncaughtException"); +const onUnhandledRejection = recordEscapedError("unhandledRejection"); + +// Resource types that keep the event loop alive, counted when the file starts. +let initialResources = new Map(); + +// Some clients resolve their close promise before the socket's own close +// callback runs (node-postgres reports `end` first). Give handles that are +// already closing a bounded grace period before calling them leaked. +const HANDLE_SETTLE_TIMEOUT_MS = 2_000; +const HANDLE_SETTLE_POLL_MS = 10; + +beforeAll(() => { + process.on("uncaughtException", onUncaughtException); + process.on("unhandledRejection", onUnhandledRejection); + initialResources = countResources(); +}); + +afterEach(async () => { + // Rejections are reported after the microtask queue drains. + await nextMacrotask(); + throwEscapedErrors("during this test"); +}); + +afterAll(async () => { + await nextMacrotask(); + try { + throwEscapedErrors("after this file's last test"); + let leaked = leakedResources(); + const deadline = performance.now() + HANDLE_SETTLE_TIMEOUT_MS; + while (leaked.length > 0 && performance.now() < deadline) { + await sleep(HANDLE_SETTLE_POLL_MS); + leaked = leakedResources(); + } + throwEscapedErrors("after this file's last test"); + if (leaked.length > 0) { + throw new Error( + `test file left event-loop handles open: ${leaked.join(", ")}; ` + + "close pools, servers, listeners, and timers (or unref timers " + + "that must outlive a test) before the file finishes" + ); + } + } finally { + process.off("uncaughtException", onUncaughtException); + process.off("unhandledRejection", onUnhandledRejection); + } +}); + +function countResources() { + const counts = new Map(); + for (const type of process.getActiveResourcesInfo()) { + counts.set(type, (counts.get(type) ?? 0) + 1); + } + return counts; +} + +function leakedResources() { + const leaked = []; + for (const [type, count] of countResources()) { + const initial = initialResources.get(type) ?? 0; + if (count > initial) leaked.push(`${type} x${count - initial}`); + } + return leaked; +} + +function throwEscapedErrors(when) { + if (escapedErrors.length === 0) return; + const errors = escapedErrors.splice(0); + throw new AggregateError( + errors.map(({ reason }) => reason), + `${errors.length} error(s) escaped ${when}: ${errors + .map(({ kind, reason }) => `${kind}: ${describe(reason)}`) + .join("; ")}` + ); +} + +function describe(reason) { + if (reason instanceof Error) return `${reason.name}: ${reason.message}`; + try { + return String(reason); + } catch { + return Object.prototype.toString.call(reason); + } +} diff --git a/js/tsconfig.base.json b/js/tsconfig.base.json index 6ba30f4f3..47564ade4 100644 --- a/js/tsconfig.base.json +++ b/js/tsconfig.base.json @@ -1,14 +1,19 @@ { "compilerOptions": { - "target": "ES2022", - "module": "Node16", - "moduleResolution": "Node16", + "target": "ES2025", + "lib": ["ES2025", "ESNext.Temporal"], + "types": ["node"], + "module": "NodeNext", + "moduleResolution": "NodeNext", "declaration": true, "declarationMap": true, "sourceMap": true, "strict": true, "esModuleInterop": true, + "noImplicitOverride": true, + "noUncheckedSideEffectImports": true, "skipLibCheck": true, - "forceConsistentCasingInFileNames": true + "forceConsistentCasingInFileNames": true, + "useUnknownInCatchVariables": true } } diff --git a/js/tsconfig.json b/js/tsconfig.json index c7b7b35a0..1229e774c 100644 --- a/js/tsconfig.json +++ b/js/tsconfig.json @@ -4,5 +4,6 @@ "rootDir": "src", "outDir": "dist" }, + "exclude": ["src/**/*.integration.test.ts", "src/**/*.test.ts"], "include": ["src"] } diff --git a/js/tsconfig.tests.json b/js/tsconfig.tests.json new file mode 100644 index 000000000..c21ab26ba --- /dev/null +++ b/js/tsconfig.tests.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "paths": { + "@riverqueue/driver-pg": [ + "./driver/pg/src/index.ts" + ], + "@riverqueue/driver-prisma": [ + "./driver/prisma/src/index.ts" + ] + } + }, + "include": [ + "src", + "driver/*/src" + ] +} diff --git a/js/vitest.config.ts b/js/vitest.config.ts index 42641b483..985bd2c4a 100644 --- a/js/vitest.config.ts +++ b/js/vitest.config.ts @@ -3,11 +3,48 @@ import path from "node:path"; export default defineConfig({ resolve: { - alias: { - riverqueue: path.resolve(import.meta.dirname, "src/index.ts"), - }, + alias: [ + { + find: /^@riverqueue\/test$/, + replacement: path.resolve(import.meta.dirname, "test/src/index.ts"), + }, + { + find: "riverqueue/unstable-driver", + replacement: path.resolve( + import.meta.dirname, + "src/unstable-driver.ts" + ), + }, + { + find: /^riverqueue$/, + replacement: path.resolve(import.meta.dirname, "src/index.ts"), + }, + ], }, test: { - exclude: ["**/node_modules/**", "**/dist/**", "**/*.integration.test.ts"], + // Line and branch coverage is supplementary evidence, not a gate; see + // `pnpm run test:coverage` in docs/development.md. + coverage: { + exclude: ["**/*.test.ts", "**/testdata/**", "examples/**"], + include: [ + "cli/src/**/*.ts", + "driver/*/src/**/*.ts", + "migrate/src/**/*.ts", + "src/**/*.ts", + "test/src/**/*.ts", + "worker-threads/src/**/*.ts", + ], + provider: "v8", + reporter: ["text-summary", "html", "json-summary"], + reportsDirectory: "coverage", + }, + exclude: [ + "**/node_modules/**", + "**/dist/**", + "**/*.integration.test.ts", + // Runs with `node --test` against packed tarballs in package:check. + "scripts/packed-tests/**", + ], + setupFiles: ["./scripts/vitest-setup.mjs"], }, }); diff --git a/js/vitest.integration.config.ts b/js/vitest.integration.config.ts index c7ce1992d..1b4c8dd27 100644 --- a/js/vitest.integration.config.ts +++ b/js/vitest.integration.config.ts @@ -3,11 +3,29 @@ import path from "node:path"; export default defineConfig({ resolve: { - alias: { - riverqueue: path.resolve(import.meta.dirname, "src/index.ts"), - }, + alias: [ + { + find: /^@riverqueue\/test$/, + replacement: path.resolve(import.meta.dirname, "test/src/index.ts"), + }, + { + find: "riverqueue/unstable-driver", + replacement: path.resolve( + import.meta.dirname, + "src/unstable-driver.ts" + ), + }, + { + find: /^riverqueue$/, + replacement: path.resolve(import.meta.dirname, "src/index.ts"), + }, + ], }, test: { + // Every PostgreSQL integration fixture owns the canonical River tables. + // Keep files sequential so one fixture cannot truncate another's rows. + fileParallelism: false, include: ["**/*.integration.test.ts"], + setupFiles: ["./scripts/vitest-setup.mjs"], }, }); From ea9b8ceb9526e273a14cdbfdf259e80cacd98ee7 Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sun, 4 Oct 2026 17:10:10 -0500 Subject: [PATCH 15/43] add River's error hierarchy and name validation Root every error River throws deliberately at `RiverError`, whose `code` is one of a fixed set of stable categories (`database`, `validation`, `configuration`, `job_cancelled`, and so on). Each subclass narrows `code` to its own category, so callers can branch with either `instanceof` or a `switch` on `code`. Validate queue names, job kinds, and user-specified IDs with River's portable grammar, including Go's `|` queue separator. Lookups of an existing queue (get, pause, resume, update) don't check the grammar, so a name no queue can have is simply not found, like Go. `assertRuntimeSupport` fails fast with a clear error on runtimes without `Temporal`, instead of a later `ReferenceError` or a lossy timestamp fallback. --- js/src/errors.test.ts | 50 ++++ js/src/errors.ts | 465 +++++++++++++++++++++++++++++++++ js/src/identifiers.test.ts | 53 ++++ js/src/identifiers.ts | 40 +++ js/src/runtime-support.test.ts | 26 ++ js/src/runtime-support.ts | 53 ++++ js/src/runtime/validation.ts | 27 ++ 7 files changed, 714 insertions(+) create mode 100644 js/src/errors.test.ts create mode 100644 js/src/errors.ts create mode 100644 js/src/identifiers.test.ts create mode 100644 js/src/identifiers.ts create mode 100644 js/src/runtime-support.test.ts create mode 100644 js/src/runtime-support.ts create mode 100644 js/src/runtime/validation.ts diff --git a/js/src/errors.test.ts b/js/src/errors.test.ts new file mode 100644 index 000000000..c509d1529 --- /dev/null +++ b/js/src/errors.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, expectTypeOf, it } from "vitest"; + +import { + RiverError, + ValidationError, + type RiverErrorCode, + type RiverErrorOptions, +} from "./errors.js"; + +describe("RiverError", () => { + it("roots a companion package's errors, with codes of its own", () => { + const EXAMPLE_ERROR_CODE = { cycle: "example.cycle" } as const; + type ExampleErrorCode = + (typeof EXAMPLE_ERROR_CODE)[keyof typeof EXAMPLE_ERROR_CODE]; + class ExampleError< + Code extends ExampleErrorCode = ExampleErrorCode, + > extends RiverError { + constructor(message: string, options: RiverErrorOptions) { + super(message, options); + this.name = "ExampleError"; + } + } + class ExampleInputError extends ValidationError { + constructor(message: string) { + super(message, { details: { field: "input" } }); + this.name = "ExampleInputError"; + } + } + + const cycle = new ExampleError("cycle", { + code: EXAMPLE_ERROR_CODE.cycle, + retryable: true, + }); + const input = new ExampleInputError("bad input"); + + expect(cycle).toBeInstanceOf(RiverError); + expect(cycle).toMatchObject({ + code: "example.cycle", + name: "ExampleError", + retryable: true, + }); + expectTypeOf(cycle.code).toEqualTypeOf<"example.cycle">(); + expect(input).toBeInstanceOf(ValidationError); + expect(input).toMatchObject({ + code: "validation", + name: "ExampleInputError", + }); + expectTypeOf().toEqualTypeOf(); + }); +}); diff --git a/js/src/errors.ts b/js/src/errors.ts new file mode 100644 index 000000000..371307b9e --- /dev/null +++ b/js/src/errors.ts @@ -0,0 +1,465 @@ +/** + * Stable error categories exposed by River. + * + * Every error River throws deliberately is a {@link RiverError} whose `code` + * is one of these values. Each subclass narrows `code` to its own category, so + * either `instanceof` or a `switch` on `code` identifies the failure. + */ +export const RIVER_ERROR_CODE = { + backendMismatch: "backend_mismatch", + configuration: "configuration", + database: "database", + extension: "extension", + jobAborted: "job_aborted", + jobAttemptFinished: "job_attempt_finished", + jobCancelled: "job_cancelled", + jobRunning: "job_running", + jobStuck: "job_stuck", + jobTimeout: "job_timeout", + lifecycle: "lifecycle", + migration: "migration", + payloadValidation: "payload_validation", + subscriptionLag: "subscription_lag", + transactionScope: "transaction_scope", + unknownJobKind: "unknown_job_kind", + unsupportedCapability: "unsupported_capability", + validation: "validation", +} as const; + +/** One of River's stable error categories. */ +export type RiverErrorCode = + (typeof RIVER_ERROR_CODE)[keyof typeof RIVER_ERROR_CODE]; + +/** Options accepted by {@link RiverError} and its subclasses. */ +export interface RiverErrorOptions< + Code extends string = RiverErrorCode, +> extends ErrorOptions { + /** Stable error category. */ + code: Code; + /** Structured, secret-free context such as an operation or job ID. */ + details?: Readonly>; + /** Whether retrying the failed operation is known to be safe. */ + retryable?: boolean; +} + +/** Options accepted by River error subclasses, which fix their own `code`. */ +export type RiverErrorSubclassOptions = Omit; + +/** + * Base class for every error River throws deliberately. + * + * Catch this class to handle all River failures, including database, + * migration, and job-abort errors. Internal invariant violations remain + * ordinary `Error` values so they stay visibly different. + * + * A companion package roots its errors here too, so one `instanceof + * RiverError` catches them. Where one of River's categories fits, it + * extends that subclass, such as {@link ValidationError}. Otherwise it + * extends `RiverError` with codes of its own, prefixed with its name and a + * dot, such as `"example.cycle"`, so they never collide with River's + * unprefixed codes; code switching on `code` keeps a default branch for + * such codes. + */ +export class RiverError extends Error { + /** Stable error category. */ + readonly code: Code; + /** Structured, secret-free context such as an operation or job ID. */ + readonly details?: Readonly>; + /** Whether retrying the failed operation is known to be safe. */ + readonly retryable: boolean; + + constructor(message: string, options: RiverErrorOptions) { + super(message, { cause: options.cause }); + this.name = "RiverError"; + this.code = options.code; + this.retryable = options.retryable ?? false; + if (options.details !== undefined) this.details = options.details; + } +} + +/** Whether retrying a failed River operation is explicitly safe. */ +export function isRetryableError( + error: unknown +): error is RiverError & { readonly retryable: true } { + return error instanceof RiverError && error.retryable; +} + +/** The selected backend cannot perform an operation required by the client. */ +export class UnsupportedCapabilityError extends RiverError<"unsupported_capability"> { + /** Backend that lacks the capability, such as `"prisma"`. */ + readonly backend: string; + /** Capability that was requested, such as `"runtime"`. */ + readonly capability: string; + + constructor( + backend: string, + capability: string, + options: RiverErrorSubclassOptions & { message?: string } = {} + ) { + const { message, ...rest } = options; + super( + message ?? + `backend ${JSON.stringify(backend)} does not support ${JSON.stringify(capability)}`, + { + ...rest, + code: RIVER_ERROR_CODE.unsupportedCapability, + details: { ...rest.details, backend, capability }, + } + ); + this.name = "UnsupportedCapabilityError"; + this.backend = backend; + this.capability = capability; + } +} + +/** An operation received a transaction belonging to another backend. */ +export class BackendMismatchError extends RiverError<"backend_mismatch"> { + /** Backend whose operation rejected the transaction. */ + readonly backend: string; + + constructor( + backend: string, + message: string, + options: RiverErrorSubclassOptions = {} + ) { + super(message, { + ...options, + code: RIVER_ERROR_CODE.backendMismatch, + details: { ...options.details, backend }, + }); + this.name = "BackendMismatchError"; + this.backend = backend; + } +} + +/** Options for {@link DatabaseOperationError}. */ +export interface DatabaseOperationErrorOptions extends RiverErrorSubclassOptions { + /** Backend that failed, such as `"postgres"` or `"sqlite"`. */ + backend: string; + /** River operation that failed, such as `"jobInsert"`. */ + operation: string; +} + +/** + * A database operation failed. + * + * The message never contains SQL text, bound values, or credentials; the + * underlying driver error is available as `cause`. `retryable` is true when + * the failure is transient (a lost connection, serialization failure, lock or + * statement timeout, or a busy SQLite database), so the whole operation can + * safely be attempted again. + */ +export class DatabaseOperationError extends RiverError<"database"> { + /** Backend that failed, such as `"postgres"` or `"sqlite"`. */ + readonly backend: string; + /** River operation that failed, such as `"jobInsert"`. */ + readonly operation: string; + + constructor(message: string, options: DatabaseOperationErrorOptions) { + const { backend, operation, ...rest } = options; + super(message, { + ...rest, + code: RIVER_ERROR_CODE.database, + details: { ...rest.details, backend, operation }, + }); + this.name = "DatabaseOperationError"; + this.backend = backend; + this.operation = operation; + } +} + +/** Options for {@link MigrationError}. */ +export interface MigrationErrorOptions extends RiverErrorSubclassOptions { + /** Backend being migrated, such as `"postgres"` or `"sqlite"`. */ + backend: string; + /** Migration operation that failed, such as `"migrate"` or `"validate"`. */ + operation: string; +} + +/** A migration could not be planned, applied, or validated. */ +export class MigrationError extends RiverError<"migration"> { + /** Backend being migrated, such as `"postgres"` or `"sqlite"`. */ + readonly backend: string; + /** Migration operation that failed, such as `"migrate"` or `"validate"`. */ + readonly operation: string; + + constructor(message: string, options: MigrationErrorOptions) { + const { backend, operation, ...rest } = options; + super(message, { + ...rest, + code: RIVER_ERROR_CODE.migration, + details: { ...rest.details, backend, operation }, + }); + this.name = "MigrationError"; + this.backend = backend; + this.operation = operation; + } +} + +/** Runtime lifecycle or supervised background failure. */ +export class LifecycleError extends RiverError<"lifecycle"> { + constructor(message: string, options: RiverErrorSubclassOptions = {}) { + super(message, { ...options, code: RIVER_ERROR_CODE.lifecycle }); + this.name = "LifecycleError"; + } +} + +/** A protected running job cannot be deleted while its attempt is active. */ +export class JobRunningError extends RiverError<"job_running"> { + readonly jobId: bigint; + + constructor(jobId: bigint) { + super(`River job ${jobId} is running and cannot be deleted`, { + code: RIVER_ERROR_CODE.jobRunning, + details: { jobId: jobId.toString(10) }, + }); + this.name = "JobRunningError"; + this.jobId = jobId; + } +} + +/** + * The error an attempt fails with when its handler ignored the aborted + * `signal` of a stopping client, such as `stop({ mode: "cancel" })`, until + * its executor ended it by force after the client's `jobStuckThreshold`, + * such as by terminating its worker thread. Unlike a handler that stops + * because of the abort, the attempt counts, and the retry policy and + * `maxAttempts` apply. `cause` is the abort reason the handler ignored. + */ +export class JobAbortedError extends RiverError<"job_aborted"> { + readonly jobId: bigint; + + constructor(jobId: bigint, options: ErrorOptions = {}) { + super("job aborted after ignoring cancellation", { + cause: options.cause, + code: RIVER_ERROR_CODE.jobAborted, + details: { jobId: jobId.toString(10) }, + }); + this.name = "JobAbortedError"; + this.jobId = jobId; + } +} + +/** + * Abort reason delivered to a handler's `signal` once its attempt finished, + * like River for Go cancelling a job's context when its executor returns, + * so work the handler left running stops instead of outliving the attempt. + */ +export class JobAttemptFinishedError extends RiverError<"job_attempt_finished"> { + readonly jobId: bigint; + + constructor(jobId: bigint) { + super(`River job ${jobId}'s attempt finished`, { + code: RIVER_ERROR_CODE.jobAttemptFinished, + details: { jobId: jobId.toString(10) }, + }); + this.name = "JobAttemptFinishedError"; + this.jobId = jobId; + } +} + +/** + * Abort reason delivered to a handler's `signal` when its job is cancelled + * remotely, for example with `client.cancel(id)` from any River client. + */ +export class JobCancelledError extends RiverError<"job_cancelled"> { + readonly jobId: bigint; + + constructor(jobId: bigint) { + super(`River job ${jobId} was cancelled`, { + code: RIVER_ERROR_CODE.jobCancelled, + details: { jobId: jobId.toString(10) }, + }); + this.name = "JobCancelledError"; + this.jobId = jobId; + } +} + +/** Abort reason delivered to a handler when its cooperative timeout expires. */ +export class JobTimeoutError extends RiverError<"job_timeout"> { + readonly jobId: bigint; + readonly timeout: Temporal.Duration; + + constructor(jobId: bigint, timeout: Temporal.Duration) { + super( + `River job ${jobId} exceeded its ${formatMilliseconds(timeout)} timeout`, + { + code: RIVER_ERROR_CODE.jobTimeout, + details: { jobId: jobId.toString(10), timeout: timeout.toString() }, + } + ); + this.name = "JobTimeoutError"; + this.jobId = jobId; + this.timeout = timeout; + } +} + +/** Observation reported when an attempt remains unsettled past its threshold. */ +export class JobStuckError extends RiverError<"job_stuck"> { + readonly jobId: bigint; + /** Time the attempt stayed unsettled after its timeout. */ + readonly threshold: Temporal.Duration; + /** Timeout that expired before the attempt became stuck. */ + readonly timeout: Temporal.Duration; + + constructor( + jobId: bigint, + timeout: Temporal.Duration, + threshold: Temporal.Duration + ) { + super( + `River job ${jobId} remained unsettled for ${formatMilliseconds(threshold)} after its ${formatMilliseconds(timeout)} timeout`, + { + code: RIVER_ERROR_CODE.jobStuck, + details: { + jobId: jobId.toString(10), + threshold: threshold.toString(), + timeout: timeout.toString(), + }, + } + ); + this.name = "JobStuckError"; + this.jobId = jobId; + this.threshold = threshold; + this.timeout = timeout; + } +} + +/** A runtime claimed a job whose kind has no registered worker. */ +export class UnknownJobKindError extends RiverError<"unknown_job_kind"> { + readonly kind: string; + + constructor(kind: string) { + super( + `job kind is not registered in the client's Workers bundle: ${kind}`, + { + code: RIVER_ERROR_CODE.unknownJobKind, + details: { kind }, + } + ); + this.name = "UnknownJobKindError"; + this.kind = kind; + } +} + +/** A work middleware, hook, or plugin failed. */ +export class ExtensionError extends RiverError<"extension"> { + constructor(message: string, options: RiverErrorSubclassOptions = {}) { + super(message, { ...options, code: RIVER_ERROR_CODE.extension }); + this.name = "ExtensionError"; + } +} + +/** A bounded subscription dropped events because its consumer fell behind. */ +export class SubscriptionLagError extends RiverError<"subscription_lag"> { + /** Number of events dropped since the previous delivered event. */ + readonly dropped: number; + + constructor(dropped: number) { + super(`River subscription dropped ${dropped} event(s)`, { + code: RIVER_ERROR_CODE.subscriptionLag, + details: { dropped }, + }); + this.name = "SubscriptionLagError"; + this.dropped = dropped; + } +} + +/** Why a {@link TransactionScopeError} was thrown. */ +export type TransactionScopeErrorReason = + /** + * River's own SQLite transaction stayed open across a turn of the event + * loop, because insert middleware or a hook awaited I/O while River held + * SQLite's write lock. River rolled the operation back to release it. + */ + | "event_loop_turn" + /** A transaction was begun inside one that is already open. */ + | "nested" + /** A value passed as `{ tx }` has no open transaction. */ + | "no_transaction" + /** + * River was called without `{ tx }` from inside a transaction that the + * call would have to wait for, such as River's own transaction around an + * insertion, which its insert middleware and hooks run inside. + */ + | "reentrant"; + +/** + * A database transaction was used in a way that can't work, such as calling + * River without `{ tx }` from inside insert middleware while River's own + * transaction holds SQLite's write lock. River fails the call at once + * instead of waiting for a lock that can't be released. Retrying the same + * code fails the same way. + */ +export class TransactionScopeError extends RiverError<"transaction_scope"> { + /** What was wrong with the transaction's use. */ + readonly reason: TransactionScopeErrorReason; + + constructor( + reason: TransactionScopeErrorReason, + message: string, + options: RiverErrorSubclassOptions = {} + ) { + super(message, { + ...options, + code: RIVER_ERROR_CODE.transactionScope, + details: { ...options.details, reason }, + }); + this.name = "TransactionScopeError"; + this.reason = reason; + } +} + +/** An invalid static client, job, driver, or worker configuration. */ +export class ConfigurationError extends RiverError<"configuration"> { + constructor(message: string, options: RiverErrorSubclassOptions = {}) { + super(message, { ...options, code: RIVER_ERROR_CODE.configuration }); + this.name = "ConfigurationError"; + } +} + +/** A value rejected at a public River boundary. */ +export class ValidationError extends RiverError<"validation"> { + constructor(message: string, options: RiverErrorSubclassOptions = {}) { + super(message, { ...options, code: RIVER_ERROR_CODE.validation }); + this.name = "ValidationError"; + } +} + +/** Where a job payload failed validation. */ +export type PayloadValidationPhase = "insert" | "work"; + +/** + * Job arguments rejected by their job definition's schema or decoder. + * + * `phase` is `"insert"` when a producer passed invalid arguments and `"work"` + * when a persisted job (possibly inserted by another language or an older + * producer) failed validation before its handler ran. + */ +export class PayloadValidationError extends RiverError<"payload_validation"> { + /** Kind of the job whose arguments were rejected. */ + readonly kind: string; + /** Whether validation failed while inserting or before working. */ + readonly phase: PayloadValidationPhase; + + constructor( + kind: string, + phase: PayloadValidationPhase, + message: string, + options: RiverErrorSubclassOptions = {} + ) { + super(message, { + ...options, + code: RIVER_ERROR_CODE.payloadValidation, + details: { ...options.details, kind, phase }, + }); + this.name = "PayloadValidationError"; + this.kind = kind; + this.phase = phase; + } +} + +/** A duration in milliseconds for an error message, such as `1500 ms`. */ +function formatMilliseconds(duration: Temporal.Duration): string { + return `${duration.total("milliseconds")} ms`; +} diff --git a/js/src/identifiers.test.ts b/js/src/identifiers.test.ts new file mode 100644 index 000000000..0ffbd36bd --- /dev/null +++ b/js/src/identifiers.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; + +import { ValidationError } from "./errors.js"; +import { queueLookupName, validateQueueName } from "./identifiers.js"; + +describe("validateQueueName", () => { + // Mirrors Go River's `^(?:[a-z0-9])+(?:[_|\-]?[a-z0-9]+)*$` and its + // 64-character limit. + it.each([ + "0", + "a", + "a-b", + "a_b", + "a|b", + "default", + "tenant|priority_emails-2", + "a".repeat(64), + ])("accepts %j like Go", (name) => { + expect(validateQueueName(name)).toBe(name); + }); + + it.each([ + "", + "-a", + "A", + "_a", + "a b", + "a-", + "a.b", + "a__b", + "a_|b", + "a|", + "|a", + "a".repeat(65), + ])("rejects %j like Go", (name) => { + expect(() => validateQueueName(name)).toThrow(ValidationError); + }); + + it("rejects the all-queues sentinel as a queue name", () => { + expect(() => validateQueueName("*")).toThrow(ValidationError); + }); +}); + +describe("queueLookupName", () => { + it("accepts any string, as Go looks queues up without validating", () => { + for (const name of ["*", "", "Not A Queue!", "x".repeat(200)]) { + expect(queueLookupName(name)).toBe(name); + } + expect(() => queueLookupName(1 as unknown as string)).toThrow( + ValidationError + ); + }); +}); diff --git a/js/src/identifiers.ts b/js/src/identifiers.ts new file mode 100644 index 000000000..b85ca5fe5 --- /dev/null +++ b/js/src/identifiers.ts @@ -0,0 +1,40 @@ +import { ValidationError } from "./errors.js"; + +/** Go River's queue grammar: `|` separates segments like `_` and `-`. */ +const QUEUE_NAME_RE = /^[a-z0-9]+(?:[_|-]?[a-z0-9]+)*$/; +const USER_SPECIFIED_ID_OR_KIND_RE = new RegExp( + "^[A-Za-z0-9_][A-Za-z0-9_\\-\\[\\]<>/.·:+]+$" +); + +/** @internal Match River's portable job-kind and user-ID grammar. */ +export function isUserSpecifiedIdOrKind(value: string): boolean { + return USER_SPECIFIED_ID_OR_KIND_RE.test(value); +} + +/** @internal Validate the queue grammar shared by inserts and runtimes. */ +export function validateQueueName(value: string): string { + if (typeof value !== "string" || value.length === 0) { + throw new ValidationError("queue name must not be empty"); + } + if (value.length > 64) { + throw new ValidationError("queue name must be at most 64 characters"); + } + if (!QUEUE_NAME_RE.test(value)) { + throw new ValidationError( + "queue name must contain lowercase letters and numbers separated by underscores, pipes, or hyphens" + ); + } + return value; +} + +/** + * @internal Check a queue name that only looks up an existing queue (get, + * pause, resume, update). Like River for Go, the name isn't checked against + * the queue grammar: a name no queue can have is simply not found. + */ +export function queueLookupName(value: string): string { + if (typeof value !== "string") { + throw new ValidationError("queue name must be a string"); + } + return value; +} diff --git a/js/src/runtime-support.test.ts b/js/src/runtime-support.test.ts new file mode 100644 index 000000000..68dd37241 --- /dev/null +++ b/js/src/runtime-support.test.ts @@ -0,0 +1,26 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { ConfigurationError } from "./errors.js"; +import { assertRuntimeSupport } from "./runtime-support.js"; + +describe("assertRuntimeSupport", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("accepts the supported Node runtime and native Temporal implementation", () => { + expect(() => assertRuntimeSupport()).not.toThrow(); + expect( + Temporal.Instant.from("2026-08-30T18:00:00.123456Z").toString() + ).toBe("2026-08-30T18:00:00.123456Z"); + }); + + it("points runtimes without native Temporal at the requirements", () => { + vi.stubGlobal("Temporal", undefined); + + expect(() => assertRuntimeSupport()).toThrow(ConfigurationError); + expect(() => assertRuntimeSupport()).toThrow( + /native Temporal API.*typeof Temporal.*river\/tree\/master\/js#requirements/u + ); + }); +}); diff --git a/js/src/runtime-support.ts b/js/src/runtime-support.ts new file mode 100644 index 000000000..eb63b0230 --- /dev/null +++ b/js/src/runtime-support.ts @@ -0,0 +1,53 @@ +import { ConfigurationError } from "./errors.js"; + +const REQUIREMENTS_URL = + "https://github.com/riverqueue/river/tree/master/js#requirements"; + +/** + * Verify the runtime features River relies on before database work begins. + * + * Official Node.js 26 builds expose Temporal by default. This explicit check + * gives custom builds and unsupported runtimes a useful failure instead of a + * later `ReferenceError` or a lossy timestamp fallback. + */ +export function assertRuntimeSupport(): void { + const major = Number.parseInt(process.versions.node.split(".")[0] ?? "", 10); + if (!Number.isSafeInteger(major) || major < 26) { + throw new ConfigurationError( + `River requires Node.js 26 or newer; found ${process.versions.node} ` + + `(see ${REQUIREMENTS_URL})` + ); + } + + const temporal = Reflect.get(globalThis, "Temporal") as + | { + Instant?: { + from?: unknown; + fromEpochNanoseconds?: unknown; + }; + Now?: { instant?: unknown }; + } + | undefined; + if ( + temporal === undefined || + typeof temporal.Instant?.from !== "function" || + typeof temporal.Instant.fromEpochNanoseconds !== "function" || + typeof temporal.Now?.instant !== "function" + ) { + throw new ConfigurationError( + "River requires a Node.js build with the native Temporal API enabled; " + + "official Node.js 26 binaries include it, but some builds compiled " + + 'from source do not. `node -p "typeof Temporal"` must print "object" ' + + `(see ${REQUIREMENTS_URL})` + ); + } + + const rawJSON: unknown = Reflect.get(JSON, "rawJSON"); + const isRawJSON: unknown = Reflect.get(JSON, "isRawJSON"); + if (typeof rawJSON !== "function" || typeof isRawJSON !== "function") { + throw new ConfigurationError( + "River requires a Node.js build with native JSON raw-number support " + + `(see ${REQUIREMENTS_URL})` + ); + } +} diff --git a/js/src/runtime/validation.ts b/js/src/runtime/validation.ts new file mode 100644 index 000000000..8db084ac6 --- /dev/null +++ b/js/src/runtime/validation.ts @@ -0,0 +1,27 @@ +/** + * Argument validation shared by runtime configuration and operations. + */ +import { ValidationError } from "../errors.js"; + +export function requireNonNegativeInteger(name: string, value: number): number { + if (!Number.isSafeInteger(value) || value < 0) { + throw new ValidationError(`${name} must be a non-negative safe integer`); + } + return value; +} + +export function requirePositiveInteger(name: string, value: number): number { + if (!Number.isSafeInteger(value) || value < 1) { + throw new ValidationError(`${name} must be a positive safe integer`); + } + return value; +} + +/** Reject options whose keys are present with an `undefined` value. */ +export function rejectExplicitUndefined(value: object): void { + for (const [key, item] of Object.entries(value)) { + if (item === undefined) { + throw new ValidationError(`${key} must be omitted instead of undefined`); + } + } +} From 9144c22f97d99a3e192c3c1de64c13f6ac1fa11c Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sun, 4 Oct 2026 17:12:55 -0500 Subject: [PATCH 16/43] add a pino-style logger interface and runtime metrics Define the `Logger` River writes to with pino's argument order, an attributes object first and then the message, so `logger: pino()` and most structured loggers work as is. Without a configured logger, `warn` and `error` go to `console`; `logger: false` silences them. Publish typed runtime metrics (fetch counts and durations, dropped and requeued completions) on the `riverqueue:metric` diagnostics channel, which costs nothing without a subscriber. --- js/package.json | 1 + js/pnpm-lock.yaml | 92 +++++++++++++++++++++++++++++++ js/src/logger.test.ts | 75 ++++++++++++++++++++++++++ js/src/logger.ts | 123 ++++++++++++++++++++++++++++++++++++++++++ js/src/metrics.ts | 31 +++++++++++ 5 files changed, 322 insertions(+) create mode 100644 js/src/logger.test.ts create mode 100644 js/src/logger.ts create mode 100644 js/src/metrics.ts diff --git a/js/package.json b/js/package.json index c7a8194b5..23eff1678 100644 --- a/js/package.json +++ b/js/package.json @@ -55,6 +55,7 @@ "eslint-config-prettier": "^10.1.8", "fast-check": "^4.10.2", "pg": "^8.22.0", + "pino": "^10.3.1", "prettier": "^3.9.6", "typescript": "^6.0.3", "typescript-eslint": "^8.65.0", diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml index 99ba43b62..1d2d04450 100644 --- a/js/pnpm-lock.yaml +++ b/js/pnpm-lock.yaml @@ -37,6 +37,9 @@ importers: pg: specifier: ^8.22.0 version: 8.22.0 + pino: + specifier: ^10.3.1 + version: 10.3.1 prettier: specifier: ^3.9.6 version: 3.9.6 @@ -251,6 +254,9 @@ packages: '@oxc-project/types@0.133.0': resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@prisma/adapter-pg@7.9.1': resolution: {integrity: sha512-Ho2RK1KanQxLNSC0sR5bpiiVep10sWPLXCcxK+KXfI/Q69TMRbiafSvLPv3V9snimX72rMCqGlyJ4sBO4lKTAw==} @@ -704,6 +710,10 @@ packages: ast-v8-to-istanbul@1.0.7: resolution: {integrity: sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==} + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + aws-ssl-profiles@1.1.2: resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} engines: {node: '>= 6.0.0'} @@ -1202,6 +1212,10 @@ packages: ohash@2.0.11: resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1276,6 +1290,16 @@ packages: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + pkg-types@2.3.1: resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} @@ -1329,6 +1353,9 @@ packages: typescript: optional: true + process-warning@5.1.0: + resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==} + proper-lockfile@4.1.2: resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} @@ -1342,6 +1369,9 @@ packages: pure-rand@8.4.2: resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + rc9@3.0.1: resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==} @@ -1358,6 +1388,13 @@ packages: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + remeda@2.33.4: resolution: {integrity: sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==} @@ -1385,6 +1422,10 @@ packages: resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} hasBin: true + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -1414,6 +1455,9 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1439,6 +1483,10 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + thread-stream@4.2.0: + resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} + engines: {node: '>=20'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1723,6 +1771,8 @@ snapshots: '@oxc-project/types@0.133.0': {} + '@pinojs/redact@0.4.0': {} + '@prisma/adapter-pg@7.9.1': dependencies: '@prisma/driver-adapter-utils': 7.9.1 @@ -2280,6 +2330,8 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 + atomic-sleep@1.0.0: {} + aws-ssl-profiles@1.1.2: {} balanced-match@4.0.4: {} @@ -2722,6 +2774,8 @@ snapshots: ohash@2.0.11: {} + on-exit-leak-free@2.1.2: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -2790,6 +2844,26 @@ snapshots: picomatch@4.0.7: {} + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.1.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.2.0 + pkg-types@2.3.1: dependencies: confbox: 0.2.4 @@ -2856,6 +2930,8 @@ snapshots: - react - react-dom + process-warning@5.1.0: {} + proper-lockfile@4.1.2: dependencies: graceful-fs: 4.2.11 @@ -2868,6 +2944,8 @@ snapshots: pure-rand@8.4.2: {} + quick-format-unescaped@4.0.4: {} + rc9@3.0.1: dependencies: defu: 6.1.7 @@ -2882,6 +2960,10 @@ snapshots: readdirp@5.0.0: {} + real-require@0.2.0: {} + + real-require@1.0.0: {} + remeda@2.33.4: {} require-from-string@2.0.2: {} @@ -2917,6 +2999,8 @@ snapshots: dependencies: ret: 0.5.0 + safe-stable-stringify@2.5.0: {} + safer-buffer@2.1.2: {} scheduler@0.27.0: {} @@ -2935,6 +3019,10 @@ snapshots: signal-exit@4.1.0: {} + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + source-map-js@1.2.1: {} split2@4.2.0: {} @@ -2951,6 +3039,10 @@ snapshots: dependencies: has-flag: 4.0.0 + thread-stream@4.2.0: + dependencies: + real-require: 1.0.0 + tinybench@2.9.0: {} tinyexec@1.3.0: {} diff --git a/js/src/logger.test.ts b/js/src/logger.test.ts new file mode 100644 index 000000000..045ea009d --- /dev/null +++ b/js/src/logger.test.ts @@ -0,0 +1,75 @@ +import { Writable } from "node:stream"; + +import pino from "pino"; +import { describe, expect, expectTypeOf, it, vi } from "vitest"; + +import { + consoleLogger, + createWorkLogger, + internalLogger, + isLoggerOption, + resolveLogger, + type Logger, +} from "./logger.js"; + +describe("Logger", () => { + it("accepts pino directly and writes its structured records", () => { + const lines: string[] = []; + const destination = new Writable({ + write(chunk: Buffer, _encoding, callback) { + lines.push(chunk.toString("utf8")); + callback(); + }, + }); + const logger = pino(destination); + expectTypeOf(logger).toExtend(); + + const work = createWorkLogger(logger, { jobId: "42", jobKind: "email" }); + work.info("plain message"); + work.warn({ attempt: 2 }, "with attributes"); + + const records = lines.map( + (line) => JSON.parse(line) as Record + ); + expect(records[0]).toMatchObject({ + jobId: "42", + jobKind: "email", + msg: "plain message", + }); + expect(records[1]).toMatchObject({ attempt: 2, msg: "with attributes" }); + }); + + it("defaults to console warnings and errors, and false silences", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const error = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + const info = vi.spyOn(console, "info").mockImplementation(() => undefined); + try { + const logger = internalLogger(resolveLogger(undefined)); + logger.info("quiet"); + logger.warn("careful", { queue: "default" }); + logger.error("broken"); + expect(resolveLogger(undefined)).toBe(consoleLogger); + expect(info).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith("[riverqueue] careful", { + queue: "default", + }); + expect(error).toHaveBeenCalledWith("[riverqueue] broken", {}); + + internalLogger(resolveLogger(false)).error("silenced"); + expect(error).toHaveBeenCalledTimes(1); + } finally { + warn.mockRestore(); + error.mockRestore(); + info.mockRestore(); + } + }); + + it("validates logger options", () => { + expect(isLoggerOption(false)).toBe(true); + expect(isLoggerOption(consoleLogger)).toBe(true); + expect(isLoggerOption({ info: () => undefined })).toBe(false); + expect(isLoggerOption(null)).toBe(false); + }); +}); diff --git a/js/src/logger.ts b/js/src/logger.ts new file mode 100644 index 000000000..ac9051e8b --- /dev/null +++ b/js/src/logger.ts @@ -0,0 +1,123 @@ +/** Structured fields attached to a log message. */ +export type LogAttributes = Readonly>; + +/** A log severity River writes at. */ +export type LogLevel = "debug" | "error" | "info" | "warn"; + +/** + * A structured logger using pino's argument order: an attributes object + * first, then the message. Pino, Bunyan, and most structured loggers satisfy + * it directly, so `logger: pino()` works as is. + * + * River logs background failures (database retries, hook failures, dropped + * completions) at `warn` and `error`. Without a configured logger those two + * levels go to `console`; pass `logger: false` to silence them. + */ +export interface Logger { + debug(attributes: LogAttributes, message: string): void; + error(attributes: LogAttributes, message: string): void; + info(attributes: LogAttributes, message: string): void; + warn(attributes: LogAttributes, message: string): void; +} + +/** One level of a {@link WorkLogger}: `(message)` or `(attributes, message)`. */ +export interface WorkLogFunction { + (message: string): void; + (attributes: LogAttributes, message: string): void; +} + +/** + * The job-scoped logger in a work context. It writes to the client's + * {@link Logger} with `jobId`, `jobKind`, and `attempt` attached, and accepts + * either `logger.info("message")` or `logger.info({ key }, "message")`. + */ +export interface WorkLogger { + readonly debug: WorkLogFunction; + readonly error: WorkLogFunction; + readonly info: WorkLogFunction; + readonly warn: WorkLogFunction; +} + +/** @internal River's own call sites: message first, optional attributes. */ +export interface InternalLogger { + debug(message: string, attributes?: LogAttributes): void; + error(message: string, attributes?: LogAttributes): void; + info(message: string, attributes?: LogAttributes): void; + warn(message: string, attributes?: LogAttributes): void; +} + +const LEVELS: readonly LogLevel[] = ["debug", "error", "info", "warn"]; + +/** The default logger: warnings and errors to `console`, nothing else. */ +export const consoleLogger: Logger = Object.freeze({ + debug: () => undefined, + error: (attributes: LogAttributes, message: string) => { + console.error(`[riverqueue] ${message}`, attributes); + }, + info: () => undefined, + warn: (attributes: LogAttributes, message: string) => { + console.warn(`[riverqueue] ${message}`, attributes); + }, +}); + +const silentLogger: Logger = Object.freeze({ + debug: () => undefined, + error: () => undefined, + info: () => undefined, + warn: () => undefined, +}); + +/** @internal Resolve the configured logger option. */ +export function resolveLogger(option: Logger | false | undefined): Logger { + if (option === false) return silentLogger; + return option ?? consoleLogger; +} + +/** @internal Adapt a configured logger to River's call sites. */ +export function internalLogger(logger: Logger): InternalLogger { + const call = + (level: LogLevel) => + (message: string, attributes: LogAttributes = {}): void => { + logger[level](attributes, message); + }; + return Object.freeze({ + debug: call("debug"), + error: call("error"), + info: call("info"), + warn: call("warn"), + }); +} + +/** @internal Validate a logger option at configuration time. */ +export function isLoggerOption(value: unknown): value is Logger | false { + if (value === false) return true; + return ( + value !== null && + typeof value === "object" && + LEVELS.every( + (level) => typeof (value as Record)[level] === "function" + ) + ); +} + +/** @internal Bind job attributes onto the client's logger for handlers. */ +export function createWorkLogger( + logger: Logger, + bound: LogAttributes +): WorkLogger { + const call = + (level: LogLevel): WorkLogFunction => + (first: LogAttributes | string, message?: string): void => { + if (typeof first === "string") { + logger[level]({ ...bound }, first); + } else { + logger[level]({ ...bound, ...first }, message ?? ""); + } + }; + return Object.freeze({ + debug: call("debug"), + error: call("error"), + info: call("info"), + warn: call("warn"), + }); +} diff --git a/js/src/metrics.ts b/js/src/metrics.ts new file mode 100644 index 000000000..340acbf46 --- /dev/null +++ b/js/src/metrics.ts @@ -0,0 +1,31 @@ +import { channel } from "node:diagnostics_channel"; + +/** + * Strongly typed runtime observations. + * + * Fetch metrics are emitted after every successful claim. Completion metrics + * report persistence failures: a requeued batch is retried, while dropped + * completions leave their jobs `running` until the rescuer recovers them. + */ +export type RiverMetric = + | { + readonly count: number; + readonly name: "job_completion_dropped" | "job_completion_requeued"; + } + | { + readonly duration: Temporal.Duration; + readonly name: "job_get_available_duration"; + readonly queue: string; + } + | { + readonly count: number; + readonly name: "job_get_available_count"; + readonly queue: string; + }; + +const riverMetricChannel = channel("riverqueue:metric"); + +/** @internal Publish to Node's zero-subscriber-cost diagnostics channel. */ +export function publishRiverMetric(metric: RiverMetric): void { + riverMetricChannel.publish(metric); +} From e68079dbf2188b1dc82083325501b54332843d26 Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sun, 4 Oct 2026 17:13:09 -0500 Subject: [PATCH 17/43] add River's JSON value model Job args, metadata, and output cross a JSON boundary that every River implementation reads and writes. Validate and copy values into that domain with `toJsonValue`/`toJsonObject`, which reject accessors, class instances, sparse arrays, cycles, non-finite numbers, unsafe integers, and `bigint` instead of relying on `JSON.stringify`'s lossy coercions. Non-finite numbers fail with Go's `encoding/json` wording. Numbers that can't round-trip through a JavaScript `number`, such as a 64-bit ID written by another language, parse to an `ExactJsonNumber` built on Node's raw-JSON primitive, so they pass back through River unchanged. Encode unique-key hash inputs and job list cursors the way River Go does: Go's escaping of `<`, `>`, `&`, U+2028, and U+2029, and top-level keys sorted bytewise and written as `sjson` assembles them, so every implementation hashes the same bytes. Property tests cover parsing, canonical decimals, and the unique encodings. --- js/src/json.property.test.ts | 444 +++++++++++++++++++++++++++ js/src/json.test.ts | 203 +++++++++++++ js/src/json.ts | 572 +++++++++++++++++++++++++++++++++++ 3 files changed, 1219 insertions(+) create mode 100644 js/src/json.property.test.ts create mode 100644 js/src/json.test.ts create mode 100644 js/src/json.ts diff --git a/js/src/json.property.test.ts b/js/src/json.property.test.ts new file mode 100644 index 000000000..6234dbbad --- /dev/null +++ b/js/src/json.property.test.ts @@ -0,0 +1,444 @@ +import fc from "fast-check"; +import { describe, expect, it } from "vitest"; + +import { + exactJsonNumber, + isExactJsonNumber, + jsonNumberToBigInt, + jsonValuesEqual, + JsonValueError, + parseJson, + stringifyJson, + toJsonObject, + toJsonValue, +} from "./json.js"; +import type { JsonValue } from "./json.js"; + +const INT8_MAX = 9_223_372_036_854_775_807n; +const INT8_MIN = -9_223_372_036_854_775_808n; +const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER); + +// Keys that look like prototype members, identifiers, numbers, and non-BMP +// text, so paths, ordering, and prototype handling all get exercised. +const keyArbitrary = fc.oneof( + { weight: 1, arbitrary: fc.constantFrom("__proto__", "constructor", "") }, + { weight: 1, arbitrary: fc.constantFrom("prototype", "toString", "10", "2") }, + { weight: 4, arbitrary: fc.string({ maxLength: 6 }) }, + { weight: 2, arbitrary: fc.string({ maxLength: 4, unit: "binary" }) } +); + +const numberArbitrary: fc.Arbitrary = fc.oneof( + fc.integer(), + fc.maxSafeInteger(), + fc + .double({ noDefaultInfinity: true, noNaN: true }) + .filter((value) => !Number.isInteger(value) || Number.isSafeInteger(value)), + fc + .bigInt({ max: INT8_MAX, min: INT8_MIN }) + .map((value) => exactJsonNumber(value.toString(10))) +); + +const leafArbitrary: fc.Arbitrary = fc.oneof( + fc.constant(null), + fc.boolean(), + numberArbitrary, + fc.string({ maxLength: 8 }), + fc.string({ maxLength: 6, unit: "binary" }), + fc.constantFrom("<&>", "\u2028\u2029", "\u{1f600}") +); + +/** Plain objects built with own data properties, even for `__proto__`. */ +function objectFromEntries( + entries: readonly (readonly [string, JsonValue])[], + nullPrototype: boolean +): Record { + const result = (nullPrototype ? Object.create(null) : {}) as Record< + string, + JsonValue + >; + for (const [key, value] of entries) { + Object.defineProperty(result, key, { + configurable: true, + enumerable: true, + value, + writable: true, + }); + } + return result; +} + +const { json: jsonArbitrary } = fc.letrec<{ + array: JsonValue[]; + json: JsonValue; + object: Record; +}>((tie) => ({ + array: fc.array(tie("json"), { maxLength: 4 }), + json: fc.oneof( + { depthSize: "small", withCrossShrink: true }, + leafArbitrary, + tie("array"), + tie("object") + ), + object: fc + .tuple( + fc.uniqueArray(fc.tuple(keyArbitrary, tie("json")), { + maxLength: 5, + selector: ([key]) => key, + }), + fc.boolean() + ) + .map(([entries, nullPrototype]) => + objectFromEntries(entries, nullPrototype) + ), +})); + +const jsonObjectArbitrary = fc + .tuple( + fc.uniqueArray(fc.tuple(keyArbitrary, jsonArbitrary), { + maxLength: 6, + selector: ([key]) => key, + }), + fc.boolean() + ) + .map(([entries, nullPrototype]) => objectFromEntries(entries, nullPrototype)); + +/** Recursively rebuild objects with their keys in a different order. */ +function reorderKeys(value: JsonValue, seed: number): JsonValue { + if (value === null || typeof value !== "object" || isExactJsonNumber(value)) + return value; + if (Array.isArray(value)) + return value.map((item, index) => reorderKeys(item, seed + index)); + const keys = Object.keys(value); + const rotation = keys.length === 0 ? 0 : seed % keys.length; + const reordered = [...keys.slice(rotation), ...keys.slice(0, rotation)] + .reverse() + .map((key, index) => { + const child = value[key]; + if (child === undefined) throw new Error("unexpected missing key"); + return [key, reorderKeys(child, seed + index + 1)] as const; + }); + return objectFromEntries(reordered, Object.getPrototypeOf(value) === null); +} + +/** One step from a container to the child holding a planted value. */ +type PathSegment = + | { + readonly key: string; + readonly kind: "key"; + readonly siblings: readonly JsonValue[]; + } + | { readonly before: readonly JsonValue[]; readonly kind: "index" }; + +const pathSegmentArbitrary: fc.Arbitrary = fc.oneof( + fc.record({ + key: keyArbitrary, + kind: fc.constant("key" as const), + siblings: fc.array(leafArbitrary, { maxLength: 2 }), + }), + fc.record({ + before: fc.array(leafArbitrary, { maxLength: 2 }), + kind: fc.constant("index" as const), + }) +); + +/** Documented JSONPath-like location format of {@link JsonValueError}. */ +function pathOf(segments: readonly PathSegment[]): string { + let path = "$"; + for (const segment of segments) { + if (segment.kind === "key") { + path += /^[A-Za-z_$][\w$]*$/.test(segment.key) + ? `.${segment.key}` + : `[${JSON.stringify(segment.key)}]`; + } else { + path += `[${segment.before.length}]`; + } + } + return path; +} + +/** Place `leaf` at the end of `segments`, surrounded by valid siblings. */ +function plant(leaf: unknown, segments: readonly PathSegment[]): unknown { + let value = leaf; + for (const segment of [...segments].reverse()) { + if (segment.kind === "key") { + const entries: [string, unknown][] = segment.siblings.map( + (sibling, index) => [`${segment.key}\u0000${index}`, sibling] + ); + entries.splice(entries.length >> 1, 0, [segment.key, value]); + value = objectFromEntries( + entries as [string, JsonValue][], + segment.siblings.length % 2 === 0 + ); + } else { + value = [...segment.before, value, null]; + } + } + return value; +} + +describe("River JSON properties", () => { + it("copies every valid value into an equal null-prototype value", () => { + fc.assert( + fc.property(jsonArbitrary, (value) => { + const copy = toJsonValue(value); + expect(jsonValuesEqual(copy, value)).toBe(true); + expect(stringifyJson(copy)).toBe(JSON.stringify(value)); + const visit = (item: JsonValue): void => { + if (item === null || typeof item !== "object") return; + if (isExactJsonNumber(item)) return; + if (Array.isArray(item)) { + item.forEach(visit); + return; + } + expect(Object.getPrototypeOf(item)).toBeNull(); + Object.values(item).forEach(visit); + }; + visit(copy); + }), + { numRuns: 300 } + ); + }); + + it("round-trips serialized values through the exact parser", () => { + fc.assert( + fc.property(jsonArbitrary, (value) => { + const text = stringifyJson(value); + const parsed = parseJson(text); + expect(jsonValuesEqual(parsed, value)).toBe(true); + // One pass may normalize a non-canonical exact token (`1.0` reads + // as `1`); after that, serialization is a fixed point. + const normalized = stringifyJson(parsed); + expect(stringifyJson(parseJson(normalized))).toBe(normalized); + }), + { numRuns: 300 } + ); + }); + + it("parses every int8 value exactly and every safe integer as a number", () => { + const aroundSafeLimit = fc + .integer({ max: 4096, min: -4096 }) + .chain((offset) => + fc.constantFrom(MAX_SAFE + BigInt(offset), -MAX_SAFE - BigInt(offset)) + ); + const aroundInt8Limit = fc + .bigInt({ max: 4096n, min: 0n }) + .chain((offset) => fc.constantFrom(INT8_MAX - offset, INT8_MIN + offset)); + fc.assert( + fc.property( + fc.oneof( + fc.bigInt({ max: INT8_MAX, min: INT8_MIN }), + aroundSafeLimit, + aroundInt8Limit + ), + (integer) => { + const parsed = parseJson(`{"id":${integer}}`); + if (parsed === null || typeof parsed !== "object") { + throw new Error("expected an object"); + } + const id = (parsed as Record).id; + if ( + id === undefined || + !(typeof id === "number" || isExactJsonNumber(id)) + ) { + throw new Error("expected a JSON number"); + } + expect(jsonNumberToBigInt(id)).toBe(integer); + expect(typeof id === "number").toBe( + integer <= MAX_SAFE && integer >= -MAX_SAFE + ); + expect(stringifyJson(parsed)).toBe(`{"id":${integer}}`); + } + ), + { numRuns: 500 } + ); + }); + + it("treats every spelling of one decimal value as equal", () => { + // mantissa × 10^exponent written with shifted decimal points, padding + // zeros, and exponent forms. + const spellings = (mantissa: bigint, exponent: number) => { + const sign = mantissa < 0n ? "-" : ""; + const digits = (mantissa < 0n ? -mantissa : mantissa).toString(); + return [ + `${sign}${digits}e${exponent}`, + `${sign}${digits}0E${exponent - 1}`, + `${sign}${digits}e+${exponent}`.replace("e+-", "e-"), + `${sign}0.${digits}e${exponent + digits.length}`, + `${sign}${digits}.000e${exponent}`, + ].map(exactJsonNumber); + }; + fc.assert( + fc.property( + fc + .bigInt({ max: 10n ** 30n, min: -(10n ** 30n) }) + .filter((mantissa) => mantissa !== 0n), + fc.integer({ max: 40, min: -40 }), + (mantissa, exponent) => { + const forms = spellings(mantissa, exponent); + for (const left of forms) { + for (const right of forms) { + expect(jsonValuesEqual(left, right)).toBe(true); + } + } + const different = exactJsonNumber( + `${mantissa * 10n + (mantissa < 0n ? -1n : 1n)}e${exponent - 1}` + ); + expect(jsonValuesEqual(forms[0] as JsonValue, different)).toBe(false); + } + ), + { numRuns: 300 } + ); + }); + + it("compares by value, independent of object key order", () => { + fc.assert( + fc.property(jsonArbitrary, fc.nat(), (value, seed) => { + const reordered = reorderKeys(value, seed); + expect(jsonValuesEqual(value, reordered)).toBe(true); + expect(jsonValuesEqual(reordered, value)).toBe(true); + // Wrapping any value changes it. + expect(jsonValuesEqual(value, [value])).toBe(false); + expect(jsonValuesEqual([value], value)).toBe(false); + }), + { numRuns: 300 } + ); + }); + + it("detects a change to any single leaf", () => { + fc.assert( + fc.property( + fc.array(pathSegmentArbitrary, { maxLength: 4 }), + leafArbitrary, + (segments, leaf) => { + const original = toJsonValue(plant(leaf, segments)); + const changed = toJsonValue(plant([leaf], segments)); + expect(jsonValuesEqual(original, changed)).toBe(false); + expect(jsonValuesEqual(changed, original)).toBe(false); + } + ), + { numRuns: 300 } + ); + }); + + it("omits undefined properties at any depth", () => { + fc.assert( + fc.property( + fc.array(pathSegmentArbitrary, { maxLength: 4 }), + leafArbitrary, + keyArbitrary, + (segments, leaf, extraKey) => { + const container = plant( + objectFromEntries([["kept", leaf]], false), + segments + ); + const withUndefined = plant( + Object.defineProperty( + objectFromEntries([["kept", leaf]], false), + extraKey === "kept" ? "kept\u0000" : extraKey, + { enumerable: true, value: undefined } + ), + segments + ); + expect(stringifyJson(withUndefined)).toBe(stringifyJson(container)); + } + ), + { numRuns: 200 } + ); + }); + + it("rejects an invalid value anywhere and reports its path", () => { + const sparse = () => { + const array = new Array(2); + array[1] = 1; + return array; + }; + const cyclic = () => { + const value: Record = {}; + value.self = value; + return value; + }; + const invalidLeaf = fc.oneof( + fc.bigInt().map((value) => ({ at: "", value })), + fc + .constantFrom( + Number.NaN, + Number.POSITIVE_INFINITY, + Number.NEGATIVE_INFINITY + ) + .map((value) => ({ at: "", value })), + fc + .oneof( + fc.bigInt({ max: INT8_MAX, min: MAX_SAFE + 1n }), + fc.bigInt({ max: -MAX_SAFE - 1n, min: INT8_MIN }) + ) + .map((value) => ({ at: "", value: Number(value) })), + fc.integer({ max: 0xdfff, min: 0xd800 }).map((code) => ({ + at: "", + value: `a${String.fromCharCode(code)}`, + })), + fc.constant({ at: "", value: () => undefined }), + fc.constant({ at: "", value: Symbol("leaf") }), + fc.constant({ at: "", value: new Date(0) }), + fc.constant({ at: "", value: new Map() }), + fc.constant({ + at: ".value", + value: Object.defineProperty({}, "value", { + enumerable: true, + get: () => 1, + }), + }), + fc.constant({ at: "[0]", value: sparse() }), + fc.constant({ at: "[0]", value: [undefined] }), + fc.constant({ at: ".self", value: cyclic() }), + fc.integer({ max: 0xdfff, min: 0xd800 }).map((code) => ({ + at: " key", + value: objectFromEntries([[String.fromCharCode(code), 1]], true), + })), + fc.constant({ + at: "", + value: (JSON as unknown as { rawJSON(text: string): unknown }).rawJSON( + '"text"' + ), + }) + ); + fc.assert( + fc.property( + fc.array(pathSegmentArbitrary, { maxLength: 4 }), + invalidLeaf, + (segments, { at, value }) => { + const planted = plant(value, segments); + const expectedPath = `${pathOf(segments)}${at}`; + for (const encode of [toJsonValue, stringifyJson]) { + let thrown: unknown; + try { + encode(planted); + } catch (error: unknown) { + thrown = error; + } + expect(thrown).toBeInstanceOf(JsonValueError); + expect((thrown as JsonValueError).path).toBe(expectedPath); + } + } + ), + { numRuns: 400 } + ); + }); + + it("keeps prototype-named keys as data without polluting prototypes", () => { + fc.assert( + fc.property(jsonObjectArbitrary, jsonArbitrary, (object, payload) => { + const text = `{"__proto__":${stringifyJson(payload)},"rest":${stringifyJson(object)}}`; + const parsed = parseJson(text); + const copied = toJsonObject(parsed); + expect(Object.hasOwn(copied, "__proto__")).toBe(true); + expect(Object.getPrototypeOf(copied)).toBeNull(); + expect(jsonValuesEqual(copied.__proto__ as JsonValue, payload)).toBe( + true + ); + expect(Object.getPrototypeOf({})).toBe(Object.prototype); + expect(Object.keys(Object.prototype)).toEqual([]); + }), + { numRuns: 200 } + ); + }); +}); diff --git a/js/src/json.test.ts b/js/src/json.test.ts new file mode 100644 index 000000000..241a6d9e3 --- /dev/null +++ b/js/src/json.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from "vitest"; + +import { + exactJsonNumber, + isExactJsonNumber, + isJsonNumber, + jsonNumberToBigInt, + JsonValueError, + jsonValuesEqual, + parseJson, + parseJsonObject, + stringifyJson, + stringifyUniqueJson, + toJsonObject, +} from "./json.js"; +import type { ExactJsonNumber } from "./json.js"; + +describe("River JSON", () => { + it("compares exact JSON with database value semantics", () => { + expect(jsonValuesEqual(exactJsonNumber("1.00"), 1)).toBe(true); + expect(jsonValuesEqual(exactJsonNumber("-0"), 0)).toBe(true); + expect( + jsonValuesEqual( + { + amount: exactJsonNumber("0.12345678901234567890"), + nested: { b: 2, a: 1 }, + }, + { + nested: { a: 1, b: 2 }, + amount: exactJsonNumber("0.1234567890123456789"), + } + ) + ).toBe(true); + expect( + jsonValuesEqual( + exactJsonNumber("0.12345678901234567890"), + exactJsonNumber("0.12345678901234567891") + ) + ).toBe(false); + expect(jsonValuesEqual([1, 2], [2, 1])).toBe(false); + }); + + it("copies valid values into null-prototype dictionaries", () => { + const input = { items: [{ enabled: true }], value: null }; + const output = toJsonObject(input); + + expect(output).toEqual(input); + expect(Object.getPrototypeOf(output)).toBeNull(); + expect(Object.getPrototypeOf((output.items as object[])[0])).toBeNull(); + expect(output).not.toBe(input); + }); + + it.each([ + ["bigint", { value: 1n }], + [ + "cycle", + (() => { + const value: Record = {}; + value.self = value; + return value; + })(), + ], + ["function", { value: () => undefined }], + ["infinity", { value: Number.POSITIVE_INFINITY }], + ["NaN", { value: Number.NaN }], + ["unsafe integer", { value: Number.MAX_SAFE_INTEGER + 1 }], + ["undefined array element", { value: [undefined] }], + ["undefined value", undefined], + ["unpaired surrogate", { value: "\ud800" }], + ])("rejects %s instead of coercing it", (_name, value) => { + expect(() => stringifyJson(value)).toThrow(JsonValueError); + }); + + it("reads ordinary and exact integers as bigint", () => { + const value = parseJsonObject( + '{"big":9223372036854775807,"small":42,"f":1.5}' + ); + expect(isJsonNumber(value.big)).toBe(true); + expect(isJsonNumber(value.small)).toBe(true); + expect(isJsonNumber("42")).toBe(false); + expect(jsonNumberToBigInt(value.big as ExactJsonNumber)).toBe( + 9223372036854775807n + ); + expect(jsonNumberToBigInt(42)).toBe(42n); + expect(() => jsonNumberToBigInt(1.5)).toThrow(JsonValueError); + expect(() => jsonNumberToBigInt(exactJsonNumber("1e400"))).toThrow( + JsonValueError + ); + }); + + it("omits undefined object properties like JSON.stringify", () => { + expect( + stringifyJson({ a: 1, b: undefined, nested: { c: undefined } }) + ).toBe('{"a":1,"nested":{}}'); + expect(Object.keys(toJsonObject({ optional: undefined }))).toEqual([]); + }); + + it("retains finite fractions, including exponent form", () => { + expect(stringifyJson({ exponent: 1e-100, fraction: 1.25 })).toBe( + '{"exponent":1e-100,"fraction":1.25}' + ); + }); + + it("preserves persisted numbers that JavaScript cannot round-trip", () => { + const value = parseJsonObject( + '{"decimal":0.1234567890123456789,"integer":9223372036854775807,"ordinary":0.1,"underflow":1e-400}' + ); + + expect(isExactJsonNumber(value.decimal)).toBe(true); + expect(isExactJsonNumber(value.integer)).toBe(true); + expect(isExactJsonNumber(value.underflow)).toBe(true); + expect(value.ordinary).toBe(0.1); + expect((value.decimal as { rawJSON: string }).rawJSON).toBe( + "0.1234567890123456789" + ); + expect(JSON.stringify(value)).toBe( + '{"decimal":0.1234567890123456789,"integer":9223372036854775807,"ordinary":0.1,"underflow":1e-400}' + ); + }); + + it("constructs exact numeric values with native raw JSON", () => { + const exact = exactJsonNumber("9223372036854775807"); + + expect(isExactJsonNumber(exact)).toBe(true); + expect(stringifyJson({ exact })).toBe('{"exact":9223372036854775807}'); + expect(() => exactJsonNumber("true")).toThrow("numeric token"); + const rawBoolean = ( + JSON as typeof JSON & { rawJSON(source: string): unknown } + ).rawJSON("true"); + expect(() => stringifyJson({ raw: rawBoolean })).toThrow( + "must be a number" + ); + }); + + it("rejects exact numeric primitives where an object is required", () => { + expect(() => parseJsonObject("9223372036854775807")).toThrow( + "must be an object" + ); + expect(parseJson("-0")).toMatchObject({ rawJSON: "-0" }); + }); + + it("rejects unsafe integer-valued numbers even in exponent form", () => { + expect(() => stringifyJson({ value: 1e100 })).toThrow("safe integer range"); + }); + + it("rejects non-finite numbers with Go's encoding/json text", () => { + for (const [value, text] of [ + [Number.NaN, "NaN"], + [Number.POSITIVE_INFINITY, "+Inf"], + [Number.NEGATIVE_INFINITY, "-Inf"], + ] as const) { + const message = `$.nested.value: unsupported value: ${text}`; + for (const encode of [toJsonObject, stringifyJson]) { + expect(() => encode({ nested: { value } }), text).toThrow(message); + } + } + }); + + it("rejects holes, accessors, and classes", () => { + const sparse = new Array(2); + sparse[1] = "present"; + expect(() => toJsonObject({ sparse })).toThrow("array holes"); + + const accessor = Object.defineProperty({}, "value", { + enumerable: true, + get: () => "surprise", + }); + expect(() => toJsonObject(accessor)).toThrow("accessors"); + + class Payload { + value = "class"; + } + expect(() => toJsonObject(new Payload())).toThrow("class instances"); + }); + + it("preserves prototype-looking JSON keys without prototype mutation", () => { + const input = JSON.parse( + '{"__proto__":{"polluted":true},"constructor":1,"prototype":2}' + ) as unknown; + + const output = toJsonObject(input); + + expect(Object.getPrototypeOf(output)).toBeNull(); + expect(Object.hasOwn(output, "__proto__")).toBe(true); + expect(output.__proto__).toEqual({ polluted: true }); + expect(({} as { polluted?: boolean }).polluted).toBeUndefined(); + expect(stringifyJson(output)).toBe( + '{"__proto__":{"polluted":true},"constructor":1,"prototype":2}' + ); + }); + + it("writes top-level unique argument keys the way sjson does", () => { + // Printable ASCII keys stay verbatim at the top level, where River Go + // rewrites them; nested keys keep encoding/json's escaping. + expect( + stringifyUniqueJson({ + "a": { "": 2 }, + "é&": 1, + 'q"': 3, + }) + ).toBe('{"a":{"\\u003ck\\u003e":2},"q\\"":3,"é\\u0026":1}'); + }); +}); diff --git a/js/src/json.ts b/js/src/json.ts new file mode 100644 index 000000000..f88495199 --- /dev/null +++ b/js/src/json.ts @@ -0,0 +1,572 @@ +import { Buffer } from "node:buffer"; + +import { ValidationError } from "./errors.js"; + +/** + * An exact JSON number represented by Node's immutable raw-JSON primitive. + * + * River returns this representation when a persisted number cannot round-trip + * through JavaScript `number` without changing its JSON value. Read the exact + * token through {@link rawJSON}; pass it back to River or `JSON.stringify` + * without losing precision. + */ +export interface ExactJsonNumber { + readonly rawJSON: string; +} + +/** A value that can be represented by River's JSON protocol. */ +export type JsonValue = + boolean | ExactJsonNumber | JsonObject | JsonValue[] | null | number | string; + +/** A JSON object accepted by River at a persistence boundary. */ +export interface JsonObject { + [key: string]: JsonValue; +} + +/** An error raised when a value cannot safely cross River's JSON boundary. */ +export class JsonValueError extends ValidationError { + /** JSONPath-like location of the rejected value, such as `$.user.id`. */ + readonly path: string; + + constructor(path: string, message: string) { + super(`${path}: ${message}`, { details: { path } }); + this.name = "JsonValueError"; + this.path = path; + } +} + +const JSON_NUMBER_PATTERN = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/; +const jsonRaw = JSON as typeof JSON & { + isRawJSON(value: unknown): value is ExactJsonNumber; + rawJSON(source: string): ExactJsonNumber; +}; +const parseWithSource = JSON.parse as ( + text: string, + reviver: ( + key: string, + value: unknown, + context: { readonly source: string } | undefined + ) => unknown +) => unknown; + +/** + * Reject a non-finite number with Go's `encoding/json` wording, such as + * `unsupported value: NaN`, so every River implementation reports it alike. + */ +function nonFiniteError(path: string, value: number): JsonValueError { + const text = Number.isNaN(value) ? "NaN" : value > 0 ? "+Inf" : "-Inf"; + return new JsonValueError(path, `unsupported value: ${text}`); +} + +/** @internal Freeze a JSON object and every nested object and array in place. */ +export function deepFreezeJson(value: T): T { + freezeJson(value); + return value; +} + +/** Construct an exact JSON number from one valid JSON numeric token. */ +export function exactJsonNumber(source: string): ExactJsonNumber { + if (typeof source !== "string" || !JSON_NUMBER_PATTERN.test(source)) { + throw new JsonValueError("$", "exact JSON number is not a numeric token"); + } + return jsonRaw.rawJSON(source); +} + +export function isExactJsonNumber(value: unknown): value is ExactJsonNumber { + return ( + jsonRaw.isRawJSON(value) && + typeof value.rawJSON === "string" && + JSON_NUMBER_PATTERN.test(value.rawJSON) + ); +} + +/** + * Whether a value is a JSON number: an ordinary `number` or an + * {@link ExactJsonNumber} that River uses for numbers JavaScript cannot + * represent exactly (such as a 64-bit ID written by another language). + * + * Prefer validating args with a schema; use this when reading untyped + * `JsonObject` values, where `typeof value === "number"` misses exact numbers. + */ +export function isJsonNumber( + value: unknown +): value is ExactJsonNumber | number { + return typeof value === "number" || isExactJsonNumber(value); +} + +/** + * Convert an integral JSON number, ordinary or exact, to a `bigint` without + * losing precision. Throws {@link JsonValueError} for fractions. + */ +export function jsonNumberToBigInt(value: ExactJsonNumber | number): bigint { + if (typeof value === "number") { + if (!Number.isSafeInteger(value)) { + throw new JsonValueError("$", "number is not a safe integer"); + } + return BigInt(value); + } + if (!isExactJsonNumber(value) || !/^-?\d+$/.test(value.rawJSON)) { + throw new JsonValueError("$", "exact JSON number is not an integer"); + } + return BigInt(value.rawJSON); +} + +/** Compare two validated River JSON values using database JSON semantics. */ +export function jsonValuesEqual(left: JsonValue, right: JsonValue): boolean { + const leftIsNumber = typeof left === "number" || isExactJsonNumber(left); + const rightIsNumber = typeof right === "number" || isExactJsonNumber(right); + if (leftIsNumber || rightIsNumber) { + if (!leftIsNumber || !rightIsNumber) return false; + const leftSource = isExactJsonNumber(left) + ? left.rawJSON + : JSON.stringify(left); + const rightSource = isExactJsonNumber(right) + ? right.rawJSON + : JSON.stringify(right); + return ( + canonicalEqualityDecimal(leftSource) === + canonicalEqualityDecimal(rightSource) + ); + } + if (left === null || right === null) return left === right; + if (typeof left !== "object" || typeof right !== "object") { + return left === right; + } + if (Array.isArray(left) || Array.isArray(right)) { + if ( + !Array.isArray(left) || + !Array.isArray(right) || + left.length !== right.length + ) { + return false; + } + return left.every((value, index) => { + const other = right[index]; + return other !== undefined && jsonValuesEqual(value, other); + }); + } + const keys = Object.keys(left); + return ( + keys.length === Object.keys(right).length && + keys.every((key) => { + const leftValue = left[key]; + const rightValue = right[key]; + return ( + leftValue !== undefined && + rightValue !== undefined && + Object.hasOwn(right, key) && + jsonValuesEqual(leftValue, rightValue) + ); + }) + ); +} + +/** Parse JSON while preserving numbers that JavaScript cannot round-trip. */ +export function parseJson(text: string): JsonValue { + if (typeof text !== "string") { + throw new JsonValueError("$", "JSON input must be a string"); + } + const parsed = parseWithSource(text, (_key, value, context) => { + if (typeof value !== "number" || context === undefined) return value; + return numberRoundTrips(context.source, value) + ? value + : exactJsonNumber(context.source); + }); + return toJsonValue(parsed); +} + +export function parseJsonObject(text: string): JsonObject { + const value = parseJson(text); + if ( + value === null || + Array.isArray(value) || + typeof value !== "object" || + isExactJsonNumber(value) + ) { + throw new JsonValueError("$", "JSON value must be an object"); + } + return value; +} + +/** + * Validate and copy an unknown value into River's JSON domain. + * + * Objects are copied into null-prototype dictionaries. This intentionally + * rejects accessors, class instances, sparse arrays, cycles, non-finite + * numbers, unsafe integers, `bigint`, and a top-level or array-element + * `undefined` instead of relying on JSON.stringify's lossy coercions. Like + * JSON.stringify, an object property whose value is `undefined` is omitted, so + * optional properties behave as they do in every JSON library. Keys such as + * `__proto__` remain ordinary data because the copy has a null prototype and + * is never merged into application objects. + */ +export function toJsonValue(value: unknown): JsonValue { + return copyJsonValue(value, "$", new Set()); +} + +/** Validate and copy an unknown value as a JSON object. */ +export function toJsonObject(value: unknown): JsonObject { + const result = copyJsonValue(value, "$", new Set()); + if ( + result === null || + Array.isArray(result) || + typeof result !== "object" || + isExactJsonNumber(result) + ) { + throw new JsonValueError("$", "job arguments must be a JSON object"); + } + return result; +} + +/** Serialize a previously unknown value without JSON's lossy coercions. */ +export function stringifyJson(value: unknown): string { + return JSON.stringify(toJsonValue(value)); +} + +/** + * Apply the escaping Go's `encoding/json` adds when it marshals JSON text: + * `<`, `>`, `&`, U+2028, and U+2029 become `\u003c`, `\u003e`, `\u0026`, + * `\u2028`, and `\u2029`. Unique-key hash inputs and job list cursors use + * it because every River implementation must produce the same bytes for + * them. + * + * Those characters can only occur inside strings of valid JSON text, so the + * result encodes the same JSON value, and text that already has them escaped + * is returned unchanged. + */ +function escapeGoJson(text: string): string { + return text.replace(/[<>&\u2028\u2029]/g, (character) => { + switch (character) { + case "<": + return "\\u003c"; + case ">": + return "\\u003e"; + case "&": + return "\\u0026"; + case "\u2028": + return "\\u2028"; + case "\u2029": + return "\\u2029"; + default: + return character; + } + }); +} + +/** + * Encode a string as Go's `encoding/json` does: like `JSON.stringify`, but + * also escaping `<`, `>`, `&`, U+2028, and U+2029. + */ +export function stringifyGoJsonString(value: string): string { + return escapeGoJson(JSON.stringify(value)); +} + +/** + * Encode arguments for a by-arguments unique key the way River Go assembles + * them with `sjson`: top-level keys sorted bytewise and written as + * {@link sjsonKey} does, nested values in their own wire order. + */ +export function stringifyUniqueJson(value: JsonObject): string { + return encodeCanonicalJson(value, "$", new Set(), true); +} + +/** + * Encode the object River assembles from selected unique paths. Objects in + * `assembled` are River's own intermediate objects, written in insertion + * (sorted path) order with `sjson` keys; every other value is an argument + * value, written in its own wire order. + */ +export function stringifySelectedUniqueJson( + value: JsonObject, + assembled: ReadonlySet +): string { + const ancestors = new Set(); + const encodeAssembled = (object: JsonObject, path: string): string => { + const members: string[] = []; + for (const [key, child] of Object.entries(object)) { + const childPath = propertyPath(path, key); + validateUnicode(key, `${path} key`); + members.push( + `${sjsonKey(key)}:${ + child !== null && typeof child === "object" && assembled.has(child) + ? encodeAssembled(child as JsonObject, childPath) + : encodeCanonicalJson(child, childPath, ancestors) + }` + ); + } + return `{${members.join(",")}}`; + }; + return encodeAssembled(value, "$"); +} + +function copyJsonValue( + value: unknown, + path: string, + ancestors: Set +): JsonValue { + if (value === null || typeof value === "boolean") return value; + + if (typeof value === "string") { + validateUnicode(value, path); + return value; + } + + if (typeof value === "number") { + if (!Number.isFinite(value)) { + throw nonFiniteError(path, value); + } + if (Number.isInteger(value) && !Number.isSafeInteger(value)) { + throw new JsonValueError( + path, + "integer-valued numbers must be within the safe integer range; use a string for exact larger integers" + ); + } + return value; + } + + if (typeof value !== "object") { + throw new JsonValueError(path, `${typeof value} is not a JSON value`); + } + + if (jsonRaw.isRawJSON(value)) { + if (!isExactJsonNumber(value)) { + throw new JsonValueError(path, "raw JSON value must be a number"); + } + return value; + } + + if (ancestors.has(value)) { + throw new JsonValueError(path, "cyclic values are not supported"); + } + + ancestors.add(value); + try { + if (Array.isArray(value)) { + const result: JsonValue[] = []; + for (let index = 0; index < value.length; index++) { + if (!Object.hasOwn(value, index)) { + throw new JsonValueError( + `${path}[${index}]`, + "array holes are not supported" + ); + } + result.push( + copyJsonValue(value[index], `${path}[${index}]`, ancestors) + ); + } + return result; + } + + const prototype = Object.getPrototypeOf(value) as unknown; + if (prototype !== Object.prototype && prototype !== null) { + throw new JsonValueError(path, "class instances are not JSON objects"); + } + + const descriptors = Object.getOwnPropertyDescriptors(value); + const result = Object.create(null) as JsonObject; + for (const key of Object.keys(descriptors)) { + validateUnicode(key, `${path} key`); + const descriptor = descriptors[key]; + if (descriptor === undefined || !descriptor.enumerable) continue; + if (!("value" in descriptor)) { + throw new JsonValueError( + propertyPath(path, key), + "accessors are not supported" + ); + } + // Like JSON.stringify, omit properties whose value is undefined so + // optional properties produced by validation libraries round-trip. + if (descriptor.value === undefined) continue; + + result[key] = copyJsonValue( + descriptor.value, + propertyPath(path, key), + ancestors + ); + } + return result; + } finally { + ancestors.delete(value); + } +} + +/** + * Encode `value` for a unique-key hash input with Go's escaping, keeping + * exact integers exact. Object keys keep their wire order, except that with + * `root`, the keys of `value` itself are sorted bytewise and written as + * {@link sjsonKey} does, as River Go's `sjson` assembly does. + */ +function encodeCanonicalJson( + value: unknown, + path: string, + ancestors: Set, + root = false +): string { + if (value === null) return "null"; + if (typeof value === "boolean") return value ? "true" : "false"; + if (typeof value === "string") { + validateUnicode(value, path); + return escapeGoJson(JSON.stringify(value)); + } + if (typeof value === "bigint") return value.toString(10); + if (typeof value === "number") { + if (!Number.isFinite(value)) { + throw nonFiniteError(path, value); + } + if (Object.is(value, -0)) return "-0"; + return JSON.stringify(value); + } + if (typeof value !== "object") { + throw new JsonValueError(path, `${typeof value} is not a JSON value`); + } + if (jsonRaw.isRawJSON(value)) { + if (!isExactJsonNumber(value)) { + throw new JsonValueError(path, "raw JSON value must be a number"); + } + return value.rawJSON; + } + if (ancestors.has(value)) { + throw new JsonValueError(path, "cyclic values are not supported"); + } + + ancestors.add(value); + try { + if (Array.isArray(value)) { + const encoded: string[] = []; + for (let index = 0; index < value.length; index++) { + if (!Object.hasOwn(value, index)) { + throw new JsonValueError( + `${path}[${index}]`, + "array holes are not supported" + ); + } + encoded.push( + encodeCanonicalJson(value[index], `${path}[${index}]`, ancestors) + ); + } + return `[${encoded.join(",")}]`; + } + + const prototype = Object.getPrototypeOf(value) as unknown; + if (prototype !== Object.prototype && prototype !== null) { + throw new JsonValueError(path, "class instances are not JSON objects"); + } + const descriptors = Object.getOwnPropertyDescriptors(value); + const keys = Object.keys(descriptors).filter( + (key) => descriptors[key]?.enumerable === true + ); + if (root) keys.sort(compareUtf8); + const encoded: string[] = []; + for (const key of keys) { + validateUnicode(key, `${path} key`); + const descriptor = descriptors[key]; + if (descriptor === undefined || !("value" in descriptor)) { + throw new JsonValueError( + propertyPath(path, key), + "accessors are not supported" + ); + } + if (descriptor.value === undefined) continue; + const encodedKey = root + ? sjsonKey(key) + : escapeGoJson(JSON.stringify(key)); + encoded.push( + `${encodedKey}:${encodeCanonicalJson( + descriptor.value, + propertyPath(path, key), + ancestors + )}` + ); + } + return `{${encoded.join(",")}}`; + } finally { + ancestors.delete(value); + } +} + +/** Compare two strings by their UTF-8 bytes, as Go compares strings. */ +export function compareUtf8(left: string, right: string): number { + return Buffer.compare(Buffer.from(left, "utf8"), Buffer.from(right, "utf8")); +} + +/** + * Whether `value`, parsed from the JSON number `source`, is exactly the + * number `source` denotes, so re-encoding it can't change its value. + */ +export function numberRoundTrips(source: string, value: number): boolean { + if (!Number.isFinite(value)) return false; + if (Number.isInteger(value) && !Number.isSafeInteger(value)) return false; + if (Object.is(value, -0)) return false; + return canonicalDecimal(source) === canonicalDecimal(JSON.stringify(value)); +} + +/** + * The canonical form of the JSON number `source`, such as `1e2` for + * `100.0`, so numerically equal tokens compare equal as text. + * + * @throws {JsonValueError} when `source` isn't a JSON number. + */ +export function canonicalDecimal(source: string): string { + const match = /^(-)?(\d+)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/.exec(source); + if (match === null) { + throw new JsonValueError("$", "invalid JSON numeric token"); + } + const [, negative, integer, fraction = "", exponent = "0"] = match; + let digits = `${integer}${fraction}`.replace(/^0+/, ""); + if (digits.length === 0) return negative === undefined ? "0" : "-0"; + + let decimalExponent = BigInt(exponent) - BigInt(fraction.length); + const trailingZeros = /0+$/.exec(digits)?.[0].length ?? 0; + if (trailingZeros > 0) { + digits = digits.slice(0, -trailingZeros); + decimalExponent += BigInt(trailingZeros); + } + return `${negative ?? ""}${digits}e${decimalExponent}`; +} + +/** Like {@link canonicalDecimal}, but with `-0` equal to `0`. */ +export function canonicalEqualityDecimal(source: string): string { + const canonical = canonicalDecimal(source); + return canonical === "-0" ? "0" : canonical; +} + +/** + * Write an object key the way `sjson` does when River Go assembles unique + * arguments: verbatim when it is printable ASCII without a quote or + * backslash, even though `encoding/json` would escape `<`, `>`, or `&`, and + * otherwise with Go's `encoding/json` escaping. + */ +export function sjsonKey(key: string): string { + return /^[\x20-\x7f]*$/.test(key) && !/["\\]/.test(key) + ? `"${key}"` + : escapeGoJson(JSON.stringify(key)); +} + +function propertyPath(path: string, key: string): string { + return /^[A-Za-z_$][\w$]*$/.test(key) + ? `${path}.${key}` + : `${path}[${JSON.stringify(key)}]`; +} + +function validateUnicode(value: string, path: string): void { + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (!(next >= 0xdc00 && next <= 0xdfff)) { + throw new JsonValueError(path, "string contains an unpaired surrogate"); + } + index++; + } else if (code >= 0xdc00 && code <= 0xdfff) { + throw new JsonValueError(path, "string contains an unpaired surrogate"); + } + } +} + +function freezeJson(value: unknown): void { + if (value === null || typeof value !== "object") return; + for (const child of Array.isArray(value) ? value : Object.values(value)) { + freezeJson(child); + } + Object.freeze(value); +} From db0fe763a1828a3a42b691042738ecc083f4793a Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sun, 4 Oct 2026 17:13:35 -0500 Subject: [PATCH 18/43] add Temporal durations and unreferenced runtime timers Every River duration option takes a `Temporal.Duration` or a duration like such as `{ seconds: 5 }`. `toDuration` rejects bare numbers, calendar units, and negative values with an error naming the option, and bounds durations by Go's maximum `time.Duration`, River's protocol limit. Add the timing primitives the runtime waits on. Every timer is unreferenced, so a pending delay never keeps a stopped client's process alive, and delays longer than Node's 24.8-day timer limit are waited out in chunks like Go's timers. `LinkedAbortSignal` links a per-operation signal to a parent and is disposed when the operation settles, avoiding `AbortSignal.any`, whose cleanup is quadratic in a long-lived parent's dependents. Waits and deadlines go through a replaceable `RuntimeTimer`, and `ManualTimer` drives one on a virtual clock so tests run deterministically. Encode PostgreSQL timestamps truncated to whole microseconds the way pgx does, so every engine stores the same instant. --- js/src/internal/abort.test.ts | 159 ++++++++++++++++++++++++ js/src/internal/abort.ts | 157 ++++++++++++++++++++++++ js/src/internal/backoff.test.ts | 57 +++++++++ js/src/internal/backoff.ts | 85 +++++++++++++ js/src/internal/duration.test.ts | 32 +++++ js/src/internal/duration.ts | 120 ++++++++++++++++++ js/src/internal/manual-timer.test.ts | 68 +++++++++++ js/src/internal/manual-timer.ts | 175 +++++++++++++++++++++++++++ js/src/internal/timestamp.test.ts | 16 +++ js/src/internal/timestamp.ts | 12 ++ 10 files changed, 881 insertions(+) create mode 100644 js/src/internal/abort.test.ts create mode 100644 js/src/internal/abort.ts create mode 100644 js/src/internal/backoff.test.ts create mode 100644 js/src/internal/backoff.ts create mode 100644 js/src/internal/duration.test.ts create mode 100644 js/src/internal/duration.ts create mode 100644 js/src/internal/manual-timer.test.ts create mode 100644 js/src/internal/manual-timer.ts create mode 100644 js/src/internal/timestamp.test.ts create mode 100644 js/src/internal/timestamp.ts diff --git a/js/src/internal/abort.test.ts b/js/src/internal/abort.test.ts new file mode 100644 index 000000000..a7d62879e --- /dev/null +++ b/js/src/internal/abort.test.ts @@ -0,0 +1,159 @@ +import { getEventListeners } from "node:events"; + +import { describe, expect, it, vi } from "vitest"; + +import { + abortableDelay, + interruptibleDelay, + LinkedAbortSignal, + raceWithAbort, + unrefTimeout, +} from "./abort.js"; + +describe("abortableDelay", () => { + it("resolves after the delay", async () => { + await expect( + abortableDelay(1, new AbortController().signal) + ).resolves.toBeUndefined(); + }); + + it("rejects with the abort reason, including when already aborted", async () => { + const controller = new AbortController(); + const reason = new Error("stopping"); + const delayed = abortableDelay(60_000, controller.signal); + + controller.abort(reason); + + await expect(delayed).rejects.toBe(reason); + await expect(abortableDelay(1, controller.signal)).rejects.toBe(reason); + }); +}); + +describe("interruptibleDelay", () => { + it("resolves early without rejecting once the signal aborts", async () => { + const controller = new AbortController(); + const delayed = interruptibleDelay(60_000, controller.signal); + + controller.abort(new Error("stopping")); + + await expect(delayed).resolves.toBeUndefined(); + await expect( + interruptibleDelay(60_000, controller.signal) + ).resolves.toBeUndefined(); + }); +}); + +describe("LinkedAbortSignal", () => { + it("aborts with the reason of the first parent to abort", () => { + const first = new AbortController(); + const second = new AbortController(); + using link = new LinkedAbortSignal([first.signal, second.signal]); + const seen: string[] = []; + first.signal.addEventListener("abort", () => seen.push("parent")); + link.signal.addEventListener("abort", () => + seen.push(`link ${String(link.signal.reason)}`) + ); + + second.abort("second"); + first.abort("first"); + + expect(link.signal.reason).toBe("second"); + // The first parent no longer aborts it. + expect(seen).toEqual(["link second", "parent"]); + expect(getEventListeners(first.signal, "abort")).toHaveLength(1); + }); + + it("starts aborted by the first aborted parent, in order", () => { + const pending = new AbortController(); + using link = new LinkedAbortSignal([ + pending.signal, + AbortSignal.abort("first"), + AbortSignal.abort("second"), + ]); + + expect(link.signal.aborted).toBe(true); + expect(link.signal.reason).toBe("first"); + expect(getEventListeners(pending.signal, "abort")).toHaveLength(0); + }); + + it("removes its parent listeners once disposed", () => { + const parent = new AbortController(); + const links = Array.from( + { length: 100 }, + () => new LinkedAbortSignal([parent.signal]) + ); + expect(getEventListeners(parent.signal, "abort")).toHaveLength(100); + + for (const link of links) link[Symbol.dispose](); + parent.abort("late"); + + expect(getEventListeners(parent.signal, "abort")).toHaveLength(0); + expect(links.every((link) => !link.signal.aborted)).toBe(true); + }); +}); + +describe("raceWithAbort", () => { + it("settles with the operation", async () => { + const signal = new AbortController().signal; + const failure = new Error("failed"); + + await expect(raceWithAbort(Promise.resolve(1), signal)).resolves.toBe(1); + await expect(raceWithAbort(2, signal)).resolves.toBe(2); + await expect(raceWithAbort(Promise.reject(failure), signal)).rejects.toBe( + failure + ); + }); + + it("rejects with the abort reason while the operation is pending", async () => { + const controller = new AbortController(); + const reason = new Error("stopping"); + const raced = raceWithAbort( + new Promise(() => undefined), + controller.signal + ); + + controller.abort(reason); + + await expect(raced).rejects.toBe(reason); + await expect(raceWithAbort(1, controller.signal)).rejects.toBe(reason); + }); +}); + +describe("unrefTimeout", () => { + it("waits out a delay longer than Node's timers support", () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + const thirtyDays = 30 * 24 * 60 * 60 * 1_000; + let fired = 0; + unrefTimeout(() => { + fired++; + }, thirtyDays); + + vi.advanceTimersByTime(2_147_483_647); + expect(fired).toBe(0); + vi.advanceTimersByTime(thirtyDays - 2_147_483_647 - 1); + expect(fired).toBe(0); + vi.advanceTimersByTime(1); + expect(fired).toBe(1); + } finally { + vi.useRealTimers(); + } + }); + + it("cancels a long delay after its first chunk", () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + let fired = 0; + const cancel = unrefTimeout(() => { + fired++; + }, 3_000_000_000); + + vi.advanceTimersByTime(2_147_483_647); + cancel(); + vi.advanceTimersByTime(3_000_000_000); + expect(fired).toBe(0); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/js/src/internal/abort.ts b/js/src/internal/abort.ts new file mode 100644 index 000000000..2af6e5bf1 --- /dev/null +++ b/js/src/internal/abort.ts @@ -0,0 +1,157 @@ +/** + * Abort-aware timing primitives shared by the runtime and its services. + * + * Every timer here is unreferenced: a pending delay never keeps the process + * alive on its own, so a stopped client can always exit. + */ + +/** + * The longest delay Node's timers support. Node fires a timer with a longer + * delay after 1 ms, so {@link unrefTimeout} waits out longer delays in + * chunks of at most this. + */ +const MAX_TIMER_DELAY_MS = 2_147_483_647; + +/** + * Run `callback` once after `milliseconds`, however long, like Go's timers, + * on an unreferenced timer. Returns a function that cancels it. + */ +export function unrefTimeout( + callback: () => void, + milliseconds: number +): () => void { + let timer: NodeJS.Timeout; + const arm = (remaining: number): void => { + timer = setTimeout( + () => { + if (remaining > MAX_TIMER_DELAY_MS) { + arm(remaining - MAX_TIMER_DELAY_MS); + } else { + callback(); + } + }, + Math.min(remaining, MAX_TIMER_DELAY_MS) + ); + timer.unref(); + }; + arm(milliseconds); + return () => { + clearTimeout(timer); + }; +} + +/** + * Resolve after `milliseconds`, or reject with `signal.reason` as soon as the + * signal aborts. + */ +export function abortableDelay( + milliseconds: number, + signal: AbortSignal +): Promise { + if (signal.aborted) return Promise.reject(signal.reason); + return new Promise((resolve, reject) => { + const onAbort = () => { + cancel(); + reject(signal.reason); + }; + const cancel = unrefTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, milliseconds); + signal.addEventListener("abort", onAbort, { once: true }); + }); +} + +/** + * Resolve after `milliseconds`, or early as soon as the signal aborts. Unlike + * {@link abortableDelay} this never rejects, which suits loops that check + * `signal.aborted` themselves after each pause. + */ +export function interruptibleDelay( + milliseconds: number, + signal: AbortSignal +): Promise { + if (signal.aborted) return Promise.resolve(); + return new Promise((resolve) => { + const finish = () => { + cancel(); + signal.removeEventListener("abort", finish); + resolve(); + }; + const cancel = unrefTimeout(finish, milliseconds); + signal.addEventListener("abort", finish, { once: true }); + }); +} + +/** + * Settle with `operation`, or reject with `signal.reason` as soon as the + * signal aborts. The operation itself keeps running; only the wait ends. + */ +export function raceWithAbort( + operation: PromiseLike | T, + signal: AbortSignal +): Promise { + if (signal.aborted) return Promise.reject(signal.reason); + return new Promise((resolve, reject) => { + const onAbort = () => reject(signal.reason); + signal.addEventListener("abort", onAbort, { once: true }); + void Promise.resolve(operation).then( + (value) => { + signal.removeEventListener("abort", onAbort); + resolve(value); + }, + (error: unknown) => { + signal.removeEventListener("abort", onAbort); + reject(error); + } + ); + }); +} + +/** + * A signal that aborts when the first of its parents aborts, with that + * parent's reason, as `AbortSignal.any(parents)` does, until it is disposed. + * + * Node cleans up each signal `AbortSignal.any` creates by scanning every + * other dependent of the same parent, so dependents of a long-lived parent, + * such as a runtime's run signal, cost time quadratic in their number. This + * signal instead holds a `{ once: true }` listener on each parent, which + * disposing it removes. Dispose it when its operation settles; the parents + * no longer abort it after that. + * + * A parent that is already aborted aborts it at once, the first such parent + * in order. Otherwise it aborts inside its parent's abort event, after the + * parent's listeners added before it. + */ +export class LinkedAbortSignal implements Disposable { + readonly signal: AbortSignal; + readonly #controller = new AbortController(); + #parents: readonly AbortSignal[] = []; + + constructor(parents: readonly AbortSignal[]) { + this.signal = this.#controller.signal; + const aborted = parents.find((parent) => parent.aborted); + if (aborted !== undefined) { + this.#controller.abort(aborted.reason); + return; + } + this.#parents = parents; + for (const parent of parents) { + parent.addEventListener("abort", this.#onAbort, { once: true }); + } + } + + /** Stop listening to the parents. */ + [Symbol.dispose](): void { + for (const parent of this.#parents) { + parent.removeEventListener("abort", this.#onAbort); + } + this.#parents = []; + } + + readonly #onAbort = (event: Event): void => { + const parent = event.target as AbortSignal; + this[Symbol.dispose](); + this.#controller.abort(parent.reason); + }; +} diff --git a/js/src/internal/backoff.test.ts b/js/src/internal/backoff.test.ts new file mode 100644 index 000000000..57539e00c --- /dev/null +++ b/js/src/internal/backoff.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; + +import { + BACKGROUND_BACKOFF, + exponentialBackoffMs, + SYSTEM_TIMER, +} from "./backoff.js"; + +describe("exponentialBackoffMs", () => { + it("doubles from the base delay up to the cap", () => { + const delays = Array.from({ length: 10 }, (_, index) => + exponentialBackoffMs(index + 1, BACKGROUND_BACKOFF, () => 0.5) + ); + + expect(delays).toEqual([ + 250, 500, 1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000, 30_000, + ]); + }); + + it("applies bounded jitter and tolerates invalid random values", () => { + const policy = { baseMs: 1_000, maxMs: 1_000 }; + + expect(exponentialBackoffMs(1, policy, () => 0)).toBe(900); + expect(exponentialBackoffMs(1, policy, () => 0.999_999)).toBe(1_100); + expect(exponentialBackoffMs(1, policy, () => Number.NaN)).toBe(1_000); + expect(exponentialBackoffMs(1, policy, () => 2)).toBe(1_000); + expect(exponentialBackoffMs(0, policy, () => 0.5)).toBe(1_000); + expect(exponentialBackoffMs(10_000, policy, () => 0.5)).toBe(1_000); + }); +}); + +describe("SYSTEM_TIMER", () => { + it("rejects a delay with the abort reason", async () => { + const controller = new AbortController(); + const reason = new Error("stopping"); + const delayed = SYSTEM_TIMER.delay(60_000, controller.signal); + + controller.abort(reason); + + await expect(delayed).rejects.toBe(reason); + await expect(SYSTEM_TIMER.delay(1, controller.signal)).rejects.toBe(reason); + }); + + it("aborts a timeout signal with the supplied reason until disposed", async () => { + const reason = new Error("timed out"); + const expired = SYSTEM_TIMER.timeout(1, () => reason); + await new Promise((resolve) => + expired.signal.addEventListener("abort", resolve, { once: true }) + ); + expect(expired.signal.reason).toBe(reason); + + const disposed = SYSTEM_TIMER.timeout(1, () => reason); + disposed.dispose(); + await SYSTEM_TIMER.delay(5, new AbortController().signal); + expect(disposed.signal.aborted).toBe(false); + }); +}); diff --git a/js/src/internal/backoff.ts b/js/src/internal/backoff.ts new file mode 100644 index 000000000..0dc304ec0 --- /dev/null +++ b/js/src/internal/backoff.ts @@ -0,0 +1,85 @@ +import { abortableDelay } from "./abort.js"; + +/** + * Timers and the monotonic clock River's runtime waits on. By default they + * are unreferenced `setTimeout` handles and `performance.now()`; tests + * replace them, for example with `overrideRuntimeTiming`, so backoff, + * intervals, and deadlines run deterministically instead of by wall-clock + * sleeps. + */ +export interface RuntimeTimer { + /** + * Resolve after `milliseconds`, or reject with `signal.reason` as soon as + * the signal aborts. Timers never keep the process alive on their own. + */ + delay(milliseconds: number, signal: AbortSignal): Promise; + /** + * Monotonic milliseconds from an arbitrary origin, the clock `delay` and + * `timeout` count against, like `performance.now()`. + */ + now(): number; + /** + * Return a signal that aborts with `reason()` after `milliseconds`. Call + * `dispose` once the guarded operation settles to clear the timer. + */ + timeout(milliseconds: number, reason: () => unknown): OperationTimeout; +} + +/** A disposable timeout signal returned by {@link RuntimeTimer.timeout}. */ +export interface OperationTimeout { + readonly signal: AbortSignal; + dispose(): void; +} + +/** Bounds for {@link exponentialBackoffMs}. */ +export interface BackoffPolicy { + /** Delay before the first retry. */ + readonly baseMs: number; + /** Largest delay, reached after repeated failures. */ + readonly maxMs: number; +} + +/** + * Background retry policy: 250 ms, 500 ms, 1 s, ... capped at 30 s. This has + * the shape of River's Go `ExponentialBackoff`, starting lower so brief + * contention such as a busy SQLite database clears quickly and capping lower + * so a producer recovers promptly after a database outage ends. + */ +export const BACKGROUND_BACKOFF: BackoffPolicy = Object.freeze({ + baseMs: 250, + maxMs: 30_000, +}); + +/** Timer backed by unreferenced `setTimeout` handles. */ +export const SYSTEM_TIMER: RuntimeTimer = Object.freeze({ + delay: abortableDelay, + now: () => performance.now(), + timeout(milliseconds: number, reason: () => unknown): OperationTimeout { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(reason()), milliseconds); + timer.unref(); + return { + dispose: () => clearTimeout(timer), + signal: controller.signal, + }; + }, +}); + +/** + * Exponential delay for the given 1-based consecutive failure count, with + * +/-10% jitter so many processes recovering from one outage spread out. + */ +export function exponentialBackoffMs( + failures: number, + policy: BackoffPolicy, + random: () => number = Math.random +): number { + const exponent = Math.min(Math.max(failures, 1) - 1, 30); + const base = Math.min(policy.maxMs, policy.baseMs * 2 ** exponent); + const sample = random(); + const jitter = + Number.isFinite(sample) && sample >= 0 && sample < 1 + ? sample * 0.2 - 0.1 + : 0; + return Math.max(1, Math.round(base + base * jitter)); +} diff --git a/js/src/internal/duration.test.ts b/js/src/internal/duration.test.ts new file mode 100644 index 000000000..c89cea888 --- /dev/null +++ b/js/src/internal/duration.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { ValidationError } from "../errors.js"; +import { + millisecondsToDuration, + toDuration, + toMilliseconds, + toNullableMilliseconds, +} from "./duration.js"; + +describe("durations", () => { + it("converts duration-likes to whole milliseconds, rounding up", () => { + expect(toMilliseconds("x", { seconds: 5 })).toBe(5_000); + expect(toMilliseconds("x", { days: 1 })).toBe(86_400_000); + expect(toMilliseconds("x", { microseconds: 1 })).toBe(1); + expect(toMilliseconds("x", Temporal.Duration.from("PT1M30S"))).toBe(90_000); + expect(toMilliseconds("x", { seconds: 0 }, { allowZero: true })).toBe(0); + expect(toNullableMilliseconds("x", null)).toBeNull(); + expect(millisecondsToDuration(90_061_001).toString()).toBe("PT25H1M1.001S"); + }); + + it("rejects numbers, calendar units, and out-of-range values", () => { + expect(() => toDuration("x", 5 as never)).toThrow("not a bare number"); + expect(() => toDuration("x", { months: 1 })).toThrow("calendar units"); + expect(() => toDuration("x", { seconds: -1 })).toThrow("negative"); + expect(() => toDuration("x", { seconds: 0 })).toThrow("positive"); + expect(() => toDuration("x", { hours: 3_000_000 })).toThrow("maximum"); + expect(() => toDuration("x", {}, { error: ValidationError })).toThrow( + ValidationError + ); + }); +}); diff --git a/js/src/internal/duration.ts b/js/src/internal/duration.ts new file mode 100644 index 000000000..fda9491e1 --- /dev/null +++ b/js/src/internal/duration.ts @@ -0,0 +1,120 @@ +import { ConfigurationError } from "../errors.js"; + +/** A duration River accepts: `Temporal.Duration` or a like such as `{ seconds: 5 }`. */ +export type DurationInput = Temporal.Duration | Temporal.DurationLike; + +/** Go's maximum `time.Duration`, River's protocol limit for durations. */ +const MAX_DURATION_NANOSECONDS = 9_223_372_036_854_775_807n; + +/** How {@link toDuration} and its variants validate a duration. */ +export interface DurationRules { + /** Whether zero is allowed. Defaults to false. */ + readonly allowZero?: boolean; + /** Error class to throw; defaults to `ConfigurationError`. */ + readonly error?: new (message: string, options?: ErrorOptions) => Error; +} + +/** + * Convert a duration option to a `Temporal.Duration`, rejecting bare numbers, + * calendar units, and negative values with an error naming the option. + */ +export function toDuration( + name: string, + value: DurationInput, + rules: DurationRules = {} +): Temporal.Duration { + const ErrorClass = rules.error ?? ConfigurationError; + if (typeof value === "number" || typeof value === "bigint") { + throw new ErrorClass( + `${name} must be a Temporal duration such as { seconds: 5 }, not a bare number` + ); + } + let duration: Temporal.Duration; + try { + duration = Temporal.Duration.from(value); + } catch (cause: unknown) { + throw new ErrorClass( + `${name} must be a Temporal duration such as { seconds: 5 }`, + { cause } + ); + } + if (duration.years !== 0 || duration.months !== 0 || duration.weeks !== 0) { + throw new ErrorClass( + `${name} must not contain calendar units (years, months, or weeks)` + ); + } + const nanoseconds = durationNanoseconds(duration); + if (nanoseconds < 0n) throw new ErrorClass(`${name} must not be negative`); + if (nanoseconds === 0n && rules.allowZero !== true) { + throw new ErrorClass(`${name} must be positive`); + } + if (nanoseconds > MAX_DURATION_NANOSECONDS) { + throw new ErrorClass(`${name} exceeds River's maximum duration`); + } + return duration; +} + +/** Exact nanoseconds in a duration without calendar units; a day is 24 h. */ +export function durationNanoseconds(duration: Temporal.Duration): bigint { + return ( + BigInt(duration.days) * 86_400_000_000_000n + + BigInt(duration.hours) * 3_600_000_000_000n + + BigInt(duration.minutes) * 60_000_000_000n + + BigInt(duration.seconds) * 1_000_000_000n + + BigInt(duration.milliseconds) * 1_000_000n + + BigInt(duration.microseconds) * 1_000n + + BigInt(duration.nanoseconds) + ); +} + +/** + * Convert a duration option to whole milliseconds for timers, rounding a + * sub-millisecond remainder up so a positive duration never becomes zero. + */ +export function toMilliseconds( + name: string, + value: DurationInput, + rules: DurationRules = {} +): number { + const nanoseconds = durationNanoseconds(toDuration(name, value, rules)); + const milliseconds = (nanoseconds + 999_999n) / 1_000_000n; + if (milliseconds > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new (rules.error ?? ConfigurationError)( + `${name} exceeds the maximum timer duration` + ); + } + return Number(milliseconds); +} + +/** Like {@link toMilliseconds}, passing `null` through as "disabled". */ +export function toNullableMilliseconds( + name: string, + value: DurationInput | null, + rules: DurationRules = {} +): number | null { + return value === null ? null : toMilliseconds(name, value, rules); +} + +/** + * Express a measured time in milliseconds, which may be fractional, as a + * balanced `Temporal.Duration` to the nanosecond. Negative or non-finite + * measurements become zero. + */ +export function measuredDuration(milliseconds: number): Temporal.Duration { + const nanoseconds = + Number.isFinite(milliseconds) && milliseconds > 0 + ? Math.round(milliseconds * 1_000_000) + : 0; + return Temporal.Duration.from({ nanoseconds }).round({ + largestUnit: "hours", + }); +} + +/** Express whole milliseconds as a balanced `Temporal.Duration`. */ +export function millisecondsToDuration( + milliseconds: number +): Temporal.Duration { + return Temporal.Duration.from({ milliseconds }).round({ + largestUnit: "hours", + }); +} diff --git a/js/src/internal/manual-timer.test.ts b/js/src/internal/manual-timer.test.ts new file mode 100644 index 000000000..cbe808a24 --- /dev/null +++ b/js/src/internal/manual-timer.test.ts @@ -0,0 +1,68 @@ +import { AssertionError } from "node:assert"; + +import { describe, expect, test } from "vitest"; + +import { ManualTimer } from "./manual-timer.js"; + +describe("ManualTimer", () => { + test("fires delays and timeouts in order only as the clock advances", async () => { + const timer = new ManualTimer(); + const fired: string[] = []; + void timer.delay(20, new AbortController().signal).then(() => { + fired.push("delay 20"); + // A timer created while advancing fires within the same advance. + void timer.delay(5, new AbortController().signal).then(() => { + fired.push("delay 5 more"); + }); + }); + const timeout = timer.timeout(10, () => new Error("deadline")); + timeout.signal.addEventListener("abort", () => { + fired.push("timeout 10"); + }); + expect(timer.pending().map(({ kind, ms }) => [kind, ms])).toEqual([ + ["timeout", 10], + ["delay", 20], + ]); + + await timer.advance(9); + expect(fired).toEqual([]); + expect(timer.now()).toBe(9); + await timer.advance(16); + expect(fired).toEqual(["timeout 10", "delay 20", "delay 5 more"]); + expect(timeout.signal.reason).toEqual(new Error("deadline")); + expect(timer.now()).toBe(25); + expect(timer.pending()).toEqual([]); + }); + + test("drops aborted delays and disposed timeouts", async () => { + const timer = new ManualTimer(); + const controller = new AbortController(); + const delayed = timer.delay(10, controller.signal); + const timeout = timer.timeout(10, () => "late"); + controller.abort("stopped"); + timeout.dispose(); + + await expect(delayed).rejects.toBe("stopped"); + await timer.advance(10); + expect(timeout.signal.aborted).toBe(false); + expect(timer.pending()).toEqual([]); + await expect(timer.delay(1, controller.signal)).rejects.toBe("stopped"); + }); + + test("waits for a matching timer", async () => { + const timer = new ManualTimer(); + setImmediate(() => { + void timer.delay(30, new AbortController().signal); + }); + + await expect(timer.waitFor(({ ms }) => ms === 30)).resolves.toMatchObject({ + dueAt: 30, + kind: "delay", + ms: 30, + }); + await expect( + timer.waitFor(({ ms }) => ms === 40, { timeoutMs: 10 }) + ).rejects.toThrow(AssertionError); + await expect(timer.advance(-1)).rejects.toThrow(RangeError); + }); +}); diff --git a/js/src/internal/manual-timer.ts b/js/src/internal/manual-timer.ts new file mode 100644 index 000000000..99ed93603 --- /dev/null +++ b/js/src/internal/manual-timer.ts @@ -0,0 +1,175 @@ +/** + * A `RuntimeTimer` on a virtual clock for tests of River's runtime and of + * extensions built on `riverqueue/unstable-driver`. + */ +import { AssertionError } from "node:assert"; + +/** A pending delay or timeout of a {@link ManualTimer}. */ +export interface ManualTimerEntry { + /** Virtual time, in milliseconds, at which it fires. */ + readonly dueAt: number; + readonly kind: "delay" | "timeout"; + /** The duration it was created with. */ + readonly ms: number; +} + +/** A timeout signal from {@link ManualTimer.timeout}. */ +export interface ManualTimeout { + readonly signal: AbortSignal; + /** Cancel the timeout once the guarded work settled. */ + dispose(): void; +} + +/** + * A timer on a virtual clock that only moves when a test advances it, so + * code that waits on delays and deadlines runs deterministically. Pass it + * as the `timer` of `overrideRuntimeTiming` to drive a client's + * runtime, including a pilot's producer reports and services: + * + * ```ts + * const timer = new ManualTimer(); + * overrideRuntimeTiming(client, { timer }); + * await client.start(); + * await timer.waitFor((entry) => entry.ms === 30_000); + * await timer.advance(30_000); + * ``` + */ +export class ManualTimer { + readonly #entries = new Set< + ManualTimerEntry & { readonly fire: () => void; readonly order: number } + >(); + #now = 0; + #order = 0; + + /** + * Move the clock forward by `ms`, firing each delay and timeout that + * comes due in order, and letting the code they wake run before the next + * fires, so timers it creates within the window fire too. + */ + async advance(ms: number): Promise { + if (!Number.isFinite(ms) || ms < 0) { + throw new RangeError("ManualTimer.advance requires a non-negative ms"); + } + const target = this.#now + ms; + for (;;) { + await settle(); + const next = this.#next(); + if (next === undefined || next.dueAt > target) break; + this.#now = Math.max(this.#now, next.dueAt); + this.#entries.delete(next); + next.fire(); + } + this.#now = target; + await settle(); + } + + /** + * Resolve after `ms` on the virtual clock, or reject with `signal.reason` + * as soon as `signal` aborts. + */ + delay(ms: number, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(signal.reason as unknown); + return new Promise((resolve, reject) => { + const onAbort = () => { + this.#entries.delete(entry); + reject(signal.reason as unknown); + }; + const entry = this.#add("delay", ms, () => { + signal.removeEventListener("abort", onAbort); + resolve(); + }); + signal.addEventListener("abort", onAbort, { once: true }); + }); + } + + /** The virtual clock's time, in milliseconds. */ + now(): number { + return this.#now; + } + + /** Pending delays and timeouts, earliest first. */ + pending(): readonly ManualTimerEntry[] { + return [...this.#entries] + .sort( + (left, right) => left.dueAt - right.dueAt || left.order - right.order + ) + .map(({ dueAt, kind, ms }) => Object.freeze({ dueAt, kind, ms })); + } + + /** A signal that aborts with `reason()` once `ms` elapsed. */ + timeout(ms: number, reason: () => unknown): ManualTimeout { + const controller = new AbortController(); + const entry = this.#add("timeout", ms, () => { + controller.abort(reason()); + }); + return { + dispose: () => { + this.#entries.delete(entry); + }, + signal: controller.signal, + }; + } + + /** + * Wait, in real time, until a pending entry matches `predicate`, and + * return it. Rejects after `timeoutMs` (default 5 s) of real time. + */ + async waitFor( + predicate: (entry: ManualTimerEntry) => boolean, + options: { readonly timeoutMs?: number } = {} + ): Promise { + const deadline = performance.now() + (options.timeoutMs ?? 5_000); + for (;;) { + const entry = this.pending().find(predicate); + if (entry !== undefined) return entry; + if (performance.now() > deadline) { + throw new AssertionError({ + message: `no matching timer; pending: ${JSON.stringify(this.pending())}`, + }); + } + await settle(); + } + } + + #add( + kind: ManualTimerEntry["kind"], + ms: number, + fire: () => void + ): ManualTimerEntry & { readonly fire: () => void; readonly order: number } { + const entry = { + dueAt: this.#now + Math.max(0, ms), + fire, + kind, + ms, + order: this.#order++, + }; + this.#entries.add(entry); + return entry; + } + + #next(): + | (ManualTimerEntry & { readonly fire: () => void; readonly order: number }) + | undefined { + let next: + | (ManualTimerEntry & { + readonly fire: () => void; + readonly order: number; + }) + | undefined; + for (const entry of this.#entries) { + if ( + next === undefined || + entry.dueAt < next.dueAt || + (entry.dueAt === next.dueAt && entry.order < next.order) + ) { + next = entry; + } + } + return next; + } +} + +/** Let pending promise reactions and one macrotask turn run. */ +function settle(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} diff --git a/js/src/internal/timestamp.test.ts b/js/src/internal/timestamp.test.ts new file mode 100644 index 000000000..3b12b49eb --- /dev/null +++ b/js/src/internal/timestamp.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; + +import { postgresTimestamp } from "./timestamp.js"; + +describe("postgresTimestamp", () => { + it.each([ + ["2026-01-01T00:00:00.0000019Z", "2026-01-01T00:00:00.000001Z"], + ["2026-01-01T00:00:00.0000015Z", "2026-01-01T00:00:00.000001Z"], + ["2026-01-01T00:00:00.123456Z", "2026-01-01T00:00:00.123456Z"], + ["2026-01-01T00:00:00Z", "2026-01-01T00:00:00Z"], + // Before the epoch, pgx still truncates toward the past. + ["1969-12-31T23:59:59.9999999Z", "1969-12-31T23:59:59.999999Z"], + ])("truncates %s to microseconds like Go's pgx", (input, expected) => { + expect(postgresTimestamp(Temporal.Instant.from(input))).toBe(expected); + }); +}); diff --git a/js/src/internal/timestamp.ts b/js/src/internal/timestamp.ts new file mode 100644 index 000000000..e9ef7cad3 --- /dev/null +++ b/js/src/internal/timestamp.ts @@ -0,0 +1,12 @@ +/** + * Encode an instant as a PostgreSQL `timestamptz` parameter the way Go's pgx + * does: truncated to whole microseconds, PostgreSQL's precision, rather than + * leaving PostgreSQL to round the sub-microsecond digits of its text input. + * Truncation is toward the past, like pgx, so every engine stores the same + * instant for the same value. + */ +export function postgresTimestamp(value: Temporal.Instant): string { + return value + .round({ roundingMode: "floor", smallestUnit: "microsecond" }) + .toString(); +} From e6d0eb01e50026b61ae9e7698b9a60538ad74d00 Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sun, 4 Oct 2026 17:13:50 -0500 Subject: [PATCH 19/43] add completion batching and background task supervision Batch job outcomes with River's bounded persistence policy: one query runs while the next batch accumulates, and a second concurrent query starts only for a full batch, which bounds database pressure and keeps hook and event timing predictable. A batch whose persistence fails is either requeued or dropped; a dropped completion leaves its job `running` for the rescuer to recover. `TaskSupervisor` owns the runtime's background tasks and turns a detached failure into one observable runtime failure. `EventDispatcher` delivers events to `onEvent` hooks in order but off the completion path, so a slow hook doesn't hold worker or completion capacity until its queue fills. `EventLoopDelayMonitor` samples event-loop delay on an unreferenced timer for reporting. --- .../completion-batcher.property.test.ts | 329 ++++++++++++ js/src/internal/completion-batcher.test.ts | 381 ++++++++++++++ js/src/internal/completion-batcher.ts | 468 ++++++++++++++++++ js/src/internal/event-dispatcher.test.ts | 37 ++ js/src/internal/event-dispatcher.ts | 51 ++ .../internal/event-loop-delay-monitor.test.ts | 71 +++ js/src/internal/event-loop-delay-monitor.ts | 109 ++++ js/src/internal/task-supervisor.test.ts | 99 ++++ js/src/internal/task-supervisor.ts | 157 ++++++ 9 files changed, 1702 insertions(+) create mode 100644 js/src/internal/completion-batcher.property.test.ts create mode 100644 js/src/internal/completion-batcher.test.ts create mode 100644 js/src/internal/completion-batcher.ts create mode 100644 js/src/internal/event-dispatcher.test.ts create mode 100644 js/src/internal/event-dispatcher.ts create mode 100644 js/src/internal/event-loop-delay-monitor.test.ts create mode 100644 js/src/internal/event-loop-delay-monitor.ts create mode 100644 js/src/internal/task-supervisor.test.ts create mode 100644 js/src/internal/task-supervisor.ts diff --git a/js/src/internal/completion-batcher.property.test.ts b/js/src/internal/completion-batcher.property.test.ts new file mode 100644 index 000000000..ee3b78db1 --- /dev/null +++ b/js/src/internal/completion-batcher.property.test.ts @@ -0,0 +1,329 @@ +import { setTimeout as delay } from "node:timers/promises"; + +import fc from "fast-check"; +import { describe, expect, it } from "vitest"; + +import { + CompletionBatcher, + CompletionDroppedError, +} from "./completion-batcher.js"; +import type { CompletionFailureAction } from "./completion-batcher.js"; + +interface Item { + readonly key: string; + readonly serialKey: string; +} + +/** One persistence query the batcher started and the test settles. */ +interface PersistCall { + readonly items: readonly Item[]; + readonly reject: (error: unknown) => void; + readonly resolve: (results: ReadonlyMap) => void; +} + +type Settlement = + | { readonly state: "dropped" } + | { readonly state: "pending" } + | { readonly state: "persisted"; readonly value: string }; + +interface Submission { + accepted: boolean; + acknowledged: boolean; + readonly acknowledge: () => void; + readonly autoAcknowledge: boolean; + readonly key: string; + settlement: Settlement; +} + +/** + * The system under test plus everything observed about it. Assertions are + * invariants rather than an exact model, because batch composition depends + * on when the flush timer fires. + */ +interface Real { + readonly batcher: CompletionBatcher; + readonly batchSize: number; + readonly calls: PersistCall[]; + failureAction: CompletionFailureAction; + readonly maxPendingItems: number; + nextKey: number; + readonly persistedKeys: Map; + readonly submissions: Submission[]; + readonly violations: string[]; +} + +interface Model { + submitted: number; +} + +type BatcherCommand = fc.AsyncCommand; + +/** Let promise reactions and a zero-delay flush timer run. */ +async function settle(): Promise { + await delay(0); + await delay(0); +} + +function assertInvariants(real: Real): void { + expect(real.violations).toEqual([]); + expect(real.batcher.inFlightQueries).toBe(real.calls.length); + expect(real.calls.length).toBeLessThanOrEqual(2); + expect(real.batcher.pendingItems).toBeLessThanOrEqual(real.maxPendingItems); + const inFlightSerialKeys = real.calls.flatMap((call) => + call.items.map((item) => item.serialKey) + ); + expect(new Set(inFlightSerialKeys).size).toBe(inFlightSerialKeys.length); + for (const [key, count] of real.persistedKeys) { + expect(count, `${key} persisted more than once`).toBe(1); + } + for (const submission of real.submissions) { + if (submission.settlement.state === "persisted") { + expect(submission.settlement.value).toBe(`persisted:${submission.key}`); + expect(real.persistedKeys.get(submission.key)).toBe(1); + } + } +} + +class SubmitCommand implements BatcherCommand { + constructor( + readonly serialKey: number, + readonly autoAcknowledge: boolean + ) {} + + check(): boolean { + return true; + } + + async run(model: Model, real: Real): Promise { + model.submitted++; + const key = `job_${real.nextKey++}`; + const item = { key, serialKey: `serial_${this.serialKey}` }; + const submission = real.batcher.submit(key, item, item.serialKey); + const record: Submission = { + accepted: false, + acknowledge: submission.acknowledge, + acknowledged: false, + autoAcknowledge: this.autoAcknowledge, + key, + settlement: { state: "pending" }, + }; + real.submissions.push(record); + void submission.accepted.then( + () => { + record.accepted = true; + }, + () => undefined + ); + void submission.result.then( + (value) => { + record.settlement = { state: "persisted", value }; + if (record.autoAcknowledge) { + record.acknowledged = true; + record.acknowledge(); + } + }, + (error: unknown) => { + if (!(error instanceof CompletionDroppedError)) { + real.violations.push(`${key} failed with ${String(error)}`); + } + record.settlement = { state: "dropped" }; + } + ); + await settle(); + assertInvariants(real); + } + + toString(): string { + return `submit(serial_${this.serialKey}, autoAck=${this.autoAcknowledge})`; + } +} + +class SettleCommand implements BatcherCommand { + constructor( + readonly position: number, + readonly outcome: "drop" | "persist" | "requeue" + ) {} + + check(): boolean { + return true; + } + + async run(_model: Model, real: Real): Promise { + if (real.calls.length === 0) return; + const [call] = real.calls.splice(this.position % real.calls.length, 1); + if (call === undefined) throw new Error("persist call missing"); + if (this.outcome === "persist") { + for (const { key } of call.items) { + real.persistedKeys.set(key, (real.persistedKeys.get(key) ?? 0) + 1); + } + call.resolve( + new Map(call.items.map(({ key }) => [key, `persisted:${key}`])) + ); + } else { + real.failureAction = this.outcome; + call.reject(new Error("database unavailable")); + } + await settle(); + assertInvariants(real); + } + + toString(): string { + return `settle(${this.position}, ${this.outcome})`; + } +} + +class AcknowledgeCommand implements BatcherCommand { + constructor(readonly position: number) {} + + check(): boolean { + return true; + } + + async run(_model: Model, real: Real): Promise { + const ready = real.submissions.filter( + (submission) => + submission.settlement.state === "persisted" && !submission.acknowledged + ); + const submission = ready[this.position % Math.max(ready.length, 1)]; + if (submission === undefined) return; + submission.acknowledged = true; + submission.acknowledge(); + // Acknowledging twice is harmless. + submission.acknowledge(); + await settle(); + assertInvariants(real); + } + + toString(): string { + return `acknowledge(${this.position})`; + } +} + +const commandsArbitrary = fc.commands( + [ + fc + .tuple(fc.integer({ max: 5, min: 0 }), fc.boolean()) + .map(([serialKey, auto]) => new SubmitCommand(serialKey, auto)), + fc + .tuple( + fc.nat(), + fc.oneof( + { arbitrary: fc.constant("persist" as const), weight: 4 }, + { arbitrary: fc.constant("requeue" as const), weight: 1 }, + { arbitrary: fc.constant("drop" as const), weight: 1 } + ) + ) + .map(([position, outcome]) => new SettleCommand(position, outcome)), + fc.nat().map((position) => new AcknowledgeCommand(position)), + ], + { maxCommands: 40 } +); + +describe("CompletionBatcher properties", () => { + it("persists or drops every accepted completion exactly once", async () => { + await fc.assert( + fc.asyncProperty( + fc.integer({ max: 4, min: 1 }), + fc.integer({ max: 3, min: 1 }), + commandsArbitrary, + async (batchSize, pendingFactor, commands) => { + const calls: PersistCall[] = []; + const violations: string[] = []; + const real: Real = { + batchSize, + batcher: new CompletionBatcher({ + batchSize, + flushIntervalMs: 0, + maxPendingItems: batchSize * pendingFactor, + onPersistFailure: () => real.failureAction, + persist: (items) => { + // A second concurrent query starts only for a full batch. + if (calls.length > 0 && items.length !== batchSize) { + violations.push( + `partial batch of ${items.length} started beside another query` + ); + } + const serialKeys = items.map((item) => item.serialKey); + if (new Set(serialKeys).size !== serialKeys.length) { + violations.push(`one batch repeats a serial key`); + } + if (items.length > batchSize) { + violations.push(`batch of ${items.length} exceeds its size`); + } + return new Promise((resolve, reject) => { + calls.push({ items, reject, resolve }); + }); + }, + }), + calls, + failureAction: "requeue", + maxPendingItems: batchSize * pendingFactor, + nextKey: 0, + persistedKeys: new Map(), + submissions: [], + violations, + }; + + await fc.asyncModelRun( + () => ({ model: { submitted: 0 }, real }), + commands + ); + + // Drain: acknowledge as the runtime does, then persist everything. + const closed = real.batcher.close().then( + () => "closed" as const, + (error: unknown) => error + ); + for (let round = 0; round < 1_000; round++) { + for (const submission of real.submissions) { + if ( + submission.settlement.state === "persisted" && + !submission.acknowledged + ) { + submission.acknowledged = true; + submission.acknowledge(); + } + } + const call = real.calls.shift(); + if (call !== undefined) { + for (const { key } of call.items) { + real.persistedKeys.set( + key, + (real.persistedKeys.get(key) ?? 0) + 1 + ); + } + call.resolve( + new Map(call.items.map(({ key }) => [key, `persisted:${key}`])) + ); + } + await settle(); + if ( + real.calls.length === 0 && + real.submissions.every( + (submission) => + submission.settlement.state !== "pending" && + (submission.settlement.state === "dropped" || + submission.acknowledged) + ) + ) { + break; + } + } + + await expect(closed).resolves.toBe("closed"); + assertInvariants(real); + expect(real.batcher.pendingItems).toBe(0); + for (const submission of real.submissions) { + // No completion is lost: each one is persisted exactly once or + // explicitly dropped for the rescuer to recover. + expect(submission.settlement.state).not.toBe("pending"); + expect(real.persistedKeys.get(submission.key) ?? 0).toBe( + submission.settlement.state === "persisted" ? 1 : 0 + ); + } + } + ), + { numRuns: 100 } + ); + }); +}); diff --git a/js/src/internal/completion-batcher.test.ts b/js/src/internal/completion-batcher.test.ts new file mode 100644 index 000000000..fe3d5c19b --- /dev/null +++ b/js/src/internal/completion-batcher.test.ts @@ -0,0 +1,381 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + CompletionBatcher, + CompletionDroppedError, +} from "./completion-batcher.js"; + +interface Item { + key: string; +} + +function results(items: readonly Item[]): ReadonlyMap { + return new Map(items.map((item) => [item.key, `persisted:${item.key}`])); +} + +describe("CompletionBatcher", () => { + it("applies bounded acceptance backpressure and promotes in FIFO order", async () => { + const releases: (() => void)[] = []; + const batcher = new CompletionBatcher({ + batchSize: 1, + flushIntervalMs: 1_000, + maxPendingItems: 2, + persist: async (items) => { + await new Promise((resolve) => releases.push(resolve)); + return results(items); + }, + }); + + const first = batcher.submit("1/1", { key: "1/1" }); + const second = batcher.submit("2/1", { key: "2/1" }); + const third = batcher.submit("3/1", { key: "3/1" }); + let thirdAccepted = false; + void third.accepted.then(() => { + thirdAccepted = true; + }); + + await Promise.all([first.accepted, second.accepted]); + await Promise.resolve(); + expect(batcher.pendingItems).toBe(2); + expect(thirdAccepted).toBe(false); + + releases.shift()?.(); + await first.result; + first.acknowledge(); + await third.accepted; + expect(thirdAccepted).toBe(true); + expect(batcher.pendingItems).toBe(2); + + for (const release of releases.splice(0)) release(); + await vi.waitFor(() => expect(releases).toHaveLength(1)); + releases.shift()?.(); + await Promise.all([second.result, third.result]); + second.acknowledge(); + third.acknowledge(); + await batcher.close(); + }); + + it("rejects accepted and capacity-blocked submissions on abort", async () => { + const failure = new Error("runtime failed"); + const batcher = new CompletionBatcher({ + batchSize: 1, + flushIntervalMs: 1_000, + maxPendingItems: 1, + persist: (_items, signal) => + new Promise((_resolve, reject) => + signal.addEventListener("abort", () => reject(signal.reason), { + once: true, + }) + ), + }); + const accepted = batcher.submit("1/1", { key: "1/1" }); + const blocked = batcher.submit("2/1", { key: "2/1" }); + await accepted.accepted; + + batcher.abort(failure); + + await expect(accepted.result).rejects.toBe(failure); + await expect(blocked.accepted).rejects.toBe(failure); + await expect(blocked.result).rejects.toBe(failure); + await expect(batcher.close()).rejects.toBe(failure); + }); + + it("maps persistence results by attempt key", async () => { + const batcher = new CompletionBatcher({ + batchSize: 2, + flushIntervalMs: 10, + persist: async (items) => + new Map([...items].reverse().map((item) => [item.key, item.key])), + }); + + const first = batcher.enqueue("10/1", { key: "10/1" }); + const second = batcher.enqueue("11/2", { key: "11/2" }); + + await expect(first).resolves.toBe("10/1"); + await expect(second).resolves.toBe("11/2"); + await batcher.close(); + }); + + it("partitions a 5,000-item batch in stable order", async () => { + const batches: string[][] = []; + const batcher = new CompletionBatcher({ + batchSize: 5_000, + flushIntervalMs: 1_000, + maxPendingItems: 10_000, + persist: async (items) => { + batches.push(items.map((item) => item.key)); + return results(items); + }, + }); + const completions = Array.from({ length: 5_000 }, (_, index) => { + const key = `${index + 1}/1`; + return batcher.enqueue(key, { key }); + }); + + await Promise.all(completions); + await batcher.close(); + + expect(batches).toHaveLength(1); + expect(batches[0]).toHaveLength(5_000); + expect(batches[0]?.slice(0, 3)).toEqual(["1/1", "2/1", "3/1"]); + expect(batches[0]?.slice(-3)).toEqual(["4998/1", "4999/1", "5000/1"]); + }); + + it("runs at most two queries and starts the second only when full", async () => { + const releases: (() => void)[] = []; + const batches: string[][] = []; + let active = 0; + let maximumActive = 0; + const batcher = new CompletionBatcher({ + batchSize: 2, + flushIntervalMs: 1_000, + persist: async (items) => { + batches.push(items.map((item) => item.key)); + active += 1; + maximumActive = Math.max(maximumActive, active); + await new Promise((resolve) => releases.push(resolve)); + active -= 1; + return results(items); + }, + }); + + const promises = [ + batcher.enqueue("1/1", { key: "1/1" }), + batcher.enqueue("2/1", { key: "2/1" }), + batcher.enqueue("3/1", { key: "3/1" }), + ]; + expect(batches).toEqual([["1/1", "2/1"]]); + expect(batcher.inFlightQueries).toBe(1); + + promises.push(batcher.enqueue("4/1", { key: "4/1" })); + expect(batches).toEqual([ + ["1/1", "2/1"], + ["3/1", "4/1"], + ]); + expect(batcher.inFlightQueries).toBe(2); + + promises.push(batcher.enqueue("5/1", { key: "5/1" })); + promises.push(batcher.enqueue("6/1", { key: "6/1" })); + expect(batches).toHaveLength(2); + + releases.shift()?.(); + await vi.waitFor(() => expect(batches).toHaveLength(3)); + expect(maximumActive).toBe(2); + + for (const release of releases.splice(0)) release(); + await Promise.all(promises); + await batcher.close(); + }); + + it("does not start a timed partial batch beside an active query", async () => { + vi.useFakeTimers(); + try { + const releases: (() => void)[] = []; + const batches: string[][] = []; + const batcher = new CompletionBatcher({ + batchSize: 2, + flushIntervalMs: 10, + persist: async (items) => { + batches.push(items.map((item) => item.key)); + await new Promise((resolve) => releases.push(resolve)); + return results(items); + }, + }); + + const first = batcher.enqueue("1/1", { key: "1/1" }); + const second = batcher.enqueue("2/1", { key: "2/1" }); + const partial = batcher.enqueue("3/1", { key: "3/1" }); + await vi.advanceTimersByTimeAsync(10); + expect(batches).toEqual([["1/1", "2/1"]]); + + releases.shift()?.(); + await vi.waitFor(() => expect(batches).toHaveLength(2)); + expect(batches[1]).toEqual(["3/1"]); + + releases.shift()?.(); + await Promise.all([first, second, partial]); + await batcher.close(); + } finally { + vi.useRealTimers(); + } + }); + + it("never persists overlapping attempts of one job concurrently", async () => { + const releases: (() => void)[] = []; + const batches: string[][] = []; + const batcher = new CompletionBatcher({ + batchSize: 2, + flushIntervalMs: 10, + persist: async (items) => { + batches.push(items.map((item) => item.key)); + await new Promise((resolve) => releases.push(resolve)); + return results(items); + }, + }); + + const firstAttempt = batcher.enqueue("1/1", { key: "1/1" }, "1"); + const otherJob = batcher.enqueue("2/1", { key: "2/1" }, "2"); + const secondAttempt = batcher.enqueue("1/2", { key: "1/2" }, "1"); + const thirdJob = batcher.enqueue("3/1", { key: "3/1" }, "3"); + const fourthJob = batcher.enqueue("4/1", { key: "4/1" }, "4"); + + expect(batches).toEqual([ + ["1/1", "2/1"], + ["3/1", "4/1"], + ]); + releases.shift()?.(); + await expect(firstAttempt).resolves.toBe("persisted:1/1"); + await expect(otherJob).resolves.toBe("persisted:2/1"); + releases.shift()?.(); + await expect(thirdJob).resolves.toBe("persisted:3/1"); + await expect(fourthJob).resolves.toBe("persisted:4/1"); + await vi.waitFor(() => expect(batches.at(-1)).toEqual(["1/2"])); + releases.shift()?.(); + await expect(secondAttempt).resolves.toBe("persisted:1/2"); + await batcher.close(); + }); + + it("rejects missing and duplicate attempt keys", async () => { + const batcher = new CompletionBatcher({ + batchSize: 2, + flushIntervalMs: 1_000, + persist: async () => new Map(), + }); + + const first = batcher.enqueue("1/1", { key: "1/1" }); + await expect(batcher.enqueue("1/1", { key: "duplicate" })).rejects.toThrow( + "duplicate completion key" + ); + const second = batcher.enqueue("2/1", { key: "2/1" }); + + await expect(first).rejects.toThrow("missing key"); + await expect(second).rejects.toThrow("missing key"); + await expect(batcher.close()).rejects.toThrow("missing key"); + }); + + it("requeues a failed batch at the front and keeps its capacity", async () => { + const failure = new Error("transient"); + const calls: string[][] = []; + const decisions: [unknown, number][] = []; + const batcher = new CompletionBatcher({ + batchSize: 2, + flushIntervalMs: 0, + maxPendingItems: 2, + onPersistFailure: (error, count) => { + decisions.push([error, count]); + return "requeue"; + }, + persist: async (items) => { + calls.push(items.map(({ key }) => key)); + if (calls.length === 1) throw failure; + return results(items); + }, + }); + const first = batcher.submit("1/1", { key: "1/1" }); + const second = batcher.submit("2/1", { key: "2/1" }); + const blocked = batcher.submit("3/1", { key: "3/1" }); + let blockedAccepted = false; + void blocked.accepted.then(() => { + blockedAccepted = true; + }); + + await expect(first.result).resolves.toBe("persisted:1/1"); + await expect(second.result).resolves.toBe("persisted:2/1"); + expect(blockedAccepted).toBe(false); + first.acknowledge(); + second.acknowledge(); + await expect(blocked.result).resolves.toBe("persisted:3/1"); + blocked.acknowledge(); + await batcher.close(); + + expect(decisions).toEqual([[failure, 2]]); + expect(calls).toEqual([["1/1", "2/1"], ["1/1", "2/1"], ["3/1"]]); + }); + + it("drops a failed batch without failing later completions", async () => { + const failure = new Error("permanent"); + const drops: [unknown, number][] = []; + let fail = true; + const batcher = new CompletionBatcher({ + batchSize: 1, + flushIntervalMs: 0, + maxPendingItems: 1, + onDrop: (error, count) => drops.push([error, count]), + onPersistFailure: () => "drop", + persist: async (items) => { + if (fail) { + fail = false; + throw failure; + } + return results(items); + }, + }); + const dropped = batcher.submit("1/1", { key: "1/1" }); + const later = batcher.submit("2/1", { key: "2/1" }); + + const rejection = await dropped.result.catch((error: unknown) => error); + expect(rejection).toBeInstanceOf(CompletionDroppedError); + expect((rejection as Error).cause).toBe(failure); + await expect(later.result).resolves.toBe("persisted:2/1"); + dropped.acknowledge(); + later.acknowledge(); + expect(batcher.pendingItems).toBe(0); + expect(drops).toEqual([[failure, 1]]); + await batcher.close(); + }); + + it("drops every unpersisted completion after a failure while draining", async () => { + const failure = new Error("outage"); + const drops: [unknown, number][] = []; + let rejectFirst!: (error: unknown) => void; + const batcher = new CompletionBatcher({ + batchSize: 1, + flushIntervalMs: 0, + maxPendingItems: 1, + onDrop: (error, count) => drops.push([error, count]), + onPersistFailure: () => "requeue", + persist: () => + new Promise((_resolve, reject) => { + rejectFirst = reject; + }), + }); + const inFlight = batcher.submit("1/1", { key: "1/1" }); + const waiting = batcher.submit("2/1", { key: "2/1" }); + await vi.waitFor(() => expect(rejectFirst).toBeTypeOf("function")); + + batcher.drain(); + rejectFirst(failure); + + await expect(inFlight.result).rejects.toBeInstanceOf( + CompletionDroppedError + ); + await expect(waiting.accepted).rejects.toBeInstanceOf( + CompletionDroppedError + ); + await expect(waiting.result).rejects.toBeInstanceOf(CompletionDroppedError); + inFlight.acknowledge(); + expect(drops).toEqual([[failure, 2]]); + expect(batcher.pendingItems).toBe(0); + await batcher.close(); + }); + + it("never requeues after close so shutdown finishes", async () => { + const failure = new Error("outage"); + let calls = 0; + const batcher = new CompletionBatcher({ + batchSize: 10, + flushIntervalMs: 1_000, + onPersistFailure: () => "requeue", + persist: async () => { + calls += 1; + throw failure; + }, + }); + const pending = batcher.submit("1/1", { key: "1/1" }); + + await batcher.close(); + + await expect(pending.result).rejects.toBeInstanceOf(CompletionDroppedError); + expect(calls).toBe(1); + }); +}); diff --git a/js/src/internal/completion-batcher.ts b/js/src/internal/completion-batcher.ts new file mode 100644 index 000000000..e801a2988 --- /dev/null +++ b/js/src/internal/completion-batcher.ts @@ -0,0 +1,468 @@ +interface CompletionEntry { + accept: () => void; + acceptReject: (reason: unknown) => void; + accepted: boolean; + acknowledged: boolean; + item: TItem; + key: string; + persisted: boolean; + released: boolean; + serialKey: string; + reject: (reason: unknown) => void; + resolve: (result: TResult) => void; +} + +/** How the batcher disposes of a batch whose persistence failed. */ +export type CompletionFailureAction = "drop" | "requeue"; + +export interface CompletionBatcherOptions { + batchSize: number; + flushIntervalMs: number; + maxPendingItems?: number; + /** + * Observe completions dropped after a persistence failure. `count` + * includes pending completions abandoned while draining. + */ + onDrop?: (error: unknown, count: number) => void; + /** + * Decide what happens to a batch whose `persist` call rejected: `"requeue"` + * returns it to the front of the queue for another attempt and `"drop"` + * rejects its completions with {@link CompletionDroppedError}. When + * omitted, every persistence failure is fatal to the batcher. + */ + onPersistFailure?: (error: unknown, count: number) => CompletionFailureAction; + persist: ( + items: readonly TItem[], + signal: AbortSignal + ) => Promise>; +} + +/** + * Rejection for a completion abandoned after persistence failed. The job row + * is left untouched, so it stays `running` until the rescuer recovers it. + */ +export class CompletionDroppedError extends Error { + constructor(cause: unknown) { + super("completion was dropped after a persistence failure", { cause }); + this.name = "CompletionDroppedError"; + } +} + +export interface CompletionSubmission { + /** Resolves once the bounded persistence queue owns the completion. */ + readonly accepted: Promise; + /** Releases queue ownership after post-commit observation settles. */ + readonly acknowledge: () => void; + /** Resolves only after the completion is durably persisted. */ + readonly result: Promise; +} + +/** + * Batches job outcomes with River's bounded two-way persistence policy. + * + * One query may run while another batch accumulates. A second concurrent query + * starts only for a full batch. Partial batches wait until no query is active, + * which bounds database pressure and preserves predictable hook/event timing. + */ +export class CompletionBatcher { + readonly batchSize: number; + readonly flushIntervalMs: number; + readonly maxPendingItems: number; + + #abortController = new AbortController(); + #acceptedCount = 0; + #buffer: CompletionEntry[] = []; + #closePromise: Promise | undefined; + #closeResolve: (() => void) | undefined; + #closing = false; + #draining = false; + /** The first fatal failure, boxed so even a thrown `undefined` counts. */ + #failure: { readonly error: unknown } | undefined; + #flushRequested = false; + #inFlight = 0; + #inFlightSerialKeys = new Set(); + #options: CompletionBatcherOptions; + #submittedKeys = new Set(); + #timer: NodeJS.Timeout | undefined; + #waiting: CompletionEntry[] = []; + + constructor(options: CompletionBatcherOptions) { + if (!Number.isSafeInteger(options.batchSize) || options.batchSize < 1) { + throw new RangeError( + "completion batch size must be a positive safe integer" + ); + } + if ( + !Number.isSafeInteger(options.flushIntervalMs) || + options.flushIntervalMs < 0 + ) { + throw new RangeError( + "completion flush interval must be a non-negative safe integer" + ); + } + const maxPendingItems = options.maxPendingItems ?? options.batchSize * 4; + if (!Number.isSafeInteger(maxPendingItems) || maxPendingItems < 1) { + throw new RangeError( + "completion maximum pending items must be a positive safe integer" + ); + } + + this.batchSize = options.batchSize; + this.flushIntervalMs = options.flushIntervalMs; + this.maxPendingItems = maxPendingItems; + this.#options = options; + } + + get inFlightQueries(): number { + return this.#inFlight; + } + + get pendingItems(): number { + return this.#acceptedCount; + } + + enqueue(key: string, item: TItem, serialKey = key): Promise { + const submission = this.submit(key, item, serialKey); + void submission.result.then(submission.acknowledge, submission.acknowledge); + return submission.result; + } + + /** + * Submits a completion while exposing bounded acceptance separately from + * durable persistence. Callers may release scarce worker capacity after + * `accepted`, while a supervisor continues observing `result`. + */ + submit( + key: string, + item: TItem, + serialKey = key + ): CompletionSubmission { + const invalid = this.#submissionError(key, serialKey); + if (invalid !== undefined) return rejectedSubmission(invalid); + + let accept!: () => void; + let acceptReject!: (reason: unknown) => void; + let reject!: (reason: unknown) => void; + let resolve!: (result: TResult) => void; + const accepted = new Promise((acceptedResolve, acceptedReject) => { + accept = acceptedResolve; + acceptReject = acceptedReject; + }); + const result = new Promise((resultResolve, resultReject) => { + resolve = resultResolve; + reject = resultReject; + }); + // The runtime registers supervision immediately after acceptance. Keep a + // synchronous persistence failure from becoming an unhandled rejection in + // the small interval before that registration completes. + void accepted.catch(() => undefined); + void result.catch(() => undefined); + + const entry: CompletionEntry = { + accept, + acceptReject, + accepted: false, + acknowledged: false, + item, + key, + persisted: false, + reject, + released: false, + resolve, + serialKey, + }; + this.#submittedKeys.add(key); + this.#waiting.push(entry); + this.#promoteWaiting(); + this.#advance(); + return { + accepted, + acknowledge: () => { + if (entry.acknowledged) return; + entry.acknowledged = true; + if (entry.persisted) { + this.#releaseEntry(entry); + this.#advance(); + } + }, + result, + }; + } + + /** Rejects queued and in-flight completion waiters after a runtime failure. */ + abort(reason: unknown): void { + this.#fail(reason); + } + + /** + * Stop requeueing failed batches. The next persistence failure drops its + * batch together with every other completion not yet persisted, so a + * shutdown during a database outage finishes in bounded time. + */ + drain(): void { + this.#draining = true; + } + + /** Flush all accepted items and reject if persistence failed. */ + close(): Promise { + if (this.#closePromise) return this.#closePromise; + + this.#closing = true; + this.#draining = true; + this.#flushRequested = true; + this.#clearTimer(); + this.#closePromise = new Promise((resolve) => { + this.#closeResolve = resolve; + }); + this.#advance(); + this.#resolveCloseIfDrained(); + return this.#closePromise.then(() => { + if (this.#failure !== undefined) throw this.#failure.error; + }); + } + + #advance(): void { + this.#promoteWaiting(); + while ( + this.#eligibleCount(this.batchSize) >= this.batchSize && + this.#inFlight < 2 + ) { + this.#dispatch(this.batchSize); + } + + if ( + this.#buffer.length > 0 && + this.#inFlight === 0 && + (this.#flushRequested || this.#closing) + ) { + this.#dispatch(this.#buffer.length); + this.#flushRequested = false; + } + + if (this.#buffer.length > 0) this.#scheduleTimer(); + this.#resolveCloseIfDrained(); + } + + #clearTimer(): void { + if (!this.#timer) return; + clearTimeout(this.#timer); + this.#timer = undefined; + } + + #dispatch(count: number): void { + const entries: CompletionEntry[] = []; + const remaining: CompletionEntry[] = []; + const selected = new Set(); + for (const entry of this.#buffer) { + if ( + entries.length >= count || + this.#inFlightSerialKeys.has(entry.serialKey) || + selected.has(entry.serialKey) + ) { + remaining.push(entry); + continue; + } + selected.add(entry.serialKey); + entries.push(entry); + } + if (entries.length === 0) return; + this.#buffer = remaining; + + this.#clearTimer(); + this.#inFlight += 1; + for (const entry of entries) { + this.#inFlightSerialKeys.add(entry.serialKey); + } + const items = entries.map(({ item }) => item); + let persistence: Promise>; + try { + persistence = Promise.resolve( + this.#options.persist(items, this.#abortController.signal) + ); + } catch (error: unknown) { + persistence = Promise.reject(error); + } + + void persistence + .then( + (results) => this.#settleBatch(entries, results), + (error: unknown) => this.#handlePersistFailure(entries, error) + ) + .catch((error: unknown) => { + this.#fail(error); + for (const entry of entries) entry.reject(this.#failure?.error); + }) + .finally(() => { + for (const entry of entries) { + this.#inFlightSerialKeys.delete(entry.serialKey); + if (this.#failure !== undefined) this.#releaseEntry(entry); + } + this.#inFlight -= 1; + this.#advance(); + }); + } + + #settleBatch( + entries: readonly CompletionEntry[], + results: ReadonlyMap + ): void { + if (this.#failure !== undefined) { + for (const entry of entries) entry.reject(this.#failure.error); + return; + } + for (const entry of entries) { + if (!results.has(entry.key)) { + throw new Error(`completion result is missing key: ${entry.key}`); + } + } + for (const entry of entries) { + entry.persisted = true; + entry.resolve(results.get(entry.key) as TResult); + if (entry.acknowledged) this.#releaseEntry(entry); + } + } + + #handlePersistFailure( + entries: readonly CompletionEntry[], + error: unknown + ): void { + if (this.#failure !== undefined) { + for (const entry of entries) entry.reject(this.#failure.error); + return; + } + const decide = this.#options.onPersistFailure; + if (decide === undefined) throw error; + if (this.#draining) { + const pending = [...this.#buffer.splice(0), ...this.#waiting.splice(0)]; + this.#dropEntries([...entries, ...pending], error); + return; + } + if (decide(error, entries.length) === "requeue") { + this.#buffer.unshift(...entries); + this.#flushRequested = true; + return; + } + this.#dropEntries(entries, error); + } + + #dropEntries( + entries: readonly CompletionEntry[], + error: unknown + ): void { + if (entries.length === 0) return; + const dropped = new CompletionDroppedError(error); + for (const entry of entries) { + entry.persisted = true; + entry.acceptReject(dropped); + entry.reject(dropped); + if (entry.accepted) { + this.#releaseEntry(entry); + } else { + this.#submittedKeys.delete(entry.key); + } + } + this.#options.onDrop?.(error, entries.length); + } + + #eligibleCount(limit: number): number { + const selected = new Set(); + for (const entry of this.#buffer) { + if ( + this.#inFlightSerialKeys.has(entry.serialKey) || + selected.has(entry.serialKey) + ) { + continue; + } + selected.add(entry.serialKey); + if (selected.size === limit) break; + } + return selected.size; + } + + #fail(error: unknown): void { + if (this.#failure !== undefined) return; + + this.#failure = { error }; + this.#closing = true; + this.#clearTimer(); + this.#abortController.abort(error); + for (const entry of this.#buffer.splice(0)) { + entry.persisted = true; + entry.reject(error); + this.#releaseEntry(entry); + } + for (const entry of this.#waiting.splice(0)) { + this.#submittedKeys.delete(entry.key); + entry.acceptReject(error); + entry.reject(error); + } + } + + #resolveCloseIfDrained(): void { + if ( + !this.#closing || + this.#waiting.length > 0 || + this.#buffer.length > 0 || + this.#inFlight > 0 || + this.#acceptedCount > 0 + ) + return; + this.#closeResolve?.(); + } + + #scheduleTimer(): void { + if (this.#timer || this.#flushRequested || this.#closing) return; + + this.#timer = setTimeout(() => { + this.#timer = undefined; + this.#flushRequested = true; + this.#advance(); + }, this.flushIntervalMs); + this.#timer.unref(); + } + + #promoteWaiting(): void { + while ( + this.#acceptedCount < this.maxPendingItems && + this.#waiting.length > 0 && + this.#failure === undefined + ) { + const entry = this.#waiting.shift() as CompletionEntry; + this.#acceptedCount += 1; + entry.accepted = true; + this.#buffer.push(entry); + entry.accept(); + } + if (this.#buffer.length > 0) this.#scheduleTimer(); + } + + #submissionError(key: string, serialKey: string): unknown { + if (key.length === 0) return new TypeError("completion key is empty"); + if (this.#failure !== undefined) return this.#failure.error; + if (this.#closing) return new Error("completion batcher is closing"); + if (this.#submittedKeys.has(key)) { + return new Error(`duplicate completion key: ${key}`); + } + if (serialKey.length === 0) { + return new TypeError("completion serial key is empty"); + } + return undefined; + } + + #releaseEntry(entry: CompletionEntry): void { + if (entry.released) return; + entry.released = true; + this.#acceptedCount -= 1; + this.#submittedKeys.delete(entry.key); + } +} + +function rejectedSubmission( + error: unknown +): CompletionSubmission { + const accepted = Promise.reject(error); + const result = Promise.reject(error); + void accepted.catch(() => undefined); + void result.catch(() => undefined); + return { accepted, acknowledge: () => undefined, result }; +} diff --git a/js/src/internal/event-dispatcher.test.ts b/js/src/internal/event-dispatcher.test.ts new file mode 100644 index 000000000..8c145d307 --- /dev/null +++ b/js/src/internal/event-dispatcher.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; + +import { EventDispatcher } from "./event-dispatcher.js"; + +describe("EventDispatcher", () => { + it("delivers in order, applies backpressure only when full, and drains", async () => { + const delivered: number[] = []; + let release!: () => void; + let gate = new Promise((resolve) => { + release = resolve; + }); + const dispatcher = new EventDispatcher(async (event) => { + await gate; + delivered.push(event); + }, 2); + + await dispatcher.enqueue(1); + await dispatcher.enqueue(2); + await dispatcher.enqueue(3); + let fourthQueued = false; + const fourth = dispatcher.enqueue(4).then(() => { + fourthQueued = true; + }); + await Promise.resolve(); + expect(fourthQueued).toBe(false); + expect(dispatcher.pending).toBe(2); + + release(); + gate = Promise.resolve(); + await fourth; + await dispatcher.drain(); + + expect(delivered).toEqual([1, 2, 3, 4]); + expect(dispatcher.pending).toBe(0); + await expect(dispatcher.drain()).resolves.toBeUndefined(); + }); +}); diff --git a/js/src/internal/event-dispatcher.ts b/js/src/internal/event-dispatcher.ts new file mode 100644 index 000000000..72a8c844d --- /dev/null +++ b/js/src/internal/event-dispatcher.ts @@ -0,0 +1,51 @@ +/** + * Delivers events to `onEvent` hooks in order, off the job-completion path. + * + * A slow hook doesn't hold worker or completion capacity: events queue up + * to `capacity`, and only a full queue makes the emitter wait. Hooks run one + * event at a time, so every hook still observes events in emission order. + */ +export class EventDispatcher { + readonly #capacity: number; + readonly #deliver: (event: Event) => Promise; + readonly #queue: Event[] = []; + #draining: Promise | null = null; + #spaceWaiters: (() => void)[] = []; + + constructor(deliver: (event: Event) => Promise, capacity: number) { + this.#capacity = capacity; + this.#deliver = deliver; + } + + /** Events queued but not yet delivered. */ + get pending(): number { + return this.#queue.length; + } + + /** Queue an event, waiting only while the queue is full. */ + async enqueue(event: Event): Promise { + while (this.#queue.length >= this.#capacity) { + await new Promise((resolve) => this.#spaceWaiters.push(resolve)); + } + this.#queue.push(event); + this.#draining ??= this.#run(); + } + + /** Resolve once every queued event has been delivered. */ + async drain(): Promise { + while (this.#draining !== null) await this.#draining; + } + + async #run(): Promise { + try { + for (;;) { + const event = this.#queue.shift(); + if (event === undefined) return; + this.#spaceWaiters.shift()?.(); + await this.#deliver(event); + } + } finally { + this.#draining = null; + } + } +} diff --git a/js/src/internal/event-loop-delay-monitor.test.ts b/js/src/internal/event-loop-delay-monitor.test.ts new file mode 100644 index 000000000..86e0c42f5 --- /dev/null +++ b/js/src/internal/event-loop-delay-monitor.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from "vitest"; + +import { EventLoopDelayMonitor } from "./event-loop-delay-monitor.js"; + +describe("EventLoopDelayMonitor", () => { + it("reports deterministic millisecond observations and resets its window", () => { + const histogram = { + disable: vi.fn(() => true), + enable: vi.fn(() => true), + max: 125_000_000, + mean: 40_000_000, + percentile: vi.fn(() => 80_000_000), + reset: vi.fn(), + }; + const observed: unknown[] = []; + const monitor = new EventLoopDelayMonitor( + { + reportIntervalMs: 1_000, + resolutionMs: 20, + warningThresholdMs: 100, + }, + (observation) => observed.push(observation), + { histogram } + ); + + const sample = monitor.sample(); + expect(sample.exceededThreshold).toBe(true); + expect([sample.max, sample.mean, sample.p99].map(String)).toEqual([ + "PT0.125S", + "PT0.04S", + "PT0.08S", + ]); + expect(monitor.last).toEqual(observed[0]); + expect(histogram.percentile).toHaveBeenCalledWith(99); + expect(histogram.reset).toHaveBeenCalledOnce(); + }); + + it("owns one unreferenced sampling timer and shuts it down idempotently", () => { + const histogram = { + disable: vi.fn(() => true), + enable: vi.fn(() => true), + max: 0, + mean: 0, + percentile: vi.fn(() => 0), + reset: vi.fn(), + }; + const timer = { unref: vi.fn() }; + const setInterval = vi.fn( + () => timer + ) as unknown as typeof globalThis.setInterval; + const monitor = new EventLoopDelayMonitor( + { + reportIntervalMs: 5_000, + resolutionMs: 20, + warningThresholdMs: 100, + }, + () => undefined, + { histogram, setInterval } + ); + + monitor.start(); + monitor.start(); + expect(histogram.enable).toHaveBeenCalledOnce(); + expect(setInterval).toHaveBeenCalledOnce(); + expect(timer.unref).toHaveBeenCalledOnce(); + + monitor.stop(); + monitor.stop(); + expect(histogram.disable).toHaveBeenCalledTimes(2); + }); +}); diff --git a/js/src/internal/event-loop-delay-monitor.ts b/js/src/internal/event-loop-delay-monitor.ts new file mode 100644 index 000000000..08f32cfaf --- /dev/null +++ b/js/src/internal/event-loop-delay-monitor.ts @@ -0,0 +1,109 @@ +import { monitorEventLoopDelay } from "node:perf_hooks"; + +import { measuredDuration } from "./duration.js"; + +/** Event-loop delay measured over one reporting interval. */ +export interface EventLoopDelayObservation { + /** Whether the longest delay reached the warning threshold. */ + readonly exceededThreshold: boolean; + /** Longest delay. */ + readonly max: Temporal.Duration; + readonly mean: Temporal.Duration; + /** 99th-percentile delay. */ + readonly p99: Temporal.Duration; +} + +export interface EventLoopDelayMonitorOptions { + readonly reportIntervalMs: number; + readonly resolutionMs: number; + readonly warningThresholdMs: number; +} + +interface DelayHistogram { + readonly max: number; + readonly mean: number; + disable(): boolean; + enable(): boolean; + percentile(percentile: number): number; + reset(): void; +} + +interface EventLoopDelayMonitorDependencies { + readonly histogram?: DelayHistogram; + readonly setInterval?: typeof globalThis.setInterval; +} + +/** River-owned event-loop delay sampling with an unreferenced report timer. */ +export class EventLoopDelayMonitor { + // Created on first use: Node keeps a histogram that was never enabled + // open as an event-loop handle, so a runtime that failed to start would + // otherwise leak it. + #histogram: DelayHistogram | undefined; + readonly #onObservation: (observation: EventLoopDelayObservation) => void; + readonly #options: EventLoopDelayMonitorOptions; + readonly #setInterval: typeof globalThis.setInterval; + #last: EventLoopDelayObservation | null = null; + #timer: ReturnType | undefined; + + constructor( + options: EventLoopDelayMonitorOptions, + onObservation: (observation: EventLoopDelayObservation) => void, + dependencies: EventLoopDelayMonitorDependencies = {} + ) { + this.#options = options; + this.#onObservation = onObservation; + this.#histogram = dependencies.histogram; + this.#setInterval = dependencies.setInterval ?? globalThis.setInterval; + } + + get last(): EventLoopDelayObservation | null { + return this.#last; + } + + sample(): EventLoopDelayObservation { + const histogram = this.#ensureHistogram(); + const observation = Object.freeze({ + exceededThreshold: + nanosecondsToMilliseconds(histogram.max) >= + this.#options.warningThresholdMs, + max: measuredDuration(nanosecondsToMilliseconds(histogram.max)), + mean: measuredDuration(nanosecondsToMilliseconds(histogram.mean)), + p99: measuredDuration( + nanosecondsToMilliseconds(histogram.percentile(99)) + ), + }); + histogram.reset(); + this.#last = observation; + this.#onObservation(observation); + return observation; + } + + start(): void { + if (this.#timer !== undefined) return; + this.#ensureHistogram().enable(); + this.#timer = this.#setInterval( + () => this.sample(), + this.#options.reportIntervalMs + ); + this.#timer.unref(); + } + + stop(): void { + if (this.#timer !== undefined) { + clearInterval(this.#timer); + this.#timer = undefined; + } + this.#histogram?.disable(); + } + + #ensureHistogram(): DelayHistogram { + this.#histogram ??= monitorEventLoopDelay({ + resolution: this.#options.resolutionMs, + }); + return this.#histogram; + } +} + +function nanosecondsToMilliseconds(value: number): number { + return Number.isFinite(value) ? value / 1_000_000 : 0; +} diff --git a/js/src/internal/task-supervisor.test.ts b/js/src/internal/task-supervisor.test.ts new file mode 100644 index 000000000..01b09124d --- /dev/null +++ b/js/src/internal/task-supervisor.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; + +import { BackgroundTaskError, TaskSupervisor } from "./task-supervisor.js"; + +describe("TaskSupervisor", () => { + it("aborts sibling tasks and reports a fatal rejection", async () => { + const supervisor = new TaskSupervisor(); + const failure = new Error("database disconnected"); + let siblingReason: unknown; + + supervisor.start("listener", async () => { + throw failure; + }); + supervisor.start("producer", async (signal) => { + await new Promise((resolve) => { + signal.addEventListener( + "abort", + () => { + siblingReason = signal.reason; + resolve(); + }, + { once: true } + ); + }); + }); + + await expect(supervisor.completed).rejects.toMatchObject({ + cause: failure, + name: "BackgroundTaskError", + taskName: "listener", + }); + await expect(supervisor.stop()).rejects.toBeInstanceOf(BackgroundTaskError); + expect(siblingReason).toBeInstanceOf(BackgroundTaskError); + }); + + it("treats unexpected task completion as fatal", async () => { + const supervisor = new TaskSupervisor(); + + supervisor.start("listener", async () => undefined); + + await expect(supervisor.completed).rejects.toMatchObject({ + taskName: "listener", + }); + }); + + it("allows explicitly finite tasks", async () => { + const supervisor = new TaskSupervisor(); + + supervisor.start("initial-sync", async () => undefined, { + allowCompletion: true, + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(supervisor.activeTaskNames).toEqual([]); + await supervisor.stop(); + await expect(supervisor.completed).resolves.toBeUndefined(); + }); + + it("gracefully aborts and waits for active tasks", async () => { + const supervisor = new TaskSupervisor(); + const observed: unknown[] = []; + supervisor.start("producer", async (signal) => { + await new Promise((resolve) => { + signal.addEventListener( + "abort", + () => { + observed.push(signal.reason); + resolve(); + }, + { once: true } + ); + }); + }); + + const reason = new Error("graceful stop"); + await supervisor.stop(reason); + + expect(observed).toEqual([reason]); + await expect(supervisor.completed).resolves.toBeUndefined(); + }); + + it("rejects duplicate and post-shutdown task names", async () => { + const supervisor = new TaskSupervisor(); + supervisor.start("producer", async (signal) => { + await new Promise((resolve) => { + signal.addEventListener("abort", () => resolve(), { once: true }); + }); + }); + + expect(() => supervisor.start("producer", async () => undefined)).toThrow( + "already exists" + ); + await supervisor.stop(); + expect(() => supervisor.start("later", async () => undefined)).toThrow( + "after supervisor shutdown" + ); + }); +}); diff --git a/js/src/internal/task-supervisor.ts b/js/src/internal/task-supervisor.ts new file mode 100644 index 000000000..c6baa7717 --- /dev/null +++ b/js/src/internal/task-supervisor.ts @@ -0,0 +1,157 @@ +/** Identifies the background task that caused a supervised runtime failure. */ +export class BackgroundTaskError extends Error { + readonly taskName: string; + + constructor(taskName: string, message: string, options?: ErrorOptions) { + super(message, options); + this.name = "BackgroundTaskError"; + this.taskName = taskName; + } +} + +export interface SupervisedTaskOptions { + /** Allow a finite task to resolve while the supervisor remains active. */ + allowCompletion?: boolean; +} + +/** + * Owns River background tasks and turns detached failures into one observable + * runtime failure. + */ +export class TaskSupervisor { + readonly completed: Promise; + readonly signal: AbortSignal; + + #abortController = new AbortController(); + #completedReject!: (reason: unknown) => void; + #completedResolve!: () => void; + #fatalError: BackgroundTaskError | undefined; + #stopping = false; + #tasks = new Map>(); + + constructor(parentSignal?: AbortSignal) { + this.signal = this.#abortController.signal; + this.completed = new Promise((resolve, reject) => { + this.#completedResolve = resolve; + this.#completedReject = reject; + }); + + // Consumers observe the same promise, but attaching a rejection handler + // here prevents a fatal task from becoming an unhandled rejection before + // a RunHandle has a chance to await it. + void this.completed.catch(() => undefined); + + if (parentSignal) { + if (parentSignal.aborted) { + this.#stopping = true; + this.#abortController.abort(parentSignal.reason); + this.#completedResolve(); + } else { + parentSignal.addEventListener( + "abort", + () => void this.stop(parentSignal.reason), + { once: true } + ); + } + } + } + + get activeTaskNames(): readonly string[] { + return [...this.#tasks.keys()].sort(); + } + + get stopping(): boolean { + return this.#stopping; + } + + /** Start a task whose entire lifetime is owned by this supervisor. */ + start( + name: string, + task: (signal: AbortSignal) => Promise, + options: SupervisedTaskOptions = {} + ): void { + if (name.length === 0) throw new TypeError("task name must not be empty"); + if (this.#tasks.has(name)) { + throw new Error(`background task already exists: ${name}`); + } + if (this.#stopping || this.#fatalError !== undefined) { + throw new Error("cannot start a task after supervisor shutdown"); + } + + let promise: Promise; + try { + promise = Promise.resolve(task(this.signal)); + } catch (error: unknown) { + promise = Promise.reject(error); + } + this.#tasks.set(name, promise); + void promise.then( + () => this.#taskResolved(name, options.allowCompletion === true), + (error: unknown) => this.#taskRejected(name, error) + ); + } + + /** Abort all tasks and wait for each one to settle. */ + async stop( + reason: unknown = new Error("River runtime stopped") + ): Promise { + if (this.#fatalError !== undefined) { + await Promise.allSettled(this.#tasks.values()); + throw this.#fatalError; + } + + if (!this.#stopping) { + this.#stopping = true; + this.#abortController.abort(reason); + } + + await Promise.allSettled(this.#tasks.values()); + // A task can fail while the others settle. + this.#throwIfFailed(); + this.#completedResolve(); + } + + async [Symbol.asyncDispose](): Promise { + await this.stop(); + } + + #fail(name: string, error: unknown): void { + if (this.#fatalError !== undefined || this.#stopping) return; + + this.#fatalError = new BackgroundTaskError( + name, + `background task failed: ${name}`, + { cause: error } + ); + this.#abortController.abort(this.#fatalError); + this.#completedReject(this.#fatalError); + } + + #taskRejected(name: string, error: unknown): void { + this.#tasks.delete(name); + this.#fail(name, error); + this.#resolveStopped(); + } + + #taskResolved(name: string, allowCompletion: boolean): void { + this.#tasks.delete(name); + if (!allowCompletion && !this.#stopping && !this.signal.aborted) { + this.#fail(name, new Error("task exited before runtime shutdown")); + } + this.#resolveStopped(); + } + + #throwIfFailed(): void { + if (this.#fatalError !== undefined) throw this.#fatalError; + } + + #resolveStopped(): void { + if ( + this.#stopping && + this.#fatalError === undefined && + this.#tasks.size === 0 + ) { + this.#completedResolve(); + } + } +} From bee1d288f95907279dc2ce9ab94f1e0812ca35f3 Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sun, 4 Oct 2026 17:15:44 -0500 Subject: [PATCH 20/43] rebuild the client as a complete River runtime Replace the insert-only 0.1 client with a River implementation that inserts, works, and maintains jobs in the same database as River for Go and Rust. Jobs are declared with `defineJob`, whose args are validated by any Standard Schema library or an explicit decoder on insert and again before work. Inserts take plain `{ job, args, options }` items and report `status: "inserted" | "duplicate"`; unique options use `unique` with `Temporal` durations and match Go's unique keys, including `excludeKind` rules and duplicate keys within one batch. Job IDs are `bigint` and timestamps `Temporal.Instant`, so no database value is rounded. `client.start()` runs a supervised runtime: per-queue producers with concurrency limits and dynamic queues, cooperative cancellation through `AbortSignal`, job timeouts and stuck-job detection, work outcomes (`complete`, `snooze`, `discard`, `cancel`), retry policies, an error handler, resumable steps, output recording, and transactional completion. Completions are persisted in bounded batches, and the runtime retries transient database failures with backoff. Leader election drives Go's maintenance services: scheduler, rescuer, job and queue cleaners, reindexer, and periodic jobs. Hooks, work and insert middleware, plugins, event subscriptions, and `diagnostics_channel` publishing extend it. `client.jobs` and `client.queues` query and control jobs and queues with keyset pagination whose job cursors use Go's `JobListCursor` text. Database access goes through drivers that are opaque handles; their operations are reachable only through the `riverqueue/unstable-driver` seam, which is not covered by semver. Rewrite `PgDriver` and `PrismaDriver` against it: `PgDriver` implements the full runtime on `node-postgres`, including YugabyteDB detection, while `PrismaDriver` supports insertion. Like River for Go, an operation given a caller's transaction runs directly in it and opens no savepoint or nested transaction, so a failure after River's write leaves the write for the caller to roll back. The drivers' tests are rewritten in later commits, since their integration tests need the canonical migrations. Enable `exactOptionalPropertyTypes`, `noUncheckedIndexedAccess`, and `verbatimModuleSyntax`, and lint with typescript-eslint's strict type-aware rules. Every package build copies the root `LICENSE`. --- js/.gitignore | 6 + js/LICENSE | 165 ++ js/driver/pg/package.json | 44 +- js/driver/pg/src/database.ts | 398 ++++ js/driver/pg/src/driver.integration.test.ts | 240 -- js/driver/pg/src/driver.test.ts | 299 --- js/driver/pg/src/driver.ts | 1036 ++++++++- js/driver/pg/src/errors.ts | 145 ++ js/driver/pg/src/exact-types.ts | 229 ++ js/driver/pg/src/index.ts | 10 + js/driver/pg/src/lease.ts | 106 + js/driver/pg/src/pilot.ts | 185 ++ js/driver/pg/src/sql/jobs.ts | 1013 +++++++++ js/driver/pg/src/sql/leader.ts | 155 ++ js/driver/pg/src/sql/maintenance.ts | 894 ++++++++ js/driver/pg/src/sql/notify.ts | 450 ++++ js/driver/pg/src/sql/params.ts | 24 + js/driver/pg/src/sql/queues.ts | 272 +++ js/driver/pg/src/sql/rows.ts | 167 ++ js/driver/pg/src/types.ts | 109 + js/driver/pg/tsconfig.json | 4 +- js/driver/prisma/package.json | 34 +- .../prisma/src/driver.integration.test.ts | 201 -- js/driver/prisma/src/driver.test.ts | 303 --- js/driver/prisma/src/driver.ts | 627 +++++- js/driver/prisma/src/index.ts | 15 +- js/driver/prisma/tsconfig.json | 1 + js/eslint.config.js | 91 +- js/examples/node-postgres/README.md | 6 +- js/examples/node-postgres/package.json | 11 +- js/examples/node-postgres/src/index.ts | 123 +- js/examples/node-postgres/tsconfig.json | 10 +- js/examples/prisma/README.md | 6 +- js/examples/prisma/package.json | 13 +- js/examples/prisma/src/index.ts | 118 +- js/examples/prisma/tsconfig.json | 10 +- js/examples/tsconfig.json | 21 + js/package.json | 33 +- js/pnpm-lock.yaml | 122 +- js/scripts/copy-license.mjs | 19 + js/src/client.test.ts | 1451 ++++++++++-- js/src/client.ts | 1945 ++++++++++++++--- js/src/driver-codecs.ts | 437 ++++ js/src/driver.ts | 692 +++++- js/src/events.ts | 362 +++ js/src/extensions.ts | 172 ++ js/src/index.ts | 250 ++- js/src/insert-options.ts | 375 ++++ js/src/insert-opts.ts | 58 - js/src/internal/attempt-executor.ts | 107 + js/src/internal/driver-registry.ts | 135 ++ js/src/internal/handle-gate.ts | 89 + js/src/internal/hex.ts | 6 + js/src/internal/insert-notify-limiter.ts | 36 + js/src/internal/maintenance-batch.ts | 207 ++ js/src/internal/plugin-payloads.ts | 72 + js/src/internal/postgres-capabilities.ts | 115 + js/src/internal/queue-metadata-text.ts | 41 + js/src/internal/queue-metadata-update.ts | 22 + js/src/internal/sql.ts | 7 + js/src/job-args-transform.ts | 241 ++ js/src/job-definition.ts | 491 +++++ js/src/job-insert-metadata-transform.ts | 174 ++ js/src/job.ts | 345 ++- js/src/options.ts | 542 +++++ js/src/periodic-job-store.ts | 36 + js/src/periodic.ts | 583 +++++ js/src/pilot-client.ts | 83 + js/src/pilot.ts | 713 ++++++ js/src/query.ts | 653 ++++++ js/src/resumable.ts | 211 ++ js/src/runtime.ts | 841 +++++++ js/src/runtime/attempt-result.ts | 195 ++ js/src/runtime/attempt-runner.ts | 1089 +++++++++ js/src/runtime/claim-result.ts | 54 + js/src/runtime/completion-command.ts | 281 +++ js/src/runtime/completion-pipeline.ts | 595 +++++ js/src/runtime/context.ts | 104 + js/src/runtime/failures.ts | 126 ++ js/src/runtime/notification-payloads.ts | 69 + js/src/runtime/notification-pump.ts | 241 ++ js/src/runtime/peer-attempts.ts | 598 +++++ js/src/runtime/pilot-operations.ts | 788 +++++++ js/src/runtime/producer-session.ts | 295 +++ js/src/runtime/queue-producer.ts | 845 +++++++ js/src/runtime/service-supervisor.ts | 98 + js/src/runtime/settings.ts | 755 +++++++ js/src/runtime/work-context.ts | 244 +++ js/src/services.ts | 1513 +++++++++++++ js/src/unique-bitmask.test.ts | 91 +- js/src/unique-bitmask.ts | 29 +- js/src/unstable-driver.ts | 169 ++ js/src/worker.ts | 539 +++++ js/tsconfig.base.json | 6 +- 94 files changed, 25661 insertions(+), 2270 deletions(-) create mode 100644 js/LICENSE create mode 100644 js/driver/pg/src/database.ts delete mode 100644 js/driver/pg/src/driver.integration.test.ts delete mode 100644 js/driver/pg/src/driver.test.ts create mode 100644 js/driver/pg/src/errors.ts create mode 100644 js/driver/pg/src/exact-types.ts create mode 100644 js/driver/pg/src/lease.ts create mode 100644 js/driver/pg/src/pilot.ts create mode 100644 js/driver/pg/src/sql/jobs.ts create mode 100644 js/driver/pg/src/sql/leader.ts create mode 100644 js/driver/pg/src/sql/maintenance.ts create mode 100644 js/driver/pg/src/sql/notify.ts create mode 100644 js/driver/pg/src/sql/params.ts create mode 100644 js/driver/pg/src/sql/queues.ts create mode 100644 js/driver/pg/src/sql/rows.ts create mode 100644 js/driver/pg/src/types.ts delete mode 100644 js/driver/prisma/src/driver.integration.test.ts delete mode 100644 js/driver/prisma/src/driver.test.ts create mode 100644 js/examples/tsconfig.json create mode 100644 js/scripts/copy-license.mjs create mode 100644 js/src/driver-codecs.ts create mode 100644 js/src/events.ts create mode 100644 js/src/extensions.ts create mode 100644 js/src/insert-options.ts delete mode 100644 js/src/insert-opts.ts create mode 100644 js/src/internal/attempt-executor.ts create mode 100644 js/src/internal/driver-registry.ts create mode 100644 js/src/internal/handle-gate.ts create mode 100644 js/src/internal/hex.ts create mode 100644 js/src/internal/insert-notify-limiter.ts create mode 100644 js/src/internal/maintenance-batch.ts create mode 100644 js/src/internal/plugin-payloads.ts create mode 100644 js/src/internal/postgres-capabilities.ts create mode 100644 js/src/internal/queue-metadata-text.ts create mode 100644 js/src/internal/queue-metadata-update.ts create mode 100644 js/src/internal/sql.ts create mode 100644 js/src/job-args-transform.ts create mode 100644 js/src/job-definition.ts create mode 100644 js/src/job-insert-metadata-transform.ts create mode 100644 js/src/options.ts create mode 100644 js/src/periodic-job-store.ts create mode 100644 js/src/periodic.ts create mode 100644 js/src/pilot-client.ts create mode 100644 js/src/pilot.ts create mode 100644 js/src/query.ts create mode 100644 js/src/resumable.ts create mode 100644 js/src/runtime.ts create mode 100644 js/src/runtime/attempt-result.ts create mode 100644 js/src/runtime/attempt-runner.ts create mode 100644 js/src/runtime/claim-result.ts create mode 100644 js/src/runtime/completion-command.ts create mode 100644 js/src/runtime/completion-pipeline.ts create mode 100644 js/src/runtime/context.ts create mode 100644 js/src/runtime/failures.ts create mode 100644 js/src/runtime/notification-payloads.ts create mode 100644 js/src/runtime/notification-pump.ts create mode 100644 js/src/runtime/peer-attempts.ts create mode 100644 js/src/runtime/pilot-operations.ts create mode 100644 js/src/runtime/producer-session.ts create mode 100644 js/src/runtime/queue-producer.ts create mode 100644 js/src/runtime/service-supervisor.ts create mode 100644 js/src/runtime/settings.ts create mode 100644 js/src/runtime/work-context.ts create mode 100644 js/src/services.ts create mode 100644 js/src/unstable-driver.ts create mode 100644 js/src/worker.ts diff --git a/js/.gitignore b/js/.gitignore index 96d82e92c..340900562 100644 --- a/js/.gitignore +++ b/js/.gitignore @@ -4,3 +4,9 @@ dist/ 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/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/driver/pg/package.json b/js/driver/pg/package.json index 8cb3e7b6e..ca51690a9 100644 --- a/js/driver/pg/package.json +++ b/js/driver/pg/package.json @@ -1,40 +1,64 @@ { "name": "@riverqueue/driver-pg", - "version": "0.1.0", - "description": "node-postgres (pg) driver for the riverqueue TypeScript client.", + "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" + "import": "./dist/index.js", + "default": "./dist/index.js" } }, + "engines": { + "node": ">=26" + }, "files": [ - "dist" + "dist", + "src", + "!src/**/*.test.ts", + "README.md", + "LICENSE" ], "scripts": { - "build": "tsc", + "build": "node ../../node_modules/typescript/bin/tsc && node ../../scripts/copy-license.mjs", "clean": "rm -rf dist", - "prepublishOnly": "pnpm run clean && pnpm run build" + "prepack": "pnpm run clean && pnpm run build" }, "repository": { "type": "git", "url": "git+https://github.com/riverqueue/river.git", "directory": "js/driver/pg" }, - "authors": ["Brandur Leach", "Blake Gentry"], + "contributors": [ + "Brandur Leach", + "Blake Gentry" + ], "license": "LGPL-3.0-or-later", + "publishConfig": { + "access": "public", + "provenance": true + }, "dependencies": { - "riverqueue": "workspace:*" + "postgres-array": "^3.0.4" }, "peerDependencies": { - "pg": ">=8.0.0" + "@types/pg": ">=8", + "pg": ">=8.0.0", + "riverqueue": "workspace:0.50.0-alpha.1" + }, + "peerDependenciesMeta": { + "@types/pg": { + "optional": true + } }, "devDependencies": { - "@types/pg": "^8.11.0", + "@types/pg": "^8.20.0", "pg": "^8.22.0", + "riverqueue": "workspace:0.50.0-alpha.1", "typescript": "^6.0.3" }, "keywords": [ 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 deleted file mode 100644 index 022fcb681..000000000 --- a/js/driver/pg/src/driver.integration.test.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; -import pg from "pg"; -import { Client, InsertManyParams, JobArgsObject } from "riverqueue"; -import type { JobArgs } from "riverqueue"; -import { PgDriver } from "./driver.js"; - -const TEST_DATABASE_URL = - process.env.TEST_DATABASE_URL || - "postgres://localhost:5432/river_test?sslmode=disable"; - -// Per-file random prefix so parallel test files don't interfere with each -// other's cleanup. -const filePrefix = `pg_${Math.random().toString(36).slice(2, 8)}`; - -describe("PgDriver integration", () => { - let pool: pg.Pool; - let client: Client; - - beforeAll(async () => { - pool = new pg.Pool({ connectionString: TEST_DATABASE_URL }); - client = new Client(new PgDriver(pool)); - }); - - afterAll(async () => { - await pool.end(); - }); - - afterEach(async () => { - await pool.query("DELETE FROM river_job WHERE kind LIKE $1", [ - `${filePrefix}%`, - ]); - }); - - it("inserts a job and returns it", async () => { - const result = await client.insert( - new JobArgsObject(`${filePrefix}_basic`, { key: "value" }) - ); - - expect(result.job.id).toBeGreaterThan(0); - expect(result.job.kind).toBe(`${filePrefix}_basic`); - expect(result.job.args).toEqual({ key: "value" }); - expect(result.job.state).toBe("available"); - expect(result.job.queue).toBe("default"); - expect(result.job.priority).toBe(1); - expect(result.job.maxAttempts).toBe(25); - expect(result.job.attempt).toBe(0); - expect(result.job.tags).toEqual([]); - expect(result.job.metadata).toEqual({}); - expect(result.job.createdAt).toBeInstanceOf(Date); - expect(result.job.scheduledAt).toBeInstanceOf(Date); - expect(result.job.attemptedAt).toBeNull(); - expect(result.job.attemptedBy).toBeNull(); - expect(result.job.errors).toBeNull(); - expect(result.job.finalizedAt).toBeNull(); - expect(result.uniqueSkippedAsDuplicated).toBe(false); - }); - - it("inserts with all options", async () => { - const future = new Date(Date.now() + 3_600_000); - - const result = await client.insert( - new JobArgsObject(`${filePrefix}_opts`, { n: 42 }), - { - maxAttempts: 5, - priority: 3, - queue: "high_priority", - scheduledAt: future, - tags: ["tag_one", "tag_two"], - } - ); - - expect(result.job.kind).toBe(`${filePrefix}_opts`); - expect(result.job.maxAttempts).toBe(5); - expect(result.job.priority).toBe(3); - expect(result.job.queue).toBe("high_priority"); - expect(result.job.state).toBe("scheduled"); - expect(result.job.tags).toEqual(["tag_one", "tag_two"]); - }); - - it("inserts many jobs", async () => { - const results = await client.insertMany([ - new JobArgsObject(`${filePrefix}_batch_a`, { i: 1 }), - new JobArgsObject(`${filePrefix}_batch_b`, { i: 2 }), - new JobArgsObject(`${filePrefix}_batch_c`, { i: 3 }), - ]); - - expect(results).toHaveLength(3); - - const ids = results.map((r) => r.job.id); - expect(new Set(ids).size).toBe(3); - - expect(results[0]!.job.kind).toBe(`${filePrefix}_batch_a`); - expect(results[1]!.job.kind).toBe(`${filePrefix}_batch_b`); - expect(results[2]!.job.kind).toBe(`${filePrefix}_batch_c`); - }); - - it("handles unique job insertion", async () => { - const uniqueOpts = { byArgs: true as const, byQueue: true as const }; - - const first = await client.insert( - new JobArgsObject(`${filePrefix}_unique`, { key: "same" }), - { uniqueOpts } - ); - expect(first.uniqueSkippedAsDuplicated).toBe(false); - expect(first.job.uniqueKey).not.toBeNull(); - - const second = await client.insert( - new JobArgsObject(`${filePrefix}_unique`, { key: "same" }), - { uniqueOpts } - ); - expect(second.uniqueSkippedAsDuplicated).toBe(true); - expect(second.job.id).toBe(first.job.id); - }); - - it("deduplicates batch with duplicate unique keys", async () => { - const uniqueOpts = { byArgs: true as const }; - const results = await client.insertMany([ - new InsertManyParams( - new JobArgsObject(`${filePrefix}_batch_uniq`, { key: "same" }), - { uniqueOpts } - ), - new InsertManyParams( - new JobArgsObject(`${filePrefix}_batch_uniq`, { key: "same" }), - { uniqueOpts } - ), - new InsertManyParams( - new JobArgsObject(`${filePrefix}_batch_uniq`, { key: "different" }), - { uniqueOpts } - ), - ]); - - expect(results).toHaveLength(3); - expect(results[0]!.uniqueSkippedAsDuplicated).toBe(false); - expect(results[1]!.uniqueSkippedAsDuplicated).toBe(true); - expect(results[2]!.uniqueSkippedAsDuplicated).toBe(false); - expect(results[1]!.job.id).toBe(results[0]!.job.id); - expect(results[2]!.job.id).not.toBe(results[0]!.job.id); - }); - - it("allows unique jobs with different args", async () => { - const uniqueOpts = { byArgs: true as const }; - - const first = await client.insert( - new JobArgsObject(`${filePrefix}_unique`, { key: "one" }), - { uniqueOpts } - ); - const second = await client.insert( - new JobArgsObject(`${filePrefix}_unique`, { key: "two" }), - { uniqueOpts } - ); - - expect(first.uniqueSkippedAsDuplicated).toBe(false); - expect(second.uniqueSkippedAsDuplicated).toBe(false); - expect(second.job.id).not.toBe(first.job.id); - }); - - it("uses custom class args with toJSON", async () => { - const kind = `${filePrefix}_email`; - - class EmailArgs implements JobArgs { - kind = kind; - constructor( - public to: string, - public subject: string - ) {} - toJSON() { - return { to: this.to, subject: this.subject }; - } - } - - const result = await client.insert( - new EmailArgs("user@example.com", "Hello") - ); - - expect(result.job.kind).toBe(kind); - expect(result.job.args).toEqual({ - to: "user@example.com", - subject: "Hello", - }); - }); - - it("verifies job exists in database after insert", async () => { - const result = await client.insert( - new JobArgsObject(`${filePrefix}_verify`, { data: "check" }) - ); - - const dbResult = await pool.query("SELECT * FROM river_job WHERE id = $1", [ - result.job.id, - ]); - expect(dbResult.rowCount).toBe(1); - expect(dbResult.rows[0].kind).toBe(`${filePrefix}_verify`); - expect(dbResult.rows[0].args).toEqual({ data: "check" }); - }); - - it("inserts within a transaction via tx option", async () => { - const poolClient = await pool.connect(); - try { - await poolClient.query("BEGIN"); - - await client.insert(new JobArgsObject(`${filePrefix}_tx_1`, {}), { - tx: poolClient, - }); - await client.insert(new JobArgsObject(`${filePrefix}_tx_2`, {}), { - tx: poolClient, - }); - - await poolClient.query("COMMIT"); - } finally { - poolClient.release(); - } - - const dbResult = await pool.query( - `SELECT kind FROM river_job WHERE kind LIKE '${filePrefix}_tx_%' ORDER BY kind` - ); - expect(dbResult.rows.map((r) => r.kind)).toEqual([ - `${filePrefix}_tx_1`, - `${filePrefix}_tx_2`, - ]); - }); - - it("rolls back transaction via tx option", async () => { - const poolClient = await pool.connect(); - try { - await poolClient.query("BEGIN"); - - await client.insert(new JobArgsObject(`${filePrefix}_rollback`, {}), { - tx: poolClient, - }); - - await poolClient.query("ROLLBACK"); - } finally { - poolClient.release(); - } - - const dbResult = await pool.query( - `SELECT * FROM river_job WHERE kind = '${filePrefix}_rollback'` - ); - expect(dbResult.rowCount).toBe(0); - }); -}); diff --git a/js/driver/pg/src/driver.test.ts b/js/driver/pg/src/driver.test.ts deleted file mode 100644 index ea1b87bb3..000000000 --- a/js/driver/pg/src/driver.test.ts +++ /dev/null @@ -1,299 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; -import { PgDriver } from "./driver.js"; -import type { JobInsertParams } from "riverqueue"; - -// Simulates what pg returns for a river_job row. -function fakePgRow(overrides: Record = {}) { - return { - id: "42", // pg returns bigint as string - args: { strings: ["a", "b"] }, - attempt: 0, - attempted_at: null, - attempted_by: null, - created_at: new Date("2024-06-01T00:00:00Z"), - errors: null, - finalized_at: null, - kind: "sort", - max_attempts: 25, - metadata: {}, - priority: 1, - queue: "default", - scheduled_at: new Date("2024-06-01T00:00:00Z"), - state: "available", - tags: ["tag1", "tag2"], - unique_key: null, - unique_states: null, - unique_skipped_as_duplicate: false, - ...overrides, - }; -} - -function fakeInsertParams( - overrides: Partial = {} -): JobInsertParams { - return { - encodedArgs: '{"strings":["a","b"]}', - kind: "sort", - maxAttempts: 25, - priority: 1, - queue: "default", - scheduledAt: new Date("2024-06-01T00:00:00Z"), - state: "available", - tags: [], - uniqueKey: null, - uniqueStates: null, - ...overrides, - }; -} - -// Minimal mock matching the pg Pool/PoolClient query interface. -function mockPgClient() { - return { - capturedSql: "" as string, - capturedValues: [] as unknown[], - rowsToReturn: [] as Record[], - query: vi.fn(async function ( - this: { - capturedSql: string; - capturedValues: unknown[]; - rowsToReturn: Record[]; - }, - sql: string, - values: unknown[] - ) { - this.capturedSql = sql; - this.capturedValues = values; - return { rows: this.rowsToReturn, rowCount: this.rowsToReturn.length }; - }), - }; -} - -describe("PgDriver", () => { - let pgClient: ReturnType; - let driver: PgDriver; - - beforeEach(() => { - pgClient = mockPgClient(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - driver = new PgDriver(pgClient as any); - }); - - describe("jobInsertMany", () => { - it("returns empty array for empty params", async () => { - const results = await driver.jobInsertMany([]); - expect(results).toEqual([]); - expect(pgClient.query).not.toHaveBeenCalled(); - }); - - it("constructs correct SQL and parameters for single insert", async () => { - const scheduledAt = new Date("2024-06-01T12:00:00Z"); - pgClient.rowsToReturn = [fakePgRow()]; - - await driver.jobInsertMany([ - fakeInsertParams({ scheduledAt, tags: ["urgent"] }), - ]); - - expect(pgClient.query).toHaveBeenCalledOnce(); - const sql = pgClient.query.mock.calls[0]![0] as string; - const values = pgClient.query.mock.calls[0]![1] as unknown[]; - - expect(sql).toContain("INSERT INTO river_job"); - expect(sql).toContain("ON CONFLICT (unique_key)"); - expect(sql).toContain("river_job_state_in_bitmask"); - expect(sql).toContain("RETURNING"); - expect(sql).toContain("unique_skipped_as_duplicate"); - - // 10 params per row - expect(values).toHaveLength(10); - expect(values[0]).toBe('{"strings":["a","b"]}'); // encodedArgs - expect(values[1]).toBe("sort"); // kind - expect(values[2]).toBe(25); // maxAttempts - expect(values[3]).toBe(1); // priority - expect(values[4]).toBe("default"); // queue - expect(values[5]).toBe(scheduledAt); // scheduledAt - expect(values[6]).toBe("available"); // state - expect(values[7]).toEqual(["urgent"]); // tags - expect(values[8]).toBeNull(); // uniqueKey - expect(values[9]).toBeNull(); // uniqueStates - }); - - it("constructs correct parameters for batch insert", async () => { - pgClient.rowsToReturn = [fakePgRow(), fakePgRow({ id: "43" })]; - - await driver.jobInsertMany([ - fakeInsertParams({ kind: "job_a" }), - fakeInsertParams({ kind: "job_b" }), - ]); - - const sql = pgClient.query.mock.calls[0]![0] as string; - const values = pgClient.query.mock.calls[0]![1] as unknown[]; - - // Should have two VALUE clauses - expect(sql).toContain("$1::jsonb"); - expect(sql).toContain("$11::jsonb"); - expect(values).toHaveLength(20); - expect(values[1]).toBe("job_a"); - expect(values[11]).toBe("job_b"); - }); - - it("converts unique key to Buffer", async () => { - const uniqueKey = new Uint8Array([1, 2, 3, 4]); - pgClient.rowsToReturn = [fakePgRow()]; - - await driver.jobInsertMany([ - fakeInsertParams({ uniqueKey, uniqueStates: "11110101" }), - ]); - - const values = pgClient.query.mock.calls[0]![1] as unknown[]; - expect(Buffer.isBuffer(values[8])).toBe(true); - expect(values[9]).toBe("11110101"); - }); - - it("uses schema prefix in SQL when provided", async () => { - pgClient.rowsToReturn = [fakePgRow()]; - - await driver.jobInsertMany([fakeInsertParams()], { - schemaPrefix: '"custom".', - }); - - const sql = pgClient.query.mock.calls[0]![0] as string; - expect(sql).toContain('INSERT INTO "custom".river_job'); - expect(sql).toContain('"custom".river_job_state_in_bitmask'); - }); - - it("omits schema prefix when empty", async () => { - pgClient.rowsToReturn = [fakePgRow()]; - - await driver.jobInsertMany([fakeInsertParams()], { - schemaPrefix: "", - }); - - const sql = pgClient.query.mock.calls[0]![0] as string; - expect(sql).toContain("INSERT INTO river_job"); - expect(sql).not.toContain('".'); - }); - }); - - describe("jobInsert", () => { - it("delegates to jobInsertMany", async () => { - pgClient.rowsToReturn = [fakePgRow()]; - - const [job, skipped] = await driver.jobInsert(fakeInsertParams()); - - expect(pgClient.query).toHaveBeenCalledOnce(); - expect(job.kind).toBe("sort"); - expect(skipped).toBe(false); - }); - }); - - describe("row mapping", () => { - it("maps basic columns correctly", async () => { - pgClient.rowsToReturn = [fakePgRow()]; - - const [job] = await driver.jobInsert(fakeInsertParams()); - - expect(job.id).toBe(42); - expect(typeof job.id).toBe("number"); - expect(job.args).toEqual({ strings: ["a", "b"] }); - expect(job.attempt).toBe(0); - expect(job.kind).toBe("sort"); - expect(job.maxAttempts).toBe(25); - expect(job.metadata).toEqual({}); - expect(job.priority).toBe(1); - expect(job.queue).toBe("default"); - expect(job.state).toBe("available"); - expect(job.tags).toEqual(["tag1", "tag2"]); - expect(job.createdAt).toEqual(new Date("2024-06-01T00:00:00Z")); - expect(job.scheduledAt).toEqual(new Date("2024-06-01T00:00:00Z")); - }); - - it("maps null columns", async () => { - pgClient.rowsToReturn = [fakePgRow()]; - - const [job] = await driver.jobInsert(fakeInsertParams()); - - expect(job.attemptedAt).toBeNull(); - expect(job.attemptedBy).toBeNull(); - expect(job.errors).toBeNull(); - expect(job.finalizedAt).toBeNull(); - expect(job.uniqueKey).toBeNull(); - expect(job.uniqueStates).toBeNull(); - }); - - it("maps non-null optional columns", async () => { - pgClient.rowsToReturn = [ - fakePgRow({ - attempted_at: new Date("2024-06-01T01:00:00Z"), - attempted_by: ["worker-1"], - finalized_at: new Date("2024-06-01T02:00:00Z"), - }), - ]; - - const [job] = await driver.jobInsert(fakeInsertParams()); - - expect(job.attemptedAt).toEqual(new Date("2024-06-01T01:00:00Z")); - expect(job.attemptedBy).toEqual(["worker-1"]); - expect(job.finalizedAt).toEqual(new Date("2024-06-01T02:00:00Z")); - }); - - it("maps errors from jsonb array", async () => { - pgClient.rowsToReturn = [ - fakePgRow({ - errors: [ - { - at: "2024-06-01T01:00:00Z", - attempt: 1, - error: "something broke", - trace: "stack trace here", - }, - ], - }), - ]; - - const [job] = await driver.jobInsert(fakeInsertParams()); - - expect(job.errors).toHaveLength(1); - expect(job.errors![0]!.at).toEqual(new Date("2024-06-01T01:00:00Z")); - expect(job.errors![0]!.attempt).toBe(1); - expect(job.errors![0]!.error).toBe("something broke"); - expect(job.errors![0]!.trace).toBe("stack trace here"); - }); - - it("maps unique key from Buffer", async () => { - const buf = Buffer.from([0xde, 0xad, 0xbe, 0xef]); - pgClient.rowsToReturn = [fakePgRow({ unique_key: buf })]; - - const [job] = await driver.jobInsert(fakeInsertParams()); - - expect(job.uniqueKey).toBeInstanceOf(Uint8Array); - expect(job.uniqueKey).toEqual(new Uint8Array([0xde, 0xad, 0xbe, 0xef])); - }); - - it("maps unique states from bit string", async () => { - // "10000001" = available + scheduled - pgClient.rowsToReturn = [fakePgRow({ unique_states: "10000001" })]; - - const [job] = await driver.jobInsert(fakeInsertParams()); - - expect(job.uniqueStates).toEqual(["available", "scheduled"]); - }); - - it("reports unique_skipped_as_duplicate", async () => { - pgClient.rowsToReturn = [ - fakePgRow({ unique_skipped_as_duplicate: true }), - ]; - - const [, skipped] = await driver.jobInsert(fakeInsertParams()); - - expect(skipped).toBe(true); - }); - - it("defaults tags to empty array when null", async () => { - pgClient.rowsToReturn = [fakePgRow({ tags: null })]; - - const [job] = await driver.jobInsert(fakeInsertParams()); - - expect(job.tags).toEqual([]); - }); - }); -}); diff --git a/js/driver/pg/src/driver.ts b/js/driver/pg/src/driver.ts index 0ed2d6c5c..385c0beb9 100644 --- a/js/driver/pg/src/driver.ts +++ b/js/driver/pg/src/driver.ts @@ -1,135 +1,941 @@ -import type { Client as PgClient, Pool, PoolClient, QueryResultRow } from "pg"; +import { Buffer } from "node:buffer"; +import type { Client as PgClient, ClientBase, Pool, PoolClient } from "pg"; +import type { JobRow } from "riverqueue"; import type { - AttemptError, - Driver, - DriverOptions, + DriverInsertResult, + DriverRecord, + InsertDriverOptions, + JobClaimOptions, + JobClaimParams, + JobClaimResult, + JobCompletionCommand, + JobCompletionResult, + JobDeleteManyParams, + JobDeleteResult, JobInsertParams, - JobRow, - JobState, -} from "riverqueue"; -import { uniqueBitmaskToStates } from "riverqueue"; + 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_]*$/; /** - * A River driver for node-postgres (`pg`). - * - * import { Pool } from "pg"; - * import { Client } from "riverqueue"; - * import { PgDriver } from "@riverqueue/driver-pg"; - * - * const pool = new Pool({ connectionString: "postgres://..." }); - * const client = new Client(new PgDriver(pool)); - * - * For transactions, pass a `PoolClient` as the `tx` option: + * River's complete node-postgres backend. * - * const poolClient = await pool.connect(); - * await poolClient.query("BEGIN"); - * await client.insert(args, { tx: poolClient }); - * await poolClient.query("COMMIT"); - * poolClient.release(); + * 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 implements Driver { - private client: Pool | PoolClient | PgClient; +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) { - this.client = client; + constructor( + client: Pool | PoolClient | PgClient, + options: PgDriverOptions = {} + ) { + const runtime = new PgRuntime(client, options); + registerDriver(this, runtime.driverRecord()); } +} - async jobInsert( - params: JobInsertParams, - options?: DriverOptions - ): Promise<[JobRow, boolean]> { - const results = await this.jobInsertMany([params], options); - return results[0] as [JobRow, boolean]; - } - - async jobInsertMany( - params: JobInsertParams[], - options?: DriverOptions - ): Promise<[JobRow, boolean][]> { - if (params.length === 0) return []; - - const COLUMNS_PER_ROW = 10; - const values: unknown[] = []; - const valueClauses: string[] = []; - - for (let i = 0; i < params.length; i++) { - const p = params[i] as JobInsertParams; - const offset = i * COLUMNS_PER_ROW; - valueClauses.push( - `($${offset + 1}::jsonb, $${offset + 2}, $${offset + 3}, $${offset + 4}, ` + - `$${offset + 5}, $${offset + 6}::timestamptz, $${offset + 7}, ` + - `$${offset + 8}::text[], $${offset + 9}::bytea, $${offset + 10}::bit(8))` +/** + * @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" ); - values.push( - p.encodedArgs, - p.kind, - p.maxAttempts, - p.priority, - p.queue, - p.scheduledAt, - p.state, - p.tags, - p.uniqueKey ? Buffer.from(p.uniqueKey) : null, - p.uniqueStates + } + + 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` + ); + } + } - const schemaPrefix = options?.schemaPrefix ?? ""; - const sql = ` - INSERT INTO ${schemaPrefix}river_job ( - args, kind, max_attempts, priority, - queue, scheduled_at, state, - tags, unique_key, unique_states - ) - VALUES ${valueClauses.join(", ")} - ON CONFLICT (unique_key) - WHERE unique_key IS NOT NULL - AND unique_states IS NOT NULL - AND ${schemaPrefix}river_job_state_in_bitmask(unique_states, state) - DO UPDATE SET kind = EXCLUDED.kind - RETURNING *, (xmax != 0) AS unique_skipped_as_duplicate - `; + /** + * Cancel a job using River's canonical persisted cancellation semantics. + * + * @internal + */ + jobCancel(id: bigint, options?: PgOperationOptions): Promise { + return jobSql.jobCancel(this.#db, id, options); + } - const queryable = options?.tx ?? this.client; - const result = await queryable.query(sql, values); - return result.rows.map((row: QueryResultRow) => this.toInsertResult(row)); + /** + * Backend test hook for deterministic cancellation clocks and topics. + * + * @internal + */ + jobCancelWithOptions( + params: PgJobCancelParams, + options?: PgOperationOptions + ): Promise { + return jobSql.jobCancelWithOptions(this.#db, params, options); } - private toInsertResult(row: QueryResultRow): [JobRow, boolean] { - return [this.toJobRow(row), row.unique_skipped_as_duplicate as boolean]; + /** + * Delete a job unless it is currently running. + * + * @internal + */ + jobDelete( + id: bigint, + options?: PgOperationOptions + ): Promise { + return jobSql.jobDelete(this.#db, id, options); } - private toJobRow(row: QueryResultRow): JobRow { - return { - id: Number(row.id), - args: row.args as Record, - attempt: row.attempt as number, - attemptedAt: (row.attempted_at as Date) ?? null, - attemptedBy: (row.attempted_by as string[]) ?? null, - createdAt: row.created_at as Date, - errors: row.errors - ? (row.errors as Record[]).map((e): AttemptError => ({ - at: new Date(e.at as string), - attempt: e.attempt as number, - error: e.error as string, - trace: e.trace as string, - })) - : null, - finalizedAt: (row.finalized_at as Date) ?? null, - kind: row.kind as string, - maxAttempts: row.max_attempts as number, - metadata: row.metadata as Record, - priority: row.priority as number, - queue: row.queue as string, - scheduledAt: row.scheduled_at as Date, - state: row.state as JobState, - tags: (row.tags as string[]) ?? [], - uniqueKey: row.unique_key - ? new Uint8Array(row.unique_key as Buffer) - : null, - uniqueStates: row.unique_states - ? uniqueBitmaskToStates(parseInt(row.unique_states as string, 2)) - : null, - }; + /** + * 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 index ae4f28141..e7df90648 100644 --- a/js/driver/pg/src/index.ts +++ b/js/driver/pg/src/index.ts @@ -1 +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.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 { + return jobLoadClaimed( + this.#db, + ids, + requireTransaction(options, "loadClaimed") + ); + } + + async notify( + topic: "control" | "insert", + payloads: readonly string[], + options: { readonly tx: ClientBase } + ): Promise { + const tx = requireTransaction(options, "notify"); + await notifyMany(this.#db, notificationTopic(topic), payloads, { tx }); + } + + async transaction( + callback: (tx: ClientBase) => PromiseLike | Result, + options: { + readonly signal?: AbortSignal; + readonly tx?: ClientBase; + } = {} + ): Promise { + if (options.tx !== undefined) { + // Like River for Go, run directly in the caller's transaction without + // a savepoint; the caller rolls it back when this fails. + const tx = requireTransaction(options, "pilotTransaction"); + options.signal?.throwIfAborted(); + const result = await callback(tx); + options.signal?.throwIfAborted(); + return result; + } + return this.#db.transaction( + "pilotTransaction", + this.#pool, + options.signal, + callback + ); + } +} + +function notificationTopic(topic: string): string { + switch (topic) { + case "control": + return "river_control"; + case "insert": + return "river_insert"; + default: + throw new ValidationError( + `River notifications can be sent on "control" or "insert", not ${JSON.stringify(topic)}` + ); + } +} + +function requireTransaction( + options: { readonly tx?: unknown } | undefined, + operation: string +): ClientBase { + const tx = options?.tx; + if (!isQueryable(tx)) { + throw backendMismatchError( + operation, + "the transaction is not a node-postgres client" + ); + } + return tx as ClientBase; +} diff --git a/js/driver/pg/src/sql/jobs.ts b/js/driver/pg/src/sql/jobs.ts new file mode 100644 index 000000000..78e485c8b --- /dev/null +++ b/js/driver/pg/src/sql/jobs.ts @@ -0,0 +1,1013 @@ +/** Job insertion, claiming, completion, and administration queries. */ +import { Buffer } from "node:buffer"; +import { randomBytes } from "node:crypto"; +import type { ClientBase, QueryResult } from "pg"; +import type { JobRow, JobState, JsonObject } from "riverqueue"; +import { ValidationError } from "riverqueue"; +import type { + DriverAttemptError, + DriverInsertResult, + InsertDriverOptions, + JobClaimParams, + JobClaimResult, + JobCompletionCommand, + JobCompletionResult, + JobDeleteManyParams, + JobDeleteResult, + JobInsertParams, + JobListParams, + JobUpdateParams, +} from "riverqueue/unstable-driver"; +import { + jobCompletionKey, + jobListKeyset, + jobListKeysetSql, + UNIQUE_INSERT_NONCE_KEY, + uniqueInsertConflictSql, + uniqueBitmaskFromStates, +} from "riverqueue/unstable-driver"; +import type { PgDatabase } from "../database.js"; +import { databaseError } from "../errors.js"; +import type { + PgJobCancelParams, + PgJobRetryParams, + PgOperationOptions, +} from "../types.js"; +import { instantParameter, validateLimit } from "./params.js"; +import type { PgCompletionRow, PgInsertRow, PgJobRow } from "./rows.js"; +import { mapOneJob, mapOneJobPartial, toJobRowPartial } from "./rows.js"; + +/** + * Leading comment identifying completion batches River may cancel server + * side. The cancellation only signals a backend still running a statement + * with this prefix, so a reused process ID can never cancel unrelated work. + */ +const COMPLETE_MANY_STATEMENT = "/* river:jobCompleteMany */"; + +/** Cancel a job using River's canonical persisted cancellation semantics. */ +export async function jobCancel( + db: PgDatabase, + id: bigint, + options?: PgOperationOptions +): Promise { + return jobCancelWithOptions( + db, + { + cancelAttemptedAt: Temporal.Now.instant(), + controlTopic: "river_control", + id, + }, + options + ); +} + +/** Backend test hook for deterministic cancellation clocks and topics. */ +export async function jobCancelWithOptions( + db: PgDatabase, + params: PgJobCancelParams, + options?: PgOperationOptions +): Promise { + const jobTable = db.table("river_job"); + const { supportsListenNotify } = await db.capabilities(options); + const sql = ` + WITH locked_job AS ( + SELECT id, queue, state, finalized_at + FROM ${jobTable} + WHERE id = $1::bigint + FOR UPDATE + ), + notification AS ( + SELECT + id, + CASE WHEN $6::boolean THEN pg_notify( + concat(coalesce($4::text, current_schema()), '.', $2::text), + json_build_object( + 'action', 'cancel', + 'job_id', id, + 'queue', queue + )::text + ) END + FROM locked_job + WHERE state NOT IN ('cancelled', 'completed', 'discarded') + AND finalized_at IS NULL + ), + updated_job AS ( + UPDATE ${jobTable} AS river_job + SET + state = CASE + WHEN river_job.state = 'running' THEN river_job.state + ELSE 'cancelled' + END, + finalized_at = CASE + WHEN river_job.state = 'running' THEN river_job.finalized_at + ELSE coalesce($5::timestamptz, now()) + END, + metadata = jsonb_set( + river_job.metadata, + '{cancel_attempted_at}'::text[], + $3::jsonb, + true + ) + FROM notification + WHERE river_job.id = notification.id + RETURNING river_job.* + ) + -- A cancel that lost a race to another committed after this statement + -- began updates nothing. Like River for Go, the fallback read locks the + -- row so it returns the committed row, not the one this statement first + -- saw. + SELECT * FROM ( + SELECT * + FROM ${jobTable} + WHERE id = $1::bigint + FOR UPDATE + ) AS fallback_job + WHERE fallback_job.id NOT IN (SELECT id FROM updated_job) + UNION + SELECT * FROM updated_job + `; + + const result = await db.query( + "jobCancel", + sql, + [ + params.id.toString(10), + params.controlTopic, + JSON.stringify(params.cancelAttemptedAt.toString()), + db.schemaName, + instantParameter(params.now), + supportsListenNotify, + ], + options + ); + return mapOneJobPartial(result); +} + +/** Delete a job unless it is currently running. */ +export async function jobDelete( + db: PgDatabase, + id: bigint, + options?: PgOperationOptions +): Promise { + const jobTable = db.table("river_job"); + const sql = ` + WITH job_to_delete AS ( + SELECT id + FROM ${jobTable} + WHERE id = $1::bigint + FOR UPDATE + ), + deleted_job AS ( + DELETE FROM ${jobTable} AS river_job + USING job_to_delete + WHERE river_job.id = job_to_delete.id + AND river_job.state != 'running' + RETURNING river_job.* + ) + SELECT *, false AS was_deleted + FROM ${jobTable} + WHERE id = $1::bigint + AND id NOT IN (SELECT id FROM deleted_job) + UNION + SELECT *, true AS was_deleted + FROM deleted_job + `; + const result = await db.query( + "jobDelete", + sql, + [id.toString(10)], + options + ); + const row = result.rows[0]; + if (row === undefined) return { status: "not_found" }; + + const job = toJobRowPartial(row).job; + return row.was_deleted + ? { job, status: "deleted" } + : { job, status: "running" }; +} + +/** Delete a bounded, explicitly authorized set of non-running jobs. */ +export async function jobDeleteMany( + db: PgDatabase, + params: JobDeleteManyParams, + options?: PgOperationOptions +): Promise { + if ( + !Number.isInteger(params.limit) || + params.limit < 1 || + params.limit > 10_000 + ) { + throw new RangeError("bulk delete maximum must be from 1 to 10000"); + } + const hasFilter = + params.ids.length > 0 || + params.kinds.length > 0 || + params.priorities.length > 0 || + params.queues.length > 0 || + params.states.length > 0; + if (!params.all && !hasFilter) { + throw new RangeError("bulk delete requires a filter or all=true"); + } + if (params.all && hasFilter) { + throw new RangeError( + "bulk delete all=true cannot be combined with filters" + ); + } + const jobTable = db.table("river_job"); + const result = await db.query( + "jobDeleteMany", + ` + WITH jobs_to_delete AS ( + SELECT id FROM ${jobTable} + WHERE state != 'running' + AND (cardinality($1::bigint[]) = 0 OR id = ANY($1::bigint[])) + AND (cardinality($2::text[]) = 0 OR kind = ANY($2::text[])) + AND (cardinality($3::smallint[]) = 0 OR priority = ANY($3::smallint[])) + AND (cardinality($4::text[]) = 0 OR queue = ANY($4::text[])) + AND (cardinality($5::text[]) = 0 OR state::text = ANY($5::text[])) + ORDER BY id ASC + LIMIT $6::int + FOR UPDATE SKIP LOCKED + ), + deleted AS ( + DELETE FROM ${jobTable} AS river_job + USING jobs_to_delete + WHERE river_job.id = jobs_to_delete.id + RETURNING river_job.* + ) + SELECT * FROM deleted ORDER BY id ASC + `, + [ + params.ids.map((id) => id.toString(10)), + params.kinds, + params.priorities, + params.queues, + params.states, + params.limit, + ], + options + ); + return result.rows.map((row) => toJobRowPartial(row).job); +} + +/** Get a job by exact 64-bit ID. */ +export async function jobGet( + db: PgDatabase, + id: bigint, + options?: PgOperationOptions +): Promise { + const result = await db.query( + "jobGet", + `SELECT * FROM ${db.table("river_job")} WHERE id = $1::bigint LIMIT 1`, + [id.toString(10)], + options + ); + return mapOneJob(result); +} + +/** + * The IDs among `ids` of running jobs with a cancellation request, like + * River for Go's `JobGetCancelRequested`. + */ +export async function jobGetCancelRequested( + db: PgDatabase, + ids: readonly bigint[], + options: { readonly signal?: AbortSignal } = {} +): Promise { + if (ids.length === 0) return []; + const result = await db.queryAbortable<{ id: string }>( + "jobGetCancelRequested", + ` + SELECT id::text AS id + FROM ${db.table("river_job")} + WHERE id = any($1::bigint[]) + AND metadata ? 'cancel_attempted_at' + AND state = 'running' + ORDER BY id + `, + [ids.map((id) => id.toString(10))], + options + ); + return result.rows.map(({ id }) => BigInt(id)); +} + +/** Atomically claim runnable jobs using River's priority order and SKIP LOCKED. */ +export async function jobClaim( + db: PgDatabase, + params: JobClaimParams, + options: { readonly signal?: AbortSignal; readonly tx?: ClientBase } = {} +): Promise { + if (params.queues.length === 0) return { jobs: [] }; + const names = new Set(); + for (const queue of params.queues) { + validateLimit(queue.limit, "claim queue limit"); + if (names.has(queue.name)) { + throw new RangeError("claim queues must contain each queue once"); + } + names.add(queue.name); + } + const jobTable = db.table("river_job"); + const query = ( + text: string, + values: unknown[] + ): Promise> => + options.tx === undefined + ? db.queryAfterAcquire(options.signal, "jobClaim", text, values) + : db.query("jobClaim", text, values, { tx: options.tx }); + const result = await query( + ` + WITH queue_limits AS ( + SELECT * FROM unnest($1::text[], $2::int[]) AS queue_limit(name, max) + ), + locked_jobs AS ( + SELECT candidate.id + FROM queue_limits + CROSS JOIN LATERAL ( + SELECT river_job.id + FROM ${jobTable} AS river_job + WHERE river_job.state = 'available' + AND river_job.queue = queue_limits.name + AND river_job.scheduled_at <= now() + AND ($3::text[] IS NULL OR river_job.kind = ANY($3::text[])) + AND NOT EXISTS ( + SELECT 1 FROM ${db.table("river_queue")} AS river_queue + WHERE river_queue.name = river_job.queue + AND river_queue.paused_at IS NOT NULL + ) + ORDER BY river_job.priority, river_job.scheduled_at, river_job.id + LIMIT queue_limits.max + FOR UPDATE SKIP LOCKED + ) AS candidate + ) + UPDATE ${jobTable} AS river_job + SET + state = 'running', + attempt = river_job.attempt + 1, + attempted_at = now(), + attempted_by = array_append( + CASE + WHEN coalesce(array_length(river_job.attempted_by, 1), 0) >= 100 + THEN river_job.attempted_by[ + array_length(river_job.attempted_by, 1) - 98: + ] + ELSE river_job.attempted_by + END, + $4::text + ) + FROM locked_jobs + WHERE river_job.id = locked_jobs.id + RETURNING river_job.* + `, + [ + params.queues.map(({ name }) => name), + params.queues.map(({ limit }) => limit), + params.kinds.length === 0 ? null : params.kinds, + params.attemptedBy, + ] + ); + return decodeClaimedJobs(result.rows); +} + +/** Persist attempt-conditional worker outcomes in one bounded query. */ +export async function jobCompleteMany( + db: PgDatabase, + commands: readonly JobCompletionCommand[], + options?: { readonly signal?: AbortSignal; readonly tx?: ClientBase } +): Promise { + if (commands.length === 0) return []; + validateCompletionCommands(commands); + if (options?.signal?.aborted === true) throw options.signal.reason; + const jobTable = db.table("river_job"); + const result = await db.queryAbortable( + "jobCompleteMany", + `${COMPLETE_MANY_STATEMENT} + WITH job_input AS ( + SELECT * + FROM unnest( + $1::bigint[], + $2::smallint[], + $3::text[], + $4::text[], + $5::text[], + $6::timestamptz[], + $7::jsonb[], + $8::timestamptz[], + $9::boolean[] + ) WITH ORDINALITY AS input( + id, expected_attempt, attempted_by, state_text, error_text, + finalized_at, metadata_updates, scheduled_at, attempt_refund, + input_order + ) + ), + updated AS ( + UPDATE ${jobTable} AS river_job + SET + attempt = CASE + WHEN job_input.attempt_refund + AND NOT (river_job.metadata ? 'cancel_attempted_at') + THEN greatest(river_job.attempt - 1, 0) + ELSE river_job.attempt + END, + errors = CASE + WHEN job_input.error_text IS NOT NULL + THEN array_append(river_job.errors, job_input.error_text::jsonb) + ELSE river_job.errors + END, + finalized_at = CASE + WHEN job_input.state_text IN ('available', 'retryable', 'scheduled') + AND river_job.metadata ? 'cancel_attempted_at' + THEN now() + WHEN job_input.state_text IN ('cancelled', 'completed', 'discarded') + THEN coalesce(job_input.finalized_at, now()) + ELSE NULL + END, + metadata = CASE + WHEN job_input.metadata_updates = '{}'::jsonb + THEN river_job.metadata + ELSE river_job.metadata || job_input.metadata_updates + END, + scheduled_at = CASE + WHEN job_input.state_text IN ('available', 'retryable', 'scheduled') + AND river_job.metadata ? 'cancel_attempted_at' + THEN river_job.scheduled_at + ELSE coalesce(job_input.scheduled_at, river_job.scheduled_at) + END, + state = CASE + WHEN job_input.state_text IN ('available', 'retryable', 'scheduled') + AND river_job.metadata ? 'cancel_attempted_at' + THEN 'cancelled'::${db.type("river_job_state")} + ELSE job_input.state_text::${db.type("river_job_state")} + END + FROM job_input + WHERE river_job.id = job_input.id + AND river_job.state = 'running' + AND river_job.attempt = job_input.expected_attempt + AND river_job.attempted_by[ + array_length(river_job.attempted_by, 1) + ] = job_input.attempted_by + RETURNING river_job.*, job_input.input_order + ), + metadata_updated AS ( + UPDATE ${jobTable} AS river_job + SET metadata = river_job.metadata || job_input.metadata_updates + FROM job_input + WHERE river_job.id = job_input.id + -- Like Go, an attempt's output and metadata still merge once the + -- job has left running, such as after a rescue or cancellation, + -- but only while the row is still that attempt's. + AND river_job.state <> 'running' + AND river_job.attempt = job_input.expected_attempt + AND river_job.attempted_by[ + array_length(river_job.attempted_by, 1) + ] = job_input.attempted_by + AND job_input.metadata_updates <> '{}'::jsonb + AND NOT EXISTS ( + SELECT 1 FROM updated WHERE updated.id = river_job.id + ) + RETURNING river_job.*, job_input.input_order + ) + SELECT updated.*, true AS transition_applied + FROM updated + UNION ALL + SELECT metadata_updated.*, false AS transition_applied + FROM metadata_updated + UNION ALL + SELECT river_job.*, job_input.input_order, false AS transition_applied + FROM job_input + JOIN ${jobTable} AS river_job ON river_job.id = job_input.id + WHERE NOT EXISTS (SELECT 1 FROM updated WHERE updated.id = river_job.id) + AND NOT EXISTS ( + SELECT 1 + FROM metadata_updated + WHERE metadata_updated.id = river_job.id + ) + ORDER BY input_order + `, + [ + commands.map(({ id }) => id.toString(10)), + commands.map(({ attempt }) => attempt), + commands.map(({ attemptedBy }) => attemptedBy), + commands.map(completionState), + commands.map(({ attempt, error }) => + error === null + ? null + : JSON.stringify(encodeDriverAttemptError(attempt, error)) + ), + commands.map(({ finalizedAt }) => instantParameter(finalizedAt)), + commands.map(({ metadata, output, outputSet }) => + JSON.stringify({ ...metadata, ...(outputSet ? { output } : {}) }) + ), + commands.map(({ scheduledAt }) => instantParameter(scheduledAt)), + commands.map(completionRefundsAttempt), + ], + options, + COMPLETE_MANY_STATEMENT + ); + const rowsByID = new Map(result.rows.map((row) => [row.id, row])); + return commands.map((command) => { + const row = rowsByID.get(command.id); + return { + // A row that can't be fully decoded is still returned, with those + // fields empty, so it can't fail the rest of the batch. + job: row === undefined ? null : toJobRowPartial(row).job, + key: jobCompletionKey(command), + status: row?.transition_applied === true ? "applied" : "stale", + }; + }); +} + +/** List jobs through a fixed parameterized filter grammar. */ +export async function jobList( + db: PgDatabase, + params: JobListParams, + options?: PgOperationOptions +): Promise { + validateLimit(params.limit, "job list maximum"); + const keyset = jobListKeyset(validateJobListOrder(params)); + const field = keyset.timeField; + const sql = jobListKeysetSql(keyset, (value) => + typeof value === "bigint" ? "$10::bigint" : "$9::timestamptz" + ); + const jobState = db.type("river_job_state"); + // Like River's Go list builder: equality on a single state lets + // PostgreSQL use an index's time ordering (ANY does not fix the state), + // and an explicit non-null finalized time matches the partial index for + // finalized states. + const singleState = + params.states.length === 1 ? (params.states[0] ?? null) : null; + const statePredicate = + singleState === null + ? `(cardinality($4::text[]) = 0 OR state = ANY($4::text[]::${jobState}[]))` + : `state = $4::${jobState}` + + (field === "finalized_at" && + (singleState === "cancelled" || + singleState === "completed" || + singleState === "discarded") + ? " AND finalized_at IS NOT NULL" + : ""); + const result = await db.query( + "jobList", + ` + SELECT * + FROM ${db.table("river_job")} + WHERE (cardinality($1::bigint[]) = 0 OR id = ANY($1::bigint[])) + AND (cardinality($2::text[]) = 0 OR kind = ANY($2::text[])) + AND (cardinality($3::text[]) = 0 OR queue = ANY($3::text[])) + AND ${statePredicate} + AND (cardinality($5::smallint[]) = 0 OR priority = ANY($5::smallint[])) + AND (cardinality($6::varchar[]) = 0 OR tags @> $6::varchar[]) + AND (cardinality($7::varchar[]) = 0 OR tags && $7::varchar[]) + AND ($8::jsonb IS NULL OR metadata @> $8::jsonb) + AND ($9::timestamptz IS NULL OR $10::bigint IS NULL OR true) + AND ${sql.after ?? "true"} + ORDER BY ${sql.orderBy} + LIMIT $11::int + `, + [ + params.ids.map((id) => id.toString(10)), + params.kinds, + params.queues, + singleState ?? params.states, + params.priorities, + params.tagsAll, + params.tagsAny, + params.metadata === null ? null : JSON.stringify(params.metadata), + instantParameter( + keyset.after?.kind === "time" ? keyset.after.time : undefined + ), + params.after?.id.toString(10) ?? null, + params.limit, + ], + options + ); + return result.rows.map((row) => toJobRowPartial(row).job); +} + +/** + * Merge metadata and set output on a job, like River for Go's `JobUpdate`. + * Output is set after the metadata merge, so it wins over a metadata + * `output` key. + */ +export async function jobUpdate( + db: PgDatabase, + id: bigint, + params: JobUpdateParams, + options?: PgOperationOptions +): Promise { + const jobTable = db.table("river_job"); + const result = await db.query( + "jobUpdate", + ` + UPDATE ${jobTable} + SET metadata = CASE + WHEN $4::boolean THEN jsonb_set( + CASE WHEN $2::boolean THEN metadata || $3::jsonb ELSE metadata END, + '{output}'::text[], + $5::jsonb, + true + ) + WHEN $2::boolean THEN metadata || $3::jsonb + ELSE metadata + END + WHERE id = $1::bigint + RETURNING * + `, + [ + id.toString(10), + params.metadata !== undefined, + JSON.stringify(params.metadata ?? {}), + params.output !== undefined, + params.output === undefined ? null : JSON.stringify(params.output), + ], + options + ); + return mapOneJob(result); +} + +/** Insert one job, honoring its unique key. */ +export async function jobInsert( + db: PgDatabase, + params: JobInsertParams, + options?: InsertDriverOptions +): Promise { + const results = await jobInsertMany(db, [params], options); + const result = results[0]; + if (result === undefined) { + throw databaseError( + "jobInsert", + "PostgreSQL returned no row for an inserted job" + ); + } + return result; +} + +/** Insert jobs in order, honoring unique keys. */ +export async function jobInsertMany( + db: PgDatabase, + params: readonly JobInsertParams[], + options?: InsertDriverOptions +): Promise { + if (params.length === 0) return []; + + // Without `xmax`, as on YugabyteDB, each row carries a nonce like SQLite's, + // and a returned row without its own nonce already existed. + const { uniqueInsertMode } = await db.capabilities(options); + const nonces = + uniqueInsertMode === "metadata_nonce" + ? params.map(() => randomBytes(8).toString("hex")) + : null; + const jobTable = db.table("river_job"); + const stateInBitmask = db.function("river_job_state_in_bitmask"); + const sql = ` + WITH raw_job_data AS ( + SELECT + input_order, args, coalesce(created_at, now()) AS created_at, kind, + max_attempts, metadata, priority, queue, + coalesce(scheduled_at, now()) AS scheduled_at, + state_text AS state, + ARRAY(SELECT jsonb_array_elements_text(tags_json)) AS tags, + CASE WHEN unique_key_hex IS NULL THEN NULL + ELSE decode(unique_key_hex, 'hex') END AS unique_key, + unique_states_text::bit(8) AS unique_states + FROM unnest( + $1::jsonb[], $2::text[], $3::smallint[], $4::jsonb[], + $5::smallint[], $6::text[], $7::timestamptz[], $8::text[], + $9::jsonb[], $10::text[], $11::text[], $13::timestamptz[] + ) WITH ORDINALITY AS input( + args, kind, max_attempts, metadata, priority, queue, + scheduled_at, state_text, tags_json, unique_key_hex, + unique_states_text, created_at, input_order + ) + ), + normalized_job_data AS ( + SELECT + *, + unique_key IS NOT NULL + AND unique_states IS NOT NULL + AND ${stateInBitmask}(unique_states, state::${db.type("river_job_state")}) + AS is_unique + FROM raw_job_data + ), + prepared_job_data AS ( + SELECT + *, + nextval($12::regclass) AS proposed_id + FROM normalized_job_data + ), + inserted_jobs AS ( + INSERT INTO ${jobTable} ( + id, args, created_at, kind, max_attempts, metadata, priority, + queue, scheduled_at, state, tags, unique_key, unique_states + ) + SELECT + proposed_id, args, created_at, kind, max_attempts, metadata, + priority, queue, scheduled_at, state::${db.type("river_job_state")}, + tags, unique_key, unique_states + FROM prepared_job_data + ORDER BY input_order + ON CONFLICT (unique_key) + WHERE unique_key IS NOT NULL + AND unique_states IS NOT NULL + AND ${stateInBitmask}(unique_states, state) + DO UPDATE SET kind = river_job.kind + RETURNING *, ${uniqueInsertConflictSql(uniqueInsertMode)} AS conflicted + ) + SELECT + inserted_jobs.*, + inserted_jobs.conflicted AS unique_skipped_as_duplicate + FROM prepared_job_data + JOIN inserted_jobs ON CASE + WHEN prepared_job_data.is_unique THEN + inserted_jobs.unique_key = prepared_job_data.unique_key + AND inserted_jobs.unique_states IS NOT NULL + AND ${stateInBitmask}(inserted_jobs.unique_states, inserted_jobs.state) + ELSE inserted_jobs.id = prepared_job_data.proposed_id + END + ORDER BY prepared_job_data.input_order + `; + + const result = await db.query( + "jobInsertMany", + sql, + [ + params.map(({ encodedArgs }) => encodedArgs), + params.map(({ kind }) => kind), + params.map(({ maxAttempts }) => maxAttempts), + params.map(({ metadata }, index) => + JSON.stringify( + nonces === null + ? metadata + : { ...metadata, [UNIQUE_INSERT_NONCE_KEY]: nonces[index] } + ) + ), + params.map(({ priority }) => priority), + params.map(({ queue }) => queue), + params.map(({ scheduledAt }) => instantParameter(scheduledAt)), + params.map(({ state }) => state), + params.map(({ tags }) => JSON.stringify(tags)), + params.map(({ uniqueKey }) => + uniqueKey === null ? null : Buffer.from(uniqueKey).toString("hex") + ), + params.map(({ uniqueStates }) => + uniqueStates === null ? null : uniqueBitmaskFromStates(uniqueStates) + ), + db.qualifiedJobSequence, + params.map(({ createdAt }) => instantParameter(createdAt)), + ], + options + ); + if (result.rows.length !== params.length) { + throw databaseError( + "jobInsertMany", + `PostgreSQL returned ${result.rows.length} rows for ${params.length} inserts` + ); + } + return result.rows.map((row, index) => { + const { job } = toJobRowPartial(row); + const duplicate = + nonces === null + ? row.unique_skipped_as_duplicate + : job.metadata[UNIQUE_INSERT_NONCE_KEY] !== nonces[index]; + return { job, status: duplicate ? "duplicate" : "inserted" }; + }); +} + +/** Retry a non-running job immediately using River's canonical transition. */ +export async function jobRetry( + db: PgDatabase, + id: bigint, + options?: PgOperationOptions +): Promise { + return jobRetryWithOptions(db, { id }, options); +} + +/** Backend test hook for deterministic retry clocks. */ +export async function jobRetryWithOptions( + db: PgDatabase, + params: PgJobRetryParams, + options?: PgOperationOptions +): Promise { + const jobTable = db.table("river_job"); + const sql = ` + WITH job_to_update AS ( + SELECT id + FROM ${jobTable} + WHERE id = $1::bigint + FOR UPDATE + ), + updated_job AS ( + UPDATE ${jobTable} AS river_job + SET + state = 'available', + max_attempts = CASE + WHEN river_job.attempt = river_job.max_attempts + THEN river_job.max_attempts + 1 + ELSE river_job.max_attempts + END, + finalized_at = NULL, + scheduled_at = coalesce($2::timestamptz, now()) + FROM job_to_update + WHERE river_job.id = job_to_update.id + AND river_job.state != 'running' + AND NOT ( + river_job.state = 'available' + AND river_job.scheduled_at < coalesce($2::timestamptz, now()) + ) + RETURNING river_job.* + ) + -- Like a cancel's, the fallback read locks the row so that a retry that + -- lost a race returns the committed row. + SELECT * FROM ( + SELECT * + FROM ${jobTable} + WHERE id = $1::bigint + FOR UPDATE + ) AS fallback_job + WHERE fallback_job.id NOT IN (SELECT id FROM updated_job) + UNION + SELECT updated_job.* + FROM updated_job + `; + const result = await db.query( + "jobRetry", + sql, + [params.id.toString(10), instantParameter(params.now)], + options + ); + return mapOneJobPartial(result); +} + +/** + * Decode freshly claimed rows one at a time, in order. The claim has already + * moved every row to `running`, so a row that can't be decoded doesn't fail + * the rest: it is returned with its error, and the runtime fails its attempt. + */ +function decodeClaimedJobs(rows: readonly PgJobRow[]): JobClaimResult { + const jobs: JobRow[] = []; + const decodeErrors = new Map(); + for (const row of rows) { + const { error, job } = toJobRowPartial(row); + jobs.push(job); + if (error !== undefined) decodeErrors.set(job.id, error); + } + return decodeErrors.size === 0 ? { jobs } : { decodeErrors, jobs }; +} + +/** + * Read claimed jobs by ID in `tx`, in the order of `ids`, decoding each as + * a claim does. Rejects when an ID repeats or has no row. + */ +export async function jobLoadClaimed( + db: PgDatabase, + ids: readonly bigint[], + tx: ClientBase +): Promise { + const unique = new Set(); + for (const id of ids) { + if (typeof id !== "bigint" || id <= 0n) { + throw new ValidationError("claimed job IDs must be positive bigints"); + } + if (unique.has(id)) { + throw new ValidationError(`claimed job ID ${id} is repeated`); + } + unique.add(id); + } + if (ids.length === 0) return { jobs: [] }; + const result = await db.query( + "loadClaimed", + `SELECT * FROM ${db.table("river_job")} WHERE id = ANY($1::bigint[])`, + [ids.map((id) => id.toString(10))], + { tx } + ); + const rows = new Map(); + for (const row of result.rows) rows.set(String(row.id), row); + return decodeClaimedJobs( + ids.map((id) => { + const row = rows.get(id.toString(10)); + if (row === undefined) { + throw databaseError( + "loadClaimed", + `claimed job ${id} has no row`, + undefined + ); + } + return row; + }) + ); +} + +/** + * Whether a completion returns its attempt, like River's `Attempt - 1` + * parameters: snoozes and interruptions do, errors never do, including an + * error retried immediately through the near-future `available` path. + */ +function completionRefundsAttempt(command: JobCompletionCommand): boolean { + return command.kind === "interrupt" || command.kind === "snooze"; +} + +function completionState(command: JobCompletionCommand): JobState { + if (command.available === true) return "available"; + switch (command.kind) { + case "cancel": + return "cancelled"; + case "complete": + return "completed"; + case "discard": + return "discarded"; + case "interrupt": + return "available"; + case "retry": + return "retryable"; + case "snooze": + return "scheduled"; + } +} + +function encodeDriverAttemptError( + attempt: number, + error: DriverAttemptError +): JsonObject { + return { + at: error.at.toString(), + attempt, + error: error.error, + trace: error.trace, + }; +} + +/** Reject a list ordering the query can't serve. */ +function validateJobListOrder(params: JobListParams): JobListParams { + if (params.after !== null && params.after.sortField !== params.sortField) { + throw new RangeError("job list cursor sort field does not match ordering"); + } + if ( + params.sortField === "finalizedAt" && + (params.states.length === 0 || + params.states.some( + (state) => + state !== "cancelled" && + state !== "completed" && + state !== "discarded" + )) + ) { + throw new RangeError( + "finalizedAt ordering requires only terminal job states" + ); + } + return params; +} + +function validateCompletionCommands( + items: readonly JobCompletionCommand[] +): void { + const ids = new Set(); + for (const item of items) { + if (ids.has(item.id)) { + throw new RangeError("a completion batch must contain each job ID once"); + } + ids.add(item.id); + if ( + !Number.isInteger(item.attempt) || + item.attempt < 1 || + item.attempt > 32_767 + ) { + throw new RangeError( + "completion attempt must be an integer from 1 to 32767" + ); + } + if (item.attemptedBy.length === 0) { + throw new RangeError("completion attemptedBy must not be empty"); + } + const terminal = + item.kind === "cancel" || + item.kind === "complete" || + item.kind === "discard"; + if (terminal !== (item.finalizedAt !== null)) { + throw new RangeError( + terminal + ? `${item.kind} completion requires a finalizedAt instant` + : `${item.kind} completion requires a null finalizedAt` + ); + } + if ( + item.available === true && + item.kind !== "retry" && + item.kind !== "snooze" + ) { + throw new RangeError( + `${item.kind} completion cannot use the available fast path` + ); + } + if ( + (item.kind === "interrupt" || + item.kind === "retry" || + item.kind === "snooze") && + item.scheduledAt === null + ) { + throw new RangeError( + `${item.kind} completion requires a scheduledAt instant` + ); + } + } +} diff --git a/js/driver/pg/src/sql/leader.ts b/js/driver/pg/src/sql/leader.ts new file mode 100644 index 000000000..55d428e6c --- /dev/null +++ b/js/driver/pg/src/sql/leader.ts @@ -0,0 +1,155 @@ +/** Leader election queries on `river_leader`. */ +import type { QueryResult } from "pg"; +import type { PgDatabase } from "../database.js"; +import type { + PgLeader, + PgLeaderElectParams, + PgLeaderTermParams, + PgOperationOptions, +} from "../types.js"; +import { instantParameter } from "./params.js"; +import type { PgLeaderDatabaseRow } from "./rows.js"; + +/** Attempt to acquire the singleton River leadership lease. */ +export async function leaderElect( + db: PgDatabase, + params: PgLeaderElectParams, + options?: PgOperationOptions +): Promise { + validateLeaderParams(params); + const result = await db.query( + "leaderElect", + ` + INSERT INTO ${db.table("river_leader")} ( + leader_id, elected_at, expires_at + ) VALUES ( + $1::text, + coalesce($2::timestamptz, now()), + coalesce($2::timestamptz, now()) + make_interval(secs => $3::double precision) + ) + ON CONFLICT (name) DO NOTHING + RETURNING * + `, + [params.leaderId, instantParameter(params.now), params.ttlSeconds], + options + ); + return mapOneLeader(result); +} + +/** Renew a leadership lease only for the exact current election term. */ +export async function leaderReelect( + db: PgDatabase, + params: PgLeaderTermParams, + options?: PgOperationOptions +): Promise { + validateLeaderParams(params); + const result = await db.query( + "leaderReelect", + ` + UPDATE ${db.table("river_leader")} + SET expires_at = coalesce($1::timestamptz, now()) + + make_interval(secs => $2::double precision) + WHERE elected_at = $3::timestamptz + AND expires_at >= coalesce($1::timestamptz, now()) + AND leader_id = $4::text + RETURNING * + `, + [ + instantParameter(params.now), + params.ttlSeconds, + instantParameter(params.electedAt), + params.leaderId, + ], + options + ); + return mapOneLeader(result); +} + +/** Read the currently persisted leader, whether or not its lease is expired. */ +export async function leaderGet( + db: PgDatabase, + options?: PgOperationOptions +): Promise { + const result = await db.query( + "leaderGet", + `SELECT * FROM ${db.table("river_leader")} LIMIT 1`, + [], + options + ); + return mapOneLeader(result); +} + +/** Remove expired leadership rows so a new election can proceed. */ +export async function leaderDeleteExpired( + db: PgDatabase, + now?: Temporal.Instant, + options?: PgOperationOptions +): Promise { + const result = await db.query( + "leaderDeleteExpired", + ` + DELETE FROM ${db.table("river_leader")} + WHERE expires_at < coalesce($1::timestamptz, now()) + `, + [instantParameter(now)], + options + ); + return result.rowCount ?? 0; +} + +/** Resign only the exact election term and notify leadership observers. */ +export async function leaderResign( + db: PgDatabase, + params: PgLeaderTermParams & { leadershipTopic: string }, + options?: PgOperationOptions +): Promise { + const { supportsListenNotify } = await db.capabilities(options); + const result = await db.query( + "leaderResign", + ` + WITH held AS ( + SELECT * FROM ${db.table("river_leader")} + WHERE elected_at = $1::timestamptz AND leader_id = $2::text + FOR UPDATE + ), + notified AS ( + SELECT CASE WHEN $5::boolean THEN pg_notify( + concat(coalesce($3::text, current_schema()), '.', $4::text), + json_build_object('leader_id', leader_id, 'action', 'resigned')::text + ) END FROM held + ) + DELETE FROM ${db.table("river_leader")} USING notified + `, + [ + instantParameter(params.electedAt), + params.leaderId, + db.schemaName, + params.leadershipTopic, + supportsListenNotify, + ], + options + ); + return (result.rowCount ?? 0) > 0; +} + +function mapOneLeader( + result: QueryResult +): PgLeader | null { + const row = result.rows[0]; + return row === undefined + ? null + : { + electedAt: row.elected_at, + expiresAt: row.expires_at, + leaderId: row.leader_id, + }; +} + +function validateLeaderParams(params: PgLeaderElectParams): void { + if (params.leaderId.length === 0 || params.leaderId.length >= 128) { + throw new RangeError("leaderId must contain from 1 to 127 characters"); + } + if (!Number.isFinite(params.ttlSeconds) || params.ttlSeconds <= 0) { + throw new RangeError("ttlSeconds must be a positive finite number"); + } +} diff --git a/js/driver/pg/src/sql/maintenance.ts b/js/driver/pg/src/sql/maintenance.ts new file mode 100644 index 000000000..01d1d30f1 --- /dev/null +++ b/js/driver/pg/src/sql/maintenance.ts @@ -0,0 +1,894 @@ +/** Leader-fenced maintenance: scheduling, rescue, cleanup, and reindexing. */ +import { quoteIdentifier } from "riverqueue/unstable-driver"; +import { Buffer } from "node:buffer"; +import type { ClientBase, PoolClient } from "pg"; +import type { AttemptError, JobRow, JsonObject } from "riverqueue"; +import type { + FinalizedJobDeleteParams, + RuntimeJobCleanupParams, + RuntimeJobRescue, + RuntimeLeader, + RuntimeMaintenanceBatch, + RuntimeScheduleParams, +} from "riverqueue/unstable-driver"; +import type { PgDatabase } from "../database.js"; +import { POSTGRES_IDENTIFIER_MAX_BYTES } from "../database.js"; +import { configurationError, unsupportedError } from "../errors.js"; +import { abortablePromise, PgClientLease } from "../lease.js"; +import type { + PgJobDeleteBeforeParams, + PgJobRescueManyParams, + PgJobScheduleResult, + PgOperationOptions, +} from "../types.js"; +import { + leaderDeleteExpired, + leaderElect, + leaderReelect, + leaderResign, +} from "./leader.js"; +import { notifyInsert } from "./notify.js"; +import { instantParameter, validateLimit } from "./params.js"; +import { queueDeleteExpired } from "./queues.js"; +import type { PgJobRow, PgScheduleRow } from "./rows.js"; +import { toJobRow, toJobRowPartial } from "./rows.js"; + +/** Statement timeout for dropping an aborted reindex's artifacts. */ +const REINDEX_CLEANUP_TIMEOUT_MS = 15_000; + +/** + * Leading comment identifying reindex statements River may cancel server + * side. The cancellation only signals a backend still running a statement + * with this prefix, so a reused process ID can never cancel unrelated work. + */ +const REINDEX_STATEMENT = "/* river:reindex */"; + +/** Delete terminal jobs below configured retention horizons. */ +export async function jobDeleteBefore( + db: PgDatabase, + params: PgJobDeleteBeforeParams, + options?: PgOperationOptions +): Promise { + validateLimit(params.max, "cleaner maximum"); + const result = await db.query( + "jobDeleteBefore", + ` + DELETE FROM ${db.table("river_job")} + WHERE id IN ( + SELECT id + FROM ${db.table("river_job")} + WHERE ( + ($1::timestamptz IS NOT NULL AND state = 'cancelled' AND finalized_at < $1::timestamptz) + OR ($2::timestamptz IS NOT NULL AND state = 'completed' AND finalized_at < $2::timestamptz) + OR ($3::timestamptz IS NOT NULL AND state = 'discarded' AND finalized_at < $3::timestamptz) + ) + AND ($4::text[] IS NULL OR NOT (queue = ANY($4::text[]))) + AND ($5::text[] IS NULL OR queue = ANY($5::text[])) + ORDER BY id ASC + LIMIT $6::int + ) + `, + [ + instantParameter(params.cancelledFinalizedAt), + instantParameter(params.completedFinalizedAt), + instantParameter(params.discardedFinalizedAt), + params.queuesExcluded ?? null, + params.queuesIncluded ?? null, + params.max, + ], + options + ); + return result.rowCount ?? 0; +} + +/** + * Delete one batch of finalized jobs by per-state cutoffs, with `null` + * keeping a state, through River's `JobDeleteBefore` statement. + */ +export function jobDeleteFinalized( + db: PgDatabase, + params: FinalizedJobDeleteParams, + options?: PgOperationOptions +): Promise { + return jobDeleteBefore( + db, + { + ...(params.cancelledBefore === null + ? {} + : { cancelledFinalizedAt: params.cancelledBefore }), + ...(params.completedBefore === null + ? {} + : { completedFinalizedAt: params.completedBefore }), + ...(params.discardedBefore === null + ? {} + : { discardedFinalizedAt: params.discardedBefore }), + max: params.limit, + ...(params.queuesExcluded === undefined || + params.queuesExcluded.length === 0 + ? {} + : { queuesExcluded: params.queuesExcluded }), + ...(params.queuesIncluded === undefined || params.queuesIncluded === null + ? {} + : { queuesIncluded: params.queuesIncluded }), + }, + options + ); +} + +/** Read running jobs old enough for rescuer inspection. */ +export async function jobGetStuck( + db: PgDatabase, + params: { afterId?: bigint; max: number; stuckHorizon: Temporal.Instant }, + options?: PgOperationOptions +): Promise { + validateLimit(params.max, "stuck job maximum"); + const result = await db.query( + "jobGetStuck", + ` + SELECT * FROM ${db.table("river_job")} + WHERE state = 'running' + AND id > $1::bigint + AND attempted_at < $2::timestamptz + ORDER BY id ASC + LIMIT $3::int + `, + [ + (params.afterId ?? 0n).toString(10), + instantParameter(params.stuckHorizon), + params.max, + ], + options + ); + // A row that can't be fully decoded is returned with those fields empty so + // it can't keep the rescuer from recovering every stuck job. + return result.rows.map((row) => toJobRowPartial(row).job); +} + +/** Rescue still-stuck running jobs without overwriting concurrent completion. */ +export async function jobRescueMany( + db: PgDatabase, + params: PgJobRescueManyParams, + options?: PgOperationOptions +): Promise { + if (params.items.length === 0) return 0; + const result = await db.query( + "jobRescueMany", + ` + WITH rescued AS ( + SELECT * + FROM unnest( + $1::bigint[], $2::jsonb[], $3::timestamptz[], + $4::timestamptz[], $5::text[] + ) AS input(id, error, finalized_at, scheduled_at, state_text) + ) + UPDATE ${db.table("river_job")} AS river_job + SET + errors = array_append(river_job.errors, rescued.error), + finalized_at = rescued.finalized_at, + scheduled_at = rescued.scheduled_at, + metadata = river_job.metadata || jsonb_build_object( + 'river:rescue_count', + coalesce( + CASE + WHEN jsonb_typeof(river_job.metadata -> 'river:rescue_count') = 'number' + THEN (river_job.metadata ->> 'river:rescue_count')::int + END, + 0 + ) + 1 + ), + state = rescued.state_text::${db.type("river_job_state")} + FROM rescued + WHERE river_job.id = rescued.id + AND river_job.state = 'running' + AND river_job.attempted_at < $6::timestamptz + `, + [ + params.items.map(({ id }) => id.toString(10)), + params.items.map(({ error }) => + JSON.stringify(encodeAttemptError(error)) + ), + params.items.map(({ finalizedAt }) => instantParameter(finalizedAt)), + params.items.map(({ scheduledAt }) => instantParameter(scheduledAt)), + params.items.map(({ state }) => state), + instantParameter(params.stuckHorizon), + ], + options + ); + return result.rowCount ?? 0; +} + +/** Move due scheduled/retryable jobs into the runnable state. */ +export async function jobSchedule( + db: PgDatabase, + params: { + max: number; + now?: Temporal.Instant; + scheduledAtHorizon?: Temporal.Instant; + }, + options?: PgOperationOptions +): Promise { + validateLimit(params.max, "scheduler maximum"); + const jobTable = db.table("river_job"); + const jobState = db.type("river_job_state"); + const inBitmask = db.function("river_job_state_in_bitmask"); + const result = await db.query( + "jobSchedule", + ` + WITH jobs_to_schedule AS ( + SELECT id, unique_key, unique_states, priority, scheduled_at + FROM ${jobTable} + WHERE state IN ('retryable', 'scheduled') + AND scheduled_at <= coalesce($1::timestamptz, now()) + ORDER BY priority ASC, scheduled_at ASC, id ASC + LIMIT $3::int + FOR UPDATE + ), + jobs_with_rownum AS ( + SELECT *, CASE + WHEN unique_key IS NOT NULL AND unique_states IS NOT NULL + THEN row_number() OVER ( + PARTITION BY unique_key ORDER BY priority, scheduled_at, id + ) + ELSE NULL + END AS row_num + FROM jobs_to_schedule + ), + unique_conflicts AS ( + SELECT DISTINCT river_job.unique_key + FROM ${jobTable} AS river_job + JOIN jobs_with_rownum AS job + ON river_job.unique_key = job.unique_key AND river_job.id != job.id + WHERE river_job.unique_key IS NOT NULL + AND river_job.unique_states IS NOT NULL + AND ${inBitmask}(river_job.unique_states, river_job.state) + ), + job_updates AS ( + SELECT + job.id, + CASE + WHEN job.row_num IS NULL THEN 'available'::${jobState} + WHEN conflict.unique_key IS NOT NULL OR job.row_num > 1 + THEN 'discarded'::${jobState} + ELSE 'available'::${jobState} + END AS new_state, + (job.row_num IS NOT NULL AND (conflict.unique_key IS NOT NULL OR job.row_num > 1)) + AS conflict_discarded + FROM jobs_with_rownum AS job + LEFT JOIN unique_conflicts AS conflict ON conflict.unique_key = job.unique_key + ), + updated AS ( + UPDATE ${jobTable} AS river_job + SET + state = job_updates.new_state, + finalized_at = CASE WHEN job_updates.conflict_discarded + THEN coalesce($2::timestamptz, now()) ELSE river_job.finalized_at END, + metadata = CASE WHEN job_updates.conflict_discarded + THEN river_job.metadata || '{"unique_key_conflict":"scheduler_discarded"}'::jsonb + ELSE river_job.metadata END + FROM job_updates + WHERE river_job.id = job_updates.id + RETURNING river_job.*, job_updates.conflict_discarded + ) + SELECT updated.* + FROM updated + ORDER BY priority ASC, scheduled_at ASC, id ASC + `, + [ + instantParameter(params.scheduledAtHorizon ?? params.now), + instantParameter(params.now), + params.max, + ], + options + ); + return result.rows.map((row) => ({ + conflictDiscarded: row.conflict_discarded, + job: toJobRow(row), + })); +} + +/** Acquire or renew one exact leadership lease after expiring stale terms. */ +export async function maintenanceLeaderAcquire( + db: PgDatabase, + leaderId: string, + ttlMs: number, + held: RuntimeLeader | null, + signal?: AbortSignal +): Promise { + // PostgreSQL's clock is authoritative for cross-host lease expiry. The + // runtime keeps a separate monotonic local trust deadline. Like Go River, + // only the held term is renewed and an unexpired term is never adopted, + // even one with this client's leader ID. + return db.withConnection(signal, async (options) => { + if (held !== null) { + return leaderReelect( + db, + { electedAt: held.electedAt, leaderId, ttlSeconds: ttlMs / 1_000 }, + options + ); + } + await leaderDeleteExpired(db, undefined, options); + return leaderElect(db, { leaderId, ttlSeconds: ttlMs / 1_000 }, options); + }); +} + +/** Resign a maintenance lease and announce it on the leadership topic. */ +export function maintenanceLeaderResign( + db: PgDatabase, + leader: RuntimeLeader +): Promise { + return leaderResign(db, { + ...leader, + leadershipTopic: "river_leadership", + now: Temporal.Now.instant(), + ttlSeconds: 1, + }); +} + +/** Schedule due jobs while `leader` still holds its lease. */ +export async function maintenanceSchedule( + db: PgDatabase, + leader: RuntimeLeader, + params: RuntimeScheduleParams, + batch?: RuntimeMaintenanceBatch +): Promise { + return ( + (await withMaintenanceLeader( + db, + leader, + "maintenanceSchedule", + batch, + async (tx) => { + const results = await jobSchedule( + db, + { + max: params.limit, + now: params.now, + scheduledAtHorizon: params.scheduledAtHorizon, + }, + { tx } + ); + // Like River for Go's scheduler, wake producers of jobs that are + // due, or nearly due, through the client's insert notification + // limiter, in the same transaction. + const queues = params.allowInsertNotifications( + results.flatMap(({ job }) => + Temporal.Instant.compare( + job.scheduledAt, + params.notificationHorizon + ) <= 0 + ? [job.queue] + : [] + ) + ); + await notifyInsert(db, queues, { tx }); + return results.length; + } + )) ?? 0 + ); +} + +/** Read stuck running jobs while `leader` still holds its lease. */ +export function maintenanceGetStuck( + db: PgDatabase, + leader: RuntimeLeader, + attemptedBefore: Temporal.Instant, + afterId: bigint, + limit: number, + batch?: RuntimeMaintenanceBatch +): Promise { + return withMaintenanceLeader(db, leader, "maintenanceGetStuck", batch, (tx) => + jobGetStuck( + db, + { afterId, max: limit, stuckHorizon: attemptedBefore }, + { tx } + ) + ).then((jobs) => jobs ?? []); +} + +/** Rescue stuck jobs while `leader` still holds its lease. */ +export function maintenanceRescue( + db: PgDatabase, + leader: RuntimeLeader, + attemptedBefore: Temporal.Instant, + jobs: readonly RuntimeJobRescue[], + tx?: ClientBase +): Promise { + const rescue = (transaction: ClientBase): Promise => + jobRescueMany( + db, + { + items: jobs.map((job) => ({ + error: job.error, + ...(job.finalizedAt === null ? {} : { finalizedAt: job.finalizedAt }), + id: job.id, + scheduledAt: job.scheduledAt, + state: job.state, + })), + stuckHorizon: attemptedBefore, + }, + { tx: transaction } + ); + const fenced = + tx === undefined + ? withMaintenanceLeader( + db, + leader, + "maintenanceRescue", + undefined, + rescue + ) + : withLeaderFenceIn(db, leader, "maintenanceRescue", tx, rescue); + return fenced.then((count) => count ?? 0); +} + +/** Delete expired terminal jobs while `leader` still holds its lease. */ +export function maintenanceCleanJobs( + db: PgDatabase, + leader: RuntimeLeader, + params: RuntimeJobCleanupParams, + timeoutMs: number | null, + signal: AbortSignal +): Promise { + return withMaintenanceLeader( + db, + leader, + "maintenanceCleanJobs", + { signal, timeoutMs }, + async (tx) => { + signal.throwIfAborted(); + return jobDeleteFinalized(db, params, { tx }); + } + ).then((count) => count ?? 0); +} + +/** Delete expired queues while `leader` still holds its lease. */ +export async function maintenanceCleanQueues( + db: PgDatabase, + leader: RuntimeLeader, + updatedBefore: Temporal.Instant, + limit: number, + batch?: RuntimeMaintenanceBatch +): Promise { + return ( + (await withMaintenanceLeader( + db, + leader, + "maintenanceCleanQueues", + batch, + async (tx) => + ( + await queueDeleteExpired( + db, + { max: limit, updatedAtHorizon: updatedBefore }, + { tx } + ) + ).length + )) ?? 0 + ); +} + +/** + * Delete up to `max` durable SQLite-style notification rows retained by + * migration v7 from before a horizon, oldest first, like Go's + * `NotificationDeleteBefore`. + */ +export async function notificationDeleteBefore( + db: PgDatabase, + params: { createdAtHorizon: Temporal.Instant; max: number }, + options?: PgOperationOptions +): Promise { + const table = db.table("river_notification"); + const result = await db.query( + "notificationDeleteBefore", + `DELETE FROM ${table} + WHERE id IN ( + SELECT id + FROM ${table} + WHERE created_at < $1::timestamptz + ORDER BY created_at, id + LIMIT $2::bigint + )`, + [instantParameter(params.createdAtHorizon), params.max], + options + ); + return result.rowCount ?? 0; +} + +/** Discover `_ccnew`/`_ccold` artifacts from an interrupted reindex. */ +export async function indexReindexArtifacts( + db: PgDatabase, + index: string, + options?: PgOperationOptions +): Promise { + const result = await db.query<{ artifact_name: string }>( + "indexReindexArtifacts", + ` + WITH index_artifacts AS ( + SELECT + c.relname::text AS artifact_name, + substring(c.relname FROM length($2::text) + 1) AS suffix + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = coalesce($1::text, current_schema()) + AND c.relkind = 'i' + AND left(c.relname, length($2::text)) = $2::text + ) + SELECT artifact_name + FROM index_artifacts + WHERE suffix ~ '^_cc(new|old)[0-9]*$' + ORDER BY artifact_name + `, + [db.schemaName, index], + options + ); + return result.rows.map(({ artifact_name }) => artifact_name); +} + +/** Reindex one allow-listed River index using a safely quoted identifier. */ +export async function indexReindex( + db: PgDatabase, + index: string, + options?: PgOperationOptions +): Promise { + validateIndexName(index); + await db.query( + "indexReindex", + `${REINDEX_STATEMENT} REINDEX INDEX CONCURRENTLY ${db.schemaPrefix}${quoteIdentifier(index)}`, + [], + options + ); +} + +/** Drop a concurrent-reindex artifact using a safely quoted identifier. */ +export async function indexDropIfExists( + db: PgDatabase, + index: string, + options?: PgOperationOptions +): Promise { + validateIndexName(index); + await db.query( + "indexDropIfExists", + `DROP INDEX CONCURRENTLY IF EXISTS ${db.schemaPrefix}${quoteIdentifier(index)}`, + [], + options + ); +} + +/** Return exact existence results for indexes in the configured schema. */ +export async function indexesExist( + db: PgDatabase, + indexes: readonly string[], + options?: PgOperationOptions +): Promise> { + for (const index of indexes) validateIndexName(index); + if (indexes.length === 0) return new Map(); + const result = await db.query<{ + exists: boolean; + index_name: string; + }>( + "indexesExist", + ` + WITH index_names AS ( + SELECT unnest($2::text[]) AS index_name + ) + SELECT + index_names.index_name::text AS index_name, + EXISTS ( + SELECT 1 + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = coalesce($1::text, current_schema()) + AND c.relname = index_names.index_name + AND c.relkind = 'i' + ) AS exists + FROM index_names + `, + [db.schemaName, indexes], + options + ); + return new Map(result.rows.map((row) => [row.index_name, row.exists])); +} + +/** Rebuild existing configured indexes with River's artifact safeguards. */ +export async function maintenanceReindex( + db: PgDatabase, + leader: RuntimeLeader, + indexes: readonly string[], + timeoutMs: number | null, + signal: AbortSignal +): Promise { + if (db.pool === null) { + throw unsupportedError( + "maintenance", + "PostgreSQL reindex maintenance requires a Pool" + ); + } + if ( + timeoutMs !== null && + (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) + ) { + throw configurationError( + "maintenanceReindex", + "reindex timeout must be a positive safe integer or null" + ); + } + if (!(await maintenanceLeaderIsCurrent(db, leader))) return 0; + const exists = await indexesExist(db, indexes); + let reindexed = 0; + for (const index of indexes) { + if (signal.aborted) throw signal.reason; + if (!(await maintenanceLeaderIsCurrent(db, leader))) return reindexed; + if (exists.get(index) !== true) continue; + const artifacts = await indexReindexArtifacts(db, index); + if (artifacts.length > 0) continue; + try { + await reindexOne(db, index, timeoutMs, signal); + reindexed++; + } catch (error: unknown) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the signal can abort while awaiting + if (signal.aborted) await cleanupReindexArtifacts(db, index); + throw error; + } + } + return reindexed; +} + +/** Whether `leader`'s lease is still current in `river_leader`. */ +async function maintenanceLeaderIsCurrent( + db: PgDatabase, + leader: RuntimeLeader +): Promise { + const result = await db.query( + "maintenanceLeaderIsCurrent", + ` + SELECT 1 + FROM ${db.table("river_leader")} + WHERE elected_at = $1::timestamptz + AND expires_at >= now() + AND leader_id = $2::text + `, + [instantParameter(leader.electedAt), leader.leaderId] + ); + return (result.rowCount ?? 0) > 0; +} + +/** + * Lock `leader`'s exact term in `tx` for the rest of the transaction, or + * return false when the lease has moved on. + * + * `FOR KEY SHARE` fences the term against resignation, expiry cleanup, and + * replacement (all deletes) for the whole operation, but not against + * renewal, which updates only `expires_at`. Like Go River, the leader keeps + * renewing its lease while maintenance is slow or blocked. + */ +async function holdLeaderFence( + db: PgDatabase, + operation: string, + leader: RuntimeLeader, + tx: ClientBase +): Promise { + const held = await db.query( + `${operation}LeaderFence`, + ` + SELECT 1 + FROM ${db.table("river_leader")} + WHERE elected_at = $1::timestamptz + AND expires_at >= now() + AND leader_id = $2::text + FOR KEY SHARE + `, + [instantParameter(leader.electedAt), leader.leaderId], + { tx } + ); + return (held.rowCount ?? 0) > 0; +} + +/** + * Run `run` in the caller's transaction `tx`, fenced by `leader`'s exact + * term like {@link withMaintenanceLeader}, or return null without running + * it once the lease has moved on. The caller ends `tx`. + */ +async function withLeaderFenceIn( + db: PgDatabase, + leader: RuntimeLeader, + operation: string, + tx: ClientBase, + run: (transaction: ClientBase) => Promise +): Promise { + if (!(await holdLeaderFence(db, operation, leader, tx))) return null; + return run(tx); +} + +/** + * Run `run` in a transaction fenced by `leader`'s exact term, or return null + * without running it once the lease has moved on. A `batch` bounds every + * statement with its timeout (`statement_timeout`, or none for `null`), so a + * timed-out batch fails and rolls back like Go's. When the batch's signal + * aborts, as when its term ends or the client stops, the connection is + * destroyed, which rolls the transaction back, and the call rejects at + * once, like a cancelled context in River for Go, so a statement stuck on + * a half-open socket can't hold up a stop. + */ +async function withMaintenanceLeader( + db: PgDatabase, + leader: RuntimeLeader, + operation: string, + batch: RuntimeMaintenanceBatch | undefined, + run: (transaction: PoolClient) => Promise +): Promise { + if (db.pool === null) { + throw unsupportedError( + "maintenance", + "PostgreSQL maintenance requires a Pool" + ); + } + batch?.signal.throwIfAborted(); + // A stop or timeout ends the wait for a connection, such as during an + // outage; a connection that arrives later goes back to the pool unused. + const acquiring = db.pool.connect(); + let lease: PgClientLease; + try { + lease = new PgClientLease(await abortablePromise(acquiring, batch?.signal)); + } catch (error: unknown) { + void acquiring.then((late) => late.release()).catch(() => {}); + throw error; + } + const client = lease.client; + const signal = batch?.signal; + const destroy = (): void => { + lease.destroy(); + }; + signal?.addEventListener("abort", destroy, { once: true }); + const step = (operation: Promise): Promise => + abortablePromise(lease.race(operation), signal); + let transactionStarted = false; + try { + await step(client.query("BEGIN")); + transactionStarted = true; + if (batch !== undefined) { + await step( + db.query( + `${operation}Timeout`, + "SELECT set_config('statement_timeout', $1::text, true)", + [(batch.timeoutMs ?? 0).toString(10)], + { tx: client } + ) + ); + } + const held = await step(holdLeaderFence(db, operation, leader, client)); + if (!held) { + await step(client.query("ROLLBACK")); + transactionStarted = false; + return null; + } + const result = await step(run(client)); + await step(client.query("COMMIT")); + transactionStarted = false; + return result; + } catch (cause: unknown) { + if (transactionStarted && !lease.failed && signal?.aborted !== true) { + try { + await lease.race(client.query("ROLLBACK")); + } catch { + lease.destroy(); + throw cause; + } + } + throw cause; + } finally { + signal?.removeEventListener("abort", destroy); + lease.release(); + } +} + +/** + * Reindex one index concurrently on a dedicated connection with a + * statement timeout, cancelling the statement server side on abort. + */ +async function reindexOne( + db: PgDatabase, + index: string, + timeoutMs: number | null, + signal: AbortSignal +): Promise { + if (db.pool === null) throw new Error("reindex pool preflight failed"); + if (signal.aborted) throw signal.reason; + const lease = new PgClientLease(await db.pool.connect()); + const client = lease.client; + const abort = () => { + // Destroying the socket alone leaves a concurrent reindex running on + // the server until it next writes to the dead connection. + db.cancelBackend(client, REINDEX_STATEMENT); + lease.destroy(); + }; + let operationError: unknown; + let operationFailed = false; + try { + signal.addEventListener("abort", abort, { once: true }); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the signal can abort while awaiting + if (signal.aborted) throw signal.reason; + await lease.race( + client.query("SELECT set_config('statement_timeout', $1::text, false)", [ + timeoutMs === null ? "0" : timeoutMs.toString(10), + ]) + ); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the signal can abort while awaiting + if (signal.aborted) throw signal.reason; + await lease.race(indexReindex(db, index, { tx: client })); + } catch (error: unknown) { + operationError = error; + operationFailed = true; + } finally { + signal.removeEventListener("abort", abort); + } + + let resetError: unknown; + let resetFailed = false; + try { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the signal can abort while awaiting + if (!lease.failed && !signal.aborted) { + await lease.race(client.query("RESET statement_timeout")); + } + lease.release(); + } catch (error: unknown) { + resetError = error; + resetFailed = true; + lease.destroy(); + } + if (operationFailed) throw operationError; + if (resetFailed) throw resetError; +} + +/** Drop `_ccnew`/`_ccold` artifacts an aborted reindex left behind. */ +async function cleanupReindexArtifacts( + db: PgDatabase, + index: string +): Promise { + if (db.pool === null) return; + const lease = new PgClientLease(await db.pool.connect()); + const client = lease.client; + try { + await lease.race( + client.query("SELECT set_config('statement_timeout', $1::text, false)", [ + REINDEX_CLEANUP_TIMEOUT_MS.toString(10), + ]) + ); + const artifacts = await lease.race( + indexReindexArtifacts(db, index, { tx: client }) + ); + for (const artifact of artifacts) { + await lease.race(indexDropIfExists(db, artifact, { tx: client })); + } + } finally { + if (!lease.failed) { + try { + await lease.race(client.query("RESET statement_timeout")); + lease.release(); + } catch { + lease.destroy(); + } + } + } +} + +function encodeAttemptError(error: AttemptError): JsonObject { + return { + at: error.at.toString(), + attempt: error.attempt, + error: error.error, + trace: error.trace, + }; +} + +function validateIndexName(value: string): void { + if ( + value.length === 0 || + value.includes("\0") || + Buffer.byteLength(value, "utf8") > POSTGRES_IDENTIFIER_MAX_BYTES + ) { + throw configurationError( + "index", + `PostgreSQL index names must contain 1 to ${POSTGRES_IDENTIFIER_MAX_BYTES} bytes without NUL` + ); + } +} diff --git a/js/driver/pg/src/sql/notify.ts b/js/driver/pg/src/sql/notify.ts new file mode 100644 index 000000000..5fa87e6e4 --- /dev/null +++ b/js/driver/pg/src/sql/notify.ts @@ -0,0 +1,450 @@ +/** `NOTIFY` delivery and supervised `LISTEN` subscriptions. */ +import { LinkedAbortSignal, quoteIdentifier } from "riverqueue/unstable-driver"; +import { Buffer } from "node:buffer"; +import type { Notification as NodePgNotification } from "pg"; +import { ConfigurationError } from "riverqueue"; +import type { RuntimeNotification } from "riverqueue/unstable-driver"; +import type { PgDatabase, PgQueryable } from "../database.js"; +import { POSTGRES_IDENTIFIER_MAX_BYTES } from "../database.js"; +import { + configurationError, + databaseError, + unsupportedError, +} from "../errors.js"; +import { PG_EXACT_TYPES } from "../exact-types.js"; +import { abortablePromise, PgClientLease } from "../lease.js"; +import type { PgNotification, PgOperationOptions } from "../types.js"; + +/** Notifications buffered per connection; beyond it the oldest is dropped. */ +const NOTIFICATION_QUEUE_CAPACITY = 1_024; + +/** Idle time after which a LISTEN connection is pinged, like River's Go notifier. */ +const LISTENER_PING_INTERVAL_MS = 5_000; + +/** + * Limit on connecting a LISTEN connection and subscribing its channels, like + * the listener timeout of River's Go notifier. A pooled connection whose + * socket went half-open would otherwise hang the subscription indefinitely. + */ +const LISTENER_SETUP_TIMEOUT_MS = 10_000; + +/** Returned by `NotificationQueue.shift` when a connection sat idle. */ +const LISTENER_IDLE: unique symbol = Symbol("listener idle"); + +/** + * Send River for Go's insert notification, `{"queue": "..."}`, for each of + * `queues`. + */ +export function notifyInsert( + db: PgDatabase, + queues: readonly string[], + options?: PgOperationOptions +): Promise { + return notifyMany( + db, + "river_insert", + queues.map((queue) => `{"queue": ${JSON.stringify(queue)}}`), + options + ); +} + +/** Send one or more PostgreSQL notifications on a River topic. */ +export async function notifyMany( + db: PgDatabase, + topic: string, + payloads: readonly string[], + options?: PgOperationOptions +): Promise { + if (payloads.length === 0) return; + // A server without LISTEN/NOTIFY, like YugabyteDB by default, gets none. + if (!(await db.capabilities(options)).supportsListenNotify) return; + await db.query( + "notifyMany", + ` + SELECT pg_notify( + concat(coalesce($1::text, current_schema()), '.', $2::text), payload + ) + FROM unnest($3::text[]) AS payload + `, + [db.schemaName, topic, payloads], + options + ); +} + +/** + * Yield namespaced PostgreSQL notifications from one LISTEN connection. + * + * Notifications are hints only: callers must retain polling because NOTIFY + * is not durable. An idle connection is pinged every five seconds, like + * River's Go notifier. Connecting and subscribing are bounded by a + * ten-second timeout, and `ready` runs once the channels are subscribed. + * The iteration ends when `signal` aborts, and fails with the error when + * connecting, subscribing, a ping, or the connection fails; the caller + * decides whether to subscribe again, as the runtime does with backoff, + * logging each failure like River for Go's notifier. + */ +export async function* listen( + db: PgDatabase, + topics: readonly string[], + signal: AbortSignal, + ready?: () => void, + options: { + readonly pingIntervalMs?: number; + readonly setupTimeoutMs?: number; + } = {} +): AsyncGenerator { + if (db.pool === null) { + throw unsupportedError( + "listen", + "PostgreSQL LISTEN requires constructing PgDriver with a Pool" + ); + } + if (topics.length === 0 || signal.aborted) return; + const pingIntervalMs = options.pingIntervalMs ?? LISTENER_PING_INTERVAL_MS; + const setupTimeoutMs = options.setupTimeoutMs ?? LISTENER_SETUP_TIMEOUT_MS; + const pool = db.pool; + + let lease: PgClientLease | undefined; + let queue: NotificationQueue | undefined; + const setupTimeout = new AbortController(); + const setupTimer = setTimeout(() => { + setupTimeout.abort( + databaseError( + "listen", + `PostgreSQL LISTEN connection setup did not finish within ${setupTimeoutMs} ms` + ) + ); + }, setupTimeoutMs); + setupTimer.unref(); + const setupLink = new LinkedAbortSignal([signal, setupTimeout.signal]); + try { + const setupSignal = setupLink.signal; + const connecting = pool.connect(); + let client: Awaited; + try { + client = await abortablePromise(connecting, setupSignal); + } catch (error: unknown) { + // Discard a connection that arrives after setup gave up on it. + connecting.then( + (late) => { + late.release(true); + }, + () => undefined + ); + throw error; + } + lease = new PgClientLease(client, (error) => queue?.fail(error)); + const connected = lease; + const listeningClient = connected.client; + const setupQuery = (query: Promise): Promise => + abortablePromise(connected.race(query), setupSignal); + const schema = + db.schemaName ?? (await setupQuery(currentSchema(listeningClient))); + const channels = topics.map((topic) => namespacedChannel(schema, topic)); + for (const channel of channels) { + await setupQuery( + listeningClient.query(`LISTEN ${quoteIdentifier(channel)}`) + ); + } + clearTimeout(setupTimer); + + const notificationQueue = new NotificationQueue(signal); + queue = notificationQueue; + const channelToTopic = new Map( + channels.map((channel, index) => [channel, topics[index] as string]) + ); + const onNotification = (message: NodePgNotification): void => { + const topic = channelToTopic.get(message.channel); + if (topic !== undefined) { + notificationQueue.push({ payload: message.payload ?? "", topic }); + } + }; + listeningClient.on("notification", onNotification); + ready?.(); + try { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the signal can abort while awaiting + while (!signal.aborted) { + const notification = await notificationQueue.shift(pingIntervalMs); + if (notification === null) break; + if (notification === LISTENER_IDLE) { + await pingListener(connected, channels, pingIntervalMs); + continue; + } + yield notification; + } + } finally { + // The pool client is destroyed below. Keep its error listener until + // it becomes unreachable because node-postgres may emit a second + // connection error while tearing down a forcibly terminated socket. + listeningClient.off("notification", onNotification); + notificationQueue.dispose(); + } + } catch (error) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the signal can abort while awaiting + if (signal.aborted && !(error instanceof ConfigurationError)) return; + throw error; + } finally { + setupLink[Symbol.dispose](); + clearTimeout(setupTimer); + lease?.destroy(); + } +} + +/** Adapt namespaced backend hints to the common runtime notification SPI. */ +export async function* runtimeNotificationSubscribe( + db: PgDatabase, + topics: readonly RuntimeNotification["topic"][], + signal: AbortSignal, + ready: () => void +): AsyncGenerator { + const backendTopics = topics.map(runtimeTopicName); + for await (const notification of listen(db, backendTopics, signal, ready)) { + yield { + payload: notification.payload, + topic: runtimeTopic(notification.topic), + }; + } +} + +/** Convert control-topic notifications into attempt-owner cancellation hints. */ +export async function* jobCancellationSubscribe( + db: PgDatabase, + attemptedBy: string, + signal: AbortSignal, + ready?: () => void +): AsyncGenerator<{ attemptedBy: string; id: bigint }> { + for await (const notification of listen( + db, + ["river_control"], + signal, + ready + )) { + let value: unknown; + try { + value = JSON.parse(notification.payload); + } catch { + continue; + } + if ( + typeof value !== "object" || + value === null || + !("action" in value) || + value.action !== "cancel" || + !("job_id" in value) + ) { + continue; + } + try { + const id = exactCancellationID(notification.payload, value.job_id); + if (id !== null) yield { attemptedBy, id }; + } catch { + // Ignore malformed notifications; polling remains the durable path. + } + } +} + +/** + * A bounded, deduplicating buffer between a LISTEN connection's + * notification events and the async generator that yields them. + */ +class NotificationQueue { + readonly #onAbort = (): void => this.close(); + readonly #signal: AbortSignal; + readonly #keys = new Set(); + #head = 0; + #values: PgNotification[] = []; + #failure: Error | undefined; + #waiting: + | { + reject: (error: Error) => void; + resolve: (value: PgNotification | null) => void; + } + | undefined; + + constructor(signal: AbortSignal) { + this.#signal = signal; + signal.addEventListener("abort", this.#onAbort, { once: true }); + } + + close(): void { + this.#waiting?.resolve(null); + this.#waiting = undefined; + } + + fail(error: Error): void { + this.#failure = error; + this.#waiting?.reject(error); + this.#waiting = undefined; + } + + dispose(): void { + this.#signal.removeEventListener("abort", this.#onAbort); + } + + push(value: PgNotification): void { + if (this.#signal.aborted) return; + if (this.#waiting !== undefined) { + this.#waiting.resolve(value); + this.#waiting = undefined; + return; + } + const key = `${value.topic}\0${value.payload}`; + if (this.#keys.has(key)) return; + if (this.#values.length - this.#head >= NOTIFICATION_QUEUE_CAPACITY) { + const dropped = this.#values[this.#head++]; + if (dropped !== undefined) { + this.#keys.delete(`${dropped.topic}\0${dropped.payload}`); + } + } + this.#keys.add(key); + this.#values.push(value); + this.#compact(); + } + + /** + * Take the next notification, `null` once closed, or {@link LISTENER_IDLE} + * when none arrives within `idleMs`. + */ + shift(idleMs: number): Promise { + if (this.#failure !== undefined) return Promise.reject(this.#failure); + if (this.#signal.aborted) return Promise.resolve(null); + const value = + this.#head < this.#values.length ? this.#values[this.#head++] : undefined; + if (value !== undefined) { + this.#keys.delete(`${value.topic}\0${value.payload}`); + this.#compact(); + return Promise.resolve(value); + } + this.#values = []; + this.#head = 0; + return new Promise((resolve, reject) => { + const idle = setTimeout(() => { + this.#waiting = undefined; + resolve(LISTENER_IDLE); + }, idleMs); + idle.unref(); + this.#waiting = { + reject: (error) => { + clearTimeout(idle); + reject(error); + }, + resolve: (notification) => { + clearTimeout(idle); + resolve(notification); + }, + }; + }); + } + + #compact(): void { + if (this.#head < 512 || this.#head * 2 < this.#values.length) return; + this.#values = this.#values.slice(this.#head); + this.#head = 0; + } +} + +async function currentSchema(client: PgQueryable): Promise { + const result = await client.query<{ schema: string }>({ + text: "SELECT current_schema()::text AS schema", + types: PG_EXACT_TYPES, + }); + const schema = result.rows[0]?.schema; + if (typeof schema !== "string" || schema.length === 0) { + throw databaseError( + "listen", + "PostgreSQL returned no current schema for LISTEN" + ); + } + return schema; +} + +function exactCancellationID(payload: string, decoded: unknown): bigint | null { + if (typeof decoded === "string" && /^-?(?:0|[1-9]\d*)$/.test(decoded)) { + return BigInt(decoded); + } + if (typeof decoded !== "number") return null; + const match = /"job_id"\s*:\s*(-?(?:0|[1-9]\d*))(?=\s*[,}])/.exec(payload); + return match?.[1] === undefined ? null : BigInt(match[1]); +} + +function namespacedChannel(schema: string, topic: string): string { + const channel = `${schema}.${topic}`; + if (topic.length === 0 || topic.includes("\0")) { + throw configurationError( + "listen", + "PostgreSQL notification topics must be non-empty and contain no NUL byte" + ); + } + if (Buffer.byteLength(channel, "utf8") > POSTGRES_IDENTIFIER_MAX_BYTES) { + throw configurationError( + "listen", + `PostgreSQL notification channel must not exceed ${POSTGRES_IDENTIFIER_MAX_BYTES} bytes` + ); + } + return channel; +} + +/** + * Round-trip a query on an idle LISTEN connection. A dead or half-open + * connection fails or times out, so the caller reconnects and re-listens. + * + * The ping repeats `LISTEN` for the first channel, which is a no-op for a + * channel already listened on. Unlike `SELECT 1` it leaves the session's + * `pg_stat_activity.query` showing `LISTEN`, which operators and tools use to + * identify notification connections. + */ +async function pingListener( + lease: PgClientLease, + channels: readonly string[], + timeoutMs: number +): Promise { + const channel = channels[0]; + if (channel === undefined) return; + const timeout = new AbortController(); + const timer = setTimeout( + () => timeout.abort(new Error("LISTEN ping timed out")), + timeoutMs + ); + timer.unref(); + try { + await abortablePromise( + lease.race(lease.client.query(`LISTEN ${quoteIdentifier(channel)}`)), + timeout.signal + ); + } catch (cause: unknown) { + throw databaseError( + "listen", + "PostgreSQL LISTEN connection did not answer a ping", + cause + ); + } finally { + clearTimeout(timer); + } +} + +function runtimeTopic(topic: string): RuntimeNotification["topic"] { + switch (topic) { + case "river_control": + return "control"; + case "river_insert": + return "insert"; + case "river_leadership": + return "leadership"; + default: + throw databaseError( + "runtimeNotificationSubscribe", + `unknown River notification topic ${JSON.stringify(topic)}` + ); + } +} + +function runtimeTopicName(topic: RuntimeNotification["topic"]): string { + switch (topic) { + case "control": + return "river_control"; + case "insert": + return "river_insert"; + case "leadership": + return "river_leadership"; + } +} diff --git a/js/driver/pg/src/sql/params.ts b/js/driver/pg/src/sql/params.ts new file mode 100644 index 000000000..b7706b32c --- /dev/null +++ b/js/driver/pg/src/sql/params.ts @@ -0,0 +1,24 @@ +/** Parameter encoding and validation shared by the SQL modules. */ +import { postgresTimestamp } from "riverqueue/unstable-driver"; + +/** + * Encode an optional instant as a `timestamptz` parameter, truncated to + * microseconds like Go's pgx so every engine stores the same instant. + */ +export function instantParameter( + value: Temporal.Instant | null | undefined +): string | null { + return value === null || value === undefined + ? null + : postgresTimestamp(value); +} + +/** Require a row limit that fits PostgreSQL's `int`. */ +export function validateLimit( + value: number, + label = "queue list maximum" +): void { + if (!Number.isInteger(value) || value < 0 || value > 2_147_483_647) { + throw new RangeError(`${label} must be an integer from 0 to 2147483647`); + } +} diff --git a/js/driver/pg/src/sql/queues.ts b/js/driver/pg/src/sql/queues.ts new file mode 100644 index 000000000..e03e96ca8 --- /dev/null +++ b/js/driver/pg/src/sql/queues.ts @@ -0,0 +1,272 @@ +/** Queue listing, upsert, pause/resume, and cleanup queries. */ +import type { + QueueListParams, + QueueRow, + QueueUpdateParams, +} from "riverqueue/unstable-driver"; +import { queueMetadataUpdate } from "riverqueue/unstable-driver"; +import type { PgDatabase } from "../database.js"; +import { databaseError } from "../errors.js"; +import type { + PgOperationOptions, + PgQueueControlParams, + PgQueueRow, + PgQueueUpsertParams, +} from "../types.js"; +import { instantParameter, validateLimit } from "./params.js"; +import type { PgQueueDatabaseRow } from "./rows.js"; +import { toQueueRow } from "./rows.js"; + +export async function queueGet( + db: PgDatabase, + name: string, + options?: PgOperationOptions +): Promise { + const result = await db.query( + "queueGet", + `SELECT *, metadata::text AS metadata_text FROM ${db.table("river_queue")} WHERE name = $1::text`, + [name], + options + ); + const row = result.rows[0]; + return row === undefined ? null : toQueueRow(row); +} + +/** Create a queue or refresh its liveness timestamp without erasing metadata. */ +export async function queueUpsert( + db: PgDatabase, + params: PgQueueUpsertParams, + options?: PgOperationOptions +): Promise { + const result = await db.query( + "queueUpsert", + ` + INSERT INTO ${db.table("river_queue")} ( + created_at, metadata, name, paused_at, updated_at + ) VALUES ( + coalesce($1::timestamptz, now()), + $2::jsonb, + $3::text, + $4::timestamptz, + coalesce($5::timestamptz, $1::timestamptz, now()) + ) + ON CONFLICT (name) DO UPDATE + SET updated_at = EXCLUDED.updated_at + RETURNING *, metadata::text AS metadata_text + `, + [ + instantParameter(params.now), + JSON.stringify(params.metadata ?? {}), + params.name, + instantParameter(params.pausedAt), + instantParameter(params.updatedAt), + ], + options + ); + const row = result.rows[0]; + if (row === undefined) { + throw databaseError( + "queueUpsert", + "PostgreSQL returned no row for an upserted queue" + ); + } + return toQueueRow(row); +} + +/** Delete stale queue rows in stable name order. */ +export async function queueDeleteExpired( + db: PgDatabase, + params: { max: number; updatedAtHorizon: Temporal.Instant }, + options?: PgOperationOptions +): Promise { + validateLimit(params.max, "expired queue maximum"); + const result = await db.query( + "queueDeleteExpired", + ` + DELETE FROM ${db.table("river_queue")} + WHERE name IN ( + SELECT name FROM ${db.table("river_queue")} + WHERE updated_at < $1::timestamptz + ORDER BY name ASC + LIMIT $2::int + ) + RETURNING *, metadata::text AS metadata_text + `, + [instantParameter(params.updatedAtHorizon), params.max], + options + ); + return result.rows + .sort((left, right) => left.name.localeCompare(right.name)) + .map(toQueueRow); +} + +/** List persisted queues in canonical name order. */ +export async function queueList( + db: PgDatabase, + params: QueueListParams, + options?: PgOperationOptions +): Promise { + validateLimit(params.limit); + const result = await db.query( + "queueList", + ` + SELECT *, metadata::text AS metadata_text FROM ${db.table("river_queue")} + WHERE name > coalesce($1::text, '') + ORDER BY name ASC LIMIT $2::int + `, + [params.nameAfter, params.limit], + options + ); + return result.rows.map(toQueueRow); +} + +/** Pause one queue, or all queues with the `"*"` sentinel. */ +export async function queuePause( + db: PgDatabase, + name: string, + options?: PgOperationOptions +): Promise { + const result = await queueSetPaused(db, { name }, true, options); + return name === "*" ? null : (result.rows[0] ?? null); +} + +/** Backend test hook for deterministic queue pause clocks. */ +export async function queuePauseWithOptions( + db: PgDatabase, + params: PgQueueControlParams, + options?: PgOperationOptions +): Promise { + return (await queueSetPaused(db, params, true, options)).rowCount; +} + +/** Resume one queue, or all queues with the `"*"` sentinel. */ +export async function queueResume( + db: PgDatabase, + name: string, + options?: PgOperationOptions +): Promise { + const result = await queueSetPaused(db, { name }, false, options); + return name === "*" ? null : (result.rows[0] ?? null); +} + +/** Backend test hook for deterministic queue resume clocks. */ +export async function queueResumeWithOptions( + db: PgDatabase, + params: PgQueueControlParams, + options?: PgOperationOptions +): Promise { + return (await queueSetPaused(db, params, false, options)).rowCount; +} + +/** + * Pause or resume matching queues like River's `QueuePause`/`QueueResume`: + * every matching row is returned, an already paused or resumed queue keeps + * its timestamps, and one control notification naming the requested queue + * (possibly `"*"`) is sent in the same transaction. + */ +async function queueSetPaused( + db: PgDatabase, + params: PgQueueControlParams, + paused: boolean, + options?: PgOperationOptions +): Promise<{ readonly rowCount: number; readonly rows: PgQueueRow[] }> { + const { supportsListenNotify } = await db.capabilities(options); + const result = await db.query< + PgQueueDatabaseRow | { [Key in keyof PgQueueDatabaseRow]: null } + >( + paused ? "queuePause" : "queueResume", + ` + WITH updated AS ( + UPDATE ${db.table("river_queue")} + SET + paused_at = CASE + WHEN NOT $5::boolean THEN NULL + WHEN paused_at IS NULL THEN coalesce($1::timestamptz, now()) + ELSE paused_at + END, + updated_at = CASE + WHEN (paused_at IS NULL) = $5::boolean + THEN coalesce($1::timestamptz, now()) + ELSE updated_at + END + WHERE CASE WHEN $2::text = '*' THEN true ELSE name = $2::text END + RETURNING *, metadata::text AS metadata_text + ), + notification AS ( + SELECT count(CASE WHEN $6::boolean THEN pg_notify( + concat(coalesce($3::text, current_schema()), '.', $4::text), + concat( + '{"action":"', + CASE WHEN $5::boolean THEN 'pause' ELSE 'resume' END, + '","queue":', + to_json($2::text)::text, + '}' + ) + ) END) AS sent + WHERE $2::text = '*' OR EXISTS (SELECT 1 FROM updated) + ) + SELECT updated.* + FROM notification + LEFT JOIN updated ON true + ORDER BY updated.name + `, + [ + instantParameter(params.now), + params.name, + db.schemaName, + "river_control", + paused, + supportsListenNotify, + ], + options + ); + // The single notification row anchors the join, so an empty match + // returns one row of nulls. + const rows = result.rows + .filter((row): row is PgQueueDatabaseRow => row.name !== null) + .map(toQueueRow); + return { rowCount: rows.length, rows }; +} + +/** Update the mutable fields of a persisted queue. */ +export async function queueUpdate( + db: PgDatabase, + name: string, + params: QueueUpdateParams, + options?: PgOperationOptions +): Promise { + const update = queueMetadataUpdate(name, params); + const { supportsListenNotify } = await db.capabilities(options); + const result = await db.query( + "queueUpdate", + ` + WITH updated AS ( + UPDATE ${db.table("river_queue")} + SET + metadata = CASE WHEN $1::boolean THEN $2::jsonb ELSE metadata END, + updated_at = now() + WHERE name = $3::text + RETURNING *, metadata::text AS metadata_text + ), + notification AS ( + SELECT CASE WHEN $7::boolean THEN pg_notify( + concat(coalesce($4::text, current_schema()), '.', $5::text), + $6::text + ) END FROM updated WHERE $1::boolean + ) + SELECT updated.* FROM updated LEFT JOIN notification ON true + `, + [ + update !== undefined, + update?.text ?? "{}", + name, + db.schemaName, + "river_control", + update?.notification ?? "", + supportsListenNotify, + ], + options + ); + const row = result.rows[0]; + return row === undefined ? null : toQueueRow(row); +} diff --git a/js/driver/pg/src/sql/rows.ts b/js/driver/pg/src/sql/rows.ts new file mode 100644 index 000000000..4fbd77c23 --- /dev/null +++ b/js/driver/pg/src/sql/rows.ts @@ -0,0 +1,167 @@ +/** Raw node-postgres row shapes and their exact River decoders. */ +import type { Buffer } from "node:buffer"; +import type { QueryResult, QueryResultRow } from "pg"; +import type { JobRow } from "riverqueue"; +import { toJsonObject } from "riverqueue"; +import { + decodeAttemptError, + decodeJobState, + recordQueueMetadataText, + uniqueBitmaskToStates, +} from "riverqueue/unstable-driver"; +import type { PgQueueRow } from "../types.js"; + +/** A `river_job` row as node-postgres returns it with River's type parsers. */ +export interface PgJobRow extends QueryResultRow { + args: unknown; + attempt: number; + attempted_at: Temporal.Instant | null; + attempted_by: string[] | null; + created_at: Temporal.Instant; + /** Each attempt error's JSON text. */ + errors: (string | null)[] | null; + finalized_at: Temporal.Instant | null; + id: bigint; + kind: string; + max_attempts: number; + metadata: unknown; + priority: number; + queue: string; + scheduled_at: Temporal.Instant; + state: string; + tags: string[] | null; + unique_key: Buffer | null; + unique_states: string | null; +} + +/** An inserted job row with the insert's duplicate flag. */ +export interface PgInsertRow extends PgJobRow { + unique_skipped_as_duplicate: boolean; +} + +export interface PgQueueDatabaseRow extends QueryResultRow { + created_at: Temporal.Instant; + metadata: unknown; + /** `metadata::text`, PostgreSQL's rendering of the stored JSONB. */ + metadata_text: string; + name: string; + paused_at: Temporal.Instant | null; + updated_at: Temporal.Instant; +} + +/** A job row returned by a completion batch, with whether it applied. */ +export interface PgCompletionRow extends PgJobRow { + transition_applied: boolean; +} + +export interface PgLeaderDatabaseRow extends QueryResultRow { + elected_at: Temporal.Instant; + expires_at: Temporal.Instant; + leader_id: string; +} + +/** A scheduled job row with whether a unique conflict discarded it. */ +export interface PgScheduleRow extends PgJobRow { + conflict_discarded: boolean; +} + +/** Decode the first row of a result, or null when there is none. */ +export function mapOneJob(result: QueryResult): JobRow | null { + const row = result.rows[0]; + return row === undefined ? null : toJobRow(row); +} + +/** + * Decode the first row of a result, or null when there is none, leaving any + * field that can't be decoded empty like {@link toJobRowPartial}. Operations + * that act on a job by ID use it, so an operator can cancel, retry, or delete + * a row another engine wrote that River can't fully read. + */ +export function mapOneJobPartial(result: QueryResult): JobRow | null { + const row = result.rows[0]; + return row === undefined ? null : toJobRowPartial(row).job; +} + +/** Decode a raw job row exactly, throwing if any field can't be decoded. */ +export function toJobRow(row: PgJobRow): JobRow { + const { error, job } = toJobRowPartial(row); + if (error !== undefined) throw error; + return job; +} + +/** + * Decode a raw job row, leaving any `args`, `metadata`, or `errors` value + * that can't be decoded empty and returning the decode error alongside, so + * one bad row can't fail a claim, a completion batch, or the rescuer. + */ +export function toJobRowPartial(row: PgJobRow): { + readonly error?: Error; + readonly job: JobRow; +} { + const failures: Error[] = []; + const decode = (field: string, empty: T, decoder: () => T): T => { + try { + return decoder(); + } catch (cause: unknown) { + failures.push( + new TypeError( + `could not decode \`${field}\`: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause } + ) + ); + return empty; + } + }; + const job: JobRow = { + args: decode("args", {}, () => toJsonObject(row.args)), + attempt: row.attempt, + attemptedAt: row.attempted_at, + attemptedBy: row.attempted_by ?? [], + createdAt: row.created_at, + errors: decode("errors", [], () => + (row.errors ?? []).map((error) => { + // Like River for Go, an element that is NULL isn't valid JSON. + if (error === null) throw new TypeError("attempt error is NULL"); + return decodeAttemptError(error); + }) + ), + finalizedAt: row.finalized_at, + id: row.id, + kind: row.kind, + maxAttempts: row.max_attempts, + metadata: decode("metadata", {}, () => toJsonObject(row.metadata)), + priority: row.priority, + queue: row.queue, + scheduledAt: row.scheduled_at, + state: decodeJobState(row.state), + tags: row.tags ?? [], + uniqueKey: row.unique_key === null ? null : new Uint8Array(row.unique_key), + uniqueStates: + row.unique_states === null + ? null + : uniqueBitmaskToStates(Number.parseInt(row.unique_states, 2)), + }; + if (failures.length === 0) return { job }; + return { + error: + failures.length === 1 && failures[0] !== undefined + ? failures[0] + : new AggregateError( + failures, + failures.map((f) => f.message).join("; ") + ), + job, + }; +} + +export function toQueueRow(row: PgQueueDatabaseRow): PgQueueRow { + const queue: PgQueueRow = { + createdAt: row.created_at, + metadata: toJsonObject(row.metadata), + name: row.name, + pausedAt: row.paused_at, + updatedAt: row.updated_at, + }; + recordQueueMetadataText(queue, row.metadata_text); + return queue; +} diff --git a/js/driver/pg/src/types.ts b/js/driver/pg/src/types.ts new file mode 100644 index 000000000..56798f84d --- /dev/null +++ b/js/driver/pg/src/types.ts @@ -0,0 +1,109 @@ +import type { ClientBase } from "pg"; +import type { AttemptError, JobRow, JsonObject } from "riverqueue"; + +/** Construction options owned by the PostgreSQL backend. */ +export interface PgDriverOptions { + /** PostgreSQL schema containing River's tables and functions. */ + schema?: string; +} + +/** Options common to PostgreSQL semantic operations. */ +export interface PgOperationOptions { + /** Caller-owned transaction connection used for the entire operation. */ + tx?: ClientBase; +} + +/** Exact semantic inputs for cancelling a job. */ +export interface PgJobCancelParams { + cancelAttemptedAt: Temporal.Instant; + controlTopic: string; + id: bigint; + now?: Temporal.Instant; +} + +/** Exact semantic inputs for retrying a job. */ +export interface PgJobRetryParams { + id: bigint; + now?: Temporal.Instant; +} + +/** Exact properties of a persisted River queue. */ +export interface PgQueueRow { + createdAt: Temporal.Instant; + metadata: JsonObject; + name: string; + pausedAt: Temporal.Instant | null; + updatedAt: Temporal.Instant; +} + +/** Exact semantic inputs for pausing or resuming queues. */ +export interface PgQueueControlParams { + /** A queue name, or `"*"` to target all known queues. */ + name: string; + now?: Temporal.Instant; +} + +/** One job selected for rescue after a stale running attempt. */ +export interface PgJobRescue { + error: AttemptError; + finalizedAt?: Temporal.Instant; + id: bigint; + scheduledAt: Temporal.Instant; + state: "cancelled" | "discarded" | "retryable"; +} + +/** Runtime inputs for safely rescuing stuck jobs. */ +export interface PgJobRescueManyParams { + items: readonly PgJobRescue[]; + stuckHorizon: Temporal.Instant; +} + +/** Scheduler output, including uniqueness conflicts discarded by River. */ +export interface PgJobScheduleResult { + conflictDiscarded: boolean; + job: JobRow; +} + +/** Cleaner retention horizons and optional queue selection. */ +export interface PgJobDeleteBeforeParams { + cancelledFinalizedAt?: Temporal.Instant; + completedFinalizedAt?: Temporal.Instant; + discardedFinalizedAt?: Temporal.Instant; + max: number; + queuesExcluded?: readonly string[]; + queuesIncluded?: readonly string[]; +} + +/** One River leadership lease. */ +export interface PgLeader { + electedAt: Temporal.Instant; + expiresAt: Temporal.Instant; + leaderId: string; +} + +/** Inputs shared by election and lease renewal. */ +export interface PgLeaderElectParams { + leaderId: string; + now?: Temporal.Instant; + ttlSeconds: number; +} + +/** Inputs that bind renewal/resignation to an exact election term. */ +export interface PgLeaderTermParams extends PgLeaderElectParams { + electedAt: Temporal.Instant; +} + +/** A PostgreSQL notification emitted through River's namespaced channels. */ +export interface PgNotification { + payload: string; + topic: string; +} + +/** Inputs for creating or refreshing a persisted queue. */ +export interface PgQueueUpsertParams { + metadata?: JsonObject; + name: string; + now?: Temporal.Instant; + pausedAt?: Temporal.Instant; + updatedAt?: Temporal.Instant; +} diff --git a/js/driver/pg/tsconfig.json b/js/driver/pg/tsconfig.json index 5285d28af..fc6b18f33 100644 --- a/js/driver/pg/tsconfig.json +++ b/js/driver/pg/tsconfig.json @@ -2,7 +2,9 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "dist" + "outDir": "dist", + "stripInternal": true }, + "exclude": ["src/**/*.integration.test.ts", "src/**/*.test.ts"], "include": ["src"] } diff --git a/js/driver/prisma/package.json b/js/driver/prisma/package.json index c2ceb5983..0c61fc8bd 100644 --- a/js/driver/prisma/package.json +++ b/js/driver/prisma/package.json @@ -1,40 +1,56 @@ { "name": "@riverqueue/driver-prisma", - "version": "0.1.0", + "version": "0.50.0-alpha.1", "description": "Prisma driver for the riverqueue TypeScript client.", "type": "module", + "sideEffects": false, "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", - "import": "./dist/index.js" + "import": "./dist/index.js", + "default": "./dist/index.js" } }, + "engines": { + "node": ">=26" + }, "files": [ - "dist" + "dist", + "src", + "!src/**/*.test.ts", + "README.md", + "LICENSE" ], "scripts": { - "build": "tsc", + "build": "node ../../node_modules/typescript/bin/tsc && node ../../scripts/copy-license.mjs", "clean": "rm -rf dist", - "prepublishOnly": "pnpm run clean && pnpm run build" + "prepack": "pnpm run clean && pnpm run build" }, "repository": { "type": "git", "url": "git+https://github.com/riverqueue/river.git", "directory": "js/driver/prisma" }, - "authors": ["Brandur Leach", "Blake Gentry"], + "contributors": [ + "Brandur Leach", + "Blake Gentry" + ], "license": "LGPL-3.0-or-later", - "dependencies": { - "riverqueue": "workspace:*" + "publishConfig": { + "access": "public", + "provenance": true }, "peerDependencies": { - "@prisma/client": ">=5.0.0" + "@prisma/client": ">=5.0.0", + "riverqueue": "workspace:0.50.0-alpha.1" }, "devDependencies": { + "@prisma/adapter-pg": "7.9.1", "@prisma/client": "7.9.1", "prisma": "7.9.1", + "riverqueue": "workspace:0.50.0-alpha.1", "typescript": "^6.0.3" }, "keywords": [ diff --git a/js/driver/prisma/src/driver.integration.test.ts b/js/driver/prisma/src/driver.integration.test.ts deleted file mode 100644 index 28602f6e8..000000000 --- a/js/driver/prisma/src/driver.integration.test.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; -import { Pool, PoolClient } from "pg"; -import { Client, JobArgsObject } from "riverqueue"; -import type { JobArgs } from "riverqueue"; -import { PrismaDriver } from "./driver.js"; -import type { PrismaClientLike } from "./driver.js"; - -const TEST_DATABASE_URL = - process.env.TEST_DATABASE_URL || - "postgres://localhost:5432/river_test?sslmode=disable"; - -// Per-file random prefix so parallel test files don't interfere with each -// other's cleanup. -const filePrefix = `prisma_${Math.random().toString(36).slice(2, 8)}`; - -// Adapts a pg Pool/PoolClient to the PrismaClientLike interface so the Prisma -// driver's actual SQL and row mapping can be tested against a real database -// without requiring a full Prisma setup. -class PgPrismaAdapter implements PrismaClientLike { - constructor(private pool: Pool | PoolClient) {} - - async $queryRawUnsafe( - sql: string, - ...values: unknown[] - ): Promise { - const result = await this.pool.query(sql, values); - return result.rows as T; - } -} - -describe("PrismaDriver integration", () => { - let pool: Pool; - let client: Client; - - beforeAll(async () => { - pool = new Pool({ connectionString: TEST_DATABASE_URL }); - client = new Client(new PrismaDriver(new PgPrismaAdapter(pool))); - }); - - afterAll(async () => { - await pool.end(); - }); - - afterEach(async () => { - await pool.query("DELETE FROM river_job WHERE kind LIKE $1", [ - `${filePrefix}%`, - ]); - }); - - it("inserts a job and returns it", async () => { - const result = await client.insert( - new JobArgsObject(`${filePrefix}_basic`, { key: "value" }) - ); - - expect(result.job.id).toBeGreaterThan(0); - expect(result.job.kind).toBe(`${filePrefix}_basic`); - expect(result.job.args).toEqual({ key: "value" }); - expect(result.job.state).toBe("available"); - expect(result.job.queue).toBe("default"); - expect(result.job.priority).toBe(1); - expect(result.job.maxAttempts).toBe(25); - expect(result.job.attempt).toBe(0); - expect(result.job.tags).toEqual([]); - expect(result.job.createdAt).toBeInstanceOf(Date); - expect(result.job.scheduledAt).toBeInstanceOf(Date); - expect(result.uniqueSkippedAsDuplicated).toBe(false); - }); - - it("inserts with all options", async () => { - const future = new Date(Date.now() + 3_600_000); - - const result = await client.insert( - new JobArgsObject(`${filePrefix}_opts`, { n: 42 }), - { - maxAttempts: 5, - priority: 3, - queue: "high_priority", - scheduledAt: future, - tags: ["tag_one", "tag_two"], - } - ); - - expect(result.job.kind).toBe(`${filePrefix}_opts`); - expect(result.job.maxAttempts).toBe(5); - expect(result.job.priority).toBe(3); - expect(result.job.queue).toBe("high_priority"); - expect(result.job.state).toBe("scheduled"); - expect(result.job.tags).toEqual(["tag_one", "tag_two"]); - }); - - it("inserts many jobs", async () => { - const results = await client.insertMany([ - new JobArgsObject(`${filePrefix}_batch_a`, { i: 1 }), - new JobArgsObject(`${filePrefix}_batch_b`, { i: 2 }), - new JobArgsObject(`${filePrefix}_batch_c`, { i: 3 }), - ]); - - expect(results).toHaveLength(3); - - const ids = results.map((r) => r.job.id); - expect(new Set(ids).size).toBe(3); - - expect(results[0]!.job.kind).toBe(`${filePrefix}_batch_a`); - expect(results[1]!.job.kind).toBe(`${filePrefix}_batch_b`); - expect(results[2]!.job.kind).toBe(`${filePrefix}_batch_c`); - }); - - it("handles unique job insertion", async () => { - const uniqueOpts = { byArgs: true as const, byQueue: true as const }; - - const first = await client.insert( - new JobArgsObject(`${filePrefix}_unique`, { key: "same" }), - { uniqueOpts } - ); - expect(first.uniqueSkippedAsDuplicated).toBe(false); - - const second = await client.insert( - new JobArgsObject(`${filePrefix}_unique`, { key: "same" }), - { uniqueOpts } - ); - expect(second.uniqueSkippedAsDuplicated).toBe(true); - expect(second.job.id).toBe(first.job.id); - }); - - it("uses custom class args with toJSON", async () => { - const kind = `${filePrefix}_notify`; - - class NotifyArgs implements JobArgs { - kind = kind; - constructor(public channel: string) {} - toJSON() { - return { channel: this.channel }; - } - } - - const result = await client.insert(new NotifyArgs("general")); - - expect(result.job.kind).toBe(kind); - expect(result.job.args).toEqual({ channel: "general" }); - }); - - it("verifies job exists in database after insert", async () => { - const result = await client.insert( - new JobArgsObject(`${filePrefix}_verify`, { data: "check" }) - ); - - const dbResult = await pool.query("SELECT * FROM river_job WHERE id = $1", [ - result.job.id, - ]); - expect(dbResult.rowCount).toBe(1); - expect(dbResult.rows[0].kind).toBe(`${filePrefix}_verify`); - }); - - it("inserts within a transaction via tx option", async () => { - const poolClient = await pool.connect(); - try { - await poolClient.query("BEGIN"); - const txAdapter = new PgPrismaAdapter(poolClient); - - await client.insert(new JobArgsObject(`${filePrefix}_tx_1`, {}), { - tx: txAdapter, - }); - await client.insert(new JobArgsObject(`${filePrefix}_tx_2`, {}), { - tx: txAdapter, - }); - - await poolClient.query("COMMIT"); - } finally { - poolClient.release(); - } - - const dbResult = await pool.query( - `SELECT kind FROM river_job WHERE kind LIKE '${filePrefix}_tx_%' ORDER BY kind` - ); - expect(dbResult.rows.map((r) => r.kind)).toEqual([ - `${filePrefix}_tx_1`, - `${filePrefix}_tx_2`, - ]); - }); - - it("rolls back transaction via tx option", async () => { - const poolClient = await pool.connect(); - try { - await poolClient.query("BEGIN"); - const txAdapter = new PgPrismaAdapter(poolClient); - - await client.insert(new JobArgsObject(`${filePrefix}_rollback`, {}), { - tx: txAdapter, - }); - - await poolClient.query("ROLLBACK"); - } finally { - poolClient.release(); - } - - const dbResult = await pool.query( - `SELECT * FROM river_job WHERE kind = '${filePrefix}_rollback'` - ); - expect(dbResult.rowCount).toBe(0); - }); -}); diff --git a/js/driver/prisma/src/driver.test.ts b/js/driver/prisma/src/driver.test.ts deleted file mode 100644 index 7ae9adc49..000000000 --- a/js/driver/prisma/src/driver.test.ts +++ /dev/null @@ -1,303 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; -import { PrismaDriver } from "./driver.js"; -import type { PrismaClientLike } from "./driver.js"; -import type { JobInsertParams } from "riverqueue"; - -// Simulates what Prisma returns for a river_job row. -// Key differences from pg: BigInt for id, Number for smallint. -function fakePrismaRow(overrides: Record = {}) { - return { - id: BigInt(42), // Prisma returns BigInt for bigint columns - args: { strings: ["a", "b"] }, - attempt: 0, - attempted_at: null, - attempted_by: null, - created_at: new Date("2024-06-01T00:00:00Z"), - errors: null, - finalized_at: null, - kind: "sort", - max_attempts: 25, - metadata: {}, - priority: 1, - queue: "default", - scheduled_at: new Date("2024-06-01T00:00:00Z"), - state: "available", - tags: ["tag1", "tag2"], - unique_key: null, - unique_states: null, - unique_skipped_as_duplicate: false, - ...overrides, - }; -} - -function fakeInsertParams( - overrides: Partial = {} -): JobInsertParams { - return { - encodedArgs: '{"strings":["a","b"]}', - kind: "sort", - maxAttempts: 25, - priority: 1, - queue: "default", - scheduledAt: new Date("2024-06-01T00:00:00Z"), - state: "available", - tags: [], - uniqueKey: null, - uniqueStates: null, - ...overrides, - }; -} - -function mockPrismaClient() { - const rowsToReturn: Record[] = []; - - const mock = { - rowsToReturn, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - $queryRawUnsafe: vi.fn(async (_sql: string, ..._values: unknown[]) => { - return mock.rowsToReturn; - }), - }; - return mock as typeof mock & PrismaClientLike; -} - -describe("PrismaDriver", () => { - let prisma: ReturnType; - let driver: PrismaDriver; - - beforeEach(() => { - prisma = mockPrismaClient(); - driver = new PrismaDriver(prisma); - }); - - describe("jobInsertMany", () => { - it("returns empty array for empty params", async () => { - const results = await driver.jobInsertMany([]); - expect(results).toEqual([]); - expect(prisma.$queryRawUnsafe).not.toHaveBeenCalled(); - }); - - it("constructs correct SQL and parameters", async () => { - prisma.rowsToReturn = [fakePrismaRow()]; - - await driver.jobInsertMany([fakeInsertParams({ tags: ["urgent"] })]); - - expect(prisma.$queryRawUnsafe).toHaveBeenCalledOnce(); - const sql = (prisma.$queryRawUnsafe as ReturnType).mock - .calls[0]![0] as string; - const values = ( - prisma.$queryRawUnsafe as ReturnType - ).mock.calls[0]!.slice(1) as unknown[]; - - expect(sql).toContain("INSERT INTO river_job"); - expect(sql).toContain("ON CONFLICT (unique_key)"); - expect(sql).toContain("RETURNING"); - - expect(values).toHaveLength(10); - expect(values[0]).toBe('{"strings":["a","b"]}'); - expect(values[1]).toBe("sort"); - expect(values[7]).toEqual(["urgent"]); - }); - - it("constructs correct parameters for batch insert", async () => { - prisma.rowsToReturn = [ - fakePrismaRow(), - fakePrismaRow({ id: BigInt(43) }), - ]; - - await driver.jobInsertMany([ - fakeInsertParams({ kind: "job_a" }), - fakeInsertParams({ kind: "job_b" }), - ]); - - const values = ( - prisma.$queryRawUnsafe as ReturnType - ).mock.calls[0]!.slice(1) as unknown[]; - - expect(values).toHaveLength(20); - expect(values[1]).toBe("job_a"); - expect(values[11]).toBe("job_b"); - }); - - it("converts unique key to Buffer", async () => { - const uniqueKey = new Uint8Array([1, 2, 3, 4]); - prisma.rowsToReturn = [fakePrismaRow()]; - - await driver.jobInsertMany([ - fakeInsertParams({ uniqueKey, uniqueStates: "11110101" }), - ]); - - const values = ( - prisma.$queryRawUnsafe as ReturnType - ).mock.calls[0]!.slice(1) as unknown[]; - - expect(Buffer.isBuffer(values[8])).toBe(true); - expect(values[9]).toBe("11110101"); - }); - - it("uses schema prefix in SQL when provided", async () => { - prisma.rowsToReturn = [fakePrismaRow()]; - - await driver.jobInsertMany([fakeInsertParams()], { - schemaPrefix: '"custom".', - }); - - const sql = (prisma.$queryRawUnsafe as ReturnType).mock - .calls[0]![0] as string; - expect(sql).toContain('INSERT INTO "custom".river_job'); - expect(sql).toContain('"custom".river_job_state_in_bitmask'); - }); - - it("schema-qualifies the river_job_state cast", async () => { - prisma.rowsToReturn = [fakePrismaRow()]; - - await driver.jobInsertMany([fakeInsertParams()], { - schemaPrefix: '"custom".', - }); - - const sql = (prisma.$queryRawUnsafe as ReturnType).mock - .calls[0]![0] as string; - - // The state parameter cast must be schema-qualified. Without it, - // Prisma inserts fail with 'type "river_job_state" does not exist' - // when the schema is not on search_path. - expect(sql).toContain('::"custom".river_job_state,'); - expect(sql).not.toMatch(/::river_job_state[^_]/); - }); - }); - - describe("jobInsert", () => { - it("delegates to jobInsertMany", async () => { - prisma.rowsToReturn = [fakePrismaRow()]; - - const [job, skipped] = await driver.jobInsert(fakeInsertParams()); - - expect(prisma.$queryRawUnsafe).toHaveBeenCalledOnce(); - expect(job.kind).toBe("sort"); - expect(skipped).toBe(false); - }); - }); - - describe("row mapping", () => { - it("converts BigInt id to number", async () => { - prisma.rowsToReturn = [fakePrismaRow({ id: BigInt(999) })]; - - const [job] = await driver.jobInsert(fakeInsertParams()); - - expect(job.id).toBe(999); - expect(typeof job.id).toBe("number"); - }); - - it("maps basic columns correctly", async () => { - prisma.rowsToReturn = [fakePrismaRow()]; - - const [job] = await driver.jobInsert(fakeInsertParams()); - - expect(job.id).toBe(42); - expect(job.args).toEqual({ strings: ["a", "b"] }); - expect(job.attempt).toBe(0); - expect(job.kind).toBe("sort"); - expect(job.maxAttempts).toBe(25); - expect(job.priority).toBe(1); - expect(job.queue).toBe("default"); - expect(job.state).toBe("available"); - expect(job.tags).toEqual(["tag1", "tag2"]); - }); - - it("maps null columns", async () => { - prisma.rowsToReturn = [fakePrismaRow()]; - - const [job] = await driver.jobInsert(fakeInsertParams()); - - expect(job.attemptedAt).toBeNull(); - expect(job.attemptedBy).toBeNull(); - expect(job.errors).toBeNull(); - expect(job.finalizedAt).toBeNull(); - expect(job.uniqueKey).toBeNull(); - expect(job.uniqueStates).toBeNull(); - }); - - it("maps non-null optional columns", async () => { - prisma.rowsToReturn = [ - fakePrismaRow({ - attempted_at: new Date("2024-06-01T01:00:00Z"), - attempted_by: ["worker-1"], - finalized_at: new Date("2024-06-01T02:00:00Z"), - }), - ]; - - const [job] = await driver.jobInsert(fakeInsertParams()); - - expect(job.attemptedAt).toEqual(new Date("2024-06-01T01:00:00Z")); - expect(job.attemptedBy).toEqual(["worker-1"]); - expect(job.finalizedAt).toEqual(new Date("2024-06-01T02:00:00Z")); - }); - - it("maps errors from jsonb array", async () => { - prisma.rowsToReturn = [ - fakePrismaRow({ - errors: [ - { - at: "2024-06-01T01:00:00Z", - attempt: 1, - error: "something broke", - trace: "stack trace here", - }, - ], - }), - ]; - - const [job] = await driver.jobInsert(fakeInsertParams()); - - expect(job.errors).toHaveLength(1); - expect(job.errors![0]!.at).toEqual(new Date("2024-06-01T01:00:00Z")); - expect(job.errors![0]!.attempt).toBe(1); - expect(job.errors![0]!.error).toBe("something broke"); - expect(job.errors![0]!.trace).toBe("stack trace here"); - }); - - it("maps unique key from Buffer", async () => { - const buf = Buffer.from([0xca, 0xfe]); - prisma.rowsToReturn = [fakePrismaRow({ unique_key: buf })]; - - const [job] = await driver.jobInsert(fakeInsertParams()); - - expect(job.uniqueKey).toBeInstanceOf(Uint8Array); - expect(job.uniqueKey).toEqual(new Uint8Array([0xca, 0xfe])); - }); - - it("maps unique states from bit string", async () => { - prisma.rowsToReturn = [fakePrismaRow({ unique_states: "11110101" })]; - - const [job] = await driver.jobInsert(fakeInsertParams()); - - // 11110101 = available, completed, pending, retryable, running, scheduled - expect(job.uniqueStates).toEqual([ - "available", - "completed", - "pending", - "retryable", - "running", - "scheduled", - ]); - }); - - it("reports unique_skipped_as_duplicate", async () => { - prisma.rowsToReturn = [ - fakePrismaRow({ unique_skipped_as_duplicate: true }), - ]; - - const [, skipped] = await driver.jobInsert(fakeInsertParams()); - - expect(skipped).toBe(true); - }); - - it("defaults tags to empty array when null", async () => { - prisma.rowsToReturn = [fakePrismaRow({ tags: null })]; - - const [job] = await driver.jobInsert(fakeInsertParams()); - - expect(job.tags).toEqual([]); - }); - }); -}); diff --git a/js/driver/prisma/src/driver.ts b/js/driver/prisma/src/driver.ts index 4bc68ccfc..d93c8baa2 100644 --- a/js/driver/prisma/src/driver.ts +++ b/js/driver/prisma/src/driver.ts @@ -1,148 +1,545 @@ +import { Buffer } from "node:buffer"; +import { randomBytes } from "node:crypto"; +import type { DurationInput, JobRow } from "riverqueue"; +import { ConfigurationError, parseJsonObject } from "riverqueue"; import type { - AttemptError, - Driver, - DriverOptions, + DriverInsertResult, + InsertDriver, + InsertDriverOptions, JobInsertParams, - JobRow, - JobState, -} from "riverqueue"; -import { uniqueBitmaskToStates } from "riverqueue"; + PostgresCapabilities, +} from "riverqueue/unstable-driver"; +import { + decodeAttemptError, + decodeJobState, + durationToMilliseconds, + POSTGRES_CAPABILITIES_SQL, + postgresCapabilitiesFromRow, + postgresTimestamp, + quoteIdentifier, + registerDriver, + UNIQUE_INSERT_NONCE_KEY, + uniqueBitmaskFromStates, + uniqueBitmaskToStates, + uniqueInsertConflictSql, +} from "riverqueue/unstable-driver"; -/** - * Minimal interface matching PrismaClient's raw query method. Using an - * interface avoids a direct import dependency on @prisma/client. - */ +const RIVER_SCHEMA_MAX_BYTES = 46; +const RIVER_SCHEMA_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; + +/** The raw-query surface shared by Prisma clients and transaction clients. */ export interface PrismaClientLike { $queryRawUnsafe(query: string, ...values: unknown[]): Promise; + /** + * Prisma's interactive transaction, present on a root client (not on the + * transaction client it passes to the callback). River uses it to run an + * insertion without `{ tx }` in a transaction of its own, like River for + * Go. + */ + $transaction?( + callback: (tx: PrismaClientLike) => Promise, + options?: PrismaTransactionOptions + ): Promise; +} + +/** Options River passes to Prisma's interactive `$transaction`. */ +export interface PrismaTransactionOptions { + /** Longest wait, in milliseconds, for a connection from Prisma's pool. */ + maxWait?: number; + /** Longest time, in milliseconds, the transaction may stay open. */ + timeout?: number; +} + +/** PostgreSQL configuration owned by the adapter. */ +export interface PrismaDriverOptions { + /** PostgreSQL schema containing River's tables and functions. */ + schema?: string; + /** + * Limits for the interactive transaction River opens for an insertion + * without `{ tx }`, such as `{ timeout: { seconds: 10 } }`. Prisma's + * defaults apply otherwise: a 2 second wait for a connection and a 5 + * second transaction timeout, which also bounds any I/O insert middleware + * awaits before calling `next()`. + */ + transactionOptions?: { + maxWait?: DurationInput; + timeout?: DurationInput; + }; } /** - * A River driver for Prisma. - * - * import { PrismaPg } from "@prisma/adapter-pg"; - * import { Client } from "riverqueue"; - * import { PrismaDriver } from "@riverqueue/driver-prisma"; - * import { PrismaClient } from "./generated/prisma/client.js"; - * - * const adapter = new PrismaPg({ - * connectionString: process.env.DATABASE_URL, - * }); - * const prisma = new PrismaClient({ adapter }); - * const client = new Client(new PrismaDriver(prisma)); - * - * For transactions, pass the transaction client as the `tx` option: + * Row shape returned by the insert query. JSON, timestamps, IDs, and bytes + * are selected as text so values are decoded exactly by River rather than + * by Prisma, which parses JSON numbers as doubles. + */ +interface PrismaJobRow extends Record { + args: string; + attempt: number; + attempted_at: string | null; + attempted_by: string[] | null; + created_at: string; + errors: string[] | null; + finalized_at: string | null; + id: string; + kind: string; + max_attempts: number; + metadata: string; + priority: number; + queue: string; + scheduled_at: string; + state: string; + tags: string[] | null; + unique_key: string | null; + unique_skipped_as_duplicate: boolean; + unique_states: string | null; +} + +/** + * River's insertion adapter for Prisma on PostgreSQL. * - * await prisma.$transaction(async (tx) => { - * await client.insert(args, { tx }); - * }); + * The Prisma client is caller-owned. A caller-owned transaction client may be + * supplied as `{ tx }` and is used for the exact operation. Schema selection + * belongs to the adapter constructor so it cannot accidentally vary within a + * transaction. + */ +export class PrismaDriver { + /** + * Type-only marker: an insert-only driver whose transactions are Prisma + * interactive-transaction clients. `new Client(new PrismaDriver(prisma))` + * is therefore typed as an `InsertClient`. + */ + declare readonly "~river"?: { + readonly capability: "insert"; + readonly transaction: PrismaClientLike; + }; + + constructor(prisma: PrismaClientLike, options: PrismaDriverOptions = {}) { + const inserter = new PrismaInserter(prisma, options); + registerDriver(this, { + backend: inserter.backend, + capability: "insert", + operations: inserter, + }); + } +} + +/** + * @internal A registered driver whose operations are callable, for this + * package's tests. */ -export class PrismaDriver implements Driver { - private prisma: PrismaClientLike; +export function testPrismaDriver( + prisma: PrismaClientLike, + options: PrismaDriverOptions = {} +): PrismaInserter { + const inserter = new PrismaInserter(prisma, options); + registerDriver(inserter, { + backend: inserter.backend, + capability: "insert", + operations: inserter, + }); + return inserter; +} + +/** + * @internal The operations behind a {@link PrismaDriver}, which River + * reaches through its private driver registry. + */ +export class PrismaInserter implements InsertDriver { + declare readonly "~river"?: { + readonly capability: "insert"; + readonly transaction: PrismaClientLike; + }; + + /** Identifies this driver's backend in River's errors and diagnostics. */ + readonly backend = "postgres-prisma-insert" as const; + + /** + * The server's capabilities once detected, cached for this driver's + * lifetime like River for Go's drivers. + */ + #capabilities: PostgresCapabilities | undefined; + readonly #prisma: PrismaClientLike; + readonly #qualifiedJobSequence: string; + readonly #schemaName: string | null; + readonly #schemaPrefix: string; + readonly #transactionOptions: PrismaTransactionOptions | undefined; + + constructor(prisma: PrismaClientLike, options: PrismaDriverOptions = {}) { + if (!isPrismaClientLike(prisma)) { + throw new ConfigurationError( + "PrismaDriver requires a Prisma client with $queryRawUnsafe" + ); + } + if (options.schema !== undefined) validateSchema(options.schema); - constructor(prisma: PrismaClientLike) { - this.prisma = prisma; + this.#prisma = prisma; + this.#transactionOptions = transactionOptions(options.transactionOptions); + this.#schemaName = options.schema ?? null; + this.#schemaPrefix = + options.schema === undefined ? "" : `${quoteIdentifier(options.schema)}.`; + this.#qualifiedJobSequence = + options.schema === undefined + ? quoteIdentifier("river_job_id_seq") + : `${quoteIdentifier(options.schema)}.${quoteIdentifier("river_job_id_seq")}`; } + /** + * 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. + */ async jobInsert( params: JobInsertParams, - options?: DriverOptions - ): Promise<[JobRow, boolean]> { + options?: InsertDriverOptions + ): Promise { const results = await this.jobInsertMany([params], options); - return results[0] as [JobRow, boolean]; + const result = results[0]; + if (result === undefined) { + throw new Error("Prisma returned no row for an inserted River job"); + } + return result; } + /** + * 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. + */ async jobInsertMany( - params: JobInsertParams[], - options?: DriverOptions - ): Promise<[JobRow, boolean][]> { + params: readonly JobInsertParams[], + options?: InsertDriverOptions + ): Promise { if (params.length === 0) return []; - const schemaPrefix = options?.schemaPrefix ?? ""; - const COLUMNS_PER_ROW = 10; - const values: unknown[] = []; - const valueClauses: string[] = []; - - for (let i = 0; i < params.length; i++) { - const p = params[i] as JobInsertParams; - const offset = i * COLUMNS_PER_ROW; - valueClauses.push( - `($${offset + 1}::jsonb, $${offset + 2}, $${offset + 3}, $${offset + 4}, ` + - `$${offset + 5}, $${offset + 6}::timestamptz, $${offset + 7}::${schemaPrefix}river_job_state, ` + - `$${offset + 8}::text[], $${offset + 9}::bytea, $${offset + 10}::bit(8))` - ); - values.push( - p.encodedArgs, - p.kind, - p.maxAttempts, - p.priority, - p.queue, - p.scheduledAt, - p.state, - p.tags, - p.uniqueKey ? Buffer.from(p.uniqueKey) : null, - p.uniqueStates - ); - } - + const queryable = options?.tx ?? this.#prisma; + // Without `xmax`, as on YugabyteDB, each row carries a nonce like + // SQLite's, and a returned row without its own nonce already existed. + const { uniqueInsertMode } = await this.#detect(queryable); + const nonces = + uniqueInsertMode === "metadata_nonce" + ? params.map(() => randomBytes(8).toString("hex")) + : null; + const jobTable = this.#name("river_job"); + const stateInBitmask = this.#name("river_job_state_in_bitmask"); + const stateType = this.#name("river_job_state"); const sql = ` - INSERT INTO ${schemaPrefix}river_job ( - args, kind, max_attempts, priority, - queue, scheduled_at, state, - tags, unique_key, unique_states + WITH raw_job_data AS ( + SELECT + input_order, args, coalesce(created_at, now()) AS created_at, + kind, max_attempts, metadata, priority, queue, + coalesce(scheduled_at, now()) AS scheduled_at, + state_text AS state, + ARRAY(SELECT jsonb_array_elements_text(tags_json)) AS tags, + CASE WHEN unique_key_hex IS NULL THEN NULL + ELSE decode(unique_key_hex, 'hex') END AS unique_key, + unique_states_text::bit(8) AS unique_states + FROM unnest( + $1::jsonb[], $2::text[], $3::smallint[], $4::jsonb[], + $5::smallint[], $6::text[], $7::timestamptz[], $8::text[], + $9::jsonb[], $10::text[], $11::text[], $13::timestamptz[] + ) WITH ORDINALITY AS input( + args, kind, max_attempts, metadata, priority, queue, + scheduled_at, state_text, tags_json, unique_key_hex, + unique_states_text, created_at, input_order + ) + ), + normalized_job_data AS ( + SELECT + *, + unique_key IS NOT NULL + AND unique_states IS NOT NULL + AND ${stateInBitmask}(unique_states, state::${stateType}) + AS is_unique + FROM raw_job_data + ), + prepared_job_data AS ( + SELECT + *, + nextval($12::regclass) AS proposed_id + FROM normalized_job_data + ), + inserted_jobs AS ( + INSERT INTO ${jobTable} ( + id, args, created_at, kind, max_attempts, metadata, priority, + queue, scheduled_at, state, tags, unique_key, unique_states + ) + SELECT + proposed_id, args, created_at, kind, max_attempts, metadata, + priority, queue, scheduled_at, state::${stateType}, tags, + unique_key, unique_states + FROM prepared_job_data + ORDER BY input_order + ON CONFLICT (unique_key) + WHERE unique_key IS NOT NULL + AND unique_states IS NOT NULL + AND ${stateInBitmask}(unique_states, state) + DO UPDATE SET kind = river_job.kind + RETURNING *, ${uniqueInsertConflictSql(uniqueInsertMode)} AS conflicted ) - VALUES ${valueClauses.join(", ")} - ON CONFLICT (unique_key) - WHERE unique_key IS NOT NULL - AND unique_states IS NOT NULL - AND ${schemaPrefix}river_job_state_in_bitmask(unique_states, state) - DO UPDATE SET kind = EXCLUDED.kind - RETURNING *, (xmax != 0) AS unique_skipped_as_duplicate + SELECT + inserted_jobs.id::text AS id, + inserted_jobs.args::text AS args, + inserted_jobs.attempt, + ${utcText("inserted_jobs.attempted_at")} AS attempted_at, + inserted_jobs.attempted_by, + ${utcText("inserted_jobs.created_at")} AS created_at, + inserted_jobs.errors::text[] AS errors, + ${utcText("inserted_jobs.finalized_at")} AS finalized_at, + inserted_jobs.kind, + inserted_jobs.max_attempts, + inserted_jobs.metadata::text AS metadata, + inserted_jobs.priority, + inserted_jobs.queue, + ${utcText("inserted_jobs.scheduled_at")} AS scheduled_at, + inserted_jobs.state::text AS state, + inserted_jobs.tags, + encode(inserted_jobs.unique_key, 'hex') AS unique_key, + inserted_jobs.unique_states::text AS unique_states, + inserted_jobs.conflicted AS unique_skipped_as_duplicate + FROM prepared_job_data + JOIN inserted_jobs ON CASE + WHEN prepared_job_data.is_unique THEN + inserted_jobs.unique_key = prepared_job_data.unique_key + AND inserted_jobs.unique_states IS NOT NULL + AND ${stateInBitmask}(inserted_jobs.unique_states, inserted_jobs.state) + ELSE inserted_jobs.id = prepared_job_data.proposed_id + END + ORDER BY prepared_job_data.input_order `; - const queryable = options?.tx ?? this.prisma; - const rows = await queryable.$queryRawUnsafe[]>( + const rows = await queryable.$queryRawUnsafe( sql, - ...values + params.map(({ encodedArgs }) => encodedArgs), + params.map(({ kind }) => kind), + params.map(({ maxAttempts }) => maxAttempts), + params.map(({ metadata }, index) => + JSON.stringify( + nonces === null + ? metadata + : { ...metadata, [UNIQUE_INSERT_NONCE_KEY]: nonces[index] } + ) + ), + params.map(({ priority }) => priority), + params.map(({ queue }) => queue), + params.map(({ scheduledAt }) => + scheduledAt === undefined ? null : postgresTimestamp(scheduledAt) + ), + params.map(({ state }) => state), + params.map(({ tags }) => JSON.stringify(tags)), + params.map(({ uniqueKey }) => + uniqueKey === null ? null : Buffer.from(uniqueKey).toString("hex") + ), + params.map(({ uniqueStates }) => + uniqueStates === null ? null : uniqueBitmaskFromStates(uniqueStates) + ), + this.#qualifiedJobSequence, + params.map(({ createdAt }) => + createdAt === undefined ? null : postgresTimestamp(createdAt) + ) + ); + if (rows.length !== params.length) { + throw new Error( + `Prisma returned ${rows.length} rows for ${params.length} River inserts` + ); + } + return rows.map((row, index) => { + const job = toJobRow(row); + const duplicate = + nonces === null + ? row.unique_skipped_as_duplicate + : job.metadata[UNIQUE_INSERT_NONCE_KEY] !== nonces[index]; + return { job, status: duplicate ? "duplicate" : "inserted" }; + }); + } + + /** + * Notify producers of new jobs in each of `queues`. NOTIFY is + * transactional, so in a caller-owned transaction it's delivered only on + * commit. + * + * 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. + */ + async notifyInsert( + queues: readonly string[], + options?: InsertDriverOptions + ): Promise { + if (queues.length === 0) return; + const queryable = options?.tx ?? this.#prisma; + // A server without LISTEN/NOTIFY, like YugabyteDB by default, gets none. + if (!(await this.#detect(queryable)).supportsListenNotify) return; + // Counting the notifications' rows makes PostgreSQL send each one while + // returning no `void` column for Prisma to decode. + await queryable.$queryRawUnsafe( + ` + WITH notifications AS ( + SELECT pg_notify( + concat(coalesce($1::text, current_schema()), '.', 'river_insert'), + concat('{"queue": ', to_json(queue)::text, '}') + ) + FROM unnest($2::text[]) AS queue + ) + SELECT count(*)::int AS notified FROM notifications + `, + this.#schemaName, + queues + ); + } + + /** + * Run one River operation in a transaction, like River for Go's + * `dbutil.WithTxV`. With `tx`, the operation joins that caller-owned + * transaction client. Otherwise it runs in an interactive transaction on + * the root Prisma client, which commits when `callback` resolves and rolls + * back when it rejects. + * + * @internal + */ + async operationScope( + tx: PrismaClientLike | undefined, + callback: (tx: PrismaClientLike) => Promise + ): Promise { + if (tx !== undefined) return callback(tx); + const prisma = this.#prisma; + if (typeof prisma.$transaction !== "function") { + throw new ConfigurationError( + "the Prisma client given to PrismaDriver has no $transaction, so " + + "River can't open a transaction of its own for this operation; " + + "pass { tx } or construct PrismaDriver with a root PrismaClient" + ); + } + const run = (transaction: PrismaClientLike): Promise => + callback(transaction); + return this.#transactionOptions === undefined + ? prisma.$transaction(run) + : prisma.$transaction(run, this.#transactionOptions); + } + + /** + * The server's capabilities, detected with `queryable` the first time. + * Concurrent first callers may each detect; the first result stored wins. + */ + async #detect(queryable: PrismaClientLike): Promise { + if (this.#capabilities !== undefined) return this.#capabilities; + const [row] = await queryable.$queryRawUnsafe< + { + product: unknown; + version_num: unknown; + yb_listen_notify_enabled: unknown; + }[] + >(POSTGRES_CAPABILITIES_SQL); + if (row === undefined) { + throw new Error("PostgreSQL returned no server capabilities"); + } + this.#capabilities ??= postgresCapabilitiesFromRow(row); + return this.#capabilities; + } + + #name(value: string): string { + return `${this.#schemaPrefix}${quoteIdentifier(value)}`; + } +} + +function transactionOptions( + options: PrismaDriverOptions["transactionOptions"] +): PrismaTransactionOptions | undefined { + if (options === undefined) return undefined; + const result: PrismaTransactionOptions = {}; + if (options.maxWait !== undefined) { + result.maxWait = durationToMilliseconds( + "transactionOptions.maxWait", + options.maxWait ); - return rows.map((row) => this.toInsertResult(row)); } + if (options.timeout !== undefined) { + result.timeout = durationToMilliseconds( + "transactionOptions.timeout", + options.timeout + ); + } + return result; +} + +function isPrismaClientLike(value: unknown): value is PrismaClientLike { + return ( + (typeof value === "object" || typeof value === "function") && + value !== null && + "$queryRawUnsafe" in value && + typeof value.$queryRawUnsafe === "function" + ); +} - private toInsertResult(row: Record): [JobRow, boolean] { - return [this.toJobRow(row), row.unique_skipped_as_duplicate as boolean]; +/** Parse a timestamp selected through {@link utcText}. */ +function parseUtcInstant(value: string): Temporal.Instant { + return Temporal.Instant.from(value); +} + +/** + * Decode an inserted row exactly. Every value arrives as text chosen by the + * query, so decoding cannot lose precision: an integer beyond 2^53 in args, + * metadata, or errors stays exact instead of making a committed insert + * throw. + */ +function toJobRow(row: PrismaJobRow): JobRow { + const state = decodeJobState(row.state); + if (!/^-?(0|[1-9]\d*)$/.test(row.id)) { + throw new TypeError(`invalid River job ID: ${JSON.stringify(row.id)}`); } - private toJobRow(row: Record): JobRow { - return { - // Prisma returns BigInt for bigint columns. - id: Number(row.id), - args: row.args as Record, - attempt: Number(row.attempt), - attemptedAt: (row.attempted_at as Date) ?? null, - attemptedBy: (row.attempted_by as string[]) ?? null, - createdAt: row.created_at as Date, - errors: row.errors - ? (row.errors as Record[]).map((e): AttemptError => ({ - at: new Date(e.at as string), - attempt: e.attempt as number, - error: e.error as string, - trace: e.trace as string, - })) - : null, - finalizedAt: (row.finalized_at as Date) ?? null, - kind: row.kind as string, - maxAttempts: Number(row.max_attempts), - metadata: row.metadata as Record, - priority: Number(row.priority), - queue: row.queue as string, - scheduledAt: row.scheduled_at as Date, - state: row.state as JobState, - tags: (row.tags as string[]) ?? [], - uniqueKey: row.unique_key - ? new Uint8Array(row.unique_key as Buffer) - : null, - uniqueStates: row.unique_states - ? uniqueBitmaskToStates(parseInt(row.unique_states as string, 2)) - : null, - }; + return { + args: parseJsonObject(row.args), + attempt: row.attempt, + attemptedAt: + row.attempted_at === null ? null : parseUtcInstant(row.attempted_at), + attemptedBy: row.attempted_by ?? [], + createdAt: parseUtcInstant(row.created_at), + errors: (row.errors ?? []).map(decodeAttemptError), + finalizedAt: + row.finalized_at === null ? null : parseUtcInstant(row.finalized_at), + id: BigInt(row.id), + kind: row.kind, + maxAttempts: row.max_attempts, + metadata: parseJsonObject(row.metadata), + priority: row.priority, + queue: row.queue, + scheduledAt: parseUtcInstant(row.scheduled_at), + state, + tags: row.tags ?? [], + uniqueKey: + row.unique_key === null + ? null + : new Uint8Array(Buffer.from(row.unique_key, "hex")), + uniqueStates: + row.unique_states === null + ? null + : uniqueBitmaskToStates(Number.parseInt(row.unique_states, 2)), + }; +} + +/** + * Select a timestamptz as UTC ISO 8601 text with microseconds, independent of + * the session's `DateStyle` and `TimeZone`. + */ +function utcText(column: string): string { + return `to_char(${column} AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`; +} + +function validateSchema(value: string): void { + if (!RIVER_SCHEMA_RE.test(value)) { + throw new TypeError( + "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 new TypeError( + `PostgreSQL schema must not exceed ${RIVER_SCHEMA_MAX_BYTES} bytes so River notification topics remain valid` + ); } } diff --git a/js/driver/prisma/src/index.ts b/js/driver/prisma/src/index.ts index afc60c98d..434fe0132 100644 --- a/js/driver/prisma/src/index.ts +++ b/js/driver/prisma/src/index.ts @@ -1,2 +1,15 @@ +import type { PrismaClientLike } from "./driver.js"; + export { PrismaDriver } from "./driver.js"; -export type { PrismaClientLike } from "./driver.js"; +export type { + PrismaClientLike, + PrismaDriverOptions, + PrismaTransactionOptions, +} from "./driver.js"; + +declare module "riverqueue" { + interface RiverTransactionRegistry { + /** Prisma interactive-transaction clients. */ + "@riverqueue/driver-prisma": PrismaClientLike; + } +} diff --git a/js/driver/prisma/tsconfig.json b/js/driver/prisma/tsconfig.json index 5285d28af..88c94deed 100644 --- a/js/driver/prisma/tsconfig.json +++ b/js/driver/prisma/tsconfig.json @@ -4,5 +4,6 @@ "rootDir": "src", "outDir": "dist" }, + "exclude": ["src/**/*.integration.test.ts", "src/**/*.test.ts"], "include": ["src"] } diff --git a/js/eslint.config.js b/js/eslint.config.js index d90f7277f..809aaa704 100644 --- a/js/eslint.config.js +++ b/js/eslint.config.js @@ -3,16 +3,103 @@ import tseslint from "typescript-eslint"; import eslintConfigPrettier from "eslint-config-prettier"; export default tseslint.config( + { + ignores: ["**/dist/"], + }, eslint.configs.recommended, - ...tseslint.configs.strict, + ...tseslint.configs.strictTypeChecked, eslintConfigPrettier, { - ignores: ["**/dist/"], + languageOptions: { + parserOptions: { + // One program over every package and its tests, the same one + // `typecheck:tests` checks. The project service would instead pick + // each package's build tsconfig, which excludes test files. Like that + // typecheck, it reads `riverqueue` through its built declarations, so + // run `pnpm run build` before linting. + project: "./tsconfig.tests.json", + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + { + linterOptions: { + reportUnusedDisableDirectives: "error", + }, + rules: { + // Concise arrow callbacks such as `() => resolve()` are idiomatic. + "@typescript-eslint/no-confusing-void-expression": [ + "error", + { ignoreArrowShorthand: true }, + ], + // Allow `while (true)` retry loops. Validation of values from untyped + // JavaScript callers and signal checks after an `await` are disabled + // line by line with a reason, since the compiler's narrowing cannot see + // either. + "@typescript-eslint/no-unnecessary-condition": [ + "error", + { allowConstantLoopConditions: "only-allowed-literals" }, + ], + // River propagates caller-supplied abort reasons and caught failures + // unchanged so callers observe the exact value they supplied or that a + // handler threw; wrapping them in a new Error would change identity. + "@typescript-eslint/only-throw-error": [ + "error", + { + allowRethrowing: true, + allowThrowingAny: true, + allowThrowingUnknown: true, + }, + ], + "@typescript-eslint/prefer-promise-reject-errors": [ + "error", + { allowThrowingAny: true, allowThrowingUnknown: true }, + ], + // `async` deliberately turns synchronous throws into rejections for + // Promise-returning interfaces, even when nothing inside is awaited. + "@typescript-eslint/require-await": "off", + // Numbers and bigints format predictably in messages. + "@typescript-eslint/restrict-template-expressions": [ + "error", + { allowNumber: true }, + ], + }, + }, + { + files: ["src/**/*.ts", "driver/*/src/**/*.ts"], + rules: { + // Node cleans up `AbortSignal.any` dependents in time quadratic in a + // long-lived parent's dependents, which stalls the event loop for + // seconds under load. + "no-restricted-properties": [ + "error", + { + message: + "use LinkedAbortSignal and dispose it when the operation settles", + object: "AbortSignal", + property: "any", + }, + ], + }, + }, + { + files: ["**/*.mjs", "examples/**"], + ...tseslint.configs.disableTypeChecked, }, { files: ["**/*.test.ts"], rules: { "@typescript-eslint/no-non-null-assertion": "off", + // Tests inspect untyped `pg` rows, `JSON.parse` output, and Vitest's + // asymmetric matchers (`expect.any`, `expect.objectContaining`), all of + // which are typed `any`, and assert on detached `vi.fn()` mocks. + "@typescript-eslint/no-unsafe-argument": "off", + "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-unsafe-call": "off", + "@typescript-eslint/no-unsafe-member-access": "off", + "@typescript-eslint/no-unsafe-return": "off", + "@typescript-eslint/unbound-method": "off", + "no-restricted-properties": "off", }, } ); diff --git a/js/examples/node-postgres/README.md b/js/examples/node-postgres/README.md index 1b83f5cdd..689e9a34d 100644 --- a/js/examples/node-postgres/README.md +++ b/js/examples/node-postgres/README.md @@ -2,11 +2,13 @@ A minimal example demonstrating how to use the [River](https://github.com/riverqueue/river/tree/master/js) TypeScript client with `node-postgres` (`pg`) to insert background jobs into PostgreSQL. -The example defines two job types (`SortArgs` and `SendEmailArgs`) and shows single job insertion, insertion with scheduling options, and batch insertion. +The example defines typed jobs and shows single insertion, scheduling, batch +insertion, and an explicit caller-owned PostgreSQL transaction that commits an +application row and its River job atomically. ## Prerequisites -- Node.js >= 18 +- Node.js >= 26 - pnpm - PostgreSQL with [River's schema](https://riverqueue.com/docs) migrated diff --git a/js/examples/node-postgres/package.json b/js/examples/node-postgres/package.json index e0851d1af..2434a10e0 100644 --- a/js/examples/node-postgres/package.json +++ b/js/examples/node-postgres/package.json @@ -3,17 +3,22 @@ "version": "0.0.0", "private": true, "type": "module", + "engines": { + "node": ">=26" + }, "scripts": { "build": "tsc", "start": "node dist/index.js" }, "dependencies": { - "riverqueue": "workspace:*", "@riverqueue/driver-pg": "workspace:*", - "pg": "^8.22.0" + "pg": "^8.22.0", + "riverqueue": "workspace:*", + "zod": "^4.6.5" }, "devDependencies": { + "@types/node": "^26.1.1", "@types/pg": "^8.11.0", - "typescript": "^5.8.0" + "typescript": "^6.0.3" } } diff --git a/js/examples/node-postgres/src/index.ts b/js/examples/node-postgres/src/index.ts index d57ae939b..2b1f53115 100644 --- a/js/examples/node-postgres/src/index.ts +++ b/js/examples/node-postgres/src/index.ts @@ -1,85 +1,94 @@ -import { Pool } from "pg"; -import { Client, InsertManyParams } from "riverqueue"; -import type { JobArgs, InsertOpts } from "riverqueue"; -import { PgDriver } from "@riverqueue/driver-pg"; - -// Define a job that sorts strings. `kind` uniquely identifies the job type and -// must match the worker name on the Go side. -class SortArgs implements JobArgs { - kind = "sort"; - - strings: string[]; +import { randomUUID } from "node:crypto"; - constructor(strings: string[]) { - this.strings = strings; - } - - toJSON() { - return { strings: this.strings }; - } -} +import { PgDriver } from "@riverqueue/driver-pg"; +import { Pool } from "pg"; +import { Client, defineJob } from "riverqueue"; +import { z } from "zod"; -// A job with default insert options baked in. -class SendEmailArgs implements JobArgs { - kind = "send_email"; +const sort = defineJob<{ strings: string[] }>()({ kind: "sort" }); +const syncAccount = defineJob({ + kind: "sync_account", + schema: z.object({ accountId: z.string().min(1) }), +}); - insertOpts: InsertOpts = { +const sendEmail = defineJob<{ + body: string; + subject: string; + to: string; +}>()({ + defaults: { maxAttempts: 5, - queue: "email", priority: 2, - }; - - to: string; - subject: string; - body: string; - - constructor(to: string, subject: string, body: string) { - this.to = to; - this.subject = subject; - this.body = body; - } - - toJSON() { - return { to: this.to, subject: this.subject, body: this.body }; - } -} + queue: "email", + }, + kind: "send_email", +}); async function main() { const pool = new Pool({ connectionString: process.env.DATABASE_URL ?? "postgres://localhost:5432/river_dev", }); - const client = new Client(new PgDriver(pool)); - // Insert a single job. - const sortResult = await client.insert( - new SortArgs(["whale", "tiger", "bear"]) - ); + const sortResult = await client.insert(sort, { + strings: ["whale", "tiger", "bear"], + }); console.log(`Inserted sort job with ID: ${sortResult.job.id}`); - // Insert with options, scheduling for 1 hour in the future. const emailResult = await client.insert( - new SendEmailArgs("user@example.com", "Hello", "Welcome aboard!"), - { scheduledAt: new Date(Date.now() + 60 * 60 * 1000) } + sendEmail, + { + body: "Welcome aboard!", + subject: "Hello", + to: "user@example.com", + }, + { + scheduledAt: Temporal.Now.instant().add({ hours: 1 }), + } ); console.log( - `Inserted email job with ID: ${emailResult.job.id}, scheduled for: ${emailResult.job.scheduledAt}` + `Inserted email job with ID: ${emailResult.job.id}, ` + + `scheduled for: ${emailResult.job.scheduledAt}` ); - // Insert many jobs at once. const batchResults = await client.insertMany([ - new SortArgs(["alpha", "gamma", "beta"]), - new InsertManyParams(new SortArgs(["one", "two", "three"]), { - priority: 3, - }), + { args: { strings: ["alpha", "gamma", "beta"] }, job: sort }, + { + args: { strings: ["one", "two", "three"] }, + job: sort, + options: { priority: 3 }, + }, ]); console.log(`Batch inserted ${batchResults.length} jobs`); + await pool.query(` + CREATE TABLE IF NOT EXISTS riverqueue_example_account ( + id text PRIMARY KEY, + created_at timestamptz NOT NULL DEFAULT now() + ) + `); + const accountId = `pg_${randomUUID()}`; + const tx = await pool.connect(); + try { + await tx.query("BEGIN"); + await tx.query("INSERT INTO riverqueue_example_account (id) VALUES ($1)", [ + accountId, + ]); + await client.insert(syncAccount, { accountId }, { tx }); + await tx.query("COMMIT"); + console.log(`Committed account ${accountId} and its job atomically`); + } catch (error) { + await tx.query("ROLLBACK"); + throw error; + } finally { + tx.release(); + } + await pool.end(); } -main().catch((err) => { - console.error(err); - process.exit(1); +main().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; }); diff --git a/js/examples/node-postgres/tsconfig.json b/js/examples/node-postgres/tsconfig.json index 657f341e8..e46006227 100644 --- a/js/examples/node-postgres/tsconfig.json +++ b/js/examples/node-postgres/tsconfig.json @@ -1,14 +1,8 @@ { + "extends": "../tsconfig.json", "compilerOptions": { - "target": "ES2022", - "module": "Node16", - "moduleResolution": "Node16", "outDir": "dist", - "rootDir": "src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true + "rootDir": "src" }, "include": ["src"] } diff --git a/js/examples/prisma/README.md b/js/examples/prisma/README.md index 54776821d..08e19fa5e 100644 --- a/js/examples/prisma/README.md +++ b/js/examples/prisma/README.md @@ -2,11 +2,13 @@ A minimal example demonstrating how to use the [River](https://github.com/riverqueue/river/tree/master/js) TypeScript client with [Prisma](https://www.prisma.io/) to insert background jobs into PostgreSQL. -The example defines two job types (`SortArgs` and `SendEmailArgs`) and shows single job insertion, insertion with scheduling options, and batch insertion. +The example defines typed jobs and shows single insertion, scheduling, batch +insertion, and a caller-owned Prisma interactive transaction that commits an +application row and its River job atomically. ## Prerequisites -- Node.js ^20.19, ^22.12, or >= 24 +- Node.js >= 26 - pnpm - PostgreSQL with [River's schema](https://riverqueue.com/docs) migrated diff --git a/js/examples/prisma/package.json b/js/examples/prisma/package.json index 134b83aa7..35af42adc 100644 --- a/js/examples/prisma/package.json +++ b/js/examples/prisma/package.json @@ -3,6 +3,9 @@ "version": "0.0.0", "private": true, "type": "module", + "engines": { + "node": ">=26" + }, "scripts": { "generate": "prisma generate", "prebuild": "prisma generate", @@ -10,13 +13,15 @@ "start": "node dist/index.js" }, "dependencies": { - "riverqueue": "workspace:*", - "@riverqueue/driver-prisma": "workspace:*", "@prisma/adapter-pg": "7.9.1", - "@prisma/client": "7.9.1" + "@prisma/client": "7.9.1", + "@riverqueue/driver-prisma": "workspace:*", + "riverqueue": "workspace:*", + "zod": "^4.6.5" }, "devDependencies": { + "@types/node": "^26.1.1", "prisma": "7.9.1", - "typescript": "^5.8.0" + "typescript": "^6.0.3" } } diff --git a/js/examples/prisma/src/index.ts b/js/examples/prisma/src/index.ts index 2c2d423bf..363062ca8 100644 --- a/js/examples/prisma/src/index.ts +++ b/js/examples/prisma/src/index.ts @@ -1,86 +1,88 @@ +import { randomUUID } from "node:crypto"; + import { PrismaPg } from "@prisma/adapter-pg"; -import { Client, InsertManyParams } from "riverqueue"; -import type { JobArgs, InsertOpts } from "riverqueue"; import { PrismaDriver } from "@riverqueue/driver-prisma"; +import { Client, defineJob } from "riverqueue"; import { PrismaClient } from "./generated/prisma/client.js"; +import { z } from "zod"; -// Define a job that sorts strings. `kind` uniquely identifies the job type and -// must match the worker name on the Go side. -class SortArgs implements JobArgs { - kind = "sort"; - - strings: string[]; - - constructor(strings: string[]) { - this.strings = strings; - } - - toJSON() { - return { strings: this.strings }; - } -} - -// A job with default insert options baked in. -class SendEmailArgs implements JobArgs { - kind = "send_email"; +const sort = defineJob<{ strings: string[] }>()({ kind: "sort" }); +const syncAccount = defineJob({ + kind: "sync_account", + schema: z.object({ accountId: z.string().min(1) }), +}); - insertOpts: InsertOpts = { +const sendEmail = defineJob<{ + body: string; + subject: string; + to: string; +}>()({ + defaults: { maxAttempts: 5, - queue: "email", priority: 2, - }; - - to: string; - subject: string; - body: string; - - constructor(to: string, subject: string, body: string) { - this.to = to; - this.subject = subject; - this.body = body; - } - - toJSON() { - return { to: this.to, subject: this.subject, body: this.body }; - } -} + queue: "email", + }, + kind: "send_email", +}); async function main() { const connectionString = process.env.DATABASE_URL ?? "postgres://localhost:5432/river_dev"; - const adapter = new PrismaPg({ connectionString }); - const prisma = new PrismaClient({ adapter }); - + const prisma = new PrismaClient({ + adapter: new PrismaPg({ connectionString }), + }); const client = new Client(new PrismaDriver(prisma)); - // Insert a single job. - const sortResult = await client.insert( - new SortArgs(["whale", "tiger", "bear"]) - ); + const sortResult = await client.insert(sort, { + strings: ["whale", "tiger", "bear"], + }); console.log(`Inserted sort job with ID: ${sortResult.job.id}`); - // Insert with options, scheduling for 1 hour in the future. const emailResult = await client.insert( - new SendEmailArgs("user@example.com", "Hello", "Welcome aboard!"), - { scheduledAt: new Date(Date.now() + 60 * 60 * 1000) } + sendEmail, + { + body: "Welcome aboard!", + subject: "Hello", + to: "user@example.com", + }, + { + scheduledAt: Temporal.Now.instant().add({ hours: 1 }), + } ); console.log( - `Inserted email job with ID: ${emailResult.job.id}, scheduled for: ${emailResult.job.scheduledAt}` + `Inserted email job with ID: ${emailResult.job.id}, ` + + `scheduled for: ${emailResult.job.scheduledAt}` ); - // Insert many jobs at once. const batchResults = await client.insertMany([ - new SortArgs(["alpha", "gamma", "beta"]), - new InsertManyParams(new SortArgs(["one", "two", "three"]), { - priority: 3, - }), + { args: { strings: ["alpha", "gamma", "beta"] }, job: sort }, + { + args: { strings: ["one", "two", "three"] }, + job: sort, + options: { priority: 3 }, + }, ]); console.log(`Batch inserted ${batchResults.length} jobs`); + await prisma.$executeRaw` + CREATE TABLE IF NOT EXISTS riverqueue_example_account ( + id text PRIMARY KEY, + created_at timestamptz NOT NULL DEFAULT now() + ) + `; + const accountId = `prisma_${randomUUID()}`; + await prisma.$transaction(async (tx) => { + await tx.$executeRaw` + INSERT INTO riverqueue_example_account (id) VALUES (${accountId}) + `; + await client.insert(syncAccount, { accountId }, { tx }); + }); + console.log(`Committed account ${accountId} and its job atomically`); + await prisma.$disconnect(); } -main().catch((err) => { - console.error(err); - process.exit(1); +main().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; }); diff --git a/js/examples/prisma/tsconfig.json b/js/examples/prisma/tsconfig.json index 657f341e8..e46006227 100644 --- a/js/examples/prisma/tsconfig.json +++ b/js/examples/prisma/tsconfig.json @@ -1,14 +1,8 @@ { + "extends": "../tsconfig.json", "compilerOptions": { - "target": "ES2022", - "module": "Node16", - "moduleResolution": "Node16", "outDir": "dist", - "rootDir": "src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true + "rootDir": "src" }, "include": ["src"] } diff --git a/js/examples/tsconfig.json b/js/examples/tsconfig.json new file mode 100644 index 000000000..f6aba7037 --- /dev/null +++ b/js/examples/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "declaration": false, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "inlineSources": true, + "lib": ["ES2024", "ESNext.Temporal"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noImplicitOverride": true, + "noUncheckedIndexedAccess": true, + "noUncheckedSideEffectImports": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "target": "ES2024", + "types": ["node"], + "useUnknownInCatchVariables": true, + "verbatimModuleSyntax": true + } +} diff --git a/js/package.json b/js/package.json index 23eff1678..2fbb194c2 100644 --- a/js/package.json +++ b/js/package.json @@ -1,14 +1,21 @@ { "name": "riverqueue", - "version": "0.1.0", - "description": "TypeScript client for River, a fast and reliable background job framework for PostgreSQL.", + "version": "0.50.0-alpha.1", + "description": "TypeScript implementation of River, a fast and reliable background job system.", "type": "module", + "sideEffects": false, "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", - "import": "./dist/index.js" + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./unstable-driver": { + "types": "./dist/unstable-driver.d.ts", + "import": "./dist/unstable-driver.js", + "default": "./dist/unstable-driver.js" } }, "engines": { @@ -22,10 +29,10 @@ "build:all": "pnpm run build && pnpm --filter='./driver/*' run build", "clean": "rm -rf dist", "clean:all": "pnpm run clean && pnpm --filter='./driver/*' run clean", - "fmt": "prettier --write 'src/**/*.ts' 'driver/**/src/**/*.ts' 'scripts/**/*.mjs'", - "fmt:check": "prettier --check 'src/**/*.ts' 'driver/**/src/**/*.ts' 'scripts/**/*.mjs'", - "lint": "eslint 'src/**/*.ts' 'driver/**/src/**/*.ts' 'scripts/**/*.mjs'", - "lint:fix": "eslint --fix 'src/**/*.ts' 'driver/**/src/**/*.ts' 'scripts/**/*.mjs'", + "fmt": "prettier --write 'src/**/*.ts' 'driver/**/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts' 'examples/*/{README.md,package.json,tsconfig.json}' 'examples/tsconfig.json' '{README.md,package.json}'", + "fmt:check": "prettier --check 'src/**/*.ts' 'driver/**/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts' 'examples/*/{README.md,package.json,tsconfig.json}' 'examples/tsconfig.json' '{README.md,package.json}'", + "lint": "eslint 'src/**/*.ts' 'driver/**/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts'", + "lint:fix": "eslint --fix 'src/**/*.ts' 'driver/**/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts'", "prepublishOnly": "pnpm run clean && pnpm run build", "test": "vitest run --passWithNoTests", "test:coverage": "vitest run --coverage", @@ -46,6 +53,14 @@ "url": "https://github.com/riverqueue/river/issues" }, "homepage": "https://github.com/riverqueue/river/tree/master/js#readme", + "peerDependencies": { + "@types/node": ">=26" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + }, "devDependencies": { "@eslint/js": "^10.0.1", "@types/node": "^26.1.1", @@ -59,8 +74,10 @@ "prettier": "^3.9.6", "typescript": "^6.0.3", "typescript-eslint": "^8.65.0", + "valibot": "^1.5.0", "vite": "^8.0.16", - "vitest": "^4.1.11" + "vitest": "^4.1.11", + "zod": "^4.6.5" }, "packageManager": "pnpm@10.22.0", "pnpm": { diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml index 1d2d04450..dff60e088 100644 --- a/js/pnpm-lock.yaml +++ b/js/pnpm-lock.yaml @@ -49,41 +49,52 @@ importers: typescript-eslint: specifier: ^8.65.0 version: 8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) + valibot: + specifier: ^1.5.0 + version: 1.5.0(typescript@6.0.3) vite: specifier: ^8.0.16 version: 8.0.16(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.1) vitest: specifier: ^4.1.11 version: 4.1.11(@types/node@26.1.1)(@vitest/coverage-v8@4.1.11)(vite@8.0.16(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.1)) + zod: + specifier: ^4.6.5 + version: 4.6.5 driver/pg: dependencies: - riverqueue: - specifier: workspace:* - version: link:../.. + postgres-array: + specifier: ^3.0.4 + version: 3.0.4 devDependencies: '@types/pg': - specifier: ^8.11.0 + specifier: ^8.20.0 version: 8.20.0 pg: specifier: ^8.22.0 version: 8.22.0 + riverqueue: + specifier: workspace:0.50.0-alpha.1 + version: link:../.. typescript: specifier: ^6.0.3 version: 6.0.3 driver/prisma: - dependencies: - riverqueue: - specifier: workspace:* - version: link:../.. devDependencies: + '@prisma/adapter-pg': + specifier: 7.9.1 + version: 7.9.1 '@prisma/client': specifier: 7.9.1 version: 7.9.1(prisma@7.9.1(@types/node@26.1.1)(@types/react@19.2.18)(magicast@0.5.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3) prisma: specifier: 7.9.1 version: 7.9.1(@types/node@26.1.1)(@types/react@19.2.18)(magicast@0.5.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3) + riverqueue: + specifier: workspace:0.50.0-alpha.1 + version: link:../.. typescript: specifier: ^6.0.3 version: 6.0.3 @@ -99,13 +110,19 @@ importers: riverqueue: specifier: workspace:* version: link:../.. + zod: + specifier: ^4.6.5 + version: 4.6.5 devDependencies: + '@types/node': + specifier: ^26.1.1 + version: 26.1.1 '@types/pg': specifier: ^8.11.0 version: 8.20.0 typescript: - specifier: ^5.8.0 - version: 5.9.3 + specifier: ^6.0.3 + version: 6.0.3 examples/prisma: dependencies: @@ -114,20 +131,26 @@ importers: version: 7.9.1 '@prisma/client': specifier: 7.9.1 - version: 7.9.1(prisma@7.9.1(@types/node@26.1.1)(@types/react@19.2.18)(magicast@0.5.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3))(typescript@5.9.3) + version: 7.9.1(prisma@7.9.1(@types/node@26.1.1)(@types/react@19.2.18)(magicast@0.5.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3) '@riverqueue/driver-prisma': specifier: workspace:* version: link:../../driver/prisma riverqueue: specifier: workspace:* version: link:../.. + zod: + specifier: ^4.6.5 + version: 4.6.5 devDependencies: + '@types/node': + specifier: ^26.1.1 + version: 26.1.1 prisma: specifier: 7.9.1 - version: 7.9.1(@types/node@26.1.1)(@types/react@19.2.18)(magicast@0.5.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3) + version: 7.9.1(@types/node@26.1.1)(@types/react@19.2.18)(magicast@0.5.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3) typescript: - specifier: ^5.8.0 - version: 5.9.3 + specifier: ^6.0.3 + version: 6.0.3 packages: @@ -1522,11 +1545,6 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} @@ -1546,6 +1564,14 @@ packages: typescript: optional: true + valibot@1.5.0: + resolution: {integrity: sha512-nil6AkP2TChWL43Z5uJ6GTxX01CUA+g8LWUM+N/rB9NBbkUMaUsi9PUNzlUPgoKASgmx9f7eGOYpJ04/fSa6FQ==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + vite@8.0.16: resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1660,6 +1686,9 @@ packages: zeptomatch@2.1.0: resolution: {integrity: sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==} + zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} + snapshots: '@babel/helper-string-parser@7.29.7': {} @@ -1784,13 +1813,6 @@ snapshots: '@prisma/client-runtime-utils@7.9.1': {} - '@prisma/client@7.9.1(prisma@7.9.1(@types/node@26.1.1)(@types/react@19.2.18)(magicast@0.5.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3))(typescript@5.9.3)': - dependencies: - '@prisma/client-runtime-utils': 7.9.1 - optionalDependencies: - prisma: 7.9.1(@types/node@26.1.1)(@types/react@19.2.18)(magicast@0.5.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3) - typescript: 5.9.3 - '@prisma/client@7.9.1(prisma@7.9.1(@types/node@26.1.1)(@types/react@19.2.18)(magicast@0.5.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3)': dependencies: '@prisma/client-runtime-utils': 7.9.1 @@ -1811,26 +1833,6 @@ snapshots: '@prisma/debug@7.9.1': {} - '@prisma/dev@0.24.17(typescript@5.9.3)': - dependencies: - '@electric-sql/pglite': 0.4.3 - '@electric-sql/pglite-socket': 0.1.3(@electric-sql/pglite@0.4.3) - '@electric-sql/pglite-tools': 0.3.3(@electric-sql/pglite@0.4.3) - '@prisma/get-platform': 7.2.0 - '@prisma/query-plan-executor': 7.2.0 - '@prisma/streams-local': 0.1.11 - find-my-way: 9.7.0 - foreground-child: 3.3.1 - get-port-please: 3.2.0 - pathe: 2.0.3 - proper-lockfile: 4.1.2 - remeda: 2.33.4 - std-env: 3.10.0 - valibot: 1.4.2(typescript@5.9.3) - zeptomatch: 2.1.0 - transitivePeerDependencies: - - typescript - '@prisma/dev@0.24.17(typescript@6.0.3)': dependencies: '@electric-sql/pglite': 0.4.3 @@ -2894,24 +2896,6 @@ snapshots: prettier@3.9.6: {} - prisma@7.9.1(@types/node@26.1.1)(@types/react@19.2.18)(magicast@0.5.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3): - dependencies: - '@prisma/config': 7.9.1(magicast@0.5.5) - '@prisma/dev': 0.24.17(typescript@5.9.3) - '@prisma/engines': 7.9.1 - '@prisma/studio-core': 0.33.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - mysql2: 3.24.4(@types/node@26.1.1) - postgres: 3.4.7 - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - '@types/node' - - '@types/react' - - '@types/react-dom' - - magicast - - react - - react-dom - prisma@7.9.1(@types/node@26.1.1)(@types/react@19.2.18)(magicast@0.5.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3): dependencies: '@prisma/config': 7.9.1(magicast@0.5.5) @@ -3076,8 +3060,6 @@ snapshots: transitivePeerDependencies: - supports-color - typescript@5.9.3: {} - typescript@6.0.3: {} undici-types@8.3.0: {} @@ -3086,11 +3068,11 @@ snapshots: dependencies: punycode: 2.3.1 - valibot@1.4.2(typescript@5.9.3): + valibot@1.4.2(typescript@6.0.3): optionalDependencies: - typescript: 5.9.3 + typescript: 6.0.3 - valibot@1.4.2(typescript@6.0.3): + valibot@1.5.0(typescript@6.0.3): optionalDependencies: typescript: 6.0.3 @@ -3157,3 +3139,5 @@ snapshots: dependencies: grammex: 3.1.13 graphmatch: 1.1.1 + + zod@4.6.5: {} diff --git a/js/scripts/copy-license.mjs b/js/scripts/copy-license.mjs new file mode 100644 index 000000000..c1043f198 --- /dev/null +++ b/js/scripts/copy-license.mjs @@ -0,0 +1,19 @@ +// Copy the repository license to the root of the package being built, where +// the registry and license tooling look for it. The copies are generated build +// output ignored by Git; the repository root LICENSE is the only source. + +import { copyFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import process from "node:process"; +import { fileURLToPath, URL } from "node:url"; + +const repositoryRoot = resolve(fileURLToPath(new URL("..", import.meta.url))); +const packageRoot = resolve(process.cwd()); + +if (packageRoot === repositoryRoot) { + throw new Error("the repository root already contains LICENSE"); +} +await copyFile( + resolve(repositoryRoot, "LICENSE"), + resolve(packageRoot, "LICENSE") +); diff --git a/js/src/client.test.ts b/js/src/client.test.ts index 95c5ef5c8..ecf8d9557 100644 --- a/js/src/client.test.ts +++ b/js/src/client.test.ts @@ -1,300 +1,1365 @@ -import { describe, it, expect, beforeEach } from "vitest"; -import { Client, InsertManyParams } from "./client.js"; -import type { Driver, DriverOptions, JobInsertParams } from "./driver.js"; -import type { JobArgs, JobRow } from "./job.js"; +import { createHash } from "node:crypto"; + +import { beforeEach, describe, expect, expectTypeOf, it } from "vitest"; + +import { buildUniqueKey, Client, encodeUniqueArgs } from "./client.js"; +import type { + DriverInsertResult, + InsertDriver, + InsertDriverOptions, + JobInsertParams, +} from "./driver.js"; +import { + driverMigrationTarget, + registerDriver, +} from "./internal/driver-registry.js"; +import { defineJob } from "./job-definition.js"; +import { ConfigurationError, ValidationError } from "./errors.js"; import { - JOB_STATE_AVAILABLE, - JOB_STATE_SCHEDULED, - JobArgsObject, + JOB_STATE, MAX_ATTEMPTS_DEFAULT, PRIORITY_DEFAULT, QUEUE_DEFAULT, } from "./job.js"; +import type { JobRow } from "./job.js"; +import type { JsonObject } from "./json.js"; +import { createJobArgsTransformPlugin } from "./job-args-transform.js"; +import type { JobArgsInsertTransformInput } from "./job-args-transform.js"; +import { createJobInsertMetadataTransformPlugin } from "./job-insert-metadata-transform.js"; +import { buildPeriodicInsert, periodicJob } from "./periodic.js"; + +type SortInput = { strings: string[] }; + +const sortJob = defineJob()({ kind: "sort" }); + +class FakeDriver implements InsertDriver<{ readonly transaction: true }> { + declare readonly "~river"?: { + readonly capability: "insert"; + readonly transaction: { readonly transaction: true }; + }; + + constructor() { + registerDriver(this, { + backend: "fake", + capability: "insert", + operations: this, + }); + } -// Stub driver that records insert params instead of hitting a database. -class FakeDriver implements Driver { insertedParams: JobInsertParams[] = []; - lastOptions?: DriverOptions; + lastOptions: InsertDriverOptions<{ readonly transaction: true }> | undefined; + nextStatus: DriverInsertResult["status"] = "inserted"; + readonly notifications: { + readonly options: + InsertDriverOptions<{ readonly transaction: true }> | undefined; + readonly queues: readonly string[]; + }[] = []; async jobInsert( params: JobInsertParams, - options?: DriverOptions - ): Promise<[JobRow, boolean]> { + options?: InsertDriverOptions<{ readonly transaction: true }> + ): Promise { this.insertedParams.push(params); this.lastOptions = options; - return [fakeJobRow(params), false]; + return { job: fakeJobRow(params), status: this.nextStatus }; } async jobInsertMany( - params: JobInsertParams[], - options?: DriverOptions - ): Promise<[JobRow, boolean][]> { + params: readonly JobInsertParams[], + options?: InsertDriverOptions<{ readonly transaction: true }> + ): Promise { this.insertedParams.push(...params); this.lastOptions = options; - return params.map((p) => [fakeJobRow(p), false]); + return params.map((item) => ({ + job: fakeJobRow(item), + status: this.nextStatus, + })); + } + + async notifyInsert( + queues: readonly string[], + options?: InsertDriverOptions<{ readonly transaction: true }> + ): Promise { + this.notifications.push({ options, queues }); } } function fakeJobRow(params: JobInsertParams): JobRow { return { - id: 1, - args: JSON.parse(params.encodedArgs) as Record, + args: params.args, attempt: 0, attemptedAt: null, - attemptedBy: null, - createdAt: new Date(), - errors: null, + attemptedBy: [], + createdAt: Temporal.Now.instant(), + errors: [], finalizedAt: null, + id: 9_007_199_254_740_993n, kind: params.kind, maxAttempts: params.maxAttempts, - metadata: {}, + metadata: params.metadata, priority: params.priority, queue: params.queue, - scheduledAt: params.scheduledAt, + scheduledAt: params.scheduledAt ?? Temporal.Now.instant(), state: params.state, tags: params.tags, uniqueKey: params.uniqueKey, - uniqueStates: null, + uniqueStates: [], }; } -class SortArgs implements JobArgs { - kind = "sort"; - - constructor(public strings: string[]) {} - - toJSON() { - return { strings: this.strings }; - } -} - describe("Client", () => { + let client: Client<{ readonly transaction: true }>; let driver: FakeDriver; - let client: Client; beforeEach(() => { driver = new FakeDriver(); - client = new Client(driver); + // Typed as a full client, as untyped JavaScript may use it, so tests can + // check that runtime operations fail on an insert-only driver. + client = new Client(driver) as unknown as Client<{ + readonly transaction: true; + }>; + }); + + it("rejects a value that isn't a registered River driver", () => { + const pool = { connect: () => undefined, query: () => undefined }; + // An object with a driver's methods that no driver registered, such as + // a driver from a second installed copy of riverqueue. + const unregistered = { + jobInsert: () => undefined, + jobInsertMany: () => undefined, + }; + for (const value of [pool, unregistered, null, "postgres://x/river"]) { + expect( + () => + // @ts-expect-error -- none of these is a River driver + new Client(value) + ).toThrow(ConfigurationError); + } + }); + + it("records a driver's migration target only when well formed", () => { + const operations = { + jobInsert: () => undefined, + jobInsertMany: () => undefined, + } as unknown as InsertDriver; + const pool = {}; + const migrating = {}; + registerDriver(migrating, { + backend: "fake", + capability: "insert", + migration: { pool, schema: "river" }, + operations, + }); + + expect(driverMigrationTarget(migrating)).toEqual({ pool, schema: "river" }); + expect(driverMigrationTarget(driver)).toBeUndefined(); + expect(driverMigrationTarget({ pool, schema: "river" })).toBeUndefined(); + for (const migration of [{}, { pool: null }, { pool, schema: 1 }]) { + expect(() => + registerDriver( + {}, + { + backend: "fake", + capability: "insert", + migration: migration as never, + operations, + } + ) + ).toThrow(ValidationError); + } }); - describe("insert", () => { - it("inserts a job with defaults", async () => { - const result = await client.insert(new SortArgs(["b", "a"])); + it("inserts a definition and preserves exact result types", async () => { + const result = await client.insert(sortJob, { strings: ["b", "a"] }); - expect(result.uniqueSkippedAsDuplicated).toBe(false); - expect(result.job.kind).toBe("sort"); + expectTypeOf(result.job.id).toEqualTypeOf(); + expectTypeOf(result.job.args.strings).toEqualTypeOf(); + expect(result).toMatchObject({ status: "inserted" }); + expect(result.job.id).toBe(9_007_199_254_740_993n); - const params = driver.insertedParams[0]!; - expect(params.kind).toBe("sort"); - expect(params.encodedArgs).toBe('{"strings":["b","a"]}'); - expect(params.maxAttempts).toBe(MAX_ATTEMPTS_DEFAULT); - expect(params.priority).toBe(PRIORITY_DEFAULT); - expect(params.queue).toBe(QUEUE_DEFAULT); - expect(params.state).toBe(JOB_STATE_AVAILABLE); - expect(params.tags).toEqual([]); - expect(params.uniqueKey).toBeNull(); - expect(params.uniqueStates).toBeNull(); + const params = driver.insertedParams[0]!; + expect(params).toMatchObject({ + encodedArgs: '{"strings":["b","a"]}', + kind: "sort", + maxAttempts: MAX_ATTEMPTS_DEFAULT, + priority: PRIORITY_DEFAULT, + queue: QUEUE_DEFAULT, + state: JOB_STATE.available, + tags: [], + uniqueKey: null, + uniqueStates: null, }); + // River's own inserts leave the creation and scheduled times of an + // unscheduled job to the database. + expect(params.scheduledAt).toBeUndefined(); + expect(params.createdAt).toBeUndefined(); + }); - it("respects insert opts", async () => { - const future = new Date(Date.now() + 60_000); - await client.insert(new SortArgs(["a"]), { - maxAttempts: 5, + it("accepts Date scheduling and relative delays", async () => { + const date = new Date(Date.UTC(2030, 0, 2, 3, 4, 5, 678)); + await client.insert(sortJob, { strings: [] }, { scheduledAt: date }); + expect(driver.insertedParams[0]).toMatchObject({ + scheduledAt: Temporal.Instant.from("2030-01-02T03:04:05.678Z"), + state: "scheduled", + }); + + const before = Temporal.Now.instant(); + await client.insert(sortJob, { strings: [] }, { delay: { minutes: 5 } }); + const after = Temporal.Now.instant(); + const delayed = driver.insertedParams[1]!; + expect(delayed.state).toBe("scheduled"); + expect( + Temporal.Instant.compare(delayed.scheduledAt!, before.add({ minutes: 5 })) + ).toBeGreaterThanOrEqual(0); + expect( + Temporal.Instant.compare(delayed.scheduledAt!, after.add({ minutes: 5 })) + ).toBeLessThanOrEqual(0); + + const delayedDefinition = defineJob({ + defaults: { delay: { hours: 1 } }, + kind: "delayed_default", + }); + await client.insert(delayedDefinition, {}, { scheduledAt: date }); + expect(driver.insertedParams[2]!.scheduledAt).toEqual( + Temporal.Instant.from("2030-01-02T03:04:05.678Z") + ); + + await expect( + client.insert( + sortJob, + { strings: [] }, + { delay: { seconds: 1 }, scheduledAt: date } + ) + ).rejects.toThrow("mutually exclusive"); + await expect( + client.insert(sortJob, { strings: [] }, { delay: { months: 1 } }) + ).rejects.toThrow("calendar units"); + await expect( + client.insert(sortJob, { strings: [] }, { delay: { seconds: -1 } }) + ).rejects.toThrow("must not be negative"); + await expect( + client.insert( + sortJob, + { strings: [] }, + { scheduledAt: new Date(Number.NaN) } + ) + ).rejects.toThrow("valid Date"); + }); + + it("uses explicit precedence without truthiness fallbacks", async () => { + const definition = defineJob()({ + defaults: { maxAttempts: 7, priority: 2, queue: "job_default" }, + kind: "sort_with_defaults", + }); + const clientWithDefaults = new Client(driver, { + defaultInsertOptions: { + maxAttempts: 9, priority: 3, - queue: "high", - scheduledAt: future, - tags: ["tag1"], - }); + queue: "client_default", + }, + }); + + await clientWithDefaults.insert( + definition, + { strings: [] }, + { + maxAttempts: 4, + priority: 1, + queue: "call_site", + tags: [], + } + ); - const params = driver.insertedParams[0]!; - expect(params.maxAttempts).toBe(5); - expect(params.priority).toBe(3); - expect(params.queue).toBe("high"); - expect(params.scheduledAt).toBe(future); - expect(params.state).toBe(JOB_STATE_SCHEDULED); - expect(params.tags).toEqual(["tag1"]); + expect(driver.insertedParams[0]).toMatchObject({ + maxAttempts: 4, + priority: 1, + queue: "call_site", + tags: [], }); - it("uses JobArgsObject", async () => { - await client.insert( - new JobArgsObject("email", { to: "user@example.com" }) - ); + await expect( + clientWithDefaults.insert(definition, { strings: [] }, { maxAttempts: 0 }) + ).rejects.toThrow("maxAttempts must be a safe integer between 1 and 32767"); + await expect( + clientWithDefaults.insert(definition, { strings: [] }, { queue: "" }) + ).rejects.toThrow("queue name must not be empty"); + }); + + it("rejects non-finite args and metadata with Go's text", async () => { + const client = new Client(new FakeDriver()); + const definition = defineJob<{ ratio: number }>()({ kind: "ratio" }); + + await expect( + client.insert(definition, { ratio: Number.NaN }) + ).rejects.toThrow("unsupported value: NaN"); + await expect( + client.insert( + definition, + { ratio: 1 }, + { metadata: { weight: Number.NEGATIVE_INFINITY } } + ) + ).rejects.toThrow("unsupported value: -Inf"); + }); - const params = driver.insertedParams[0]!; - expect(params.kind).toBe("email"); - expect(params.encodedArgs).toBe('{"to":"user@example.com"}'); + it("keeps explicit scheduled and pending states distinct", async () => { + const scheduledAt = Temporal.Instant.from("2026-08-30T12:34:56.123456789Z"); + await client.insert(sortJob, { strings: [] }, { scheduledAt }); + await client.insert( + sortJob, + { strings: [] }, + { pending: true, scheduledAt } + ); + + expect(driver.insertedParams[0]).toMatchObject({ + scheduledAt, + state: JOB_STATE.scheduled, + }); + expect(driver.insertedParams[1]).toMatchObject({ + scheduledAt, + state: JOB_STATE.pending, }); + }); - it("strips kind and insertOpts from args without toJSON", async () => { - const args: JobArgs = { - kind: "plain", - insertOpts: { maxAttempts: 3 }, - }; - // Add a data property at runtime - (args as unknown as Record).data = "hello"; + it("inserts periodic occurrences available at their occurrence time", async () => { + // Go's periodic enqueuer sets the occurrence time after resolving the + // insert state, so a job due slightly in the future is still available. + const scheduledAt = Temporal.Now.instant().add({ milliseconds: 50 }); + const periodic = await buildPeriodicInsert({ + job: periodicJob({ + args: { strings: [] }, + every: { hours: 1 }, + job: sortJob, + }), + scheduledAt, + }); + if (periodic === null) throw new Error("expected a periodic insert"); + await client.insertMany([periodic]); + // A caller's own schedule still makes the job scheduled. + await client.insertMany([ + { args: { strings: [] }, job: sortJob, options: { scheduledAt } }, + ]); - await client.insert(args); + expect(driver.insertedParams[0]).toMatchObject({ + scheduledAt, + state: JOB_STATE.available, + }); + expect(driver.insertedParams[1]).toMatchObject({ + scheduledAt, + state: JOB_STATE.scheduled, + }); + }); - const params = driver.insertedParams[0]!; - expect(JSON.parse(params.encodedArgs)).toEqual({ data: "hello" }); - expect(params.maxAttempts).toBe(3); + it("validates Standard Schema input but persists the untransformed JSON", async () => { + const emailJob = defineJob({ + kind: "email", + schema: { + "~standard": { + types: undefined as unknown as { + input: { email: string }; + output: { email: string; normalized: true }; + }, + validate(value: unknown) { + const input = value as { email?: unknown }; + if (typeof input.email !== "string") { + return { issues: [{ message: "email must be a string" }] }; + } + return { + value: { + email: input.email.toLowerCase(), + normalized: true as const, + }, + }; + }, + vendor: "river-test", + version: 1 as const, + }, + }, }); + + await client.insert(emailJob, { email: "Person@Example.COM" }); + + expect(driver.insertedParams[0]!.encodedArgs).toBe( + '{"email":"Person@Example.COM"}' + ); + await expect( + client.insert(emailJob, { email: 42 } as never) + ).rejects.toThrow("invalid payload for job kind"); + }); + + it("rejects a batch repeating a unique key before writing, like Go", async () => { + await expect( + client.insertMany([ + { + args: { strings: ["same"] }, + job: sortJob, + options: { unique: { byArgs: true } }, + }, + { args: { strings: ["different"] }, job: sortJob }, + { + args: { strings: ["same"] }, + job: sortJob, + options: { unique: { byArgs: true } }, + }, + ]) + ).rejects.toThrow( + new ValidationError("unique key appears more than once in batch") + ); + expect(driver.insertedParams).toHaveLength(0); + }); + + it("preserves each definition input across heterogeneous batches", async () => { + const countJob = defineJob<{ count: number }>()({ kind: "count" }); + const results = await client.insertMany([ + { args: { strings: ["typed"] }, job: sortJob }, + { args: { count: 42 }, job: countJob }, + ]); + + expectTypeOf(results[0].job.args).toEqualTypeOf(); + expectTypeOf(results[1].job.args).toEqualTypeOf<{ count: number }>(); + + const typecheckOnly = (): boolean => false; + if (typecheckOnly()) { + // @ts-expect-error args must correspond to the definition in this item. + await client.insertMany([{ args: { count: 42 }, job: sortJob }]); + } }); - describe("insert with uniqueOpts", () => { - it("generates unique key when constraints are set", async () => { - await client.insert(new SortArgs(["a"]), { - uniqueOpts: { byQueue: true }, + it("preserves nested JSON field order in unique hashes like Go", async () => { + const objectJob = defineJob()({ kind: "object" }); + await client.insert( + objectJob, + { nested: { a: 1, b: 2 } }, + { unique: { byArgs: true } } + ); + await client.insert( + objectJob, + { nested: { b: 2, a: 1 } }, + { unique: { byArgs: true } } + ); + + expect(driver.insertedParams[0]!.uniqueKey).not.toEqual( + driver.insertedParams[1]!.uniqueKey + ); + }); + + it.each([ + { + args: {}, + hash: "23aa86692d9807ab10e433e378f1c0804573f5e345818461b919322dd381b4c3", + }, + { + args: { + account: { id: "acct", region: "west", ignored: "ignored" }, + "path/key": "slash", + }, + hash: "7d62e81ac25cfa2dec69ad5a41e0b78188ee1b299bed329b453da6b3abca70bd", + }, + ])( + "hashes selected paths through actual insertion: $hash", + async ({ args, hash }) => { + const definition = defineJob()({ + kind: "conformance_selected_args", }); + await client.insert(definition, args, { + unique: { + byArgs: ["path/key", "label", "account.region", "account.id"], + }, + }); + expect( + Buffer.from(driver.insertedParams[0]!.uniqueKey!).toString("hex") + ).toBe(hash); + } + ); + + it("assembles selected unique paths in sorted path order like Go", async () => { + const definition = defineJob()({ kind: "object" }); + await client.insert( + definition, + { "": 3, a: { b: 1 }, "a-c": 2 }, + { unique: { byArgs: ["a.b", "a-c", ""] } } + ); + + // "a-c" sorts before "a.b" bytewise, so River Go writes it first even + // though "a" sorts before "a-c" as a key, and writes "" verbatim. + const expected = createHash("sha256") + .update('&kind=object&args={"":3,"a-c":2,"a":{"b":1}}') + .digest("hex"); + expect( + Buffer.from(driver.insertedParams[0]!.uniqueKey!).toString("hex") + ).toBe(expected); + }); + + it("hashes literal keys and accepts escaped selected paths", async () => { + const definition = defineJob()({ kind: "object" }); + for (const path of ["a*", "a.b?", "a|b", "#", "a@b", "a\\b", ":a"]) { + expect(() => + defineJob({ + defaults: { unique: { byArgs: [path] } }, + kind: "unique_paths", + }) + ).not.toThrow(); + } + for (const path of ["", "a..b", "a\\"]) { + expect(() => + defineJob({ + defaults: { unique: { byArgs: [path] } }, + kind: "unique_paths", + }) + ).toThrow(ValidationError); + } + + await client.insert( + definition, + { "a#b": 1, "": 2 }, + { unique: { byArgs: true } } + ); + await client.insert( + definition, + { "a#b": 2, "": 2 }, + { unique: { byArgs: true } } + ); + expect(driver.insertedParams[0]!.uniqueKey).not.toEqual( + driver.insertedParams[1]!.uniqueKey + ); + // Nested keys are hashed verbatim, and keys only matter when hashed. + await client.insert( + definition, + { nested: { "a#b": 1 } }, + { unique: { byArgs: true } } + ); + await client.insert(definition, { "a#b": 1 }); + expect(driver.insertedParams).toHaveLength(4); + }); - const params = driver.insertedParams[0]!; - expect(params.uniqueKey).not.toBeNull(); - expect(params.uniqueStates).not.toBeNull(); + it("rejects selected path segments Go reads as array indices", async () => { + // River Go's sjson builds a JSON array, not an object, for these + // segments, so a JavaScript key could never match Go's. + for (const path of [ + "0", + "-1", + "items.0", + "items.10", + "a.-1", + "a.007.b", + "a.\\0", + "\\-1", + ]) { + expect(() => + defineJob({ + defaults: { unique: { byArgs: [path] } }, + kind: "unique_paths", + }) + ).toThrow(/array index/); + expect(() => + encodeUniqueArgs({ items: [1], a: { "-1": 1 } }, [path]) + ).toThrow(ValidationError); + } + const definition = defineJob()({ kind: "object" }); + await expect( + client.insert( + definition, + { items: [1] }, + { unique: { byArgs: ["items.0"] } } + ) + ).rejects.toThrow(ValidationError); + expect(driver.insertedParams).toHaveLength(0); + + // Other segments that merely contain digits or a minus sign are fields. + for (const path of ["a0", "0a", "-2", "-1a", "a.+1", "a.1x", ":0"]) { + expect(() => + defineJob({ + defaults: { unique: { byArgs: [path] } }, + kind: "unique_paths", + }) + ).not.toThrow(); + } + }); + + it("accepts overlapping unique paths without mutating arguments", async () => { + const definition = defineJob()({ kind: "object" }); + const args = Object.freeze({ nested: Object.freeze({ z: 1, a: 2 }) }); + await client.insert(definition, args, { + unique: { byArgs: ["nested", "nested.a"] }, + }); + await client.insert(definition, args, { unique: { byArgs: ["nested"] } }); + expect(driver.insertedParams[0]!.uniqueKey).toEqual( + driver.insertedParams[1]!.uniqueKey + ); + expect(args).toEqual({ nested: { z: 1, a: 2 } }); + }); + + it("matches Go year-one period truncation fixtures", async () => { + const definition = defineJob<{ id: number }>()({ + kind: "conformance_simple", }); + await client.insert( + definition, + { id: 42 }, + { + scheduledAt: Temporal.Instant.from("2026-01-02T03:04:05.6789Z"), + unique: { byPeriod: { minutes: 90 } }, + } + ); - it("does not generate unique key for empty uniqueOpts", async () => { - await client.insert(new SortArgs(["a"]), { - uniqueOpts: {}, - }); + expect( + Array.from(driver.insertedParams[0]!.uniqueKey!, (byte) => + byte.toString(16).padStart(2, "0") + ).join("") + ).toBe("5396f06a082abd7a929915135ebd363a9a47d800176b03ce7736f93a5ba9e22e"); + }); - const params = driver.insertedParams[0]!; - expect(params.uniqueKey).toBeNull(); - expect(params.uniqueStates).toBeNull(); + it("hashes by-period uniqueness from the effective scheduled time", async () => { + const definition = defineJob<{ id: number }>()({ + defaults: { delay: { hours: 2 } }, + kind: "conformance_simple", }); + const unique = { byPeriod: { hours: 1 } }; + await client.insert(definition, { id: 42 }, { unique }); + const delayed = driver.insertedParams[0]!; + await client.insert( + definition, + { id: 42 }, + { scheduledAt: delayed.scheduledAt!, unique } + ); + await client.insert( + definition, + { id: 42 }, + { + scheduledAt: Temporal.Instant.from("2026-01-02T10:51:05.6789+05:30"), + unique, + } + ); + + await client.insert( + definition, + { id: 42 }, + { scheduledAt: Temporal.Now.instant(), unique } + ); + + expect(delayed.state).toBe("scheduled"); + expect(delayed.uniqueKey).toEqual(driver.insertedParams[1]!.uniqueKey); + expect(delayed.uniqueKey).not.toEqual(driver.insertedParams[3]!.uniqueKey); + expect( + Buffer.from(driver.insertedParams[2]!.uniqueKey!).toString("hex") + ).toBe("b7f3c49952996b760b8b3ff6cf48f426e03a6ef0f004fb6faa51725365cf309a"); + }); + + it("leaves an unscheduled job's time to the database, like Go", async () => { + const definition = defineJob<{ id: number }>()({ + kind: "conformance_simple", + }); + const unique = { byArgs: true, byPeriod: { minutes: 1 } } as const; + + const before = Temporal.Now.instant(); + await client.insert(definition, { id: 42 }, { unique }); + const after = Temporal.Now.instant(); + + const params = driver.insertedParams[0]!; + expect("scheduledAt" in params).toBe(false); + expect("createdAt" in params).toBe(false); + expect(params.state).toBe("available"); + // Like Go, the period key uses the current time instead. + const keys = [before, after].map((scheduledAt) => + Buffer.from( + buildUniqueKey({ ...params, scheduledAt }, unique)[0] + ).toString("hex") + ); + expect(keys).toContain(Buffer.from(params.uniqueKey!).toString("hex")); + }); + + it("supports exact subsecond Temporal uniqueness periods", async () => { + const definition = defineJob<{ id: number }>()({ + kind: "conformance_simple", + }); + await client.insert( + definition, + { id: 42 }, + { + scheduledAt: Temporal.Instant.from("2026-01-02T03:04:05.6789Z"), + unique: { + byPeriod: Temporal.Duration.from({ milliseconds: 1_500 }), + }, + } + ); - it("does not generate unique key for args-level empty uniqueOpts", async () => { - class ArgsWithEmptyUnique implements JobArgs { - kind = "with_empty_unique"; - insertOpts = { uniqueOpts: {} }; + expect( + Array.from(driver.insertedParams[0]!.uniqueKey!, (byte) => + byte.toString(16).padStart(2, "0") + ).join("") + ).toBe("2c1a88adffe46598d28e4ca05f5e7a93a77a36face4345341262c77d26524398"); - toJSON() { - return {}; + await expect( + client.insert( + definition, + { id: 42 }, + { + unique: { + byPeriod: Temporal.Duration.from({ weeks: 1 }), + }, + } + ) + ).rejects.toThrow("must not contain calendar units"); + await expect( + client.insert( + definition, + { id: 42 }, + { + unique: { + byPeriod: Temporal.Duration.from({ milliseconds: 999 }), + }, } + ) + ).rejects.toThrow("must be at least one second"); + await client.insert( + definition, + { id: 42 }, + { + scheduledAt: Temporal.Instant.from("2026-01-02T03:04:05.6789Z"), + unique: { byPeriod: { days: 1 } }, } + ); + await client.insert( + definition, + { id: 42 }, + { + scheduledAt: Temporal.Instant.from("2026-01-02T03:04:05.6789Z"), + unique: { byPeriod: { hours: 24 } }, + } + ); + expect(driver.insertedParams.at(-1)!.uniqueKey).toEqual( + driver.insertedParams.at(-2)!.uniqueKey + ); + }); - await client.insert(new ArgsWithEmptyUnique()); + it("matches Go empty-state and exclude-kind uniqueness edges", async () => { + await client.insert( + sortJob, + { strings: ["empty states"] }, + { unique: { byState: [] } } + ); + // Like Go, excluding the kind needs another dimension, and then keys + // match across kinds. + await expect( + client.insert( + sortJob, + { strings: ["exclude kind only"] }, + { unique: { excludeKind: true } } + ) + ).rejects.toThrow( + new ValidationError( + "unique.excludeKind requires byArgs, byQueue, or byPeriod" + ) + ); + const otherKind = defineJob()({ kind: "other_sort" }); + for (const definition of [sortJob, otherKind]) { + await client.insert( + definition, + { strings: ["exclude kind"] }, + { unique: { byArgs: true, excludeKind: true } } + ); + } - const params = driver.insertedParams[0]!; - expect(params.uniqueKey).toBeNull(); - expect(params.uniqueStates).toBeNull(); + expect(driver.insertedParams[0]).toMatchObject({ + uniqueStates: [ + "available", + "completed", + "pending", + "retryable", + "running", + "scheduled", + ], }); + expect(driver.insertedParams[0]?.uniqueKey).not.toBeNull(); + expect(driver.insertedParams).toHaveLength(3); + expect(driver.insertedParams[1]?.uniqueKey).not.toBeNull(); + expect(driver.insertedParams[2]?.uniqueKey).toEqual( + driver.insertedParams[1]?.uniqueKey + ); }); - describe("insertMany", () => { - it("inserts multiple jobs", async () => { - const results = await client.insertMany([ - new SortArgs(["b"]), - new SortArgs(["a"]), - ]); + it("validates every unique option through one path", async () => { + for (const unique of [ + { byArgs: false }, + { byQueue: "yes" }, + { excludeKind: 1 }, + { excludeKind: true }, + { byQueue: false, excludeKind: true }, + ]) { + await expect( + client.insert(sortJob, { strings: [] }, { unique } as never) + ).rejects.toThrow("unique."); + expect( + () => new Client(driver, { defaultInsertOptions: { unique } as never }) + ).toThrow("unique."); + expect(() => + defineJob({ defaults: { unique } as never, kind: "invalid_unique" }) + ).toThrow("unique."); + } + }); - expect(results).toHaveLength(2); - expect(driver.insertedParams).toHaveLength(2); - expect(driver.insertedParams[0]!.encodedArgs).toBe('{"strings":["b"]}'); - expect(driver.insertedParams[1]!.encodedArgs).toBe('{"strings":["a"]}'); + it("composes exact argument transforms around every insertion path", async () => { + const order: string[] = []; + const first = createJobArgsTransformPlugin({ + name: "first", + onInsert: (input) => { + order.push("first:insert"); + expect(Object.isFrozen(input)).toBe(true); + expect(Object.isFrozen(input.args)).toBe(true); + expect(Object.isFrozen(input.args.strings)).toBe(true); + const args = { first: input.args }; + return { args, encodedArgs: JSON.stringify(args) }; + }, + onRead: ({ args }) => { + order.push("first:read"); + return args.first as JsonObject; + }, + }); + const second = createJobArgsTransformPlugin({ + name: "second", + onInsert: (input) => { + order.push("second:insert"); + const args = { second: input.args }; + return { args, encodedArgs: JSON.stringify(args) }; + }, + onRead: ({ args }) => { + order.push("second:read"); + return args.second as JsonObject; + }, + }); + const operations: string[] = []; + let beforeArgs: JsonObject | undefined; + let afterArgs: JsonObject | undefined; + const transformedClient = new Client(driver, { + hooks: { + afterInsert: (context, results) => { + operations.push(`after:${context.operation}`); + afterArgs = results[0]?.job.args; + }, + beforeInsert: (context) => { + operations.push(`before:${context.operation}`); + beforeArgs = context.requests[0]?.args; + expect(() => { + (context.requests[0]?.args as { mutated?: boolean }).mutated = true; + }).toThrow(); + }, + }, + plugins: [first, second], }); - it("supports InsertManyParams with per-job opts", async () => { - await client.insertMany([ - new InsertManyParams(new SortArgs(["a"]), { maxAttempts: 5 }), - new SortArgs(["b"]), - ]); + const inserted = await transformedClient.insert(sortJob, { + strings: ["one"], + }); + await transformedClient.insertMany([ + { args: { strings: ["many"] }, job: sortJob }, + ]); + await transformedClient.insertMany([ + { args: { strings: ["fast"] }, job: sortJob }, + ]); - expect(driver.insertedParams[0]!.maxAttempts).toBe(5); - expect(driver.insertedParams[1]!.maxAttempts).toBe(MAX_ATTEMPTS_DEFAULT); + expect(driver.insertedParams[0]?.args).toEqual({ + second: { first: { strings: ["one"] } }, }); + expect(driver.insertedParams[0]?.encodedArgs).toBe( + '{"second":{"first":{"strings":["one"]}}}' + ); + expect(beforeArgs).toEqual({ + second: { first: { strings: ["fast"] } }, + }); + expect(afterArgs).toEqual({ + second: { first: { strings: ["fast"] } }, + }); + expect(inserted.job.args).toEqual({ strings: ["one"] }); + expect(order).toEqual([ + "first:insert", + "second:insert", + "second:read", + "first:read", + "first:insert", + "second:insert", + "second:read", + "first:read", + "first:insert", + "second:insert", + "second:read", + "first:read", + ]); + expect(operations).toEqual([ + "before:insert", + "after:insert", + "before:insertMany", + "after:insertMany", + "before:insertMany", + "after:insertMany", + ]); + }); - it("deduplicates jobs with the same unique key in a batch", async () => { - const uniqueOpts = { byArgs: true as const }; - const results = await client.insertMany([ - new InsertManyParams(new SortArgs(["same"]), { uniqueOpts }), - new InsertManyParams(new SortArgs(["same"]), { uniqueOpts }), - new InsertManyParams(new SortArgs(["different"]), { uniqueOpts }), - ]); + it("derives unique keys before transforming persisted arguments", async () => { + const plainDriver = new FakeDriver(); + const transformedDriver = new FakeDriver(); + const plainClient = new Client(plainDriver); + const transformedClient = new Client(transformedDriver, { + plugins: [ + createJobArgsTransformPlugin({ + name: "wrapper", + onInsert: ({ args }) => { + const wrapped = { envelope: args }; + return { args: wrapped, encodedArgs: JSON.stringify(wrapped) }; + }, + onRead: ({ args }) => args.envelope as JsonObject, + }), + ], + }); - // Only two jobs sent to the driver (first "same" + "different"). - expect(driver.insertedParams).toHaveLength(2); + await plainClient.insert( + sortJob, + { strings: ["same"] }, + { unique: { byArgs: true } } + ); + await transformedClient.insert( + sortJob, + { strings: ["same"] }, + { unique: { byArgs: true } } + ); - // All three results returned in original order. - expect(results).toHaveLength(3); - expect(results[0]!.uniqueSkippedAsDuplicated).toBe(false); - expect(results[1]!.uniqueSkippedAsDuplicated).toBe(true); // batch dup - expect(results[2]!.uniqueSkippedAsDuplicated).toBe(false); + expect(transformedDriver.insertedParams[0]?.uniqueKey).toEqual( + plainDriver.insertedParams[0]?.uniqueKey + ); + }); - // The duplicate returns the same job as the first occurrence. - expect(results[1]!.job.id).toBe(results[0]!.job.id); + it("transforms insert metadata from plaintext for every insertion path", async () => { + const seenDefinitions: unknown[] = []; + const transformedClient = new Client(driver, { + hooks: { + beforeInsert: ({ requests }) => { + seenDefinitions.push(...requests.map(({ definition }) => definition)); + }, + }, + plugins: [ + createJobInsertMetadataTransformPlugin({ + name: "routing", + onInsert: (input) => { + expect(Object.isFrozen(input)).toBe(true); + expect(Object.isFrozen(input.args)).toBe(true); + expect(Object.isFrozen(input.metadata)).toBe(true); + seenDefinitions.push(input.definition); + const strings = input.args.strings as readonly string[]; + return { + metadata: { + ...input.metadata, + route: `${input.queue}:${input.kind}:${String(strings[0])}`, + }, + ...(strings[0] === "fast" ? { pending: true as const } : {}), + }; + }, + }), + createJobArgsTransformPlugin({ + name: "envelope", + onInsert: ({ args, definition }) => { + seenDefinitions.push(definition); + const wrapped = { envelope: args }; + return { + args: wrapped, + encodedArgs: JSON.stringify(wrapped), + }; + }, + onRead: ({ args }) => args.envelope as JsonObject, + }), + ], }); - it("does not deduplicate jobs without unique keys", async () => { - const results = await client.insertMany([ - new SortArgs(["a"]), - new SortArgs(["a"]), - ]); + await transformedClient.insert( + sortJob, + { strings: ["one"] }, + { metadata: { caller: true }, queue: "critical" } + ); + await transformedClient.insertMany([ + { + args: { strings: ["many"] }, + job: sortJob, + options: { queue: "bulk" }, + }, + ]); + await transformedClient.insertMany([ + { + args: { strings: ["fast"] }, + job: sortJob, + options: { queue: "fast" }, + }, + ]); - // Both sent to the driver (no unique constraints). - expect(driver.insertedParams).toHaveLength(2); - expect(results).toHaveLength(2); + expect(driver.insertedParams.map(({ metadata }) => metadata)).toEqual([ + { caller: true, route: "critical:sort:one" }, + { route: "bulk:sort:many" }, + { route: "fast:sort:fast" }, + ]); + // A transformer can make an insertion pending on any path. + expect(driver.insertedParams.map(({ state }) => state)).toEqual([ + "available", + "available", + "pending", + ]); + expect(driver.insertedParams[0]?.args).toEqual({ + envelope: { strings: ["one"] }, }); + // Metadata transform, args transform, and insert hook each see the + // caller's definition object on every insertion path. + expect(seenDefinitions).toHaveLength(9); + expect(seenDefinitions.every((definition) => definition === sortJob)).toBe( + true + ); }); - describe("validation", () => { - it("rejects empty kind", async () => { - await expect(client.insert({ kind: "" })).rejects.toThrow( - "args must have a non-empty kind" - ); - }); - - it("rejects tags over 255 characters", async () => { + it("rejects invalid metadata transformer results", async () => { + for (const result of [ + { metadata: [] }, + { metadata: {}, pending: false }, + null, + ]) { + const transformedClient = new Client(driver, { + plugins: [ + createJobInsertMetadataTransformPlugin({ + name: "invalid", + onInsert: () => result as never, + }), + ], + }); await expect( - client.insert(new SortArgs(["a"]), { tags: ["x".repeat(256)] }) - ).rejects.toThrow("255 characters"); - }); + transformedClient.insert(sortJob, { strings: [] }) + ).rejects.toThrow(/transformer "invalid"/); + } + expect(driver.insertedParams).toEqual([]); + }); - it("rejects tags with invalid characters", async () => { - await expect( - client.insert(new SortArgs(["a"]), { tags: ["bad tag!"] }) - ).rejects.toThrow("tag should match regex"); + it("rejects inconsistent transformed encodings before hooks or storage", async () => { + let beforeInsertCalls = 0; + const transformedClient = new Client(driver, { + hooks: { + beforeInsert: () => { + beforeInsertCalls += 1; + }, + }, + plugins: [ + createJobArgsTransformPlugin({ + name: "invalid", + onInsert: () => ({ + args: { value: "left" }, + encodedArgs: '{"value":"right"}', + }), + onRead: ({ args }) => args, + }), + ], }); - it("rejects unique states missing required states", async () => { - await expect( - client.insert(new SortArgs(["a"]), { - uniqueOpts: { - byArgs: true, - byState: [JOB_STATE_AVAILABLE], + await expect( + transformedClient.insert(sortJob, { strings: [] }) + ).rejects.toThrow("mismatched args and encodedArgs"); + expect(beforeInsertCalls).toBe(0); + expect(driver.insertedParams).toHaveLength(0); + }); + + it("rejects insert-only argument transforms that cannot decode results", () => { + expect(() => + createJobArgsTransformPlugin({ + name: "insert-only", + onInsert: (input: JobArgsInsertTransformInput) => ({ + args: input.args, + encodedArgs: input.encodedArgs, + }), + } as never) + ).toThrow("onRead must be a function"); + }); + + it("passes a caller-owned transaction through unchanged", async () => { + const tx = { transaction: true as const }; + await client.insert(sortJob, { strings: [] }, { tx }); + + expect(driver.lastOptions).toEqual({ tx }); + }); +}); + +describe("Client insert notifications", () => { + const setup = (fetchCooldown: Temporal.DurationLike = { seconds: 60 }) => { + const driver = new FakeDriver(); + const client = new Client(driver, { fetchCooldown }); + return { client, driver }; + }; + + it("notifies each queue of available jobs once per fetch cooldown", async () => { + const { client, driver } = setup(); + + await client.insertMany([ + { args: { strings: [] }, job: sortJob }, + { args: { strings: [] }, job: sortJob, options: { queue: "other" } }, + { args: { strings: ["again"] }, job: sortJob }, + ]); + await client.insert(sortJob, { strings: ["later"] }); + await client.insert(sortJob, { strings: [] }, { queue: "third" }); + + expect(driver.notifications).toEqual([ + { options: undefined, queues: [QUEUE_DEFAULT, "other"] }, + { options: undefined, queues: ["third"] }, + ]); + }); + + it("notifies a queue again once the fetch cooldown passes", async () => { + const { client, driver } = setup({ milliseconds: 1 }); + + await client.insert(sortJob, { strings: [] }); + await new Promise((resolve) => setTimeout(resolve, 10)); + await client.insert(sortJob, { strings: [] }); + + expect(driver.notifications.map(({ queues }) => queues)).toEqual([ + [QUEUE_DEFAULT], + [QUEUE_DEFAULT], + ]); + }); + + it("notifies nobody of jobs that aren't available", async () => { + const { client, driver } = setup(); + + await client.insert( + sortJob, + { strings: [] }, + { scheduledAt: Temporal.Now.instant().add({ hours: 1 }) } + ); + await client.insert(sortJob, { strings: [] }, { pending: true }); + + expect(driver.notifications).toEqual([]); + }); + + it("notifies a unique duplicate's queue like River for Go", async () => { + const { client, driver } = setup(); + driver.nextStatus = "duplicate"; + + await client.insert(sortJob, { strings: [] }); + + expect(driver.notifications.map(({ queues }) => queues)).toEqual([ + [QUEUE_DEFAULT], + ]); + }); + + it("notifies in the insertion's transaction", async () => { + const { client, driver } = setup(); + const tx = { transaction: true as const }; + + await client.insert(sortJob, { strings: [] }, { tx }); + + expect(driver.notifications).toEqual([ + { options: { tx }, queues: [QUEUE_DEFAULT] }, + ]); + }); + + it("keeps a limiter per client", async () => { + const driver = new FakeDriver(); + const first = new Client(driver); + const second = new Client(driver); + + await first.insert(sortJob, { strings: [] }); + await second.insert(sortJob, { strings: [] }); + await first.insert(sortJob, { strings: [] }); + + expect(driver.notifications.map(({ queues }) => queues)).toEqual([ + [QUEUE_DEFAULT], + [QUEUE_DEFAULT], + ]); + }); + + it("validates the fetch cooldown and gives it to queues without their own", () => { + const driver = new FakeDriver(); + + expect( + () => new Client(driver, { fetchCooldown: { milliseconds: 0 } }) + ).toThrow("fetchCooldown must be positive"); + expect( + () => new Client(driver, { fetchCooldown: { seconds: -1 } }) + ).toThrow("fetchCooldown must not be negative"); + expect( + () => + new Client(driver, { + fetchCooldown: { seconds: 2 }, + queues: { default: { maxWorkers: 1 } }, + }) + ).toThrow("queue pollInterval cannot be shorter than fetchCooldown"); + expect( + () => + new Client(driver, { + fetchCooldown: { seconds: 2 }, + queues: { + default: { maxWorkers: 1, pollInterval: { seconds: 5 } }, + fast: { + fetchCooldown: { milliseconds: 10 }, + maxWorkers: 1, + }, }, }) - ).rejects.toThrow("byState should include required state"); - }); + ).not.toThrow(); + }); +}); - it("rejects invalid schema names", () => { - expect(() => new Client(driver, { schema: "bad schema" })).toThrow( - "invalid schema name" - ); - expect(() => new Client(driver, { schema: "has;semicolon" })).toThrow( - "invalid schema name" +describe("Client operation scopes", () => { + type Transaction = { readonly name: string }; + + /** Records a scope's events and discards its inserts when it rejects. */ + class ScopedDriver implements InsertDriver { + declare readonly "~river"?: { + readonly capability: "insert"; + readonly transaction: Transaction; + }; + + constructor() { + registerDriver(this, { + backend: "fake", + capability: "insert", + operations: this, + }); + } + + readonly committed: string[] = []; + readonly events: string[] = []; + #pending: string[] = []; + + jobInsert( + params: JobInsertParams, + options?: InsertDriverOptions + ): Promise { + return this.jobInsertMany([params], options).then( + ([result]) => result as DriverInsertResult ); - expect(() => new Client(driver, { schema: "1starts" })).toThrow( - "invalid schema name" + } + + jobInsertMany( + params: readonly JobInsertParams[], + options?: InsertDriverOptions + ): Promise { + this.events.push(`write:${options?.tx?.name ?? "none"}`); + this.#pending.push(...params.map(({ kind }) => kind)); + return Promise.resolve( + params.map((item) => ({ job: fakeJobRow(item), status: "inserted" })) ); + } + + async operationScope( + tx: Transaction | undefined, + callback: (tx: Transaction) => Promise + ): Promise { + if (tx !== undefined) { + this.events.push(`join:${tx.name}`); + return callback(tx); + } + this.events.push("begin"); + this.#pending = []; + try { + const result = await callback({ name: "scope" }); + this.committed.push(...this.#pending); + this.events.push("commit"); + return result; + } catch (error: unknown) { + this.events.push("rollback"); + throw error; + } + } + } + + const scopedJob = defineJob({ + decode(value) { + events?.push("validate"); + return value; + }, + kind: "scoped", + }); + let events: string[] | undefined; + + const setup = ( + options: { + afterInsert?: () => void; + afterNext?: () => void; + } = {} + ) => { + const driver = new ScopedDriver(); + events = driver.events; + const client = new Client(driver, { + hooks: { + afterInsert: () => { + driver.events.push("afterInsert"); + options.afterInsert?.(); + }, + beforeInsert: () => { + driver.events.push("beforeInsert"); + }, + }, + insertMiddleware: [ + async (_context, next) => { + driver.events.push("middleware:before"); + const results = await next(); + driver.events.push("middleware:after"); + options.afterNext?.(); + return results; + }, + ], }); + return { client, driver }; + }; + + it("runs validation, middleware, hooks, and the write in one scope", async () => { + const { client, driver } = setup(); + + await client.insert(scopedJob, {}); + + expect(driver.events).toEqual([ + "begin", + "validate", + "middleware:before", + "beforeInsert", + "write:scope", + "afterInsert", + "middleware:after", + "commit", + ]); + expect(driver.committed).toEqual(["scoped"]); }); - describe("schema", () => { - it("passes empty schema prefix by default", async () => { - await client.insert(new SortArgs(["a"])); - expect(driver.lastOptions?.schemaPrefix).toBe(""); + it("rolls back when middleware throws after next()", async () => { + const failure = new Error("after next"); + const { client, driver } = setup({ + afterNext: () => { + throw failure; + }, }); - it("passes schema prefix when configured", async () => { - const schemaClient = new Client(driver, { schema: "private" }); - await schemaClient.insert(new SortArgs(["a"])); - expect(driver.lastOptions?.schemaPrefix).toBe('"private".'); + await expect(client.insert(scopedJob, {})).rejects.toBe(failure); + await expect( + client.insertMany([{ args: {}, job: scopedJob }]) + ).rejects.toBe(failure); + + expect(driver.events.filter((event) => event === "rollback")).toHaveLength( + 2 + ); + expect(driver.committed).toEqual([]); + }); + + it("rolls back when an afterInsert hook throws", async () => { + const failure = new Error("after insert"); + const { client, driver } = setup({ + afterInsert: () => { + throw failure; + }, }); - it("passes schema prefix to insertMany", async () => { - const schemaClient = new Client(driver, { schema: "custom" }); - await schemaClient.insertMany([new SortArgs(["a"])]); - expect(driver.lastOptions?.schemaPrefix).toBe('"custom".'); + await expect(client.insert(scopedJob, {})).rejects.toBe(failure); + + expect(driver.events.at(-1)).toBe("rollback"); + expect(driver.committed).toEqual([]); + }); + + it("joins a caller-owned transaction instead of opening one", async () => { + const { client, driver } = setup(); + + await client.insertMany([{ args: {}, job: scopedJob }], { + tx: { name: "caller" }, }); + + expect(driver.events[0]).toBe("join:caller"); + expect(driver.events).toContain("write:caller"); + expect(driver.events).not.toContain("begin"); }); }); diff --git a/js/src/client.ts b/js/src/client.ts index e246f6c20..39a08cc08 100644 --- a/js/src/client.ts +++ b/js/src/client.ts @@ -1,364 +1,1775 @@ import { createHash } from "node:crypto"; -import type { Driver, DriverOptions, JobInsertParams } from "./driver.js"; -import type { InsertOpts, UniqueOpts } from "./insert-opts.js"; -import type { JobArgs, JobRow, JobState } from "./job.js"; +import type { + ClientDriver, + DriverCapability, + DriverInsertResult, + InsertDriver, + InsertDriverOptions, + JobInsertParams, + QueueRow, + RuntimeDriver, +} from "./driver.js"; import { - JOB_STATE_AVAILABLE, - JOB_STATE_COMPLETED, - JOB_STATE_PENDING, - JOB_STATE_RETRYABLE, - JOB_STATE_RUNNING, - JOB_STATE_SCHEDULED, + ConfigurationError, + JobRunningError, + LifecycleError, + ValidationError, +} from "./errors.js"; +import { UnsupportedCapabilityError } from "./errors.js"; +import { EventHub } from "./events.js"; +import { EventDispatcher } from "./internal/event-dispatcher.js"; +import { bytesToHex } from "./internal/hex.js"; +import type { + EventSubscription, + RiverEvent, + RiverEventKind, + SubscribedEvent, + SubscribeOptions, +} from "./events.js"; +import type { + InsertContext, + InsertMiddleware, + RiverHooks, +} from "./extensions.js"; +import type { JobDefinition, JobDefinitionInput } from "./job-definition.js"; +import { prepareJobInput } from "./job-definition.js"; +import type { + InsertOptions, + NormalizedInsertOptions, + NormalizedUniqueOptions, + ResolvedInsertOptions, + UniqueOptions, +} from "./insert-options.js"; +import { + normalizeInsertOptions, + normalizeUniqueOptions, + parseUniquePath, + resolveScheduledAt, + uniquePeriodNanoseconds, +} from "./insert-options.js"; +import { + JOB_STATE, MAX_ATTEMPTS_DEFAULT, PRIORITY_DEFAULT, QUEUE_DEFAULT, } from "./job.js"; +import type { JobRow, JobState } from "./job.js"; +import type { JsonObject } from "./json.js"; +import { + isExactJsonNumber, + parseJson, + stringifyJson, + stringifySelectedUniqueJson, + stringifyUniqueJson, + toJsonObject, +} from "./json.js"; +import { + getJobArgsTransformers, + transformJobArgsForInsert, + transformJobArgsForRead, +} from "./job-args-transform.js"; +import type { JobArgsTransformer } from "./job-args-transform.js"; +import { + getJobInsertMetadataTransformers, + transformJobInsertMetadata, +} from "./job-insert-metadata-transform.js"; +import type { JobInsertMetadataTransformer } from "./job-insert-metadata-transform.js"; import { uniqueBitmaskFromStates } from "./unique-bitmask.js"; +import { assertRuntimeSupport } from "./runtime-support.js"; +import type { + JobListOptions, + JobListResult, + JobDeleteManyOptions, + JobUpdateOptions, + QueueListOptions, + QueueListResult, + QueueUpdateOptions, +} from "./query.js"; +import { + encodeJobListCursor, + encodeQueueCursor, + normalizeJobDeleteManyOptions, + normalizeJobListOptions, + normalizeJobUpdateOptions, + normalizeQueueListOptions, +} from "./query.js"; +import type { RuntimeBinding, RuntimeSettings } from "./runtime.js"; +import { + normalizeRuntimeSettings, + RunHandle, + RuntimeController, +} from "./runtime.js"; +import { driverRecord, pilotFactory } from "./internal/driver-registry.js"; +import { withHandle } from "./internal/handle-gate.js"; +import { InsertNotifyLimiter } from "./internal/insert-notify-limiter.js"; +import { millisecondsToDuration } from "./internal/duration.js"; +import type { PeriodicJobStore } from "./periodic-job-store.js"; +import type { + DriverRecord, + Pilot, + PilotAttempts, + PilotDatabase, + PilotFactory, + PilotHost, + PilotInterceptors, + PreparedInsertParams, +} from "./pilot.js"; +import { + PilotOperations, + serializedDatabase, + validatePreparedParams, +} from "./runtime/pilot-operations.js"; +import { + DEFAULT_FETCH_COOLDOWN_MS, + makeClientId, + resolveQueues, + type PilotQueueParser, +} from "./runtime/settings.js"; +import { + disablePeriodicJobs, + PeriodicJobs, + periodicOccurrenceOptions, +} from "./periodic.js"; +import { toRuntimeSettings, type ClientOptions } from "./options.js"; +import { queueLookupName } from "./identifiers.js"; +import type { PeerAttempts } from "./runtime/peer-attempts.js"; +import { + internalLogger, + resolveLogger, + type InternalLogger, +} from "./logger.js"; + +const NANOSECONDS_FROM_YEAR_ONE_TO_UNIX_EPOCH = + 62_135_596_800n * 1_000_000_000n; + +const DEFAULT_UNIQUE_STATES: readonly JobState[] = Object.freeze([ + JOB_STATE.available, + JOB_STATE.completed, + JOB_STATE.pending, + JOB_STATE.retryable, + JOB_STATE.running, + JOB_STATE.scheduled, +]); -const TAG_RE = /^\w[\w-]+\w$/; - -const DEFAULT_UNIQUE_STATES: JobState[] = [ - JOB_STATE_AVAILABLE, - JOB_STATE_COMPLETED, - JOB_STATE_PENDING, - JOB_STATE_RETRYABLE, - JOB_STATE_RUNNING, - JOB_STATE_SCHEDULED, -]; - -const REQUIRED_UNIQUE_STATES: JobState[] = [ - JOB_STATE_AVAILABLE, - JOB_STATE_PENDING, - JOB_STATE_RUNNING, - JOB_STATE_SCHEDULED, -]; - -/** Result of a single job insertion. */ -export interface InsertResult { - /** The inserted job row (or existing row if unique-skipped). */ - job: JobRow; - - /** True if insertion was skipped due to an existing unique job. */ - uniqueSkippedAsDuplicated: boolean; +export interface InsertResult { + /** Inserted row, or the conflicting row for a duplicate unique job. */ + readonly job: JobRow; + + /** Whether River inserted a new row or returned a unique conflict. */ + readonly status: "duplicate" | "inserted"; } -/** - * Pairs job args with per-job insertion options for use with `insertMany`. - * - * Example: - * - * await client.insertMany([ - * new InsertManyParams(new SortArgs(["b"]), { maxAttempts: 5 }), - * new SortArgs(["a"]), // raw job args use defaults - * ]); - */ -export class InsertManyParams { - readonly args: JobArgs; - readonly insertOpts?: InsertOpts; +/** One plain-object item in an insertMany call. */ +export interface InsertManyItem< + Definition extends JobDefinition = JobDefinition, +> { + readonly args: JobDefinitionInput; + readonly job: Definition; + readonly options?: InsertOptions; +} - constructor(args: JobArgs, insertOpts?: InsertOpts) { - this.args = args; - this.insertOpts = insertOpts; +/** Checks each batch item's args against its own item's definition. */ +export type CheckedInsertManyItems = { + readonly [Index in keyof Items]: Items[Index] extends { + readonly job: infer Definition extends JobDefinition; } + ? Omit & { + readonly args: JobDefinitionInput; + } + : never; +}; + +/** Exact result tuple corresponding to a heterogeneous insertion tuple. */ +export type InsertManyResults = { + readonly [Index in keyof Items]: Items[Index] extends { + readonly job: infer Definition extends JobDefinition; + } + ? InsertResult> + : never; +}; + +/** Operation options that may run in a caller-owned transaction. */ +export interface TransactionOptions { + /** + * Run the operation in this caller-owned transaction, such as a + * node-postgres client after `BEGIN`. Like River for Go, River runs its + * statements directly in it, opening no savepoint, and never commits or + * rolls it back. When the operation fails, writes it already made, such + * as a job inserted before insert middleware or a hook threw, stay in the + * transaction, so roll it back. To recover from a failure and continue + * the transaction, wrap the call in a savepoint of your own. + */ + tx?: Transaction; } -/** Options for constructing a River Client. */ -export interface ClientOpts { +/** + * Job insertion, available with every driver including insert-only ones such + * as `@riverqueue/driver-prisma`. Type producer-only code against this + * interface so it accepts full clients and test clients alike. + */ +export interface InsertClient { /** - * A non-default PostgreSQL schema where River tables are located. All - * table references in database queries will use this as a prefix. + * Validate and insert one job, optionally in a caller-owned transaction. * - * Defaults to empty, which causes queries to use the Postgres `search_path`. + * Resolves with `status: "duplicate"` and the existing row when a unique + * job already exists. */ - schema?: string; + insert( + definition: Definition, + args: JobDefinitionInput, + options?: InsertOptions & TransactionOptions + ): Promise>>; + + /** + * Insert a heterogeneous batch atomically, preserving input order in the + * result tuple. An empty batch resolves to `[]` without a database call. + */ + insertMany( + items: Items & CheckedInsertManyItems, + options?: TransactionOptions + ): Promise>; } -const SCHEMA_NAME_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/; +/** Job queries and controls, available as {@link Client.jobs}. */ +export interface JobOperations { + /** + * Cancel a job. A running attempt anywhere in the fleet is asked to stop + * cooperatively through its `signal`; returns null when the job does not + * exist. + */ + cancel( + id: bigint, + options?: TransactionOptions + ): Promise; + /** + * Delete a job that is not running, returning null when it does not exist. + * Throws {@link JobRunningError} for a running job. + */ + delete( + id: bigint, + options?: TransactionOptions + ): Promise; + /** Delete a bounded, explicitly filtered set of non-running jobs. */ + deleteMany( + options: JobDeleteManyOptions & TransactionOptions + ): Promise; + /** Get one job, returning null when it does not exist. */ + get( + id: bigint, + options?: TransactionOptions + ): Promise; + /** List jobs with exact, opaque keyset pagination through `nextCursor`. */ + list( + options?: JobListOptions & TransactionOptions + ): Promise; + /** + * Make a job that is not running immediately available for another + * attempt. Returns null when it does not exist. + */ + retry( + id: bigint, + options?: TransactionOptions + ): Promise; + /** + * Merge metadata into a job and set its output, like River for Go's + * `JobUpdate`. Returns null when the job does not exist. + */ + update( + id: bigint, + updates: JobUpdateOptions, + options?: TransactionOptions + ): Promise; +} /** - * Client for River that inserts jobs. Unlike the Go River client, this one - * can only insert jobs — job execution is handled by a Go River server. - * - * Used in conjunction with a driver: - * - * import { Client } from "riverqueue"; - * import { PgDriver } from "@riverqueue/driver-pg"; - * - * const client = new Client(new PgDriver(pool)); - * await client.insert(new SortArgs(["whale", "tiger"])); + * Queue queries and controls, available as {@link Client.queues}. Like River + * for Go, these look queues up by + * name without checking it against the queue-name grammar, so a name no + * queue can have is simply not found (null). + */ +export interface QueueOperations { + /** Get one queue, returning null when it does not exist. */ + get( + name: string, + options?: TransactionOptions + ): Promise; + /** List queues with opaque name pagination through `nextCursor`. */ + list( + options?: QueueListOptions & TransactionOptions + ): Promise; + /** + * Pause a queue across the fleet. Returns null when the queue does not + * exist. + * + * `"*"` pauses every queue, like River for Go, and always resolves null + * because no single queue row describes the result. + */ + pause( + name: string, + options?: TransactionOptions + ): Promise; + /** + * Resume a paused queue across the fleet. Returns null when the queue does + * not exist. + * + * `"*"` resumes every queue, like River for Go, and always resolves null + * because no single queue row describes the result. + */ + resume( + name: string, + options?: TransactionOptions + ): Promise; + /** Update queue metadata. Returns null when the queue does not exist. */ + update( + name: string, + updates: QueueUpdateOptions, + options?: TransactionOptions + ): Promise; +} + +/** + * A River client: typed job insertion plus, for drivers that support it, job + * and queue operations and the worker runtime. * - * To use a non-default schema: + * `Transaction` is the driver's caller-owned transaction type (for example a + * node-postgres client); it is inferred from the driver passed to + * `new Client(driver)`. + */ +export interface Client< + Transaction = unknown, +> extends InsertClient { + /** Job queries and controls. */ + readonly jobs: JobOperations; + /** + * Dynamically configurable leader-owned periodic jobs. Modifying them throws + * a {@link ConfigurationError} when the client was created with + * `leaderElectionDisabled: true`, because it never leads. + */ + readonly periodicJobs: PeriodicJobs; + /** Queue queries and controls. */ + readonly queues: QueueOperations; + + /** + * Ask whichever client currently leads maintenance to resign so another + * can take over, optionally when a caller-owned transaction commits. + */ + requestLeadershipResignation( + options?: TransactionOptions + ): Promise; + + /** + * Start the worker runtime: queues, workers, notifications, and (when + * elected leader) maintenance. A client starts at most once; stop it with + * the returned handle or `await using`. + */ + start(): Promise; + + /** + * Subscribe to bounded job, queue, leadership, and maintenance events, + * emitted after their database transitions commit. Filtering by `kinds` + * narrows the yielded event type. + * + * @example + * ```ts + * using failures = client.subscribe({ kinds: ["job_failed"] }); + * for await (const event of failures) { + * if (event.kind === "job_failed") report(event.job, event.error); + * } + * ``` + */ + subscribe( + options?: SubscribeOptions + ): EventSubscription>; +} + +/** + * Constructor for {@link Client}. * - * const client = new Client(new PgDriver(pool), { schema: "private" }); + * A driver that supports the worker runtime, such as `PgDriver` or + * `SqliteDriver`, produces a full {@link Client}. An insert-only driver such as + * `PrismaDriver` produces an {@link InsertClient}, so runtime-only calls fail at + * compile time (and with {@link UnsupportedCapabilityError} from untyped + * JavaScript). */ -export class Client { - private driver: Driver; - private schemaPrefix: string; // differs from `schema` in that it's the full prefix used in queries (e.g. `"my_schema".` or "") +export interface ClientConstructor { + /** Create a client for a driver that supports the worker runtime. */ + new ( + driver: ClientDriver, + options?: ClientOptions + ): Client; + /** Create an insert-only client for an insertion-only driver. */ + new ( + driver: ClientDriver, + options?: ClientOptions + ): InsertClient; + readonly prototype: Client; +} - constructor(driver: Driver, opts?: ClientOpts) { - this.driver = driver; +/** + * @internal The client implementation behind {@link Client}, which + * `PilotClient` extends. + */ +export class RiverClient implements Client { + readonly jobs: JobOperations; + readonly periodicJobs: PeriodicJobs; + readonly queues: QueueOperations; + /** The backend's name, for errors. */ + readonly #backend: string; + readonly #defaultInsertOptions: Readonly; + /** The registered operations of the client's driver. */ + readonly #driver: InsertDriver; + readonly #driverCapability: DriverCapability; + readonly #eventHub = new EventHub(); + readonly #eventHooks = new EventDispatcher( + (event) => this.#deliverEventHooks(event), + EVENT_HOOK_QUEUE_CAPACITY + ); + readonly #hooks: readonly RiverHooks[]; + readonly #insertMiddleware: readonly InsertMiddleware[]; + readonly #jobArgsTransformers: readonly Readonly[]; + readonly #jobInsertMetadataTransformers: readonly Readonly[]; + /** The client's pilot and its database, when it has one. */ + readonly #attached: AttachedPilot | undefined; + /** Whether the pilot's `init` is running, when the client is unusable. */ + #initializing = false; + /** Suppresses repeated insert notifications for a queue. */ + readonly #insertNotifyLimiter: InsertNotifyLimiter; + readonly #logger: InternalLogger; + readonly #operations: PilotOperations; + readonly #pilotQueueParser: PilotQueueParser | undefined; + /** Each configured queue's settings parsed by the pilot. */ + readonly #pilotQueueSettings: Readonly>; + readonly #runtimeOptions: Readonly; + #runtime: RuntimeController | undefined; - if (opts?.schema) { - if (!SCHEMA_NAME_RE.test(opts.schema)) { - throw new Error( - `invalid schema name: ${JSON.stringify(opts.schema)} (must match ${SCHEMA_NAME_RE})` - ); - } - this.schemaPrefix = `"${opts.schema}".`; + /** + * `pilotBinding` is private: only `PilotClient` can create one, and + * anything else, such as an extra argument from untyped JavaScript, is + * ignored. + */ + constructor( + driver: ClientDriver, + options: ClientOptions = {}, + pilotBinding?: unknown + ) { + assertRuntimeSupport(); + // Validates untyped JavaScript input, such as a pool passed by mistake. + const value: unknown = driver; + const record = + typeof value === "object" && value !== null + ? driverRecord(value) + : undefined; + if (record === undefined) { + throw new ConfigurationError( + "Client requires a River driver, such as new PgDriver(pool), not a " + + "database connection or pool; if riverqueue is installed twice, " + + "check `npm ls riverqueue`" + ); + } + this.#backend = record.backend; + this.#driver = record.operations; + this.#driverCapability = record.capability; + const createPilot = pilotFactory(pilotBinding); + const attached = + createPilot === undefined ? undefined : attachPilot(record, createPilot); + this.#attached = attached; + this.#operations = new PilotOperations(attached?.pilot, attached?.database); + this.#pilotQueueParser = pilotQueueParser(attached?.pilot); + const { defaultInsertOptions, ...runtimeOptions } = options; + this.#defaultInsertOptions = normalizeInsertOptions(defaultInsertOptions); + const settings = toRuntimeSettings(runtimeOptions); + if (attached === undefined) { + this.#pilotQueueSettings = {}; + this.#runtimeOptions = normalizeRuntimeSettings(settings); } else { - this.schemaPrefix = ""; + // The pilot owns some queue keys: parse them once, keeping River's own + // settings separately. A pilot's host needs the client ID now. + const { queues, ...rest } = settings; + const resolved = + queues === undefined + ? undefined + : resolveQueues( + queues, + this.#pilotQueueParser, + rest.fetchCooldownMs ?? DEFAULT_FETCH_COOLDOWN_MS + ); + this.#pilotQueueSettings = Object.freeze( + Object.fromEntries( + Object.entries(resolved ?? {}).map(([name, queue]) => [ + name, + queue.pilotSettings, + ]) + ) + ); + this.#runtimeOptions = normalizeRuntimeSettings({ + ...rest, + clientId: settings.clientId ?? makeClientId(), + ...(resolved === undefined + ? {} + : { + queues: Object.fromEntries( + Object.entries(resolved).map(([name, queue]) => [ + name, + queue.config, + ]) + ), + }), + }); + } + this.#insertNotifyLimiter = new InsertNotifyLimiter( + this.#runtimeOptions.fetchCooldownMs ?? DEFAULT_FETCH_COOLDOWN_MS + ); + this.#logger = internalLogger(resolveLogger(this.#runtimeOptions.logger)); + this.periodicJobs = new PeriodicJobs( + this.#runtimeOptions.periodicJobs ?? [] + ); + if (this.#runtimeOptions.leaderElectionDisabled === true) { + disablePeriodicJobs(this.periodicJobs); } + this.jobs = Object.freeze({ + cancel: (id, options) => + this.#serialize(options?.tx, () => this.#jobCancel(id, options)), + delete: (id, options) => + this.#serialize(options?.tx, () => this.#jobDelete(id, options)), + deleteMany: (options) => + this.#serialize(options.tx, () => this.#jobDeleteMany(options)), + get: (id, options) => + this.#serialize(options?.tx, () => this.#jobGet(id, options)), + list: (options) => + this.#serialize(options?.tx, () => this.#jobList(options)), + retry: (id, options) => + this.#serialize(options?.tx, () => this.#jobRetry(id, options)), + update: (id, updates, options) => + this.#serialize(options?.tx, () => + this.#jobUpdate(id, updates, options) + ), + } satisfies JobOperations); + this.queues = Object.freeze({ + get: (name, options) => + this.#serialize(options?.tx, () => this.#queueGet(name, options)), + list: (options) => + this.#serialize(options?.tx, () => this.#queueList(options)), + pause: (name, options) => + this.#serialize(options?.tx, () => this.#queuePause(name, options)), + resume: (name, options) => + this.#serialize(options?.tx, () => this.#queueResume(name, options)), + update: (name, updates, options) => + this.#serialize(options?.tx, () => + this.#queueUpdate(name, updates, options) + ), + } satisfies QueueOperations); + this.#hooks = Object.freeze([ + ...(this.#runtimeOptions.plugins?.flatMap((plugin) => + plugin.hooks === undefined ? [] : [plugin.hooks] + ) ?? []), + ...(this.#runtimeOptions.hooks === undefined + ? [] + : [this.#runtimeOptions.hooks]), + ]); + this.#insertMiddleware = Object.freeze([ + ...(this.#runtimeOptions.plugins?.flatMap( + (plugin) => plugin.insertMiddleware ?? [] + ) ?? []), + ...(this.#runtimeOptions.insertMiddleware ?? []), + ]); + this.#jobArgsTransformers = Object.freeze( + getJobArgsTransformers(this.#runtimeOptions.plugins) + ); + this.#jobInsertMetadataTransformers = Object.freeze( + getJobInsertMetadataTransformers(this.#runtimeOptions.plugins) + ); + if (attached !== undefined) this.#initPilot(attached); } - /** - * Insert a single job for work. Options include standard insertion options - * and an optional `tx` for running within a transaction. - */ - async insert( - args: JobArgs, - opts?: InsertOpts & { tx?: TTx } - ): Promise { - const params = this.makeInsertParams(args, opts ?? {}); - const [job, uniqueSkipped] = await this.driver.jobInsert( - params, - this.driverOptions(opts?.tx) + /** Cancel a job and cooperatively abort its matching local attempt. */ + async #jobCancel( + id: bigint, + options: TransactionOptions = {} + ): Promise { + const job = await this.#operations.cancel( + this.#runtimeDriver("job cancellation"), + validateJobId(id), + options.tx ); - return { job, uniqueSkippedAsDuplicated: uniqueSkipped }; + // A caller-owned transaction may still roll back. Its transactional + // notification is delivered only after commit and is the authoritative + // point at which a local attempt may be aborted. + if (job !== null && options.tx === undefined) + this.#runtime?.cancelLocal(job); + return job; } - /** - * Insert many jobs in a single batch operation. Accepts an array of - * `JobArgs` or `InsertManyParams` (which pairs args with per-job options). - * Pass `tx` to run the entire batch within a transaction. - */ - async insertMany( - args: (JobArgs | InsertManyParams)[], - opts?: { tx?: TTx } - ): Promise { - const allParams = args.map((arg) => { - if (arg instanceof InsertManyParams) { - return this.makeInsertParams(arg.args, arg.insertOpts || {}); - } - return this.makeInsertParams(arg, {}); - }); + /** Delete a non-running job, returning null when it does not exist. */ + async #jobDelete( + id: bigint, + options: TransactionOptions = {} + ): Promise { + const result = await this.#runtimeDriver("job deletion").jobDelete( + validateJobId(id), + driverOptions(options.tx) + ); + if (result.status === "not_found") return null; + if (result.status === "running") throw new JobRunningError(result.job.id); + return result.job; + } - // Deduplicate by unique key within the batch. PostgreSQL aborts a - // multi-row INSERT ... ON CONFLICT DO UPDATE if two rows conflict on - // the same unique key, so we must only send the first occurrence to the - // database and mark subsequent duplicates ourselves. - const { dedupedParams, resultMapping } = - this.deduplicateByUniqueKey(allParams); + /** Delete a bounded, explicitly filtered set of non-running jobs. */ + async #jobDeleteMany( + options: JobDeleteManyOptions & TransactionOptions + ): Promise { + const { tx, ...deleteOptions } = options; + return this.#runtimeDriver("bulk job deletion").jobDeleteMany( + normalizeJobDeleteManyOptions(deleteOptions), + driverOptions(tx) + ); + } - const dbResults = await this.driver.jobInsertMany( - dedupedParams, - this.driverOptions(opts?.tx) + /** Get one job exactly, returning null when it does not exist. */ + async #jobGet( + id: bigint, + options: TransactionOptions = {} + ): Promise { + return this.#runtimeDriver("job queries").jobGet( + validateJobId(id), + driverOptions(options.tx) ); + } - return resultMapping.map((mapping) => { - if ("duplicateOf" in mapping) { - const [job] = dbResults[mapping.duplicateOf] as [JobRow, boolean]; - return { job, uniqueSkippedAsDuplicated: true }; + /** Insert one validated job, optionally in a caller-owned transaction. */ + async insert( + definition: Definition, + args: JobDefinitionInput, + options: InsertOptions & TransactionOptions = {} + ): Promise>> { + return this.#operationScope(options.tx, async (tx) => { + const params = await this.#makeInsertParams(definition, args, options); + const [result] = await this.#runInsertExtensions("insert", [params], tx); + if (result === undefined) { + throw new Error("insertion adapter returned no result"); } - const [job, uniqueSkipped] = dbResults[mapping.index] as [ - JobRow, - boolean, - ]; - return { job, uniqueSkippedAsDuplicated: uniqueSkipped }; + return result as InsertResult>; }); } - private deduplicateByUniqueKey(params: JobInsertParams[]): { - dedupedParams: JobInsertParams[]; - resultMapping: ({ index: number } | { duplicateOf: number })[]; - } { - const uniqueKeyToIndex = new Map(); - const dedupedParams: JobInsertParams[] = []; - const resultMapping: ({ index: number } | { duplicateOf: number })[] = []; - - for (const p of params) { - if (p.uniqueKey) { - const hexKey = Buffer.from(p.uniqueKey).toString("hex"); - const existing = uniqueKeyToIndex.get(hexKey); - if (existing !== undefined) { - resultMapping.push({ duplicateOf: existing }); - continue; - } - uniqueKeyToIndex.set(hexKey, dedupedParams.length); + /** Insert a heterogeneous batch atomically while preserving input order. */ + async insertMany( + items: Items & CheckedInsertManyItems, + options: TransactionOptions = {} + ): Promise> { + if (items.length === 0) return [] as unknown as InsertManyResults; + return this.#operationScope(options.tx, async (tx) => { + const allParams: JobInsertParams[] = []; + for (const item of items) { + allParams.push( + await this.#makeInsertParams(item.job, item.args, item.options ?? {}) + ); } - resultMapping.push({ index: dedupedParams.length }); - dedupedParams.push(p); - } - return { dedupedParams, resultMapping }; + const results = await this.#runInsertExtensions( + "insertMany", + allParams, + tx + ); + if (results.length !== allParams.length) { + throw new Error( + `insertion adapter returned ${results.length} results for ${allParams.length} jobs` + ); + } + return results as unknown as InsertManyResults; + }); } - private driverOptions(tx?: TTx): DriverOptions { - return { schemaPrefix: this.schemaPrefix, tx }; + /** List jobs with exact, opaque keyset pagination. */ + async #jobList( + options: JobListOptions & TransactionOptions = {} + ): Promise { + const { tx, ...listOptions } = options; + const params = normalizeJobListOptions(listOptions); + const jobs = await this.#runtimeDriver("job queries").jobList( + params, + driverOptions(tx) + ); + const last = jobs.at(-1); + return { + jobs, + nextCursor: + last === undefined || jobs.length < params.limit + ? null + : encodeJobListCursor(last, params), + }; } - private makeInsertParams( - args: JobArgs, - insertOpts: InsertOpts - ): JobInsertParams { - if (!args.kind) { - throw new Error("args must have a non-empty kind"); + async #queueGet( + name: string, + options: TransactionOptions = {} + ): Promise { + return this.#runtimeDriver("queue queries").queueGet( + queueLookupName(name), + driverOptions(options.tx) + ); + } + + /** List dynamic queues with opaque name pagination. */ + async #queueList( + options: QueueListOptions & TransactionOptions = {} + ): Promise { + const { tx, ...listOptions } = options; + const params = normalizeQueueListOptions(listOptions); + const queues = await this.#runtimeDriver("queue queries").queueList( + params, + driverOptions(tx) + ); + const last = queues.at(-1); + return { + nextCursor: + last === undefined || queues.length < params.limit + ? null + : encodeQueueCursor(last), + queues, + }; + } + + /** Pause a queue after the backend transition commits. */ + async #queuePause( + name: string, + options: TransactionOptions = {} + ): Promise { + const queue = await this.#runtimeDriver("queue pause").queuePause( + queueLookupName(name), + driverOptions(options.tx) + ); + if (queue !== null && options.tx === undefined) { + this.#runtime?.applyCommittedQueueControl(queue); + await this.#emit({ + at: Temporal.Now.instant(), + kind: "queue_paused", + queue, + }); } + // "*" returns no row, so have this client's queues reread their pause + // state now instead of at the next control poll. + if (name === "*" && options.tx === undefined) { + this.#runtime?.wakeQueueControl(); + } + return queue; + } - const encodedArgs = this.encodeArgs(args); + /** Resume a queue after the backend transition commits. */ + async #queueResume( + name: string, + options: TransactionOptions = {} + ): Promise { + const queue = await this.#runtimeDriver("queue resume").queueResume( + queueLookupName(name), + driverOptions(options.tx) + ); + if (queue !== null && options.tx === undefined) { + this.#runtime?.applyCommittedQueueControl(queue); + await this.#emit({ + at: Temporal.Now.instant(), + kind: "queue_resumed", + queue, + }); + } + // "*" returns no row, so have this client's queues reread their pause + // state now instead of at the next control poll. + if (name === "*" && options.tx === undefined) { + this.#runtime?.wakeQueueControl(); + } + return queue; + } - const argsInsertOpts: InsertOpts = args.insertOpts || {}; + async #queueUpdate( + name: string, + updates: QueueUpdateOptions, + options: TransactionOptions = {} + ): Promise { + const params = + updates.metadata === undefined + ? {} + : { metadata: toJsonObject(updates.metadata) }; + const queue = await this.#runtimeDriver("queue updates").queueUpdate( + queueLookupName(name), + params, + driverOptions(options.tx) + ); + if ( + queue !== null && + options.tx === undefined && + updates.metadata !== undefined + ) { + await this.#emit({ + at: Temporal.Now.instant(), + kind: "queue_updated", + queue, + }); + } + return queue; + } - const scheduledAt = insertOpts.scheduledAt || argsInsertOpts.scheduledAt; + /** Ask whichever runtime currently leads to resign, optionally on commit. */ + async requestLeadershipResignation( + options: TransactionOptions = {} + ): Promise { + await this.#serialize(options.tx, () => + this.#requestLeadershipResignation(options) + ); + } - const params: JobInsertParams = { - encodedArgs, - kind: args.kind, - maxAttempts: - insertOpts.maxAttempts || - argsInsertOpts.maxAttempts || - MAX_ATTEMPTS_DEFAULT, - priority: - insertOpts.priority || argsInsertOpts.priority || PRIORITY_DEFAULT, - queue: insertOpts.queue || argsInsertOpts.queue || QUEUE_DEFAULT, - scheduledAt: scheduledAt || new Date(), - state: scheduledAt ? JOB_STATE_SCHEDULED : JOB_STATE_AVAILABLE, - tags: this.validateTags(insertOpts.tags || argsInsertOpts.tags || []), - uniqueKey: null, - uniqueStates: null, - }; + async #requestLeadershipResignation( + options: TransactionOptions + ): Promise { + const driver = this.#runtimeDriver("leadership resignation requests"); + const request = driver.runtimeRequestLeadershipResignation?.bind(driver); + if (request === undefined) { + throw new UnsupportedCapabilityError( + this.#backend, + "leadership resignation requests" + ); + } + await request(driverOptions(options.tx)); + } - const uniqueOpts = insertOpts.uniqueOpts || argsInsertOpts.uniqueOpts; - if (uniqueOpts && this.hasUniqueConstraints(uniqueOpts)) { - const [uniqueKey, uniqueStates] = this.makeUniqueKeyAndBitmask( - params, - uniqueOpts + /** Make a non-running job immediately eligible for another attempt. */ + async #jobRetry( + id: bigint, + options: TransactionOptions = {} + ): Promise { + return this.#operations.retry( + this.#runtimeDriver("job retry"), + validateJobId(id), + options.tx + ); + } + + /** Start the configured worker runtime exactly once. */ + async start(): Promise { + if (this.#runtime !== undefined) { + throw new LifecycleError( + "client runtime has already been started; a Client starts at most once, so create a new Client to start again" ); - params.uniqueKey = uniqueKey; - params.uniqueStates = uniqueStates; } + const driver = this.#runtimeDriver("runtime"); + const binding: RuntimeBinding = { + allowInsertNotifications: (queues) => + this.#insertNotifyLimiter.allow(queues), + backend: this.#backend, + ...(this.#attached === undefined + ? {} + : { + database: this.#attached.database, + pilot: this.#attached.pilot, + }), + operations: this.#operations, + pilotQueueParser: this.#pilotQueueParser, + pilotQueueSettings: this.#pilotQueueSettings, + }; + this.#runtime = new RuntimeController( + this, + driver, + { + drain: () => this.#eventHooks.drain(), + emit: (event) => this.#emit(event), + }, + this.periodicJobs, + this.#runtimeOptions, + binding + ); + try { + await this.#runtime.ready; + } catch (error: unknown) { + // A failed start tears down what it started before rejecting. + await this.#runtime.completed.catch(() => undefined); + throw error; + } + return new RunHandle(this.#runtime); + } - return params; + /** Subscribe to bounded post-transition observations. */ + subscribe( + options: SubscribeOptions = {} + ): EventSubscription> { + return this.#eventHub.subscribe(options); } - private hasUniqueConstraints(uniqueOpts: UniqueOpts): boolean { - return !!( - uniqueOpts.byArgs || - uniqueOpts.byPeriod || - uniqueOpts.byQueue || - uniqueOpts.byState || - uniqueOpts.excludeKind + async #jobUpdate( + id: bigint, + updates: JobUpdateOptions, + options: TransactionOptions = {} + ): Promise { + return this.#runtimeDriver("job updates").jobUpdate( + validateJobId(id), + normalizeJobUpdateOptions(updates), + driverOptions(options.tx) ); } - private encodeArgs(args: JobArgs): string { - // If toJSON() is defined, JSON.stringify will call it automatically, - // giving the implementation full control over serialization. - const argsAny = args as unknown as Record; - if (typeof argsAny.toJSON === "function") { - return JSON.stringify(args); + #runtimeDriver(capability: string): RuntimeDriver { + this.#assertConstructed(); + if (this.#driverCapability === "runtime") { + return this.#driver as RuntimeDriver; } + throw new UnsupportedCapabilityError(this.#backend, capability); + } - // Otherwise, serialize all properties except non-data fields. - const obj = { ...argsAny }; - delete obj.kind; - delete obj.insertOpts; - return JSON.stringify(obj); + async #emit(event: RiverEvent): Promise { + this.#eventHub.publish(event); + if (this.#hooks.some((hooks) => hooks.onEvent !== undefined)) { + await this.#eventHooks.enqueue(event); + } } - private makeUniqueKeyAndBitmask( - params: JobInsertParams, - uniqueOpts: UniqueOpts - ): [Uint8Array, string] { - // It's extremely important here that this unique key format and algorithm - // match the one in the main River library _exactly_. Don't change them - // unless they're updated everywhere. - let uniqueKeyStr = ""; + async #deliverEventHooks(event: RiverEvent): Promise { + for (const hooks of this.#hooks) { + if (hooks.onEvent === undefined) continue; + try { + await hooks.onEvent(event); + } catch (cause: unknown) { + this.#logger.error("River onEvent hook failed", { + error: + cause instanceof Error + ? (cause.stack ?? cause.message) + : String(cause), + eventKind: event.kind, + }); + } + } + } - if (!uniqueOpts.excludeKind) { - uniqueKeyStr += `&kind=${params.kind}`; + /** + * Run one insertion in a driver operation scope, like River for Go's + * `dbutil.WithTxV`: without `tx`, validation, insert middleware, hooks, + * and the write share one transaction River owns, so an error anywhere, + * even after middleware's `next()` returns, rolls the jobs back. + */ + #operationScope( + tx: Transaction | undefined, + callback: (tx: Transaction | undefined) => Promise + ): Promise { + try { + this.#assertConstructed(); + } catch (error: unknown) { + return Promise.reject(error); } + const driver = this.#driver; + return this.#serialize(tx, () => + driver.operationScope === undefined + ? callback(tx) + : driver.operationScope(tx, callback) + ); + } - if (uniqueOpts.byArgs) { - const parsedArgs = JSON.parse(params.encodedArgs) as Record< - string, - unknown - >; - let filteredArgs: Record; - - if (Array.isArray(uniqueOpts.byArgs)) { - filteredArgs = {}; - for (const key of uniqueOpts.byArgs) { - if (key in parsedArgs) { - filteredArgs[key] = parsedArgs[key]; - } - } - } else { - filteredArgs = parsedArgs; - } + /** + * Run an operation in the caller's transaction `tx` once no other + * operation of this client's pilot holds it, when the client has a pilot. + * An intercepted operation runs statements on `tx` across its + * interceptor's awaits, so operations sharing `tx` must not interleave. + */ + #serialize( + tx: Transaction | undefined, + run: () => Promise + ): Promise { + if (tx === undefined || !this.#operations.serializes) return run(); + return withHandle(tx, run); + } - // Sort keys for deterministic output matching other River clients. - const sortedArgs: Record = {}; - for (const key of Object.keys(filteredArgs).sort()) { - sortedArgs[key] = filteredArgs[key]; + async #runInsertExtensions( + operation: InsertContext["operation"], + params: readonly JobInsertParams[], + tx: Transaction | undefined, + signal?: AbortSignal + ): Promise { + // A job without a schedule is stored with the database's time; hooks + // and middleware see when the insertion was requested. + const requestedAt = Temporal.Now.instant(); + const context: InsertContext = { + operation, + requests: Object.freeze( + params.map((paramsItem) => + Object.freeze({ + args: paramsItem.args, + definition: insertDefinitions.get(paramsItem), + kind: paramsItem.kind, + maxAttempts: paramsItem.maxAttempts, + metadata: paramsItem.metadata, + priority: paramsItem.priority, + queue: paramsItem.queue, + scheduledAt: paramsItem.scheduledAt ?? requestedAt, + state: paramsItem.state, + tags: paramsItem.tags, + unique: paramsItem.uniqueKey !== null, + }) + ) + ), + }; + const originals = params.map( + (row) => insertOriginalEncodedArgs.get(row) ?? row.encodedArgs + ); + const database = async (): Promise => { + return this.#operations.insert( + operation, + params, + tx, + async (rows, scopeTx) => { + rejectRepeatedUniqueKeys(rows); + const results = + operation === "insert" + ? [ + await this.#driver.jobInsert( + rows[0] as JobInsertParams, + driverOptions(scopeTx) + ), + ] + : await this.#driver.jobInsertMany(rows, driverOptions(scopeTx)); + await this.#notifyInsert(rows, scopeTx); + return results; + }, + signal, + originals + ); + }; + // Like River for Go, insert hooks run inside the innermost insert + // middleware, around the database write. + const hooked = async (): Promise => { + for (const hooks of this.#hooks) { + await hooks.beforeInsert?.(context); } - uniqueKeyStr += `&args=${JSON.stringify(sortedArgs)}`; + const results = await database(); + for (const hooks of this.#hooks) { + await hooks.afterInsert?.(context, results); + } + return results; + }; + const invoke = composeInsertMiddleware( + this.#insertMiddleware, + context, + hooked + ); + const storageResults = await invoke(); + const results: DriverInsertResult[] = []; + for (const result of storageResults) { + const args = transformJobArgsForRead( + this.#jobArgsTransformers, + result.job.kind, + result.job.args + ); + results.push( + args === result.job.args + ? result + : { ...result, job: { ...result.job, args } } + ); + } + return results; + } + + /** Reject use of the client while its pilot's `init` runs. */ + #assertConstructed(): void { + if (this.#initializing) { + throw new LifecycleError( + "the client can't be used until its construction returns" + ); } + } - if (uniqueOpts.byPeriod) { - const lowerBound = this.truncateTime( - params.scheduledAt, - uniqueOpts.byPeriod + /** Call the pilot's `init` with its host, once, synchronously. */ + #initPilot(attached: AttachedPilot): void { + const { database, pilot } = attached; + const attempts: PilotAttempts = { + claim: (attempt, run) => + this.#withPeerAttempts((peers) => + peers.claim(attempt, run as Parameters[1]) + ), + complete: (attempt, outcomes) => + this.#withPeerAttempts((peers) => peers.complete(attempt, outcomes)), + }; + const host: PilotHost = { + attempts: Object.freeze(attempts), + client: this, + clientId: this.#runtimeOptions.clientId ?? "", + database, + insertPrepared: (params, options) => + this.#insertPrepared(params, options), + logger: resolveLogger(this.#runtimeOptions.logger), + notifyCommitted: (results) => { + this.#notifyCommitted(results); + }, + producerReportInterval: millisecondsToDuration( + this.#runtimeOptions.queueHeartbeatIntervalMs ?? 30_000 + ), + workerKinds: Object.freeze([ + ...(this.#runtimeOptions.workers?.kinds() ?? []), + ]), + }; + Object.freeze(host); + if (pilot.init === undefined) return; + // `init` is typed as returning nothing; check untyped pilots anyway. + const init: (host: PilotHost) => unknown = + pilot.init.bind(pilot); + this.#initializing = true; + let returned: unknown; + try { + returned = init(host); + } finally { + this.#initializing = false; + } + if (isThenable(returned)) { + void Promise.resolve(returned).catch(() => undefined); + throw new ConfigurationError( + "a pilot's init must run synchronously, without I/O" ); - uniqueKeyStr += `&period=${this.formatTimeUTC(lowerBound)}`; } + } - if (uniqueOpts.byQueue) { - uniqueKeyStr += `&queue=${params.queue}`; + /** + * Run `operation` on the running runtime's peer attempts, for + * {@link PilotHost.attempts}. It starts synchronously. + */ + #withPeerAttempts( + operation: (peers: PeerAttempts) => Promise + ): Promise { + const peers = this.#runtime?.peerAttempts; + if (peers === undefined) { + return Promise.reject( + new LifecycleError("peer attempts require a running River runtime") + ); } + return operation(peers); + } - const uniqueKey = createHash("sha256").update(uniqueKeyStr).digest(); - const states = this.validateUniqueStates( - uniqueOpts.byState || DEFAULT_UNIQUE_STATES + /** Insert rows already prepared, for {@link PilotHost.insertPrepared}. */ + async #insertPrepared( + params: readonly PreparedInsertParams[], + options: { + readonly signal?: AbortSignal; + readonly tx?: Transaction; + } = {} + ): Promise { + const rows = validatePreparedParams(params); + options.signal?.throwIfAborted(); + if (rows.length === 0) return []; + return this.#operationScope(options.tx, (tx) => + this.#runInsertExtensions( + "insertMany", + rows.map((row) => this.#preparedInsertParams(row)), + tx, + options.signal + ) ); - const uniqueStates = uniqueBitmaskFromStates(states); + } - return [new Uint8Array(uniqueKey), uniqueStates]; + /** + * A prepared row as an ordinary insertion of it stores it: the client's + * insert metadata and argument transforms run on its stored arguments, + * without a job definition, and its other fields are kept. Stored + * arguments that aren't a JSON object skip the argument transforms, and + * everything else sees empty arguments, like River for Go's stand-in + * arguments for such rows. + */ + #preparedInsertParams(row: PreparedInsertParams): JobInsertParams { + const stored = parseJson(row.encodedArgs); + const isObject = + typeof stored === "object" && stored !== null && !Array.isArray(stored); + const args: JsonObject = isObject ? (stored as JsonObject) : {}; + const insertMetadata = transformJobInsertMetadata( + this.#jobInsertMetadataTransformers, + undefined, + row.kind, + args, + row.metadata, + row.queue, + row.state === JOB_STATE.pending + ); + const prepared: JobInsertParams = { + ...row, + ...(isObject + ? transformJobArgsForInsert( + this.#jobArgsTransformers, + undefined, + row.kind, + args, + row.encodedArgs + ) + : { args, encodedArgs: row.encodedArgs }), + metadata: insertMetadata.metadata, + state: insertMetadata.pending ? JOB_STATE.pending : row.state, + }; + insertOriginalEncodedArgs.set(prepared, row.encodedArgs); + return prepared; } - private truncateTime(time: Date, intervalSeconds: number): Date { - const epochSeconds = time.getTime() / 1000; - return new Date( - Math.floor(epochSeconds / intervalSeconds) * intervalSeconds * 1000 + /** Wake local producers for jobs another transaction owner committed. */ + #notifyCommitted(results: readonly DriverInsertResult[]): void { + const queues = new Set(); + for (const result of results) { + if (result.status === "inserted" && result.job.state === "available") { + queues.add(result.job.queue); + } + } + if (queues.size > 0) this.#runtime?.wakeQueues(queues); + } + + /** + * Notify producers of the queues of available jobs just inserted in + * `tx`, like River for Go's client, whether or not each job was a unique + * duplicate, skipping queues this client notified within its fetch + * cooldown. + */ + async #notifyInsert( + rows: readonly JobInsertParams[], + tx: Transaction | undefined + ): Promise { + const notifyInsert = this.#driver.notifyInsert?.bind(this.#driver); + if (notifyInsert === undefined) return; + const queues = this.#insertNotifyLimiter.allow( + rows.flatMap((row) => + row.state === JOB_STATE.available ? [row.queue] : [] + ) ); + if (queues.length > 0) await notifyInsert(queues, driverOptions(tx)); } - private formatTimeUTC(date: Date): string { - const pad = (n: number) => n.toString().padStart(2, "0"); - return ( - `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}` + - `T${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}Z` + async #makeInsertParams( + definition: Definition, + input: JobDefinitionInput, + callOptions: InsertOptions + ): Promise { + const args = await prepareJobInput(definition, input); + const { options, scheduledExplicitly } = resolveInsertOptions( + callOptions, + definition.defaults, + this.#defaultInsertOptions + ); + const insertMetadata = transformJobInsertMetadata( + this.#jobInsertMetadataTransformers, + definition, + definition.kind, + args, + options.metadata, + options.queue, + options.pending ); + const metadata = insertMetadata.metadata; + + const params: JobInsertParams = { + args, + encodedArgs: stringifyJson(args), + kind: definition.kind, + maxAttempts: options.maxAttempts, + metadata, + priority: options.priority, + queue: options.queue, + // Like River for Go, a job without a schedule takes the database's + // current time. + ...(scheduledExplicitly ? { scheduledAt: options.scheduledAt } : {}), + // A periodic occurrence's time doesn't make the job `scheduled`. + state: insertMetadata.pending + ? JOB_STATE.pending + : scheduledExplicitly && !periodicOccurrenceOptions.has(callOptions) + ? JOB_STATE.scheduled + : JOB_STATE.available, + tags: options.tags, + uniqueKey: null, + uniqueStates: null, + }; + + let finalizedParams = params; + if (options.unique !== undefined && hasUniqueConstraints(options.unique)) { + const [uniqueKey, uniqueStates] = buildUniqueKey(params, options.unique); + finalizedParams = { ...params, uniqueKey, uniqueStates }; + } + const transformed = transformJobArgsForInsert( + this.#jobArgsTransformers, + definition, + finalizedParams.kind, + finalizedParams.args, + finalizedParams.encodedArgs + ); + const prepared = { ...finalizedParams, ...transformed }; + insertDefinitions.set(prepared, definition); + insertOriginalEncodedArgs.set(prepared, finalizedParams.encodedArgs); + return prepared; } +} - private validateTags(tags: string[]): string[] { - for (const tag of tags) { - if (tag.length > 255) { - throw new Error("tags should be 255 characters or less"); +/** + * Create a River client from a driver. + * + * @example + * ```ts + * const client = new Client(new PgDriver(pool), { + * queues: { default: { maxWorkers: 50 } }, + * workers, + * }); + * ``` + */ +export const Client: ClientConstructor = RiverClient; + +/** A pilot a client attached, with the database its driver provided. */ +interface AttachedPilot { + readonly database: PilotDatabase; + readonly pilot: Pilot; +} + +/** Pilots already attached to a client; each belongs to one client. */ +const attachedPilots = new WeakSet(); + +const INTERCEPTED_OPERATIONS: ReadonlySet = new Set< + keyof PilotInterceptors +>(["cancel", "complete", "getStuck", "insert", "rescue", "retry"]); + +/** River's own public queue keys, which a pilot can't own. */ +const RIVER_QUEUE_KEYS: ReadonlySet = new Set([ + "fetchCooldown", + "maxWorkers", + "pollInterval", +]); + +/** + * Create a client's pilot from its driver's registered database and check + * its shape. + */ +function attachPilot( + record: DriverRecord, + createPilot: PilotFactory +): AttachedPilot { + if (record.database === undefined) { + throw new UnsupportedCapabilityError(record.backend, "pilots", { + message: + "this driver can't serve a pilot; use PgDriver constructed with a " + + "Pool, or SqliteDriver", + }); + } + // Statements a pilot runs in a caller's transaction wait their turn with + // the client's other operations on it. + const database = serializedDatabase(record.database); + const pilot: unknown = createPilot(database); + if (typeof pilot !== "object" || pilot === null || isThenable(pilot)) { + throw new ConfigurationError( + "a pilot factory must synchronously return a pilot object" + ); + } + if (attachedPilots.has(pilot)) { + throw new ConfigurationError( + "a pilot belongs to one client; create a new pilot for each client" + ); + } + const attached = pilot as Pilot; + validatePilot(attached); + attachedPilots.add(attached); + return { database, pilot: attached }; +} + +function validatePilot(pilot: Pilot): void { + const concurrency: unknown = pilot.completionConcurrency; + if ( + concurrency !== undefined && + (typeof concurrency !== "number" || + !Number.isSafeInteger(concurrency) || + concurrency < 1) + ) { + throw new ConfigurationError( + "a pilot's completionConcurrency must be a positive integer" + ); + } + const excluded: unknown = pilot.jobCleanerQueuesExcluded; + if ( + excluded !== undefined && + (!Array.isArray(excluded) || + !excluded.every((queue) => typeof queue === "string")) + ) { + throw new ConfigurationError( + "a pilot's jobCleanerQueuesExcluded must be an array of queue names" + ); + } + const store: unknown = pilot.periodicJobs; + if ( + store !== undefined && + (typeof store !== "object" || + store === null || + typeof (store as Partial).getAll !== "function" || + typeof (store as Partial).keepAliveAndReap !== + "function" || + typeof (store as Partial).upsertMany !== "function") + ) { + throw new ConfigurationError( + "a pilot's periodicJobs must implement getAll, keepAliveAndReap, and upsertMany" + ); + } + for (const method of [ + "init", + "maintenanceServices", + "services", + "startProducer", + ] as const) { + if (pilot[method] !== undefined && typeof pilot[method] !== "function") { + throw new ConfigurationError(`a pilot's ${method} must be a function`); + } + } + const intercept: unknown = pilot.intercept; + if (intercept !== undefined) { + if (typeof intercept !== "object" || intercept === null) { + throw new ConfigurationError("a pilot's intercept must be an object"); + } + for (const [name, value] of Object.entries(intercept)) { + if (!INTERCEPTED_OPERATIONS.has(name)) { + throw new ConfigurationError( + `a pilot can't intercept ${JSON.stringify(name)}` + ); } - if (!TAG_RE.test(tag)) { - throw new Error(`tag should match regex ${TAG_RE}`); + if (value !== undefined && typeof value !== "function") { + throw new ConfigurationError( + `a pilot's intercept.${name} must be a function` + ); } } - return tags; } + const queueOptions: unknown = pilot.queueOptions; + if (queueOptions === undefined) return; + const { keys, parse } = (queueOptions ?? {}) as { + readonly keys?: unknown; + readonly parse?: unknown; + }; + if ( + !Array.isArray(keys) || + typeof parse !== "function" || + !keys.every((key) => typeof key === "string" && key.length > 0) + ) { + throw new ConfigurationError( + "a pilot's queueOptions needs keys (non-empty strings) and a parse function" + ); + } + const seen = new Set(); + for (const key of keys as string[]) { + // River rejects queue keys ending in `Ms` before any owner sees them. + if (RIVER_QUEUE_KEYS.has(key) || seen.has(key) || /[a-z]Ms$/.test(key)) { + throw new ConfigurationError( + `a pilot can't own the queue key ${JSON.stringify(key)}` + ); + } + seen.add(key); + } +} + +/** The queue keys a pilot owns, bound to its parser. */ +function pilotQueueParser( + pilot: Pilot | undefined +): PilotQueueParser | undefined { + const options = pilot?.queueOptions; + if (options === undefined) return undefined; + return Object.freeze({ + keys: new Set(options.keys), + parse: (queue: string, config: Readonly>) => + options.parse(queue, config), + }); +} + +function isThenable(value: unknown): boolean { + return ( + (typeof value === "object" || typeof value === "function") && + value !== null && + typeof (value as { readonly then?: unknown }).then === "function" + ); +} + +/** Events buffered for `onEvent` hooks before emitters wait. */ +const EVENT_HOOK_QUEUE_CAPACITY = 1_024; + +/** The definition each prepared insertion came from, for extension inputs. */ +const insertDefinitions = new WeakMap(); + +/** + * Each prepared insertion's arguments before the client's argument + * transforms, for a pilot's insert interceptor. + */ +const insertOriginalEncodedArgs = new WeakMap(); + +function validateJobId(id: bigint): bigint { + if (typeof id !== "bigint" || id <= 0n) { + throw new ValidationError("job id must be a positive bigint"); + } + return id; +} + +/** + * Reject a batch in which a unique key appears more than once among the jobs + * whose state it covers, before anything is written. River for Go writes a + * batch in one statement, which PostgreSQL refuses when two rows claim the + * same key, and checks SQLite batches the same way. + */ +function rejectRepeatedUniqueKeys(params: readonly JobInsertParams[]): void { + const keys = new Set(); + for (const { state, uniqueKey, uniqueStates } of params) { + if (uniqueKey === null || uniqueKey.length === 0) continue; + if (uniqueStates === null || !uniqueStates.includes(state)) continue; + const key = bytesToHex(uniqueKey); + if (keys.has(key)) { + throw new ValidationError("unique key appears more than once in batch"); + } + keys.add(key); + } +} - private validateUniqueStates(states: JobState[]): JobState[] { - for (const required of REQUIRED_UNIQUE_STATES) { - if (!states.includes(required)) { - throw new Error(`byState should include required state '${required}'`); +function driverOptions( + tx: Transaction | undefined +): InsertDriverOptions | undefined { + return tx === undefined ? undefined : { tx }; +} + +function composeInsertMiddleware( + middleware: readonly InsertMiddleware[], + context: InsertContext, + database: () => Promise +): () => Promise { + return async () => { + const dispatch = async ( + index: number + ): Promise => { + const current = middleware[index]; + if (current === undefined) return database(); + let called = false; + return current(context, async () => { + if (called) { + throw new Error("insert middleware called next more than once"); + } + called = true; + return dispatch(index + 1); + }); + }; + return dispatch(0); + }; +} + +function hasUniqueConstraints(options: NormalizedUniqueOptions): boolean { + return ( + options.byArgs === true || + (Array.isArray(options.byArgs) && options.byArgs.length > 0) || + options.byPeriod !== undefined || + options.byQueue === true || + options.byState !== undefined || + options.excludeKind === true + ); +} + +/** + * Exact-version hashing seam shared by insertion and conformance. Like + * River for Go, a period key uses the job's scheduled time, or the current + * time for a job without one. + */ +export function buildUniqueKey( + params: Pick, + options: UniqueOptions +): [Uint8Array, readonly JobState[]] { + const normalized = normalizeUniqueOptions(options); + let uniqueKeyString = ""; + + if (options.excludeKind !== true) { + uniqueKeyString += `&kind=${params.kind}`; + } + + if (options.byArgs !== undefined) { + uniqueKeyString += `&args=${encodeUniqueArgs(params.args, options.byArgs)}`; + } + + const periodNanoseconds = uniquePeriodNanoseconds(normalized); + if (periodNanoseconds !== null) { + uniqueKeyString += `&period=${truncateInstant( + params.scheduledAt ?? Temporal.Now.instant(), + periodNanoseconds + ).toString({ smallestUnit: "second" })}`; + } + + if (options.byQueue === true) uniqueKeyString += `&queue=${params.queue}`; + + const uniqueKey = createHash("sha256").update(uniqueKeyString).digest(); + const states = + options.byState === undefined || options.byState.length === 0 + ? DEFAULT_UNIQUE_STATES + : options.byState; + // Validate the shared state-to-bit mapping here even though each backend + // performs its own physical encoding at the database boundary. + uniqueBitmaskFromStates(states); + return [new Uint8Array(uniqueKey), Object.freeze([...states])]; +} + +/** + * Encode the arguments part of a unique key exactly as River for Go does: + * the text hashed after `&args=`. With `byArgs: true` it is every top-level + * argument, keys sorted bytewise; with a list of paths it is the selected + * values assembled in sorted path order, or an empty string when none of the + * paths is present. An unescaped dot descends into an object; a backslash + * quotes the following character for a literal field name. Paths sort by + * their unescaped field names. Keys are written the way Go's `sjson` writes + * them, and nested values keep their own order. + * + * Arguments must be a JSON object. Like River for Go, `byArgs: true` hashes + * an empty array as `{}`. + * + * Extensions that derive other keys from job arguments use this so they + * hash arguments identically to River's unique keys. + * + * @throws {ValidationError} for arguments that aren't a JSON object (other + * than an empty array with `byArgs: true`), an invalid selected path, + * including one with a segment River for Go reads as an array index (an + * unsigned integer or `-1`), or an empty path list. + */ +export function encodeUniqueArgs( + args: JsonObject, + byArgs: true | readonly string[] +): string { + if (byArgs !== true) { + normalizeUniqueOptions({ byArgs }); + } + let uniqueArgs = uniqueArgsObject(args, byArgs === true); + // River's own intermediate objects for selected paths, as opposed to + // argument values copied into them. + const assembled = new Set(); + if (byArgs !== true) { + uniqueArgs = Object.create(null) as JsonObject; + assembled.add(uniqueArgs); + const paths = byArgs + .map((path) => { + const segments = parseUniquePath(path); + return { path, segments, sortKey: segments.join(".") }; + }) + .sort( + (a, b) => + Buffer.compare(Buffer.from(a.sortKey), Buffer.from(b.sortKey)) || + Buffer.compare(Buffer.from(a.path), Buffer.from(b.path)) + ); + const selectedPaths: (readonly string[])[] = []; + for (const { segments } of paths) { + // Selecting an object already includes its descendants. Avoid writing + // through that borrowed object when another path selects a child. + if ( + selectedPaths.some( + (selected) => + selected.length < segments.length && + selected.every((part, index) => part === segments[index]) + ) + ) + continue; + let value: unknown = args; + for (const segment of segments) { + value = + value !== null && + typeof value === "object" && + !Array.isArray(value) && + Object.hasOwn(value, segment) + ? (value as JsonObject)[segment] + : undefined; + } + if (value !== undefined) { + let target = uniqueArgs; + for (const segment of segments.slice(0, -1)) { + if (!Object.hasOwn(target, segment)) { + const child = Object.create(null) as JsonObject; + assembled.add(child); + target[segment] = child; + } + const child = target[segment]; + if ( + child === null || + typeof child !== "object" || + Array.isArray(child) + ) { + throw new ValidationError( + "unique.byArgs contains overlapping paths" + ); + } + target = child as JsonObject; + } + const leaf = segments.at(-1); + if (leaf === undefined) + throw new ValidationError("unique.byArgs contains an empty path"); + target[leaf] = value as JsonObject[string]; + selectedPaths.push(segments); } } - return states; } + let encodedArgs: string; + if (!Array.isArray(byArgs)) { + encodedArgs = stringifyUniqueJson(uniqueArgs); + } else if (Object.keys(uniqueArgs).length === 0) { + encodedArgs = ""; + } else { + encodedArgs = stringifySelectedUniqueJson(uniqueArgs, assembled); + } + return encodedArgs; +} + +/** + * Return `args` if it's a JSON object. River for Go rejects any other + * arguments for argument uniqueness, except that when every argument is + * hashed, an empty array is treated as an empty object. + */ +function uniqueArgsObject(args: unknown, allArgs: boolean): JsonObject { + if ( + args !== null && + typeof args === "object" && + !Array.isArray(args) && + !isExactJsonNumber(args) + ) { + return args as JsonObject; + } + if (allArgs && Array.isArray(args) && args.length === 0) { + return Object.create(null) as JsonObject; + } + throw new ValidationError("unique args must encode a JSON object"); +} + +function resolveInsertOptions( + callOptions: InsertOptions, + definition: Readonly, + client: Readonly +): { options: ResolvedInsertOptions; scheduledExplicitly: boolean } { + const call = normalizeInsertOptions(callOptions); + // Scheduling is one dimension: the most specific level that sets either an + // absolute time or a delay wins, so a call-site delay overrides a + // definition-level scheduledAt and vice versa. + const scheduling = [call, definition, client].find( + (level) => level.scheduledAt !== undefined || level.delay !== undefined + ); + const now = Temporal.Now.instant(); + const scheduledAt = + scheduling === undefined ? undefined : resolveScheduledAt(scheduling, now); + const unique = call.unique ?? definition.unique ?? client.unique; + + const options: ResolvedInsertOptions = { + maxAttempts: + call.maxAttempts ?? + definition.maxAttempts ?? + client.maxAttempts ?? + MAX_ATTEMPTS_DEFAULT, + metadata: call.metadata ?? definition.metadata ?? client.metadata ?? {}, + pending: call.pending ?? definition.pending ?? client.pending ?? false, + priority: + call.priority ?? + definition.priority ?? + client.priority ?? + PRIORITY_DEFAULT, + queue: call.queue ?? definition.queue ?? client.queue ?? QUEUE_DEFAULT, + scheduledAt: scheduledAt ?? now, + tags: call.tags ?? definition.tags ?? client.tags ?? [], + }; + if (unique !== undefined) options.unique = unique; + return { options, scheduledExplicitly: scheduledAt !== undefined }; +} + +function truncateInstant( + instant: Temporal.Instant, + intervalNanoseconds: bigint +): Temporal.Instant { + const absoluteNanoseconds = + instant.epochNanoseconds + NANOSECONDS_FROM_YEAR_ONE_TO_UNIX_EPOCH; + let period = absoluteNanoseconds / intervalNanoseconds; + if ( + absoluteNanoseconds < 0n && + absoluteNanoseconds % intervalNanoseconds !== 0n + ) { + period--; + } + return Temporal.Instant.fromEpochNanoseconds( + period * intervalNanoseconds - NANOSECONDS_FROM_YEAR_ONE_TO_UNIX_EPOCH + ); } diff --git a/js/src/driver-codecs.ts b/js/src/driver-codecs.ts new file mode 100644 index 000000000..efa1a483a --- /dev/null +++ b/js/src/driver-codecs.ts @@ -0,0 +1,437 @@ +import type { AttemptError, JobState } from "./job.js"; +import { JOB_STATE } from "./job.js"; + +const ALL_JOB_STATES: ReadonlySet = new Set(Object.values(JOB_STATE)); + +/** Go's zero `time.Time`, which River leaves in an `at` it can't read. */ +const GO_ZERO_TIME = Temporal.Instant.from("0001-01-01T00:00:00Z"); + +const ATTEMPT_ERROR_FIELDS = ["at", "attempt", "error", "trace"] as const; + +type AttemptErrorField = (typeof ATTEMPT_ERROR_FIELDS)[number]; + +/** An attempt error field's name and the JSON text of its value. */ +type AttemptErrorMember = readonly [AttemptErrorField, string]; + +/** + * Decode one persisted attempt error from its JSON text, as River for Go's + * drivers do when they read a job's `errors`. + * + * River always writes attempt errors in one shape, which decodes as Go's + * `encoding/json` decodes it. Because a job can't be read or worked unless + * all of its attempt errors decode, an element written by another tool or + * edited by hand that is valid JSON in any other shape decodes on a best + * effort basis instead: + * + * - Field names match case-insensitively, and unknown fields are ignored. + * - `at` accepts only what Go's `time.Time` does: RFC 3339 as Go's + * `time.Parse` reads it, taken from the string as written without + * unescaping it. Anything else is Go's zero time. + * - `attempt` accepts integers, and numbers or strings holding a number with + * an integral value no larger in magnitude than 2^53. Anything else is + * `0`. + * - `error` and `trace` keep any value other than a string or `null` as its + * compacted JSON text. + * - A string element is used as `error`, and any other element that isn't + * an object is kept as its compacted JSON text in `error`. + * + * Only text that isn't valid JSON throws. This tolerance is for database + * reads only: decoding a job's public JSON form stays strict. + */ +export function decodeAttemptError(json: string): AttemptError { + JSON.parse(json); + return decodeValidAttemptError(json.trim()); +} + +/** + * Decode a persisted JSON array of attempt errors, decoding each element + * like {@link decodeAttemptError}. `null` is empty. Text that isn't valid + * JSON, or that isn't an array, throws, so the job can be reported as + * undecodable. + */ +export function decodeAttemptErrors(json: string): AttemptError[] { + const parsed: unknown = JSON.parse(json); + if (parsed === null) return []; + if (!Array.isArray(parsed)) throw new TypeError("JSON is not an array"); + return jsonElements(json.trim()).map(decodeValidAttemptError); +} + +/** Validate a persisted job state, rejecting values River does not define. */ +export function decodeJobState(value: string): JobState { + if (!ALL_JOB_STATES.has(value)) { + throw new TypeError(`unknown River job state: ${JSON.stringify(value)}`); + } + return value as JobState; +} + +/** + * Decode one attempt error from valid, trimmed JSON text like Go's + * `riverdriver.UnmarshalAttemptError`: as `encoding/json` decodes it when it + * can, and leniently otherwise. + */ +function decodeValidAttemptError(json: string): AttemptError { + if (!json.startsWith("{")) { + return { + at: GO_ZERO_TIME, + attempt: 0, + error: lenientString(json), + trace: "", + }; + } + // Like Go, field names match case-insensitively. + const members: AttemptErrorMember[] = []; + for (const [name, value] of jsonMembers(json)) { + const folded = asciiLowerCase(name); + const field = ATTEMPT_ERROR_FIELDS.find((each) => each === folded); + if (field !== undefined) members.push([field, value]); + } + return strictAttemptError(members) ?? lenientAttemptError(members); +} + +/** + * Decode an attempt error's fields as Go's `encoding/json` does, in order, + * returning `undefined` if it would reject any of them. As in Go, `null` + * leaves a field as it was. + */ +function strictAttemptError( + members: readonly AttemptErrorMember[] +): AttemptError | undefined { + let at = GO_ZERO_TIME; + let attempt = 0; + let error = ""; + let trace = ""; + for (const [field, value] of members) { + if (value === "null") continue; + switch (field) { + case "at": { + const decoded = goTime(value); + if (decoded === undefined) return undefined; + at = decoded; + break; + } + case "attempt": { + const integer = /^-?\d+$/.test(value) ? int64(value) : undefined; + if (integer === undefined) return undefined; + attempt = integer; + break; + } + case "error": + case "trace": + if (!value.startsWith('"')) return undefined; + if (field === "error") error = goString(value); + else trace = goString(value); + break; + } + } + return { at, attempt, error, trace }; +} + +/** + * Decode an attempt error's fields leniently, like Go's fallback: each + * field from its last value, `null` included. + */ +function lenientAttemptError( + members: readonly AttemptErrorMember[] +): AttemptError { + const last = new Map(members); + const at = last.get("at"); + const attempt = last.get("attempt"); + return { + at: (at === undefined ? undefined : goTime(at)) ?? GO_ZERO_TIME, + attempt: attempt === undefined ? 0 : lenientInteger(attempt), + error: lenientString(last.get("error") ?? ""), + trace: lenientString(last.get("trace") ?? ""), + }; +} + +const INT64_MAX = 9_223_372_036_854_775_807n; +const INT64_MIN = -9_223_372_036_854_775_808n; +const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER); + +/** + * Leading and trailing white space as Go's `strings.TrimSpace` trims it, + * which differs from `String.prototype.trim`. + */ +const GO_SPACE_EDGES = + /^[\t\n\v\f\r \u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+|[\t\n\v\f\r \u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+$/g; + +/** + * Go's lenient `attempt`: an int64, or a number with an integral value no + * larger in magnitude than 2^53, from a JSON number or from a string holding + * one, which Go reads with `strconv`. + */ +function lenientInteger(json: string): number { + let text: string; + if (json.startsWith('"')) { + text = goString(json).replace(GO_SPACE_EDGES, ""); + } else if (/^[-\d]/.test(json)) { + text = json; + } else { + return 0; + } + if (/^[+-]?\d+$/.test(text)) { + const integer = int64(text); + if (integer !== undefined) return integer; + } + const number = goParseFloat(text); + // `+ 0` turns a negative zero into zero, as Go's `int` conversion does. + return Number.isInteger(number) && Math.abs(number) <= 2 ** 53 + ? number + 0 + : 0; +} + +/** + * Parse decimal integer text in Go's int64 range, saturating values beyond + * JavaScript's safe range as other persisted counts do. Returns `undefined` + * out of range. + */ +function int64(text: string): number | undefined { + const integer = BigInt(text); + if (integer < INT64_MIN || integer > INT64_MAX) return undefined; + if (integer > MAX_SAFE) return Number.MAX_SAFE_INTEGER; + if (integer < -MAX_SAFE) return -Number.MAX_SAFE_INTEGER; + return Number(integer); +} + +/** + * Parse text like Go's `strconv.ParseFloat`, decimal or hexadecimal with + * Go's digit separators, returning `NaN` for text Go rejects. Infinities and + * values Go reports out of range can't be attempts either, so they are + * `NaN` too. + */ +function goParseFloat(text: string): number { + if (!underscoresAllowed(text)) return Number.NaN; + const hex = /^([+-]?)0x([\da-f_]*)(?:\.([\da-f_]*))?p([+-]?[\d_]+)$/i.exec( + text + ); + if (hex !== null) { + const [, sign, whole = "", fraction = "", exponent = ""] = hex; + const fractionDigits = fraction.replaceAll("_", ""); + const digits = `${whole.replaceAll("_", "")}${fractionDigits}`; + if (digits === "") return Number.NaN; + const value = + Number(BigInt(`0x${digits}`)) * + 2 ** (Number(exponent.replaceAll("_", "")) - 4 * fractionDigits.length); + return sign === "-" ? -value : value; + } + if ( + !/^[+-]?(?:[\d_]+\.?[\d_]*|\.[\d_]+)(?:e[+-]?[\d_]+)?$/i.test(text) || + !/\d/.test(text) + ) { + return Number.NaN; + } + const value = Number(text.replaceAll("_", "")); + return Number.isFinite(value) ? value : Number.NaN; +} + +/** + * Whether a number's underscores are where Go's `strconv` allows them: each + * between digits, or between a base prefix and a digit. + */ +function underscoresAllowed(text: string): boolean { + if (!text.includes("_")) return true; + let body = text.replace(/^[+-]/, ""); + // A base prefix counts as a digit. + let saw: "!" | "0" | "^" | "_" = "^"; + const hex = /^0x/i.test(body); + if (hex) { + body = body.slice(2); + saw = "0"; + } + for (const character of body) { + if (hex ? /[\da-f]/i.test(character) : /\d/.test(character)) { + saw = "0"; + } else if (character === "_") { + if (saw !== "0") return false; + saw = "_"; + } else { + if (saw === "_") return false; + saw = "!"; + } + } + return saw !== "_"; +} + +/** + * Go's lenient `error` and `trace`: a string is used as is, `null` or a + * missing field is empty, and any other value is kept as its compacted JSON + * text. + */ +function lenientString(json: string): string { + if (json === "" || json === "null") return ""; + if (json.startsWith('"')) return goString(json); + return compactJson(json); +} + +/** + * Decode a JSON string as Go does, which replaces an unpaired surrogate with + * U+FFFD. + */ +function goString(json: string): string { + return (JSON.parse(json) as string).toWellFormed(); +} + +/** + * Decode an attempt error's `at` like Go 1.26's `time.Time.UnmarshalJSON`: + * a string holding a timestamp that Go's `time.Parse` reads with its RFC + * 3339 layout, taken as written without unescaping. Returns `undefined` for + * anything else. + */ +function goTime(json: string): Temporal.Instant | undefined { + if (json.length < 2 || !json.startsWith('"') || !json.endsWith('"')) { + return undefined; + } + return parseGoRfc3339(json.slice(1, -1)); +} + +/** + * Parse a timestamp as Go's `time.Parse` does with its RFC 3339 layout: + * `YYYY-MM-DD`, `T`, a one or two digit hour, `:MM:SS` in range without a + * leap second, an optional fraction introduced by `.` or `,` whose digits + * past nanoseconds are ignored, and `Z` or a `±hh:mm` offset of up to 24 + * hours and 60 minutes. + */ +function parseGoRfc3339(text: string): Temporal.Instant | undefined { + const match = + /^(\d{4})-(\d{2})-(\d{2})T(\d{1,2}):(\d{2}):(\d{2})(?:[.,](\d+))?(?:Z|([+-])(\d{2}):(\d{2}))$/.exec( + text + ); + if (match === null) return undefined; + const [, year, month, day, hour, minute, second, fraction = ""] = match; + const [sign, offsetHours = "0", offsetMinutes = "0"] = match.slice(8); + if ( + Number(hour) > 23 || + Number(minute) > 59 || + Number(second) > 59 || + Number(offsetHours) > 24 || + Number(offsetMinutes) > 60 + ) { + return undefined; + } + const nanoseconds = Number(fraction.slice(0, 9).padEnd(9, "0")); + let local: Temporal.PlainDateTime; + try { + local = Temporal.PlainDateTime.from( + { + day: Number(day), + hour: Number(hour), + microsecond: Math.floor(nanoseconds / 1_000) % 1_000, + millisecond: Math.floor(nanoseconds / 1_000_000), + minute: Number(minute), + month: Number(month), + nanosecond: nanoseconds % 1_000, + second: Number(second), + year: Number(year), + }, + { overflow: "reject" } + ); + } catch { + return undefined; + } + const offsetSeconds = + (sign === "-" ? -1 : 1) * + (Number(offsetHours) * 3_600 + Number(offsetMinutes) * 60); + return local + .toZonedDateTime("UTC") + .toInstant() + .subtract({ seconds: offsetSeconds }); +} + +/** + * Lower-case ASCII letters only. No other character folds to a letter of an + * attempt error's field names in Go. + */ +function asciiLowerCase(text: string): string { + return text.replace(/[A-Z]/g, (letter) => letter.toLowerCase()); +} + +const JSON_WHITESPACE: ReadonlySet = new Set([" ", "\t", "\n", "\r"]); + +/** + * Remove insignificant white space from valid JSON text without otherwise + * changing it, like Go's `json.Compact`. + */ +function compactJson(json: string): string { + let compacted = ""; + let index = 0; + while (index < json.length) { + if (json[index] === '"') { + const end = skipString(json, index); + compacted += json.slice(index, end); + index = end; + } else { + const character = json.charAt(index); + if (!JSON_WHITESPACE.has(character)) compacted += character; + index += 1; + } + } + return compacted; +} + +/** The text of each element of valid, trimmed JSON array text. */ +function jsonElements(json: string): string[] { + const elements: string[] = []; + let index = skipWhitespace(json, 1); + while (json[index] !== "]") { + const end = skipValue(json, index); + elements.push(json.slice(index, end)); + index = skipWhitespace(json, end); + if (json[index] === ",") index = skipWhitespace(json, index + 1); + } + return elements; +} + +/** Each member's decoded name and value text, of valid, trimmed object text. */ +function jsonMembers(json: string): [string, string][] { + const members: [string, string][] = []; + let index = skipWhitespace(json, 1); + while (json[index] !== "}") { + const nameEnd = skipString(json, index); + const name = JSON.parse(json.slice(index, nameEnd)) as string; + const valueStart = skipWhitespace(json, skipWhitespace(json, nameEnd) + 1); + const valueEnd = skipValue(json, valueStart); + members.push([name, json.slice(valueStart, valueEnd)]); + index = skipWhitespace(json, valueEnd); + if (json[index] === ",") index = skipWhitespace(json, index + 1); + } + return members; +} + +/** The index just past the valid JSON value starting at `start`. */ +function skipValue(json: string, start: number): number { + const first = json[start]; + if (first === '"') return skipString(json, start); + let index = start; + if (first === "{" || first === "[") { + let depth = 0; + while (index < json.length) { + const character = json[index]; + if (character === '"') { + index = skipString(json, index); + continue; + } + if (character === "{" || character === "[") depth += 1; + else if (character === "}" || character === "]") depth -= 1; + index += 1; + if (depth === 0) break; + } + return index; + } + while (index < json.length && !/[\s,\]}]/.test(json.charAt(index))) { + index += 1; + } + return index; +} + +/** The index just past the JSON string starting at `start`. */ +function skipString(json: string, start: number): number { + let index = start + 1; + while (json[index] !== '"') index += json[index] === "\\" ? 2 : 1; + return index + 1; +} + +function skipWhitespace(json: string, start: number): number { + let index = start; + while (JSON_WHITESPACE.has(json[index] ?? "")) index += 1; + return index; +} diff --git a/js/src/driver.ts b/js/src/driver.ts index 1be96413e..6f3a71527 100644 --- a/js/src/driver.ts +++ b/js/src/driver.ts @@ -1,50 +1,676 @@ -import type { JobRow, JobState } from "./job.js"; +import type { AttemptError, JobRow, JobState } from "./job.js"; +import type { JsonObject, JsonValue } from "./json.js"; + +/** Semantic result of deleting one job without deleting running work. */ +export type JobDeleteResult = + | { readonly job: JobRow; readonly status: "deleted" } + | { readonly job: JobRow; readonly status: "running" } + | { readonly status: "not_found" }; + +/** Bounded filters for deleting non-running jobs. */ +export interface JobDeleteManyParams { + readonly all: boolean; + readonly ids: readonly bigint[]; + readonly kinds: readonly string[]; + readonly limit: number; + readonly priorities: readonly number[]; + readonly queues: readonly string[]; + readonly states: readonly JobState[]; +} + +/** + * Where a job list resumes, relative to its ordering: after an ID alone, + * after a cursor job whose time field is null, or after a cursor job's time. + */ +export type JobListAfter = + | { readonly id: bigint; readonly kind: "id" } + | { readonly id: bigint; readonly kind: "nullTime" } + | { + readonly id: bigint; + readonly kind: "time"; + readonly time: Temporal.Instant; + }; + +/** + * Exact keyset boundary passed to full-engine backends. Like River for Go, + * `time` is the value of the field the list is ordered by, and `null` when + * ordering by ID or when that field is null for the cursor's job. For a field + * that can't be null for the listed states, a boundary without a `time` + * resumes after `id` alone. + */ +export interface JobListCursorValue { + readonly id: bigint; + readonly kind: string; + readonly queue: string; + readonly sortField: JobListOrderBy; + readonly time: Temporal.Instant | null; +} + +/** + * How a job list is ordered and where it resumes, which each backend renders + * as SQL so that every backend orders and pages identically. + */ +export interface JobListKeyset { + readonly after: JobListAfter | null; + readonly direction: SortDirection; + /** + * Whether the time field may be null for listed jobs. Nulls then sort + * explicitly last ascending and first descending, PostgreSQL's default, so + * every backend agrees and cursors can match them. + */ + readonly nullable: boolean; + /** The time column ordered before ID, or `null` to order by ID alone. */ + readonly timeField: JobListTimeField | null; +} + +/** + * The field jobs are listed by. `time` is the time field of the first listed + * state (`scheduled_at` when no state is listed), like River for Go. + */ +export type JobListOrderBy = "finalizedAt" | "id" | "scheduledAt" | "time"; + +/** A time column a job list can be ordered by. */ +export type JobListTimeField = "attempted_at" | "finalized_at" | "scheduled_at"; + +/** A list order. */ +export type SortDirection = "asc" | "desc"; + +/** Normalized, backend-neutral job list operation. */ +export interface JobListParams { + readonly after: JobListCursorValue | null; + readonly ids: readonly bigint[]; + readonly kinds: readonly string[]; + readonly limit: number; + readonly metadata: JsonObject | null; + readonly priorities: readonly number[]; + readonly queues: readonly string[]; + readonly sortDirection: SortDirection; + readonly sortField: JobListOrderBy; + readonly states: readonly JobState[]; + readonly tagsAll: readonly string[]; + readonly tagsAny: readonly string[]; +} + +/** Job update. Omitted fields leave the job unchanged. */ +export interface JobUpdateParams { + /** Merge these top-level keys into the job's metadata. */ + readonly metadata?: JsonObject; + /** Set the job's output at `metadata.output`. */ + readonly output?: JsonValue; +} + +/** Persisted dynamic queue row. */ +export interface QueueRow { + readonly createdAt: Temporal.Instant; + readonly metadata: JsonObject; + readonly name: string; + readonly pausedAt: Temporal.Instant | null; + readonly updatedAt: Temporal.Instant; +} + +/** Keyset pagination for listing queues, ordered by name. */ +export interface QueueListParams { + readonly limit: number; + readonly nameAfter: string | null; +} + +/** Changes to a queue's persisted settings. */ +export interface QueueUpdateParams { + readonly metadata?: JsonObject; +} + +/** One queue and capacity request in an atomic claim operation. */ +export interface JobClaimQueue { + readonly limit: number; + readonly name: string; +} + +/** Which jobs a claim may lock, and the client that claims them. */ +export interface JobClaimParams { + readonly attemptedBy: string; + /** + * Claim only jobs of these kinds, filtered before the limit and locking, + * or jobs of every kind when empty. + */ + readonly kinds: readonly string[]; + readonly queues: readonly JobClaimQueue[]; +} + +/** + * Jobs locked by {@link RuntimeDriver.jobClaim}, in the order they were + * claimed. Every job has been moved to `running`, including any whose row + * couldn't be fully decoded, so the runtime must finish an attempt for each + * of them. + */ +export interface JobClaimResult { + /** + * Why rows couldn't be fully decoded, by job ID. Absent or empty when + * every row decoded. River doesn't work such a job: its attempt fails with + * the decode error through the normal failure path. + */ + readonly decodeErrors?: ReadonlyMap; + /** + * Every claimed job, including those whose rows couldn't be fully decoded, + * which have the fields that couldn't be decoded left empty (`{}` or + * `[]`) and their errors in `decodeErrors`. + */ + readonly jobs: readonly JobRow[]; +} + +/** Attempt-identity-safe terminal command. */ +export interface JobCompletionCommand { + readonly attempt: number; + readonly attemptedBy: string; + /** + * Persist a `retry` or `snooze` as `available` rather than `retryable` or + * `scheduled` because its delay is within the scheduler interval, like + * River's near-future fast path. Producers claim it once `scheduledAt` + * passes. Whether the attempt is refunded still follows `kind`: a snooze + * or interruption refunds it and a retry never does. + */ + readonly available?: boolean; + readonly error: DriverAttemptError | null; + /** Captured handler-finish time for terminal transitions; otherwise null. */ + readonly finalizedAt: Temporal.Instant | null; + readonly id: bigint; + readonly kind: + "cancel" | "complete" | "discard" | "interrupt" | "retry" | "snooze"; + /** Atomically merged attempt metadata (resumable checkpoints, etc.). */ + readonly metadata?: JsonObject; + readonly output: JsonValue | null; + /** Distinguishes no output update from recording the JSON value `null`. */ + readonly outputSet: boolean; + readonly scheduledAt: Temporal.Instant | null; +} + +/** A backend-neutral notification hint. Notifications are never authoritative. */ +export interface RuntimeNotification { + readonly payload: string; + readonly topic: "control" | "insert" | "leadership"; +} + +/** + * One maintenance leadership term: the client that leads (`leaderId`), when it + * was elected, and when its lease expires unless renewed. A new election + * starts a new term with a new `electedAt`. + */ +export interface LeaderTerm { + readonly electedAt: Temporal.Instant; + readonly expiresAt: Temporal.Instant; + readonly leaderId: string; +} + +/** A leadership term as passed to leader-fenced driver operations. */ +export type RuntimeLeader = LeaderTerm; + +/** One semantic transition selected by the stuck-job rescuer. */ +export interface RuntimeJobRescue { + readonly error: AttemptError; + readonly finalizedAt: Temporal.Instant | null; + readonly id: bigint; + readonly scheduledAt: Temporal.Instant; + readonly state: "cancelled" | "discarded" | "retryable"; +} + +/** Retention horizons for one bounded leader-owned cleaner pass. */ +export interface RuntimeJobCleanupParams { + readonly cancelledBefore: Temporal.Instant | null; + readonly completedBefore: Temporal.Instant | null; + readonly discardedBefore: Temporal.Instant | null; + readonly limit: number; + /** Queues whose jobs the pass leaves alone. */ + readonly queuesExcluded?: readonly string[]; +} + +/** + * Bounds of one leader-owned maintenance batch. A backend that can cancel + * database work should stop the batch after `timeoutMs`, like PostgreSQL's + * `statement_timeout`; `signal` aborts at the timeout or when the leadership + * term ends. + */ +export interface RuntimeMaintenanceBatch { + readonly signal: AbortSignal; + /** The batch's timeout in milliseconds, or `null` for none. */ + readonly timeoutMs: number | null; +} + +/** Exact horizons for one leader-owned scheduler pass. */ +export interface RuntimeScheduleParams { + /** + * The queues among `queues` to send an insert notification for, each once, + * from the client's insert notification limiter. The pass calls it with + * the queue of every job it scheduled at or before `notificationHorizon`, + * and sends the notifications in its own transaction. + */ + readonly allowInsertNotifications: ( + queues: readonly string[] + ) => readonly string[]; + readonly limit: number; + /** Timestamp used for terminal metadata written by this pass. */ + readonly now: Temporal.Instant; + /** Scheduled jobs at or before this instant may wake waiting producers. */ + readonly notificationHorizon: Temporal.Instant; + /** Scheduled jobs at or before this instant may be promoted early. */ + readonly scheduledAtHorizon: Temporal.Instant; +} /** - * Internal insert parameters sent to drivers. This interface is meant for - * driver implementations and is subject to change. + * Bounds on a runtime operation's wait to start. `signal` aborts waiting + * for a connection or lock, such as when the runtime stops during an + * outage; an operation that has started always finishes, so no write is + * abandoned half done. */ +export interface RuntimeWaitOptions { + readonly signal?: AbortSignal; +} + +/** + * Options for {@link RuntimeDriver.jobClaim}. With `tx`, the claim runs in + * that transaction, which its owner commits or rolls back; without it, the + * claim commits on its own. + */ +export interface JobClaimOptions< + Transaction = unknown, +> extends RuntimeWaitOptions { + readonly tx?: Transaction; +} + +/** An attempt error as a completion command persists it. */ +export interface DriverAttemptError { + readonly at: Temporal.Instant; + readonly error: string; + readonly trace: string; +} + +/** A completion applies only when the claimed attempt still owns the row. */ +export interface JobCompletionResult { + readonly job: JobRow | null; + /** The {@link jobCompletionKey} of the command this result answers. */ + readonly key: string; + readonly status: "applied" | "stale"; +} + +/** + * Identify one completion command by its attempt: job ID, attempt number, and + * the claiming client. Drivers return it as {@link JobCompletionResult.key} + * so the runtime can match each result to the attempt that produced it. + */ +export function jobCompletionKey( + command: Pick +): string { + return `${command.id.toString(10)}:${command.attempt}:${command.attemptedBy}`; +} + +/** Remote cancellation of an attempt currently owned by this process. */ +export interface JobCancellationNotice { + readonly attemptedBy: string; + readonly id: bigint; +} + +/** Internal exact insertion parameters sent to a first-party adapter. */ export interface JobInsertParams { - encodedArgs: string; - kind: string; - maxAttempts: number; - priority: number; - queue: string; - scheduledAt: Date; - state: JobState; - tags: string[]; - uniqueKey: Uint8Array | null; - /** Bitmask string like "10110001" representing states for uniqueness. */ - uniqueStates: string | null; + readonly args: JsonObject; + /** + * The row's creation time. Omit it, as River's own inserts do, to use the + * current time. A caller that reinserts a job it took out of River sets it + * to keep the job's original creation time, like the `CreatedAt` of River + * for Go's driver insert parameters. + */ + readonly createdAt?: Temporal.Instant; + /** + * The arguments as JSON text, which drivers store instead of re-encoding + * `args`. + */ + readonly encodedArgs: string; + readonly kind: string; + readonly maxAttempts: number; + readonly metadata: JsonObject; + readonly priority: number; + readonly queue: string; + /** + * When the job becomes workable. Omit it, as River does for a job + * inserted without a schedule, to use the database's current time, like + * River for Go, so an application clock ahead of the database's doesn't + * delay the job. + */ + readonly scheduledAt?: Temporal.Instant; + readonly state: JobState; + readonly tags: readonly string[]; + readonly uniqueKey: Uint8Array | null; + /** Persisted states participating in uniqueness conflicts. */ + readonly uniqueStates: readonly JobState[] | null; +} + +/** A row returned by an insertion adapter. */ +export interface DriverInsertResult { + readonly job: JobRow; + readonly status: "duplicate" | "inserted"; } +/** Options passed to an insertion adapter operation. */ +export interface InsertDriverOptions { + /** Optional caller-owned transaction for this operation. */ + tx?: Transaction; +} + +/** A backend operation may be native-async or synchronously serialized. */ +export type BackendResult = PromiseLike | T; + +/** Whether a driver supports only insertion or the full worker runtime. */ +export type DriverCapability = "insert" | "runtime"; + +/** + * Type-level description of a River driver, used by `new Client(driver)` to + * infer the driver's transaction type and whether it supports the worker + * runtime. Applications never implement it; first-party drivers declare the + * marker with `declare readonly "~river"` so it has no runtime cost. + */ +export interface ClientDriver< + Transaction = unknown, + Capability extends DriverCapability = DriverCapability, +> { + /** Type-only marker. It is never set at runtime. */ + readonly "~river"?: + | { + readonly capability: Capability; + readonly transaction: Transaction; + } + | undefined; +} + +/** + * Transaction types contributed by installed River drivers. + * + * Driver packages augment this interface (for example `@riverqueue/driver-pg` + * adds node-postgres clients) so worker contexts accept exactly the + * transaction types of the drivers an application uses. Applications never + * need to augment it. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- augmented by driver packages +export interface RiverTransactionRegistry {} + /** - * Interface that database drivers must implement. River drivers translate - * the generic insert params into database-specific operations. + * Union of transaction types from installed drivers, or `unknown` when no + * driver package registered one. */ +export type RegisteredTransaction = [keyof RiverTransactionRegistry] extends [ + never, +] + ? unknown + : RiverTransactionRegistry[keyof RiverTransactionRegistry]; + /** - * Interface that database drivers must implement. The TTx type parameter - * represents the driver-specific transaction type (e.g. PoolClient for pg, - * PrismaClientLike for Prisma). + * Narrow protocol implemented by River's producer adapters. + * + * This is not the full runtime database engine boundary. PostgreSQL schema and + * other backend-specific configuration belong to the adapter constructor. */ -export interface Driver { - /** Insert a single job. */ +export interface InsertDriver< + Transaction = unknown, + Capability extends DriverCapability = "insert", +> extends ClientDriver { jobInsert( params: JobInsertParams, - options?: DriverOptions - ): Promise<[JobRow, boolean]>; + options?: InsertDriverOptions + ): BackendResult; - /** Insert multiple jobs in a single batch operation. */ jobInsertMany( - params: JobInsertParams[], - options?: DriverOptions - ): Promise<[JobRow, boolean][]>; + params: readonly JobInsertParams[], + options?: InsertDriverOptions + ): BackendResult; + + /** + * Send an insert notification for each of `queues`, in `options.tx` when + * given, like River for Go's `NotifyMany` on its insert topic. Insertion + * itself notifies nobody: after inserting available jobs, the client calls + * this in the same transaction for the queues its insert notification + * limiter allows. A driver without this method sends no insert + * notifications. + */ + notifyInsert?( + queues: readonly string[], + options?: InsertDriverOptions + ): BackendResult; + + /** + * Run one River operation in a transaction, like River for Go's + * `dbutil.WithTxV`. The client runs a whole insertion in one scope + * (validation, insert middleware, hooks, and the write), and the leader + * runs a durable periodic batch together with its next-run times. + * + * With `tx`, the operation joins that caller-owned transaction: the driver + * checks it and calls `callback` with it, and never commits or rolls it + * back. Without `tx`, the driver opens a transaction River owns, calls + * `callback` with a value that stands for it, commits when `callback` + * resolves, and rolls back when it rejects. + * + * The value passed to `callback` is only for River operations on this + * driver, passed as `{ tx }`. For some backends it is an opaque token + * rather than a usable connection. A driver without this method runs + * every operation without a scope. + */ + operationScope?( + tx: Transaction | undefined, + callback: (tx: Transaction) => Promise + ): Promise; } -/** Options passed from the Client to drivers on each operation. */ -export interface DriverOptions { - /** Schema-qualified table prefix (e.g. `"my_schema".`), or empty string for default. */ - schemaPrefix: string; - /** Optional transaction to run the operation within. */ - tx?: TTx; +/** + * Unstable semantic contract implemented by first-party full-engine backends. + * + * This SPI intentionally exposes no SQL or backend client types. It is public + * only so separately packaged first-party drivers can implement it; user code + * must not implement it. Its shape may change in any release before 1.0. + */ +export interface RuntimeDriver extends InsertDriver< + Transaction, + "runtime" +> { + /** Synchronous capability/configuration preflight before tasks are started. */ + runtimeStartPreflight?(options: { + readonly maintenance: boolean; + readonly notifications: boolean; + readonly reindex: boolean; + }): void; + + jobCancel( + id: bigint, + options?: InsertDriverOptions + ): BackendResult; + /** + * Lock available jobs for work, moving them to `running`. A locked row + * that can't be fully decoded doesn't fail the call; it is returned with + * its error in `decodeErrors` so the runtime fails its attempt instead of + * stranding it. + * `options.signal` aborts only waiting to start: a backend stops waiting + * for a connection or lock when it aborts, but never abandons a claim + * that has begun, so no job is claimed by a runtime that stopped. With + * `options.tx`, the claim runs in that transaction instead of committing + * on its own. + */ + jobClaim( + params: JobClaimParams, + options?: JobClaimOptions + ): BackendResult; + jobCompleteMany( + commands: readonly JobCompletionCommand[], + options?: { + readonly signal?: AbortSignal; + readonly tx?: Transaction; + } + ): BackendResult; + jobDelete( + id: bigint, + options?: InsertDriverOptions + ): BackendResult; + jobDeleteMany( + params: JobDeleteManyParams, + options?: InsertDriverOptions + ): BackendResult; + jobGet( + id: bigint, + options?: InsertDriverOptions + ): BackendResult; + jobList( + params: JobListParams, + options?: InsertDriverOptions + ): BackendResult; + jobRetry( + id: bigint, + options?: InsertDriverOptions + ): BackendResult; + jobUpdate( + id: bigint, + params: JobUpdateParams, + options?: InsertDriverOptions + ): BackendResult; + queueGet( + name: string, + options?: InsertDriverOptions + ): BackendResult; + queueList( + params: QueueListParams, + options?: InsertDriverOptions + ): BackendResult; + queuePause( + name: string, + options?: InsertDriverOptions + ): BackendResult; + queueResume( + name: string, + options?: InsertDriverOptions + ): BackendResult; + queueUpdate( + name: string, + params: QueueUpdateParams, + options?: InsertDriverOptions + ): BackendResult; + + /** Refresh one locally configured queue without replacing its controls. */ + runtimeQueueUpsert?( + name: string, + now: Temporal.Instant, + options?: RuntimeWaitOptions + ): BackendResult; + + /** + * Whether the database delivers notifications to listeners, detecting the + * server the first time, like River for Go's `InitDriver` followed by + * `SupportsListener`. A runtime whose database doesn't, such as YugabyteDB + * without `yb_enable_listen_notify`, polls instead as if `pollOnly` were + * set. A driver without this method always delivers them. + */ + runtimeDeliversNotifications?( + options?: RuntimeWaitOptions + ): BackendResult; + + /** Notification hints for inserts, controls, and leadership changes. */ + runtimeNotificationSubscribe?( + topics: readonly RuntimeNotification["topic"][], + signal: AbortSignal, + ready: () => void + ): AsyncIterable; + + /** Broadcast a request for whichever runtime leads to resign its term. */ + runtimeRequestLeadershipResignation?( + options?: InsertDriverOptions + ): BackendResult; + + /** + * Renew `held`, the exact term this client leads, or elect a new term when + * it holds none. Like Go River, a held term is renewed only while the + * persisted row still has its `elected_at`, and an unexpired row is never + * adopted, even one with this client's `leaderId`: another process using + * the same client ID must lose its term before a fresh one is elected. + */ + maintenanceLeaderAcquire?( + leaderId: string, + now: Temporal.Instant, + ttlMs: number, + held: RuntimeLeader | null, + options?: RuntimeWaitOptions + ): BackendResult; + /** Resign only the supplied exact term. */ + maintenanceLeaderResign?(leader: RuntimeLeader): BackendResult; + /** Move one bounded page of due jobs toward availability. */ + maintenanceSchedule?( + leader: RuntimeLeader, + params: RuntimeScheduleParams, + batch?: RuntimeMaintenanceBatch + ): BackendResult; + /** Read one stable page of attempts eligible for rescue. */ + maintenanceGetStuck?( + leader: RuntimeLeader, + attemptedBefore: Temporal.Instant, + afterId: bigint, + limit: number, + batch?: RuntimeMaintenanceBatch + ): BackendResult; + /** + * Rescue a previously inspected page with an attempted-at fence. + * + * `attemptedBefore` is the same horizon the pass passed to + * `maintenanceGetStuck`. Implementations update only rows that are still + * `running` with `attempted_at` strictly before it, so a job completed, + * released, or claimed again after it was selected is left untouched. + * With `options.tx`, the fenced update runs in that transaction. + */ + maintenanceRescue?( + leader: RuntimeLeader, + attemptedBefore: Temporal.Instant, + jobs: readonly RuntimeJobRescue[], + options?: InsertDriverOptions + ): BackendResult; + /** Delete one bounded page of terminal jobs. */ + maintenanceCleanJobs?( + leader: RuntimeLeader, + params: RuntimeJobCleanupParams, + timeoutMs: number | null, + signal: AbortSignal + ): BackendResult; + /** Delete one bounded page of inactive queue records. */ + maintenanceCleanQueues?( + leader: RuntimeLeader, + updatedBefore: Temporal.Instant, + limit: number, + batch?: RuntimeMaintenanceBatch + ): BackendResult; + /** + * Delete up to `limit` durable notification hints created before + * `createdBefore`, oldest first, where applicable. + */ + maintenanceCleanNotifications?( + leader: RuntimeLeader, + createdBefore: Temporal.Instant, + limit: number, + batch?: RuntimeMaintenanceBatch + ): BackendResult; + /** Rebuild configured backend indexes when the backend supports it. */ + maintenanceReindex?( + leader: RuntimeLeader, + indexNames: readonly string[], + timeoutMs: number | null, + signal: AbortSignal + ): BackendResult; + + /** + * The IDs among `ids` of running jobs with a cancellation request, like + * River for Go's `JobGetCancelRequested`. A runtime without notifications + * checks its running attempts with it every two seconds. + */ + jobGetCancelRequested?( + ids: readonly bigint[], + options?: RuntimeWaitOptions + ): BackendResult; + + /** + * Optional backend-native remote-cancellation stream. `ready` runs once the + * stream receives every later cancellation, so the runtime can check for + * cancellations missed while it reconnected. + */ + jobCancellationSubscribe?( + attemptedBy: string, + signal: AbortSignal, + ready?: () => void + ): AsyncIterable; } diff --git a/js/src/events.ts b/js/src/events.ts new file mode 100644 index 000000000..e3dd6b318 --- /dev/null +++ b/js/src/events.ts @@ -0,0 +1,362 @@ +import { channel } from "node:diagnostics_channel"; + +import { + type JobStuckError, + SubscriptionLagError, + ValidationError, +} from "./errors.js"; +import type { JobRow } from "./job.js"; +import type { QueueRow } from "./driver.js"; +import type { LeaderTerm } from "./driver.js"; +import type { MaintenanceServiceName } from "./services.js"; +import type { EventLoopDelayObservation } from "./internal/event-loop-delay-monitor.js"; + +export type JobEventKind = JobEvent["kind"]; + +export type QueueEventKind = QueueEvent["kind"]; + +/** Fields shared by every River event. */ +export interface RiverEventBase { + /** When River observed the transition. */ + readonly at: Temporal.Instant; + /** Discriminates the event type. */ + readonly kind: Kind; +} + +/** A claimed job started an attempt. */ +export interface JobStartedEvent extends RiverEventBase<"job_started"> { + /** The job as of the transition. */ + readonly job: JobRow; +} + +/** A job's attempt succeeded and the job is `completed`. */ +export interface JobCompletedEvent extends RiverEventBase<"job_completed"> { + /** The job as of the transition. */ + readonly job: JobRow; +} + +/** + * A job's attempt failed; the job is `retryable`, `available` (a + * near-future retry), or `discarded` after its last attempt. + */ +export interface JobFailedEvent extends RiverEventBase<"job_failed"> { + readonly error: unknown; + /** The job as of the transition. */ + readonly job: JobRow; +} + +/** A job was cancelled, by its handler or remotely. */ +export interface JobCancelledEvent extends RiverEventBase<"job_cancelled"> { + /** The attempt's error, when it failed as it was cancelled. */ + readonly error?: unknown; + /** The job as of the transition. */ + readonly job: JobRow; +} + +/** A job snoozed and will run again later without using an attempt. */ +export interface JobSnoozedEvent extends RiverEventBase<"job_snoozed"> { + /** The job as of the transition. */ + readonly job: JobRow; +} + +/** A job's attempt stopped for shutdown and the job is available again. */ +export interface JobInterruptedEvent extends RiverEventBase<"job_interrupted"> { + /** The job as of the transition. */ + readonly job: JobRow; +} + +/** + * A finished attempt no longer owned its row (another process completed, + * cancelled, or re-claimed the job), so its result was not persisted. + */ +export interface JobRaceEvent extends RiverEventBase<"job_race"> { + /** The job as of the transition. */ + readonly job: JobRow; +} + +/** An attempt stayed unsettled past its timeout plus stuck threshold. */ +export interface JobStuckEvent extends RiverEventBase<"job_stuck"> { + /** Which timeout and threshold the attempt exceeded. */ + readonly error: JobStuckError; + /** The job as of the transition. */ + readonly job: JobRow; +} + +export type JobEvent = + | JobCancelledEvent + | JobCompletedEvent + | JobFailedEvent + | JobInterruptedEvent + | JobRaceEvent + | JobSnoozedEvent + | JobStartedEvent + | JobStuckEvent; + +export interface QueueEvent extends RiverEventBase< + | "queue_added" + | "queue_paused" + | "queue_reconfigured" + | "queue_resumed" + | "queue_updated" +> { + /** The queue as of the change. */ + readonly queue: QueueRow; +} + +/** A queue was removed from this client. */ +export interface QueueRemovedEvent extends RiverEventBase<"queue_removed"> { + readonly queueName: string; +} + +/** This client won or lost maintenance leadership. */ +export interface LeaderEvent extends RiverEventBase< + "leader_acquired" | "leader_lost" +> { + /** The leadership term won or lost. */ + readonly leader: LeaderTerm; +} + +/** A leader-owned maintenance service pass failed. */ +export interface MaintenanceFailedEvent extends RiverEventBase<"maintenance_failed"> { + /** The error the maintenance pass failed with. */ + readonly error: unknown; + readonly service: MaintenanceServiceName; +} + +/** A leader-owned maintenance service pass succeeded. */ +export interface MaintenanceSucceededEvent extends RiverEventBase<"maintenance_succeeded"> { + /** Rows the pass affected. */ + readonly count: number; + readonly service: MaintenanceServiceName; +} + +/** The event loop was delayed past the configured threshold. */ +export interface EventLoopDelayEvent extends RiverEventBase<"runtime_event_loop_delay"> { + /** The delay measured over the reporting interval. */ + readonly eventLoopDelay: EventLoopDelayObservation; +} + +/** + * A subscription dropped events because its consumer fell behind. Delivered + * to every subscription regardless of its `kinds` filter. + */ +export interface SubscriptionLagEvent extends RiverEventBase<"subscription_lag"> { + /** Events dropped since the previous lag event. */ + readonly dropped: number; + /** Describes the lag, for logging. */ + readonly error: SubscriptionLagError; +} + +/** Every observation River emits, discriminated by `kind`. */ +export type RiverEvent = + | EventLoopDelayEvent + | JobEvent + | LeaderEvent + | MaintenanceFailedEvent + | MaintenanceSucceededEvent + | QueueEvent + | QueueRemovedEvent + | SubscriptionLagEvent; + +export type RiverEventKind = RiverEvent["kind"]; + +/** Events a subscription filtered to `Kind` yields. */ +export type SubscribedEvent = + Extract | SubscriptionLagEvent; + +/** Options for `client.subscribe`. */ +export interface SubscribeOptions< + Kind extends RiverEventKind = RiverEventKind, +> { + /** + * Maximum buffered events before the oldest are dropped and a + * `subscription_lag` event reports how many. Defaults to 256. + */ + readonly capacity?: number; + /** Only deliver these kinds (plus `subscription_lag`). */ + readonly kinds?: readonly Kind[]; + /** Close the subscription when this signal aborts. */ + readonly signal?: AbortSignal; +} + +interface SubscriptionWaiter { + readonly resolve: (result: IteratorResult) => void; +} + +/** + * A bounded, explicitly disposable async iterable of events emitted after + * their database transitions commit. Close it with `close()`, `using`, + * `await using`, or by breaking out of a `for await` loop. + */ +export class EventSubscription + implements AsyncIterable, AsyncIterator, Disposable +{ + readonly #buffer: Event[] = []; + readonly #capacity: number; + readonly #kinds: ReadonlySet | null; + readonly #remove: () => void; + readonly #signal: AbortSignal | undefined; + readonly #waiters: SubscriptionWaiter[] = []; + #closed = false; + #dropped = 0; + + /** @internal Constructed by Client.subscribe. */ + constructor(remove: () => void, options: SubscribeOptions, capacity: number) { + this.#remove = remove; + this.#capacity = capacity; + this.#kinds = options.kinds === undefined ? null : new Set(options.kinds); + this.#signal = options.signal; + options.signal?.addEventListener("abort", this.#onAbort, { once: true }); + } + + [Symbol.asyncIterator](): AsyncIterator { + return this; + } + + async [Symbol.asyncDispose](): Promise { + this.close(); + } + + [Symbol.dispose](): void { + this.close(); + } + + /** Stop delivering events and release the subscription's listeners. */ + close(): void { + if (this.#closed) return; + this.#closed = true; + this.#dropped = 0; + this.#signal?.removeEventListener("abort", this.#onAbort); + this.#remove(); + this.#buffer.length = 0; + for (const waiter of this.#waiters.splice(0)) { + waiter.resolve({ done: true, value: undefined }); + } + } + + /** Wait for the next event, or `done` once closed. */ + next(): Promise> { + if (this.#dropped > 0) { + const dropped = this.#dropped; + this.#dropped = 0; + return Promise.resolve({ + done: false, + value: { + at: Temporal.Now.instant(), + dropped, + error: new SubscriptionLagError(dropped), + kind: "subscription_lag", + } as Event, + }); + } + const event = this.#buffer.shift(); + if (event !== undefined) { + return Promise.resolve({ done: false, value: event }); + } + if (this.#closed) { + return Promise.resolve({ done: true, value: undefined }); + } + return new Promise((resolve) => this.#waiters.push({ resolve })); + } + + /** Close the subscription; called when a `for await` loop exits early. */ + return(): Promise> { + this.close(); + return Promise.resolve({ done: true, value: undefined }); + } + + /** @internal */ + publish(event: RiverEvent): void { + if ( + this.#closed || + (this.#kinds !== null && !this.#kinds.has(event.kind)) + ) { + return; + } + const waiter = this.#waiters.shift(); + if (waiter !== undefined) { + waiter.resolve({ done: false, value: event as Event }); + return; + } + if (this.#buffer.length === this.#capacity) { + this.#buffer.shift(); + this.#dropped++; + } + this.#buffer.push(event as Event); + } + + readonly #onAbort = () => this.close(); +} + +/** @internal Build a job event whose fields match its kind. */ +export function jobEvent( + kind: JobEventKind, + at: Temporal.Instant, + job: JobRow, + error: unknown +): JobEvent { + switch (kind) { + case "job_cancelled": + return error === undefined ? { at, job, kind } : { at, error, job, kind }; + case "job_failed": + return { at, error, job, kind }; + case "job_stuck": + return { + at, + error: error as JobStuckError, + job, + kind, + }; + case "job_completed": + case "job_interrupted": + case "job_race": + case "job_snoozed": + case "job_started": + return { at, job, kind }; + } +} + +/** @internal Client-owned collection of bounded subscriptions. */ +export class EventHub { + readonly #subscriptions = new Set(); + + close(): void { + for (const subscription of [...this.#subscriptions]) subscription.close(); + } + + publish(event: RiverEvent): void { + for (const subscription of this.#subscriptions) { + subscription.publish(event); + } + riverEventChannel.publish(event); + } + + subscribe( + options: SubscribeOptions = {} + ): EventSubscription> { + if (options.signal?.aborted) { + throw options.signal.reason; + } + const capacity = options.capacity ?? 256; + if (!Number.isSafeInteger(capacity) || capacity < 1) { + throw new ValidationError( + "subscription capacity must be a positive safe integer" + ); + } + const holder: { value?: EventSubscription> } = {}; + const subscription = new EventSubscription>( + () => { + if (holder.value !== undefined) { + this.#subscriptions.delete(holder.value as EventSubscription); + } + }, + options, + capacity + ); + holder.value = subscription; + this.#subscriptions.add(subscription as EventSubscription); + return subscription; + } +} + +const riverEventChannel = channel("riverqueue:event"); diff --git a/js/src/extensions.ts b/js/src/extensions.ts new file mode 100644 index 000000000..ec9c16509 --- /dev/null +++ b/js/src/extensions.ts @@ -0,0 +1,172 @@ +import type { RiverEvent } from "./events.js"; +import type { InsertResult } from "./client.js"; +import type { JobDefinition } from "./job-definition.js"; +import type { JobState } from "./job.js"; +import type { JsonObject, JsonValue } from "./json.js"; +import type { PeriodicJobsStartParams } from "./periodic.js"; +import type { RiverMetric } from "./metrics.js"; +import type { WorkAttemptContext, WorkContext, WorkOutcome } from "./worker.js"; + +/** A Koa-style around-work extension. `next` may be called exactly once. */ +/* eslint-disable @typescript-eslint/no-invalid-void-type -- ordinary and async no-return middleware are valid work handlers */ +export type WorkMiddleware = ( + context: WorkAttemptContext, + next: () => Promise +) => PromiseLike | WorkOutcome | void; +/* eslint-enable @typescript-eslint/no-invalid-void-type */ + +/** What an {@link InsertMiddleware} sees about the insertion it wraps. */ +export interface InsertContext { + readonly operation: "insert" | "insertMany"; + /** Immutable application-level insertion requests in call order. */ + readonly requests: readonly InsertRequest[]; +} + +/** Immutable application-level view of an insertion request. */ +export interface InsertRequest { + readonly args: JsonObject; + /** + * The job definition the caller inserted (the same object identity), or + * undefined for an insertion without one. + */ + readonly definition: JobDefinition | undefined; + readonly kind: string; + readonly maxAttempts: number; + readonly metadata: JsonObject; + readonly priority: number; + readonly queue: string; + /** + * When the job becomes workable. For a job inserted without a schedule, + * when the insertion was requested: like River for Go, the database stores + * its own current time for such a job. + */ + readonly scheduledAt: Temporal.Instant; + readonly state: JobState; + readonly tags: readonly string[]; + readonly unique: boolean; +} + +/** + * Wraps every insertion, like Koa middleware: call `next()` once to insert + * and return (or adjust) its results. + * + * As in River for Go, an insertion without `{ tx }` runs in one transaction + * River owns, from argument validation through every middleware and hook to + * the write, so an error thrown anywhere, even after `next()` returns, rolls + * the jobs back. With `{ tx }`, it runs in the caller's transaction, which + * the caller commits or rolls back. + * + * On SQLite, River holds the database's write lock from the write until it + * commits, so middleware must not await I/O after `next()` returns, and + * `afterInsert` hooks must not await I/O at all. River detects most such + * insertions and fails them, but not every one: see the SQLite driver's + * documentation. Do the I/O before calling `next()`, or react to committed + * jobs with `client.subscribe`. + */ +export type InsertMiddleware = ( + context: InsertContext, + next: () => Promise +) => PromiseLike; + +/** + * Ordered extension points. As in River for Go, insert and work hooks run + * inside the innermost middleware: insert middleware wraps `beforeInsert`, + * the database write, and `afterInsert`, and work middleware wraps + * `beforeWork`, argument decoding, the worker, and `afterWork`. A job whose + * kind has no worker, or whose row can't be decoded, fails before any + * middleware or hook runs. + */ +export interface RiverHooks { + /** Observe inserted jobs, inside the innermost insert middleware. */ + afterInsert?( + context: InsertContext, + results: readonly InsertResult[] + ): PromiseLike | void; + /** Observe any River event after it is published to subscribers. */ + onEvent?(event: RiverEvent): PromiseLike | void; + /** Observe a runtime metric without blocking job fetching. */ + onMetric?(metric: RiverMetric): PromiseLike | void; + /** + * Observe, or replace by returning another, an attempt's result. The + * returned result becomes the attempt's result, like River for Go's + * `HookWorkEnd`; returning nothing keeps it. + */ + afterWork?( + context: WorkContext, + result: WorkAttemptResult + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- returning nothing preserves the prior result + ): PromiseLike | WorkAttemptResult | void; + /** + * Runs before arguments are decoded and the worker runs. An error it + * throws becomes the attempt's error, and the worker doesn't run. + */ + beforeWork?(context: WorkAttemptContext): PromiseLike | void; + /** Runs before jobs are inserted; an error fails the insertion. */ + beforeInsert?(context: InsertContext): PromiseLike | void; + /** + * Runs each time this client becomes leader and starts inserting periodic + * jobs, with durable records from a periodic job store when one is + * configured. A rejection is logged and periodic enqueuing retries on the + * next loop, like Go River's `HookPeriodicJobsStart`. + */ + onPeriodicJobsStart?( + params: PeriodicJobsStartParams + ): PromiseLike | void; +} + +interface WorkAttemptResultBase { + readonly metadata?: JsonObject; + readonly output?: JsonValue; +} + +/** Closed result of one handler attempt, narrowed by `status`. */ +export type WorkAttemptResult = + | (WorkAttemptResultBase & { + readonly cancel?: never; + readonly error: unknown; + readonly outcome?: never; + readonly status: "cancelled"; + }) + | (WorkAttemptResultBase & { + readonly cancel?: boolean; + readonly error: unknown; + readonly outcome?: never; + readonly status: "failed"; + }) + | (WorkAttemptResultBase & { + readonly cancel?: never; + readonly error?: never; + readonly outcome?: WorkOutcome; + readonly status: "succeeded"; + }); + +/** What an {@link RiverErrorHandler} may ask River to do with a failed job. */ +export interface ErrorHandlerResult { + readonly cancel?: boolean; +} + +/** Attempt context visible after work extension processing has finished. */ +export type ErrorHandlerContext = Omit; + +/** + * Nonfatal application policy invoked once for each failed attempt, + * including an attempt that stopped because its timeout expired (the error + * is then a `JobTimeoutError`). Like River for Go, it isn't invoked + * for an attempt interrupted by a client stop, or for one that fails after + * the job was cancelled remotely (the cancellation decides that attempt's + * outcome), unless the failure is a JavaScript runtime fault such as a + * `TypeError`, River's analog of a Go panic. + */ +export type RiverErrorHandler = ( + context: ErrorHandlerContext, + error: unknown +) => + ErrorHandlerResult | PromiseLike | undefined; + +/** A named collection of ordinary middleware and hooks. */ +export interface RiverPlugin { + readonly hooks?: RiverHooks; + readonly insertMiddleware?: readonly InsertMiddleware[]; + readonly middleware?: readonly WorkMiddleware[]; + readonly name: string; +} diff --git a/js/src/index.ts b/js/src/index.ts index 79b0d0b8d..18dabeb47 100644 --- a/js/src/index.ts +++ b/js/src/index.ts @@ -1,23 +1,239 @@ -export { Client, InsertManyParams } from "./client.js"; -export type { ClientOpts, InsertResult } from "./client.js"; -export type { Driver, DriverOptions, JobInsertParams } from "./driver.js"; -export type { InsertOpts, UniqueOpts } from "./insert-opts.js"; +/// +// The public declarations use the global `Temporal` types. The preserved +// reference loads them for TypeScript consumers without a `lib` setting. + +export { Client } from "./client.js"; +export type { + CheckedInsertManyItems, + ClientConstructor, + InsertClient, + InsertManyItem, + InsertManyResults, + InsertResult, + JobOperations, + QueueOperations, + TransactionOptions, +} from "./client.js"; +export type { + ClientDriver, + DriverCapability, + JobListOrderBy, + LeaderTerm, + RegisteredTransaction, + RiverTransactionRegistry, + SortDirection, +} from "./driver.js"; export { - JOB_STATE_AVAILABLE, - JOB_STATE_CANCELLED, - JOB_STATE_COMPLETED, - JOB_STATE_DISCARDED, - JOB_STATE_PENDING, - JOB_STATE_RETRYABLE, - JOB_STATE_RUNNING, - JOB_STATE_SCHEDULED, - JobArgsObject, + BackendMismatchError, + ConfigurationError, + DatabaseOperationError, + ExtensionError, + isRetryableError, + JobAbortedError, + JobAttemptFinishedError, + JobCancelledError, + JobRunningError, + JobStuckError, + JobTimeoutError, + LifecycleError, + MigrationError, + PayloadValidationError, + RIVER_ERROR_CODE, + RiverError, + SubscriptionLagError, + TransactionScopeError, + UnknownJobKindError, + UnsupportedCapabilityError, + ValidationError, +} from "./errors.js"; +export type { + DatabaseOperationErrorOptions, + MigrationErrorOptions, + PayloadValidationPhase, + RiverErrorCode, + RiverErrorOptions, + RiverErrorSubclassOptions, + TransactionScopeErrorReason, +} from "./errors.js"; +export { EventSubscription } from "./events.js"; +export type { + EventLoopDelayEvent, + JobCancelledEvent, + JobCompletedEvent, + JobEvent, + JobEventKind, + JobFailedEvent, + JobInterruptedEvent, + JobRaceEvent, + JobSnoozedEvent, + JobStartedEvent, + JobStuckEvent, + LeaderEvent, + MaintenanceFailedEvent, + MaintenanceSucceededEvent, + QueueEvent, + QueueEventKind, + QueueRemovedEvent, + RiverEvent, + RiverEventBase, + RiverEventKind, + SubscribedEvent, + SubscribeOptions, + SubscriptionLagEvent, +} from "./events.js"; +export type { + ErrorHandlerContext, + ErrorHandlerResult, + InsertContext, + InsertMiddleware, + InsertRequest, + RiverErrorHandler, + RiverHooks, + RiverPlugin, + WorkAttemptResult, + WorkMiddleware, +} from "./extensions.js"; +export { defineJob, isJobDefinition } from "./job-definition.js"; +export type { + DecodedJobInput, + DecoderJobDefinitionConfig, + DefineJobWithInput, + JobDefinition, + JobDefinitionArgs, + JobDefinitionInput, + JobDefinitionOptions, + JobDefinitionTypeError, + JsonCompatible, + SchemaJobDefinitionConfig, + StandardSchemaInput, + StandardSchemaIssue, + StandardSchemaOutput, + StandardSchemaResult, + StandardSchemaV1, + UncheckedJobDefinitionConfig, +} from "./job-definition.js"; +export type { + InsertOptions, + NormalizedInsertOptions, + NormalizedUniqueOptions, + UniqueOptions, +} from "./insert-options.js"; +export type { + LogAttributes, + Logger, + LogLevel, + WorkLogFunction, + WorkLogger, +} from "./logger.js"; +export { consoleLogger } from "./logger.js"; +export type { RiverMetric } from "./metrics.js"; +export { + JOB_STATE, + jobFromJsonValue, + jobToJsonValue, MAX_ATTEMPTS_DEFAULT, PRIORITY_DEFAULT, QUEUE_DEFAULT, } from "./job.js"; -export type { AttemptError, JobArgs, JobRow, JobState } from "./job.js"; +export type { + AttemptError, + AttemptErrorJson, + JobRow, + JobRowJson, + JobState, +} from "./job.js"; +export { + exactJsonNumber, + isExactJsonNumber, + isJsonNumber, + jsonNumberToBigInt, + JsonValueError, + parseJson, + parseJsonObject, + stringifyJson, + toJsonObject, + toJsonValue, +} from "./json.js"; +export type { ExactJsonNumber, JsonObject, JsonValue } from "./json.js"; +export { periodicJob, PeriodicJobs } from "./periodic.js"; +export type { + DurablePeriodicJob, + PeriodicJob, + PeriodicJobArgs, + PeriodicJobHandle, + PeriodicJobInsert, + PeriodicJobOptions, + PeriodicJobsStartParams, + PeriodicJobTiming, + PeriodicSchedule, +} from "./periodic.js"; +export { Resumable } from "./resumable.js"; +export type { ResumableCheckpointOptions } from "./resumable.js"; +export type { + JobListOptions, + JobListResult, + JobDeleteManyOptions, + JobUpdateOptions, + QueueListOptions, + QueueListResult, + QueueRow, + QueueUpdateOptions, +} from "./query.js"; export { - uniqueBitmaskFromStates, - uniqueBitmaskToStates, -} from "./unique-bitmask.js"; + currentWorkContext, + recordOutput, + setMetadata, + RunHandle, +} from "./runtime.js"; +export type { + CurrentWorkContext, + EventLoopDelayObservation, + JobStuckHandler, + JobStuckHandlerParams, + JobStuckHandlerResult, + QueueRuntimeDiagnostics, + RetryPolicy, + RunDiagnostics, + RunState, +} from "./runtime.js"; +export type { + ClientOptions, + DurationInput, + EventLoopDelayOptions, + MaintenanceOptions, + QueueConfig, + StopOptions, +} from "./options.js"; +export type { + MaintenanceDiagnostics, + MaintenanceServiceName, + ReindexerSchedule, +} from "./services.js"; +export { REINDEXER_INDEX_NAMES_DEFAULT } from "./services.js"; +export { assertRuntimeSupport } from "./runtime-support.js"; +export { cancel, complete, discard, snooze, Workers } from "./worker.js"; +export type { + CancelOutcome, + CompleteOutcome, + DiscardOutcome, + ExecutorWorkerRegistration, + InProcessWorkerRegistration, + Job, + SnoozeOutcome, + WorkAttemptContext, + WorkContext, + WorkHandler, + WorkHandlerFactory, + WorkExecution, + WorkExecutor, + WorkExecutorAbortOptions, + WorkExecutorAbortResult, + WorkExecutorHandle, + WorkExecutorTarget, + WorkerHooks, + NormalizedWorkerOptions, + WorkerOptions, + WorkerPlugin, + WorkerRegistration, + WorkOutcome, +} from "./worker.js"; diff --git a/js/src/insert-options.ts b/js/src/insert-options.ts new file mode 100644 index 000000000..930c547df --- /dev/null +++ b/js/src/insert-options.ts @@ -0,0 +1,375 @@ +import { ValidationError } from "./errors.js"; +import { durationNanoseconds, toDuration } from "./internal/duration.js"; +import type { DurationInput } from "./internal/duration.js"; +import { validateQueueName } from "./identifiers.js"; +import { JOB_STATE } from "./job.js"; +import type { JobState } from "./job.js"; +import type { JsonObject } from "./json.js"; +import { deepFreezeJson, toJsonObject } from "./json.js"; + +const TAG_RE = /^\w[\w-]+\w$/; +const MAX_GO_DURATION_NANOSECONDS = 9_223_372_036_854_775_807n; +const NANOSECONDS_PER_SECOND = 1_000_000_000n; + +const REQUIRED_UNIQUE_STATES: readonly JobState[] = Object.freeze([ + JOB_STATE.available, + JOB_STATE.pending, + JOB_STATE.running, + JOB_STATE.scheduled, +]); + +/** Options that may be supplied at any insertion-default level. */ +export interface InsertOptions { + /** + * Delay from insertion time before the job becomes eligible to run, such as + * `{ minutes: 5 }`. Mutually exclusive with `scheduledAt` at the same level; + * a call-site `delay` overrides a definition-level `scheduledAt` and vice + * versa. Calendar units (years, months, weeks) are rejected; a day is 24 + * hours. + */ + delay?: DurationInput; + + /** Maximum total attempts, including the first attempt. */ + maxAttempts?: number; + + /** Application metadata stored with the job. */ + metadata?: JsonObject; + + /** Insert the job as pending rather than immediately available. */ + pending?: boolean; + + /** Priority from 1 (highest) through 4 (lowest). */ + priority?: number; + + /** Queue on which the job will be worked. */ + queue?: string; + + /** + * Absolute time at which the job becomes eligible to run. A `Date` is + * converted to a `Temporal.Instant`; persisted rows always use `Instant`. + */ + scheduledAt?: Date | Temporal.Instant; + + /** Tags used to group and query jobs. */ + tags?: readonly string[]; + + /** Dimensions used to deduplicate jobs. */ + unique?: UniqueOptions; +} + +/** Dimensions used to deduplicate a job. */ +export interface UniqueOptions { + /** Include all args or the named fields, using dot-separated nested paths. */ + byArgs?: true | readonly string[]; + + /** + * Deduplicate within fixed windows of this length, such as `{ hours: 1 }`, + * aligned like Go River's by-period uniqueness. At least one second; + * calendar units (years, months, weeks) are rejected and a day is 24 hours. + */ + byPeriod?: DurationInput; + + /** Include the queue in the unique key. */ + byQueue?: boolean; + + /** States in which an existing job conflicts with an insertion. */ + byState?: readonly JobState[]; + + /** + * Omit kind from the unique key, deduplicating across all jobs regardless + * of kind. Requires `byArgs`, `byQueue`, or `byPeriod`. + */ + excludeKind?: boolean; +} + +/** Snapshot of insertion options after validation and normalization. */ +export interface NormalizedInsertOptions extends Omit< + InsertOptions, + "delay" | "scheduledAt" | "unique" +> { + delay?: Temporal.Duration; + scheduledAt?: Temporal.Instant; + unique?: NormalizedUniqueOptions; +} + +/** Uniqueness options after validation and normalization. */ +export interface NormalizedUniqueOptions extends Omit< + UniqueOptions, + "byPeriod" +> { + byPeriod?: Temporal.Duration; +} + +/** Resolved, validated options sent to an insertion backend. */ +export interface ResolvedInsertOptions { + maxAttempts: number; + metadata: JsonObject; + pending: boolean; + priority: number; + queue: string; + scheduledAt: Temporal.Instant; + tags: readonly string[]; + unique?: NormalizedUniqueOptions; +} + +/** Snapshot and validate a partial insertion configuration. */ +export function normalizeInsertOptions( + options: InsertOptions = {} +): Readonly { + const copy: NormalizedInsertOptions = {}; + + if (options.maxAttempts !== undefined) { + requireInteger("maxAttempts", options.maxAttempts, 1, 32_767); + copy.maxAttempts = options.maxAttempts; + } + if (options.metadata !== undefined) { + copy.metadata = deepFreezeJson(toJsonObject(options.metadata)); + } + if (options.pending !== undefined) { + if (typeof options.pending !== "boolean") { + throw new ValidationError("pending must be a boolean"); + } + copy.pending = options.pending; + } + if (options.priority !== undefined) { + requireInteger("priority", options.priority, 1, 4); + copy.priority = options.priority; + } + if (options.queue !== undefined) { + validateQueueName(options.queue); + copy.queue = options.queue; + } + if (options.delay !== undefined && options.scheduledAt !== undefined) { + throw new ValidationError("delay and scheduledAt are mutually exclusive"); + } + if (options.delay !== undefined) { + copy.delay = toDuration("delay", options.delay, { + allowZero: true, + error: ValidationError, + }); + } + if (options.scheduledAt !== undefined) { + copy.scheduledAt = toInstant("scheduledAt", options.scheduledAt); + } + if (options.tags !== undefined) { + copy.tags = validateTags(options.tags); + } + if (options.unique !== undefined) { + copy.unique = normalizeUniqueOptions(options.unique); + } + return Object.freeze(copy); +} + +/** + * @internal Split a selected field path on unescaped dots. A backslash quotes + * the next character, as in the paths River Go derives from JSON field names. + * + * A segment that is an unsigned integer or `-1`, escaped or not, is rejected. + * River Go assembles selected values with `sjson`, which builds a JSON array + * rather than an object for such a segment, so the hashed text would differ + * from the object JavaScript assembles. + */ +export function parseUniquePath(path: string): readonly string[] { + const segments: string[] = []; + let segment = ""; + let escaped = false; + const pushSegment = () => { + if (/^[0-9]+$/.test(segment) || segment === "-1") { + throw new ValidationError( + "unique path " + + JSON.stringify(path) + + " has segment " + + JSON.stringify(segment) + + ", which River Go treats as an array index when assembling unique" + + " args, so its unique key can't match Go's; select fields whose" + + " names aren't unsigned integers or -1" + ); + } + segments.push(segment); + segment = ""; + }; + for (const character of path) { + if (escaped) { + segment += character; + escaped = false; + } else if (character === "\\") { + escaped = true; + } else if (character === ".") { + if (segment.length === 0) { + throw new ValidationError( + "unique path " + JSON.stringify(path) + " has an empty segment" + ); + } + pushSegment(); + } else { + segment += character; + } + } + if (escaped || segment.length === 0) { + throw new ValidationError( + "unique path " + + JSON.stringify(path) + + " has an empty segment or trailing escape" + ); + } + pushSegment(); + return segments; +} + +/** @internal Snapshot and validate uniqueness options. */ +export function normalizeUniqueOptions( + options: UniqueOptions +): Readonly { + const copy: NormalizedUniqueOptions = {}; + if (options.byArgs !== undefined) { + const byArgs: unknown = options.byArgs; + if (byArgs !== true && !Array.isArray(byArgs)) { + throw new ValidationError( + "unique.byArgs must be true or an array of field names" + ); + } + if (options.byArgs !== true) { + if (options.byArgs.length === 0) { + throw new ValidationError("unique.byArgs field list must not be empty"); + } + for (const path of options.byArgs) { + if (typeof path !== "string") { + throw new ValidationError( + "unique.byArgs field names must be strings" + ); + } + parseUniquePath(path); + } + copy.byArgs = Object.freeze([...options.byArgs]); + } else { + copy.byArgs = options.byArgs; + } + } + if (options.byPeriod !== undefined) { + copy.byPeriod = toDuration("unique.byPeriod", options.byPeriod, { + error: ValidationError, + }); + uniquePeriodNanoseconds(copy); + } + if (options.byQueue !== undefined) { + if (typeof options.byQueue !== "boolean") { + throw new ValidationError("unique.byQueue must be a boolean"); + } + copy.byQueue = options.byQueue; + } + if (options.byState !== undefined) { + for (const state of options.byState) { + if (!Object.values(JOB_STATE).includes(state)) { + throw new ValidationError( + `unknown River job state: ${JSON.stringify(state)}` + ); + } + } + if (options.byState.length > 0) { + for (const required of REQUIRED_UNIQUE_STATES) { + if (!options.byState.includes(required)) { + throw new ValidationError( + `unique.byState must include required state ${JSON.stringify(required)}` + ); + } + } + } + copy.byState = Object.freeze([...options.byState]); + } + if (options.excludeKind !== undefined) { + if (typeof options.excludeKind !== "boolean") { + throw new ValidationError("unique.excludeKind must be a boolean"); + } + copy.excludeKind = options.excludeKind; + } + // Like Go, a key without the kind needs another dimension: otherwise it + // would be the same for every job in the table. + if ( + copy.excludeKind === true && + copy.byArgs === undefined && + copy.byPeriod === undefined && + copy.byQueue !== true + ) { + throw new ValidationError( + "unique.excludeKind requires byArgs, byQueue, or byPeriod" + ); + } + return Object.freeze(copy); +} + +/** @internal Resolve River's exact Go-compatible uniqueness period. */ +export function uniquePeriodNanoseconds( + options: NormalizedUniqueOptions +): bigint | null { + if (options.byPeriod === undefined) return null; + const nanoseconds = durationNanoseconds(options.byPeriod); + if (nanoseconds < NANOSECONDS_PER_SECOND) { + throw new ValidationError("unique.byPeriod must be at least one second"); + } + if (nanoseconds > MAX_GO_DURATION_NANOSECONDS) { + throw new ValidationError("unique.byPeriod exceeds River's range"); + } + return nanoseconds; +} + +/** @internal Resolve an insertion's scheduled time from its options. */ +export function resolveScheduledAt( + options: Pick, + now: Temporal.Instant +): Temporal.Instant | undefined { + if (options.scheduledAt !== undefined) return options.scheduledAt; + if (options.delay === undefined) return undefined; + return now.add({ + nanoseconds: Number(durationNanoseconds(options.delay)), + }); +} + +function toInstant( + name: string, + value: Date | Temporal.Instant +): Temporal.Instant { + if (value instanceof Temporal.Instant) return value; + if (value instanceof Date) { + const milliseconds = value.getTime(); + if (Number.isNaN(milliseconds)) { + throw new ValidationError(`${name} must be a valid Date`); + } + return Temporal.Instant.fromEpochMilliseconds(milliseconds); + } + throw new ValidationError(`${name} must be a Temporal.Instant or Date`); +} + +function requireInteger( + name: string, + value: number, + min: number, + max?: number +): void { + if ( + !Number.isSafeInteger(value) || + value < min || + (max !== undefined && value > max) + ) { + const range = + max === undefined ? `at least ${min}` : `between ${min} and ${max}`; + throw new ValidationError(`${name} must be a safe integer ${range}`); + } +} + +function validateTags(tags: readonly string[]): readonly string[] { + const value: unknown = tags; + if (!Array.isArray(value)) throw new ValidationError("tags must be an array"); + const copy = [...tags]; + for (const tag of copy) { + if (typeof tag !== "string") { + throw new ValidationError("tags must contain only strings"); + } + if (tag.length > 255) { + throw new ValidationError("tags must be at most 255 characters"); + } + if (!TAG_RE.test(tag)) { + throw new ValidationError(`tag must match ${TAG_RE}`); + } + } + return Object.freeze(copy); +} diff --git a/js/src/insert-opts.ts b/js/src/insert-opts.ts deleted file mode 100644 index 878affbcd..000000000 --- a/js/src/insert-opts.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { JobState } from "./job.js"; - -/** - * Options for job insertion. Can be provided via `insertOpts` on job args - * (as defaults for all jobs of that kind) or passed directly to `insert` / - * `insertMany` (which take precedence over args-level defaults). - */ -export interface InsertOpts { - /** Maximum total attempts (including retries) before discarding. */ - maxAttempts?: number; - - /** Priority 1 (highest) to 4 (lowest). Defaults to PRIORITY_DEFAULT. */ - priority?: number; - - /** Queue name. Defaults to QUEUE_DEFAULT. */ - queue?: string; - - /** Schedule the job for a future time instead of running immediately. */ - scheduledAt?: Date; - - /** Arbitrary tags for grouping and categorizing jobs. */ - tags?: string[]; - - /** Options for unique job constraints. */ - uniqueOpts?: UniqueOpts; -} - -/** - * Parameters for unique job constraints. Each enabled property adds a - * dimension to the uniqueness check. With no properties set, no uniqueness - * is enforced. - */ -export interface UniqueOpts { - /** - * Enforce uniqueness by encoded args. Set `true` for all args, or an - * array of specific field names to consider. - */ - byArgs?: boolean | string[]; - - /** - * Enforce uniqueness within a time period (in seconds). Time is rounded - * down to the nearest multiple of the period. - */ - byPeriod?: number; - - /** Enforce uniqueness per queue. */ - byQueue?: boolean; - - /** - * Job states to consider for uniqueness. Defaults to available, completed, - * pending, retryable, running, and scheduled. The states available, - * pending, running, and scheduled are always required. - */ - byState?: JobState[]; - - /** Exclude job kind from the uniqueness check. */ - excludeKind?: boolean; -} diff --git a/js/src/internal/attempt-executor.ts b/js/src/internal/attempt-executor.ts new file mode 100644 index 000000000..2a96e4cb0 --- /dev/null +++ b/js/src/internal/attempt-executor.ts @@ -0,0 +1,107 @@ +import type { + WorkContext, + WorkExecutor, + WorkExecutorAbortResult, + WorkExecutorHandle, + WorkOutcome, + WorkerRegistration, +} from "../worker.js"; +import type { JsonObject } from "../json.js"; +import { millisecondsToDuration } from "./duration.js"; + +export interface AttemptExecutionHandle { + readonly result: Promise; + /** Settles when an executor begins the attempt; absent means already. */ + readonly started?: PromiseLike; + /** + * Abort the attempt's handler, letting an executor end it by force once + * `gracePeriodMs` passes without it settling. + */ + abort( + reason: unknown, + gracePeriodMs: number + ): Promise; + /** + * Whether the executor stopped the attempt by force after it began, once + * it ignored an abort through its grace period. Settles once the abort + * requested so far, if any, settled. + */ + forciblyStopped(): Promise; +} + +/** Coordinates ordinary closures and optional executors behind one seam. */ +export class AttemptExecutor { + readonly #executors = new Set(); + + diagnostics(): Readonly> { + const result = Object.create(null) as Record; + for (const executor of this.#executors) { + result[executor.name] = executor.diagnostics?.() ?? {}; + } + return Object.freeze(result); + } + + start( + registration: WorkerRegistration, + context: WorkContext + ): AttemptExecutionHandle { + if (registration.type === "in_process") { + const result = Promise.resolve().then( + async (): Promise => + (await registration.handler(context)) as WorkOutcome | undefined + ); + void result.catch(() => undefined); + return { + abort: () => Promise.resolve({ terminated: false }), + forciblyStopped: () => Promise.resolve(false), + result, + }; + } + + const executor = registration.target.executor; + this.#executors.add(executor); + let handle: WorkExecutorHandle; + try { + handle = executor.start(context, registration.target.handler); + } catch (error: unknown) { + return { + abort: () => Promise.resolve({ terminated: true }), + forciblyStopped: () => Promise.resolve(false), + result: Promise.reject(error), + }; + } + const result = Promise.resolve(handle.result); + void result.catch(() => undefined); + // An executor that ends an attempt still waiting for its capacity + // reports that as a termination too, but that attempt never ran. + let began = handle.started === undefined; + if (handle.started !== undefined) { + void Promise.resolve(handle.started).then( + () => { + began = true; + }, + () => undefined + ); + } + let aborting: Promise | undefined; + return { + abort: (reason, gracePeriodMs) => { + aborting = Promise.resolve( + handle.abort(reason, { + gracePeriod: millisecondsToDuration(gracePeriodMs), + }) + ); + return aborting; + }, + forciblyStopped: async () => { + if (aborting === undefined) return false; + const { terminated } = await aborting.catch(() => ({ + terminated: false, + })); + return terminated && began; + }, + result, + ...(handle.started === undefined ? {} : { started: handle.started }), + }; + } +} diff --git a/js/src/internal/driver-registry.ts b/js/src/internal/driver-registry.ts new file mode 100644 index 000000000..45eb8614c --- /dev/null +++ b/js/src/internal/driver-registry.ts @@ -0,0 +1,135 @@ +/** + * Private records of first-party driver instances and of the pilot bindings + * `PilotClient` passes to River's client constructor. Nothing here is + * readable through a package entry point. + */ +import type { ClientDriver } from "../driver.js"; +import { ConfigurationError, ValidationError } from "../errors.js"; +import type { + DriverMigrationTarget, + DriverRecord, + PilotFactory, +} from "../pilot.js"; + +const drivers = new WeakMap>(); + +/** + * Register what River needs to know privately about a first-party driver + * instance. A driver registers itself once, from its constructor. + * + * @throws {ConfigurationError} when `handle` is already registered. + */ +export function registerDriver( + handle: ClientDriver, + record: DriverRecord +): void { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- validates untyped JavaScript input + if (typeof handle !== "object" || handle === null) { + throw new ValidationError("a River driver must be an object"); + } + if (drivers.has(handle)) { + throw new ConfigurationError("this River driver is already registered"); + } + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- validates untyped JavaScript input + if (record.capability !== "insert" && record.capability !== "runtime") { + throw new ValidationError( + 'a River driver record needs capability "insert" or "runtime"' + ); + } + if (record.database !== undefined && record.capability !== "runtime") { + throw new ValidationError( + "only a runtime driver can provide a pilot database" + ); + } + const operations: unknown = record.operations; + if ( + typeof operations !== "object" || + operations === null || + typeof (operations as { jobInsert?: unknown }).jobInsert !== "function" || + typeof (operations as { jobInsertMany?: unknown }).jobInsertMany !== + "function" + ) { + throw new ValidationError( + "a River driver record needs operations that insert jobs" + ); + } + if (typeof record.backend !== "string" || record.backend.length === 0) { + throw new ValidationError("a River driver record needs a backend name"); + } + const migration = record.migration; + if (migration !== undefined && !isMigrationTarget(migration)) { + throw new ValidationError( + "a River driver's migration target needs { pool, schema }, " + + "{ client, schema }, or { database }" + ); + } + const frozen: DriverRecord = Object.freeze({ + backend: record.backend, + capability: record.capability, + operations: record.operations, + ...(record.database === undefined + ? {} + : { database: Object.freeze(record.database) }), + ...(migration === undefined + ? {} + : { migration: Object.freeze({ ...migration }) }), + }); + drivers.set(handle, frozen); +} + +/** The record `handle` registered, if any. */ +export function driverRecord( + handle: object +): DriverRecord | undefined { + return drivers.get(handle) as DriverRecord | undefined; +} + +/** + * The connection `createMigrator` migrates for a registered driver, or + * undefined when `handle` isn't one or its driver can't migrate. + */ +export function driverMigrationTarget( + handle: unknown +): DriverMigrationTarget | undefined { + if (typeof handle !== "object" || handle === null) return undefined; + return drivers.get(handle)?.migration; +} + +function isMigrationTarget(target: unknown): target is DriverMigrationTarget { + if (typeof target !== "object" || target === null) return false; + const connection = + "database" in target + ? target.database + : "pool" in target + ? target.pool + : "client" in target + ? target.client + : undefined; + if (typeof connection !== "object" || connection === null) return false; + if ("database" in target) return true; + const schema = (target as { schema?: unknown }).schema; + return schema === undefined || typeof schema === "string"; +} + +const pilotBindings = new WeakMap>(); + +/** A private token that makes River's client constructor attach a pilot. */ +export function createPilotBinding( + createPilot: PilotFactory +): object { + const binding = Object.freeze(Object.create(null) as object); + pilotBindings.set(binding, createPilot as unknown as PilotFactory); + return binding; +} + +/** + * The pilot factory behind a binding from {@link createPilotBinding}, or + * undefined for anything else, such as an extra constructor argument passed + * from untyped JavaScript. + */ +export function pilotFactory( + binding: unknown +): PilotFactory | undefined { + if (typeof binding !== "object" || binding === null) return undefined; + return pilotBindings.get(binding) as PilotFactory | undefined; +} diff --git a/js/src/internal/handle-gate.ts b/js/src/internal/handle-gate.ts new file mode 100644 index 000000000..40b2fa0d4 --- /dev/null +++ b/js/src/internal/handle-gate.ts @@ -0,0 +1,89 @@ +/** + * One-at-a-time access to a caller's transaction handle for a client with + * a pilot. An intercepted operation runs statements on the handle across + * its interceptor's awaits, so a second operation on the same handle must + * not run in the meantime and interleave its statements with the first's. + * Operations on one handle therefore run in arrival order, like + * node-postgres's own queue of a client's queries. + * + * An operation started from inside one that holds the handle, such as a + * nested insertion from an interceptor, runs inside it rather than waiting + * for it, and operations nested in the same holder again run one at a time. + */ +import { AsyncLocalStorage } from "node:async_hooks"; + +/** One holder of a handle, and the queue of operations nested inside it. */ +class Level { + /** Set once the holder's operation finished. */ + ended = false; + readonly parent: Level | undefined; + #held = false; + readonly #waiters: (() => void)[] = []; + + constructor(parent: Level | undefined) { + this.parent = parent; + } + + /** Wait for this level's turn; the result releases it exactly once. */ + async acquire(): Promise<() => void> { + if (this.#held) { + await new Promise((resolve) => { + this.#waiters.push(resolve); + }); + } else { + this.#held = true; + } + let released = false; + return () => { + if (released) return; + released = true; + const next = this.#waiters.shift(); + if (next === undefined) this.#held = false; + else next(); + }; + } +} + +/** Each handle's outermost queue. */ +const roots = new WeakMap(); +/** The levels the current async context holds, by handle. */ +const holding = new AsyncLocalStorage>(); + +/** + * Run `operation` once no other operation holds `handle`, holding it + * meanwhile. A handle that isn't an object runs at once. + */ +export async function withHandle( + handle: unknown, + operation: () => PromiseLike | T +): Promise { + if ( + (typeof handle !== "object" && typeof handle !== "function") || + handle === null + ) { + return operation(); + } + const held = holding.getStore(); + // A callback created inside an operation can outlive it; it then queues + // where that operation did. + let owner = held?.get(handle); + while (owner?.ended === true) owner = owner.parent; + let parent = owner; + if (parent === undefined) { + parent = roots.get(handle); + if (parent === undefined) { + parent = new Level(undefined); + roots.set(handle, parent); + } + } + const release = await parent.acquire(); + const level = new Level(parent); + const nested = new Map(held); + nested.set(handle, level); + try { + return await holding.run(nested, operation); + } finally { + level.ended = true; + release(); + } +} diff --git a/js/src/internal/hex.ts b/js/src/internal/hex.ts new file mode 100644 index 000000000..7dda4a1cd --- /dev/null +++ b/js/src/internal/hex.ts @@ -0,0 +1,6 @@ +/** Lowercase hexadecimal text of `value`'s bytes. */ +export function bytesToHex(value: Uint8Array): string { + return Array.from(value, (byte) => byte.toString(16).padStart(2, "0")).join( + "" + ); +} diff --git a/js/src/internal/insert-notify-limiter.ts b/js/src/internal/insert-notify-limiter.ts new file mode 100644 index 000000000..5e7e1fc30 --- /dev/null +++ b/js/src/internal/insert-notify-limiter.ts @@ -0,0 +1,36 @@ +/** + * Limits one client's insert notifications to one per queue per fetch + * cooldown, like River for Go's per-client insert notification limiter. + * Producers fetch a queue at most once per cooldown, so a notification sent + * sooner would wake nothing new; a job whose notification is suppressed is + * found by the producer's next fetch. + */ +export class InsertNotifyLimiter { + readonly #cooldownMs: number; + /** When each queue's last notification was allowed. */ + readonly #lastSent = new Map(); + readonly #now: () => number; + + constructor(cooldownMs: number, now: () => number = () => performance.now()) { + this.#cooldownMs = cooldownMs; + this.#now = now; + } + + /** + * The queues among `queues`, each listed once, that are due an insert + * notification, recording that one was sent for each of them now. + */ + allow(queues: Iterable): readonly string[] { + const now = this.#now(); + const allowed: string[] = []; + for (const queue of new Set(queues)) { + const lastSent = this.#lastSent.get(queue); + if (lastSent !== undefined && now - lastSent <= this.#cooldownMs) { + continue; + } + this.#lastSent.set(queue, now); + allowed.push(queue); + } + return allowed; + } +} diff --git a/js/src/internal/maintenance-batch.ts b/js/src/internal/maintenance-batch.ts new file mode 100644 index 000000000..92fdc7745 --- /dev/null +++ b/js/src/internal/maintenance-batch.ts @@ -0,0 +1,207 @@ +/** + * Batch handling shared by the leader's maintenance services, like Go + * River's `riversharedmaintenance` package: each service works in bounded + * batches with a per-batch timeout, shrinks its batches after repeated + * timeouts, and pauses for a random 50 ms to 1 s between batches to give the + * database room. + */ + +import type { RuntimeMaintenanceBatch } from "../driver.js"; +import { interruptibleDelay } from "./abort.js"; + +/** Rows most maintenance services handle per batch. */ +export const BATCH_SIZE_DEFAULT = 10_000; + +/** The batch size after {@link ReducedBatchSizeBreaker} opens. */ +export const BATCH_SIZE_REDUCED = 1_000; + +/** Shortest pause between two batches of one pass. */ +export const BATCH_BACKOFF_MIN_MS = 50; + +/** Longest pause between two batches of one pass. */ +export const BATCH_BACKOFF_MAX_MS = 1_000; + +/** Timeout of one batch for services without their own setting. */ +export const MAINTENANCE_TIMEOUT_DEFAULT_MS = 30_000; + +/** Options for {@link CircuitBreaker}. */ +export interface CircuitBreakerOptions { + /** Trips within `windowMs` that open the breaker. */ + readonly limit: number; + /** Sliding window, in milliseconds, in which trips count. */ + readonly windowMs: number; +} + +/** + * A circuit breaker that opens once `limit` trips happen within a sliding + * window, then stays open for its lifetime, like Go River's + * `circuitbreaker.CircuitBreaker`. + */ +export class CircuitBreaker { + readonly #now: () => number; + readonly #options: CircuitBreakerOptions; + #open = false; + #trips: number[] = []; + + constructor(options: CircuitBreakerOptions, now: () => number = Date.now) { + if (!Number.isSafeInteger(options.limit) || options.limit < 1) { + throw new RangeError("CircuitBreaker limit must be above zero"); + } + if (!Number.isFinite(options.windowMs) || options.windowMs < 1) { + throw new RangeError("CircuitBreaker windowMs must be above zero"); + } + this.#now = now; + this.#options = options; + } + + /** Trips within the window that open the breaker. */ + get limit(): number { + return this.#options.limit; + } + + /** Whether the breaker has opened. */ + get open(): boolean { + return this.#open; + } + + /** + * Forget earlier trips unless the breaker is open, so only consecutive + * failures open it. Returns whether the breaker was reset. + */ + resetIfNotOpen(): boolean { + if (!this.#open) this.#trips = []; + return !this.#open; + } + + /** + * Count one trip, dropping trips older than the window. Returns whether + * the breaker is open afterward. + */ + trip(): boolean { + if (this.#open) return true; + const now = this.#now(); + const horizon = now - this.#options.windowMs; + this.#trips = this.#trips.filter((trippedAt) => trippedAt >= horizon); + this.#trips.push(now); + if (this.#trips.length >= this.#options.limit) this.#open = true; + return this.#open; + } +} + +/** + * The breaker most maintenance services use: three timed-out batches in a row + * within ten minutes switch the service to {@link BATCH_SIZE_REDUCED} for the + * rest of its life. + */ +function reducedBatchSizeBreaker(now: () => number = Date.now): CircuitBreaker { + return new CircuitBreaker({ limit: 3, windowMs: 10 * 60_000 }, now); +} + +/** Options for {@link MaintenanceBatcher}. */ +export interface MaintenanceBatcherOptions { + /** Monotonic clock in milliseconds. Defaults to `performance.now`. */ + readonly now?: () => number; + /** Uniform random source in `[0, 1)`. Defaults to `Math.random`. */ + readonly random?: () => number; + /** Per-batch timeout, or `null` for none. */ + readonly timeoutMs: number | null; +} + +/** + * Runs one maintenance service's batches: picks the batch size, bounds each + * batch by its timeout, and trips the service's reduced batch size breaker + * when a batch times out. + * + * A batch times out when it's still running at its deadline. PostgreSQL + * enforces the timeout on the server, so the batch fails and rolls back like + * Go's. SQLite statements can't be interrupted, so a SQLite batch that + * overruns keeps its work; it still counts as a timeout for the breaker. + */ +export class MaintenanceBatcher { + readonly #breaker: CircuitBreaker; + readonly #now: () => number; + readonly #random: () => number; + readonly #timeoutMs: number | null; + + constructor(options: MaintenanceBatcherOptions) { + this.#now = options.now ?? (() => performance.now()); + this.#random = options.random ?? Math.random; + this.#timeoutMs = options.timeoutMs; + this.#breaker = reducedBatchSizeBreaker(this.#now); + } + + /** The breaker that selects the batch size. */ + get breaker(): CircuitBreaker { + return this.#breaker; + } + + /** Rows the next batch should handle. */ + get batchSize(): number { + return this.#breaker.open ? BATCH_SIZE_REDUCED : BATCH_SIZE_DEFAULT; + } + + /** + * Pause for a random 50 ms to 1 s before the next batch of a pass. Resolves + * early when `signal` aborts. + */ + backoff(signal: AbortSignal): Promise { + return interruptibleDelay(this.backoffMs(), signal); + } + + /** Draw the next pause between batches, in milliseconds. */ + backoffMs(): number { + return ( + BATCH_BACKOFF_MIN_MS + + Math.floor(this.#random() * (BATCH_BACKOFF_MAX_MS - BATCH_BACKOFF_MIN_MS)) + ); + } + + /** + * Run one batch within its timeout. `signal` ends the batch early, such as + * when the leadership term ends; only a timeout trips the breaker. + */ + async run( + signal: AbortSignal, + batch: (bounds: RuntimeMaintenanceBatch) => Promise | T + ): Promise { + signal.throwIfAborted(); + const timeoutMs = this.#timeoutMs; + const timeout = new AbortController(); + const timer = + timeoutMs === null + ? undefined + : setTimeout(() => { + timeout.abort(new MaintenanceBatchTimeoutError(timeoutMs)); + }, timeoutMs); + timer?.unref(); + const startedAt = this.#now(); + const timedOut = () => + timeoutMs !== null && + (timeout.signal.aborted || this.#now() - startedAt >= timeoutMs); + try { + const result = await batch({ + // One per maintenance batch, so `AbortSignal.any` stays cheap, and + // the signal still aborts with the term after the batch. + // eslint-disable-next-line no-restricted-properties + signal: AbortSignal.any([signal, timeout.signal]), + timeoutMs, + }); + if (timedOut()) this.#breaker.trip(); + else this.#breaker.resetIfNotOpen(); + return result; + } catch (error: unknown) { + if (timedOut()) this.#breaker.trip(); + throw error; + } finally { + clearTimeout(timer); + } + } +} + +/** The reason a maintenance batch's signal aborts when it times out. */ +export class MaintenanceBatchTimeoutError extends Error { + constructor(timeoutMs: number) { + super(`maintenance batch timed out after ${timeoutMs.toString(10)} ms`); + this.name = "MaintenanceBatchTimeoutError"; + } +} diff --git a/js/src/internal/plugin-payloads.ts b/js/src/internal/plugin-payloads.ts new file mode 100644 index 000000000..892298ea3 --- /dev/null +++ b/js/src/internal/plugin-payloads.ts @@ -0,0 +1,72 @@ +/** + * Private payloads of one kind of plugin, kept off the plugin objects so + * code holding a plugin can neither read nor copy its payload. + */ +import { ConfigurationError } from "../errors.js"; + +/** + * The payload of each plugin of one kind. Each plugin also carries a + * payload-free `Symbol.for` brand shared by every installed copy of + * riverqueue, so a copy can tell a plugin it has no payload for, such as + * one from another copy or a spread copy of a plugin, from a plugin of + * another kind, and reject it instead of silently ignoring it. + */ +export class PluginPayloads { + readonly #brand: symbol; + readonly #kind: string; + readonly #payloads = new WeakMap(); + + /** + * `brandKey` is the `Symbol.for` key of this kind's brand, and `kind` + * names the kind in errors. + */ + constructor(brandKey: string, kind: string) { + this.#brand = Symbol.for(brandKey); + this.#kind = kind; + } + + /** Give `target`, a snapshot of `source`, the same payload. */ + copy(source: object, target: object): void { + const payload = this.get(source); + if (payload !== undefined) this.set(target, payload); + } + + /** + * The payload of `plugin`, or undefined for a plugin of another kind. + * + * @throws {ConfigurationError} when `plugin` carries this kind's brand + * but this copy of riverqueue has no payload for it. + */ + get(plugin: object): Payload | undefined { + const payload = this.#payloads.get(plugin); + if (payload === undefined && Object.hasOwn(plugin, this.#brand)) { + const name = (plugin as { readonly name?: unknown }).name; + throw new ConfigurationError( + `${this.#kind} ${JSON.stringify(name)} comes from another installed ` + + "copy of riverqueue; check `npm ls riverqueue`" + ); + } + return payload; + } + + /** The payloads of the plugins of this kind, in plugin order. */ + list(plugins: readonly object[] | undefined): readonly Payload[] { + if (plugins === undefined) return []; + return plugins.flatMap((plugin) => { + const payload = this.get(plugin); + return payload === undefined ? [] : [payload]; + }); + } + + /** Brand `plugin` and attach its payload. */ + set(plugin: object, payload: Payload): void { + this.#payloads.set(plugin, payload); + // Enumerable, so a spread copy of the plugin keeps it. + Object.defineProperty(plugin, this.#brand, { + configurable: false, + enumerable: true, + value: true, + writable: false, + }); + } +} diff --git a/js/src/internal/postgres-capabilities.ts b/js/src/internal/postgres-capabilities.ts new file mode 100644 index 000000000..6ce128e93 --- /dev/null +++ b/js/src/internal/postgres-capabilities.ts @@ -0,0 +1,115 @@ +/** + * Features of a PostgreSQL-compatible server that River adapts to, like + * River for Go's `riverdriver.PostgresCapabilities`. + * + * YugabyteDB speaks PostgreSQL's protocol but has no `xmax` system column + * and, unless configured for it, no `LISTEN`/`NOTIFY`. River's PostgreSQL + * drivers detect the server once and cache the result for the driver's + * lifetime, so enabling Yugabyte's notifications takes effect only for a new + * driver, such as after a restart. + */ + +/** + * Reads the server's product, version, and Yugabyte notification setting, + * and the session's `DateStyle`, which a driver reading timestamps as text + * needs to be ISO. The functions are unqualified, as in River for Go, so + * they resolve through the connection's `search_path`. + */ +export const POSTGRES_CAPABILITIES_SQL = ` + SELECT + current_setting('DateStyle') AS date_style, + version()::text AS product, + current_setting('server_version_num')::int AS version_num, + coalesce(current_setting('yb_enable_listen_notify', true), 'off')::boolean + AS yb_listen_notify_enabled +`; + +/** + * How an insert that may conflict on its unique key tells a new row from an + * existing one it returned instead: + * + * - `metadata_nonce`: each proposed row's metadata carries a random nonce + * under {@link UNIQUE_INSERT_NONCE_KEY}, and a returned row without the + * one its insert wrote already existed. Used where `xmax` is unavailable. + * - `returning_old`: PostgreSQL 18's `OLD` row in `RETURNING`. + * - `xmax`: PostgreSQL's `xmax` system column, nonzero for an updated row. + */ +export type UniqueInsertMode = "metadata_nonce" | "returning_old" | "xmax"; + +/** Features detected from a PostgreSQL-compatible server. */ +export interface PostgresCapabilities { + /** + * Whether `pg_notify` delivers notifications to listeners. Without it, + * River sends no notifications and clients poll instead. + */ + readonly supportsListenNotify: boolean; + readonly uniqueInsertMode: UniqueInsertMode; +} + +/** The metadata key of a unique insert's nonce, shared with River for Go. */ +export const UNIQUE_INSERT_NONCE_KEY = "river:unique_nonce"; + +/** + * Derive capabilities from the server's `version()` text, its + * `server_version_num`, and Yugabyte's `yb_enable_listen_notify` setting, + * which reads as off when absent. + */ +export function postgresCapabilities( + product: string, + versionNum: number, + ybListenNotifyEnabled: boolean +): PostgresCapabilities { + const lower = product.toLowerCase(); + const yugabyte = lower.includes("-yb") || lower.includes("yugabyte"); + return Object.freeze({ + // Yugabyte's notifications need 2025.2.3 or later with + // `ysql_yb_enable_listen_notify=true` on both masters and tservers. + supportsListenNotify: !yugabyte || ybListenNotifyEnabled, + uniqueInsertMode: yugabyte + ? "metadata_nonce" + : versionNum >= 180_000 + ? "returning_old" + : "xmax", + }); +} + +/** + * Decode a row of {@link POSTGRES_CAPABILITIES_SQL}, whose `version_num` + * may arrive as a number or a decimal string depending on the client's type + * parsers. + */ +export function postgresCapabilitiesFromRow(row: { + readonly product: unknown; + readonly version_num: unknown; + readonly yb_listen_notify_enabled: unknown; +}): PostgresCapabilities { + const versionNum = Number(row.version_num); + if ( + typeof row.product !== "string" || + !Number.isSafeInteger(versionNum) || + typeof row.yb_listen_notify_enabled !== "boolean" + ) { + throw new TypeError("unexpected PostgreSQL server capabilities row"); + } + return postgresCapabilities( + row.product, + versionNum, + row.yb_listen_notify_enabled + ); +} + +/** + * The SQL expression that's true for an existing row a unique insert + * returned, in a `RETURNING` clause. It's always false for + * `metadata_nonce`, which compares nonces after the insert instead. + */ +export function uniqueInsertConflictSql(mode: UniqueInsertMode): string { + switch (mode) { + case "metadata_nonce": + return "false"; + case "returning_old": + return "(OLD.id IS NOT NULL)"; + case "xmax": + return "(xmax != 0)"; + } +} diff --git a/js/src/internal/queue-metadata-text.ts b/js/src/internal/queue-metadata-text.ts new file mode 100644 index 000000000..2021ae6c2 --- /dev/null +++ b/js/src/internal/queue-metadata-text.ts @@ -0,0 +1,41 @@ +/** + * The stored text of queue rows' metadata, which drivers record as they + * decode rows so a pilot can read it exactly, such as number literals + * like `1.0` that decoding folds into plain numbers. Nothing here is + * readable through a package entry point. + */ +import type { QueueRow } from "../driver.js"; +import { ValidationError } from "../errors.js"; +import { stringifyJson } from "../json.js"; + +const texts = new WeakMap(); + +/** + * Record `text`, the metadata of `queue` as its database stores it. A + * first-party driver calls it for each queue row it decodes. + */ +export function recordQueueMetadataText(queue: QueueRow, text: string): void { + // JavaScript callers may pass anything. + const value: unknown = queue; + if (typeof value !== "object" || value === null) { + throw new ValidationError("a queue row must be an object"); + } + if (typeof text !== "string") { + throw new ValidationError("queue metadata text must be a string"); + } + texts.set(queue, text); +} + +/** + * The stored text of `queue`'s metadata, or River's encoding of its parsed + * metadata when no driver recorded it. + */ +export function queueMetadataText(queue: QueueRow): string { + return texts.get(queue) ?? stringifyJson(queue.metadata); +} + +/** Carry `from`'s recorded text over to a copy of it. */ +export function copyQueueMetadataText(from: QueueRow, to: QueueRow): void { + const text = texts.get(from); + if (text !== undefined) texts.set(to, text); +} diff --git a/js/src/internal/queue-metadata-update.ts b/js/src/internal/queue-metadata-update.ts new file mode 100644 index 000000000..4b460fcd1 --- /dev/null +++ b/js/src/internal/queue-metadata-update.ts @@ -0,0 +1,22 @@ +import type { QueueUpdateParams } from "../driver.js"; +import { stringifyJson } from "../json.js"; + +/** + * What a queue update with new metadata writes: `text`, the metadata to + * store, and `notification`, the `metadata_changed` notification's payload + * for queue `queue`. Undefined when the update keeps the queue's metadata. + */ +export function queueMetadataUpdate( + queue: string, + params: QueueUpdateParams +): { readonly notification: string; readonly text: string } | undefined { + if (params.metadata === undefined) return undefined; + return { + notification: stringifyJson({ + action: "metadata_changed", + metadata: params.metadata, + queue, + }), + text: stringifyJson(params.metadata), + }; +} diff --git a/js/src/internal/sql.ts b/js/src/internal/sql.ts new file mode 100644 index 000000000..05e3f48a4 --- /dev/null +++ b/js/src/internal/sql.ts @@ -0,0 +1,7 @@ +/** + * Quote an identifier for PostgreSQL or SQLite, doubling embedded quotes. + * It doesn't check the identifier's length or characters. + */ +export function quoteIdentifier(value: string): string { + return `"${value.replaceAll('"', '""')}"`; +} diff --git a/js/src/job-args-transform.ts b/js/src/job-args-transform.ts new file mode 100644 index 000000000..ca92911d2 --- /dev/null +++ b/js/src/job-args-transform.ts @@ -0,0 +1,241 @@ +import type { JobDefinition } from "./job-definition.js"; +import { ValidationError } from "./errors.js"; +import { PluginPayloads } from "./internal/plugin-payloads.js"; +import type { RiverPlugin } from "./extensions.js"; +import type { ExactJsonNumber, JsonObject } from "./json.js"; +import { + deepFreezeJson, + jsonValuesEqual, + parseJsonObject, + toJsonObject, +} from "./json.js"; + +declare const jobArgsTransformPluginBrand: unique symbol; + +/** A recursively immutable JSON object supplied to an argument transformer. */ +export interface ReadonlyJsonObject { + readonly [key: string]: ReadonlyJsonValue; +} + +/** A recursively immutable JSON value supplied to an argument transformer. */ +export type ReadonlyJsonValue = + | boolean + | ExactJsonNumber + | null + | number + | ReadonlyJsonObject + | readonly ReadonlyJsonValue[] + | string; + +/** Immutable insertion input for an exact-version argument transformer. */ +export interface JobArgsInsertTransformInput { + readonly args: ReadonlyJsonObject; + /** + * The job definition the caller inserted (the same object identity), or + * undefined for an insertion without one. + */ + readonly definition: JobDefinition | undefined; + readonly encodedArgs: string; + readonly kind: string; +} + +/** Validated insertion output from an exact-version argument transformer. */ +export interface JobArgsInsertTransformOutput { + readonly args: ReadonlyJsonObject; + readonly encodedArgs: string; +} + +interface TransformedJobArgs { + readonly args: JsonObject; + readonly encodedArgs: string; +} + +/** Immutable persisted input for an exact-version argument transformer. */ +export interface JobArgsReadTransformInput { + readonly args: ReadonlyJsonObject; + readonly kind: string; +} + +/** + * Transforms job arguments as they are written to and read from the + * database, for extensions that store arguments in another form. Extensions + * using it must pin the exact `riverqueue` version. + * + * Insertion transformations run in plugin order. Read transformations run in + * reverse order so independently composed codecs unwrap in the natural order. + * Insert middleware and hooks observe storage-shaped arguments. Immediate + * insert results are unwrapped to preserve their typed input contract; query + * and administrative operations continue to expose storage-shaped rows. + * Omitting `onInsert` is a read-only migration mode; in that mode `onRead` + * must pass through the plaintext rows this client continues to insert. + */ +export interface JobArgsTransformer { + readonly name: string; + readonly onInsert?: ( + input: JobArgsInsertTransformInput + ) => JobArgsInsertTransformOutput; + readonly onRead: (input: JobArgsReadTransformInput) => ReadonlyJsonObject; +} + +/** Opaque River plugin produced by {@link createJobArgsTransformPlugin}. */ +export interface JobArgsTransformPlugin extends RiverPlugin { + readonly [jobArgsTransformPluginBrand]: true; +} + +/** Each plugin's transformer, kept off the plugin object. */ +const transformers = new PluginPayloads>( + "riverqueue.job-args-transform-plugin", + "job argument transform plugin" +); + +/** + * Create a plugin that transforms job arguments as they are written to and + * read from the database. + */ +export function createJobArgsTransformPlugin( + transformer: JobArgsTransformer +): JobArgsTransformPlugin { + const normalized = normalizeTransformer(transformer); + const plugin = { + name: normalized.name, + } as JobArgsTransformPlugin; + transformers.set(plugin, normalized); + return Object.freeze(plugin); +} + +/** @internal Preserve an opaque transformer while snapshotting plugins. */ +export function cloneJobArgsTransformPlugin( + source: RiverPlugin, + target: RiverPlugin +): void { + transformers.copy(source, target); +} + +/** @internal Return whether this is a matched argument-transform plugin. */ +export function isJobArgsTransformPlugin(plugin: object): boolean { + return transformers.get(plugin) !== undefined; +} + +/** @internal Return only exact transformers, preserving plugin order. */ +export function getJobArgsTransformers( + plugins: readonly RiverPlugin[] | undefined +): readonly Readonly[] { + return transformers.list(plugins); +} + +/** @internal Apply insertion transformations after plaintext uniqueness. */ +export function transformJobArgsForInsert( + configured: readonly Readonly[], + definition: JobDefinition | undefined, + kind: string, + args: JsonObject, + encodedArgs: string +): TransformedJobArgs { + if (configured.length === 0) { + return { args: deepFreezeJson(args), encodedArgs }; + } + + let currentArgs = args; + let currentEncodedArgs = encodedArgs; + for (const transformer of configured) { + if (transformer.onInsert === undefined) continue; + const input = Object.freeze({ + args: immutableJsonObject(currentArgs), + definition, + encodedArgs: currentEncodedArgs, + kind, + }); + const output = transformer.onInsert(input); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- validates untyped JavaScript input + if (output === null || typeof output !== "object") { + throw new ValidationError( + `job argument transformer ${JSON.stringify(transformer.name)} returned an invalid insertion result` + ); + } + const outputArgs = toJsonObject(output.args); + if (typeof output.encodedArgs !== "string") { + throw new ValidationError( + `job argument transformer ${JSON.stringify(transformer.name)} returned a non-string encodedArgs` + ); + } + let parsed: JsonObject; + try { + parsed = parseJsonObject(output.encodedArgs); + } catch (cause: unknown) { + throw new ValidationError( + `job argument transformer ${JSON.stringify(transformer.name)} returned invalid encodedArgs`, + { cause } + ); + } + if (!jsonValuesEqual(outputArgs, parsed)) { + throw new ValidationError( + `job argument transformer ${JSON.stringify(transformer.name)} returned mismatched args and encodedArgs` + ); + } + currentArgs = outputArgs; + currentEncodedArgs = output.encodedArgs; + } + return { + args: deepFreezeJson(currentArgs), + encodedArgs: currentEncodedArgs, + }; +} + +/** @internal Apply read transformations in reverse plugin order. */ +export function transformJobArgsForRead( + configured: readonly Readonly[], + kind: string, + args: JsonObject +): JsonObject { + if (configured.length === 0) return args; + + let currentArgs = args; + for (let index = configured.length - 1; index >= 0; index -= 1) { + const transformer = configured[index]; + if (transformer === undefined) continue; + currentArgs = toJsonObject( + transformer.onRead( + Object.freeze({ args: immutableJsonObject(currentArgs), kind }) + ) + ); + } + return currentArgs; +} + +function immutableJsonObject(value: JsonObject): ReadonlyJsonObject { + return deepFreezeJson(toJsonObject(value)); +} + +function normalizeTransformer( + transformer: JobArgsTransformer +): Readonly { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- validates untyped JavaScript input + if (transformer === null || typeof transformer !== "object") { + throw new ValidationError("job argument transformer must be an object"); + } + if (typeof transformer.name !== "string" || transformer.name.length === 0) { + throw new ValidationError( + "job argument transformer name must be a non-empty string" + ); + } + if ( + transformer.onInsert !== undefined && + typeof transformer.onInsert !== "function" + ) { + throw new ValidationError( + "job argument transformer onInsert must be a function" + ); + } + if (typeof transformer.onRead !== "function") { + throw new ValidationError( + "job argument transformer onRead must be a function" + ); + } + return Object.freeze({ + name: transformer.name, + ...(transformer.onInsert === undefined + ? {} + : { onInsert: transformer.onInsert }), + onRead: transformer.onRead, + }); +} diff --git a/js/src/job-definition.ts b/js/src/job-definition.ts new file mode 100644 index 000000000..a9e5ecee8 --- /dev/null +++ b/js/src/job-definition.ts @@ -0,0 +1,491 @@ +import { + ConfigurationError, + PayloadValidationError, + RiverError, + type PayloadValidationPhase, +} from "./errors.js"; +import type { + InsertOptions, + NormalizedInsertOptions, +} from "./insert-options.js"; +import { normalizeInsertOptions } from "./insert-options.js"; +import { isUserSpecifiedIdOrKind } from "./identifiers.js"; +import type { ExactJsonNumber, JsonObject, JsonValue } from "./json.js"; +import { toJsonObject } from "./json.js"; + +declare const definitionTypes: unique symbol; + +/** + * The structural contract implemented by Standard Schema validators such as + * Zod, Valibot, and ArkType. See https://standardschema.dev. + */ +export interface StandardSchemaV1 { + readonly "~standard": { + readonly types?: + | { + readonly input: Input; + readonly output: Output; + } + | undefined; + readonly validate: ( + value: unknown + ) => + PromiseLike> | StandardSchemaResult; + readonly vendor: string; + readonly version: 1; + }; +} + +/** Successful or failed Standard Schema validation. */ +export type StandardSchemaResult = + | { readonly issues?: undefined; readonly value: Output } + | { readonly issues: readonly StandardSchemaIssue[] }; + +/** The Standard Schema issue fields River retains for diagnostics. */ +export interface StandardSchemaIssue { + readonly message: string; + readonly path?: + ReadonlyArray | undefined; +} + +/** Input type accepted by a Standard Schema validator. */ +export type StandardSchemaInput = + Schema extends StandardSchemaV1 ? Input : never; + +/** Output type produced by a Standard Schema validator. */ +export type StandardSchemaOutput = + Schema extends StandardSchemaV1 ? Output : never; + +/** + * `T` itself when every value it describes can be stored as River job JSON, + * otherwise a type that `T` is not assignable to. + * + * Unlike `T extends JsonObject`, this accepts interfaces (which have no + * implicit index signature) and optional properties (River omits properties + * whose value is `undefined`, like `JSON.stringify`). It rejects `Date`, + * `bigint`, functions, symbols, `Map`/`Set`, class instances, and `unknown`. + */ +export type JsonCompatible = [T] extends [JsonValue] + ? T + : T extends boolean | ExactJsonNumber | null | number | string + ? T + : T extends bigint | symbol | undefined | ((...args: never[]) => unknown) + ? never + : T extends readonly unknown[] + ? { [Index in keyof T]: JsonCompatible } + : T extends object + ? { [Key in keyof T]: JsonCompatibleProperty } + : never; + +/** Object properties may also be `undefined`, which River omits. */ +type JsonCompatibleProperty = T extends undefined + ? undefined + : JsonCompatible; + +/** Readable compile-time error carried by an invalid job definition. */ +export interface JobDefinitionTypeError { + readonly "~riverTypeError": Message; +} + +/** + * The producer input type for a definition whose decoder returns `Args`: + * `Args` itself when it is a JSON object, otherwise a type error asking for an + * explicit producer type. + */ +export type DecodedJobInput = [Args] extends [JsonCompatible] + ? Args extends readonly unknown[] + ? JobDefinitionTypeError<"job args must be a JSON object, not an array"> + : Args extends object + ? Args + : JobDefinitionTypeError<"job args must be a JSON object"> + : JobDefinitionTypeError<"decode() returns values that are not JSON; declare the producer input with defineJob()({ ... })">; + +type SchemaJobInput = + unknown extends StandardSchemaInput + ? JsonObject + : StandardSchemaInput; + +type SchemaCheck = [SchemaJobInput] extends [ + JsonCompatible>, +] + ? SchemaJobInput extends readonly unknown[] + ? JobDefinitionTypeError<"schema input must be a JSON object, not an array"> + : unknown + : JobDefinitionTypeError<"schema input must be JSON (no Date, bigint, undefined, Map, or class values); validate the persisted JSON shape and convert in the worker">; + +export interface JobDefinitionOptions { + /** Insertion defaults below call-site options and above client defaults. */ + readonly defaults?: InsertOptions; + /** Stable persisted job kind shared by every language that works this job. */ + readonly kind: Kind; + /** + * Other kinds this job's worker also works, like River for Go's + * `JobArgsWithKindAliases`. To rename a kind safely, make the new name the + * `kind` and the old one an alias: jobs are inserted under the new kind, + * while jobs already stored under the old one are still worked. Remove the + * alias once those have finished, retries included. + */ + readonly kindAliases?: readonly string[]; +} + +/** A job definition whose arguments are validated by a Standard Schema. */ +export interface SchemaJobDefinitionConfig< + Schema extends StandardSchemaV1, + Kind extends string = string, +> extends JobDefinitionOptions { + readonly decode?: never; + /** + * Standard Schema validator for the persisted JSON arguments. River runs it + * when inserting and again before working, because another producer (an + * older deploy or another language) may have inserted the job. + */ + readonly schema: Schema; +} + +/** A job definition whose arguments are validated by an explicit decoder. */ +export interface DecoderJobDefinitionConfig< + Args, + Kind extends string = string, +> extends JobDefinitionOptions { + /** + * Validate the persisted JSON arguments and return the worker's args. + * Throw to reject them. River calls it when inserting and before working. + */ + readonly decode: (value: JsonObject) => Args | PromiseLike; + readonly schema?: never; +} + +/** A job definition without runtime validation. */ +export interface UncheckedJobDefinitionConfig< + Kind extends string = string, +> extends JobDefinitionOptions { + readonly decode?: never; + readonly schema?: never; +} + +/** + * Immutable identity and type information for one job kind. + * + * `Input` is what producers pass to `client.insert`; `Args` is what the worker + * receives after validation. Definitions contain no client, pool, or handler, + * so web producers can import them without worker dependencies. + */ +export interface JobDefinition< + Input extends object = object, + Args = unknown, + Kind extends string = string, +> { + /** Insertion defaults below call-site options and above client defaults. */ + readonly defaults: Readonly; + /** Stable persisted job kind. */ + readonly kind: Kind; + /** Other kinds its worker also works; see {@link JobDefinitionOptions.kindAliases}. */ + readonly kindAliases?: readonly string[]; + /** Type-only marker carrying the definition's input and args types. */ + readonly [definitionTypes]?: { + readonly args: Args; + readonly input: Input; + }; +} + +/** Worker args produced by a job definition. */ +export type JobDefinitionArgs = + Definition extends JobDefinition ? Args : never; + +/** Producer input accepted by a job definition. */ +export type JobDefinitionInput = + Definition extends JobDefinition ? Input : never; + +interface DefinitionInternals { + readonly decode?: ( + value: JsonObject, + phase: PayloadValidationPhase + ) => Promise; +} + +const definitionInternals = new WeakMap(); + +/** + * Define a job validated by a Standard Schema (Zod, Valibot, ArkType, ...). + * + * Producers pass the schema's input type; workers receive its output type. + * The schema's input must be JSON: River persists exactly what the producer + * passed so other languages can read it and unique hashes stay stable. + * + * @example + * ```ts + * import * as v from "valibot"; + * + * export const sendEmail = defineJob({ + * kind: "send_email", + * schema: v.object({ to: v.pipe(v.string(), v.email()) }), + * }); + * ``` + */ +export function defineJob< + const Schema extends StandardSchemaV1, + const Kind extends string, +>( + config: SchemaJobDefinitionConfig, Kind> +): JobDefinition< + SchemaJobInput extends object ? SchemaJobInput : never, + StandardSchemaOutput, + Kind +>; + +/** + * Define a job validated by an explicit decoder. + * + * The decoder receives the untrusted persisted JSON object and returns the + * worker's args. Producers insert values of the decoder's return type, which + * must therefore be JSON; to insert a different type, declare it with + * `defineJob()({ ... })`. + * + * @example + * ```ts + * export const resizeImage = defineJob({ + * kind: "resize_image", + * decode(value) { + * if (typeof value.url !== "string") throw new TypeError("url required"); + * return { url: value.url }; + * }, + * }); + * ``` + */ +export function defineJob( + config: DecoderJobDefinitionConfig +): JobDefinition>, Awaited, Kind>; + +/** + * Declare a job's producer input type explicitly, then define it. + * + * With a decoder, workers receive the decoder's return type. Without one, + * workers receive `JsonObject`: a type argument alone never makes persisted + * input from another producer appear validated. + * + * @example + * ```ts + * interface ReportInput { + * reportId: string; + * } + * + * export const buildReport = defineJob()({ + * kind: "build_report", + * decode(value) { + * if (typeof value.reportId !== "string") { + * throw new TypeError("reportId must be a string"); + * } + * return { reportId: value.reportId, requestedAt: Temporal.Now.instant() }; + * }, + * }); + * ``` + */ +export function defineJob(): [ + Input, +] extends [JsonCompatible] + ? DefineJobWithInput + : JobDefinitionTypeError<"the declared producer input must be JSON (no Date, bigint, undefined, Map, or class values)">; + +/** + * Define a job without runtime validation. Producers insert any JSON object + * and workers receive `JsonObject` args. + */ +export function defineJob( + config: UncheckedJobDefinitionConfig +): JobDefinition; + +export function defineJob( + config?: + | DecoderJobDefinitionConfig + | SchemaJobDefinitionConfig + | UncheckedJobDefinitionConfig +): JobDefinition | DefineJobWithInput { + if (config === undefined) { + return createDefinition as DefineJobWithInput; + } + return createDefinition(config); +} + +/** Second step of `defineJob()`, with the producer type fixed. */ +export interface DefineJobWithInput { + /** Define a job whose decoder returns the worker's args. */ + ( + config: DecoderJobDefinitionConfig + ): JobDefinition, Kind>; + /** Define a job whose workers receive unvalidated `JsonObject` args. */ + ( + config: UncheckedJobDefinitionConfig + ): JobDefinition; +} + +function createDefinition( + config: + | DecoderJobDefinitionConfig + | SchemaJobDefinitionConfig + | UncheckedJobDefinitionConfig +): JobDefinition { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- validates untyped JavaScript input + if (config === null || typeof config !== "object") { + throw new ConfigurationError("job definition must be an object"); + } + validateKind(config.kind); + const kindAliases = normalizeKindAliases(config.kind, config.kindAliases); + + const definition: JobDefinition = { + defaults: normalizeInsertOptions(config.defaults), + kind: config.kind, + kindAliases, + }; + + let decode: DefinitionInternals["decode"]; + if (config.schema !== undefined) { + const schema = config.schema; + if ( + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- validates untyped JavaScript input + schema === null || + typeof schema !== "object" || + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- validates untyped JavaScript input + typeof schema["~standard"]?.validate !== "function" + ) { + throw new ConfigurationError( + "job schema must implement Standard Schema (a `~standard.validate` function)" + ); + } + decode = async (value, phase) => { + const result = await schema["~standard"].validate(value); + if (result.issues !== undefined) { + throw payloadError(config.kind, phase, result.issues); + } + return result.value; + }; + } else if (config.decode !== undefined) { + if (typeof config.decode !== "function") { + throw new ConfigurationError("job decode must be a function"); + } + const configDecode = config.decode; + decode = async (value, phase) => { + try { + return await configDecode(value); + } catch (cause: unknown) { + if (cause instanceof RiverError) throw cause; + throw new PayloadValidationError( + config.kind, + phase, + `invalid payload for job kind ${JSON.stringify(config.kind)}: ${ + cause instanceof Error ? cause.message : String(cause) + }`, + { cause } + ); + } + }; + } + + definitionInternals.set(definition, decode === undefined ? {} : { decode }); + return Object.freeze(definition); +} + +/** Validate insertion input and return the exact JSON object to persist. */ +export async function prepareJobInput( + definition: Definition, + input: JobDefinitionInput +): Promise { + const persisted = toJsonObject(input); + const decode = requireInternals(definition).decode; + if (decode !== undefined) await decode(persisted, "insert"); + return persisted; +} + +/** Validate persisted args with a definition and return the worker's args. */ +export async function decodeJobArgs( + definition: Definition, + value: unknown +): Promise> { + const persisted = toJsonObject(value); + const decode = requireInternals(definition).decode; + return ( + decode === undefined ? persisted : await decode(persisted, "work") + ) as JobDefinitionArgs; +} + +/** Whether a value was created by {@link defineJob}. */ +export function isJobDefinition(value: unknown): value is JobDefinition { + return ( + value !== null && + typeof value === "object" && + definitionInternals.has(value) + ); +} + +function payloadError( + kind: string, + phase: PayloadValidationPhase, + issues: readonly StandardSchemaIssue[] +): PayloadValidationError { + const details = issues.map((issue) => ({ + message: issue.message, + ...(issue.path === undefined + ? {} + : { + path: issue.path.map((part) => + typeof part === "object" ? String(part.key) : String(part) + ), + }), + })); + const first = details[0]; + const summary = + first === undefined + ? "" + : `: ${first.path === undefined ? "" : `${first.path.join(".")}: `}${first.message}`; + return new PayloadValidationError( + kind, + phase, + `invalid payload for job kind ${JSON.stringify(kind)}${summary}`, + { details: { issues: details } } + ); +} + +function requireInternals(definition: JobDefinition): DefinitionInternals { + const internals = definitionInternals.get(definition); + if (internals === undefined) { + throw new ConfigurationError( + "job definition was not created by defineJob (or came from a second copy of the riverqueue package)" + ); + } + return internals; +} + +function normalizeKindAliases( + kind: string, + aliases: readonly string[] | undefined +): readonly string[] { + if (aliases === undefined) return Object.freeze([]); + // Validates untyped JavaScript input. + const value: unknown = aliases; + if (!Array.isArray(value)) { + throw new ConfigurationError("job kindAliases must be an array of kinds"); + } + const seen = new Set([kind]); + for (const alias of aliases) { + validateKind(alias); + if (seen.has(alias)) { + throw new ConfigurationError( + `job kind alias ${JSON.stringify(alias)} repeats a kind of the same job` + ); + } + seen.add(alias); + } + return Object.freeze([...aliases]); +} + +function validateKind(kind: string): void { + if (typeof kind !== "string" || !isUserSpecifiedIdOrKind(kind)) { + throw new ConfigurationError( + "job kind must be at least 2 characters, start with a letter, number, or underscore, and contain only letters, numbers, and _-[]<>/.·:+" + ); + } + if (kind.startsWith("river_internal_")) { + throw new ConfigurationError( + 'job kinds beginning with "river_internal_" are reserved' + ); + } +} diff --git a/js/src/job-insert-metadata-transform.ts b/js/src/job-insert-metadata-transform.ts new file mode 100644 index 000000000..6eac39193 --- /dev/null +++ b/js/src/job-insert-metadata-transform.ts @@ -0,0 +1,174 @@ +import { ValidationError } from "./errors.js"; +import { PluginPayloads } from "./internal/plugin-payloads.js"; +import type { RiverPlugin } from "./extensions.js"; +import type { JobDefinition } from "./job-definition.js"; +import type { ReadonlyJsonObject } from "./job-args-transform.js"; +import type { JsonObject } from "./json.js"; +import { deepFreezeJson, toJsonObject } from "./json.js"; + +declare const jobInsertMetadataTransformPluginBrand: unique symbol; + +/** Plaintext insertion input for a matched-version metadata transformer. */ +export interface JobInsertMetadataTransformInput { + readonly args: ReadonlyJsonObject; + /** + * The job definition the caller inserted (the same object identity), or + * undefined for an insertion without one. Extensions may key per-definition + * behavior off it, for example with a `WeakMap`. + */ + readonly definition: JobDefinition | undefined; + readonly kind: string; + readonly metadata: ReadonlyJsonObject; + /** Whether the job will be inserted `pending` so far. */ + readonly pending: boolean; + readonly queue: string; +} + +/** What a metadata transformer returns for one insertion. */ +export interface JobInsertMetadataTransformResult { + /** The job's complete metadata after this transformer. */ + readonly metadata: ReadonlyJsonObject; + /** + * Insert the job `pending` instead of available or scheduled, like the + * `pending` insert option. A transformer can only set it, never clear it. + */ + readonly pending?: true; +} + +/** + * Adjusts an insertion's metadata (and optionally makes it `pending`) before + * uniqueness is computed and before argument transforms run. Transformers + * run in plugin order on every insertion path. + */ +export interface JobInsertMetadataTransformer { + readonly name: string; + readonly onInsert: ( + input: JobInsertMetadataTransformInput + ) => JobInsertMetadataTransformResult; +} + +/** The combined result of every configured metadata transformer. */ +export interface TransformedInsertMetadata { + readonly metadata: JsonObject; + readonly pending: boolean; +} + +/** Opaque plugin produced by {@link createJobInsertMetadataTransformPlugin}. */ +export interface JobInsertMetadataTransformPlugin extends RiverPlugin { + readonly [jobInsertMetadataTransformPluginBrand]: true; +} + +/** Each plugin's transformer, kept off the plugin object. */ +const transformers = new PluginPayloads>( + "riverqueue.job-insert-metadata-transform-plugin", + "job insert metadata transform plugin" +); + +/** + * Create a plugin that adds or rewrites a job's metadata at insert time, + * before argument transforms run, and may insert the job as `pending`. + */ +export function createJobInsertMetadataTransformPlugin( + transformer: JobInsertMetadataTransformer +): JobInsertMetadataTransformPlugin { + const normalized = normalizeTransformer(transformer); + const plugin = { name: normalized.name } as JobInsertMetadataTransformPlugin; + transformers.set(plugin, normalized); + return Object.freeze(plugin); +} + +/** @internal Preserve an opaque transformer while snapshotting plugins. */ +export function cloneJobInsertMetadataTransformPlugin( + source: RiverPlugin, + target: RiverPlugin +): void { + transformers.copy(source, target); +} + +/** @internal Return only exact transformers, preserving plugin order. */ +export function getJobInsertMetadataTransformers( + plugins: readonly RiverPlugin[] | undefined +): readonly Readonly[] { + return transformers.list(plugins); +} + +/** @internal Return whether this is a matched metadata-transform plugin. */ +export function isJobInsertMetadataTransformPlugin(plugin: object): boolean { + return transformers.get(plugin) !== undefined; +} + +/** @internal Apply metadata transforms while arguments are still plaintext. */ +export function transformJobInsertMetadata( + configured: readonly Readonly[], + definition: JobDefinition | undefined, + kind: string, + args: JsonObject, + metadata: JsonObject, + queue: string, + pending: boolean +): TransformedInsertMetadata { + let current = metadata; + let currentPending = pending; + for (const transformer of configured) { + const input = Object.freeze({ + args: immutableJsonObject(args), + definition, + kind, + metadata: immutableJsonObject(current), + pending: currentPending, + queue, + }); + const name = JSON.stringify(transformer.name); + const result = transformer.onInsert(input); + if ( + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- validates untyped JavaScript input + result === null || + typeof result !== "object" || + // eslint-disable-next-line @typescript-eslint/no-unnecessary-boolean-literal-compare, @typescript-eslint/no-unnecessary-condition -- validates untyped JavaScript input + (result.pending !== undefined && result.pending !== true) + ) { + throw new ValidationError( + `job insert metadata transformer ${name} must return { metadata, pending? }` + ); + } + try { + current = toJsonObject(result.metadata); + } catch (cause: unknown) { + throw new ValidationError( + `job insert metadata transformer ${name} returned invalid metadata`, + { cause } + ); + } + if (result.pending === true) currentPending = true; + } + return { metadata: deepFreezeJson(current), pending: currentPending }; +} + +function immutableJsonObject(value: JsonObject): ReadonlyJsonObject { + return deepFreezeJson(toJsonObject(value)); +} + +function normalizeTransformer( + transformer: JobInsertMetadataTransformer +): Readonly { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- validates untyped JavaScript input + if (transformer === null || typeof transformer !== "object") { + throw new ValidationError( + "job insert metadata transformer must be an object" + ); + } + if (typeof transformer.name !== "string" || transformer.name.length === 0) { + throw new ValidationError( + "job insert metadata transformer name must be non-empty" + ); + } + if (typeof transformer.onInsert !== "function") { + throw new ValidationError( + "job insert metadata transformer onInsert must be a function" + ); + } + return Object.freeze({ + name: transformer.name, + onInsert: transformer.onInsert, + }); +} diff --git a/js/src/job.ts b/js/src/job.ts index c793eba8a..e5a9c3138 100644 --- a/js/src/job.ts +++ b/js/src/job.ts @@ -1,148 +1,259 @@ -import type { InsertOpts } from "./insert-opts.js"; - -// Job states matching the River database enum. -export const JOB_STATE_AVAILABLE = "available" as const; -export const JOB_STATE_CANCELLED = "cancelled" as const; -export const JOB_STATE_COMPLETED = "completed" as const; -export const JOB_STATE_DISCARDED = "discarded" as const; -export const JOB_STATE_PENDING = "pending" as const; -export const JOB_STATE_RETRYABLE = "retryable" as const; -export const JOB_STATE_RUNNING = "running" as const; -export const JOB_STATE_SCHEDULED = "scheduled" as const; - -export type JobState = - | typeof JOB_STATE_AVAILABLE - | typeof JOB_STATE_CANCELLED - | typeof JOB_STATE_COMPLETED - | typeof JOB_STATE_DISCARDED - | typeof JOB_STATE_PENDING - | typeof JOB_STATE_RETRYABLE - | typeof JOB_STATE_RUNNING - | typeof JOB_STATE_SCHEDULED; - -/** Default number of maximum attempts for a job. */ +import { ValidationError } from "./errors.js"; +import { bytesToHex } from "./internal/hex.js"; +import type { JsonObject, JsonValue } from "./json.js"; +import { toJsonObject } from "./json.js"; + +/** Persisted River job states. */ +export const JOB_STATE = { + available: "available", + cancelled: "cancelled", + completed: "completed", + discarded: "discarded", + pending: "pending", + retryable: "retryable", + running: "running", + scheduled: "scheduled", +} as const; + +/** A job's state, one of the {@link JOB_STATE} values. */ +export type JobState = (typeof JOB_STATE)[keyof typeof JOB_STATE]; + +// Retain the established constants while making JOB_STATE the preferred API. + export const MAX_ATTEMPTS_DEFAULT = 25; -/** Default priority for a job. */ export const PRIORITY_DEFAULT = 1; -/** Default queue for a job. */ export const QUEUE_DEFAULT = "default"; -/** - * Interface for job args. Implementations must provide a `kind` string that - * uniquely identifies the job type in the database. - * - * Implementations should define a `toJSON()` method to control which fields - * are serialized as the job's args. If `toJSON()` is not defined, all - * properties except `kind` and `insertOpts` are serialized. - * - * They may optionally provide `insertOpts` to set default insertion options - * for all jobs of this kind. - * - * Example: - * - * class SortArgs implements JobArgs { - * kind = "sort"; - * - * constructor(public strings: string[]) {} - * - * toJSON() { - * return { strings: this.strings }; - * } - * } - */ -export interface JobArgs { - kind: string; - insertOpts?: InsertOpts; -} - -/** - * Provides a way to create job args from a plain object for quick insertion - * without defining a class. - * - * Example: - * - * const args = new JobArgsObject("sort", { strings: ["whale", "tiger"] }); - * await client.insert(args); - */ -export class JobArgsObject implements JobArgs { - readonly kind: string; - private readonly obj: Record; - - constructor(kind: string, obj: Record) { - if (!kind) throw new Error("kind is required"); - if (!obj) throw new Error("obj is required"); - this.kind = kind; - this.obj = obj; - } +/** A failed work attempt persisted with a job. */ +export interface AttemptError { + readonly at: Temporal.Instant; + readonly attempt: number; + readonly error: string; + readonly trace: string; +} - toJSON(): Record { - return this.obj; - } +/** Exact properties of a persisted River job. */ +export interface JobRow { + readonly args: TArgs; + readonly attempt: number; + readonly attemptedAt: Temporal.Instant | null; + readonly attemptedBy: readonly string[]; + readonly createdAt: Temporal.Instant; + readonly errors: readonly AttemptError[]; + readonly finalizedAt: Temporal.Instant | null; + readonly id: bigint; + readonly kind: string; + readonly maxAttempts: number; + readonly metadata: JsonObject; + readonly priority: number; + readonly queue: string; + readonly scheduledAt: Temporal.Instant; + readonly state: JobState; + readonly tags: readonly string[]; + readonly uniqueKey: Uint8Array | null; + readonly uniqueStates: readonly JobState[] | null; } -/** A failed job work attempt containing information about the error. */ -export interface AttemptError { - at: Date; +/** JSON-safe form of {@link AttemptError}. */ +export interface AttemptErrorJson extends JsonObject { + at: string; attempt: number; error: string; trace: string; } -/** Contains the properties of a job that are persisted to the database. */ -export interface JobRow { - /** ID of the job, generated by a Postgres sequence. */ - id: number; - - /** The job's args as an object decoded from JSON. */ - args: Record; - - /** The attempt number of the job. Jobs are inserted at 0. */ +/** JSON-safe form of {@link JobRow}. */ +export interface JobRowJson extends JsonObject { + args: JsonObject; attempt: number; + attemptedAt: string | null; + attemptedBy: string[]; + createdAt: string; + errors: AttemptErrorJson[]; + finalizedAt: string | null; + id: string; + kind: string; + maxAttempts: number; + metadata: JsonObject; + priority: number; + queue: string; + scheduledAt: string; + state: JobState; + tags: string[]; + uniqueKey: string | null; + uniqueStates: JobState[] | null; +} - /** The time that the job was last worked. */ - attemptedAt: Date | null; +/** Convert a job to a form that is safe to pass to JSON.stringify. */ +export function jobToJsonValue(job: JobRow): JobRowJson { + return { + args: toJsonObject(job.args), + attempt: job.attempt, + attemptedAt: job.attemptedAt?.toString() ?? null, + attemptedBy: [...job.attemptedBy], + createdAt: job.createdAt.toString(), + errors: job.errors.map((error) => ({ + at: error.at.toString(), + attempt: error.attempt, + error: error.error, + trace: error.trace, + })), + finalizedAt: job.finalizedAt?.toString() ?? null, + id: job.id.toString(10), + kind: job.kind, + maxAttempts: job.maxAttempts, + metadata: toJsonObject(job.metadata), + priority: job.priority, + queue: job.queue, + scheduledAt: job.scheduledAt.toString(), + state: job.state, + tags: [...job.tags], + uniqueKey: job.uniqueKey === null ? null : bytesToHex(job.uniqueKey), + uniqueStates: job.uniqueStates === null ? null : [...job.uniqueStates], + }; +} - /** The set of worker IDs that have worked this job. */ - attemptedBy: string[] | null; +/** Decode a JSON-safe job while restoring exact bigint and Temporal values. */ +export function jobFromJsonValue(value: unknown): JobRow { + const object = toJsonObject(value); + const attemptedAt = nullableString(object, "attemptedAt"); + const finalizedAt = nullableString(object, "finalizedAt"); + const uniqueKey = nullableString(object, "uniqueKey"); - /** When the job record was created. */ - createdAt: Date; + return { + args: toJsonObject(object.args), + attempt: integer(object, "attempt", { min: 0 }), + attemptedAt: attemptedAt === null ? null : parseInstant(attemptedAt), + attemptedBy: stringArray(object, "attemptedBy"), + createdAt: parseInstant(string(object, "createdAt")), + errors: errors(object.errors), + finalizedAt: finalizedAt === null ? null : parseInstant(finalizedAt), + id: decimalBigInt(object, "id"), + kind: string(object, "kind"), + maxAttempts: integer(object, "maxAttempts", { min: 0 }), + metadata: toJsonObject(object.metadata), + priority: integer(object, "priority", { max: 4, min: 1 }), + queue: string(object, "queue"), + scheduledAt: parseInstant(string(object, "scheduledAt")), + state: jobState(object.state), + tags: stringArray(object, "tags"), + uniqueKey: uniqueKey === null ? null : hexToBytes(uniqueKey), + uniqueStates: nullableJobStateArray(object, "uniqueStates"), + }; +} - /** Errors from previous work attempts, ordered earliest to latest. */ - errors: AttemptError[] | null; +function decimalBigInt(object: JsonObject, key: string): bigint { + const value = string(object, key); + if (!/^-?(0|[1-9]\d*)$/.test(value)) { + throw invalidField(key, "must be a canonical decimal integer string"); + } + return BigInt(value); +} - /** When the job was finalized (completed successfully or discarded). */ - finalizedAt: Date | null; +function errors(value: JsonValue | undefined): AttemptError[] { + if (!Array.isArray(value)) throw invalidField("errors", "must be an array"); + return value.map((item) => { + const object = toJsonObject(item); + return { + at: parseInstant(string(object, "at")), + attempt: integer(object, "attempt", { min: 0 }), + error: string(object, "error"), + trace: string(object, "trace"), + }; + }); +} - /** Kind uniquely identifies the type of job and which worker should work it. */ - kind: string; +function hexToBytes(value: string): Uint8Array { + if (value.length % 2 !== 0 || !/^[0-9a-f]*$/.test(value)) { + throw invalidField("uniqueKey", "must be lowercase hexadecimal"); + } + return Uint8Array.from( + value.match(/.{2}/g)?.map((byte) => Number.parseInt(byte, 16)) ?? [] + ); +} - /** The maximum number of attempts before the job is discarded. */ - maxAttempts: number; +function integer( + object: JsonObject, + key: string, + range: { max?: number; min?: number } +): number { + const value = object[key]; + if (typeof value !== "number" || !Number.isSafeInteger(value)) { + throw invalidField(key, "must be a safe integer"); + } + if (range.min !== undefined && value < range.min) { + throw invalidField(key, `must be at least ${range.min}`); + } + if (range.max !== undefined && value > range.max) { + throw invalidField(key, `must be at most ${range.max}`); + } + return value; +} - /** Arbitrary metadata associated with the job. */ - metadata: Record; +function invalidField(key: string, message: string): ValidationError { + return new ValidationError(`invalid job.${key}: ${message}`, { + details: { field: key }, + }); +} - /** Priority of the job, 1 (highest) to 4 (lowest). */ - priority: number; +function jobState(value: JsonValue | undefined): JobState { + if ( + typeof value !== "string" || + !Object.values(JOB_STATE).includes(value as JobState) + ) { + throw new ValidationError( + `unknown River job state: ${JSON.stringify(value)}` + ); + } + return value as JobState; +} - /** The queue where the job will be worked. */ - queue: string; +function jobStateArray(object: JsonObject, key: string): JobState[] { + const value = object[key]; + if (!Array.isArray(value)) throw invalidField(key, "must be an array"); + return value.map(jobState); +} - /** When the job is scheduled to become available for work. */ - scheduledAt: Date; +function nullableJobStateArray( + object: JsonObject, + key: string +): JobState[] | null { + return object[key] === null ? null : jobStateArray(object, key); +} - /** The current state of the job. */ - state: JobState; +function nullableString(object: JsonObject, key: string): string | null { + const value = object[key]; + if (value === null) return null; + if (typeof value !== "string") + throw invalidField(key, "must be a string or null"); + return value; +} - /** Arbitrary tags for grouping and categorizing jobs. */ - tags: string[]; +function parseInstant(value: string): Temporal.Instant { + try { + return Temporal.Instant.from(value); + } catch (cause) { + throw new ValidationError( + `invalid Temporal.Instant: ${JSON.stringify(value)}`, + { + cause, + } + ); + } +} - /** Unique key for the job, generated by objing unique opts configuration. */ - uniqueKey: Uint8Array | null; +function string(object: JsonObject, key: string): string { + const value = object[key]; + if (typeof value !== "string") throw invalidField(key, "must be a string"); + return value; +} - /** States considered for uniqueness checks. */ - uniqueStates: JobState[] | null; +function stringArray(object: JsonObject, key: string): string[] { + const value = object[key]; + if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) { + throw invalidField(key, "must be an array of strings"); + } + return value as string[]; } diff --git a/js/src/options.ts b/js/src/options.ts new file mode 100644 index 000000000..f85380337 --- /dev/null +++ b/js/src/options.ts @@ -0,0 +1,542 @@ +import type { + InsertMiddleware, + RiverErrorHandler, + RiverHooks, + RiverPlugin, + WorkMiddleware, +} from "./extensions.js"; +import type { RegisteredTransaction } from "./driver.js"; +import type { InsertOptions } from "./insert-options.js"; +import type { Logger } from "./logger.js"; +import type { PeriodicJob } from "./periodic.js"; +import type { + EventLoopDelaySettings, + JobStuckHandler, + QueueSettings, + RetryPolicy, + RuntimeSettings, + StopSettings, +} from "./runtime.js"; +import type { Workers } from "./worker.js"; +import type { MaintenanceSettings, ReindexerSchedule } from "./services.js"; +import { ValidationError } from "./errors.js"; +import { + toMilliseconds, + toNullableMilliseconds, + type DurationInput, +} from "./internal/duration.js"; + +export type { DurationInput } from "./internal/duration.js"; + +/** + * Local configuration of one queue this client works. + * + * Durations accept a `Temporal.Duration` or a duration-like object such as + * `{ seconds: 5 }`. + */ +export interface QueueConfig { + /** + * Minimum time between claim queries. Defaults to the client's + * `fetchCooldown`. + */ + readonly fetchCooldown?: DurationInput; + /** Maximum jobs from this queue worked concurrently by this client. */ + readonly maxWorkers: number; + /** + * How often to poll for jobs when no insert notification arrives. Defaults + * to 1 second. + */ + readonly pollInterval?: DurationInput; +} + +/** Event-loop delay monitoring, enabled by default. */ +export interface EventLoopDelayOptions { + /** How often to report `runtime_event_loop_delay` events. */ + readonly reportInterval?: DurationInput; + /** Sampling resolution of the delay histogram. */ + readonly resolution?: DurationInput; + /** Delay above which River logs a warning. */ + readonly warningThreshold?: DurationInput; +} + +/** Options for `RunHandle.stop`. */ +export interface StopOptions { + /** + * `"graceful"` (the default) stops claiming and lets running jobs finish; + * `"cancel"` also aborts every running job's `signal`. + */ + readonly mode?: "cancel" | "graceful"; + /** Abort a graceful stop early, escalating to cancellation. */ + readonly signal?: AbortSignal; + /** + * Escalate a graceful stop to cancellation after this long. Without it, a + * graceful stop waits for running jobs indefinitely. + */ + readonly timeout?: DurationInput; +} + +/** + * Leader-owned maintenance: election, scheduling, rescue, cleaning, and + * reindexing. Retentions accept `null` to keep rows forever and timeouts + * accept `null` for no limit. + */ +export interface MaintenanceOptions { + /** Keep cancelled jobs this long. Defaults to 24 hours. */ + readonly cancelledJobRetention?: DurationInput | null; + /** Keep completed jobs this long. Defaults to 24 hours. */ + readonly completedJobRetention?: DurationInput | null; + /** Keep discarded jobs this long. Defaults to 7 days. */ + readonly discardedJobRetention?: DurationInput | null; + /** + * How often a leader renews its term and a follower bids for leadership. + * Defaults to 5 seconds. Like River for Go, a follower's interval is + * jittered by up to a fifth, and a follower bids within 50 ms of another + * client's resignation. + */ + readonly electionInterval?: DurationInput; + /** How often the job cleaner runs. Defaults to 30 seconds. */ + readonly jobCleanerInterval?: DurationInput; + /** Bound on one job cleaner pass. Defaults to 1 minute. */ + readonly jobCleanerTimeout?: DurationInput | null; + /** How often expired SQLite notification rows are deleted. */ + readonly notificationCleanerInterval?: DurationInput; + /** Keep SQLite notification rows this long. */ + readonly notificationRetention?: DurationInput; + /** How often the queue cleaner runs. */ + readonly queueCleanerInterval?: DurationInput; + /** Delete queues nothing has reported for this long. */ + readonly queueRetention?: DurationInput; + /** Indexes rebuilt by the PostgreSQL reindexer. */ + readonly reindexerIndexNames?: readonly string[]; + /** When the PostgreSQL reindexer runs next after a given instant. */ + readonly reindexerSchedule?: ReindexerSchedule; + /** Bound on one index rebuild. */ + readonly reindexerTimeout?: DurationInput | null; + /** + * Rescue a running job whose attempt started longer ago than this (jobs + * whose timeout is disabled are never rescued). Defaults to 1 hour. + */ + readonly rescueAfter?: DurationInput; + /** How often the rescuer runs. Defaults to 30 seconds. */ + readonly rescuerInterval?: DurationInput; + /** How often scheduled and retryable jobs are made available. */ + readonly schedulerInterval?: DurationInput; +} + +/** Options for constructing a River client. */ +export interface ClientOptions { + /** + * Stable identifier of this client, recorded in `attempted_by` and used + * for leadership. Defaults to a random value. + */ + readonly clientId?: string; + /** Maximum completions persisted in one query. Defaults to 1,000. */ + readonly completionBatchSize?: number; + /** + * How long a partly filled completion batch waits for more completions. + * Zero flushes immediately. + */ + readonly completionFlushInterval?: DurationInput; + /** Defaults below job-definition defaults and call-site options. */ + readonly defaultInsertOptions?: InsertOptions; + /** Invoked once for each failed attempt; may request cancellation. */ + readonly errorHandler?: RiverErrorHandler; + /** Event-loop delay monitoring; `false` disables it. */ + readonly eventLoopDelay?: false | EventLoopDelayOptions; + /** + * Minimum time between claim queries for queues that don't set their own + * `fetchCooldown`, like River for Go's `Config.FetchCooldown`. Defaults to + * 100 milliseconds and must be at least 1 millisecond. + * + * It also limits insert notifications: after this client notifies + * producers of a queue's new jobs, it sends no other notification for that + * queue until the cooldown passes. Producers fetch at most this often + * anyway, and poll for jobs whose notification was suppressed. + */ + readonly fetchCooldown?: DurationInput; + /** + * Claim only jobs whose kinds have workers in `workers`, like River for + * Go's `Config.FetchOnlyKnownKinds`. Defaults to false. Jobs of other kinds + * stay available without using an attempt, so clients with different + * workers can share a queue, such as while moving job kinds from one + * language to another. The kinds are those registered when the client + * starts. + * + * This affects only claiming. A leader's rescuer still handles stuck jobs + * of every queue and discards those whose kinds it doesn't know, so a + * client with some of the kinds should set `leaderElectionDisabled`, and + * another eligible client should have workers for every kind. Without + * this option, a job of an unknown kind is claimed and fails with an + * unknown job kind error. + */ + readonly fetchOnlyKnownKinds?: boolean; + /** Client-wide hooks, run after plugin hooks. */ + readonly hooks?: RiverHooks; + /** Client-wide insert middleware, run after plugin middleware. */ + readonly insertMiddleware?: readonly InsertMiddleware[]; + /** + * How long River waits after an attempt's timeout, or after it asks a + * running handler to stop, before treating the attempt as stuck, like + * River for Go's `Config.JobStuckThreshold`. Defaults to 10 seconds and + * must not be negative. + * + * A handler still running this long after its timeout is reported stuck + * and passed to `stuckHandler`. An executor that can end a handler by + * force, such as `@riverqueue/worker-threads`, waits this long after + * aborting a handler's signal before terminating it. + */ + readonly jobStuckThreshold?: DurationInput; + /** + * Default cooperative timeout for each job attempt. Defaults to 1 minute; + * `null` disables it. A worker's own `timeout` takes precedence. + */ + readonly jobTimeout?: DurationInput | null; + /** + * Keep this client out of leader election, like River for Go's + * `Config.LeaderElectionDisabled`. Defaults to false. The client never runs + * leader-owned maintenance or inserts periodic jobs, but still works jobs + * from its configured queues, including periodic jobs other clients insert. + * + * At least one other started client on the same database and schema must + * remain eligible to lead for scheduled jobs, retries, periodic jobs, + * stuck-job rescue, and cleanup to progress. A client with leader election + * disabled never leads, even when no other client is running. + * `periodicJobs` must be empty, and `maintenance` settings have no effect. + */ + readonly leaderElectionDisabled?: boolean; + /** + * Structured logger with pino's `(attributes, message)` argument order. + * Defaults to `console` for warnings and errors; `false` silences River. + */ + readonly logger?: Logger | false; + /** Settings for leader-owned maintenance. */ + readonly maintenance?: MaintenanceOptions; + /** Client-wide work middleware, wrapping every handler. */ + readonly middleware?: readonly WorkMiddleware[]; + /** + * Periodic jobs the leader inserts; see `periodicJob`. Must be empty when + * `leaderElectionDisabled` is true. + */ + readonly periodicJobs?: readonly PeriodicJob[]; + /** Named collections of hooks and middleware. */ + readonly plugins?: readonly RiverPlugin[]; + /** + * Disable notification streams and rely on polling alone. Running jobs + * then learn of cancellations by polling every `queueControlPollInterval`. + * A client of a PostgreSQL server without `LISTEN`/`NOTIFY`, such as + * YugabyteDB without `yb_enable_listen_notify`, polls this way on its own. + */ + readonly pollOnly?: boolean; + /** + * How often persisted queue pauses and resumes are polled, and, for a + * client without notifications, its running jobs' cancellations. + */ + readonly queueControlPollInterval?: DurationInput; + /** How often this client reports its configured queues. */ + readonly queueHeartbeatInterval?: DurationInput; + /** Queues this client works, keyed by name. */ + readonly queues?: Readonly>; + /** Override retry scheduling; invalid times fall back to River's default. */ + readonly retryPolicy?: RetryPolicy; + /** Policy invoked after a timed-out attempt exceeds `jobStuckThreshold`. */ + readonly stuckHandler?: JobStuckHandler; + /** Job handlers worked by `client.start()`. */ + readonly workers?: Workers; +} + +/** Every client option the runtime takes, so a misspelled one is rejected. */ +const CLIENT_OPTIONS: Readonly< + Record, true> +> = { + clientId: true, + completionBatchSize: true, + completionFlushInterval: true, + errorHandler: true, + eventLoopDelay: true, + fetchCooldown: true, + fetchOnlyKnownKinds: true, + hooks: true, + insertMiddleware: true, + jobStuckThreshold: true, + jobTimeout: true, + leaderElectionDisabled: true, + logger: true, + maintenance: true, + middleware: true, + periodicJobs: true, + plugins: true, + pollOnly: true, + queueControlPollInterval: true, + queueHeartbeatInterval: true, + queues: true, + retryPolicy: true, + stuckHandler: true, + workers: true, +}; + +const EVENT_LOOP_DELAY_OPTIONS: Readonly< + Record +> = { reportInterval: true, resolution: true, warningThreshold: true }; + +const MAINTENANCE_OPTIONS: Readonly> = { + cancelledJobRetention: true, + completedJobRetention: true, + discardedJobRetention: true, + electionInterval: true, + jobCleanerInterval: true, + jobCleanerTimeout: true, + notificationCleanerInterval: true, + notificationRetention: true, + queueCleanerInterval: true, + queueRetention: true, + reindexerIndexNames: true, + reindexerSchedule: true, + reindexerTimeout: true, + rescueAfter: true, + rescuerInterval: true, + schedulerInterval: true, +}; + +const STOP_OPTIONS: Readonly> = { + mode: true, + signal: true, + timeout: true, +}; + +/** @internal Convert public client options to the runtime's settings. */ +export function toRuntimeSettings( + options: Omit +): RuntimeSettings { + rejectMillisecondOptions("", options); + rejectUnknownOptions("client", options, CLIENT_OPTIONS); + const { + completionFlushInterval, + eventLoopDelay, + fetchCooldown, + jobStuckThreshold, + jobTimeout, + maintenance, + queueControlPollInterval, + queueHeartbeatInterval, + queues, + ...rest + } = options; + return { + ...rest, + ...(completionFlushInterval === undefined + ? {} + : { + completionFlushIntervalMs: toMilliseconds( + "completionFlushInterval", + completionFlushInterval, + { allowZero: true } + ), + }), + ...(eventLoopDelay === undefined + ? {} + : { + eventLoopDelay: + eventLoopDelay === false + ? false + : toEventLoopDelaySettings(eventLoopDelay), + }), + ...(fetchCooldown === undefined + ? {} + : { fetchCooldownMs: toMilliseconds("fetchCooldown", fetchCooldown) }), + ...(jobStuckThreshold === undefined + ? {} + : { + jobStuckThresholdMs: toMilliseconds( + "jobStuckThreshold", + jobStuckThreshold, + { allowZero: true } + ), + }), + ...(jobTimeout === undefined + ? {} + : { jobTimeoutMs: toNullableMilliseconds("jobTimeout", jobTimeout) }), + ...(maintenance === undefined + ? {} + : { + maintenance: toMaintenanceSettings(maintenance), + }), + ...(queueControlPollInterval === undefined + ? {} + : { + queueControlPollIntervalMs: toMilliseconds( + "queueControlPollInterval", + queueControlPollInterval + ), + }), + ...(queueHeartbeatInterval === undefined + ? {} + : { + queueHeartbeatIntervalMs: toMilliseconds( + "queueHeartbeatInterval", + queueHeartbeatInterval + ), + }), + ...(queues === undefined + ? {} + : { + queues: Object.fromEntries( + Object.entries(queues).map(([name, config]) => [ + name, + toQueueSettings(config), + ]) + ), + }), + }; +} + +/** @internal Convert a public queue configuration. */ +export function toQueueSettings(config: QueueConfig): QueueSettings { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- validates untyped JavaScript input + if (config === null || typeof config !== "object") return config; + rejectMillisecondOptions("queue ", config); + const { fetchCooldown, pollInterval, ...rest } = config; + return { + ...rest, + ...(fetchCooldown === undefined + ? {} + : { + fetchCooldownMs: toMilliseconds("queue fetchCooldown", fetchCooldown), + }), + ...(pollInterval === undefined + ? {} + : { + pollIntervalMs: toMilliseconds("queue pollInterval", pollInterval), + }), + }; +} + +/** @internal Convert public stop options. */ +export function toStopSettings(options: StopOptions): StopSettings { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- validates untyped JavaScript input + if (options !== null && typeof options === "object") { + rejectMillisecondOptions("stop ", options); + rejectUnknownOptions("stop", options, STOP_OPTIONS); + } + const { timeout, ...rest } = options; + return { + ...rest, + ...(timeout === undefined + ? {} + : { timeoutMs: toMilliseconds("stop timeout", timeout) }), + }; +} + +/** + * Reject a millisecond option name such as `pollIntervalMs`. River's options + * take durations under the name without the suffix, so an old or guessed + * `*Ms` name would otherwise be silently ignored or bypass validation. + */ +function rejectMillisecondOptions(scope: string, options: object): void { + for (const key of Object.keys(options)) { + if (/[a-z]Ms$/.test(key)) { + throw new ValidationError( + `${scope}${key} is not an option; use ${scope}${key.slice(0, -2)} with a Temporal duration such as { seconds: 5 }` + ); + } + } +} + +/** + * Reject a key `known` doesn't have, like a misspelled `rescueAfter`, which + * would otherwise be silently ignored, as queue configuration does. + */ +function rejectUnknownOptions( + scope: string, + options: object, + known: Readonly> +): void { + for (const key of Object.keys(options)) { + if (!Object.hasOwn(known, key)) { + throw new ValidationError( + `${scope} has no option ${JSON.stringify(key)}`, + { details: { option: key } } + ); + } + } +} + +function toEventLoopDelaySettings( + options: EventLoopDelayOptions +): EventLoopDelaySettings { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- validates untyped JavaScript input + if (options !== null && typeof options === "object") { + rejectMillisecondOptions("eventLoopDelay.", options); + rejectUnknownOptions("eventLoopDelay", options, EVENT_LOOP_DELAY_OPTIONS); + } + return { + ...(options.reportInterval === undefined + ? {} + : { + reportIntervalMs: toMilliseconds( + "eventLoopDelay.reportInterval", + options.reportInterval + ), + }), + ...(options.resolution === undefined + ? {} + : { + resolutionMs: toMilliseconds( + "eventLoopDelay.resolution", + options.resolution + ), + }), + ...(options.warningThreshold === undefined + ? {} + : { + warningThresholdMs: toMilliseconds( + "eventLoopDelay.warningThreshold", + options.warningThreshold + ), + }), + }; +} + +const MAINTENANCE_DURATIONS = [ + ["cancelledJobRetention", "cancelledJobRetentionMs", true], + ["completedJobRetention", "completedJobRetentionMs", true], + ["discardedJobRetention", "discardedJobRetentionMs", true], + ["electionInterval", "electionIntervalMs", false], + ["jobCleanerInterval", "jobCleanerIntervalMs", false], + ["jobCleanerTimeout", "jobCleanerTimeoutMs", true], + ["notificationCleanerInterval", "notificationCleanerIntervalMs", false], + ["notificationRetention", "notificationRetentionMs", false], + ["queueCleanerInterval", "queueCleanerIntervalMs", false], + ["queueRetention", "queueRetentionMs", false], + ["reindexerTimeout", "reindexerTimeoutMs", true], + ["rescueAfter", "rescueAfterMs", false], + ["rescuerInterval", "rescuerIntervalMs", false], + ["schedulerInterval", "schedulerIntervalMs", false], +] as const; + +function toMaintenanceSettings( + options: MaintenanceOptions +): MaintenanceSettings { + // Validates untyped JavaScript input. + const value: unknown = options; + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new ValidationError("maintenance must be an object"); + } + rejectMillisecondOptions("maintenance.", options); + rejectUnknownOptions("maintenance", options, MAINTENANCE_OPTIONS); + const settings: Record = {}; + if (options.reindexerIndexNames !== undefined) { + settings.reindexerIndexNames = options.reindexerIndexNames; + } + if (options.reindexerSchedule !== undefined) { + settings.reindexerSchedule = options.reindexerSchedule; + } + for (const [name, setting, nullable] of MAINTENANCE_DURATIONS) { + const value: DurationInput | null | undefined = options[name]; + if (value === undefined) continue; + settings[setting] = + value === null && nullable + ? null + : toMilliseconds(`maintenance.${name}`, value as DurationInput); + } + return settings; +} diff --git a/js/src/periodic-job-store.ts b/js/src/periodic-job-store.ts new file mode 100644 index 000000000..40ed3d41f --- /dev/null +++ b/js/src/periodic-job-store.ts @@ -0,0 +1,36 @@ +import type { DurablePeriodicJob } from "./periodic.js"; + +/** A durable next-run time persisted with a periodic insertion batch. */ +export interface DurablePeriodicJobUpsert { + readonly id: string; + readonly nextRunAt: Temporal.Instant; + readonly updatedAt: Temporal.Instant; +} + +/** + * A pilot's durable storage for periodic job schedules, so the next run of + * a periodic job with an `id` survives leader changes and restarts. It + * mirrors River for Go's periodic-job pilot operations. + * + * The leader calls `getAll` when it starts enqueuing periodic jobs and seeds + * each job's next run from the matching record; calls `upsertMany` inside the + * same transaction that inserts each batch of periodic jobs; and calls + * `keepAliveAndReap` with the registered IDs every ten minutes so the store + * can delete records for jobs no client registers anymore. + */ +export interface PeriodicJobStore { + /** Return every durable periodic job record. */ + getAll(options: { + readonly signal: AbortSignal; + }): PromiseLike; + /** Refresh records for `ids` and delete records not refreshed recently. */ + keepAliveAndReap( + ids: readonly string[], + options: { readonly signal: AbortSignal } + ): PromiseLike; + /** Persist next-run times in the transaction inserting their jobs. */ + upsertMany( + tx: Transaction, + jobs: readonly DurablePeriodicJobUpsert[] + ): PromiseLike; +} diff --git a/js/src/periodic.ts b/js/src/periodic.ts new file mode 100644 index 000000000..cb0f77a87 --- /dev/null +++ b/js/src/periodic.ts @@ -0,0 +1,583 @@ +import type { InsertManyItem } from "./client.js"; +import { ConfigurationError, ValidationError } from "./errors.js"; +import { isUserSpecifiedIdOrKind } from "./identifiers.js"; +import type { InsertOptions } from "./insert-options.js"; +import type { JobDefinition, JobDefinitionInput } from "./job-definition.js"; +import { isJobDefinition } from "./job-definition.js"; +import type { JsonObject } from "./json.js"; +import { + durationNanoseconds, + toDuration, + type DurationInput, +} from "./internal/duration.js"; + +/** + * When a periodic job runs: `next(after)` returns the first occurrence + * strictly after `after`, or null to stop scheduling it. + * + * `cron()` builds one from a River Go-compatible cron expression; implement + * it directly for other calendar rules. + */ +export interface PeriodicSchedule { + next(after: Temporal.Instant): Temporal.Instant | null; +} + +/** One occurrence built by a periodic job's `construct` callback. */ +export interface PeriodicJobInsert { + readonly args: JobDefinitionInput; + readonly options?: InsertOptions; +} + +/** Static arguments, or a callback building each occurrence. */ +export type PeriodicJobArgs = + | { + /** Arguments inserted on every occurrence. */ + readonly args: JobDefinitionInput; + readonly construct?: never; + /** Insertion options for every occurrence. */ + readonly options?: InsertOptions; + } + | { + readonly args?: never; + /** + * Build each occurrence's args and options, or return null to skip it. + * A thrown error is logged and that occurrence is skipped. + */ + readonly construct: () => + | PeriodicJobInsert + | null + | PromiseLike | null>; + readonly options?: never; + }; + +/** A fixed interval or a custom schedule. */ +export type PeriodicJobTiming = + | { + /** + * Fixed interval between occurrences, such as `{ hours: 1 }`. Calendar + * units (years, months, weeks) are rejected; a day is 24 hours. + */ + readonly every: DurationInput; + readonly schedule?: never; + } + | { + readonly every?: never; + /** Custom schedule, such as one built by `cron()`. */ + readonly schedule: PeriodicSchedule; + }; + +/** Options for {@link periodicJob}. */ +export type PeriodicJobOptions = { + /** + * Stable ID, unique within a client. It is recorded in the inserted job's + * `river:periodic_job_id` metadata and lets a durable schedule store (an + * extension) remember the next run across leader changes. + */ + readonly id?: string; + /** Job definition inserted on each occurrence. */ + readonly job: Definition; + /** Also insert once each time this client becomes leader. */ + readonly runOnStart?: boolean; +} & PeriodicJobArgs & + PeriodicJobTiming; + +declare const periodicJobBrand: unique symbol; + +/** An immutable periodic job created by {@link periodicJob}. */ +export interface PeriodicJob { + /** Stable ID, or null when none was configured. */ + readonly id: string | null; + /** Job definition inserted on each occurrence. */ + readonly job: Definition; + /** Whether an occurrence is also inserted when leadership starts. */ + readonly runOnStart: boolean; + /** When occurrences are due. */ + readonly schedule: PeriodicSchedule; + /** Type-only brand; periodic jobs come from {@link periodicJob}. */ + readonly [periodicJobBrand]?: true; +} + +/** + * Define a job that the elected leader inserts on a schedule. + * + * Periodic jobs run only on the client that currently holds River + * leadership. In a fleet mixing River implementations (Go, Rust, and + * JavaScript), register the same periodic jobs in every implementation, or + * leadership moving between languages silently changes which periodic jobs + * run. + * + * @example + * ```ts + * const hourlyReport = periodicJob({ + * args: { scope: "all" }, + * every: { hours: 1 }, + * id: "hourly_report", + * job: buildReport, + * runOnStart: true, + * }); + * const client = new Client(driver, { periodicJobs: [hourlyReport], workers }); + * ``` + */ +export function periodicJob( + options: PeriodicJobOptions +): PeriodicJob { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- validates untyped JavaScript input + if (options === null || typeof options !== "object") { + throw new ConfigurationError("periodic job options must be an object"); + } + if (!isJobDefinition(options.job)) { + throw new ConfigurationError( + "periodic job requires a job definition created by defineJob" + ); + } + if (options.id !== undefined) validatePeriodicJobId(options.id); + if ( + options.runOnStart !== undefined && + typeof options.runOnStart !== "boolean" + ) { + throw new ConfigurationError("periodic job runOnStart must be a boolean"); + } + + const schedule = resolveSchedule(options); + const construct = resolveConstruct(options); + const job: PeriodicJob = Object.freeze({ + id: options.id ?? null, + job: options.job, + runOnStart: options.runOnStart ?? false, + schedule, + }); + periodicConstructors.set(job, construct); + return job; +} + +/** Opaque removal handle returned by {@link PeriodicJobs.add}. */ +export interface PeriodicJobHandle { + readonly "~periodicJobHandle": number; +} + +/** A durable periodic job record reported by a periodic job store. */ +export interface DurablePeriodicJob { + readonly createdAt: Temporal.Instant; + readonly id: string; + readonly nextRunAt: Temporal.Instant; + readonly updatedAt: Temporal.Instant; +} + +/** Parameters for an `onPeriodicJobsStart` hook. */ +export interface PeriodicJobsStartParams { + /** + * Durable periodic job records found by a configured periodic job store, + * including records for jobs that were removed but not yet reaped. Empty + * unless an extension provides a store. + */ + readonly durableJobs: readonly DurablePeriodicJob[]; + /** The client's periodic job registry, which the hook may modify. */ + readonly periodicJobs: PeriodicJobs; +} + +interface PeriodicEntry { + readonly handle: number; + initialized: boolean; + readonly job: PeriodicJob; + nextRun: Temporal.Instant | null; +} + +/** @internal One occurrence due for insertion. */ +export interface DuePeriodicOccurrence { + readonly job: PeriodicJob; + readonly scheduledAt: Temporal.Instant; +} + +/** @internal Result of advancing the registry to `now`. */ +export interface DuePeriodicBatch { + /** Durable next-run times to persist with this batch's insertions. */ + readonly durableUpdates: readonly { + readonly id: string; + readonly nextRunAt: Temporal.Instant; + }[]; + readonly occurrences: readonly DuePeriodicOccurrence[]; +} + +interface RegistryInternals { + readonly entries: Map; + disable(): void; + setChangeHandler(change: (() => void) | undefined): void; +} + +/** Go River's margin for inserting occurrences due in the very near future. */ +const DUE_MARGIN = { milliseconds: 100 } as const; + +/** + * @internal Insert options whose `scheduledAt` is a periodic occurrence time + * rather than a caller's schedule. Like Go River's periodic enqueuer, which + * sets the occurrence time only after the insert state is resolved, such a + * job is inserted `available` even when the occurrence is due within + * {@link DUE_MARGIN} of now, instead of `scheduled` until the scheduler runs. + */ +export const periodicOccurrenceOptions = new WeakSet(); + +const periodicConstructors = new WeakMap< + PeriodicJob, + () => PromiseLike | null> +>(); + +let registryInternals: (registry: PeriodicJobs) => RegistryInternals; + +/** + * A client's mutable registry of periodic jobs, available as + * `client.periodicJobs`. Jobs may be added and removed while the client runs; + * the leader picks up changes immediately. + * + * Only the elected leader inserts periodic jobs, so a change takes full effect + * only when it's made on every client in the fleet that may lead. The registry + * of a client configured with `leaderElectionDisabled: true` can't be + * modified, because that client never leads. + */ +export class PeriodicJobs { + #change: (() => void) | undefined; + #disabled = false; + readonly #entries = new Map(); + #nextHandle = 1; + + static { + registryInternals = (registry) => ({ + disable: () => { + registry.#disabled = true; + }, + entries: registry.#entries, + setChangeHandler: (change) => { + registry.#change = change; + }, + }); + } + + constructor(jobs: readonly PeriodicJob[] = []) { + this.addMany(jobs); + } + + /** Number of registered periodic jobs. */ + get size(): number { + return this.#entries.size; + } + + /** Register a periodic job and return a handle for removing it. */ + add(job: PeriodicJob): PeriodicJobHandle { + const [handle] = this.addMany([job]); + if (handle === undefined) throw new Error("periodic job was not added"); + return handle; + } + + /** Register several periodic jobs at once. */ + addMany(jobs: readonly PeriodicJob[]): readonly PeriodicJobHandle[] { + this.#requireModifiable(); + this.#validate(jobs); + const handles = jobs.map((job) => { + const value = this.#nextHandle++; + this.#entries.set(value, { + handle: value, + initialized: false, + job, + nextRun: null, + }); + return Object.freeze({ "~periodicJobHandle": value }); + }); + if (handles.length > 0) this.#change?.(); + return Object.freeze(handles); + } + + /** Remove every periodic job. */ + clear(): void { + this.#requireModifiable(); + this.#entries.clear(); + this.#change?.(); + } + + /** Remove the job registered with `handle`; false when already removed. */ + remove(handle: PeriodicJobHandle): boolean { + this.#requireModifiable(); + const removed = this.#entries.delete(handle["~periodicJobHandle"]); + if (removed) this.#change?.(); + return removed; + } + + /** Remove the job registered with `id`; false when none matches. */ + removeById(id: string): boolean { + this.#requireModifiable(); + for (const [handle, entry] of this.#entries) { + if (entry.job.id === id) { + this.#entries.delete(handle); + this.#change?.(); + return true; + } + } + return false; + } + + #requireModifiable(): void { + if (this.#disabled) { + throw new ConfigurationError( + "cannot modify periodic jobs when leaderElectionDisabled is true, because this client never leads" + ); + } + } + + #validate(jobs: readonly PeriodicJob[]): void { + const ids = new Set( + [...this.#entries.values()] + .map(({ job }) => job.id) + .filter((id): id is string => id !== null) + ); + for (const job of jobs) { + if (!periodicConstructors.has(job)) { + throw new ValidationError( + "periodic jobs must be created with periodicJob()" + ); + } + if (job.id !== null) { + if (ids.has(job.id)) { + throw new ValidationError(`duplicate periodic job id ${job.id}`); + } + ids.add(job.id); + } + } + } +} + +/** @internal Reject changes to the registry of a client that never leads. */ +export function disablePeriodicJobs(registry: PeriodicJobs): void { + registryInternals(registry).disable(); +} + +/** @internal Wake the leader's enqueuer when registry membership changes. */ +export function setPeriodicJobsChangeHandler( + registry: PeriodicJobs, + change: (() => void) | undefined +): void { + registryInternals(registry).setChangeHandler(change); +} + +/** @internal Forget all scheduling state when leadership starts or ends. */ +export function resetPeriodicJobs(registry: PeriodicJobs): void { + for (const entry of registryInternals(registry).entries.values()) { + entry.initialized = false; + entry.nextRun = null; + } +} + +/** @internal IDs of registered periodic jobs, for durable keep-alive. */ +export function periodicJobIds(registry: PeriodicJobs): readonly string[] { + return [...registryInternals(registry).entries.values()].flatMap(({ job }) => + job.id === null ? [] : [job.id] + ); +} + +/** @internal Earliest scheduled occurrence, or null when none is scheduled. */ +export function nextPeriodicRunAt( + registry: PeriodicJobs +): Temporal.Instant | null { + let earliest: Temporal.Instant | null = null; + for (const { nextRun } of registryInternals(registry).entries.values()) { + if ( + nextRun !== null && + (earliest === null || Temporal.Instant.compare(nextRun, earliest) < 0) + ) { + earliest = nextRun; + } + } + return earliest; +} + +/** @internal Whether a newly registered job still needs initialization. */ +export function hasUninitializedPeriodicJobs(registry: PeriodicJobs): boolean { + for (const entry of registryInternals(registry).entries.values()) { + if (!entry.initialized) return true; + } + return false; +} + +/** + * @internal Advance every registered job to `now`, like Go River's periodic + * job enqueuer: newly registered jobs get their first run (seeded from + * `durableNextRuns` when their ID has one) and a run-on-start occurrence; + * jobs due within a small margin produce one occurrence and advance from + * their scheduled time. Occurrences advance whether or not their insertion + * later succeeds. + */ +export function advancePeriodicJobs( + registry: PeriodicJobs, + now: Temporal.Instant, + durableNextRuns: Map, + onScheduleError: (job: PeriodicJob, error: unknown) => void +): DuePeriodicBatch { + const occurrences: DuePeriodicOccurrence[] = []; + const durableUpdates: { id: string; nextRunAt: Temporal.Instant }[] = []; + const dueBefore = now.add(DUE_MARGIN); + const entries = [...registryInternals(registry).entries.values()].sort( + (left, right) => left.handle - right.handle + ); + for (const entry of entries) { + const id = entry.job.id; + if (!entry.initialized) { + entry.initialized = true; + const seeded = id === null ? undefined : durableNextRuns.get(id); + if (id !== null) durableNextRuns.delete(id); + entry.nextRun = + seeded ?? safeNextOccurrence(entry.job, now, onScheduleError); + if (id !== null && entry.nextRun !== null) { + durableUpdates.push({ id, nextRunAt: entry.nextRun }); + } + if (entry.job.runOnStart) { + occurrences.push({ job: entry.job, scheduledAt: now }); + } + continue; + } + if ( + entry.nextRun === null || + Temporal.Instant.compare(entry.nextRun, dueBefore) >= 0 + ) { + continue; + } + occurrences.push({ job: entry.job, scheduledAt: entry.nextRun }); + entry.nextRun = safeNextOccurrence( + entry.job, + entry.nextRun, + onScheduleError + ); + if (id !== null && entry.nextRun !== null) { + durableUpdates.push({ id, nextRunAt: entry.nextRun }); + } + } + return { durableUpdates, occurrences }; +} + +/** + * @internal Build the insertion for one occurrence, or null when its + * constructor skipped it. Constructor errors propagate to the caller, which + * logs them. + */ +export async function buildPeriodicInsert( + occurrence: DuePeriodicOccurrence +): Promise { + const construct = periodicConstructors.get(occurrence.job); + if (construct === undefined) throw new Error("unknown periodic job"); + const insert = await construct(); + if (insert === null) return null; + const options = insert.options ?? {}; + const metadata: JsonObject = { + ...(options.metadata ?? {}), + periodic: true, + ...(occurrence.job.id === null + ? {} + : { "river:periodic_job_id": occurrence.job.id }), + }; + const scheduledByOccurrence = + options.scheduledAt === undefined && options.delay === undefined; + const periodicOptions: InsertOptions = { + ...options, + metadata, + ...(scheduledByOccurrence ? { scheduledAt: occurrence.scheduledAt } : {}), + }; + if (scheduledByOccurrence) periodicOccurrenceOptions.add(periodicOptions); + return { + args: insert.args as JsonObject, + job: occurrence.job.job as JobDefinition, + options: periodicOptions, + }; +} + +function resolveSchedule(timing: PeriodicJobTiming): PeriodicSchedule { + const hasEvery = timing.every !== undefined; + const hasSchedule = timing.schedule !== undefined; + if (hasEvery === hasSchedule) { + throw new ConfigurationError( + "periodic job requires exactly one of every or schedule" + ); + } + if (timing.schedule !== undefined) { + const schedule = timing.schedule; + if (typeof schedule.next !== "function") { + throw new ConfigurationError("periodic schedule must implement next()"); + } + return schedule; + } + return everySchedule(timing.every); +} + +function resolveConstruct( + options: PeriodicJobArgs +): () => PromiseLike | null> { + const hasArgs = options.args !== undefined; + const hasConstruct = options.construct !== undefined; + if (hasArgs === hasConstruct) { + throw new ConfigurationError( + "periodic job requires exactly one of args or construct" + ); + } + if (options.construct !== undefined) { + const construct = options.construct; + if (typeof construct !== "function") { + throw new ConfigurationError("periodic job construct must be a function"); + } + return async () => await construct(); + } + const insert = Object.freeze({ + args: options.args, + ...(options.options === undefined ? {} : { options: options.options }), + }) as PeriodicJobInsert; + return () => Promise.resolve(insert); +} + +function everySchedule(interval: DurationInput): PeriodicSchedule { + const nanoseconds = durationNanoseconds( + toDuration("periodic job every", interval) + ); + return Object.freeze({ + next: (after: Temporal.Instant) => + Temporal.Instant.fromEpochNanoseconds( + after.epochNanoseconds + nanoseconds + ), + }); +} + +/** A schedule that throws stops scheduling its job instead of spinning. */ +function safeNextOccurrence( + job: PeriodicJob, + after: Temporal.Instant, + onScheduleError: (job: PeriodicJob, error: unknown) => void +): Temporal.Instant | null { + try { + return nextOccurrence(job.schedule, after); + } catch (error: unknown) { + onScheduleError(job, error); + return null; + } +} + +function nextOccurrence( + schedule: PeriodicSchedule, + after: Temporal.Instant +): Temporal.Instant | null { + const next = schedule.next(after); + if (next === null) return null; + if ( + !(next instanceof Temporal.Instant) || + Temporal.Instant.compare(next, after) <= 0 + ) { + throw new ValidationError( + "periodic schedule next() must return null or an instant after its input" + ); + } + return next; +} + +function validatePeriodicJobId(id: string): void { + if ( + typeof id !== "string" || + id.length >= 128 || + !isUserSpecifiedIdOrKind(id) + ) { + throw new ConfigurationError( + "periodic job id must be 2 to 127 characters, start with a letter, number, or underscore, and contain only letters, numbers, and _-[]<>/.·:+" + ); + } +} diff --git a/js/src/pilot-client.ts b/js/src/pilot-client.ts new file mode 100644 index 000000000..270dc6036 --- /dev/null +++ b/js/src/pilot-client.ts @@ -0,0 +1,83 @@ +/** + * The client base class of first-party companion packages, exported only + * from `riverqueue/unstable-driver`. + */ +import type { ClientDriver } from "./driver.js"; +import type { Client } from "./client.js"; +import type { ClientOptions } from "./options.js"; +import { RiverClient } from "./client.js"; +import { createPilotBinding } from "./internal/driver-registry.js"; +import type { QueueConfig } from "./options.js"; +import type { PilotFactory } from "./pilot.js"; + +/** + * Options of a {@link PilotClient}: a client's options, with queues that + * may use the keys its pilot owns. + */ +export type PilotClientOptions< + Transaction, + Config extends QueueConfig = QueueConfig, +> = Omit, "queues"> & { + /** Queues this client works, keyed by name. */ + readonly queues?: Readonly>; +}; +import type { RunHandle } from "./runtime.js"; + +/** + * A River client with a pilot attached. A companion package's client + * extends {@link PilotClient}, and its public declarations name only an + * interface extending `Client`, so applications never see the pilot. + * + * `Config` is the queue configuration the run handle accepts, including the + * keys the pilot owns. + * + * Because an intercepted operation runs statements on a caller's + * transaction across its interceptor's awaits, this client's operations and + * its pilot's statements on one caller transaction run one at a time, in + * arrival order, like node-postgres runs a client's queries. One started + * from inside another, such as from an interceptor, runs inside it. + */ +export interface PilotClient< + Transaction = unknown, + Config extends QueueConfig = QueueConfig, +> extends Client { + start(): Promise>; +} + +/** + * Constructor of {@link PilotClient}. It is abstract: only a subclass can + * call it, passing the factory of the client's pilot. + * + * River calls `createPilot` once, synchronously, with the driver's + * database, then the pilot's `init`, before the constructor returns, so a + * subclass's own fields aren't assigned yet when they run. The driver must + * be a first-party driver that supports pilots: `PgDriver` constructed with + * a `Pool`, or `SqliteDriver`. + */ +export type PilotClientConstructor = abstract new < + Transaction, + Config extends QueueConfig = QueueConfig, +>( + driver: ClientDriver, + options: PilotClientOptions, + createPilot: PilotFactory +) => PilotClient; + +abstract class PilotClientImplementation< + Transaction, +> extends RiverClient { + protected constructor( + driver: ClientDriver, + options: PilotClientOptions, + createPilot: PilotFactory + ) { + if (new.target === PilotClientImplementation) { + throw new TypeError("PilotClient is abstract; extend it"); + } + super(driver, options, createPilotBinding(createPilot)); + } +} + +/** The base class of a client with a pilot; see {@link PilotClient}. */ +export const PilotClient: PilotClientConstructor = + PilotClientImplementation as unknown as PilotClientConstructor; diff --git a/js/src/pilot.ts b/js/src/pilot.ts new file mode 100644 index 000000000..97d7af967 --- /dev/null +++ b/js/src/pilot.ts @@ -0,0 +1,713 @@ +/** + * Exact-version SPI through which a first-party companion package takes part + * in the operations River owns. Only `riverqueue/unstable-driver` exports it; + * applications never configure a pilot. + */ +import type { Client } from "./client.js"; +import type { + DriverCapability, + DriverInsertResult, + InsertDriver, + JobClaimResult, + JobCompletionCommand, + JobCompletionResult, + JobInsertParams, + LeaderTerm, + QueueRow, + RuntimeJobRescue, +} from "./driver.js"; +import type { WorkAttemptResult } from "./extensions.js"; +import type { JobRow } from "./job.js"; +import type { Logger } from "./logger.js"; +import type { PeriodicJobStore } from "./periodic-job-store.js"; +import type { WorkAttemptContext, WorkContext } from "./worker.js"; + +/** + * Filters for one batch of {@link PilotDatabase.deleteFinalizedJobs}, the + * same ones River's job cleaner uses. A job is deleted when it's in a state + * whose cutoff is set and was finalized before that cutoff. + */ +export interface FinalizedJobDeleteParams { + /** Delete cancelled jobs finalized before this, or none when `null`. */ + readonly cancelledBefore: Temporal.Instant | null; + /** Delete completed jobs finalized before this, or none when `null`. */ + readonly completedBefore: Temporal.Instant | null; + /** Delete discarded jobs finalized before this, or none when `null`. */ + readonly discardedBefore: Temporal.Instant | null; + /** The most jobs the batch deletes, taking the lowest IDs first. */ + readonly limit: number; + /** Queues whose jobs are kept, even when `queuesIncluded` lists them. */ + readonly queuesExcluded?: readonly string[]; + /** + * Queues the batch is limited to. Absent or `null` matches every queue, + * while an empty list matches none. + */ + readonly queuesIncluded?: readonly string[] | null; +} + +/** + * A driver's database, as River hands it to a pilot: native connections and + * transactions on the connection River uses, plus the few River statements a + * companion needs inside its own transactions. + * + * `Transaction` is the driver's native handle: a node-postgres client, or a + * `node:sqlite` `DatabaseSync`. River's own transactions reach a pilot as + * native handles too, never as opaque values. Callbacks must not keep or + * close a handle, and must not begin, commit, or roll back transactions on + * it themselves. River can't detect a handle kept past its callback: a + * statement run on it later joins whatever that connection then has open. + */ +export interface PilotDatabase { + /** The backend's name, such as `"postgres"` or `"sqlite"`. */ + readonly backend: string; + /** The schema holding River's tables, or null for the default. */ + readonly schema: string | null; + + /** + * Run `callback` with a native handle River has borrowed for it, which is + * not in a transaction: a pooled client on PostgreSQL, or River's own + * connection on SQLite while River's lock on it is held. Use it for reads + * and single autocommit statements. `signal` stops only the wait for the + * handle. A transaction the callback leaves open is rolled back, and the + * call rejects with a `TransactionScopeError`. + */ + connection( + callback: (handle: Transaction) => PromiseLike | Result, + options?: { readonly signal?: AbortSignal } + ): Promise; + + /** + * Run `callback` in a new transaction, committed once it resolves and + * rolled back when it rejects, or directly in a supplied `tx`. Like River + * for Go, River opens no savepoint in `tx` and never commits or rolls it + * back: when `callback` rejects, its writes stay in `tx` until the + * transaction's owner rolls it back. + * + * `signal` stops the wait to begin, and when it has aborted by the time + * `callback` resolves the work is rolled back instead of committed, or, + * in a supplied `tx`, the call rejects. + * `callback` runs at most once: it never runs when River can't begin, and + * is never run again. On SQLite, River retries beginning while another + * connection holds the write lock; on PostgreSQL, a failure to lease a + * connection or begin rejects at once. A failed commit rejects without + * claiming whether the database kept the changes. + * + * On SQLite, River's transaction holds the database's write lock, so + * `callback` may await only promises (other River work and statements), + * not I/O or timers, as for insert middleware. + */ + transaction( + callback: (tx: Transaction) => PromiseLike | Result, + options?: { + readonly signal?: AbortSignal; + readonly tx?: Transaction; + } + ): Promise; + + /** + * Delete one batch of finalized jobs with River's job cleaner statement, + * resolving with how many it deleted, so an extension's own cleaner + * passes, such as per-queue retention, delete exactly what River's + * would. Like River for Go's driver `JobDeleteBefore`, the queue filters + * apply before the limit, so jobs they keep never use up a batch. It + * needs no leadership term. + * + * With `tx`, it runs in that transaction, otherwise in a transaction of + * its own. It has no timeout or cancellation; to bound the wait for a + * connection, run it in {@link PilotDatabase.transaction} with a signal. + */ + deleteFinalizedJobs( + params: FinalizedJobDeleteParams, + options?: { readonly tx?: Transaction } + ): Promise; + + /** + * Read claimed jobs in `tx`, decoding them as River's own claim does: a + * row that can't be fully decoded is returned with its error in + * `decodeErrors`. Rejects when an ID is repeated or has no row. + */ + loadClaimed( + ids: readonly bigint[], + options: { readonly tx: Transaction } + ): Promise; + + /** + * Send River notifications on `topic` in `tx`. They reach listeners only + * once `tx` commits, and never if it rolls back. + */ + notify( + topic: "control" | "insert", + payloads: readonly string[], + options: { readonly tx: Transaction } + ): Promise; +} + +/** What every interceptor that runs in a transaction receives. */ +export interface PilotTransactionContext { + readonly database: PilotDatabase; + /** Aborts when River abandons the operation. */ + readonly signal: AbortSignal; + /** + * The transaction the operation runs in: River's own, or the caller's. + * `next` runs River's standard operation in it too. Its owner commits or + * rolls it back, never the interceptor, which keeps the operation's + * related writes in it. + */ + readonly tx: Transaction; +} + +/** An intercepted insertion. */ +export interface PilotInsertContext< + Transaction, +> extends PilotTransactionContext { + readonly operation: "insert" | "insertMany"; + /** + * Each prepared row's arguments as JSON text from before the client's + * argument transforms rewrote them, in the order of `params`. A + * transform, such as one that encrypts arguments, runs before the + * interceptor, so `params` carries its output, which is what River + * stores. An interceptor that derives values from the arguments, such as + * keys or routing, reads them here instead. A row no transform changed + * has its `encodedArgs` here, and a row from + * {@link PilotHost.insertPrepared} has the `encodedArgs` it was given. + */ + readonly originalEncodedArgs: readonly string[]; + /** Prepared rows, in order. */ + readonly params: readonly JobInsertParams[]; +} + +/** Rows that replace an insertion's prepared rows, one for one. */ +export interface PilotInsertReplacement { + /** + * The replacement rows. Each row's `encodedArgs` is what River stores and + * returns, whatever its `args`. Insert hooks and middleware still see the + * requests as prepared before the replacement. + */ + readonly params: readonly JobInsertParams[]; +} + +/** An intercepted completion batch. */ +export interface PilotCompleteContext< + Transaction, +> extends PilotTransactionContext { + readonly commands: readonly JobCompletionCommand[]; +} + +/** An intercepted cancellation or retry of one job. */ +export interface PilotJobContext< + Transaction, +> extends PilotTransactionContext { + readonly id: bigint; +} + +/** + * The rescuer's read of one page of stuck jobs. It runs in no transaction; + * River's standard read fences it by `leader` itself. + */ +export interface PilotStuckContext { + readonly afterId: bigint; + readonly attemptedBefore: Temporal.Instant; + readonly database: PilotDatabase; + readonly leader: LeaderTerm; + readonly limit: number; + /** Aborts when the read's timeout elapses or the leadership term ends. */ + readonly signal: AbortSignal; + /** + * The read's timeout in milliseconds, or `null` for none. River's standard + * read also sets it as the statement's timeout on PostgreSQL; a read the + * pilot runs itself should do the same. + */ + readonly timeoutMs: number | null; +} + +/** The rescuer's update of one page of stuck jobs. */ +export interface PilotRescueContext< + Transaction, +> extends PilotTransactionContext { + readonly attemptedBefore: Temporal.Instant; + readonly jobs: readonly RuntimeJobRescue[]; + readonly leader: LeaderTerm; +} + +/** + * Operations a pilot wraps, Koa-style, around River's standard operation, + * which `next` runs. `next` is bound to the context's transaction. + * + * `insert`, `complete`, `cancel`, and `retry` must call `next` exactly once + * and resolve with exactly what it resolved with; they add effects in the + * same transaction. `insert` alone may pass replacement rows to `next`, one + * for each prepared row, in order. `getStuck` and `rescue` may instead + * replace River's operation: they call `next` at most once, and resolve with + * its result when they do. + * + * River awaits `next` before settling the operation, even when the + * interceptor doesn't. A second call, or one after the interceptor settled, + * rejects. Any violation, and any rejection, fails the operation and rolls + * its transaction back with an `ExtensionError`. River freezes result + * lists and checks the rows' identities, but doesn't copy rows: an + * interceptor must not change their contents, which callers see. + */ +export interface PilotInterceptors { + cancel?( + context: PilotJobContext, + next: () => Promise + ): Promise; + complete?( + context: PilotCompleteContext, + next: () => Promise + ): Promise; + getStuck?( + context: PilotStuckContext, + next: () => Promise + ): Promise; + insert?( + context: PilotInsertContext, + next: ( + replacement?: PilotInsertReplacement + ) => Promise + ): Promise; + rescue?( + context: PilotRescueContext, + next: () => Promise + ): Promise; + retry?( + context: PilotJobContext, + next: () => Promise + ): Promise; +} + +/** + * Queue configuration keys a pilot owns. River rejects queue keys that + * neither it nor the pilot owns. + */ +export interface PilotQueueOptions { + /** The owned keys. They must not be River's own queue keys. */ + readonly keys: readonly string[]; + /** + * Validate one queue's owned keys, those present in `config`, and return + * its settings. It runs synchronously for every configured queue, and for + * every `addQueue` and `updateQueue`, before River changes anything. Throw + * a `ValidationError` to reject the configuration. + */ + parse(queue: string, config: Readonly>): Settings; +} + +/** + * The one companion attached to a client. Every member is optional. `init` + * runs with the pilot as `this`, and interceptors with `intercept`. + */ +export interface Pilot< + Transaction, + QueueSettings = unknown, + ClientType extends Client = Client, +> { + /** + * Called once, synchronously, while the client is constructed. It must + * not perform I/O or call the client: `host.client` is usable only once + * construction returns. + */ + init?(host: PilotHost): void; + readonly intercept?: PilotInterceptors; + readonly queueOptions?: PilotQueueOptions; + + /** + * Start the producer session of one queue generation, once River has + * persisted the queue and before its first claim. River owns the + * session's lifetime: it claims through it, reports configuration + * changes and finished jobs to it, keeps it alive, and shuts it down once + * the queue drained. A rejection fails the queue's start, and the pilot + * must first release whatever it allocated. + */ + startProducer?( + context: ProducerStartContext + ): Promise>; + + /** + * The most background completion batches this client persists at once. + * It counts local batches, not database connections, and never limits a + * worker's transactional completion. A batch waiting its turn doesn't + * spend its timeout or retries, and a stop still persists every pending + * completion. Default: no limit. + */ + readonly completionConcurrency?: number; + /** Queues River's job cleaner leaves alone, for the pilot to clean. */ + readonly jobCleanerQueuesExcluded?: readonly string[]; + /** + * Durable storage for periodic job schedules, used instead of a periodic + * job store plugin. River runs `upsertMany` in the transaction inserting + * the periodic jobs, and passes it a native handle, as it does to + * interceptors. + */ + readonly periodicJobs?: PeriodicJobStore; + + /** + * Services River runs for as long as the runtime runs, listed once per + * run and started before any queue claims. See {@link PilotService}. + * Services run in the background, so a service can't fail the client's + * `start()`; one that keeps failing is logged and restarted instead. + */ + services?(): readonly PilotService[]; + + /** + * Services River runs while this client leads maintenance, once per + * leadership term, with the term and a signal that aborts when the term + * ends. A new term's services start only once the previous term's + * settled. Listed once per run. + */ + maintenanceServices?(): readonly PilotService[]; +} + +/** + * A background service River supervises. `run` should resolve only once + * `signal` aborted. River restarts a run that rejects, or resolves before + * then, after capped exponential backoff with jitter, which resets after a + * long healthy run; it waits for a run to settle before starting another, + * and stops restarting once `signal` aborts. + */ +export interface PilotService { + /** Names the service in River's logs. */ + readonly name: string; + run(context: { + readonly signal: AbortSignal; + readonly term: Term; + }): Promise; +} + +/** One queue generation's configuration, replaced as a whole. */ +export interface ProducerConfiguration { + /** The most jobs of the queue this client works at once. */ + readonly maxWorkers: number; + /** + * The queue's metadata as its database stores it, such as `{"retries": + * 1.0}`, for a pilot that decodes it more strictly than River's parsed + * `queue.metadata`, whose number literals `1.0` and `1e2` read as plain + * numbers. + */ + readonly metadataText: string; + /** The persisted queue, including its metadata. */ + readonly queue: QueueRow; + /** The pilot's own settings, parsed by its `queueOptions`. */ + readonly settings: Settings; +} + +/** What {@link Pilot.startProducer} receives. */ +export interface ProducerStartContext< + Transaction, + Settings = unknown, +> extends ProducerConfiguration { + readonly clientId: string; + readonly database: PilotDatabase; + /** Aborts when the runtime stops while the producer starts. */ + readonly signal: AbortSignal; +} + +/** + * River's standard claim, which a producer session's claim may call once + * in its transaction `tx`. + */ +export type ProducerClaimNext = (options: { + readonly tx: Transaction; +}) => Promise; + +/** What {@link PilotProducer.keepAlive} receives. */ +export interface ProducerKeepAliveContext { + /** Aborts when the report times out, after 10 s, or reports stop. */ + readonly signal: AbortSignal; + /** Sessions that haven't reported since this time are stale. */ + readonly staleBefore: Temporal.Instant; +} + +/** What {@link PilotProducer.shutdown} receives. */ +export interface ProducerShutdownContext { + /** Aborts at the attempt's deadline. */ + readonly signal: AbortSignal; +} + +/** One claim of a producer session. */ +export interface ProducerClaimContext { + /** The client ID claimed jobs must record as their attempt's owner. */ + readonly attemptedBy: string; + readonly database: PilotDatabase; + /** + * The kinds the claim may return, sorted, or empty for every kind. A + * client with `fetchOnlyKnownKinds` passes the kinds it has workers for, + * like River for Go's `JobGetAvailableParams.Kind`. + */ + readonly kinds: readonly string[]; + /** The most jobs the claim may return. */ + readonly limit: number; + readonly queue: string; + /** + * Aborts once River stops claiming the queue. It ends retries and + * backoff; a claim that already committed must still be returned. + */ + readonly retrySignal: AbortSignal; + /** Aborts when River abandons the claim's work entirely. */ + readonly signal: AbortSignal; +} + +/** + * A pilot's producer session for one queue generation. Every member is + * optional; River calls them with the session as `this`. + * + * At most one claim and one keep-alive run at a time, and they may overlap + * each other; `jobFinished` may run during either. Configuration changes + * run between claims. Once River starts draining the queue it starts no + * new claim or configuration change, and after `shutdown` settles it calls + * nothing more. + * + * River waits for every call it starts to settle, so a `keepAlive` or + * `shutdown` that ignores its aborted signal and never settles stalls the + * client's `stop()`. + */ +export interface PilotProducer { + /** + * Claim jobs for the queue. `next({ tx })` runs River's standard claim in + * the pilot's transaction `tx`. The claim may instead select jobs itself, + * reading them with `database.loadClaimed`, and calls `next` at most + * once; when it does, it resolves with `next`'s result. + * + * Resolve only with rows whose claim committed, and record them before + * resolving. River checks them before working any: each must be running, + * in this queue, owned by `attemptedBy`, on an attempt of at least 1, + * listed once, not already worked here, and no more than `limit`. A + * claim that breaks those rules stops the runtime; its rows are left to + * the rescuer. A rejection is retried after backoff like River's own + * claim failures, so the pilot must undo reservations of a claim that + * didn't commit. + */ + claim?( + context: ProducerClaimContext, + next: ProducerClaimNext + ): Promise; + + /** + * Validate and adopt a new configuration, synchronously and without I/O. + * Throw to reject it: River keeps the previous configuration, and rejects + * an `updateQueue` that asked for it or logs a persisted change. + */ + configurationChanged?(configuration: ProducerConfiguration): void; + + /** + * One claimed job's attempt ended and its outcome went to River's + * completer, which may not have persisted it yet. `job` is the row as + * claimed. Called exactly once for each job the session claimed and + * River accepted, including jobs River couldn't decode or work. + */ + jobFinished?(job: JobRow): void; + + /** + * Report the session as alive, after an initial jitter and then at the + * client's producer report interval, including while the queue drains. + * Reports keep a fixed rate and never overlap: a slow report delays the + * next one. A rejection is logged and the next report runs on schedule. + */ + keepAlive?(context: ProducerKeepAliveContext): Promise; + + /** + * Release the session once its queue drained and its reports stopped. + * River tries up to four times, one after another, aborting `signal` + * after 100 ms, 500 ms, 2.5 s, and 12.5 s, and logs the failure when all + * four fail. + */ + shutdown?(context: ProducerShutdownContext): Promise; +} + +/** What a peer claim's callback receives. */ +export interface PeerClaimContext { + /** + * The claiming attempt's signal. It aborts when the attempt is cancelled, + * by a hard stop, its job's cancellation, or its timeout, and once the + * attempt finished; River then rolls the claim back if it hasn't + * committed. A graceful stop doesn't abort it: a coordinator still + * running keeps claiming, and the stop waits for the peers it claims. + */ + readonly signal: AbortSignal; + /** The transaction the claim commits in, once the callback resolves. */ + readonly tx: Transaction; +} + +/** The outcome of one peer, for {@link PilotAttempts.complete}. */ +export interface PeerOutcome { + /** + * The peer, as {@link PilotAttempts.claim} returned it. Its ID, attempt, + * and attempting client identify it; River persists the row it tracks. + */ + readonly job: JobRow; + readonly result: WorkAttemptResult; +} + +/** + * Peer attempts: jobs a running attempt of this client, their coordinator, + * works together with its own job, such as a group of related jobs handled + * in one go. River tracks each peer under its coordinator from the claim's + * commit until its outcome persists. Peers don't take the queue's worker + * slots, and their producer session never hears of them. River doesn't + * cancel a peer remotely; cancelling the coordinator's job reaches peers + * only through the coordinator's signal. + * + * `attempt` is the coordinator's context, as its handler or middleware + * received it. Both methods reject once the coordinator's attempt ended, + * including while its own outcome persists, and a claim also rejects once + * the attempt is cancelled. A graceful stop ends neither: a running + * coordinator keeps claiming and completing peers, and the stop resolves + * only after they have outcomes. When the attempt ends, River waits for + * the calls it accepted, then completes every peer still without an + * outcome: it interrupts them only when the runtime stopped or cancelled + * its work (the attempt's abort reason is a `LifecycleError`), and + * otherwise, including after the coordinator's job was cancelled or timed + * out, fails them with an `ExtensionError`, so the retry policy applies. + */ +export interface PilotAttempts { + /** + * Claim peers of `attempt`. River begins a transaction and calls `run` + * with it; `run` moves the peers to `running` for this client with its + * own statements in that transaction and resolves with them read back by + * {@link PilotDatabase.loadClaimed}. River checks them before committing + * and rolls back, rejecting with an `ExtensionError`, unless each is + * running, on an attempt of this client, listed once, and neither + * `attempt`'s own job nor a job this client already works as an attempt + * or peer, nor one this coordinator already finished at that attempt. + * + * Resolves with the peers after the client's argument transforms. A peer + * River can't decode or transform isn't returned: River completes it as + * a failed attempt. + */ + claim( + attempt: WorkAttemptContext | WorkContext, + run: (context: PeerClaimContext) => Promise + ): Promise; + + /** + * Complete peers of `attempt` through River's completion pipeline, like + * the coordinator's own outcome: the error handler runs for failures, + * metadata the coordinator set is added, and the pilot's `complete` + * interceptor sees the persistence. Resolves once every outcome + * persisted. + * + * River accepts the outcomes all or none: each job must be a peer of + * `attempt` without an outcome yet, listed once. An outcome that fails + * before River's completer accepts it, such as an invalid result or one + * whose output is too large, leaves its peer without one, and the call + * rejects; once accepted, the outcome is River's, and its peer is never + * completed again. + */ + complete( + attempt: WorkAttemptContext | WorkContext, + outcomes: readonly PeerOutcome[] + ): Promise; +} + +/** + * A job's stored fields, for {@link PilotHost.insertPrepared}. Its + * arguments are `encodedArgs`, any JSON text, stored as given. + */ +export type PreparedInsertParams = Omit; + +/** What River gives a pilot in {@link Pilot.init}. */ +export interface PilotHost< + Transaction, + ClientType extends Client = Client, +> { + /** Peer attempts of this client's running attempts. */ + readonly attempts: PilotAttempts; + /** + * The client, usable once its construction returns. It is the final + * client object, such as a companion package's subclass of + * `PilotClient`, which the pilot names as `ClientType`: River creates the + * pilot before that subclass exists, so it can't check the type. + */ + readonly client: ClientType; + readonly clientId: string; + readonly database: PilotDatabase; + readonly logger: Logger; + /** How often producers report their queues. */ + readonly producerReportInterval: Temporal.Duration; + /** The job kinds this client has workers for. */ + readonly workerKinds: readonly string[]; + + /** + * Insert rows already prepared, such as jobs taken out of River that go + * back in with their stored fields, like an ordinary insertion of them: + * the client's insert metadata and argument transforms, insert + * middleware, and insert hooks each run once, then the pilot's insert + * interceptor, and inserts notify as usual. + * + * Transforms, middleware, and hooks see the stored arguments, read from + * `encodedArgs`, and no job definition. What they return is stored, so a + * transform keeps a row as it is by returning it unchanged, such as + * arguments it already transformed on an earlier insertion: + * `encodedArgs` passes through them byte for byte. Stored arguments that + * aren't a JSON object, which other River clients may insert, are kept + * as they are: argument transforms don't run for them, and metadata + * transforms, middleware, and hooks see empty arguments in their place. + * The unique key and states, creation time, and schedule are kept, and + * no unique key is computed. + */ + insertPrepared( + params: readonly PreparedInsertParams[], + options?: { + readonly signal?: AbortSignal; + readonly tx?: Transaction; + } + ): Promise; + + /** + * Wake this client's producers for inserted jobs another transaction + * owner has committed. It's only a local optimization: producers find the + * jobs anyway. + */ + notifyCommitted(results: readonly DriverInsertResult[]): void; +} + +/** Creates the pilot of one client from its driver's database. */ +export type PilotFactory< + Transaction, + QueueSettings = unknown, + ClientType extends Client = Client, +> = ( + database: PilotDatabase +) => Pilot; + +/** + * The connection a driver's migrations run on: a node-postgres pool or + * connected client with River's schema (undefined for the `search_path`), or + * a `node:sqlite` database. + */ +export type DriverMigrationTarget = + | { readonly client: object; readonly schema: string | undefined } + | { + readonly database: object; + /** + * Run one synchronous migration attempt on `database` under the + * driver's lock, retrying the whole attempt while another connection + * holds SQLite's write lock. The attempt leaves no transaction open + * when it throws. + */ + readonly run?: (attempt: (database: object) => T) => Promise; + } + | { readonly pool: object; readonly schema: string | undefined }; + +/** + * What a first-party driver registers about one driver instance with + * {@link registerDriver}. + */ +export interface DriverRecord { + /** The backend's name, such as `postgres` or `sqlite`, for errors. */ + readonly backend: string; + /** Whether the driver can run workers, or only insert. */ + readonly capability: "insert" | "runtime"; + /** The database a pilot uses, when the driver supports pilots. */ + readonly database?: PilotDatabase; + /** + * The connection and schema `createMigrator` migrates when given this + * driver, when the driver supports migrations. + */ + readonly migration?: DriverMigrationTarget; + /** + * The operations River runs through the driver, kept off the public + * driver object. A runtime driver's must implement every runtime + * operation. + */ + readonly operations: InsertDriver; +} diff --git a/js/src/query.ts b/js/src/query.ts new file mode 100644 index 000000000..53db4e1d0 --- /dev/null +++ b/js/src/query.ts @@ -0,0 +1,653 @@ +import type { + JobListAfter, + JobListCursorValue, + JobListKeyset, + JobListOrderBy, + JobListParams, + JobListTimeField, + JobDeleteManyParams, + JobUpdateParams as DriverJobUpdateParams, + QueueListParams, + QueueRow, + SortDirection, +} from "./driver.js"; +import { ValidationError } from "./errors.js"; +import { JOB_STATE } from "./job.js"; +import type { JobRow, JobState } from "./job.js"; +import type { JsonObject, JsonValue } from "./json.js"; +import { stringifyGoJsonString, toJsonObject, toJsonValue } from "./json.js"; + +const ALL_STATES = Object.freeze(Object.values(JOB_STATE)); +const MAX_CURSOR_BYTES = 16 * 1024; +const MAX_INT64 = 9_223_372_036_854_775_807n; +const MIN_INT64 = -9_223_372_036_854_775_808n; +// Go's zero time, which a job list cursor carries when it has no time. +const GO_ZERO_TIME = "0001-01-01T00:00:00Z"; +const GO_ZERO_TIME_NS = Temporal.Instant.from(GO_ZERO_TIME).epochNanoseconds; +const JOB_LIST_SORT_FIELDS: Readonly> = + Object.freeze({ + finalizedAt: "finalized_at", + id: "id", + scheduledAt: "scheduled_at", + time: "time", + }); +const STRICT_UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); + +export type { QueueRow } from "./driver.js"; + +/** Filters and exact keyset pagination for {@link JobOperations.list | `client.jobs.list`}. */ +export interface JobListOptions { + readonly after?: string; + readonly ids?: readonly bigint[]; + readonly kinds?: readonly string[]; + readonly limit?: number; + /** Require stored metadata to contain this JSON object. */ + readonly metadata?: JsonObject; + readonly orderBy?: JobListOrderBy; + readonly priorities?: readonly number[]; + readonly queues?: readonly string[]; + readonly sortDirection?: SortDirection; + readonly states?: readonly JobState[]; + readonly tagsAll?: readonly string[]; + readonly tagsAny?: readonly string[]; +} + +/** + * One page of jobs. Pass `nextCursor` as `after` for the next page; it is + * `null` on the last page. The cursor is River for Go's `JobListCursor` + * text, so River for Go and Rust can continue a listing from it and vice + * versa. + */ +export interface JobListResult { + readonly jobs: readonly JobRow[]; + readonly nextCursor: string | null; +} + +/** Safe filters for {@link JobOperations.deleteMany | `client.jobs.deleteMany`}. */ +export interface JobDeleteManyOptions { + /** Authorize an unfiltered deletion. Cannot be combined with filters. */ + readonly all?: true; + readonly ids?: readonly bigint[]; + readonly kinds?: readonly string[]; + readonly limit?: number; + readonly priorities?: readonly number[]; + readonly queues?: readonly string[]; + readonly states?: readonly JobState[]; +} + +/** + * Changes accepted by {@link JobOperations.update | `client.jobs.update`}, + * like River for Go's `JobUpdateParams`. Omitted fields leave the job + * unchanged. + */ +export interface JobUpdateOptions { + /** Merge these top-level keys into the job's metadata. */ + readonly metadata?: JsonObject; + /** Set the job's output, stored at `metadata.output`. */ + readonly output?: JsonValue; +} + +export function normalizeJobDeleteManyOptions( + options: JobDeleteManyOptions +): JobDeleteManyParams { + rejectExplicitUndefined(options); + const limit = options.limit ?? 100; + requireInteger("deleteMany limit", limit, 1, 10_000); + const ids = Object.freeze([...(options.ids ?? [])]); + for (const id of ids) { + if (typeof id !== "bigint" || id <= 0n) { + throw new ValidationError("deleteMany ids must be positive bigints"); + } + } + const priorities = Object.freeze([...(options.priorities ?? [])]); + for (const priority of priorities) { + requireInteger("deleteMany priority", priority, 1, 4); + } + const states = Object.freeze([...(options.states ?? [])]); + for (const state of states) { + if (!ALL_STATES.includes(state)) { + throw new ValidationError(`unknown River job state: ${state}`); + } + } + const kinds = copyStrings("kinds", options.kinds); + const queues = copyStrings("queues", options.queues); + const hasFilter = + ids.length > 0 || + kinds.length > 0 || + priorities.length > 0 || + queues.length > 0 || + states.length > 0; + if (options.all === true && hasFilter) { + throw new ValidationError("deleteMany all cannot be combined with filters"); + } + if (options.all !== true && !hasFilter) { + throw new ValidationError("deleteMany requires a filter or all: true"); + } + return { + all: options.all === true, + ids, + kinds, + limit, + priorities, + queues, + states, + }; +} + +/** Pagination for listing queues: `after` is a previous page's `nextCursor`. */ +export interface QueueListOptions { + readonly after?: string; + readonly limit?: number; +} + +/** One page of queues; `nextCursor` is `null` on the last page. */ +export interface QueueListResult { + readonly nextCursor: string | null; + readonly queues: readonly QueueRow[]; +} + +/** Changes to a queue's persisted settings. Omitted fields are unchanged. */ +export interface QueueUpdateOptions { + readonly metadata?: JsonObject; +} + +export function normalizeJobListOptions( + options: JobListOptions = {} +): JobListParams { + rejectExplicitUndefined(options); + const limit = options.limit ?? 100; + requireInteger("list limit", limit, 1, 10_000); + const sortField = options.orderBy ?? "id"; + if (!["finalizedAt", "id", "scheduledAt", "time"].includes(sortField)) { + throw new ValidationError("invalid list orderBy"); + } + const sortDirection = options.sortDirection ?? "asc"; + if (!["asc", "desc"].includes(sortDirection)) { + throw new ValidationError("invalid list sortDirection"); + } + + const states = [...(options.states ?? ALL_STATES)]; + for (const state of states) { + if (!ALL_STATES.includes(state)) { + throw new ValidationError(`unknown River job state: ${state}`); + } + } + if ( + sortField === "finalizedAt" && + states.some((state) => + ["available", "pending", "retryable", "running", "scheduled"].includes( + state + ) + ) + ) { + throw new ValidationError( + "finalizedAt ordering requires only finalized job states" + ); + } + + const after = + options.after === undefined ? null : decodeJobListCursor(options.after); + if (after !== null && after.sortField !== sortField) { + throw new ValidationError("cursor order does not match list orderBy"); + } + + return { + after, + ids: Object.freeze([...(options.ids ?? [])]), + kinds: copyStrings("kinds", options.kinds), + limit, + metadata: + options.metadata === undefined ? null : toJsonObject(options.metadata), + priorities: Object.freeze([...(options.priorities ?? [])]), + queues: copyStrings("queues", options.queues), + sortDirection, + sortField, + states: Object.freeze(states), + tagsAll: copyStrings("tagsAll", options.tagsAll), + tagsAny: copyStrings("tagsAny", options.tagsAny), + }; +} + +export function normalizeJobUpdateOptions( + options: JobUpdateOptions +): DriverJobUpdateParams { + rejectExplicitUndefined(options); + rejectUnknownKeys("job update", options, ["metadata", "output"]); + return { + ...(options.metadata === undefined + ? {} + : { metadata: toJsonObject(options.metadata) }), + ...(options.output === undefined + ? {} + : { output: normalizeOutput(options.output) }), + }; +} + +export function normalizeQueueListOptions( + options: QueueListOptions = {} +): QueueListParams { + rejectExplicitUndefined(options); + const limit = options.limit ?? 100; + requireInteger("queue list limit", limit, 1, 10_000); + return { + limit, + nameAfter: + options.after === undefined ? null : decodeQueueCursor(options.after), + }; +} + +/** + * Encode the cursor after `job` in a list with `params`' ordering exactly as + * River for Go's `JobListCursor` marshals it, so a token from any River + * implementation can continue a listing in any other. + */ +export function encodeJobListCursor( + job: JobRow, + params: Pick +): string { + return encodeJobListCursorValue(jobListCursorValue(job, params)); +} + +/** + * The cursor value after `job` in a list with `params`' ordering. Like River + * for Go, its time is the job's value of the field the list is ordered by, + * which for `time` ordering over several states can differ from the field of + * the job's own state, and `null` when that field is null for the job. + */ +export function jobListCursorValue( + job: JobRow, + params: Pick +): JobListCursorValue { + const timeField = jobListTimeField(params); + let time: Temporal.Instant | null = null; + if (timeField !== null) { + time = jobTimeFieldValue(job, timeField); + if (time === null && !jobListTimeFieldNullable(timeField, params.states)) { + throw new ValidationError( + `cannot create a ${params.sortField} cursor from a job without ${timeField}` + ); + } + } + return { + id: job.id, + kind: job.kind, + queue: job.queue, + sortField: params.sortField, + time, + }; +} + +/** + * How a job list with `params` is ordered and where it resumes. Every + * backend renders it with {@link jobListKeysetSql}. + */ +export function jobListKeyset( + params: Pick< + JobListParams, + "after" | "sortDirection" | "sortField" | "states" + > +): JobListKeyset { + const timeField = jobListTimeField(params); + const nullable = + timeField !== null && jobListTimeFieldNullable(timeField, params.states); + let after: JobListAfter | null = null; + if (params.after !== null) { + const { id, time } = params.after; + after = + timeField === null + ? { id, kind: "id" } + : time !== null + ? { id, kind: "time", time } + : // Like Go, a cursor without a time for a field that can't be + // null resumes by ID. + { id, kind: nullable ? "nullTime" : "id" }; + } + return { after, direction: params.sortDirection, nullable, timeField }; +} + +/** + * Render `keyset` as SQL: the condition selecting rows after its cursor, or + * `null` without one, and the `ORDER BY` terms. `bind` returns the + * placeholder of each parameter, in the order they appear in the condition. + */ +export function jobListKeysetSql( + keyset: JobListKeyset, + bind: (value: bigint | Temporal.Instant) => string +): { readonly after: string | null; readonly orderBy: string } { + const ascending = keyset.direction === "asc"; + const direction = ascending ? "ASC" : "DESC"; + const comparison = ascending ? ">" : "<"; + const field = keyset.timeField; + let orderBy = `id ${direction}`; + if (field !== null) { + const nulls = keyset.nullable + ? ascending + ? " NULLS LAST" + : " NULLS FIRST" + : ""; + orderBy = `${field} ${direction}${nulls}, ${orderBy}`; + } + const after = keyset.after; + if (after === null) return { after: null, orderBy }; + if (field === null || after.kind === "id") { + return { after: `id ${comparison} ${bind(after.id)}`, orderBy }; + } + if (after.kind === "nullTime") { + // After a null time, only nulls with a later ID follow ascending, and + // every non-null time also follows descending. + return { + after: ascending + ? `(${field} IS NULL AND id > ${bind(after.id)})` + : `(${field} IS NOT NULL OR id < ${bind(after.id)})`, + orderBy, + }; + } + // Nulls follow every time ascending and precede every time descending. + const time = bind(after.time); + const sameTime = bind(after.time); + const id = bind(after.id); + const orNull = keyset.nullable && ascending ? ` OR ${field} IS NULL` : ""; + return { + after: + `(${field} ${comparison} ${time} OR ` + + `(${field} = ${sameTime} AND id ${comparison} ${id})${orNull})`, + orderBy, + }; +} + +/** + * The time field a list with `params` is ordered by before ID, or `null` + * for ID ordering. `time` ordering uses the first listed state's field, and + * `scheduled_at` without a state filter, like Go, whose default states + * start with `available`. + */ +function jobListTimeField( + params: Pick +): JobListTimeField | null { + switch (params.sortField) { + case "id": + return null; + case "finalizedAt": + return "finalized_at"; + case "scheduledAt": + return "scheduled_at"; + case "time": { + const first = params.states[0]; + if (first === "running") return "attempted_at"; + if ( + first === "cancelled" || + first === "completed" || + first === "discarded" + ) { + return "finalized_at"; + } + return "scheduled_at"; + } + } +} + +/** + * Encode a cursor value in River for Go's `JobListCursor` text format: + * padded URL-safe base64 of Go's JSON encoding of `id`, `kind`, `queue`, + * `sort_field`, and `time`. A cursor without a time carries Go's zero time. + */ +export function encodeJobListCursorValue(value: JobListCursorValue): string { + const time = + value.sortField === "id" || value.time === null + ? GO_ZERO_TIME + : goCursorTime(value.time); + const json = + `{"id":${value.id.toString(10)}` + + `,"kind":${stringifyGoJsonString(value.kind)}` + + `,"queue":${stringifyGoJsonString(value.queue)}` + + `,"sort_field":"${JOB_LIST_SORT_FIELDS[value.sortField]}"` + + `,"time":"${time}"}`; + const bytes = Buffer.from(json, "utf8"); + return bytes + .toString("base64url") + .padEnd(Math.ceil(bytes.length / 3) * 4, "="); +} + +export function encodeQueueCursor(queue: QueueRow): string { + return encodeOpaque({ name: queue.name, v: 1 }); +} + +/** + * Decode a job list cursor from any River implementation. Like River for + * Rust, this accepts the URL-safe or standard base64 alphabet, with or + * without padding, so tokens from every River for Go release decode. Fields + * other than Go's five are ignored. A zero time, as Go writes for ID + * ordering, decodes to no time. + */ +export function decodeJobListCursor(cursor: string): JobListCursorValue { + let value: unknown; + try { + value = parseWithSource( + STRICT_UTF8.decode(decodeCursorBase64(cursor)), + (key, parsed, context) => + // Go reads `id` as an int64, which JavaScript numbers can't hold. + key === "id" && typeof parsed === "number" + ? /^-?\d+$/.test(context.source) + ? BigInt(context.source) + : Number.NaN + : parsed + ); + } catch (cause) { + throw new ValidationError("invalid job list cursor", { cause }); + } + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new ValidationError("invalid job list cursor"); + } + const fields = value as Record; + const sortField = ( + Object.keys(JOB_LIST_SORT_FIELDS) as JobListOrderBy[] + ).find((field) => JOB_LIST_SORT_FIELDS[field] === fields.sort_field); + if ( + typeof fields.id !== "bigint" || + fields.id < MIN_INT64 || + fields.id > MAX_INT64 || + typeof fields.kind !== "string" || + typeof fields.queue !== "string" || + sortField === undefined || + typeof fields.time !== "string" || + // Go parses only four-digit years. + !/^\d{4}-/.test(fields.time) + ) { + throw new ValidationError("invalid job list cursor"); + } + let time: Temporal.Instant; + try { + time = Temporal.Instant.from(fields.time); + } catch (cause) { + throw new ValidationError("invalid job list cursor", { cause }); + } + return { + id: fields.id, + kind: fields.kind, + queue: fields.queue, + sortField, + time: + sortField === "id" || time.epochNanoseconds === GO_ZERO_TIME_NS + ? null + : time, + }; +} + +function decodeQueueCursor(cursor: string): string { + const value = decodeOpaque(cursor); + if ( + Object.keys(value).sort().join("\0") !== ["name", "v"].join("\0") || + value.v !== 1 || + typeof value.name !== "string" || + value.name.length === 0 + ) { + throw new ValidationError("invalid queue list cursor"); + } + return value.name; +} + +/** Whether `field` may be null for jobs in `states` (every state if empty). */ +function jobListTimeFieldNullable( + field: JobListTimeField, + states: readonly JobState[] +): boolean { + switch (field) { + case "attempted_at": + return true; + case "finalized_at": + // The schema requires `finalized_at` for exactly the finalized states, + // and no filter here can widen the state filter. + return ( + states.length === 0 || + states.some( + (state) => + state !== "cancelled" && + state !== "completed" && + state !== "discarded" + ) + ); + case "scheduled_at": + return false; + } +} + +function jobTimeFieldValue( + job: JobRow, + field: JobListTimeField +): Temporal.Instant | null { + switch (field) { + case "attempted_at": + return job.attemptedAt; + case "finalized_at": + return job.finalizedAt; + case "scheduled_at": + return job.scheduledAt; + } +} + +/** + * Format a time as Go's `time.Time` JSON does in UTC: RFC 3339 with + * trailing fractional zeros removed. Go can't encode years outside 0–9999. + */ +function goCursorTime(time: Temporal.Instant): string { + const text = time.toString(); + if (!/^\d{4}-/.test(text)) { + throw new ValidationError( + "job list cursor time must be within years 0000 through 9999" + ); + } + return text; +} + +/** Base64 in either alphabet, with or without padding. */ +function decodeCursorBase64(cursor: string): Buffer { + if ( + typeof cursor !== "string" || + cursor.length === 0 || + cursor.length > MAX_CURSOR_BYTES + ) { + throw new TypeError("cursor is empty or too long"); + } + const unpadded = cursor.replace(/={1,2}$/, ""); + if ( + (unpadded.length !== cursor.length && cursor.length % 4 !== 0) || + unpadded.length % 4 === 1 || + !(/^[A-Za-z0-9_-]*$/.test(unpadded) || /^[A-Za-z0-9+/]*$/.test(unpadded)) + ) { + throw new TypeError("cursor is not base64"); + } + // Node's base64 decoder reads both alphabets. + return Buffer.from(unpadded, "base64"); +} + +function copyStrings( + name: string, + values: readonly string[] | undefined +): readonly string[] { + const result = [...(values ?? [])]; + if (result.some((value) => typeof value !== "string" || value.length === 0)) { + throw new ValidationError(`${name} must contain non-empty strings`); + } + return Object.freeze(result); +} + +function normalizeOutput(value: JsonValue): JsonValue { + const output = toJsonValue(value); + if (Buffer.byteLength(JSON.stringify(output), "utf8") > 32 * 1024 * 1024) { + throw new ValidationError("job output must not exceed 32 MiB"); + } + return output; +} + +/** `JSON.parse` with the source text of each primitive (ES2026). */ +const parseWithSource = JSON.parse as ( + text: string, + reviver: ( + key: string, + value: unknown, + context: { readonly source: string } + ) => unknown +) => unknown; + +function decodeOpaque(cursor: string): Record { + try { + if ( + cursor.length === 0 || + cursor.length > MAX_CURSOR_BYTES || + !/^[A-Za-z0-9_-]+$/.test(cursor) + ) { + throw new TypeError("cursor is not bounded unpadded URL-safe base64"); + } + const decoded = Buffer.from(cursor, "base64url"); + if (decoded.toString("base64url") !== cursor) { + throw new TypeError("cursor is not canonical URL-safe base64"); + } + // Reject rather than silently replace invalid UTF-8 in a tampered cursor. + const value: unknown = JSON.parse(STRICT_UTF8.decode(decoded)); + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new TypeError("cursor payload is not an object"); + } + return value as Record; + } catch (cause) { + throw new ValidationError("invalid pagination cursor", { cause }); + } +} + +function encodeOpaque(value: Record): string { + return Buffer.from(JSON.stringify(value), "utf8").toString("base64url"); +} + +function rejectExplicitUndefined(value: object): void { + for (const [key, item] of Object.entries(value)) { + if (item === undefined) { + throw new ValidationError(`${key} must be omitted instead of undefined`); + } + } +} + +function rejectUnknownKeys( + scope: string, + value: object, + known: readonly string[] +): void { + for (const key of Object.keys(value)) { + if (!known.includes(key)) { + throw new ValidationError( + `${scope} ${key} is not an option; expected one of ${known.join(", ")}` + ); + } + } +} + +function requireInteger( + name: string, + value: number, + min: number, + max: number +): void { + if (!Number.isSafeInteger(value) || value < min || value > max) { + throw new ValidationError( + `${name} must be a safe integer between ${min} and ${max}` + ); + } +} diff --git a/js/src/resumable.ts b/js/src/resumable.ts new file mode 100644 index 000000000..2a9391356 --- /dev/null +++ b/js/src/resumable.ts @@ -0,0 +1,211 @@ +import type { Client } from "./client.js"; +import { LifecycleError, ValidationError } from "./errors.js"; +import type { JobRow } from "./job.js"; +import type { JsonObject, JsonValue } from "./json.js"; +import { toJsonObject, toJsonValue } from "./json.js"; + +const CURSOR_KEY = "river:resumable_cursor"; +const STEP_KEY = "river:resumable_step"; + +/** + * Save resumable progress now, atomically with `tx`, instead of when the + * attempt ends. + */ +export interface ResumableCheckpointOptions { + readonly cursor?: JsonValue; + readonly tx: Transaction; +} + +interface ResumableFinish { + readonly error: Error | null; + readonly metadata: JsonObject; +} + +/** Attempt-scoped resumable-step coordinator exposed on WorkContext. */ +export class Resumable { + readonly #allNames = new Set(); + readonly #client: Client; + readonly #cursors = new Map(); + readonly #hadCursors: boolean; + readonly #job: JobRow; + readonly #resumeStep: string | null; + #completedStep: string | null = null; + #failure: Error | null = null; + #resumeMatched: boolean; + #stepName: string | null = null; + + /** @internal Constructed by the runtime for one attempt. */ + constructor(client: Client, job: JobRow) { + this.#client = client; + this.#job = job; + const resumeStep = job.metadata[STEP_KEY]; + this.#resumeStep = + typeof resumeStep === "string" && resumeStep.length > 0 + ? resumeStep + : null; + this.#resumeMatched = this.#resumeStep === null; + const cursors = job.metadata[CURSOR_KEY]; + if (Array.isArray(cursors)) { + throw new ValidationError( + "river:resumable_cursor must be an object when present" + ); + } + if ( + cursors !== null && + typeof cursors === "object" && + !Array.isArray(cursors) + ) { + for (const [name, cursor] of Object.entries( + cursors as Readonly> + )) { + if (cursor !== undefined) this.#cursors.set(name, cursor); + } + } + this.#hadCursors = this.#cursors.size > 0; + } + + /** + * Run a named step unless an earlier failed attempt completed it. + * Await steps sequentially; nested steps are supported, concurrent steps are not. + */ + async step( + name: string, + callback: () => PromiseLike | void + ): Promise { + const previousStepName = this.#stepName; + if (!this.#begin(name, false)) return; + try { + await callback(); + this.#completedStep = name; + } catch (cause: unknown) { + this.#failure = stepError(name, cause); + throw this.#failure; + } finally { + this.#stepName = previousStepName; + } + } + + /** Run a named cursor step with the last JSON cursor, or null initially. */ + async stepWithCursor( + name: string, + callback: (cursor: JsonValue | null) => PromiseLike | void + ): Promise { + const previousStepName = this.#stepName; + if (!this.#begin(name, true)) return; + try { + await callback(this.#cursors.get(name) ?? null); + this.#completedStep = name; + this.#cursors.delete(name); + } catch (cause: unknown) { + this.#failure = stepError(name, cause); + throw this.#failure; + } finally { + this.#stepName = previousStepName; + } + } + + /** Record the JSON cursor for the currently running cursor step. */ + setCursor(cursor: JsonValue): void { + if (this.#stepName === null) { + throw new LifecycleError( + "resumable cursor can only be set inside stepWithCursor()" + ); + } + this.#cursors.set(this.#stepName, toJsonValue(cursor)); + } + + /** Persist the current step and optional cursor in a caller transaction. */ + async checkpoint( + options: ResumableCheckpointOptions + ): Promise { + if (this.#stepName === null) { + throw new LifecycleError( + "resumable checkpoint can only be set inside a resumable step" + ); + } + this.#completedStep = this.#stepName; + if (options.cursor !== undefined) { + this.#cursors.set(this.#stepName, toJsonValue(options.cursor)); + } + const updated = await this.#client.jobs.update( + this.#job.id, + { metadata: this.#checkpointMetadata() }, + { tx: options.tx } + ); + if (updated === null) { + throw new LifecycleError(`running job ${this.#job.id} no longer exists`); + } + return updated; + } + + /** @internal Resolve metadata to merge with the attempt completion. */ + finish(workerFailed: boolean): ResumableFinish { + if (!workerFailed && !this.#resumeMatched && this.#failure === null) { + this.#failure = new LifecycleError( + `resumable step ${JSON.stringify(this.#resumeStep)} not found in worker` + ); + } + if (!workerFailed && this.#failure === null) { + return { error: null, metadata: {} }; + } + return { + error: this.#failure, + metadata: this.#completedStep === null ? {} : this.#checkpointMetadata(), + }; + } + + #begin(name: string, cursorStep: boolean): boolean { + if (name.length === 0) { + throw new ValidationError("resumable step name is empty"); + } + if (this.#failure !== null) throw this.#failure; + if (this.#allNames.has(name)) { + this.#failure = new ValidationError( + `duplicate resumable step name ${JSON.stringify(name)}` + ); + throw this.#failure; + } + this.#allNames.add(name); + if (!this.#resumeMatched) { + if (name !== this.#resumeStep) return false; + this.#completedStep = name; + this.#resumeMatched = true; + if (!cursorStep || !this.#cursors.has(name)) return false; + } + this.#stepName = name; + return true; + } + + #checkpointMetadata(): JsonObject { + const metadata: JsonObject = { + [STEP_KEY]: this.#completedStep ?? this.#stepName ?? "", + }; + if (this.#cursors.size > 0) { + metadata[CURSOR_KEY] = toJsonObject( + Object.fromEntries(this.#cursors.entries()) + ); + } else if (this.#hadCursors) { + metadata[CURSOR_KEY] = null; + } + return metadata; + } +} + +/** Construct an attempt-scoped resumable coordinator for first-party tooling. */ +export function createResumable(client: Client, job: JobRow): Resumable { + return new Resumable(client, job); +} + +/** Finalize resumable state for first-party worker test helpers. */ +export function finishResumable( + resumable: Resumable, + workerFailed: boolean +): ResumableFinish { + return resumable.finish(workerFailed); +} + +function stepError(name: string, cause: unknown): Error { + return new LifecycleError(`resumable step ${JSON.stringify(name)} failed`, { + cause, + }); +} diff --git a/js/src/runtime.ts b/js/src/runtime.ts new file mode 100644 index 000000000..b779213af --- /dev/null +++ b/js/src/runtime.ts @@ -0,0 +1,841 @@ +import type { Client } from "./client.js"; +import type { QueueRow, RuntimeDriver } from "./driver.js"; +import { LifecycleError, ValidationError } from "./errors.js"; +import type { RiverEvent } from "./events.js"; +import type { RiverHooks } from "./extensions.js"; +import { unrefTimeout } from "./internal/abort.js"; +import { SYSTEM_TIMER, type RuntimeTimer } from "./internal/backoff.js"; +import { EventLoopDelayMonitor } from "./internal/event-loop-delay-monitor.js"; +import type { JobRow } from "./job.js"; +import type { InternalLogger } from "./logger.js"; +import { publishRiverMetric, type RiverMetric } from "./metrics.js"; +import { + toQueueSettings, + toStopSettings, + type QueueConfig, + type StopOptions, +} from "./options.js"; +import type { PeriodicJobStore } from "./periodic-job-store.js"; +import type { PeriodicJobs, PeriodicJobsStartParams } from "./periodic.js"; +import type { Pilot, PilotDatabase, PilotService } from "./pilot.js"; +import { AttemptRunner } from "./runtime/attempt-runner.js"; +import { CompletionPipeline } from "./runtime/completion-pipeline.js"; +import type { RuntimeContext } from "./runtime/context.js"; +import { canonicalError, invokeHook } from "./runtime/failures.js"; +import { NotificationPump } from "./runtime/notification-pump.js"; +import { PeerAttempts } from "./runtime/peer-attempts.js"; +import type { PilotOperations } from "./runtime/pilot-operations.js"; +import { QueueProducer } from "./runtime/queue-producer.js"; +import { serviceList, superviseService } from "./runtime/service-supervisor.js"; +import { + resolveQueueConfig, + resolveRuntimeSettings, + type PilotQueueParser, + type QueueSettings, + type ResolvedQueue, + type RunDiagnostics, + type RunState, + type RuntimeTiming, + type RuntimeEventSink, + type RuntimeSettings, + type StopSettings, +} from "./runtime/settings.js"; +import { + rejectExplicitUndefined, + requirePositiveInteger, +} from "./runtime/validation.js"; +import { RuntimeServices } from "./services.js"; + +export type { EventLoopDelayObservation } from "./internal/event-loop-delay-monitor.js"; +/** @internal */ +export { defaultNextRetry } from "./runtime/completion-command.js"; +/** @internal */ +export { normalizeRuntimeSettings } from "./runtime/settings.js"; +export type { + EventLoopDelaySettings, + JobStuckHandler, + JobStuckHandlerParams, + JobStuckHandlerResult, + QueueRuntimeDiagnostics, + QueueSettings, + RetryPolicy, + RunDiagnostics, + RunState, + RuntimeSettings, + StopSettings, +} from "./runtime/settings.js"; +/** @internal */ +export type { RuntimeEventSink } from "./runtime/settings.js"; +export type { RuntimeTiming } from "./runtime/settings.js"; +export { + currentWorkContext, + recordOutput, + setMetadata, +} from "./runtime/work-context.js"; +export type { CurrentWorkContext } from "./runtime/work-context.js"; + +const runtimeTimingOverrides = new WeakMap(); + +/** + * Replace the clock, randomness, and timers of the runtime `client` starts + * next, including those it runs a companion's producer sessions and + * services with: report jitter and intervals, keep-alive and shutdown + * deadlines, service backoff, and leadership deadlines. Call it before + * `start()`; a running runtime keeps its timing. For tests only. + */ +export function overrideRuntimeTiming( + client: object, + timing: RuntimeTiming +): void { + // JavaScript callers may pass anything. + const target: unknown = client; + if (typeof target !== "object" || target === null) { + throw new ValidationError("overrideRuntimeTiming requires a client"); + } + for (const key of ["now", "random"] as const) { + if (timing[key] !== undefined && typeof timing[key] !== "function") { + throw new ValidationError(`timing.${key} must be a function`); + } + } + const timer: unknown = timing.timer; + if ( + timer !== undefined && + (typeof timer !== "object" || + timer === null || + typeof (timer as Partial).delay !== "function" || + typeof (timer as Partial).now !== "function" || + typeof (timer as Partial).timeout !== "function") + ) { + throw new ValidationError( + "timing.timer must have delay, now, and timeout methods" + ); + } + runtimeTimingOverrides.set(client, { + ...(timing.now !== undefined && { now: timing.now }), + ...(timing.random !== undefined && { random: timing.random }), + ...(timing.timer !== undefined && { timer: timing.timer }), + }); +} + +/** + * @internal What a client hands its runtime: its backend's name, the + * operations its pilot may intercept, and the pilot's parts, if any. + */ +export interface RuntimeBinding { + /** The client's insert notification limiter, for the scheduler. */ + readonly allowInsertNotifications: ( + queues: readonly string[] + ) => readonly string[]; + readonly backend: string; + readonly database?: PilotDatabase; + readonly operations: PilotOperations; + readonly pilot?: Pilot; + readonly pilotQueueParser: PilotQueueParser | undefined; + /** Each configured queue's settings, as the pilot parsed them. */ + readonly pilotQueueSettings: Readonly>; +} + +/** + * Handle owning one supervised Client runtime. + * + * `Config` is the queue configuration `addQueue` and `updateQueue` accept. + * TypeScript compares handles structurally, so a handle for River's own + * queue configuration also type-checks as one accepting more keys; the + * runtime rejects any queue key the client doesn't know. + */ +export class RunHandle { + readonly #controller: RuntimeController; + + /** Rejects immediately if an owned background task fails. */ + readonly completed: Promise; + + /** @internal */ + constructor(controller: RuntimeController) { + this.#controller = controller; + this.completed = controller.completed; + } + + get diagnostics(): RunDiagnostics { + return this.#controller.diagnostics; + } + + get state(): RunState { + return this.#controller.state; + } + + /** Add and persist a queue, starting claims only after its controls load. */ + async addQueue(name: string, config: Config): Promise { + await this.#controller.addQueue(name, toQueueSettings(config)); + } + + /** Stop locally claiming a queue; its persisted row expires naturally. */ + removeQueue(name: string): Promise { + return this.#controller.removeQueue(name); + } + + /** Ask whichever runtime currently leads to resign its exact term. */ + requestLeadershipResignation(): Promise { + return this.#controller.requestLeadershipResignation(); + } + + /** Atomically replace local queue capacity/polling configuration. */ + async updateQueue(name: string, config: Config): Promise { + await this.#controller.updateQueue(name, toQueueSettings(config)); + } + + /** + * Stop the runtime. A graceful stop (the default) waits for running jobs; + * `mode: "cancel"`, the `signal`, or an elapsed `timeout` aborts them. + */ + async stop(options: StopOptions = {}): Promise { + await this.#controller.stop(toStopSettings(options)); + } + + async [Symbol.asyncDispose](): Promise { + await this.stop({ mode: "graceful" }); + } +} + +/** + * @internal Runtime implementation created by Client.start. + * + * The controller owns the runtime's lifecycle (running, stopping, stopped, or + * failed), its background task supervision, and event and metric delivery. + * The work itself is split among collaborators sharing a + * {@link RuntimeContext}: a {@link QueueProducer} claims jobs, an + * {@link AttemptRunner} works them, a {@link CompletionPipeline} persists + * their outcomes, and a {@link NotificationPump} applies backend + * notifications. + */ +export class RuntimeController { + readonly ready: Promise; + + /** + * Ends polling for cancellations once producers drained, so a job can + * still be cancelled while its queue drains. + */ + readonly #cancellationPollAbort = new AbortController(); + /** How often a runtime without notifications checks for cancellations. */ + readonly #cancellationPollIntervalMs: number; + readonly #claimAbort = new AbortController(); + readonly #client: Client; + readonly #clientId: string; + readonly #completedPromise: Promise; + #completedReject!: (reason: unknown) => void; + #completedResolve!: () => void; + readonly #completions: CompletionPipeline; + readonly #context: RuntimeContext; + readonly #eventLoopDelayMonitor: EventLoopDelayMonitor | undefined; + readonly #events: RuntimeEventSink; + #fatalError: LifecycleError | undefined; + /** The client's claim cooldown, for queues that don't set their own. */ + readonly #fetchCooldownMs: number; + readonly #hooks: readonly RiverHooks[]; + readonly #initialQueues: Readonly>; + /** Tasks that end only after producers drained. */ + readonly #lateTasks = new Set>(); + #livenessTimer: NodeJS.Timeout | undefined; + readonly #logger: InternalLogger; + /** Ends maintenance and leadership, as a stop begins. */ + readonly #maintenanceAbort = new AbortController(); + readonly #notifications: NotificationPump; + readonly #nowFunc: () => Temporal.Instant; + readonly #peers: PeerAttempts | undefined; + readonly #pilotQueueParser: PilotQueueParser | undefined; + readonly #pollOnly: boolean; + readonly #producer: QueueProducer; + readonly #runAbort = new AbortController(); + readonly #runner: AttemptRunner; + /** The pilot's runtime services, listed once per run. */ + readonly #runtimeServices: readonly PilotService[]; + readonly #services: RuntimeServices | null; + /** Ends the pilot's runtime services, once producers drained. */ + readonly #servicesAbort = new AbortController(); + #shutdownPromise: Promise | undefined; + #state: RunState = "running"; + readonly #tasks = new Set>(); + + constructor( + client: Client, + driver: RuntimeDriver, + events: RuntimeEventSink, + periodicJobs: PeriodicJobs, + options: RuntimeSettings, + binding: RuntimeBinding, + dependencies: RuntimeTiming = runtimeTimingOverrides.get(client) ?? {} + ) { + const settings = resolveRuntimeSettings(driver, options); + // Like River for Go's producers, which check queue settings and + // cancellations on the same interval. + this.#cancellationPollIntervalMs = settings.queueControlPollIntervalMs; + this.#client = client; + this.#clientId = settings.clientId; + this.#events = events; + this.#fetchCooldownMs = settings.fetchCooldownMs; + this.#hooks = settings.hooks; + this.#initialQueues = Object.fromEntries( + Object.entries(settings.queues).map(([name, config]) => [ + name, + { config, pilotSettings: binding.pilotQueueSettings[name] }, + ]) + ); + this.#pilotQueueParser = binding.pilotQueueParser; + this.#pollOnly = settings.pollOnly; + this.#logger = settings.logger; + this.#nowFunc = dependencies.now ?? (() => Temporal.Now.instant()); + const random = dependencies.random ?? Math.random; + + const context: RuntimeContext = Object.freeze({ + claimSignal: this.#claimAbort.signal, + backend: binding.backend, + client, + clientId: settings.clientId, + driver, + emit: (event: RiverEvent) => this.#emit(event), + emitMetric: (metric: RiverMetric) => { + this.#emitMetric(metric); + }, + fail: (error: unknown) => { + this.#fail(error); + }, + guard: (task: Promise) => this.#guard(task), + logger: settings.logger, + now: () => this.#now(), + operations: binding.operations, + random, + runSignal: this.#runAbort.signal, + timer: dependencies.timer ?? SYSTEM_TIMER, + trackTask: (task: Promise) => { + this.#trackTask(task); + }, + }); + this.#context = context; + this.#completions = new CompletionPipeline(context, { + batchSize: settings.completionBatchSize, + concurrency: binding.pilot?.completionConcurrency, + flushIntervalMs: settings.completionFlushIntervalMs, + retryPolicy: settings.retryPolicy, + schedulerIntervalMs: settings.schedulerIntervalMs, + }); + this.#peers = + binding.database === undefined + ? undefined + : new PeerAttempts(context, { + completions: this.#completions, + database: binding.database, + errorHandler: settings.errorHandler, + isWorking: (id) => this.#runner.isWorking(id), + transformJobArgs: (row) => this.#runner.transformJobArgs(row), + workerRetryPolicy: (kind) => this.#runner.workerRetryPolicy(kind), + }); + this.#runner = new AttemptRunner(context, { + completions: this.#completions, + errorHandler: settings.errorHandler, + hooks: settings.hooks, + jobArgsTransformers: settings.jobArgsTransformers, + jobStuckThresholdMs: settings.jobStuckThresholdMs, + jobTimeoutMs: settings.jobTimeoutMs, + middleware: settings.middleware, + peers: this.#peers, + runSignal: this.#runAbort.signal, + stuckHandler: settings.stuckHandler, + workLogger: settings.workLogger, + workers: settings.workers, + }); + const startProducer = binding.pilot?.startProducer?.bind(binding.pilot); + this.#producer = new QueueProducer(context, this.#runner, { + controlPollIntervalMs: settings.queueControlPollIntervalMs, + fetchKinds: settings.fetchKinds, + heartbeatIntervalMs: settings.queueHeartbeatIntervalMs, + ...(startProducer === undefined || binding.database === undefined + ? {} + : { + pilot: { + database: binding.database, + reportIntervalMs: settings.queueHeartbeatIntervalMs, + startProducer, + }, + }), + }); + this.#services = + settings.maintenance === null + ? null + : new RuntimeServices({ + allowInsertNotifications: binding.allowInsertNotifications, + client, + clientId: settings.clientId, + driver, + emit: (event) => this.#emit(event), + logger: settings.logger, + maintenance: settings.maintenance, + now: () => this.#now(), + onPeriodicJobsStart: (params) => this.#onPeriodicJobsStart(params), + operations: binding.operations, + periodicJobStore: + binding.pilot?.periodicJobs === undefined + ? undefined + : pilotPeriodicJobStore( + binding.pilot.periodicJobs, + binding.database + ), + periodicJobs, + random, + rescue: (job, now, signal) => this.#runner.rescue(job, now, signal), + termServices: + binding.pilot?.maintenanceServices === undefined + ? [] + : serviceList( + binding.pilot.maintenanceServices(), + "maintenanceServices()" + ), + timer: context.timer, + ...(binding.pilot?.jobCleanerQueuesExcluded === undefined + ? {} + : { + jobCleanerQueuesExcluded: + binding.pilot.jobCleanerQueuesExcluded, + }), + }); + this.#runtimeServices = + binding.pilot?.services === undefined + ? [] + : serviceList(binding.pilot.services(), "services()"); + this.#notifications = new NotificationPump(context, { + leaderResigned: () => this.#services?.leaderResigned(), + producer: this.#producer, + resignLeadership: () => this.#services?.resignLeadership(), + runner: this.#runner, + }); + if (settings.eventLoopDelay !== null) { + this.#eventLoopDelayMonitor = new EventLoopDelayMonitor( + settings.eventLoopDelay, + (observation) => { + if (!observation.exceededThreshold) return; + void this.#emit({ + at: this.#now(), + eventLoopDelay: observation, + kind: "runtime_event_loop_delay", + }).catch((error: unknown) => { + this.#fail(error); + }); + } + ); + } + + this.#completedPromise = new Promise((resolve, reject) => { + this.#completedResolve = resolve; + this.#completedReject = reject; + }); + void this.#completedPromise.catch(() => undefined); + // An active worker runtime owns unfinished database work. Keep Node alive + // even though individual polling and batching timers are unref'ed so an + // insert-only client remains process-neutral. + this.#livenessTimer = setInterval(() => undefined, 60_000); + this.ready = this.#initialize(); + void this.ready.catch((error: unknown) => { + this.#fail(error); + }); + } + + get completed(): Promise { + return this.#completedPromise; + } + + get diagnostics(): RunDiagnostics { + return { + activeAttempts: this.#runner.activeAttempts, + clientId: this.#clientId, + completionCapacity: this.#completions.maxPendingItems, + completionQueries: this.#completions.inFlightQueries, + eventLoopDelay: this.#eventLoopDelayMonitor?.last ?? null, + maintenance: this.#services?.diagnostics ?? null, + pendingCompletions: this.#completions.pendingItems, + queues: this.#producer.diagnostics(), + state: this.#state, + executors: this.#runner.executorDiagnostics(), + }; + } + + /** @internal The peer attempts of the pilot's client. */ + get peerAttempts(): PeerAttempts | undefined { + return this.#peers; + } + + get state(): RunState { + return this.#state; + } + + async addQueue(name: string, config: QueueSettings): Promise { + this.#requireRunning(); + await this.#producer.add( + name, + resolveQueueConfig( + name, + config, + this.#pilotQueueParser, + this.#fetchCooldownMs + ) + ); + } + + /** @internal Apply a queue command already committed by this client. */ + applyCommittedQueueControl(queue: QueueRow): void { + this.#producer.applyCommittedControl(queue); + } + + cancelLocal(job: JobRow): void { + this.#runner.cancelLocal(job); + } + + async removeQueue(name: string): Promise { + this.#requireRunning(); + return this.#producer.remove(name); + } + + async requestLeadershipResignation(): Promise { + this.#requireRunning(); + await this.#client.requestLeadershipResignation(); + await this.#services?.resignLeadership(); + } + + stop(options: StopSettings = {}): Promise { + rejectExplicitUndefined(options); + const mode = options.mode ?? "graceful"; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- validates untyped JavaScript input + if (mode !== "graceful" && mode !== "cancel") { + return Promise.reject(new ValidationError("invalid stop mode")); + } + if (options.timeoutMs !== undefined) { + requirePositiveInteger("stop timeout", options.timeoutMs); + } + if (options.signal?.aborted) return Promise.reject(options.signal.reason); + + if (this.#shutdownPromise === undefined) { + if (this.#state !== "failed") this.#state = "stopping"; + this.#claimAbort.abort(new LifecycleError("River runtime is stopping")); + this.#completions.drain(); + this.#shutdownPromise = this.#finishStop(); + void this.#shutdownPromise.catch(() => undefined); + } + if (mode === "cancel") { + this.#cancelAttempts(new LifecycleError("River runtime cancelled")); + } + + return withStopBounds( + this.#shutdownPromise, + options, + mode === "graceful" + ? () => { + this.#cancelAttempts( + new LifecycleError("River graceful stop deadline elapsed") + ); + } + : undefined + ); + } + + async updateQueue(name: string, config: QueueSettings): Promise { + this.#requireRunning(); + await this.#producer.update( + name, + resolveQueueConfig( + name, + config, + this.#pilotQueueParser, + this.#fetchCooldownMs + ) + ); + } + /** Reread persisted queue pauses now, after this client paused or resumed every queue. */ + wakeQueueControl(): void { + this.#producer.wakeControl(); + } + + /** Wake the claim loops of `queues` after another owner committed jobs. */ + wakeQueues(queues: Iterable): void { + for (const queue of queues) this.#producer.wake(queue); + } + + #cancelAttempts(reason: LifecycleError): void { + this.#runAbort.abort(reason); + this.#runner.abortAttempts(reason); + } + + async #emit(event: RiverEvent): Promise { + await this.#events.emit(event); + } + + #emitMetric(metric: RiverMetric): void { + publishRiverMetric(metric); + for (const hooks of this.#hooks) { + if (hooks.onMetric === undefined) continue; + try { + void Promise.resolve(hooks.onMetric(metric)).catch((error: unknown) => { + this.#logger.error("River metric hook failed", { + error: canonicalError(error, this.#now()).error, + }); + }); + } catch (error: unknown) { + this.#logger.error("River metric hook failed", { + error: canonicalError(error, this.#now()).error, + }); + } + } + } + + #fail(error: unknown): void { + if (this.#fatalError !== undefined || this.#state === "stopped") return; + this.#fatalError = + error instanceof LifecycleError + ? error + : new LifecycleError("River background task failed", { cause: error }); + this.#state = "failed"; + this.#eventLoopDelayMonitor?.stop(); + this.#claimAbort.abort(this.#fatalError); + this.#runAbort.abort(this.#fatalError); + this.#completions.abort(this.#fatalError); + this.#runner.abortAttempts(this.#fatalError); + // A fatal failure tears the runtime down like a stop; `completed` + // rejects once that cleanup finished. + if (this.#shutdownPromise === undefined) { + this.#shutdownPromise = this.#finishStop(); + void this.#shutdownPromise.catch(() => undefined); + } + } + + /** + * Tear the runtime down in River's order, like River for Go: claims + * already stopped, and leadership, maintenance, and the pilot's + * maintenance services end as the stop begins; each queue drains its + * attempts while its producer keeps reporting; then producers stop + * reporting and shut down; then the pilot's services end while the + * completer flushes; finally committed events are delivered. `completed` + * settles only after all of it, rejecting with the first fatal failure. + */ + async #finishStop(): Promise { + await this.ready.catch(() => undefined); + this.#maintenanceAbort.abort( + new LifecycleError("River runtime is stopping") + ); + await this.#producer.drainAll(); + this.#cancellationPollAbort.abort( + new LifecycleError("River runtime is stopping") + ); + await Promise.allSettled(this.#tasks); + this.#servicesAbort.abort(new LifecycleError("River runtime is stopping")); + const [completionClose] = await Promise.allSettled([ + this.#completions.close(), + ...this.#lateTasks, + ]); + // Events already committed are still delivered to `onEvent` hooks + // before the runtime reports that it stopped. + await this.#events.drain(); + this.#eventLoopDelayMonitor?.stop(); + this.#releaseLiveness(); + if (this.#fatalError !== undefined) { + // The failure aborts the completer with itself, which is no failure + // of the completer's own. + if ( + completionClose.status === "rejected" && + completionClose.reason !== this.#fatalError + ) { + this.#logger.error("River completer failed while stopping", { + error: canonicalError(completionClose.reason, this.#now()).error, + }); + } + this.#completedReject(this.#fatalError); + throw this.#fatalError; + } + if (completionClose.status === "rejected") { + this.#completedReject(completionClose.reason); + throw completionClose.reason; + } + this.#state = "stopped"; + this.#completedResolve(); + } + + async #guard(task: Promise): Promise { + try { + await task; + } catch (error: unknown) { + this.#fail(error); + throw error; + } + } + + async #initialize(): Promise { + // Like River for Go's client, learn whether the database delivers + // notifications before anything starts. Without them, running jobs + // learn of cancellations by polling until every producer has drained. + const listen = await this.#listens(); + if (!listen) { + this.#trackTask( + this.#runner.pollCancellations( + this.#cancellationPollIntervalMs, + this.#cancellationPollAbort.signal + ) + ); + } + // Like River for Go's notifier, notifications are listening before any + // queue claims, and a failure to listen fails the start. + await this.#notifications.start(listen); + // The pilot's services start before any queue claims; River promises + // the order, not that they are ready. + for (const service of this.#runtimeServices) { + this.#trackLateTask( + superviseService(service, undefined, this.#servicesAbort.signal, { + logger: this.#logger, + random: this.#context.random, + timer: this.#context.timer, + }) + ); + } + try { + for (const [name, queue] of Object.entries(this.#initialQueues)) { + await this.#producer.start(name, queue, false); + } + } catch (error: unknown) { + // Queues this start already started drain before it fails. + this.#claimAbort.abort( + new LifecycleError("River runtime failed to start", { cause: error }) + ); + await this.#producer.drainAll(); + throw error; + } + this.#trackTask(this.#guard(this.#producer.runControlLoop())); + if (this.#services !== null) { + // Leadership and maintenance end as a stop begins, like River for Go. + this.#trackLateTask( + this.#guard(this.#services.run(this.#maintenanceAbort.signal)) + ); + } + this.#eventLoopDelayMonitor?.start(); + } + + /** + * Whether this runtime hears notifications: it isn't poll-only, its driver + * can subscribe, and the database delivers them, which a PostgreSQL + * driver detects from the server. + */ + async #listens(): Promise { + const driver = this.#context.driver; + if ( + this.#pollOnly || + (driver.runtimeNotificationSubscribe === undefined && + driver.jobCancellationSubscribe === undefined) + ) { + return false; + } + const delivers = driver.runtimeDeliversNotifications?.bind(driver); + if ( + delivers === undefined || + (await delivers({ signal: this.#claimAbort.signal })) + ) { + return true; + } + this.#logger.info( + "River's database does not support LISTEN/NOTIFY; polling instead" + ); + return false; + } + + #now(): Temporal.Instant { + return this.#nowFunc(); + } + + async #onPeriodicJobsStart(params: PeriodicJobsStartParams): Promise { + for (const hooks of this.#hooks) { + if (hooks.onPeriodicJobsStart === undefined) continue; + await invokeHook("onPeriodicJobsStart", () => + hooks.onPeriodicJobsStart?.(params) + ); + } + } + + #releaseLiveness(): void { + if (this.#livenessTimer === undefined) return; + clearInterval(this.#livenessTimer); + this.#livenessTimer = undefined; + } + + #requireRunning(): void { + if (this.#state !== "running" || this.#claimAbort.signal.aborted) { + throw new LifecycleError("River runtime is not running"); + } + } + + /** + * End leadership, maintenance, and the pilot's term services: the one + * step of a stop whose place in the order is a setting. + */ + #trackLateTask(task: Promise): void { + this.#lateTasks.add(task); + void task.then( + () => this.#lateTasks.delete(task), + () => this.#lateTasks.delete(task) + ); + } + + #trackTask(task: Promise): void { + this.#tasks.add(task); + void task.then( + () => this.#tasks.delete(task), + () => this.#tasks.delete(task) + ); + } +} + +/** + * A pilot's periodic job store, whose `upsertMany` River runs in a pilot + * transaction on the transaction inserting the jobs, so that it receives a + * native handle like the pilot's interceptors do. + */ +function pilotPeriodicJobStore( + store: PeriodicJobStore, + database: PilotDatabase | undefined +): PeriodicJobStore { + if (database === undefined) return store; + return { + getAll: (options) => store.getAll(options), + keepAliveAndReap: (ids, options) => store.keepAliveAndReap(ids, options), + upsertMany: (tx, jobs) => + database.transaction((handle) => store.upsertMany(handle, jobs), { tx }), + }; +} + +async function withStopBounds( + stopping: Promise, + options: StopSettings, + onTimeout?: () => void +): Promise { + const bounds: Promise[] = []; + let cancelTimer: (() => void) | undefined; + let onAbort: (() => void) | undefined; + const { timeoutMs } = options; + if (timeoutMs !== undefined) { + bounds.push( + new Promise((_, reject) => { + cancelTimer = unrefTimeout(() => { + onTimeout?.(); + reject(new LifecycleError("River runtime stop timed out")); + }, timeoutMs); + }) + ); + } + if (options.signal !== undefined) { + bounds.push( + new Promise((_, reject) => { + onAbort = () => reject(options.signal?.reason); + options.signal?.addEventListener("abort", onAbort, { once: true }); + }) + ); + } + try { + await Promise.race([stopping, ...bounds]); + } finally { + cancelTimer?.(); + if (onAbort !== undefined) { + options.signal?.removeEventListener("abort", onAbort); + } + } +} diff --git a/js/src/runtime/attempt-result.ts b/js/src/runtime/attempt-result.ts new file mode 100644 index 000000000..f356d692d --- /dev/null +++ b/js/src/runtime/attempt-result.ts @@ -0,0 +1,195 @@ +/** + * Construction and validation of work attempt results and outcomes. + */ +import { ValidationError } from "../errors.js"; +import type { WorkAttemptResult, WorkMiddleware } from "../extensions.js"; +import { toMilliseconds } from "../internal/duration.js"; +import { toJsonObject } from "../json.js"; +import type { WorkAttemptContext, WorkOutcome } from "../worker.js"; +import { normalizeOutput } from "./work-context.js"; + +/** A succeeded result for `outcome`, keeping `previous` metadata and output. */ +export function succeededResult( + outcome: WorkOutcome | undefined, + previous?: WorkAttemptResult +): WorkAttemptResult { + const normalizedOutcome = normalizeOutcome(outcome); + return { + ...attemptData(previous), + ...(normalizedOutcome === undefined ? {} : { outcome: normalizedOutcome }), + status: "succeeded", + }; +} + +/** + * The result of a thrown `error`: cancelled when it is the attempt signal's + * abort reason (or an error of the same name), otherwise failed. + */ +export function thrownResult( + error: unknown, + signal: AbortSignal, + previous?: WorkAttemptResult +): WorkAttemptResult { + const reason: unknown = signal.reason; + const cancelled = + signal.aborted && + (Object.is(error, reason) || + (error instanceof Error && + reason instanceof Error && + error.name === reason.name)); + return { + ...attemptData(previous), + error: cancelled ? reason : error, + status: cancelled ? "cancelled" : "failed", + }; +} + +/** The metadata and output carried by `result`, if any. */ +export function attemptData( + result: WorkAttemptResult | undefined +): Pick { + if (result === undefined) return {}; + return { + ...(result.metadata === undefined ? {} : { metadata: result.metadata }), + ...(result.output === undefined && !("output" in result) + ? {} + : { output: result.output }), + }; +} + +/** Validate and freeze a handler's outcome. */ +function normalizeOutcome( + outcome: WorkOutcome | undefined +): WorkOutcome | undefined { + validateOutcome(outcome); + if (outcome === undefined) return undefined; + if (outcome.type === "complete" && outcome.output !== undefined) { + return Object.freeze({ + output: normalizeOutput(outcome.output), + type: "complete", + }); + } + return Object.freeze({ ...outcome }); +} + +/** Validate and snapshot a result returned by an `afterWork` hook. */ +export function normalizeWorkAttemptResult( + value: WorkAttemptResult +): WorkAttemptResult { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- validates untyped JavaScript input + if (value === null || typeof value !== "object") { + throw new ValidationError("afterWork must return a work attempt result"); + } + const data = { + ...(value.metadata === undefined + ? {} + : { metadata: toJsonObject(value.metadata) }), + ...(value.output === undefined && !("output" in value) + ? {} + : { output: normalizeOutput(value.output) }), + }; + switch (value.status) { + case "cancelled": + if (!("error" in value) || "outcome" in value || "cancel" in value) { + throw new ValidationError("invalid cancelled work attempt result"); + } + return { ...data, error: value.error, status: "cancelled" }; + case "failed": + if (!("error" in value) || "outcome" in value) { + throw new ValidationError("invalid failed work attempt result"); + } + if (value.cancel !== undefined && typeof value.cancel !== "boolean") { + throw new ValidationError("failed result cancel must be a boolean"); + } + return { + ...data, + ...(value.cancel === undefined ? {} : { cancel: value.cancel }), + error: value.error, + status: "failed", + }; + case "succeeded": { + if ("error" in value || "cancel" in value) { + throw new ValidationError("invalid succeeded work attempt result"); + } + const outcome = normalizeOutcome(value.outcome); + return { + ...data, + ...(outcome === undefined ? {} : { outcome }), + status: "succeeded", + }; + } + default: + throw new ValidationError("unknown work attempt result status"); + } +} + +/** Reject a handler outcome River cannot persist. */ +function validateOutcome(outcome: WorkOutcome | undefined): void { + if (outcome === undefined) return; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- validates untyped JavaScript input + if (outcome === null || typeof outcome !== "object") { + throw new ValidationError("worker returned an invalid outcome"); + } + if (outcome.type === "complete") { + if (outcome.output !== undefined) normalizeOutput(outcome.output); + return; + } + if (outcome.type === "cancel" || outcome.type === "discard") { + if ( + outcome.reason !== undefined && + (typeof outcome.reason !== "string" || outcome.reason.length === 0) + ) { + throw new ValidationError( + `${outcome.type} reason must be a non-empty string` + ); + } + return; + } + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- validates untyped JavaScript input + if (outcome.type === "snooze") { + toMilliseconds("snooze duration", outcome.duration, { + allowZero: true, + error: ValidationError, + }); + return; + } + throw new ValidationError("worker returned an invalid outcome"); +} + +/** + * Compose work middleware around `handler`, outermost first. Each + * middleware may call `next` at most once. + */ +export function composeMiddleware( + middleware: readonly WorkMiddleware[], + handler: ( + context: WorkAttemptContext + ) => PromiseLike | WorkOutcome | undefined +): (context: WorkAttemptContext) => Promise { + return async (context) => { + const dispatch = async ( + index: number + ): Promise => { + const current = middleware[index]; + if (current === undefined) { + return await handler(context); + } + let called = false; + return (await current(context, async () => { + if (called) + throw new Error("work middleware called next more than once"); + called = true; + return dispatch(index + 1); + })) as WorkOutcome | undefined; + }; + return dispatch(0); + }; +} + +/** Whether a result completes the job without any explicit outcome. */ +export function isPlainCompletion(result: WorkAttemptResult): boolean { + return ( + result.status === "succeeded" && + (result.outcome === undefined || result.outcome.type === "complete") + ); +} diff --git a/js/src/runtime/attempt-runner.ts b/js/src/runtime/attempt-runner.ts new file mode 100644 index 000000000..cca2ea5a5 --- /dev/null +++ b/js/src/runtime/attempt-runner.ts @@ -0,0 +1,1089 @@ +/** + * The attempt runner: builds each attempt's work context, runs it through + * middleware, hooks, and the configured executor, enforces timeouts and + * reports stuck attempts, and reconciles the result with any abort that + * raced it before handing it to the completion pipeline. + */ +import type { RuntimeDriver, RuntimeJobRescue } from "../driver.js"; +import { + JobAbortedError, + JobAttemptFinishedError, + JobCancelledError, + JobStuckError, + JobTimeoutError, + UnknownJobKindError, + ValidationError, +} from "../errors.js"; +import type { + RiverErrorHandler, + RiverHooks, + WorkAttemptResult, + WorkMiddleware, +} from "../extensions.js"; +import { + LinkedAbortSignal, + raceWithAbort, + unrefTimeout, +} from "../internal/abort.js"; +import type { AttemptExecutionHandle } from "../internal/attempt-executor.js"; +import { AttemptExecutor } from "../internal/attempt-executor.js"; +import { + millisecondsToDuration, + toMilliseconds, +} from "../internal/duration.js"; +import type { JobArgsTransformer } from "../job-args-transform.js"; +import { transformJobArgsForRead } from "../job-args-transform.js"; +import { decodeJobArgs } from "../job-definition.js"; +import type { JobRow } from "../job.js"; +import type { JsonObject } from "../json.js"; +import { toJsonObject } from "../json.js"; +import type { Logger } from "../logger.js"; +import { createWorkLogger } from "../logger.js"; +import type { + WorkAttemptContext, + WorkContext, + WorkerRegistration, +} from "../worker.js"; +import { workerRegistration, type Workers } from "../worker.js"; +import { + attemptData, + composeMiddleware, + isPlainCompletion, + normalizeWorkAttemptResult, + succeededResult, + thrownResult, +} from "./attempt-result.js"; +import type { CompletionPipeline } from "./completion-pipeline.js"; +import type { RuntimeContext } from "./context.js"; +import { canonicalError, describeError, isRuntimeFault } from "./failures.js"; +import type { PeerAttempts } from "./peer-attempts.js"; +import type { JobStuckHandler, RetryPolicy } from "./settings.js"; +import { validatePlugins } from "./settings.js"; +import type { WorkOutputState } from "./work-context.js"; +import { + beginWorkAttempt, + errorHandlerContext, + makeDecodedWorkContext, + normalizeOutput, + publishWorkResult, + resultWithMetadata, + resultWithOutput, + runInWorkContext, + setWorkMetadata, + shareWorkAttempt, +} from "./work-context.js"; + +/** Configuration for an {@link AttemptRunner}. */ +export interface AttemptRunnerOptions { + readonly completions: CompletionPipeline; + readonly errorHandler: RiverErrorHandler | undefined; + /** Client-level hooks, plugin hooks first. */ + readonly hooks: readonly RiverHooks[]; + readonly jobArgsTransformers: readonly Readonly[]; + /** + * Wait after a timeout before reporting a stuck attempt, and after + * aborting a handler before an executor may end it by force. + */ + readonly jobStuckThresholdMs: number; + /** Default job timeout; null disables it. */ + readonly jobTimeoutMs: number | null; + /** Client-level work middleware, plugin middleware first. */ + readonly middleware: readonly WorkMiddleware[]; + /** The peers of attempts, when the client's pilot can claim them. */ + readonly peers?: PeerAttempts | undefined; + /** Aborts every attempt when the runtime cancels its work. */ + readonly runSignal: AbortSignal; + readonly stuckHandler: JobStuckHandler | undefined; + readonly workLogger: Logger; + readonly workers: Workers; +} + +/** IDs per cancellation check query, like River for Go's producer. */ +const CANCEL_POLL_BATCH_SIZE = 1_000; + +/** Bound on each cancellation check query, like River for Go's producer. */ +const CANCEL_POLL_TIMEOUT_MS = 10_000; + +/** Runs claimed jobs and tracks the attempts in flight. */ +export class AttemptRunner { + readonly #attemptExecutor = new AttemptExecutor(); + /** In-flight attempts by `attemptKey`. */ + readonly #attempts = new Map(); + /** + * For each claim in flight, the `attemptKey`s of cancellations that + * found no attempt running here. + */ + readonly #claimCancellations = new Set>(); + readonly #completions: CompletionPipeline; + readonly #context: RuntimeContext; + readonly #errorHandler: RiverErrorHandler | undefined; + readonly #hooks: readonly RiverHooks[]; + readonly #jobArgsTransformers: readonly Readonly[]; + readonly #jobStuckThresholdMs: number; + readonly #jobTimeoutMs: number | null; + readonly #middleware: readonly WorkMiddleware[]; + readonly #peers: PeerAttempts | undefined; + readonly #runSignal: AbortSignal; + readonly #stuckHandler: JobStuckHandler | undefined; + /** Attempts currently reported stuck. */ + #stuckJobs = 0; + readonly #workLogger: Logger; + readonly #workers: Workers; + + constructor(context: RuntimeContext, options: AttemptRunnerOptions) { + this.#completions = options.completions; + this.#context = context; + this.#errorHandler = options.errorHandler; + this.#hooks = options.hooks; + this.#jobArgsTransformers = options.jobArgsTransformers; + this.#jobStuckThresholdMs = options.jobStuckThresholdMs; + this.#jobTimeoutMs = options.jobTimeoutMs; + this.#middleware = options.middleware; + this.#peers = options.peers; + this.#runSignal = options.runSignal; + this.#stuckHandler = options.stuckHandler; + this.#workLogger = options.workLogger; + this.#workers = options.workers; + } + + /** Attempts currently running in this process. */ + get activeAttempts(): number { + return this.#attempts.size; + } + + /** Abort every in-flight attempt with `reason`. */ + abortAttempts(reason: unknown): void { + for (const controller of this.#attempts.values()) controller.abort(reason); + } + + /** Cancel the attempt of job `id` that `attemptedBy` owns, if it runs here. */ + cancelAttempt(id: bigint, attemptedBy: string): void { + const key = attemptKey(id, attemptedBy); + const attempt = this.#attempts.get(key); + if (attempt !== undefined) { + attempt.abort(new JobCancelledError(id)); + return; + } + for (const pending of this.#claimCancellations) pending.add(key); + } + + /** + * Cancel every attempt this client runs whose job was cancelled while + * cancellation notices couldn't arrive, such as while a notification + * stream was reconnecting. Reads the attempts' jobs like + * {@link pollCancellations}, in batches of 1,000 IDs each bounded by ten + * seconds, and stops when `signal` aborts. + */ + async recoverCancellations(signal: AbortSignal): Promise { + const clientId = this.#context.clientId; + const driver = this.#context.driver; + const requested = driver.jobGetCancelRequested?.bind(driver); + const ids = this.#ownAttemptIds(); + if (requested === undefined) { + // A backend without the batched read checks each job's row. + for (const id of ids) { + signal.throwIfAborted(); + const job = await raceWithAbort(driver.jobGet(id), signal); + if ( + job?.state === "running" && + job.attemptedBy.at(-1) === clientId && + job.metadata.cancel_attempted_at !== undefined + ) { + this.cancelAttempt(id, clientId); + } + } + return; + } + await this.#cancelRequested(requested, ids, signal); + } + + /** + * Cancel this client's running attempts whose jobs have a cancellation + * request, checking every `intervalMs` until `signal` aborts, like River + * for Go's producers without a notifier. Each check reads the attempts' + * jobs in batches of 1,000 IDs, each bounded by ten seconds, and reads + * nothing while no attempt runs. A failed check is logged and the next + * one tries again. + */ + async pollCancellations( + intervalMs: number, + signal: AbortSignal + ): Promise { + const driver = this.#context.driver; + const requested = driver.jobGetCancelRequested?.bind(driver); + if (requested === undefined) return; + for (;;) { + try { + await this.#context.timer.delay(intervalMs, signal); + } catch { + return; + } + try { + await this.#cancelRequested(requested, this.#ownAttemptIds(), signal); + } catch (error: unknown) { + if (signal.aborted) return; + this.#context.logger.error( + "River could not check running jobs for cancellation requests", + { error: describeError(error) } + ); + } + } + } + + /** Cancel this process's attempts of a job just cancelled through this client. */ + cancelLocal(job: JobRow): void { + const keyPrefix = `${job.id.toString(10)}:`; + for (const [key, controller] of this.#attempts) { + if (key.startsWith(keyPrefix)) { + controller.abort(new JobCancelledError(job.id)); + } + } + // The job may be in a claim still in flight here. + const key = attemptKey(job.id, this.#context.clientId); + if (!this.#attempts.has(key)) { + for (const pending of this.#claimCancellations) pending.add(key); + } + } + + /** Diagnostics reported by each work executor in use. */ + executorDiagnostics(): Readonly> { + return this.#attemptExecutor.diagnostics(); + } + + /** + * Decide how the rescuer recovers a stuck job, like River's JobRescuer: + * cancel it after a cancellation request, discard an unknown kind, and + * otherwise retry or discard it once its timeout has passed or its + * payload no longer decodes. Returns null to leave it running. + */ + async rescue( + job: JobRow, + now: Temporal.Instant, + signal: AbortSignal + ): Promise { + signal.throwIfAborted(); + const error = { + at: now, + attempt: Math.max(job.attempt, 0), + error: "Stuck job rescued by JobRescuer", + trace: "", + }; + if (job.metadata.cancel_attempted_at !== undefined) { + return { + error, + finalizedAt: now, + id: job.id, + scheduledAt: job.scheduledAt, + state: "cancelled", + }; + } + + const registration = workerRegistration(this.#workers, job.kind); + if (registration === undefined) { + return { + error, + finalizedAt: now, + id: job.id, + scheduledAt: job.scheduledAt, + state: "discarded", + }; + } + + let payloadInvalid = false; + let retryPolicyJob = job; + try { + const transformedJob = this.transformJobArgs(job); + retryPolicyJob = transformedJob; + signal.throwIfAborted(); + await raceWithAbort( + decodeJobArgs(registration.definition, transformedJob.args), + signal + ); + } catch { + signal.throwIfAborted(); + payloadInvalid = true; + } + // Match River's rescuer: a payload that cannot be decoded is rescued + // regardless of timeout, while a kind whose timeout is disabled may run + // indefinitely and is never rescued. + const timeoutMs = this.#effectiveJobTimeout(registration); + if (!payloadInvalid && timeoutMs === null) return null; + if ( + !payloadInvalid && + timeoutMs !== null && + job.attemptedAt !== null && + now.epochNanoseconds - job.attemptedAt.epochNanoseconds < + BigInt(timeoutMs) * 1_000_000n + ) { + return null; + } + + if (job.attempt >= job.maxAttempts) { + return { + error, + finalizedAt: now, + id: job.id, + scheduledAt: job.scheduledAt, + state: "discarded", + }; + } + return { + error, + finalizedAt: null, + id: job.id, + scheduledAt: this.#completions.nextRetryAt( + retryPolicyJob, + now, + payloadInvalid ? undefined : registration.options.retryPolicy + ), + state: "retryable", + }; + } + + /** + * Work one claimed row and persist its outcome. `releaseCapacity` frees + * its producer slot early, when a stuck handler adds a worker slot. + * + * A row the driver couldn't fully decode (`decodeError`) isn't worked. Like + * an unknown kind, its attempt fails before hooks or middleware run, so the + * error handler sees it and the retry policy retries or discards it. + * + * `cancelled` starts the attempt already cancelled, for a job whose + * cancellation arrived while the claim that took it was in flight. + */ + async run( + row: JobRow, + releaseCapacity: () => void, + decodeError?: Error, + cancelled = false + ): Promise { + const registration = workerRegistration(this.#workers, row.kind); + const attemptController = new AbortController(); + const timeoutController = new AbortController(); + const key = attemptKey(row.id, this.#context.clientId); + this.#attempts.set(key, attemptController); + if (cancelled) attemptController.abort(new JobCancelledError(row.id)); + // Like Go, a remote cancellation or shutdown is tracked apart from the + // attempt's own timeout: a remote cancellation that arrives after the + // timeout still decides how the attempt ends. + const cancelLink = new LinkedAbortSignal([ + this.#runSignal, + attemptController.signal, + ]); + const cancelSignal = cancelLink.signal; + // Like River for Go's executor cancelling the job's context as it + // returns, the handler's signal aborts once the attempt finished. + const finishedController = new AbortController(); + const link = new LinkedAbortSignal([ + cancelSignal, + timeoutController.signal, + finishedController.signal, + ]); + const signal = link.signal; + const remotelyCancelled = (): boolean => + cancelSignal.aborted && cancelSignal.reason instanceof JobCancelledError; + const timers = new AttemptTimers(row.id, timeoutController); + let transformedRow = row; + let transformFailure: { readonly error: unknown } | undefined; + if (decodeError !== undefined) { + transformFailure = { + error: new Error( + `job row couldn't be decoded: ${decodeError.message}`, + { + cause: decodeError, + } + ), + }; + } else if (registration !== undefined) { + try { + transformedRow = this.transformJobArgs(row); + } catch (error: unknown) { + transformFailure = { error }; + } + } + const outputState: WorkOutputState = {}; + const rawContext = this.#attemptContext( + transformedRow, + signal, + outputState + ); + const metadataState = beginWorkAttempt(rawContext, outputState); + + try { + await this.#context.emit({ + at: rawContext.execution.startedAt, + job: transformedRow, + kind: "job_started", + }); + + let handle: AttemptExecutionHandle | undefined; + // Like River for Go, the worker's retry policy applies once the job's + // arguments decode. + let workerRetryPolicy: RetryPolicy | undefined; + const execution = ( + transformFailure !== undefined + ? this.#executePreWorkFailure( + rawContext, + transformFailure.error, + remotelyCancelled + ) + : registration === undefined + ? this.#executePreWorkFailure( + rawContext, + new UnknownJobKindError(row.kind), + remotelyCancelled + ) + : this.#execute( + rawContext, + registration, + remotelyCancelled, + async () => { + const workContext = await this.#prepareWorkContext( + rawContext, + registration, + transformedRow, + outputState, + timers, + releaseCapacity + ); + workerRetryPolicy = registration.options.retryPolicy; + return workContext; + }, + (started) => { + handle = started; + timers.started(started); + } + ) + ).finally(() => { + if (timers.settle()) this.#stuckJobs -= 1; + }); + const result = await this.#awaitResult(execution, signal, () => handle); + const abortReason: unknown = remotelyCancelled() + ? cancelSignal.reason + : signal.aborted + ? signal.reason + : undefined; + // The attempt's peers end before it, each with an outcome. + await this.#peers?.finish(metadataState, abortReason); + await this.#persistAttempt( + transformedRow, + result, + abortReason, + rawContext.execution.startedAt, + workerRetryPolicy + ); + } finally { + finishedController.abort(new JobAttemptFinishedError(row.id)); + link[Symbol.dispose](); + cancelLink[Symbol.dispose](); + timers.dispose(); + this.#peers?.abandon(metadataState); + metadataState.active = false; + this.#attempts.delete(key); + } + } + + /** Whether this client is working an attempt of job `id`, or owns it as a peer. */ + isActive(id: bigint): boolean { + return this.isWorking(id) || this.#peers?.owns(id) === true; + } + + /** Whether this client is working an attempt of job `id`. */ + isWorking(id: bigint): boolean { + return this.#attempts.has(attemptKey(id, this.#context.clientId)); + } + + /** + * Keep cancellations of jobs not worked here that arrive until `end()`, + * for one claim, like River for Go's producer does for one fetch: an + * attempt the claim starts for such a job starts cancelled. Other + * cancellations are discarded when the claim ends. + */ + watchCancellations(): { + cancelled(id: bigint): boolean; + end(): void; + } { + const pending = new Set(); + this.#claimCancellations.add(pending); + return { + cancelled: (id) => pending.has(attemptKey(id, this.#context.clientId)), + end: () => { + this.#claimCancellations.delete(pending); + }, + }; + } + + /** Apply the configured argument transformers to a claimed row. */ + transformJobArgs(row: JobRow): JobRow { + const args = transformJobArgsForRead( + this.#jobArgsTransformers, + row.kind, + row.args + ); + return args === row.args ? row : { ...row, args }; + } + + /** The retry policy of the worker registered for `kind`, if any. */ + workerRetryPolicy(kind: string): RetryPolicy | undefined { + return workerRegistration(this.#workers, kind)?.options.retryPolicy; + } + + /** The context an attempt's hooks, middleware, and executor start from. */ + #attemptContext( + row: JobRow, + signal: AbortSignal, + outputState: WorkOutputState + ): WorkAttemptContext { + const context: WorkAttemptContext = { + client: this.#context.client, + execution: { + attemptedBy: this.#context.clientId, + startedAt: this.#context.now(), + }, + job: row, + logger: createWorkLogger(this.#workLogger, { + attempt: row.attempt, + jobId: row.id.toString(10), + jobKind: row.kind, + }), + recordOutput: (value) => { + outputState.output = normalizeOutput(value); + }, + setMetadata: (metadataKey, value) => { + setWorkMetadata(context, metadataKey, value); + }, + signal, + }; + return context; + } + + /** + * Wait for an attempt to settle. If its signal aborts first, ask the + * executor to stop, giving the handler the stuck threshold to settle + * before the executor may end it by force, then keep capacity until the + * handler really settles. + * Cancellation and interruption events are emitted only once, after the + * resulting transition commits. + */ + async #awaitResult( + execution: Promise, + signal: AbortSignal, + handle: () => AttemptExecutionHandle | undefined + ): Promise { + const winner = await Promise.race([ + execution.then((value) => ({ type: "execution" as const, value })), + waitForAbort(signal).then((reason) => ({ + reason, + type: "abort" as const, + })), + ]); + if (winner.type === "execution") return winner.value; + const started = handle(); + const { terminated } = + started === undefined + ? { terminated: false } + : await started.abort(winner.reason, this.#jobStuckThresholdMs); + const result = await execution; + // An executor that forcibly terminated the handler proves the abort + // stopped it, whatever error the termination surfaced, unless the + // handler ignored a stop's abort, which fails its attempt. + if ( + terminated && + result.status === "failed" && + !(result.error instanceof JobAbortedError) + ) { + return { + ...attemptData(result), + error: winner.reason, + status: "cancelled", + }; + } + return result; + } + + #effectiveJobTimeout(registration: WorkerRegistration): number | null { + const timeout = registration.options.timeout; + if (timeout === undefined) return this.#jobTimeoutMs; + return timeout === null ? null : toMilliseconds("worker timeout", timeout); + } + + async #execute( + rawContext: WorkAttemptContext, + registration: WorkerRegistration, + remotelyCancelled: () => boolean, + prepareContext: () => Promise, + started: (handle: AttemptExecutionHandle) => void + ): Promise { + const kindPlugins = registration.options.plugins ?? []; + validatePlugins(kindPlugins, "worker"); + const hooks = Object.freeze([ + ...this.#hooks, + ...kindPlugins.flatMap((plugin) => + plugin.hooks === undefined ? [] : [plugin.hooks] + ), + ...(registration.options.hooks === undefined + ? [] + : [registration.options.hooks]), + ]); + // Worker middleware is outermost like Go's Worker.Middleware; job-kind + // plugin middleware is innermost like JobArgs.Plugin middleware. + const middleware = Object.freeze([ + ...(registration.options.middleware ?? []), + ...this.#middleware, + ...kindPlugins.flatMap((plugin) => plugin.middleware ?? []), + ]); + let decodedContext: WorkContext | undefined; + let workResult: WorkAttemptResult | undefined; + let result: WorkAttemptResult; + try { + const invoke = composeMiddleware(middleware, async () => { + for (const hook of hooks) { + await hook.beforeWork?.(rawContext); + } + decodedContext = await prepareContext(); + const context = decodedContext; + return runInWorkContext(context, async () => { + let innerResult: WorkAttemptResult; + let handle: AttemptExecutionHandle | undefined; + try { + handle = this.#attemptExecutor.start(registration, context); + started(handle); + const outcome = await handle.result; + innerResult = succeededResult(outcome); + } catch (error: unknown) { + innerResult = + (await ignoredStopFailure(handle, context)) ?? + thrownResult(error, context.signal); + } + innerResult = resultWithOutput(innerResult, context); + for (const hook of hooks) { + if (hook.afterWork === undefined) continue; + try { + const replacement = await hook.afterWork(context, innerResult); + if (replacement !== undefined) { + innerResult = normalizeWorkAttemptResult(replacement); + } + } catch (error: unknown) { + innerResult = { error, status: "failed" }; + break; + } + } + workResult = innerResult; + if (innerResult.status !== "succeeded") throw innerResult.error; + return innerResult.outcome; + }); + }); + const outcome = await runInWorkContext(rawContext, () => + invoke(rawContext) + ); + result = + workResult?.status === "succeeded" && + Object.is(outcome, workResult.outcome) + ? workResult + : succeededResult(outcome, workResult); + } catch (error: unknown) { + result = + workResult !== undefined && + workResult.status !== "succeeded" && + Object.is(error, workResult.error) + ? workResult + : thrownResult(error, rawContext.signal, workResult); + } + if (decodedContext !== undefined) { + const resumable = decodedContext.resumable.finish( + result.status !== "succeeded" + ); + if (resumable.error !== null && result.status !== "failed") { + result = { error: resumable.error, status: "failed" }; + } + if (Object.keys(resumable.metadata).length > 0) { + result = { + ...result, + metadata: toJsonObject({ + ...(result.metadata ?? {}), + ...resumable.metadata, + }), + }; + } + } + // A handler that stopped because its timeout expired failed with the + // timeout, like a Go worker returning its context's deadline error. + if ( + result.status === "cancelled" && + result.error instanceof JobTimeoutError + ) { + result = { + ...attemptData(result), + error: result.error, + status: "failed", + }; + } + result = await this.#handleError(rawContext, result, remotelyCancelled); + result = resultWithOutput(result, rawContext); + result = resultWithMetadata(result, rawContext); + publishWorkResult(rawContext, result); + return result; + } + + async #executePreWorkFailure( + context: WorkAttemptContext, + error: unknown, + remotelyCancelled: () => boolean + ): Promise { + let result = thrownResult(error, context.signal); + result = await this.#handleError(context, result, remotelyCancelled); + result = resultWithOutput(result, context); + result = resultWithMetadata(result, context); + publishWorkResult(context, result); + return result; + } + + /** + * Give the error handler a failed attempt, like Go's executor: every + * failure, including an attempt that stopped on its timeout, reaches it. + * An error after a remote cancellation doesn't, because Go replaces it + * with the cancellation, but a runtime fault (Go's panic, which Go still + * hands to `HandlePanic`) does. Interrupted (`cancelled`) attempts never + * reach it. + */ + async #handleError( + context: WorkAttemptContext, + result: WorkAttemptResult, + remotelyCancelled: () => boolean + ): Promise { + if ( + result.status !== "failed" || + this.#errorHandler === undefined || + (remotelyCancelled() && !isRuntimeFault(result.error)) + ) { + return result; + } + try { + const decision = await this.#errorHandler( + errorHandlerContext(context), + result.error + ); + if ( + decision?.cancel !== undefined && + typeof decision.cancel !== "boolean" + ) { + throw new ValidationError("errorHandler cancel must be a boolean"); + } + if (decision?.cancel === true) return { ...result, cancel: true }; + } catch (error: unknown) { + this.#context.logger.error("River error handler failed", { + error: canonicalError(error, this.#context.now()).error, + }); + } + return result; + } + + /** + * Persist a settled attempt, reconciling it with any abort that raced it + * the way River's Go executor does: + * + * - A remote cancellation wins over everything except a plain successful + * completion, which is persisted as `completed`. + * - After a timeout, a success completes; a handler that stopped because of + * the abort records the timeout as its error, and any other failure is + * recorded as returned. + * - After a shutdown abort, only a handler that stopped because of that + * abort is interrupted; a success completes and a genuine failure is + * recorded and retried normally, like a handler its executor stopped by + * force after it ignored the abort. + */ + async #persistAttempt( + row: JobRow, + result: WorkAttemptResult, + abortReason: unknown, + startedAt: Temporal.Instant, + retryPolicy: RetryPolicy | undefined + ): Promise { + const completions = this.#completions; + const persistResult = (): Promise => + completions.persistResult(row, result, startedAt, undefined, retryPolicy); + const persistAbort = (reason: unknown): Promise => + completions.persistAbort( + row, + reason, + startedAt, + result, + undefined, + retryPolicy + ); + if (abortReason === undefined) { + await (result.status === "cancelled" + ? persistAbort(result.error) + : persistResult()); + return; + } + if (abortReason instanceof JobCancelledError) { + await (isPlainCompletion(result) + ? persistResult() + : persistAbort(abortReason)); + return; + } + await (result.status === "cancelled" + ? persistAbort(abortReason) + : persistResult()); + } + + /** + * Decode the attempt's arguments and build the context its handler + * receives, preparing its timeout and stuck-report timers to arm once the + * executor starts it. + */ + async #prepareWorkContext( + rawContext: WorkAttemptContext, + registration: WorkerRegistration, + row: JobRow, + outputState: WorkOutputState, + timers: AttemptTimers, + releaseCapacity: () => void + ): Promise { + const decodedArgs = await decodeJobArgs(registration.definition, row.args); + timers.prepare( + this.#effectiveJobTimeout(registration), + this.#jobStuckThresholdMs, + (timeoutMs, thresholdMs) => { + this.#stuckJobs += 1; + void this.#reportStuck( + row, + timeoutMs, + thresholdMs, + releaseCapacity + ).catch((error: unknown) => { + this.#context.fail(error); + }); + } + ); + const context = makeDecodedWorkContext( + rawContext, + decodedArgs, + row, + outputState, + this.#context.clientId, + this.#context.driver, + this.#context.operations + ); + shareWorkAttempt(rawContext, context, outputState); + return context; + } + + /** + * Cancel the attempts among `ids` whose jobs have a cancellation request, + * reading them in batches of 1,000 IDs, each bounded by ten seconds. + */ + async #cancelRequested( + requested: NonNullable, + ids: readonly bigint[], + signal: AbortSignal + ): Promise { + const clientId = this.#context.clientId; + for (let start = 0; start < ids.length; start += CANCEL_POLL_BATCH_SIZE) { + const timeout = this.#context.timer.timeout( + CANCEL_POLL_TIMEOUT_MS, + () => new Error("River's cancellation check timed out") + ); + const link = new LinkedAbortSignal([signal, timeout.signal]); + try { + const cancelled = await raceWithAbort( + requested(ids.slice(start, start + CANCEL_POLL_BATCH_SIZE), { + signal: link.signal, + }), + link.signal + ); + for (const id of cancelled) this.cancelAttempt(id, clientId); + } finally { + link[Symbol.dispose](); + timeout.dispose(); + } + } + } + + /** The job IDs of the attempts this client runs itself. */ + #ownAttemptIds(): bigint[] { + const suffix = `:${this.#context.clientId}`; + return [...this.#attempts.keys()] + .filter((key) => key.endsWith(suffix)) + .map((key) => BigInt(key.slice(0, -suffix.length))); + } + + async #reportStuck( + job: JobRow, + timeoutMs: number, + thresholdMs: number, + releaseCapacity: () => void + ): Promise { + // Like River for Go, report the timeout the attempt ran with, the + // worker's own when it sets one. + this.#context.logger.warn("River job appears to be stuck", { + jobId: job.id.toString(10), + kind: job.kind, + timeoutMs, + }); + await this.#context.emit({ + at: this.#context.now(), + error: new JobStuckError( + job.id, + millisecondsToDuration(timeoutMs), + millisecondsToDuration(thresholdMs) + ), + job, + kind: "job_stuck", + }); + if (this.#stuckHandler === undefined) return; + try { + const decision = await this.#stuckHandler({ + id: job.id, + kind: job.kind, + queue: job.queue, + totalStuckJobs: this.#stuckJobs, + }); + if ( + decision?.addWorkerSlot !== undefined && + typeof decision.addWorkerSlot !== "boolean" + ) { + throw new ValidationError( + "stuckHandler addWorkerSlot must be a boolean" + ); + } + if (decision?.addWorkerSlot === true) releaseCapacity(); + } catch (error: unknown) { + this.#context.logger.error("River stuck handler failed", { + error: canonicalError(error, this.#context.now()).error, + }); + } + } +} + +/** The key identifying one attempt: the job ID and its claiming client. */ +function attemptKey(id: bigint, attemptedBy: string): string { + return `${id}:${attemptedBy}`; +} + +/** Abort `controller` with a timeout error after `timeoutMs`; returns the disposer. */ +function armJobTimeout( + jobId: bigint, + timeoutMs: number | null, + controller: AbortController +): () => void { + if (timeoutMs === null) return () => undefined; + return unrefTimeout(() => { + controller.abort( + new JobTimeoutError(jobId, millisecondsToDuration(timeoutMs)) + ); + }, timeoutMs); +} + +/** + * The failure of an attempt whose executor stopped it by force because it + * still ignored a stop's abort after the client's stuck threshold. It had + * that long to respond, so unlike a handler that stops because of the + * abort, its attempt counts. A remote cancellation or a timeout decides the + * outcome of a forced stop as usual. + */ +async function ignoredStopFailure( + handle: AttemptExecutionHandle | undefined, + context: WorkContext +): Promise { + const reason: unknown = context.signal.reason; + if ( + handle === undefined || + !context.signal.aborted || + reason instanceof JobCancelledError || + reason instanceof JobTimeoutError || + !(await handle.forciblyStopped()) + ) { + return undefined; + } + return { + error: new JobAbortedError(context.job.id, { cause: reason }), + status: "failed", + }; +} + +/** Resolve with the signal's abort reason once it aborts. */ +function waitForAbort(signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve(signal.reason); + return new Promise((resolve) => + signal.addEventListener("abort", () => resolve(signal.reason), { + once: true, + }) + ); +} + +/** + * An attempt's timeout and stuck-report timers. Both arm once the executor + * actually starts the attempt, which an executor with its own capacity may + * delay (see `WorkExecutorHandle.started`), and stop when execution settles. + */ +class AttemptTimers { + #arm: (() => void) | undefined; + #disposeTimeout: () => void = () => undefined; + readonly #jobId: bigint; + #settled = false; + #stuckReported = false; + #cancelStuckTimer: (() => void) | undefined; + readonly #timeoutController: AbortController; + + constructor(jobId: bigint, timeoutController: AbortController) { + this.#jobId = jobId; + this.#timeoutController = timeoutController; + } + + /** Stop the timeout without marking execution settled. */ + dispose(): void { + this.#disposeTimeout(); + } + + /** + * Prepare the timers: a timeout after `timeoutMs`, and `onStuck` once + * `stuckThresholdMs` more passes without the attempt settling. + */ + prepare( + timeoutMs: number | null, + stuckThresholdMs: number, + onStuck: (timeoutMs: number, stuckThresholdMs: number) => void + ): void { + this.#arm = () => { + this.#arm = undefined; + if (this.#settled) return; + this.#disposeTimeout = armJobTimeout( + this.#jobId, + timeoutMs, + this.#timeoutController + ); + if (timeoutMs !== null) { + this.#cancelStuckTimer = unrefTimeout(() => { + if (this.#settled) return; + this.#stuckReported = true; + onStuck(timeoutMs, stuckThresholdMs); + }, timeoutMs + stuckThresholdMs); + } + }; + } + + /** + * Stop both timers because execution settled. Returns whether the attempt + * had been reported stuck. + */ + settle(): boolean { + this.#settled = true; + this.#disposeTimeout(); + this.#cancelStuckTimer?.(); + return this.#stuckReported; + } + + /** Arm the prepared timers once the executor starts the attempt. */ + started(handle: AttemptExecutionHandle): void { + if (handle.started === undefined) { + this.#arm?.(); + } else { + void Promise.resolve(handle.started).then( + () => this.#arm?.(), + () => undefined + ); + } + } +} diff --git a/js/src/runtime/claim-result.ts b/js/src/runtime/claim-result.ts new file mode 100644 index 000000000..83b657980 --- /dev/null +++ b/js/src/runtime/claim-result.ts @@ -0,0 +1,54 @@ +/** + * Checking the claim results that extensions hand to the runtime. + */ +import type { JobClaimResult } from "../driver.js"; +import type { JobRow } from "../job.js"; + +/** A claimed row an extension returned, before its fields are checked. */ +export interface ClaimedRow { + readonly job: unknown; + /** Whether the row couldn't be fully decoded, so it may lack fields. */ + readonly partial: boolean; +} + +/** + * Check the shape of a claim result an extension returned: a list of job + * rows, and any decode errors, each an `Error` keyed by the ID of one of + * those rows. Returns each row, in claim order, for the caller to check its + * fields, and a frozen copy of the result to keep once they pass. + */ +export function checkClaimResult( + result: unknown, + fail: (reason: string) => never +): { readonly checked: JobClaimResult; readonly rows: readonly ClaimedRow[] } { + if (typeof result !== "object" || result === null) fail("no claim result"); + const { decodeErrors, jobs } = result as Partial; + if (!Array.isArray(jobs)) fail("a result without a job list"); + if (decodeErrors !== undefined && !(decodeErrors instanceof Map)) { + fail("decode errors that aren't a Map"); + } + const errors = new Map(); + for (const [id, error] of decodeErrors ?? []) { + if (typeof id !== "bigint" || !(error instanceof Error)) { + fail("a decode error that isn't an Error keyed by a job ID"); + } + errors.set(id, error); + } + const ids = new Set(); + const rows = (jobs as readonly unknown[]).map((job): ClaimedRow => { + const id = (job as Partial | null | undefined)?.id; + ids.add(id); + return { job, partial: typeof id === "bigint" && errors.has(id) }; + }); + for (const id of errors.keys()) { + if (!ids.has(id)) + fail(`a decode error for job ${id}, which it didn't return`); + } + return { + checked: Object.freeze({ + decodeErrors: errors, + jobs: Object.freeze([...(jobs as readonly JobRow[])]), + }), + rows, + }; +} diff --git a/js/src/runtime/completion-command.ts b/js/src/runtime/completion-command.ts new file mode 100644 index 000000000..b9addbb6c --- /dev/null +++ b/js/src/runtime/completion-command.ts @@ -0,0 +1,281 @@ +/** + * Translation of attempt results into attempt-fenced completion commands, + * and River's default retry schedule. + */ +import type { JobCompletionCommand } from "../driver.js"; +import { ValidationError } from "../errors.js"; +import type { JobEventKind } from "../events.js"; +import type { WorkAttemptResult } from "../extensions.js"; +import { toMilliseconds } from "../internal/duration.js"; +import type { JobRow } from "../job.js"; +import { + exactJsonNumber, + isExactJsonNumber, + type ExactJsonNumber, + type JsonValue, +} from "../json.js"; +import { canonicalError, truncate } from "./failures.js"; + +/** + * The completion command persisting `result` for `job`'s current attempt. + * Retries and snoozes due within the scheduler interval are made available + * immediately, like River's near-future fast path. + */ +export function completionCommand( + job: JobRow, + attemptedBy: string, + result: WorkAttemptResult, + now: Temporal.Instant, + startedAt: Temporal.Instant, + nextRetryAt: Temporal.Instant, + schedulerIntervalMs: number +): JobCompletionCommand { + const withinSchedulerInterval = (at: Temporal.Instant) => + at.epochNanoseconds - now.epochNanoseconds <= + BigInt(schedulerIntervalMs) * 1_000_000n; + const explicitOutput = + result.outcome?.type === "complete" && result.outcome.output !== undefined; + const hasOutput = explicitOutput || "output" in result; + const output = explicitOutput + ? (result.outcome.output ?? null) + : (result.output ?? null); + if (result.cancel === true) { + return { + attempt: job.attempt, + attemptedBy, + error: canonicalError(result.error, startedAt), + finalizedAt: now, + id: job.id, + kind: "cancel", + metadata: result.metadata ?? {}, + output, + outputSet: hasOutput, + scheduledAt: null, + }; + } + if (result.status === "failed") { + const error = canonicalError(result.error, startedAt); + return { + attempt: job.attempt, + attemptedBy, + error, + finalizedAt: job.attempt >= job.maxAttempts ? now : null, + id: job.id, + kind: job.attempt >= job.maxAttempts ? "discard" : "retry", + ...(job.attempt < job.maxAttempts && withinSchedulerInterval(nextRetryAt) + ? { available: true } + : {}), + metadata: result.metadata ?? {}, + output, + outputSet: hasOutput, + scheduledAt: job.attempt >= job.maxAttempts ? null : nextRetryAt, + }; + } + switch (result.outcome?.type) { + case "cancel": + // Go records `JobCancelError.Error()`, which reads `` without a + // wrapped error, and never a trace. + return { + attempt: job.attempt, + attemptedBy, + error: { + at: startedAt, + error: truncate( + `JobCancelError: ${result.outcome.reason ?? ""}`, + 32_768 + ), + trace: "", + }, + finalizedAt: now, + id: job.id, + kind: "cancel", + metadata: result.metadata ?? {}, + output, + outputSet: hasOutput, + scheduledAt: null, + }; + case "discard": + return { + attempt: job.attempt, + attemptedBy, + error: + result.outcome.reason === undefined + ? null + : canonicalError(result.outcome.reason, startedAt), + finalizedAt: now, + id: job.id, + kind: "discard", + metadata: result.metadata ?? {}, + output, + outputSet: hasOutput, + scheduledAt: null, + }; + case "snooze": { + const scheduledAt = now.add({ + milliseconds: toMilliseconds( + "snooze duration", + result.outcome.duration, + { allowZero: true, error: ValidationError } + ), + }); + return { + attempt: job.attempt, + attemptedBy, + ...(withinSchedulerInterval(scheduledAt) ? { available: true } : {}), + error: null, + finalizedAt: null, + id: job.id, + kind: "snooze", + metadata: { + ...(result.metadata ?? {}), + snoozes: nextSnoozeCount(job.metadata.snoozes), + }, + output, + outputSet: hasOutput, + scheduledAt, + }; + } + case "complete": + return { + attempt: job.attempt, + attemptedBy, + error: null, + finalizedAt: now, + id: job.id, + kind: "complete", + metadata: result.metadata ?? {}, + output, + outputSet: hasOutput, + scheduledAt: null, + }; + case undefined: + return { + attempt: job.attempt, + attemptedBy, + error: null, + finalizedAt: now, + id: job.id, + kind: "complete", + metadata: result.metadata ?? {}, + output, + outputSet: hasOutput, + scheduledAt: null, + }; + } +} + +/** + * The snooze count after one more snooze, like River for Go's executor, + * which writes `int(gjson.GetBytes(metadata, "snoozes").Int()) + 1`: `true` + * counts as 1, a decimal integer string as its value, a number truncated + * toward zero, and anything else as 0, with int64 wraparound. + */ +function nextSnoozeCount( + value: JsonValue | undefined +): ExactJsonNumber | number { + const next = BigInt.asIntN(64, gjsonInt(value) + 1n); + return next >= BigInt(Number.MIN_SAFE_INTEGER) && + next <= BigInt(Number.MAX_SAFE_INTEGER) + ? Number(next) + : exactJsonNumber(next.toString(10)); +} + +/** gjson's `Result.Int()` for a JSON value. */ +function gjsonInt(value: JsonValue | undefined): bigint { + if (value === true) return 1n; + if (typeof value === "string") return gjsonParseInt(value) ?? 0n; + if (typeof value !== "number" && !isExactJsonNumber(value)) return 0n; + const raw = typeof value === "number" ? String(value) : value.rawJSON; + const float = typeof value === "number" ? value : Number(value.rawJSON); + // gjson's safeInt, then its parse of the raw integer text, then Go's + // float conversion, which saturates out of range on arm64. + if (Math.abs(float) <= Number.MAX_SAFE_INTEGER) { + return BigInt(Math.trunc(float)); + } + const parsed = gjsonParseInt(raw); + if (parsed !== undefined) return parsed; + if (Number.isNaN(float)) return 0n; + if (float >= 2 ** 63) return BigInt.asIntN(64, (1n << 63n) - 1n); + if (float <= -(2 ** 63)) return -(1n << 63n); + return BigInt(Math.trunc(float)); +} + +/** + * gjson's `parseInt`: an optional `-` then decimal digits only, wrapping + * like int64 arithmetic; undefined for anything else. + */ +function gjsonParseInt(text: string): bigint | undefined { + const negative = text.startsWith("-"); + const digits = negative ? text.slice(1) : text; + if (!/^[0-9]+$/.test(digits)) return undefined; + let result = 0n; + for (const digit of digits) { + result = BigInt.asIntN(64, result * 10n + BigInt(digit)); + } + return negative ? BigInt.asIntN(64, -result) : result; +} + +/** The event announcing a committed completion, by the job's new state. */ +export function completionEventKind( + requested: JobCompletionCommand["kind"], + job: JobRow +): JobEventKind { + switch (job.state) { + case "cancelled": + return "job_cancelled"; + case "completed": + return "job_completed"; + case "discarded": + case "retryable": + return "job_failed"; + case "scheduled": + return "job_snoozed"; + case "available": + if (requested === "interrupt") return "job_interrupted"; + if (requested === "snooze") return "job_snoozed"; + return "job_failed"; + case "pending": + case "running": + return "job_race"; + } +} + +/** Go's maximum `time.Duration`, about 292 years. */ +const MAX_RETRY_DURATION_NANOSECONDS = (1n << 63n) - 1n; + +/** Go's maximum `time.Duration`, in seconds. */ +const MAX_RETRY_DURATION_SECONDS = + Number(MAX_RETRY_DURATION_NANOSECONDS) / 1_000_000_000; + +/** @internal Exact default scheduling shared with deterministic tests. */ +export function defaultNextRetry( + job: Readonly, + now: Temporal.Instant, + random: () => number = Math.random +): Temporal.Instant { + const errorCount = job.errors.length + 1; + const baseSeconds = Math.min(errorCount ** 4, MAX_RETRY_DURATION_SECONDS); + const randomValue = random(); + const boundedRandom = + Number.isFinite(randomValue) && randomValue >= 0 && randomValue < 1 + ? randomValue + : 0.5; + const seconds = + baseSeconds === MAX_RETRY_DURATION_SECONDS + ? baseSeconds + : Math.min( + baseSeconds + baseSeconds * (boundedRandom * 0.2 - 0.1), + MAX_RETRY_DURATION_SECONDS + ); + // Like Go, a capped delay is exactly the maximum duration: the capped + // seconds are a float of one nanosecond more. + if (seconds >= MAX_RETRY_DURATION_SECONDS) { + return Temporal.Instant.fromEpochNanoseconds( + now.epochNanoseconds + MAX_RETRY_DURATION_NANOSECONDS + ); + } + const durationNanoseconds = BigInt(Math.trunc(seconds * 1_000_000_000)); + return Temporal.Instant.fromEpochNanoseconds( + now.epochNanoseconds + durationNanoseconds + ); +} diff --git a/js/src/runtime/completion-pipeline.ts b/js/src/runtime/completion-pipeline.ts new file mode 100644 index 000000000..ec11c9320 --- /dev/null +++ b/js/src/runtime/completion-pipeline.ts @@ -0,0 +1,595 @@ +/** + * The completion pipeline: batches attempt completions, persists them with + * River's bounded retry policy, requeues or drops batches that keep failing, + * and emits each job's event once its transition commits. + */ +import type { JobCompletionCommand, JobCompletionResult } from "../driver.js"; +import { jobCompletionKey } from "../driver.js"; +import { + DatabaseOperationError, + isRetryableError, + JobCancelledError, + JobTimeoutError, +} from "../errors.js"; +import { jobEvent } from "../events.js"; +import type { WorkAttemptResult } from "../extensions.js"; +import { LinkedAbortSignal, raceWithAbort } from "../internal/abort.js"; +import { exponentialBackoffMs } from "../internal/backoff.js"; +import { + CompletionBatcher, + CompletionDroppedError, +} from "../internal/completion-batcher.js"; +import type { JobRow } from "../job.js"; +import { + completionCommand, + completionEventKind, + defaultNextRetry, +} from "./completion-command.js"; +import type { RuntimeContext } from "./context.js"; +import { describeError } from "./failures.js"; +import type { RetryPolicy } from "./settings.js"; + +/** Persistence attempts per completion batch before requeueing or dropping. */ +const COMPLETION_ATTEMPTS = 3; +/** Retry delays between completion attempts: 1 s, 2 s, then 4 s. */ +const COMPLETION_BACKOFF = Object.freeze({ baseMs: 1_000, maxMs: 4_000 }); +/** + * Bound on one completion query, matching River's hot-operation timeout. A + * lock wait or dead connection cannot hold completion capacity indefinitely. + */ +const COMPLETION_TIMEOUT_MS = 10_000; + +/** Configuration for a {@link CompletionPipeline}. */ +export interface CompletionPipelineOptions { + /** Completions per persistence query; twice this many may be pending. */ + readonly batchSize: number; + /** + * The most batches persisted at once, or undefined for no limit beyond + * the batcher's own. + */ + readonly concurrency?: number | undefined; + /** How long a partial batch waits for more completions. */ + readonly flushIntervalMs: number; + readonly retryPolicy: RetryPolicy | undefined; + /** Retries and snoozes due within this interval are made available now. */ + readonly schedulerIntervalMs: number; +} + +/** + * How a caller follows one completion it hands over. Tracked completions + * resolve only once persisted, and reject when they can't be. + */ +export interface CompletionTracking { + /** The completer accepted it and now retries it until it settles. */ + accepted?(): void; + /** It was persisted, applied or stale, before its event is delivered. */ + persisted?(): void; +} + +/** Owns the completion batcher and the work that follows each commit. */ +export class CompletionPipeline { + readonly #batcher: CompletionBatcher< + JobCompletionCommand, + JobCompletionResult + >; + readonly #context: RuntimeContext; + #draining = false; + /** Permits for concurrent batches, when their number is limited. */ + readonly #permits: Semaphore | undefined; + readonly #retryPolicy: RetryPolicy | undefined; + readonly #schedulerIntervalMs: number; + /** Post-commit work (events) not yet finished. */ + readonly #tasks = new Set>(); + + constructor(context: RuntimeContext, options: CompletionPipelineOptions) { + this.#context = context; + this.#permits = + options.concurrency === undefined + ? undefined + : new Semaphore(options.concurrency); + this.#retryPolicy = options.retryPolicy; + this.#schedulerIntervalMs = options.schedulerIntervalMs; + this.#batcher = new CompletionBatcher({ + batchSize: options.batchSize, + flushIntervalMs: options.flushIntervalMs, + maxPendingItems: options.batchSize * 2, + onDrop: (error, count) => this.#reportDroppedCompletions(error, count), + onPersistFailure: (error, count) => + this.#completionFailureAction(error, count), + persist: (commands, signal) => this.#persistCompletions(commands, signal), + }); + } + + /** Persistence queries currently running. */ + get inFlightQueries(): number { + return this.#batcher.inFlightQueries; + } + + /** Completions that may be pending before attempts wait for capacity. */ + get maxPendingItems(): number { + return this.#batcher.maxPendingItems; + } + + /** Completions accepted but not yet persisted. */ + get pendingItems(): number { + return this.#batcher.pendingItems; + } + + /** Fail every pending completion with `reason`, as the runtime fails. */ + abort(reason: unknown): void { + this.#batcher.abort(reason); + } + + /** + * Flush everything pending, then wait for post-commit work. Rethrows a + * flush failure only after that work finishes. + */ + async close(): Promise { + let closeFailure: { readonly error: unknown } | undefined; + try { + await this.#batcher.close(); + } catch (error: unknown) { + closeFailure = { error }; + } + while (this.#tasks.size > 0) { + await Promise.allSettled(this.#tasks); + } + if (closeFailure !== undefined) throw closeFailure.error; + } + + /** + * Stop requeueing failed batches so a stop during a database outage + * finishes: the first persistent failure abandons the rest to the rescuer, + * exactly like River's other runtimes. + */ + drain(): void { + this.#draining = true; + this.#batcher.drain(); + } + + /** + * When a failed attempt runs next, like River for Go's executor: the + * worker's retry policy, else the client's, else River's default schedule + * when neither gives a time or the time is in the past. A policy that + * throws or returns something other than an instant gives no time. + */ + nextRetryAt( + job: JobRow, + now: Temporal.Instant, + workerRetryPolicy?: RetryPolicy + ): Temporal.Instant { + const scheduledAt = + policyRetryAt(workerRetryPolicy, job, now) ?? + policyRetryAt(this.#retryPolicy, job, now); + if ( + scheduledAt !== undefined && + Temporal.Instant.compare(scheduledAt, now) >= 0 + ) { + return scheduledAt; + } + return defaultNextRetry(job, now, this.#context.random); + } + + /** + * Persist an attempt stopped by an abort. Like River's executor, attempt + * errors are stamped with the attempt's start time and a remote + * cancellation records no trace. + */ + async persistAbort( + row: JobRow, + reason: unknown, + startedAt: Temporal.Instant, + result?: WorkAttemptResult, + tracking?: CompletionTracking, + workerRetryPolicy?: RetryPolicy + ): Promise { + const hasOutput = result !== undefined && "output" in result; + const output = result?.output ?? null; + if (reason instanceof JobCancelledError) { + await this.#persistCommand( + row, + { + attempt: row.attempt, + attemptedBy: this.#context.clientId, + error: { + at: startedAt, + error: "JobCancelError: job cancelled remotely", + trace: "", + }, + finalizedAt: this.#context.now(), + id: row.id, + kind: "cancel", + metadata: result?.metadata ?? {}, + output, + outputSet: hasOutput, + scheduledAt: null, + }, + undefined, + tracking + ); + return; + } + if (reason instanceof JobTimeoutError) { + await this.persistResult( + row, + { + error: reason, + ...(result?.metadata === undefined + ? {} + : { metadata: result.metadata }), + ...(hasOutput ? { output } : {}), + status: "failed", + }, + startedAt, + tracking, + workerRetryPolicy + ); + return; + } + await this.#persistCommand( + row, + { + attempt: row.attempt, + attemptedBy: this.#context.clientId, + error: null, + finalizedAt: null, + id: row.id, + kind: "interrupt", + metadata: result?.metadata ?? {}, + output, + outputSet: hasOutput, + scheduledAt: this.#context.now(), + }, + undefined, + tracking + ); + } + + /** + * Persist a settled attempt's result. The returned promise resolves once + * the completion is accepted for batching, or, with `tracking`, once it is + * persisted and its event delivered. + */ + async persistResult( + row: JobRow, + result: WorkAttemptResult, + startedAt: Temporal.Instant, + tracking?: CompletionTracking, + workerRetryPolicy?: RetryPolicy + ): Promise { + const now = this.#context.now(); + const nextRetryAt = + result.status === "failed" + ? this.nextRetryAt(row, now, workerRetryPolicy) + : now; + const command = completionCommand( + row, + this.#context.clientId, + result, + now, + startedAt, + nextRetryAt, + this.#schedulerIntervalMs + ); + await this.#persistCommand(row, command, result.error, tracking); + } + + #completionFailureAction(error: unknown, count: number): "drop" | "requeue" { + if (!isRetryableError(error)) return "drop"; + this.#context.logger.error( + "River completion persistence failed repeatedly; requeueing batch", + { error: describeError(error), jobs: count } + ); + this.#context.emitMetric({ count, name: "job_completion_requeued" }); + return "requeue"; + } + + async #persistCommand( + row: JobRow, + command: JobCompletionCommand, + localError?: unknown, + tracking?: CompletionTracking + ): Promise { + const awaitPersistence = tracking !== undefined; + const submission = this.#batcher.submit( + jobCompletionKey(command), + command, + command.id.toString(10) + ); + try { + await submission.accepted; + } catch (error: unknown) { + // A completion abandoned while waiting for capacity was reported when + // it was dropped; its row stays running until the rescuer recovers it. + if (!(error instanceof CompletionDroppedError)) throw error; + if (awaitPersistence) throw error.cause; + return; + } + tracking?.accepted?.(); + const persistence = this.#context.guard( + submission.result + .then( + async (completion) => { + tracking?.persisted?.(); + if (completion.job === null) { + await this.#context.emit({ + at: this.#context.now(), + job: row, + kind: "job_race", + }); + return; + } + await this.#context.emit( + jobEvent( + completionEventKind(command.kind, completion.job), + this.#context.now(), + completion.job, + localError + ) + ); + }, + (error: unknown) => { + if (!(error instanceof CompletionDroppedError)) throw error; + if (awaitPersistence) throw error.cause; + } + ) + .finally(submission.acknowledge) + ); + this.#trackTask(persistence); + if (awaitPersistence) await persistence; + } + + /** + * Persist one completion batch with River's bounded retry policy: each + * attempt is limited to {@link COMPLETION_TIMEOUT_MS}, and up to + * {@link COMPLETION_ATTEMPTS} attempts run with exponential backoff before + * the batcher requeues or drops the batch. + */ + async #persistCompletions( + commands: readonly JobCompletionCommand[], + batcherSignal: AbortSignal + ): Promise> { + for (let attempt = 1; ; attempt++) { + batcherSignal.throwIfAborted(); + // The permit comes before a database connection, so a batch never + // holds a connection while it waits for a permit, and before the + // attempt's deadline, so waiting spends neither it nor a retry. + const release = await this.#permits?.acquire(batcherSignal); + const timeout = this.#context.timer.timeout( + COMPLETION_TIMEOUT_MS, + () => + new DatabaseOperationError( + `River completion persistence timed out after ${COMPLETION_TIMEOUT_MS} ms`, + { + backend: this.#context.backend, + operation: "jobCompleteMany", + retryable: true, + } + ) + ); + const link = new LinkedAbortSignal([batcherSignal, timeout.signal]); + const signal = link.signal; + try { + const operation = (async () => + this.#context.operations.complete(this.#context.driver, commands, { + signal, + }))(); + // The query keeps its signal until it settles, even past a timeout. + const unlink = () => { + link[Symbol.dispose](); + }; + void operation.then(unlink, unlink); + // The permit bounds queries, not attempts: one that outlives its + // deadline keeps the permit until it settles. + if (release !== undefined) void operation.then(release, release); + const results = await raceWithAbort(operation, signal); + const byKey = new Map(); + for (const result of results) byKey.set(result.key, result); + // An earlier attempt that timed out may still have committed, which + // leaves nothing for this one to update. Report such a completion as + // what it was, not as a race with another process. + if (attempt > 1) { + await this.#recoverLateCommits(commands, byKey, signal); + } + return byKey; + } catch (thrown: unknown) { + if (batcherSignal.aborted) throw thrown; + const error: unknown = timeout.signal.aborted + ? timeout.signal.reason + : thrown; + const lastAttempt = attempt >= COMPLETION_ATTEMPTS; + // Mirror River's completer: back off after every failed attempt, + // including the last one before a requeue, but never delay a + // shutdown that will abandon the batch anyway. + const delayMs = + lastAttempt && this.#draining + ? 0 + : exponentialBackoffMs( + attempt, + COMPLETION_BACKOFF, + this.#context.random + ); + this.#context.logger.warn( + "River completion persistence attempt failed", + { + attempt, + attempts: COMPLETION_ATTEMPTS, + delayMs, + error: describeError(error), + jobs: commands.length, + retryable: isRetryableError(error), + } + ); + if (delayMs > 0) + await this.#context.timer.delay(delayMs, batcherSignal); + if (lastAttempt) throw error; + } finally { + timeout.dispose(); + } + } + } + + /** + * Replace each unapplied result in `byKey` with the job's row when the row + * shows that `commands`' own earlier, timed-out attempt committed the + * transition. + */ + async #recoverLateCommits( + commands: readonly JobCompletionCommand[], + byKey: Map, + signal: AbortSignal + ): Promise { + const unapplied = commands.filter( + (command) => byKey.get(jobCompletionKey(command))?.job === null + ); + if (unapplied.length === 0) return; + let rows: readonly JobRow[]; + try { + // One read for the whole batch, bounded like the attempt itself. + rows = await raceWithAbort( + this.#context.driver.jobList({ + after: null, + ids: unapplied.map(({ id }) => id), + kinds: [], + limit: unapplied.length, + metadata: null, + priorities: [], + queues: [], + sortDirection: "asc", + sortField: "id", + states: [], + tagsAll: [], + tagsAny: [], + }), + signal + ); + } catch { + // Without the rows, the race reports stand. + return; + } + const byId = new Map(rows.map((row) => [row.id, row])); + for (const command of unapplied) { + const row = byId.get(command.id); + if (row !== undefined && committedBy(command, row)) { + const key = jobCompletionKey(command); + byKey.set(key, { job: row, key, status: "applied" }); + } + } + } + + #reportDroppedCompletions(error: unknown, count: number): void { + this.#context.logger.error( + "River dropped completions after persistence failed; the jobs stay running until the rescuer recovers them", + { error: describeError(error), jobs: count } + ); + this.#context.emitMetric({ count, name: "job_completion_dropped" }); + } + + #trackTask(task: Promise): void { + this.#tasks.add(task); + void task.then( + () => this.#tasks.delete(task), + () => this.#tasks.delete(task) + ); + } +} + +/** + * Whether `row` holds the transition `command` persists, so an earlier attempt + * of the same completion committed it: the attempt, client, state, and time + * all match, which another process's completion, cancellation, rescue, or + * claim can't reproduce. + */ +function committedBy(command: JobCompletionCommand, row: JobRow): boolean { + if (row.attemptedBy.at(-1) !== command.attemptedBy) return false; + const sameTime = ( + stored: Temporal.Instant | null, + expected: Temporal.Instant | null + ) => + stored !== null && + expected !== null && + stored.epochMilliseconds === expected.epochMilliseconds; + const finalized = (state: JobRow["state"]) => + row.attempt === command.attempt && + row.state === state && + sameTime(row.finalizedAt, command.finalizedAt); + const rescheduled = (state: JobRow["state"], attempt: number) => + row.attempt === attempt && + row.state === (command.available === true ? "available" : state) && + sameTime(row.scheduledAt, command.scheduledAt); + switch (command.kind) { + case "cancel": + return finalized("cancelled"); + case "complete": + return finalized("completed"); + case "discard": + return finalized("discarded"); + case "interrupt": + return rescheduled("available", Math.max(command.attempt - 1, 0)); + case "retry": + return ( + rescheduled("retryable", command.attempt) && + row.errors.at(-1)?.attempt === command.attempt && + row.errors.at(-1)?.error === command.error?.error + ); + case "snooze": + return rescheduled("scheduled", Math.max(command.attempt - 1, 0)); + } +} + +/** A counting semaphore whose waits end when their signal aborts. */ +class Semaphore { + #available: number; + readonly #waiters: { readonly grant: () => void }[] = []; + + constructor(permits: number) { + this.#available = permits; + } + + async acquire(signal: AbortSignal): Promise<() => void> { + signal.throwIfAborted(); + if (this.#available > 0) { + this.#available--; + } else { + await new Promise((resolve, reject) => { + const waiter = { + grant: () => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, + }; + const onAbort = () => { + const index = this.#waiters.indexOf(waiter); + if (index !== -1) this.#waiters.splice(index, 1); + reject(signal.reason as Error); + }; + signal.addEventListener("abort", onAbort, { once: true }); + this.#waiters.push(waiter); + }); + } + let released = false; + return () => { + if (released) return; + released = true; + const next = this.#waiters.shift(); + if (next === undefined) this.#available++; + else next.grant(); + }; + } +} + +/** A retry policy's time, or undefined when it throws or gives no instant. */ +function policyRetryAt( + policy: RetryPolicy | undefined, + job: JobRow, + now: Temporal.Instant +): Temporal.Instant | undefined { + if (policy === undefined) return undefined; + try { + const scheduledAt: unknown = policy(job, now); + return scheduledAt instanceof Temporal.Instant ? scheduledAt : undefined; + } catch { + // Like a zero time from Go's NextRetry, a failed policy gives no time. + return undefined; + } +} diff --git a/js/src/runtime/context.ts b/js/src/runtime/context.ts new file mode 100644 index 000000000..e7949bedd --- /dev/null +++ b/js/src/runtime/context.ts @@ -0,0 +1,104 @@ +/** + * What the runtime's collaborators share: the client and driver they work + * for, its clock and timers, event and metric delivery, and the supervision + * hooks through which a background task fails the runtime. + */ +import type { Client } from "../client.js"; +import type { RuntimeDriver } from "../driver.js"; +import { isRetryableError } from "../errors.js"; +import type { RiverEvent } from "../events.js"; +import { abortableDelay } from "../internal/abort.js"; +import { + BACKGROUND_BACKOFF, + exponentialBackoffMs, + type RuntimeTimer, +} from "../internal/backoff.js"; +import type { JsonValue } from "../json.js"; +import type { InternalLogger } from "../logger.js"; +import type { RiverMetric } from "../metrics.js"; +import type { PilotOperations } from "./pilot-operations.js"; +import { describeError, isPermanentRuntimeError } from "./failures.js"; + +/** Services `RuntimeController` provides to each of its collaborators. */ +export interface RuntimeContext { + /** The backend's name, for errors. */ + readonly backend: string; + /** Aborts once the runtime stops claiming work, on stop or failure. */ + readonly claimSignal: AbortSignal; + readonly client: Client; + readonly clientId: string; + readonly driver: RuntimeDriver; + readonly logger: InternalLogger; + /** Operations the client's pilot may intercept. */ + readonly operations: PilotOperations; + readonly random: () => number; + /** Aborts when the runtime abandons its work: a cancelling stop or failure. */ + readonly runSignal: AbortSignal; + readonly timer: RuntimeTimer; + /** Deliver an event to subscribers and `onEvent` hooks. */ + emit(event: RiverEvent): Promise; + /** Publish a metric to diagnostics channels and `onMetric` hooks. */ + emitMetric(metric: RiverMetric): void; + /** Fail the runtime: abort its work and reject `completed`. */ + fail(error: unknown): void; + /** Fail the runtime if `task` rejects, rethrowing the rejection. */ + guard(task: Promise): Promise; + now(): Temporal.Instant; + /** Add `task` to the background work a stop waits for. */ + trackTask(task: Promise): void; +} + +/** + * Log a failed background database operation, then wait with capped, + * jittered exponential backoff before the caller retries. Configuration and + * capability errors are rethrown because retrying cannot fix them. + */ +export async function backOffAfterFailure( + context: RuntimeContext, + task: string, + error: unknown, + failures: number, + signal: AbortSignal, + attributes: Readonly> = {} +): Promise { + if (isPermanentRuntimeError(error)) throw error; + const retryable = isRetryableError(error); + const delayMs = exponentialBackoffMs( + failures, + BACKGROUND_BACKOFF, + context.random + ); + context.logger[retryable ? "warn" : "error"]( + `River ${task} failed; retrying after backoff`, + { + ...attributes, + attempt: failures, + delayMs, + error: describeError(error), + retryable, + } + ); + await context.timer.delay(delayMs, signal); +} + +/** + * Run a foreground database operation, retrying retryable failures after + * 10 ms doubling to 1 s until it succeeds or `signal` aborts. + */ +export async function retryDatabaseOperation( + operation: () => PromiseLike | T, + signal: AbortSignal +): Promise { + let failures = 0; + while (true) { + if (signal.aborted) throw signal.reason; + try { + return await operation(); + } catch (error: unknown) { + if (!isRetryableError(error)) throw error; + const delayMs = Math.min(1_000, 10 * 2 ** Math.min(failures, 7)); + failures += 1; + await abortableDelay(delayMs, signal); + } + } +} diff --git a/js/src/runtime/failures.ts b/js/src/runtime/failures.ts new file mode 100644 index 000000000..5da57c21a --- /dev/null +++ b/js/src/runtime/failures.ts @@ -0,0 +1,126 @@ +/** + * Encoding and classification of errors raised while running jobs. + */ +import { + BackendMismatchError, + ConfigurationError, + ExtensionError, + RiverError, + UnsupportedCapabilityError, +} from "../errors.js"; + +/** + * Encode a thrown value as River's attempt error record. + * + * Go records a stack trace only for panics, never for returned errors. The + * JavaScript analog of a panic is a runtime fault surfaced as one of the + * native error classes, such as the `TypeError` from reading a property of + * `undefined` or a `RangeError`; those keep their (bounded) stack. Errors a + * handler throws deliberately, including subclasses of `Error`, are ordinary + * failures and record an empty trace, which also keeps rows small. + */ +export function canonicalError( + thrown: unknown, + at: Temporal.Instant +): { at: Temporal.Instant; error: string; trace: string } { + if (thrown instanceof Error) { + return { + at, + error: truncate(thrown.message || thrown.name, 32_768), + trace: isRuntimeFault(thrown) + ? truncate(thrown.stack ?? thrown.name, 32_768) + : "", + }; + } + let value: string; + try { + value = typeof thrown === "string" ? thrown : String(thrown); + } catch { + value = "non-Error value"; + } + return { at, error: truncate(value, 32_768), trace: "" }; +} + +/** Native error classes whose instances signal a JavaScript runtime fault. */ +const RUNTIME_FAULT_PROTOTYPES: readonly object[] = [ + EvalError.prototype, + RangeError.prototype, + ReferenceError.prototype, + SyntaxError.prototype, + TypeError.prototype, + URIError.prototype, +]; + +/** Whether an error is a JavaScript runtime fault, River's panic analog. */ +export function isRuntimeFault(error: unknown): boolean { + if (typeof error !== "object" || error === null) return false; + const prototype: unknown = Object.getPrototypeOf(error); + return ( + prototype !== null && + typeof prototype === "object" && + RUNTIME_FAULT_PROTOTYPES.includes(prototype) + ); +} + +/** + * Whether a background failure cannot be fixed by retrying: the backend is + * misconfigured or lacks a required capability. Everything else a backend + * throws is operational and retried with backoff. + */ +export function isPermanentRuntimeError(error: unknown): boolean { + return ( + error instanceof ConfigurationError || + error instanceof UnsupportedCapabilityError || + error instanceof BackendMismatchError + ); +} + +/** + * One-line operator description of an error and its `cause` chain, including + * backend codes such as a PostgreSQL SQLSTATE, for background-failure logs. + */ +export function describeError(error: unknown): string { + const parts: string[] = []; + const seen = new Set(); + let current: unknown = error; + for (let depth = 0; depth < 4 && current !== undefined; depth++) { + if (current === null || seen.has(current)) break; + seen.add(current); + if (current instanceof Error) { + // River error codes are categories; backend codes such as SQLSTATEs + // are what an operator needs. + const code = + current instanceof RiverError + ? undefined + : (current as { readonly code?: unknown }).code; + const message = current.message || current.name; + parts.push( + typeof code === "string" && code !== "" && !message.includes(code) + ? `${message} (${code})` + : message + ); + current = current.cause; + } else { + parts.push(canonicalError(current, Temporal.Now.instant()).error); + break; + } + } + return truncate(parts.join(": "), 4_096); +} + +/** Cut `value` to at most `length` UTF-16 code units. */ +export function truncate(value: string, length: number): string { + return value.length <= length ? value : value.slice(0, length); +} + +/** Run a lifecycle hook, wrapping any failure in an `ExtensionError`. */ +export async function invokeHook( + name: string, + hook: () => unknown +): Promise { + try { + await hook(); + } catch (cause: unknown) { + throw new ExtensionError(`River ${name} hook failed`, { cause }); + } +} diff --git a/js/src/runtime/notification-payloads.ts b/js/src/runtime/notification-payloads.ts new file mode 100644 index 000000000..080b64cd2 --- /dev/null +++ b/js/src/runtime/notification-payloads.ts @@ -0,0 +1,69 @@ +/** + * Parsing of runtime notification payloads. Notifications are hints, so + * malformed payloads are ignored rather than reported. + */ +export function notificationQueue(payload: string): string | null { + try { + const value: unknown = JSON.parse(payload); + if ( + value !== null && + typeof value === "object" && + "queue" in value && + typeof value.queue === "string" + ) { + return value.queue; + } + } catch { + // Notifications are hints; malformed payloads are ignored. + } + return null; +} + +/** The job ID a control notification asks to cancel, if any. */ +export function notificationCancellation(payload: string): bigint | null { + if (!/"action"\s*:\s*"cancel"/.test(payload)) return null; + const match = /"job_id"\s*:\s*(?:"([0-9]+)"|([0-9]+))/.exec(payload); + const value = match?.[1] ?? match?.[2]; + return value === undefined ? null : BigInt(value); +} + +/** + * The leader ID of a leadership notification announcing that a leader + * resigned, like River for Go's `{"action": "resigned", "leader_id": ...}`. + */ +export function notificationLeaderResigned(payload: string): string | null { + try { + const value: unknown = JSON.parse(payload); + if ( + value !== null && + typeof value === "object" && + "action" in value && + value.action === "resigned" && + "leader_id" in value && + typeof value.leader_id === "string" + ) { + return value.leader_id; + } + } catch { + // Notifications are hints; malformed payloads are ignored. + } + return null; +} + +/** Whether a leadership notification asks the current leader to resign. */ +export function notificationRequestsLeadershipResignation( + payload: string +): boolean { + try { + const value: unknown = JSON.parse(payload); + return ( + value !== null && + typeof value === "object" && + "action" in value && + value.action === "request_resign" + ); + } catch { + // Notifications are hints; malformed payloads are ignored. + return false; + } +} diff --git a/js/src/runtime/notification-pump.ts b/js/src/runtime/notification-pump.ts new file mode 100644 index 000000000..eae6930b1 --- /dev/null +++ b/js/src/runtime/notification-pump.ts @@ -0,0 +1,241 @@ +/** + * The notification pump: supervises the backend's notification streams, + * waking queues on inserts, applying queue controls and cancellations, and + * forwarding leadership resignation requests. Notifications are hints; + * polling remains the durable path. + */ +import type { RuntimeNotification } from "../driver.js"; +import { LifecycleError } from "../errors.js"; +import type { AttemptRunner } from "./attempt-runner.js"; +import type { RuntimeContext } from "./context.js"; +import { backOffAfterFailure } from "./context.js"; +import { describeError } from "./failures.js"; +import { + notificationCancellation, + notificationLeaderResigned, + notificationQueue, + notificationRequestsLeadershipResignation, +} from "./notification-payloads.js"; +import type { QueueProducer } from "./queue-producer.js"; + +/** Configuration for a {@link NotificationPump}. */ +export interface NotificationPumpOptions { + /** Another client resigned leadership. */ + readonly leaderResigned: () => void; + readonly producer: QueueProducer; + /** Resign leadership when another client asks, if this client leads. */ + readonly resignLeadership: () => PromiseLike | undefined; + readonly runner: AttemptRunner; +} + +/** Subscribes to backend notifications for the runtime's lifetime. */ +export class NotificationPump { + readonly #context: RuntimeContext; + readonly #leaderResigned: () => void; + readonly #producer: QueueProducer; + readonly #resignLeadership: () => PromiseLike | undefined; + readonly #runner: AttemptRunner; + + constructor(context: RuntimeContext, options: NotificationPumpOptions) { + this.#context = context; + this.#leaderResigned = options.leaderResigned; + this.#producer = options.producer; + this.#resignLeadership = options.resignLeadership; + this.#runner = options.runner; + } + + /** + * Start the supported streams as tracked background tasks, unless the + * runtime relies on polling alone (`listen` false). Resolves once the + * runtime notification stream first becomes ready, so no insert made after + * startup can be missed. Like River for Go's notifier, which fails its + * client's start when it can't connect and listen, rejects with the + * stream's error if it fails or ends before then; later failures are + * logged and resubscribed with backoff. + */ + async start(listen: boolean): Promise { + if (!listen) return; + const driver = this.#context.driver; + if (driver.runtimeNotificationSubscribe !== undefined) { + const ready = Promise.withResolvers(); + const task = this.#context.guard( + this.#notificationLoop(() => ready.resolve(undefined)) + ); + this.#context.trackTask(task); + await Promise.race([ + ready.promise, + task.then(() => { + throw new LifecycleError( + "runtime notification stream ended before becoming ready" + ); + }), + ]); + } else if (driver.jobCancellationSubscribe !== undefined) { + this.#context.trackTask( + this.#context.guard(this.#remoteCancellationLoop()) + ); + } + } + + async #handleNotification(notification: RuntimeNotification): Promise { + switch (notification.topic) { + case "insert": { + const queue = notificationQueue(notification.payload); + if (queue === null) { + this.#producer.wakeAll(); + } else { + this.#producer.wake(queue); + } + return; + } + case "control": { + const cancellation = notificationCancellation(notification.payload); + if (cancellation !== null) { + this.#runner.cancelAttempt(cancellation, this.#context.clientId); + } + const queueName = notificationQueue(notification.payload); + if (queueName === "*") { + await this.#producer.refreshAll(); + } else if (queueName !== null) { + await this.#producer.refresh(queueName); + } + return; + } + case "leadership": { + if (notificationRequestsLeadershipResignation(notification.payload)) { + await this.#resignLeadership(); + return; + } + // Like River for Go's elector, a client ignores its own resignation. + const leaderId = notificationLeaderResigned(notification.payload); + if (leaderId !== null && leaderId !== this.#context.clientId) { + this.#leaderResigned(); + } + return; + } + } + } + + async #notificationLoop(ready: () => void): Promise { + const subscribe = this.#context.driver.runtimeNotificationSubscribe?.bind( + this.#context.driver + ); + if (subscribe === undefined) return; + let subscribed = false; + await this.#superviseSubscription( + "runtime notification stream", + (signal, streamReady) => + subscribe(["control", "insert", "leadership"], signal, streamReady), + (notification) => this.#handleNotification(notification), + () => { + if (subscribed) { + this.#recoverMissedNotifications(); + } else { + subscribed = true; + ready(); + } + }, + () => subscribed + ); + } + + /** + * Notifications are hints and are lost while a stream reconnects. Poll every + * queue and its persisted controls immediately so nothing waits for the + * next polling interval, and check running attempts for cancellations. + */ + #recoverMissedNotifications(): void { + this.#producer.wakeAll(); + this.#producer.wakeControl(); + this.#recoverMissedCancellations(); + } + + /** + * Cancel attempts whose cancellation notice was lost while a stream + * reconnected. A failed check is logged; the attempt then finishes as if + * its notice never arrived, as it would before this recovery. + */ + #recoverMissedCancellations(): void { + const recovery = this.#runner + .recoverCancellations(this.#context.claimSignal) + .catch((error: unknown) => { + this.#context.logger.warn( + "River could not check running jobs for missed cancellations", + { error: describeError(error) } + ); + }); + this.#context.trackTask(recovery); + } + + async #remoteCancellationLoop(): Promise { + const subscribe = this.#context.driver.jobCancellationSubscribe?.bind( + this.#context.driver + ); + if (subscribe === undefined) return; + let subscribed = false; + await this.#superviseSubscription( + "remote cancellation stream", + (signal, ready) => subscribe(this.#context.clientId, signal, ready), + (notice) => { + this.#runner.cancelAttempt(notice.id, notice.attemptedBy); + }, + () => { + if (subscribed) { + this.#recoverMissedCancellations(); + } else { + subscribed = true; + } + } + ); + } + + /** + * Supervise a backend notification stream. A stream that fails or ends is + * logged and resubscribed with capped backoff; configuration errors remain + * fatal, and so is any failure while `resubscribes` returns false. + * `connected` runs after every (re)subscription becomes ready. + */ + async #superviseSubscription( + name: string, + subscribe: (signal: AbortSignal, ready: () => void) => AsyncIterable, + handle: (item: T) => Promise | void, + connected: () => void, + resubscribes: () => boolean = () => true + ): Promise { + const signal = this.#context.claimSignal; + let failures = 0; + while (!signal.aborted) { + let failure: unknown; + try { + for await (const item of subscribe(signal, () => { + failures = 0; + connected(); + })) { + await handle(item); + } + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the signal can abort while awaiting + if (signal.aborted) return; + failure = new LifecycleError(`${name} ended unexpectedly`); + } catch (error: unknown) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the signal can abort while awaiting + if (signal.aborted) return; + failure = error; + } + if (!resubscribes()) throw failure; + failures += 1; + try { + await backOffAfterFailure( + this.#context, + name, + failure, + failures, + signal + ); + } catch (error: unknown) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the signal can abort while awaiting + if (signal.aborted) return; + throw error; + } + } + } +} diff --git a/js/src/runtime/peer-attempts.ts b/js/src/runtime/peer-attempts.ts new file mode 100644 index 000000000..31ebe1e5b --- /dev/null +++ b/js/src/runtime/peer-attempts.ts @@ -0,0 +1,598 @@ +/** + * Peer attempts: jobs that a running attempt, their coordinator, claims + * and completes alongside its own job, such as a group of related jobs it + * works together. + * + * River owns each peer from the commit of the claim that took it until its + * outcome settles, under the attempt that claimed it. A peer moves from + * `claimed` to `preparing` once an outcome for it is accepted, to + * `submitted` once the completer accepts that outcome, and to `settled` + * once the completer persisted it; an outcome that fails before the + * completer accepts it returns the peer to `claimed`. When the coordinator ends, + * River stops accepting its peer operations, waits for those it accepted, + * and completes every peer still `claimed` with an outcome of its own. + * Peers don't take producer slots, and their producer never hears about + * them, like the other jobs of a multi-job result in River for Go. + */ +import type { JobClaimResult } from "../driver.js"; +import { ExtensionError, LifecycleError, ValidationError } from "../errors.js"; +import type { RiverErrorHandler, WorkAttemptResult } from "../extensions.js"; +import type { JobRow } from "../job.js"; +import type { JsonObject } from "../json.js"; +import { toJsonObject } from "../json.js"; +import type { PeerClaimContext, PeerOutcome, PilotDatabase } from "../pilot.js"; +import type { WorkAttemptContext } from "../worker.js"; +import { normalizeWorkAttemptResult } from "./attempt-result.js"; +import type { + CompletionPipeline, + CompletionTracking, +} from "./completion-pipeline.js"; +import type { RuntimeContext } from "./context.js"; +import { checkClaimResult } from "./claim-result.js"; +import { canonicalError, describeError } from "./failures.js"; +import type { RetryPolicy } from "./settings.js"; +import type { AnyWorkContext, WorkOutputState } from "./work-context.js"; +import { + errorHandlerContext, + normalizeOutput, + publishWorkResult, + snapshotWorkMetadata, + workAttemptState, +} from "./work-context.js"; + +/** Collaborators of {@link PeerAttempts}. */ +export interface PeerAttemptsOptions { + readonly completions: CompletionPipeline; + /** The pilot's database, in whose transactions peers are claimed. */ + readonly database: PilotDatabase; + readonly errorHandler: RiverErrorHandler | undefined; + /** Whether this client works an ordinary attempt of job `id`. */ + readonly isWorking: (id: bigint) => boolean; + /** Apply the client's argument transformers to a claimed row. */ + readonly transformJobArgs: (row: JobRow) => JobRow; + /** The retry policy of the worker registered for a kind, if any. */ + readonly workerRetryPolicy: (kind: string) => RetryPolicy | undefined; +} + +type PeerState = "claimed" | "preparing" | "settled" | "submitted"; + +/** One peer a coordinator owns. */ +interface Peer { + /** The row as claimed, which identifies the peer's attempt. */ + readonly claimed: JobRow; + readonly ledger: PeerLedger; + /** The row completions persist and events report, after transforms. */ + row: JobRow; + state: PeerState; +} + +/** A peer and the outcome accepted for it. */ +interface PeerSubmission { + readonly peer: Peer; + readonly result: WorkAttemptResult; +} + +/** The peers of one coordinating attempt, and its operations on them. */ +class PeerLedger { + /** Set once the coordinator ended; no new operation starts. */ + closed = false; + /** Set once River stopped tracking the peers; see `abandon`. */ + released = false; + /** The coordinating attempt's context. */ + readonly coordinator: AnyWorkContext; + /** Operations accepted and not yet settled. */ + readonly operations = new Set>(); + /** Peers by job ID, including settled ones. */ + readonly peers = new Map(); + + constructor(coordinator: AnyWorkContext) { + this.coordinator = coordinator; + } + + /** Accept `operation`: the coordinator's end waits for it to settle. */ + track(operation: Promise): Promise { + this.operations.add(operation); + const untrack = () => this.operations.delete(operation); + void operation.then(untrack, untrack); + return operation; + } +} + +/** Claims and completes the peers of this runtime's attempts. */ +export class PeerAttempts { + readonly #completions: CompletionPipeline; + readonly #context: RuntimeContext; + readonly #database: PilotDatabase; + /** States of attempts that ended; they take no peer operations. */ + readonly #ended = new WeakSet(); + readonly #errorHandler: RiverErrorHandler | undefined; + readonly #isWorking: (id: bigint) => boolean; + /** Each coordinator's ledger, by the attempt's state. */ + readonly #ledgers = new WeakMap(); + /** Peers claimed or being claimed and not yet settled, by job ID. */ + readonly #owned = new Map(); + readonly #transformJobArgs: (row: JobRow) => JobRow; + readonly #workerRetryPolicy: (kind: string) => RetryPolicy | undefined; + + constructor(context: RuntimeContext, options: PeerAttemptsOptions) { + this.#completions = options.completions; + this.#context = context; + this.#database = options.database; + this.#errorHandler = options.errorHandler; + this.#isWorking = options.isWorking; + this.#transformJobArgs = options.transformJobArgs; + this.#workerRetryPolicy = options.workerRetryPolicy; + } + + /** + * Stop tracking the peers of the attempt whose state is `state`, leaving + * any without a settled outcome to the rescuer. For an attempt that ends + * without {@link finish}. + */ + abandon(state: object): void { + this.#ended.add(state); + const ledger = this.#ledgers.get(state); + if (ledger === undefined) return; + ledger.closed = true; + ledger.released = true; + this.#ledgers.delete(state); + for (const id of ledger.peers.keys()) { + if (this.#owned.get(id) === ledger) this.#owned.delete(id); + } + } + + /** + * Claim peers of `attempt` with `claim`, in a transaction River commits, + * and return the rows River will track, after argument transforms. A row + * River can't decode or transform is completed as a failure instead. + */ + claim( + attempt: AnyWorkContext, + run: (context: PeerClaimContext) => Promise + ): Promise { + let ledger: PeerLedger; + try { + ledger = this.#openLedger(attempt); + if (typeof run !== "function") { + throw new ValidationError("a peer claim requires a callback"); + } + } catch (error: unknown) { + return Promise.reject(error); + } + return ledger.track(this.#claim(ledger, run)); + } + + /** + * Complete peers of `attempt` through the ordinary completion pipeline, + * resolving once every outcome persisted. + */ + complete( + attempt: AnyWorkContext, + outcomes: readonly PeerOutcome[] + ): Promise { + let ledger: PeerLedger; + let submissions: readonly PeerSubmission[]; + try { + ledger = this.#openLedger(attempt); + submissions = reserve(ledger, outcomes); + } catch (error: unknown) { + return Promise.reject(error); + } + return ledger.track(this.#submit(ledger, submissions)); + } + + /** + * End the peers of the attempt whose state is `state`, once the attempt + * settled: refuse its new peer operations, wait for the accepted ones, + * then complete each peer still without an outcome. A peer of an attempt + * the runtime interrupted (`abortReason` a `LifecycleError`) is + * interrupted too; any other such peer fails. + */ + async finish(state: object, abortReason: unknown): Promise { + // The attempt takes no peer operation from now on, even while its own + // outcome persists. + this.#ended.add(state); + const ledger = this.#ledgers.get(state); + if (ledger === undefined) return; + ledger.closed = true; + while (ledger.operations.size > 0) { + await Promise.allSettled([...ledger.operations]); + } + const missing = [...ledger.peers.values()].filter( + (peer) => peer.state === "claimed" + ); + if (missing.length > 0) { + const result: WorkAttemptResult = + abortReason instanceof LifecycleError + ? { error: abortReason, status: "cancelled" } + : { + error: new ExtensionError( + `the attempt of job ${ledger.coordinator.job.id} ended without an outcome for this job` + ), + status: "failed", + }; + for (const peer of missing) peer.state = "preparing"; + try { + await this.#submit( + ledger, + missing.map((peer) => ({ peer, result })) + ); + } catch (error: unknown) { + this.#context.logger.error( + "River failed to complete peers their attempt left without an outcome", + { + error: describeError(error), + jobId: ledger.coordinator.job.id.toString(10), + peers: missing.length, + } + ); + } + } + this.abandon(state); + } + + /** Whether job `id` is a peer this runtime owns or is claiming. */ + owns(id: bigint): boolean { + return this.#owned.has(id); + } + + async #claim( + ledger: PeerLedger, + run: (context: PeerClaimContext) => Promise + ): Promise { + // A soft stop doesn't end a running coordinator's claims: it keeps + // claiming until its attempt ends or is cancelled, and the stop waits + // for the peers it claims. + const signal = ledger.coordinator.signal; + const reserved: bigint[] = []; + let claimed: JobClaimResult; + try { + signal.throwIfAborted(); + claimed = await this.#database.transaction( + async (tx) => { + const result: unknown = await run(Object.freeze({ signal, tx })); + const checked = this.#check(ledger, result); + // Reserved until the commit settles, so no other claim of this + // client can take the same jobs meanwhile. + for (const row of checked.jobs) { + this.#owned.set(row.id, ledger); + reserved.push(row.id); + } + return checked; + }, + { signal } + ); + } catch (error: unknown) { + for (const id of reserved) { + if (this.#owned.get(id) === ledger) this.#owned.delete(id); + } + throw error; + } + + if (ledger.released) { + // River stopped tracking the attempt's peers while the claim ran; + // its rows are left to the rescuer. + for (const id of reserved) { + if (this.#owned.get(id) === ledger) this.#owned.delete(id); + } + throw new LifecycleError("peer operations require a running attempt"); + } + // The claim committed: every row is this coordinator's peer now, even + // when the coordinator was cancelled meanwhile. + const failures: PeerSubmission[] = []; + const record = (job: JobRow): Peer => { + const peer: Peer = { claimed: job, ledger, row: job, state: "claimed" }; + ledger.peers.set(job.id, peer); + return peer; + }; + const rows: JobRow[] = []; + for (const job of claimed.jobs) { + const peer = record(job); + const decodeError = claimed.decodeErrors?.get(job.id); + if (decodeError !== undefined) { + failures.push({ + peer, + result: { + error: new Error( + `job row couldn't be decoded: ${decodeError.message}`, + { cause: decodeError } + ), + status: "failed", + }, + }); + continue; + } + try { + peer.row = this.#transformJobArgs(peer.claimed); + rows.push(peer.row); + } catch (error: unknown) { + failures.push({ peer, result: { error, status: "failed" } }); + } + } + if (failures.length > 0) { + for (const { peer } of failures) peer.state = "preparing"; + await this.#submit(ledger, failures); + } + return Object.freeze(rows); + } + + /** + * Check a peer claim's result before it commits: each row running, on an + * attempt of this client, listed once, and neither the coordinator's own + * job nor a job this client already works, owns, or finished at that + * attempt. + */ + #check(ledger: PeerLedger, result: unknown): JobClaimResult { + const fail: (reason: string) => never = (reason) => { + throw new ExtensionError(`a peer claim returned ${reason}`); + }; + const { checked, rows } = checkClaimResult(result, fail); + const seen = new Set(); + const clientId = this.#context.clientId; + for (const { job, partial } of rows) { + if (typeof job !== "object" || job === null) fail("a missing job row"); + const row = job as Partial; + const id = row.id; + if (typeof id !== "bigint") fail("a job without an ID"); + if (seen.has(id)) fail(`job ${id} twice`); + seen.add(id); + if (id === ledger.coordinator.job.id) { + fail(`job ${id}, the claiming attempt's own job`); + } + if (this.#owned.has(id)) { + fail(`job ${id}, which this client already works as a peer`); + } + if (this.#isWorking(id)) { + fail(`job ${id}, which this client already works`); + } + const attempt = row.attempt; + if (typeof attempt === "number" && attempt >= 1) { + const earlier = ledger.peers.get(id); + if (earlier !== undefined && attempt <= earlier.claimed.attempt) { + fail(`job ${id} at attempt ${attempt}, which already ended here`); + } + } else if (!(partial && attempt === undefined)) { + fail(`job ${id}, which has no attempt`); + } + if (row.state !== "running" && !(partial && row.state === undefined)) { + fail(`job ${id}, which isn't running`); + } + const owner = row.attemptedBy?.at(-1); + if (owner !== clientId && !(partial && owner === undefined)) { + fail(`job ${id}, which another client claimed`); + } + } + return checked; + } + + /** + * The ledger of the running attempt `attempt`, which must be this + * client's and must not have ended. + */ + #openLedger(attempt: AnyWorkContext): PeerLedger { + // JavaScript callers may pass anything. + const context: unknown = attempt; + if (typeof context !== "object" || context === null) { + throw new ValidationError("peer operations require an attempt's context"); + } + if (attempt.client !== this.#context.client) { + throw new ValidationError("the attempt belongs to another River client"); + } + const state = workAttemptState(attempt); + if (state === undefined || !state.active || this.#ended.has(state)) { + throw new LifecycleError("peer operations require a running attempt"); + } + let ledger = this.#ledgers.get(state); + if (ledger === undefined) { + ledger = new PeerLedger(attempt); + this.#ledgers.set(state, ledger); + } + if (ledger.closed) { + throw new LifecycleError("peer operations require a running attempt"); + } + return ledger; + } + + /** + * Hand peer outcomes to the completer, running the error handler and + * adding the coordinator's metadata first, like the coordinator's own + * outcome. An outcome that fails before reaching the completer returns + * its peer to `claimed`. Rejects with the first failure once every + * outcome settled. + */ + async #submit( + ledger: PeerLedger, + submissions: readonly PeerSubmission[] + ): Promise { + const coordinator = ledger.coordinator; + const sharedMetadata = snapshotWorkMetadata(coordinator); + const persisting: Promise[] = []; + let failure: { readonly error: unknown } | undefined; + const settle = async () => { + for (const settled of await Promise.allSettled(persisting.splice(0))) { + if (settled.status === "rejected") + failure ??= { error: settled.reason }; + } + }; + for (const { peer, result } of submissions) { + let prepared: WorkAttemptResult; + try { + prepared = await this.#prepare( + coordinator, + peer.row, + normalizeWorkAttemptResult(result), + sharedMetadata + ); + } catch (error: unknown) { + peer.state = "claimed"; + failure ??= { error }; + continue; + } + persisting.push( + this.#persist(peer, prepared, coordinator.execution.startedAt) + ); + if (persisting.length >= this.#completions.maxPendingItems) { + await settle(); + } + } + await settle(); + if (failure !== undefined) throw failure.error; + } + + /** + * Hand one outcome to the completer. Once the completer accepts it, it + * owns the outcome and retries it until it settles; a rejection after + * that leaves the peer to the rescuer. Ownership ends as the outcome + * persists, before its event, so the job can be claimed again at once. + */ + async #persist( + peer: Peer, + result: WorkAttemptResult, + startedAt: Temporal.Instant + ): Promise { + const tracking: CompletionTracking = { + accepted: () => { + peer.state = "submitted"; + }, + persisted: () => { + peer.state = "settled"; + if (this.#owned.get(peer.claimed.id) === peer.ledger) { + this.#owned.delete(peer.claimed.id); + } + }, + }; + // Like River for Go, a failed peer retries on its worker's policy. + const retryPolicy = this.#workerRetryPolicy(peer.row.kind); + try { + await (result.status === "cancelled" + ? this.#completions.persistAbort( + peer.row, + result.error, + startedAt, + result, + tracking, + retryPolicy + ) + : this.#completions.persistResult( + peer.row, + result, + startedAt, + tracking, + retryPolicy + )); + } catch (error: unknown) { + // Not accepted: the peer still needs an outcome. + if (peer.state === "preparing") peer.state = "claimed"; + throw error; + } + } + + async #prepare( + coordinator: AnyWorkContext, + job: JobRow, + value: WorkAttemptResult, + sharedMetadata: JsonObject + ): Promise { + let result = value; + const outputState: WorkOutputState = {}; + const peerContext: WorkAttemptContext = { + client: coordinator.client, + execution: coordinator.execution, + job, + logger: coordinator.logger, + recordOutput: (output) => { + outputState.output = normalizeOutput(output); + }, + setMetadata: coordinator.setMetadata, + signal: coordinator.signal, + }; + if (result.status === "failed" && this.#errorHandler !== undefined) { + try { + const decision = await this.#errorHandler( + errorHandlerContext(peerContext), + result.error + ); + if ( + decision?.cancel !== undefined && + typeof decision.cancel !== "boolean" + ) { + throw new ValidationError("errorHandler cancel must be a boolean"); + } + if (decision?.cancel === true) result = { ...result, cancel: true }; + } catch (error: unknown) { + this.#context.logger.error("River error handler failed", { + error: canonicalError(error, this.#context.now()).error, + }); + } + } + if ("output" in outputState) { + result = { ...result, output: outputState.output }; + } + if (Object.keys(sharedMetadata).length > 0) { + result = { + ...result, + metadata: toJsonObject({ + ...(result.metadata ?? {}), + ...sharedMetadata, + }), + }; + } + publishWorkResult(peerContext, result); + return result; + } +} + +/** + * Accept one outcome for each of `outcomes`' peers, all or none: each job + * must be a peer of `ledger`, identified by its ID, attempt, and attempting + * client, have no outcome yet, and appear once. + */ +function reserve( + ledger: PeerLedger, + outcomes: readonly PeerOutcome[] +): readonly PeerSubmission[] { + // JavaScript callers may pass anything. + const values: unknown = outcomes; + if (!Array.isArray(values)) { + throw new ValidationError("peer outcomes must be an array"); + } + const submissions: PeerSubmission[] = []; + const seen = new Set(); + for (const outcome of values as unknown[]) { + if (typeof outcome !== "object" || outcome === null) { + throw new ValidationError("a peer outcome must be an object"); + } + const { job, result } = outcome as Partial; + const id: unknown = (job as Partial | undefined)?.id; + const peer = typeof id === "bigint" ? ledger.peers.get(id) : undefined; + if (job === undefined || peer === undefined) { + throw new ExtensionError( + `job ${String(id)} isn't a peer of the attempt completing it` + ); + } + if ( + job.attempt !== peer.claimed.attempt || + !Array.isArray(job.attemptedBy) || + job.attemptedBy.at(-1) !== peer.claimed.attemptedBy.at(-1) + ) { + throw new ExtensionError( + `job ${peer.claimed.id} attempt ${String(job.attempt)} isn't the peer attempt ${peer.claimed.attempt} this attempt owns` + ); + } + if (seen.has(peer)) { + throw new ExtensionError(`job ${peer.claimed.id} has two outcomes`); + } + seen.add(peer); + if (peer.state !== "claimed") { + throw new ExtensionError(`job ${peer.claimed.id} already has an outcome`); + } + const value: unknown = result; + if (typeof value !== "object" || value === null) { + throw new ValidationError("a peer outcome requires a result"); + } + // A snapshot, which River validates before handing it over. + submissions.push({ peer, result: { ...(value as WorkAttemptResult) } }); + } + for (const { peer } of submissions) peer.state = "preparing"; + return submissions; +} diff --git a/js/src/runtime/pilot-operations.ts b/js/src/runtime/pilot-operations.ts new file mode 100644 index 000000000..b9dd6ab17 --- /dev/null +++ b/js/src/runtime/pilot-operations.ts @@ -0,0 +1,788 @@ +/** + * Dispatch of the operations a client's pilot intercepts. Each intercepted + * operation runs in one transaction (River's own, or the caller's) around + * the pilot's interceptor and River's standard operation, which the + * interceptor runs through a continuation bound to that transaction. + * Without an interceptor the standard operation runs as before. + */ +import type { + DriverInsertResult, + JobCompletionCommand, + JobCompletionResult, + JobInsertParams, + RuntimeDriver, + RuntimeJobRescue, + RuntimeLeader, + RuntimeMaintenanceBatch, +} from "../driver.js"; +import { ExtensionError, ValidationError } from "../errors.js"; +import { withHandle } from "../internal/handle-gate.js"; +import { JOB_STATE, type JobRow, type JobState } from "../job.js"; +import { parseJson } from "../json.js"; +import type { + FinalizedJobDeleteParams, + Pilot, + PilotDatabase, + PilotInsertReplacement, + PilotInterceptors, + PreparedInsertParams, +} from "../pilot.js"; + +/** A signal for operations nothing can abandon. */ +const NEVER_ABORTED = new AbortController().signal; + +/** The standard insertion River runs for `next`. */ +export type StandardInsert = ( + params: readonly JobInsertParams[], + tx: Transaction | undefined +) => Promise; + +/** + * The client's operations that its pilot may intercept. Without a pilot, or + * for an operation it doesn't intercept, each runs River's standard + * operation directly. + */ +export class PilotOperations { + readonly #database: PilotDatabase | undefined; + readonly #intercept: PilotInterceptors; + readonly #pilot: Pilot | undefined; + + constructor( + pilot?: Pilot, + database?: PilotDatabase + ) { + this.#database = database; + this.#intercept = pilot?.intercept ?? {}; + this.#pilot = pilot; + } + + /** Cancel one job, as `client.jobs.cancel` does. */ + cancel( + driver: RuntimeDriver, + id: bigint, + tx: Transaction | undefined + ): Promise { + const interceptor = this.#intercept.cancel?.bind(this.#intercept); + if (interceptor === undefined) { + return Promise.resolve(driver.jobCancel(id, driverOptions(tx))); + } + return this.#jobOperation("cancel", interceptor, id, tx, (scopeTx) => + Promise.resolve(driver.jobCancel(id, { tx: scopeTx })) + ); + } + + /** + * Persist completions: a background batch (with `signal`) or a worker's + * transactional completion (with `tx`). + */ + complete( + driver: RuntimeDriver, + commands: readonly JobCompletionCommand[], + options: { readonly signal?: AbortSignal; readonly tx?: Transaction } + ): Promise { + if (options.tx !== undefined && this.serializes) { + // A worker's transactional completion shares the caller's + // transaction with the client's other operations. + return withHandle(options.tx, () => + this.#complete(driver, commands, options) + ); + } + return this.#complete(driver, commands, options); + } + + #complete( + driver: RuntimeDriver, + commands: readonly JobCompletionCommand[], + options: { readonly signal?: AbortSignal; readonly tx?: Transaction } + ): Promise { + const interceptor = this.#intercept.complete?.bind(this.#intercept); + if (interceptor === undefined || commands.length === 0) { + return Promise.resolve(driver.jobCompleteMany(commands, options)); + } + const database = this.#requireDatabase(); + const signal = options.signal ?? NEVER_ABORTED; + return database.transaction( + (tx) => + runInterceptor({ + invoke: (next) => + interceptor( + Object.freeze({ + commands: Object.freeze([...commands]), + database, + signal, + tx, + }), + next + ), + mode: "once", + operation: "complete", + snapshot: listFields, + standard: async () => + protectArray(await driver.jobCompleteMany(commands, { tx })), + }), + transactionOptions(options.signal, options.tx) + ); + } + + /** Read one page of stuck jobs for the rescuer. */ + getStuck( + driver: RuntimeDriver, + leader: RuntimeLeader, + attemptedBefore: Temporal.Instant, + afterId: bigint, + limit: number, + batch: RuntimeMaintenanceBatch + ): Promise { + const standard = async (): Promise => + (await driver.maintenanceGetStuck?.( + leader, + attemptedBefore, + afterId, + limit, + batch + )) ?? []; + const interceptor = this.#intercept.getStuck?.bind(this.#intercept); + if (interceptor === undefined) return standard(); + const database = this.#requireDatabase(); + return runInterceptor({ + invoke: (next) => + interceptor( + Object.freeze({ + afterId, + attemptedBefore, + database, + leader, + limit, + signal: batch.signal, + timeoutMs: batch.timeoutMs, + }), + next + ), + mode: "optional", + operation: "getStuck", + replacement: (result) => validateStuckRows(result, afterId, limit), + snapshot: listFields, + standard: async () => protectArray(await standard()), + }); + } + + /** + * Run one insertion's database write: River's standard insertion, or the + * pilot's interceptor around it in `tx` or a transaction of River's. + * `originalEncodedArgs` holds each row's arguments before the client's + * argument transforms, for the interceptor. + */ + insert( + operation: "insert" | "insertMany", + params: readonly JobInsertParams[], + tx: Transaction | undefined, + standard: StandardInsert, + signal: AbortSignal = NEVER_ABORTED, + originalEncodedArgs: readonly string[] = params.map( + (row) => row.encodedArgs + ) + ): Promise { + const interceptor = this.#intercept.insert?.bind(this.#intercept); + if (interceptor === undefined) return standard(params, tx); + const database = this.#requireDatabase(); + const prepared = Object.freeze([...params]); + const originals = Object.freeze([...originalEncodedArgs]); + return database.transaction( + (scopeTx) => + runInterceptor< + readonly DriverInsertResult[], + [replacement?: PilotInsertReplacement] + >({ + invoke: (next) => + interceptor( + Object.freeze({ + database, + operation, + originalEncodedArgs: originals, + params: prepared, + signal, + tx: scopeTx, + }), + next + ), + mode: "once", + operation: "insert", + snapshot: listFields, + standard: async (replacement?: PilotInsertReplacement) => { + const rows = + replacement === undefined + ? prepared + : validateReplacement(replacement, prepared.length); + const results = await standard(rows, scopeTx); + if (results.length !== rows.length) { + throw violation( + `River's insertion returned ${results.length} results for ${rows.length} jobs` + ); + } + return protectArray(results); + }, + }), + transactionOptions(signal, tx) + ); + } + + /** + * Whether operations on one caller transaction must run one at a time, + * because a pilot may run statements on it across awaits. + */ + get serializes(): boolean { + return this.#pilot !== undefined; + } + + /** Rescue one page of stuck jobs, fenced by `leader`. */ + rescue( + driver: RuntimeDriver, + leader: RuntimeLeader, + attemptedBefore: Temporal.Instant, + jobs: readonly RuntimeJobRescue[], + signal: AbortSignal + ): Promise { + const interceptor = this.#intercept.rescue?.bind(this.#intercept); + if (interceptor === undefined) { + return Promise.resolve( + driver.maintenanceRescue?.(leader, attemptedBefore, jobs) ?? 0 + ); + } + const database = this.#requireDatabase(); + const frozenJobs = Object.freeze([...jobs]); + return database.transaction( + (tx) => + runInterceptor({ + invoke: (next) => + interceptor( + Object.freeze({ + attemptedBefore, + database, + jobs: frozenJobs, + leader, + signal, + tx, + }), + next + ), + mode: "optional", + operation: "rescue", + replacement: (result) => { + if ( + typeof result !== "number" || + !Number.isSafeInteger(result) || + result < 0 || + result > frozenJobs.length + ) { + throw violation( + `rescue interceptor resolved with ${String(result)}, not a count of at most ${frozenJobs.length} rescued jobs` + ); + } + return result; + }, + snapshot: (count) => count, + standard: async () => + (await driver.maintenanceRescue?.( + leader, + attemptedBefore, + frozenJobs, + { tx } + )) ?? 0, + }), + { signal } + ); + } + + /** Retry one job, as `client.jobs.retry` does. */ + retry( + driver: RuntimeDriver, + id: bigint, + tx: Transaction | undefined + ): Promise { + const interceptor = this.#intercept.retry?.bind(this.#intercept); + if (interceptor === undefined) { + return Promise.resolve(driver.jobRetry(id, driverOptions(tx))); + } + return this.#jobOperation("retry", interceptor, id, tx, (scopeTx) => + Promise.resolve(driver.jobRetry(id, { tx: scopeTx })) + ); + } + + #jobOperation( + operation: "cancel" | "retry", + interceptor: NonNullable["cancel"]>, + id: bigint, + tx: Transaction | undefined, + standard: (tx: Transaction) => Promise + ): Promise { + const database = this.#requireDatabase(); + return database.transaction( + (scopeTx) => + runInterceptor({ + invoke: (next) => + interceptor( + Object.freeze({ + database, + id, + signal: NEVER_ABORTED, + tx: scopeTx, + }), + next + ), + mode: "once", + operation, + snapshot: rowFields, + standard: () => standard(scopeTx), + }), + transactionOptions(undefined, tx) + ); + } + + #requireDatabase(): PilotDatabase { + if (this.#database === undefined) { + // Construction attaches a pilot only with a database. + throw new ExtensionError("the client's pilot has no database"); + } + return this.#database; + } +} + +/** + * A pilot's view of its driver's database, where statements in a supplied + * transaction wait for the client's other operations on it, as the + * client's own operations do, instead of interleaving with another + * operation's statements. + */ +export function serializedDatabase( + database: PilotDatabase +): PilotDatabase { + return Object.freeze({ + backend: database.backend, + connection: ( + callback: (handle: Transaction) => PromiseLike | Result, + options?: { readonly signal?: AbortSignal } + ) => database.connection(callback, options), + deleteFinalizedJobs: ( + params: FinalizedJobDeleteParams, + options?: { readonly tx?: Transaction } + ) => + options?.tx === undefined + ? database.deleteFinalizedJobs(params, options) + : withHandle(options.tx, () => + database.deleteFinalizedJobs(params, options) + ), + loadClaimed: ( + ids: readonly bigint[], + options: { readonly tx: Transaction } + ) => withHandle(options.tx, () => database.loadClaimed(ids, options)), + notify: ( + topic: "control" | "insert", + payloads: readonly string[], + options: { readonly tx: Transaction } + ) => + withHandle(options.tx, () => database.notify(topic, payloads, options)), + schema: database.schema, + transaction: ( + callback: (tx: Transaction) => PromiseLike | Result, + options?: { readonly signal?: AbortSignal; readonly tx?: Transaction } + ) => + options?.tx === undefined + ? database.transaction(callback, options) + : withHandle(options.tx, () => database.transaction(callback, options)), + }); +} + +/** Errors River raised because an interceptor broke its contract. */ +const violations = new WeakSet(); + +function violation( + message: string, + options: { + readonly cause?: unknown; + readonly details?: Readonly>; + } = {} +): ExtensionError { + const error = new ExtensionError(message, options); + violations.add(error); + return error; +} + +/** + * Whether `error` is River's report of an interceptor breaking its + * contract, rather than a failure the interceptor itself raised. + */ +export function isContractViolation(error: unknown): boolean { + return typeof error === "object" && error !== null && violations.has(error); +} + +/** @internal An interceptor call with its continuation rules. */ +export interface InterceptorCall { + readonly invoke: ( + next: (...args: Args) => Promise + ) => PromiseLike; + /** Whether `next` must be called exactly once, or at most once. */ + readonly mode: "once" | "optional"; + readonly operation: string; + /** + * Check a result an interceptor produced without calling `next`, for an + * operation it may replace. + */ + readonly replacement?: (result: unknown) => Result; + /** The fields of River's result that an interceptor must not change. */ + readonly snapshot: (value: Result) => unknown; + readonly standard: (...args: Args) => Promise; +} + +/** + * @internal Run an interceptor around River's standard operation, enforcing the + * continuation rules: `next` at most once and only while the interceptor + * runs, never left in flight, and a result River can trust. + */ +export async function runInterceptor( + call: InterceptorCall +): Promise { + const details = { operation: call.operation }; + let settled = false; + let calls = 0; + let inFlight: Promise | undefined; + let outcome: + | { + readonly fields: unknown; + readonly ok: true; + readonly value: Result; + } + | { readonly ok: false; readonly reason: unknown } + | undefined; + const next = (...args: Args): Promise => { + if (settled) { + return Promise.reject( + violation( + `${call.operation} interceptor called next() after it settled`, + { details } + ) + ); + } + if (calls > 0) { + return Promise.reject( + violation( + `${call.operation} interceptor called next() more than once`, + { details } + ) + ); + } + calls++; + let started: Promise; + try { + started = call.standard(...args); + } catch (error: unknown) { + started = Promise.reject(error); + } + const tracked = started.then( + (value) => { + // Snapshot before the interceptor can see, or change, the result. + outcome = { fields: call.snapshot(value), ok: true, value }; + return value; + }, + (reason: unknown) => { + outcome = { ok: false, reason }; + throw reason; + } + ); + void tracked.catch(() => undefined); + inFlight = tracked; + return tracked; + }; + + let result: unknown; + let failure: { readonly error: unknown } | undefined; + const settlement = { early: false }; + try { + result = await Promise.resolve(call.invoke(next)).finally(() => { + settled = true; + settlement.early = inFlight !== undefined && outcome === undefined; + }); + } catch (error: unknown) { + failure = { error }; + } + settled = true; + // Never let the transaction end with River's operation still running in + // it, even when the interceptor didn't await it. + if (inFlight !== undefined) await inFlight.catch(() => undefined); + if (failure !== undefined) throw failure.error; + if (settlement.early) { + throw violation( + `${call.operation} interceptor settled before its next() continuation did; await it`, + { details } + ); + } + const settledOutcome = outcome; + if (settledOutcome === undefined) { + if (call.mode === "once" || call.replacement === undefined) { + throw violation( + `${call.operation} interceptor must call next() exactly once`, + { details } + ); + } + return call.replacement(result); + } + if (!settledOutcome.ok) { + // The interceptor swallowed a failure of River's own operation, so it + // has no result River can accept. + throw settledOutcome.reason; + } + if (result !== settledOutcome.value) { + throw violation( + `${call.operation} interceptor must resolve with the result of next()`, + { details } + ); + } + if (!sameFields(settledOutcome.fields, call.snapshot(settledOutcome.value))) { + throw violation( + `${call.operation} interceptor changed the result of next()`, + { details } + ); + } + return settledOutcome.value; +} + +/** Freeze a result container River dispatches on. */ +function protectArray(items: readonly Item[]): readonly Item[] { + return Object.isFrozen(items) ? items : Object.freeze([...items]); +} + +/** The identity fields of each item of a result list. */ +function listFields(items: readonly unknown[]): unknown { + return items.map((item) => { + if (typeof item !== "object" || item === null) return item; + if ("status" in item && "job" in item) { + const result = item as { + readonly job: JobRow | null; + readonly key?: string; + readonly status: string; + }; + return [ + item, + result.status, + result.key, + result.job, + result.job === null ? null : rowFields(result.job), + ]; + } + return [item, rowFields(item as JobRow)]; + }); +} + +/** The fields of a job row River uses to dispatch and fence it. */ +function rowFields(row: JobRow | null): unknown { + if (row === null) return null; + return [ + row.id, + row.attempt, + row.kind, + row.queue, + row.state, + row.attemptedBy.at(-1), + ]; +} + +function sameFields(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) return true; + if (!Array.isArray(left) || !Array.isArray(right)) return false; + if (left.length !== right.length) return false; + return left.every((value, index) => sameFields(value, right[index])); +} + +function driverOptions( + tx: Transaction | undefined +): { readonly tx: Transaction } | undefined { + return tx === undefined ? undefined : { tx }; +} + +function transactionOptions( + signal: AbortSignal | undefined, + tx: Transaction | undefined +): { readonly signal?: AbortSignal; readonly tx?: Transaction } { + return { + ...(signal === undefined ? {} : { signal }), + ...(tx === undefined ? {} : { tx }), + }; +} + +/** + * Check rows a `getStuck` interceptor read instead of River: at most + * `limit` running jobs in ascending ID order after `afterId`, which is how + * the rescuer pages through them. + */ +function validateStuckRows( + result: unknown, + afterId: bigint, + limit: number +): readonly JobRow[] { + if (!Array.isArray(result) || result.length > limit) { + throw violation( + `getStuck interceptor must resolve with at most ${limit} jobs` + ); + } + let previous = afterId; + for (const row of result as unknown[]) { + if ( + typeof row !== "object" || + row === null || + typeof (row as JobRow).id !== "bigint" || + (row as JobRow).id <= previous || + (row as JobRow).state !== JOB_STATE.running + ) { + throw violation( + "getStuck interceptor must resolve with running jobs in ascending ID order after afterId" + ); + } + previous = (row as JobRow).id; + } + return protectArray(result as readonly JobRow[]); +} + +function validateReplacement( + replacement: unknown, + expected: number +): readonly JobInsertParams[] { + const params = + typeof replacement === "object" && replacement !== null + ? (replacement as { readonly params?: unknown }).params + : undefined; + if (!Array.isArray(params) || params.length !== expected) { + throw violation( + `insert interceptor must replace the ${expected} prepared jobs one for one` + ); + } + try { + params.forEach((item: unknown, index) => { + validatePreparedRow(item, index, true); + }); + return Object.freeze([...(params as readonly JobInsertParams[])]); + } catch (error: unknown) { + throw violation("insert interceptor passed an invalid replacement job", { + cause: error, + }); + } +} + +const JOB_STATES: ReadonlySet = new Set(Object.values(JOB_STATE)); + +/** + * Check reinserted jobs' stored fields, and snapshot the list. + * + * @throws {ValidationError} for a row River can't insert. + */ +export function validatePreparedParams( + params: readonly unknown[] +): readonly PreparedInsertParams[] { + if (!Array.isArray(params)) { + throw new ValidationError("prepared jobs must be an array"); + } + params.forEach((item, index) => { + validatePreparedRow(item, index, false); + }); + return Object.freeze([...(params as readonly PreparedInsertParams[])]); +} + +/** + * Check one row prepared outside River's own preparation. An + * interceptor's replacement row carries its own `args`; a reinserted row + * takes its arguments from `encodedArgs` alone. + */ +function validatePreparedRow( + item: unknown, + index: number, + withArgs: boolean +): void { + const fail = (field: string, expected: string): never => { + throw new ValidationError( + `prepared job ${index} ${field} must be ${expected}`, + { details: { field, index } } + ); + }; + if (typeof item !== "object" || item === null || Array.isArray(item)) { + fail("", "an object"); + } + const row = item as Partial>; + if (withArgs) { + if (!isPlainObject(row.args)) fail("args", "a JSON object"); + } else if ("args" in row) { + // Arguments come from `encodedArgs` alone. + fail("args", "omitted"); + } + if (typeof row.encodedArgs !== "string" || !isJsonText(row.encodedArgs)) { + fail("encodedArgs", "JSON text"); + } + if (typeof row.kind !== "string" || row.kind.length === 0) { + fail("kind", "a non-empty string"); + } + if (typeof row.queue !== "string" || row.queue.length === 0) { + fail("queue", "a non-empty string"); + } + if (!isIntegerBetween(row.maxAttempts, 1, 32_767)) { + fail("maxAttempts", "an integer from 1 to 32767"); + } + if (!isIntegerBetween(row.priority, 1, 4)) { + fail("priority", "an integer from 1 to 4"); + } + if (!isPlainObject(row.metadata)) fail("metadata", "a JSON object"); + if ( + row.scheduledAt !== undefined && + !(row.scheduledAt instanceof Temporal.Instant) + ) { + fail("scheduledAt", "a Temporal.Instant or omitted"); + } + if ( + row.createdAt !== undefined && + !(row.createdAt instanceof Temporal.Instant) + ) { + fail("createdAt", "a Temporal.Instant or omitted"); + } + if (typeof row.state !== "string" || !JOB_STATES.has(row.state)) { + fail("state", "a job state"); + } + if ( + !Array.isArray(row.tags) || + !row.tags.every((tag) => typeof tag === "string") + ) { + fail("tags", "an array of strings"); + } + if (row.uniqueKey !== null && !(row.uniqueKey instanceof Uint8Array)) { + fail("uniqueKey", "bytes or null"); + } + if ( + row.uniqueStates !== null && + !( + Array.isArray(row.uniqueStates) && + row.uniqueStates.every( + (state: unknown) => + typeof state === "string" && JOB_STATES.has(state as JobState) + ) + ) + ) { + fail("uniqueStates", "an array of job states or null"); + } +} + +function isIntegerBetween(value: unknown, min: number, max: number): boolean { + return ( + typeof value === "number" && + Number.isInteger(value) && + value >= min && + value <= max + ); +} + +function isJsonText(text: string): boolean { + try { + parseJson(text); + return true; + } catch { + return false; + } +} + +function isPlainObject(value: unknown): boolean { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/js/src/runtime/producer-session.ts b/js/src/runtime/producer-session.ts new file mode 100644 index 000000000..ed7e3707e --- /dev/null +++ b/js/src/runtime/producer-session.ts @@ -0,0 +1,295 @@ +/** + * A pilot's producer session for one queue generation: River claims + * through it, tells it about configuration changes and finished attempts, + * keeps it alive at the report interval, and shuts it down once the queue + * drained, like River for Go's producer does with its pilot. + */ +import type { JobClaimParams, JobClaimResult } from "../driver.js"; +import { ExtensionError } from "../errors.js"; +import type { JobRow } from "../job.js"; +import type { + PilotDatabase, + PilotProducer, + ProducerConfiguration, +} from "../pilot.js"; +import { checkClaimResult } from "./claim-result.js"; +import type { RuntimeContext } from "./context.js"; +import { describeError } from "./failures.js"; +import { isContractViolation, runInterceptor } from "./pilot-operations.js"; + +/** Bound on one keep-alive, like River for Go's producer reports. */ +const KEEP_ALIVE_TIMEOUT_MS = 10_000; +/** The most initial jitter before the first keep-alive. */ +const KEEP_ALIVE_JITTER_MS = 1_000; +/** Sessions silent for longer than this are stale, as in River for Go. */ +const STALE_PRODUCER_RETENTION_MS = 5 * 60_000; +/** Shutdown deadlines of River for Go's four shutdown attempts. */ +const SHUTDOWN_TIMEOUTS_MS = [100, 500, 2_500, 12_500] as const; + +/** A claim result that broke the session's contract. */ +export class ClaimHandoffError extends ExtensionError {} + +/** Wraps one {@link PilotProducer} with River's rules for calling it. */ +export class ProducerSession { + readonly #context: RuntimeContext; + readonly #database: PilotDatabase; + readonly #producer: PilotProducer; + readonly #queue: string; + readonly #reportIntervalMs: number; + readonly #reports = new AbortController(); + #reporting: Promise | undefined; + #shutDown = false; + + constructor(options: { + readonly context: RuntimeContext; + readonly database: PilotDatabase; + readonly producer: PilotProducer; + readonly queue: string; + readonly reportIntervalMs: number; + }) { + this.#context = options.context; + this.#database = options.database; + this.#producer = options.producer; + this.#queue = options.queue; + this.#reportIntervalMs = options.reportIntervalMs; + } + + /** Whether the session claims jobs itself. */ + get claims(): boolean { + return this.#producer.claim !== undefined; + } + + /** + * Claim through the session, checking the rows it hands off before River + * works any. A {@link ClaimHandoffError} means the session broke its + * contract, and the runtime must stop. + */ + async claim( + params: JobClaimParams, + limit: number, + retrySignal: AbortSignal, + isActive: (id: bigint) => boolean + ): Promise { + const claim = this.#producer.claim?.bind(this.#producer); + if (claim === undefined) { + return this.#context.driver.jobClaim(params, { signal: retrySignal }); + } + let result: JobClaimResult; + try { + result = await runInterceptor< + JobClaimResult, + [options: { readonly tx: unknown }] + >({ + invoke: (next) => + claim( + Object.freeze({ + attemptedBy: params.attemptedBy, + database: this.#database, + kinds: params.kinds, + limit, + queue: this.#queue, + retrySignal, + signal: this.#context.runSignal, + }), + next + ), + mode: "optional", + operation: "claim", + replacement: (value) => value as JobClaimResult, + snapshot: (value) => value, + standard: async (options) => { + const tx = (options as { readonly tx?: unknown } | undefined)?.tx; + if (tx === undefined) { + throw new ClaimHandoffError( + "a producer's claim must pass its transaction to next({ tx })" + ); + } + return this.#context.driver.jobClaim(params, { tx }); + }, + }); + } catch (error: unknown) { + if (error instanceof ClaimHandoffError) throw error; + if (isContractViolation(error)) { + throw new ClaimHandoffError((error as Error).message, { cause: error }); + } + throw error; + } + return validateHandoff( + result, + this.#queue, + params.attemptedBy, + limit, + isActive + ); + } + + /** + * Offer the session a new configuration. Throws, leaving the previous + * one in place, when the session rejects it. + */ + configurationChanged(configuration: ProducerConfiguration): void { + if (this.#shutDown) return; + this.#producer.configurationChanged?.(Object.freeze({ ...configuration })); + } + + /** Report a claimed job's finished attempt, once. */ + jobFinished(job: JobRow): void { + if (this.#shutDown) return; + try { + this.#producer.jobFinished?.(job); + } catch (error: unknown) { + this.#context.logger.error("River producer failed to finish a job", { + error: describeError(error), + jobId: job.id.toString(10), + queue: this.#queue, + }); + } + } + + /** Release the session, with River for Go's four bounded attempts. */ + async shutdown(): Promise { + const shutdown = this.#producer.shutdown?.bind(this.#producer); + this.#shutDown = true; + if (shutdown === undefined) return; + for (const [index, timeoutMs] of SHUTDOWN_TIMEOUTS_MS.entries()) { + const timeout = this.#context.timer.timeout( + timeoutMs, + () => + new ExtensionError( + `producer shutdown attempt timed out after ${timeoutMs} ms` + ) + ); + try { + // A deadline only aborts the signal; the attempt must settle before + // River tries again. + await shutdown({ signal: timeout.signal }); + return; + } catch (error: unknown) { + this.#context.logger.error("River producer shutdown failed", { + attempt: index + 1, + error: describeError(error), + queue: this.#queue, + timeoutMs, + }); + } finally { + timeout.dispose(); + } + } + this.#context.logger.warn( + "River producer failed to shut down cleanly after all attempts", + { queue: this.#queue } + ); + } + + /** Start keep-alives: an initial jitter, then one per report interval. */ + startReports(): void { + if (this.#producer.keepAlive === undefined) return; + this.#reporting ??= this.#reportLoop(this.#reports.signal); + } + + /** Stop keep-alives and wait for one in flight. */ + async stopReports(): Promise { + this.#reports.abort(); + await this.#reporting; + } + + async #reportLoop(signal: AbortSignal): Promise { + const keepAlive = this.#producer.keepAlive?.bind(this.#producer); + if (keepAlive === undefined) return; + const timer = this.#context.timer; + try { + await timer.delay( + Math.floor(this.#context.random() * KEEP_ALIVE_JITTER_MS), + signal + ); + while (!signal.aborted) { + // Reports keep a fixed rate, like River for Go's ticker; a slow one + // delays the next, and they never overlap. + const startedAt = timer.now(); + const timeout = timer.timeout( + KEEP_ALIVE_TIMEOUT_MS, + () => new ExtensionError("producer keep-alive timed out") + ); + try { + await keepAlive({ + // One per report interval, so `AbortSignal.any` stays cheap, and + // the signal still aborts with the session after the report. + // eslint-disable-next-line no-restricted-properties + signal: AbortSignal.any([signal, timeout.signal]), + staleBefore: this.#context + .now() + .subtract({ milliseconds: STALE_PRODUCER_RETENTION_MS }), + }); + } catch (error: unknown) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the signal can abort while awaiting + if (!signal.aborted) { + this.#context.logger.error("River producer keep-alive failed", { + error: describeError(error), + queue: this.#queue, + }); + } + } finally { + timeout.dispose(); + } + await timer.delay( + Math.max(0, this.#reportIntervalMs - (timer.now() - startedAt)), + signal + ); + } + } catch (error: unknown) { + if (!signal.aborted) throw error; + } + } +} + +/** + * Check rows a session claimed before River works any: at most `limit`, + * each running in `queue`, owned by `attemptedBy`, listed once, and not + * already being worked. A fallback row of a job River couldn't decode may + * lack fields; those it has must match. + */ +function validateHandoff( + result: unknown, + queue: string, + attemptedBy: string, + limit: number, + isActive: (id: bigint) => boolean +): JobClaimResult { + // Annotated so each call narrows like a throw. + const fail: (reason: string) => never = (reason) => { + throw new ClaimHandoffError( + `a producer's claim for queue ${JSON.stringify(queue)} returned ${reason}`, + { details: { queue } } + ); + }; + const { checked, rows } = checkClaimResult(result, fail); + if (rows.length > limit) fail(`${rows.length} jobs for a limit of ${limit}`); + const seen = new Set(); + for (const { job, partial } of rows) { + if (typeof job !== "object" || job === null) fail("a missing job row"); + const row = job as Partial; + const id = row.id; + if (typeof id !== "bigint") fail("a job without an ID"); + if (seen.has(id)) fail(`job ${id} twice`); + seen.add(id); + if (isActive(id)) fail(`job ${id}, which this client is already working`); + const attempt = row.attempt; + if ( + !(typeof attempt === "number" && attempt >= 1) && + !(partial && attempt === undefined) + ) { + fail(`job ${id}, which has no attempt`); + } + if (row.state !== "running" && !(partial && row.state === undefined)) { + fail(`job ${id}, which isn't running`); + } + if (row.queue !== queue && !(partial && row.queue === "")) { + fail(`job ${id} of another queue`); + } + const owner = row.attemptedBy?.at(-1); + if (owner !== attemptedBy && !(partial && owner === undefined)) { + fail(`job ${id}, which another client claimed`); + } + } + return checked; +} diff --git a/js/src/runtime/queue-producer.ts b/js/src/runtime/queue-producer.ts new file mode 100644 index 000000000..4bf0f84af --- /dev/null +++ b/js/src/runtime/queue-producer.ts @@ -0,0 +1,845 @@ +/** + * The queue producer: one claim loop per configured queue that fills free + * worker capacity while respecting the fetch cooldown and poll interval, and + * a control loop that heartbeats configured queues and applies persisted + * pause and resume commands. + * + * Each queue runs as a generation, `starting → running → draining → + * stopped`, which reserves the queue's name until it has fully stopped: a + * second add of the name fails, and a re-add after a removal waits for the + * old generation. With a pilot, each generation has a producer session. + */ +import type { JobClaimResult, QueueRow } from "../driver.js"; +import { ExtensionError, LifecycleError, ValidationError } from "../errors.js"; +import { validateQueueName } from "../identifiers.js"; +import { + abortableDelay, + LinkedAbortSignal, + raceWithAbort, + unrefTimeout, +} from "../internal/abort.js"; +import { + measuredDuration, + millisecondsToDuration, +} from "../internal/duration.js"; +import { deepFreezeJson, jsonValuesEqual, toJsonObject } from "../json.js"; +import type { + PilotDatabase, + PilotProducer, + ProducerStartContext, +} from "../pilot.js"; +import type { AttemptRunner } from "./attempt-runner.js"; +import type { RuntimeContext } from "./context.js"; +import { backOffAfterFailure, retryDatabaseOperation } from "./context.js"; +import { + copyQueueMetadataText, + queueMetadataText, +} from "../internal/queue-metadata-text.js"; +import { describeError, isPermanentRuntimeError } from "./failures.js"; +import { ClaimHandoffError, ProducerSession } from "./producer-session.js"; +import type { + QueueRuntimeDiagnostics, + QueueSettings, + ResolvedQueue, +} from "./settings.js"; + +/** Configuration for a {@link QueueProducer}. */ +export interface QueueProducerOptions { + /** How often persisted queue controls are polled. */ + readonly controlPollIntervalMs: number; + /** The kinds claims are limited to, or empty to claim every kind. */ + readonly fetchKinds: readonly string[]; + /** How often configured queues' rows are refreshed. */ + readonly heartbeatIntervalMs: number; + /** The pilot's producer sessions, when it has any. */ + readonly pilot?: { + readonly database: PilotDatabase; + /** How often sessions report themselves alive. */ + readonly reportIntervalMs: number; + readonly startProducer: ( + context: ProducerStartContext + ) => Promise>; + }; +} + +/** One generation of a configured queue. */ +interface QueueRuntime { + /** Aborts once the generation stops claiming. */ + readonly abort: AbortController; + config: Required; + /** Settles once the generation drained and its session shut down. */ + drained: Promise | undefined; + /** Serializes claims and configuration changes. */ + gate: Promise; + lastClaimStartedAtMs: number; + paused: boolean; + /** + * The metadata last offered to the session, accepted or not, so a + * rejected value is offered and logged once, like River for Go. + */ + offered: QueueRow["metadata"] | undefined; + /** The stored text of {@link offered}. */ + offeredText: string | undefined; + /** + * The queue's settings parsed by the client's pilot, replaced + * together with `config`. + */ + pilotSettings: unknown; + /** The latest persisted queue row the session accepted. */ + queue: QueueRow | undefined; + /** The removal of this generation, once one began. */ + removal: Promise | undefined; + session: ProducerSession | undefined; + /** Settles once the generation started, or failed to. */ + readonly started: Promise; + state: "draining" | "running" | "starting" | "stopped"; + task: Promise; + wake: (() => void) | null; +} + +/** Claims jobs for the configured queues and hands them to the runner. */ +export class QueueProducer { + readonly #context: RuntimeContext; + readonly #controlPollIntervalMs: number; + readonly #fetchKinds: readonly string[]; + readonly #heartbeatIntervalMs: number; + readonly #pilot: QueueProducerOptions["pilot"]; + /** Every generation, from the start of its start to the end of its drain. */ + readonly #queues = new Map(); + readonly #runner: AttemptRunner; + #wakeControl: (() => void) | null = null; + + constructor( + context: RuntimeContext, + runner: AttemptRunner, + options: QueueProducerOptions + ) { + this.#context = context; + this.#controlPollIntervalMs = options.controlPollIntervalMs; + this.#fetchKinds = options.fetchKinds; + this.#heartbeatIntervalMs = options.heartbeatIntervalMs; + this.#pilot = options.pilot; + this.#runner = runner; + } + + /** + * Start a queue added, already validated, while the runtime runs. A + * queue being removed is replaced once its old generation stopped. + */ + async add(name: string, queue: ResolvedQueue): Promise { + const validatedName = validateQueueName(name); + const existing = this.#queues.get(validatedName); + if (existing !== undefined) { + if (existing.removal === undefined) { + throw new ValidationError( + `queue ${validatedName} is already configured` + ); + } + await existing.removal.catch(() => undefined); + if (this.#queues.has(validatedName)) { + throw new ValidationError( + `queue ${validatedName} is already configured` + ); + } + } + await this.start(validatedName, queue, true); + } + + /** Apply a queue command already committed by this client. */ + applyCommittedControl(queue: QueueRow): void { + const runtime = this.#queues.get(queue.name); + if (runtime === undefined) return; + runtime.paused = queue.pausedAt !== null; + runtime.wake?.(); + } + + /** Each running queue's effective configuration and pause state. */ + diagnostics(): Readonly> { + return Object.fromEntries( + [...this.#queues] + .filter(([, runtime]) => runtime.state !== "starting") + .map(([name, runtime]) => [ + name, + { + fetchCooldown: millisecondsToDuration( + runtime.config.fetchCooldownMs + ), + maxWorkers: runtime.config.maxWorkers, + paused: runtime.paused, + pollInterval: millisecondsToDuration(runtime.config.pollIntervalMs), + }, + ]) + ); + } + + /** Reload one queue's persisted controls after a control notification. */ + async refresh(name: string): Promise { + const runtime = this.#queues.get(name); + if (runtime === undefined) return; + let queue: QueueRow | null; + try { + queue = await this.#context.driver.queueGet(name); + } catch (error: unknown) { + if (isPermanentRuntimeError(error)) throw error; + // The queue control poll applies the change on its next pass. + this.#context.logger.warn("River queue control refresh failed", { + error: describeError(error), + queue: name, + }); + return; + } + if (queue === null) return; + await this.#applyQueueControl(runtime, queue); + } + + /** Reload every queue's persisted controls. */ + async refreshAll(): Promise { + await Promise.all( + [...this.#queues.keys()].map((name) => this.refresh(name)) + ); + } + + /** + * Stop claiming a queue, drain its attempts, and shut its session down. + * The name stays reserved until then, and a second removal joins the + * first. + */ + remove(name: string): Promise { + let validatedName: string; + try { + validatedName = validateQueueName(name); + } catch (error: unknown) { + return Promise.reject(error); + } + const runtime = this.#queues.get(validatedName); + if (runtime === undefined) return Promise.resolve(false); + runtime.removal ??= (async () => { + const ran = await runtime.started.then( + () => true, + () => false + ); + await this.#drain(runtime, new LifecycleError("River queue was removed")); + if (this.#queues.get(validatedName) === runtime) { + this.#queues.delete(validatedName); + } + if (!ran) return false; + await this.#context.emit({ + at: this.#context.now(), + kind: "queue_removed", + queueName: validatedName, + }); + return true; + })(); + return runtime.removal; + } + + /** + * Poll persisted queue controls until the runtime stops claiming, + * refreshing each configured queue's heartbeat row on its interval. + */ + async runControlLoop(): Promise { + const signal = this.#context.claimSignal; + const upsert = this.#context.driver.runtimeQueueUpsert?.bind( + this.#context.driver + ); + if (upsert === undefined) { + throw new LifecycleError( + "runtime backend cannot persist configured queue heartbeats" + ); + } + let nextHeartbeatAt = 0; + let failures = 0; + try { + while (!signal.aborted) { + const nowMs = this.#context.now().epochMilliseconds; + const heartbeat = nowMs >= nextHeartbeatAt; + try { + for (const [name, runtime] of this.#queues) { + if (runtime.state !== "running") continue; + // A stop ends the wait for a connection during an outage. A read + // is simply abandoned; a heartbeat only stops waiting to start, + // so it never commits after the runtime stopped. + const queue = heartbeat + ? await upsert(name, this.#context.now(), { signal }) + : await raceWithAbort( + this.#context.driver.queueGet(name), + signal + ); + if (queue === null) continue; + await this.#applyQueueControl(runtime, queue); + } + } catch (error: unknown) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the signal can abort while awaiting + if (signal.aborted) return; + failures += 1; + await backOffAfterFailure( + this.#context, + "queue control poll", + error, + failures, + signal + ); + continue; + } + failures = 0; + if (heartbeat) { + nextHeartbeatAt = nowMs + this.#heartbeatIntervalMs; + } + await this.#waitForQueueControl(signal); + } + } catch (error: unknown) { + if (!signal.aborted) throw error; + } + } + + /** + * Persist a queue, load its controls, start its producer session, and + * start its claim loop. Claims begin only after the controls load, so a + * paused queue never claims. The name is reserved from the start; a + * failed start releases it. + */ + async start( + name: string, + resolved: ResolvedQueue, + emit: boolean + ): Promise { + let settle!: (error?: unknown) => void; + const started = new Promise((resolve, reject) => { + settle = (error) => { + if (error === undefined) resolve(); + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- the start's own failure + else reject(error); + }; + }); + void started.catch(() => undefined); + const runtime: QueueRuntime = { + abort: new AbortController(), + config: resolved.config, + drained: undefined, + gate: Promise.resolve(), + lastClaimStartedAtMs: Number.NEGATIVE_INFINITY, + offered: undefined, + offeredText: undefined, + paused: false, + pilotSettings: resolved.pilotSettings, + queue: undefined, + removal: undefined, + session: undefined, + started, + state: "starting", + task: Promise.resolve(), + wake: null, + }; + this.#queues.set(name, runtime); + let queue: QueueRow; + try { + queue = await this.#upsert(name); + runtime.queue = queue; + runtime.paused = queue.pausedAt !== null; + runtime.session = await this.#startSession(name, runtime, queue); + } catch (error: unknown) { + runtime.state = "stopped"; + if (this.#queues.get(name) === runtime) this.#queues.delete(name); + settle(error); + throw error; + } + runtime.state = "running"; + runtime.task = this.#context.guard(this.#queueLoop(name, runtime)); + this.#context.trackTask(runtime.task); + runtime.session?.startReports(); + settle(); + if (emit) { + await this.#context.emit({ + at: this.#context.now(), + kind: "queue_added", + queue, + }); + } + } + + /** + * Stop claiming every queue, drain their attempts, stop their sessions' + * reports, and shut the sessions down. + */ + async drainAll(): Promise { + await Promise.all( + [...this.#queues.values()].map((runtime) => + this.#drain(runtime, new LifecycleError("River runtime is stopping")) + ) + ); + } + + /** + * Replace a running queue's configuration, already validated, and reload + * its controls. + */ + async update(name: string, resolved: ResolvedQueue): Promise { + const validatedName = validateQueueName(name); + const runtime = this.#queues.get(validatedName); + if (runtime?.state !== "running" || runtime.removal !== undefined) { + throw new ValidationError(`queue ${validatedName} is not configured`); + } + const queue = await this.#upsert(validatedName); + await this.#withGate(runtime, () => { + if (runtime.state !== "running") { + throw new LifecycleError(`queue ${validatedName} is stopping`); + } + // The session validates the whole configuration before River + // applies any of it. + runtime.session?.configurationChanged({ + maxWorkers: resolved.config.maxWorkers, + metadataText: queueMetadataText(queue), + queue: frozenQueueRow(queue), + settings: resolved.pilotSettings, + }); + runtime.config = resolved.config; + runtime.pilotSettings = resolved.pilotSettings; + runtime.offered = queue.metadata; + runtime.offeredText = queueMetadataText(queue); + runtime.queue = queue; + runtime.paused = queue.pausedAt !== null; + }); + runtime.wake?.(); + await this.#context.emit({ + at: this.#context.now(), + kind: "queue_reconfigured", + queue, + }); + } + + /** Wake one queue's claim loop, if it is waiting. */ + wake(name: string): void { + this.#queues.get(name)?.wake?.(); + } + + /** Wake every queue's claim loop. */ + wakeAll(): void { + for (const runtime of this.#queues.values()) runtime.wake?.(); + } + + /** Wake the queue control poll, if it is waiting. */ + wakeControl(): void { + this.#wakeControl?.(); + } + + async #applyQueueControl( + runtime: QueueRuntime, + queue: QueueRow + ): Promise { + this.#offerPersistedQueue(runtime, queue); + const paused = queue.pausedAt !== null; + if (paused === runtime.paused) return; + runtime.paused = paused; + runtime.wake?.(); + await this.#context.emit({ + at: this.#context.now(), + kind: paused ? "queue_paused" : "queue_resumed", + queue, + }); + } + + async #queueLoop(queue: string, runtime: QueueRuntime): Promise { + const active = new Set>(); + const attempts = new Set>(); + let claimFailures = 0; + let databaseLikelyHasMore = false; + const link = new LinkedAbortSignal([ + this.#context.claimSignal, + runtime.abort.signal, + ]); + const signal = link.signal; + try { + while (!signal.aborted) { + // Match River's producer: one claim may fill every currently + // available worker slot. Completion ownership remains independently + // bounded, so a large worker pool cannot create an unbounded + // persistence backlog. + const capacity = runtime.config.maxWorkers - active.size; + // Like River for Go's producer after a full fetch, claim again as + // soon as any worker slot frees, subject to the fetch cooldown. + if (databaseLikelyHasMore && capacity <= 0 && active.size > 0) { + await Promise.race(active); + continue; + } + if (!runtime.paused && capacity > 0) { + await this.#waitForFetchCooldown(runtime, signal); + // Slots that freed during the cooldown join this claim, as Go + // sizes a fetch when it starts. + const limit = runtime.config.maxWorkers - active.size; + if (limit <= 0) continue; + runtime.lastClaimStartedAtMs = performance.now(); + const claimStartedAtMs = runtime.lastClaimStartedAtMs; + // Cancellations of jobs not yet worked here that arrive while + // this claim runs apply to the attempts it starts. + const cancellations = this.#runner.watchCancellations(); + let claimedCount = 0; + try { + let claim: JobClaimResult; + try { + const params = { + attemptedBy: this.#context.clientId, + // Without `fetchOnlyKnownKinds`, River claims every kind in a + // configured queue. Unknown kinds consume an attempt and + // persist a compatible execution error instead of remaining + // stranded indefinitely. + kinds: this.#fetchKinds, + queues: [{ limit, name: queue }], + }; + const session = runtime.session; + claim = await this.#withGate(runtime, () => { + // A claim queued behind a configuration change doesn't start + // once the queue stopped claiming. + signal.throwIfAborted(); + return session === undefined + ? // A stop ends a wait for a connection, never a started + // claim. + this.#context.driver.jobClaim(params, { signal }) + : session.claim(params, limit, signal, (id) => + this.#runner.isActive(id) + ); + }); + } catch (error: unknown) { + // Rows a session claimed against its contract are left to the + // rescuer, and the runtime stops, even when the claim raced a + // stop or the queue's removal. + if (error instanceof ClaimHandoffError) { + this.#context.logger.error( + "River producer's claim broke its contract; the runtime stops", + { error: describeError(error), queue } + ); + throw error; + } + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the signal can abort while awaiting + if (signal.aborted) throw error; + claimFailures += 1; + await backOffAfterFailure( + this.#context, + "job claim", + error, + claimFailures, + signal, + { queue } + ); + continue; + } + claimFailures = 0; + this.#context.emitMetric({ + duration: measuredDuration(performance.now() - claimStartedAtMs), + name: "job_get_available_duration", + queue, + }); + // Every claimed job, including one whose row couldn't be decoded, + // is now running and needs an attempt, started in claim order + // like River for Go. An undecodable job isn't worked; its attempt + // fails with the decode error. + const claimed = claim.jobs.map((job) => ({ + decodeError: claim.decodeErrors?.get(job.id), + job, + })); + this.#context.emitMetric({ + count: claimed.length, + name: "job_get_available_count", + queue, + }); + for (const { decodeError, job } of claimed) { + let releaseCapacity!: () => void; + const capacityReleased = new Promise((resolve) => { + let released = false; + releaseCapacity = () => { + if (released) return; + released = true; + resolve(); + }; + }); + const execution = this.#runner.run( + job, + releaseCapacity, + decodeError, + cancellations.cancelled(job.id) + ); + const attempt = execution.finally(() => { + attempts.delete(attempt); + releaseCapacity(); + // The attempt ended and its outcome went to the completer. + runtime.session?.jobFinished(job); + }); + attempts.add(attempt); + void attempt.catch((error: unknown) => this.#context.fail(error)); + const slot = Promise.race([attempt, capacityReleased]).finally( + () => { + active.delete(slot); + } + ); + void slot.catch(() => undefined); + active.add(slot); + } + claimedCount = claimed.length; + databaseLikelyHasMore = claimed.length === limit; + } finally { + cancellations.end(); + } + if (databaseLikelyHasMore) continue; + if (claimedCount > 0) { + await this.#waitForQueue(runtime, signal); + continue; + } + } + + databaseLikelyHasMore = false; + if (active.size >= runtime.config.maxWorkers) { + await Promise.race(active); + } else { + await this.#waitForQueue(runtime, signal); + } + } + } catch (error: unknown) { + if (!signal.aborted || error instanceof ClaimHandoffError) throw error; + } finally { + // Every attempt settles, and reports its finished job, before the + // generation drains further; a failed attempt already failed the + // runtime. + await Promise.allSettled(attempts); + link[Symbol.dispose](); + } + } + + /** + * Drain a generation once: stop its claims, wait for its attempts, stop + * its session's reports, and shut the session down. + */ + #drain(runtime: QueueRuntime, reason: LifecycleError): Promise { + runtime.drained ??= (async () => { + await runtime.started.catch(() => undefined); + if (runtime.state === "stopped") return; + runtime.state = "draining"; + runtime.abort.abort(reason); + runtime.wake?.(); + // A failed loop already failed the runtime. + await runtime.task.catch(() => undefined); + const session = runtime.session; + if (session !== undefined) { + await session.stopReports().catch((error: unknown) => { + this.#context.logger.warn("River producer reports failed", { + error: describeError(error), + }); + }); + await session.shutdown(); + } + runtime.state = "stopped"; + })(); + return runtime.drained; + } + + /** + * Offer a running generation's session a persisted queue row whose + * metadata changed, between claims. A session that rejects it keeps its + * configuration, and River logs why. + */ + #offerPersistedQueue(runtime: QueueRuntime, queue: QueueRow): void { + const session = runtime.session; + const previous = runtime.queue; + if (session === undefined) { + runtime.queue = queue; + return; + } + // A row read before a later update applied is stale. + if (previous !== undefined && isOlderRow(queue, previous)) return; + const text = queueMetadataText(queue); + if ( + previous !== undefined && + jsonValuesEqual(previous.metadata, queue.metadata) && + queueMetadataText(previous) === text + ) { + runtime.offered = previous.metadata; + runtime.offeredText = text; + const kept = { ...queue, metadata: previous.metadata }; + copyQueueMetadataText(queue, kept); + runtime.queue = kept; + return; + } + // A rejected value is offered, and logged, once. + if ( + runtime.offered !== undefined && + jsonValuesEqual(runtime.offered, queue.metadata) && + runtime.offeredText === text + ) { + return; + } + runtime.offered = queue.metadata; + runtime.offeredText = text; + void this.#withGate(runtime, () => { + if (runtime.state !== "running") return; + if (runtime.queue !== undefined && isOlderRow(queue, runtime.queue)) { + return; + } + try { + session.configurationChanged({ + maxWorkers: runtime.config.maxWorkers, + metadataText: text, + queue: frozenQueueRow(queue), + settings: runtime.pilotSettings, + }); + runtime.queue = queue; + } catch (error: unknown) { + this.#context.logger.error( + "River producer rejected the queue's persisted configuration", + { error: describeError(error), queue: queue.name } + ); + } + }); + } + + /** Start a generation's producer session, when the pilot has one. */ + async #startSession( + name: string, + runtime: QueueRuntime, + queue: QueueRow + ): Promise { + const pilot = this.#pilot; + if (pilot === undefined) return undefined; + const producer: unknown = await pilot.startProducer( + Object.freeze({ + clientId: this.#context.clientId, + database: pilot.database, + maxWorkers: runtime.config.maxWorkers, + metadataText: queueMetadataText(queue), + queue, + settings: runtime.pilotSettings, + // One per queue generation, living as long as its session. + // eslint-disable-next-line no-restricted-properties + signal: AbortSignal.any([ + this.#context.claimSignal, + runtime.abort.signal, + ]), + }) + ); + if (typeof producer !== "object" || producer === null) { + throw new ExtensionError( + `startProducer for queue ${JSON.stringify(name)} returned no producer` + ); + } + return new ProducerSession({ + context: this.#context, + database: pilot.database, + producer, + queue: name, + reportIntervalMs: pilot.reportIntervalMs, + }); + } + + /** Run `operation` once no claim or configuration change is running. */ + #withGate( + runtime: QueueRuntime, + operation: () => PromiseLike | T + ): Promise { + const run = runtime.gate.then(operation); + runtime.gate = run.then( + () => undefined, + () => undefined + ); + return run; + } + + async #upsert(name: string) { + const upsert = this.#context.driver.runtimeQueueUpsert?.bind( + this.#context.driver + ); + if (upsert === undefined) { + throw new LifecycleError( + "runtime backend cannot persist configured queue heartbeats" + ); + } + return retryDatabaseOperation( + () => upsert(name, this.#context.now()), + this.#context.claimSignal + ); + } + + async #waitForFetchCooldown( + runtime: QueueRuntime, + signal: AbortSignal + ): Promise { + const remaining = + runtime.config.fetchCooldownMs - + (performance.now() - runtime.lastClaimStartedAtMs); + if (remaining > 0) await abortableDelay(Math.ceil(remaining), signal); + } + + #waitForQueue(runtime: QueueRuntime, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(signal.reason); + return new Promise((resolve, reject) => { + const cancel = unrefTimeout( + done, + jitteredPollInterval( + runtime.config.pollIntervalMs, + this.#context.random + ) + ); + const onAbort = () => { + cleanup(); + reject(signal.reason); + }; + function cleanup() { + cancel(); + signal.removeEventListener("abort", onAbort); + runtime.wake = null; + } + function done() { + cleanup(); + resolve(); + } + runtime.wake = done; + signal.addEventListener("abort", onAbort, { once: true }); + }); + } + + /** Wait for the queue control poll interval or an explicit wake-up. */ + #waitForQueueControl(signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(signal.reason); + return new Promise((resolve, reject) => { + const cancel = unrefTimeout(done, this.#controlPollIntervalMs); + const onAbort = () => { + cleanup(); + reject(signal.reason); + }; + const cleanup = () => { + cancel(); + signal.removeEventListener("abort", onAbort); + if (this.#wakeControl === done) this.#wakeControl = null; + }; + function done() { + cleanup(); + resolve(); + } + this.#wakeControl = done; + signal.addEventListener("abort", onAbort, { once: true }); + }); + } +} + +/** + * A queue's poll interval plus a random jitter of up to a tenth of it, and + * at least 10 ms, like River for Go's producer, so producers don't poll in + * lockstep after a pause. + */ +function jitteredPollInterval( + intervalMs: number, + random: () => number +): number { + return intervalMs + Math.floor(random() * Math.max(intervalMs / 10, 10)); +} + +/** A queue row the session can keep but not change. */ +function frozenQueueRow(queue: QueueRow): QueueRow { + const frozen = Object.freeze({ + ...queue, + metadata: deepFreezeJson(toJsonObject(queue.metadata)), + }); + copyQueueMetadataText(queue, frozen); + return frozen; +} + +/** Whether a queue row was written before another. */ +function isOlderRow(queue: QueueRow, than: QueueRow): boolean { + return Temporal.Instant.compare(queue.updatedAt, than.updatedAt) < 0; +} diff --git a/js/src/runtime/service-supervisor.ts b/js/src/runtime/service-supervisor.ts new file mode 100644 index 000000000..260770761 --- /dev/null +++ b/js/src/runtime/service-supervisor.ts @@ -0,0 +1,98 @@ +/** + * Supervision of a pilot's background services: each runs independently, + * and one that fails or returns early restarts after backoff. + */ +import { ExtensionError } from "../errors.js"; +import { + BACKGROUND_BACKOFF, + exponentialBackoffMs, + type RuntimeTimer, +} from "../internal/backoff.js"; +import type { InternalLogger } from "../logger.js"; +import type { PilotService } from "../pilot.js"; +import { describeError } from "./failures.js"; + +/** + * How long a service must run before a failure starts its backoff over, + * so a service that keeps failing quickly backs off further each time. + */ +const HEALTHY_RUN_MS = 60_000; + +/** What {@link superviseService} needs from its runtime. */ +export interface SupervisorOptions { + readonly logger: Pick; + readonly random: () => number; + readonly timer: RuntimeTimer; +} + +/** + * Run `service` until `signal` aborts. A run that rejects, or resolves + * before `signal` aborted, is logged and restarted after capped + * exponential backoff with jitter, once it has settled. Backoff ends at + * once when `signal` aborts, and nothing restarts after that. + */ +export async function superviseService( + service: PilotService, + term: Term, + signal: AbortSignal, + options: SupervisorOptions +): Promise { + let failures = 0; + while (!signal.aborted) { + const startedAt = options.timer.now(); + let failure: unknown; + try { + await service.run({ signal, term }); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the signal can abort while awaiting + if (signal.aborted) return; + failure = new ExtensionError( + `service ${service.name} returned while it should still run` + ); + } catch (error: unknown) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the signal can abort while awaiting + if (signal.aborted) return; + failure = error; + } + failures = + options.timer.now() - startedAt >= HEALTHY_RUN_MS ? 1 : failures + 1; + const delayMs = exponentialBackoffMs( + failures, + BACKGROUND_BACKOFF, + options.random + ); + options.logger.error("River service failed; restarting after backoff", { + attempt: failures, + delayMs, + error: describeError(failure), + service: service.name, + }); + try { + await options.timer.delay(delayMs, signal); + } catch { + return; + } + } +} + +/** Check and snapshot a pilot's list of services. */ +export function serviceList( + services: unknown, + what: string +): readonly PilotService[] { + if (!Array.isArray(services)) { + throw new ExtensionError(`a pilot's ${what} must return an array`); + } + for (const service of services as unknown[]) { + if ( + typeof service !== "object" || + service === null || + typeof (service as { readonly name?: unknown }).name !== "string" || + typeof (service as { readonly run?: unknown }).run !== "function" + ) { + throw new ExtensionError( + `each of a pilot's ${what} needs a name and a run function` + ); + } + } + return Object.freeze([...(services as PilotService[])]); +} diff --git a/js/src/runtime/settings.ts b/js/src/runtime/settings.ts new file mode 100644 index 000000000..41979893b --- /dev/null +++ b/js/src/runtime/settings.ts @@ -0,0 +1,755 @@ +/** + * Runtime configuration: settings types, validation, and normalization. + */ +import type { RuntimeDriver } from "../driver.js"; +import { ConfigurationError, ValidationError } from "../errors.js"; +import type { RiverEvent } from "../events.js"; +import type { + InsertMiddleware, + RiverErrorHandler, + RiverHooks, + RiverPlugin, + WorkMiddleware, +} from "../extensions.js"; +import { validateQueueName } from "../identifiers.js"; +import type { RuntimeTimer } from "../internal/backoff.js"; +import type { + EventLoopDelayMonitorOptions, + EventLoopDelayObservation, +} from "../internal/event-loop-delay-monitor.js"; +import type { JobArgsTransformer } from "../job-args-transform.js"; +import { + cloneJobArgsTransformPlugin, + isJobArgsTransformPlugin, +} from "../job-args-transform.js"; +import { getJobArgsTransformers } from "../job-args-transform.js"; +import { + cloneJobInsertMetadataTransformPlugin, + isJobInsertMetadataTransformPlugin, +} from "../job-insert-metadata-transform.js"; +import type { JobRow } from "../job.js"; +import type { JsonObject } from "../json.js"; +import type { Logger } from "../logger.js"; +import type { InternalLogger } from "../logger.js"; +import { isLoggerOption } from "../logger.js"; +import { internalLogger, resolveLogger } from "../logger.js"; +import type { PeriodicJob } from "../periodic.js"; +import type { + MaintenanceDiagnostics, + MaintenanceSettings, +} from "../services.js"; +import { supportsMaintenance } from "../services.js"; +import type { Workers } from "../worker.js"; +import { + rejectExplicitUndefined, + requireNonNegativeInteger, + requirePositiveInteger, +} from "./validation.js"; + +/** River for Go's `FetchCooldownDefault`. */ +export const DEFAULT_FETCH_COOLDOWN_MS = 100; + +/** One queue's capacity and polling configuration, in milliseconds. */ +export interface QueueSettings { + /** Minimum interval between claim queries. Defaults to the client's. */ + readonly fetchCooldownMs?: number; + readonly maxWorkers: number; + /** Polling fallback when no notification arrives. Defaults to 1 second. */ + readonly pollIntervalMs?: number; +} + +/** Information supplied when a timed-out attempt remains unsettled. */ +export interface JobStuckHandlerParams { + readonly id: bigint; + readonly kind: string; + readonly queue: string; + readonly totalStuckJobs: number; +} + +/** Capacity policy returned after observing a stuck attempt. */ +export interface JobStuckHandlerResult { + readonly addWorkerSlot?: boolean; +} + +/** + * Called when an attempt keeps running past its timeout. Return + * `{ addWorkerSlot: true }` to let the queue start another job meanwhile. + */ +export type JobStuckHandler = ( + params: JobStuckHandlerParams +) => + | JobStuckHandlerResult + | PromiseLike + | undefined; + +/** Runtime configuration after conversion from the public client options. */ +export interface RuntimeSettings { + readonly clientId?: string; + readonly completionBatchSize?: number; + readonly completionFlushIntervalMs?: number; + /** Event-loop delay monitoring, enabled by default. */ + readonly eventLoopDelay?: false | EventLoopDelaySettings; + readonly errorHandler?: RiverErrorHandler; + /** + * Default claim cooldown for queues, and how long a queue's insert + * notifications are suppressed after one. Defaults to 100 milliseconds. + */ + readonly fetchCooldownMs?: number; + /** Claim only the kinds `workers` has when the runtime starts. */ + readonly fetchOnlyKnownKinds?: boolean; + readonly hooks?: RiverHooks; + readonly insertMiddleware?: readonly InsertMiddleware[]; + /** + * Wait after a timeout, or after aborting a running handler, before an + * attempt is stuck. Defaults to 10 seconds. + */ + readonly jobStuckThresholdMs?: number; + /** Default cooperative job timeout. Defaults to one minute; null disables it. */ + readonly jobTimeoutMs?: number | null; + /** Never elect this client, so it runs no leader-owned services. */ + readonly leaderElectionDisabled?: boolean; + /** + * Structured logger with pino's `(attributes, message)` argument order. + * Defaults to `console` for warnings and errors; `false` silences River. + */ + readonly logger?: Logger | false; + /** Settings for leader-owned services. */ + readonly maintenance?: MaintenanceSettings; + readonly middleware?: readonly WorkMiddleware[]; + readonly plugins?: readonly RiverPlugin[]; + /** Disable backend notification streams and rely on bounded polling. */ + readonly pollOnly?: boolean; + readonly periodicJobs?: readonly PeriodicJob[]; + readonly queues?: Readonly>; + /** Persisted queue control polling interval. Defaults to 2 seconds. */ + readonly queueControlPollIntervalMs?: number; + /** Configured queue heartbeat interval. Defaults to 30 seconds. */ + readonly queueHeartbeatIntervalMs?: number; + /** Override retry scheduling; invalid times fall back to River's default. */ + readonly retryPolicy?: RetryPolicy; + /** Policy invoked after a timed-out attempt exceeds its stuck threshold. */ + readonly stuckHandler?: JobStuckHandler; + readonly workers?: Workers; +} + +/** Returns when a failed job should next run. */ +export type RetryPolicy = ( + job: Readonly, + now: Temporal.Instant +) => Temporal.Instant; + +/** @internal Validate and snapshot caller-owned runtime configuration. */ +export function normalizeRuntimeSettings( + options: RuntimeSettings +): Readonly { + rejectExplicitUndefined(options); + if (options.clientId !== undefined) validateClientId(options.clientId); + if (options.completionBatchSize !== undefined) { + requirePositiveInteger("completionBatchSize", options.completionBatchSize); + } + if (options.completionFlushIntervalMs !== undefined) { + requireNonNegativeInteger( + "completionFlushInterval", + options.completionFlushIntervalMs + ); + } + if (options.queueControlPollIntervalMs !== undefined) { + requirePositiveInteger( + "queueControlPollInterval", + options.queueControlPollIntervalMs + ); + } + if (options.queueHeartbeatIntervalMs !== undefined) { + requirePositiveInteger( + "queueHeartbeatInterval", + options.queueHeartbeatIntervalMs + ); + } + if (options.fetchCooldownMs !== undefined) { + requirePositiveInteger("fetchCooldown", options.fetchCooldownMs); + } + if (options.jobStuckThresholdMs !== undefined) { + requireNonNegativeInteger("jobStuckThreshold", options.jobStuckThresholdMs); + } + if (options.jobTimeoutMs !== undefined && options.jobTimeoutMs !== null) { + requirePositiveInteger("jobTimeout", options.jobTimeoutMs); + } + if (options.logger !== undefined && !isLoggerOption(options.logger)) { + throw new ValidationError( + "logger must implement debug, info, warn, and error, or be false" + ); + } + if ( + options.stuckHandler !== undefined && + typeof options.stuckHandler !== "function" + ) { + throw new ValidationError("stuckHandler must be a function"); + } + normalizeEventLoopDelay(options.eventLoopDelay); + validatePlugins(options.plugins, "client"); + if (options.workers !== undefined) requireWorkers(options.workers); + if ( + options.fetchOnlyKnownKinds !== undefined && + typeof options.fetchOnlyKnownKinds !== "boolean" + ) { + throw new ValidationError("fetchOnlyKnownKinds must be a boolean"); + } + if ( + options.leaderElectionDisabled !== undefined && + typeof options.leaderElectionDisabled !== "boolean" + ) { + throw new ValidationError("leaderElectionDisabled must be a boolean"); + } + if ( + options.leaderElectionDisabled === true && + options.periodicJobs !== undefined && + options.periodicJobs.length > 0 + ) { + throw new ConfigurationError( + "periodicJobs must be empty when leaderElectionDisabled is true, because this client never leads" + ); + } + + const plugins = options.plugins?.map((plugin) => { + const clone = { + ...(plugin.hooks === undefined + ? {} + : { hooks: Object.freeze({ ...plugin.hooks }) }), + ...(plugin.insertMiddleware === undefined + ? {} + : { + insertMiddleware: Object.freeze([...plugin.insertMiddleware]), + }), + ...(plugin.middleware === undefined + ? {} + : { middleware: Object.freeze([...plugin.middleware]) }), + name: plugin.name, + }; + cloneJobArgsTransformPlugin(plugin, clone); + cloneJobInsertMetadataTransformPlugin(plugin, clone); + return Object.freeze(clone); + }); + const maintenance = + options.maintenance === undefined + ? undefined + : Object.freeze({ + ...options.maintenance, + ...(options.maintenance.reindexerIndexNames === undefined + ? {} + : { + reindexerIndexNames: Object.freeze([ + ...options.maintenance.reindexerIndexNames, + ]), + }), + }); + return Object.freeze({ + ...options, + ...(options.eventLoopDelay === undefined || options.eventLoopDelay === false + ? {} + : { eventLoopDelay: Object.freeze({ ...options.eventLoopDelay }) }), + ...(options.hooks === undefined + ? {} + : { hooks: Object.freeze({ ...options.hooks }) }), + ...(options.insertMiddleware === undefined + ? {} + : { + insertMiddleware: Object.freeze([...options.insertMiddleware]), + }), + ...(maintenance === undefined ? {} : { maintenance }), + ...(options.middleware === undefined + ? {} + : { middleware: Object.freeze([...options.middleware]) }), + ...(options.periodicJobs === undefined + ? {} + : { periodicJobs: Object.freeze([...options.periodicJobs]) }), + ...(plugins === undefined ? {} : { plugins: Object.freeze(plugins) }), + ...(options.queues === undefined + ? {} + : { + queues: normalizeQueues( + options.queues, + options.fetchCooldownMs ?? DEFAULT_FETCH_COOLDOWN_MS + ), + }), + }); +} + +/** @internal Where the runtime publishes events and waits for delivery. */ +export interface RuntimeEventSink { + drain(): Promise; + emit(event: RiverEvent): Promise; +} + +/** + * Replacement clock, randomness, and timers for a client's runtime, so + * tests drive River's timing deterministically. See + * `overrideRuntimeTiming` in `riverqueue/unstable-driver`. + */ +export interface RuntimeTiming { + /** Wall-clock time River records. Default: `Temporal.Now.instant()`. */ + readonly now?: () => Temporal.Instant; + /** Numbers in `[0, 1)` for jitter. Default: `Math.random`. */ + readonly random?: () => number; + /** + * Every delay, deadline, and interval River waits for, and the monotonic + * clock they count against. Default: unreferenced `setTimeout` handles + * and `performance.now()`. + */ + readonly timer?: RuntimeTimer; +} + +/** Event-loop delay monitoring configuration, in milliseconds. */ +export interface EventLoopDelaySettings { + readonly reportIntervalMs?: number; + readonly resolutionMs?: number; + readonly warningThresholdMs?: number; +} + +/** A running client's lifecycle state. */ +export type RunState = "failed" | "running" | "stopped" | "stopping"; + +/** A snapshot of a running client, from `run.diagnostics`. */ +export interface RunDiagnostics { + readonly activeAttempts: number; + readonly clientId: string; + readonly completionCapacity: number; + readonly completionQueries: number; + readonly eventLoopDelay: EventLoopDelayObservation | null; + readonly maintenance: MaintenanceDiagnostics | null; + readonly pendingCompletions: number; + readonly queues: Readonly>; + readonly state: RunState; + readonly executors: Readonly>; +} + +/** One queue's configuration and pause state in {@link RunDiagnostics}. */ +export interface QueueRuntimeDiagnostics { + readonly fetchCooldown: Temporal.Duration; + readonly maxWorkers: number; + readonly paused: boolean; + readonly pollInterval: Temporal.Duration; +} + +/** Stop configuration after conversion from the public stop options. */ +export interface StopSettings { + readonly mode?: "cancel" | "graceful"; + readonly signal?: AbortSignal; + readonly timeoutMs?: number; +} + +/** + * @internal Queue configuration keys a pilot owns, and how it parses them + * into its own settings for one queue. + */ +export interface PilotQueueParser { + readonly keys: ReadonlySet; + parse(queue: string, config: Readonly>): unknown; +} + +/** @internal One queue's validated configuration. */ +export interface ResolvedQueue { + readonly config: Required; + /** The pilot's parsed settings, or undefined without a pilot parser. */ + readonly pilotSettings: unknown; +} + +/** River's own queue keys, after conversion to settings. */ +const QUEUE_SETTINGS_KEYS: ReadonlySet = new Set([ + "fetchCooldownMs", + "maxWorkers", + "pollIntervalMs", +]); + +/** + * @internal Validate one queue's configuration: River's keys, and the keys the + * pilot owns, which it parses synchronously. Any other key is rejected. + * Nothing is changed when this throws. + */ +export function resolveQueueConfig( + name: string, + config: QueueSettings, + parser: PilotQueueParser | undefined, + defaultFetchCooldownMs: number +): ResolvedQueue { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- validates untyped JavaScript input + if (config === null || typeof config !== "object") { + return { + config: normalizeQueueConfig(config, defaultFetchCooldownMs), + pilotSettings: undefined, + }; + } + const own: Record = {}; + const owned: Record = {}; + for (const [key, value] of Object.entries(config)) { + if (QUEUE_SETTINGS_KEYS.has(key)) own[key] = value; + else if (parser?.keys.has(key) === true) owned[key] = value; + else throw unknownQueueOption(name, key); + } + const normalized = normalizeQueueConfig( + own as unknown as QueueSettings, + defaultFetchCooldownMs + ); + if (parser === undefined) { + return { config: normalized, pilotSettings: undefined }; + } + const parsed = parser.parse(name, Object.freeze(owned)); + if ( + (typeof parsed === "object" || typeof parsed === "function") && + parsed !== null && + typeof (parsed as { readonly then?: unknown }).then === "function" + ) { + throw new ConfigurationError( + "a pilot's queue option parser must return its settings synchronously" + ); + } + return { config: normalized, pilotSettings: parsed }; +} + +/** @internal Validate every configured queue, requiring at least one. */ +export function resolveQueues( + queues: Readonly> | undefined, + parser: PilotQueueParser | undefined, + defaultFetchCooldownMs: number +): Readonly> { + if (queues === undefined || Object.keys(queues).length === 0) { + throw new ValidationError("runtime requires at least one configured queue"); + } + const result = Object.create(null) as Record; + for (const [name, config] of Object.entries(queues)) { + result[validateQueueName(name)] = Object.freeze( + resolveQueueConfig(name, config, parser, defaultFetchCooldownMs) + ); + } + return Object.freeze(result); +} + +function unknownQueueOption(queue: string, key: string): ValidationError { + return new ValidationError( + `queue ${JSON.stringify(queue)} has no option ${JSON.stringify(key)}`, + { details: { option: key, queue } } + ); +} + +/** Validate the configured queues, requiring at least one. */ +function normalizeQueues( + queues: Readonly> | undefined, + defaultFetchCooldownMs: number +): Readonly>> { + if (queues === undefined || Object.keys(queues).length === 0) { + throw new ValidationError("runtime requires at least one configured queue"); + } + const result = Object.create(null) as Record>; + for (const [name, config] of Object.entries(queues)) { + result[validateQueueName(name)] = resolveQueueConfig( + name, + config, + undefined, + defaultFetchCooldownMs + ).config; + } + return Object.freeze(result); +} + +/** + * Validate one queue configuration and fill in its defaults, taking its + * claim cooldown from the client's unless it sets its own. + */ +function normalizeQueueConfig( + config: QueueSettings, + defaultFetchCooldownMs: number +): Required { + rejectExplicitUndefined(config); + const fetchCooldownMs = requirePositiveInteger( + "queue fetchCooldown", + config.fetchCooldownMs ?? defaultFetchCooldownMs + ); + const pollIntervalMs = requirePositiveInteger( + "queue pollInterval", + config.pollIntervalMs ?? 1_000 + ); + if (pollIntervalMs < fetchCooldownMs) { + throw new ValidationError( + "queue pollInterval cannot be shorter than fetchCooldown, which defaults to the client's fetchCooldown" + ); + } + const maxWorkers = requirePositiveInteger( + "queue maxWorkers", + config.maxWorkers + ); + if (maxWorkers > 10_000) { + throw new ValidationError("queue maxWorkers must be at most 10000"); + } + return Object.freeze({ + fetchCooldownMs, + maxWorkers, + pollIntervalMs, + }); +} + +/** Validate event-loop delay settings, or null when monitoring is off. */ +function normalizeEventLoopDelay( + options: false | EventLoopDelaySettings | undefined +): EventLoopDelayMonitorOptions | null { + if (options === false) return null; + const value = options ?? {}; + rejectExplicitUndefined(value); + return { + reportIntervalMs: requirePositiveInteger( + "eventLoopDelay.reportInterval", + value.reportIntervalMs ?? 1_000 + ), + resolutionMs: requirePositiveInteger( + "eventLoopDelay.resolution", + value.resolutionMs ?? 20 + ), + warningThresholdMs: requireNonNegativeInteger( + "eventLoopDelay.warningThreshold", + value.warningThresholdMs ?? 100 + ), + }; +} + +/** Require a worker registry with at least one registered kind. */ +function requireWorkers(workers: Workers | undefined): Workers { + if (workers === undefined || workers.size === 0) { + throw new ValidationError( + "runtime requires at least one registered worker" + ); + } + return workers; +} + +/** Require a client ID of 1 to 100 characters. */ +function validateClientId(value: string): string { + if (value.length === 0 || value.length > 100) { + throw new ValidationError( + "clientId must contain between 1 and 100 characters" + ); + } + return value; +} + +/** + * Reject duplicate or unnamed plugins, and client-only plugins configured + * on a worker. + */ +export function validatePlugins( + plugins: readonly { readonly name: string }[] | undefined, + scope: "client" | "worker" +): void { + const names = new Set(); + for (const plugin of plugins ?? []) { + if (scope === "worker" && isJobArgsTransformPlugin(plugin)) { + throw new ValidationError( + "job argument transform plugins must be configured on Client" + ); + } + if (scope === "worker" && isJobInsertMetadataTransformPlugin(plugin)) { + throw new ValidationError( + "job insert metadata transform plugins must be configured on Client" + ); + } + if (plugin.name.length === 0) + throw new ValidationError("plugin name is empty"); + if (names.has(plugin.name)) { + throw new ValidationError(`duplicate plugin name ${plugin.name}`); + } + names.add(plugin.name); + } +} + +/** A unique client ID for this process. */ +export function makeClientId(): string { + return `riverqueue-js-${process.pid}-${crypto.randomUUID()}`; +} + +/** River's default scheduler interval, which bounds the near-future fast path. */ +const DEFAULT_SCHEDULER_INTERVAL_MS = 5_000; + +/** @internal Runtime settings validated against the driver, with defaults applied. */ +export interface ResolvedRuntimeSettings { + readonly clientId: string; + readonly completionBatchSize: number; + readonly completionFlushIntervalMs: number; + readonly errorHandler: RiverErrorHandler | undefined; + /** Event-loop delay monitoring, or null when it is disabled. */ + readonly eventLoopDelay: EventLoopDelayMonitorOptions | null; + readonly fetchCooldownMs: number; + /** + * The kinds claims are limited to, sorted, or empty to claim every kind. + * `workers` is never empty, so neither is this when claims are limited. + */ + readonly fetchKinds: readonly string[]; + /** Client hooks, plugin hooks first. */ + readonly hooks: readonly RiverHooks[]; + readonly jobArgsTransformers: readonly Readonly[]; + readonly jobStuckThresholdMs: number; + readonly jobTimeoutMs: number | null; + readonly logger: InternalLogger; + /** Settings for leader-owned services, or null when they do not run. */ + readonly maintenance: MaintenanceSettings | null; + /** Client work middleware, plugin middleware first. */ + readonly middleware: readonly WorkMiddleware[]; + readonly pollOnly: boolean; + readonly queueControlPollIntervalMs: number; + readonly queueHeartbeatIntervalMs: number; + readonly queues: Readonly>>; + readonly retryPolicy: RetryPolicy | undefined; + readonly schedulerIntervalMs: number; + readonly stuckHandler: JobStuckHandler | undefined; + readonly workLogger: Logger; + readonly workers: Workers; +} + +/** + * @internal Validate runtime settings against `driver` and apply defaults. This runs + * the driver's start preflight, so a misconfigured backend fails before any + * runtime work begins. + */ +export function resolveRuntimeSettings( + driver: RuntimeDriver, + options: RuntimeSettings +): ResolvedRuntimeSettings { + const workers = requireWorkers(options.workers); + const clientId = validateClientId(options.clientId ?? makeClientId()); + const fetchCooldownMs = requirePositiveInteger( + "fetchCooldown", + options.fetchCooldownMs ?? DEFAULT_FETCH_COOLDOWN_MS + ); + const queues = normalizeQueues(options.queues, fetchCooldownMs); + // Like Go, the kinds are those registered when the runtime starts. + const fetchKinds = Object.freeze( + options.fetchOnlyKnownKinds === true ? [...workers.kinds()].sort() : [] + ); + const pollOnly = options.pollOnly ?? false; + const maintenanceEnabled = options.leaderElectionDisabled !== true; + const reindexEnabled = + maintenanceEnabled && + driver.maintenanceReindex !== undefined && + (options.maintenance === undefined || + options.maintenance.reindexerIndexNames === undefined || + options.maintenance.reindexerIndexNames.length > 0); + driver.runtimeStartPreflight?.({ + maintenance: maintenanceEnabled, + notifications: !pollOnly, + reindex: reindexEnabled, + }); + const queueControlPollIntervalMs = requirePositiveInteger( + "queueControlPollInterval", + options.queueControlPollIntervalMs ?? 2_000 + ); + const queueHeartbeatIntervalMs = requirePositiveInteger( + "queueHeartbeatInterval", + options.queueHeartbeatIntervalMs ?? 30_000 + ); + const jobTimeoutMs = + options.jobTimeoutMs === undefined + ? 60_000 + : options.jobTimeoutMs === null + ? null + : requirePositiveInteger("jobTimeout", options.jobTimeoutMs); + const jobStuckThresholdMs = requireNonNegativeInteger( + "jobStuckThreshold", + options.jobStuckThresholdMs ?? 10_000 + ); + const middleware = Object.freeze([ + ...(options.plugins?.flatMap((plugin) => plugin.middleware ?? []) ?? []), + ...(options.middleware ?? []), + ]); + const hooks = Object.freeze([ + ...(options.plugins?.flatMap((plugin) => + plugin.hooks === undefined ? [] : [plugin.hooks] + ) ?? []), + ...(options.hooks === undefined ? [] : [options.hooks]), + ]); + const jobArgsTransformers = Object.freeze( + getJobArgsTransformers(options.plugins) + ); + const workLogger = resolveLogger(options.logger); + validatePlugins(options.plugins, "client"); + const eventLoopDelay = normalizeEventLoopDelay(options.eventLoopDelay); + const completionBatchSize = requirePositiveInteger( + "completionBatchSize", + options.completionBatchSize ?? 100 + ); + const completionFlushIntervalMs = requireNonNegativeInteger( + "completionFlushInterval", + options.completionFlushIntervalMs ?? 50 + ); + if ( + maintenanceEnabled && + options.maintenance !== undefined && + !supportsMaintenance(driver) + ) { + throw new ValidationError( + "maintenance was configured but the runtime backend does not support it" + ); + } + const maintenance = resolveMaintenanceSettings( + options.maintenance, + jobTimeoutMs, + options.jobTimeoutMs !== undefined + ); + return Object.freeze({ + clientId, + completionBatchSize, + completionFlushIntervalMs, + errorHandler: options.errorHandler, + eventLoopDelay, + fetchCooldownMs, + fetchKinds, + hooks, + jobArgsTransformers, + jobStuckThresholdMs, + jobTimeoutMs, + logger: internalLogger(workLogger), + maintenance: + !maintenanceEnabled || !supportsMaintenance(driver) ? null : maintenance, + middleware, + pollOnly, + queueControlPollIntervalMs, + queueHeartbeatIntervalMs, + queues, + retryPolicy: options.retryPolicy, + // Retries and snoozes due within one scheduler pass are persisted as + // available, as River's executor does, whether or not this runtime runs + // the scheduler itself. + schedulerIntervalMs: + maintenance.schedulerIntervalMs ?? DEFAULT_SCHEDULER_INTERVAL_MS, + stuckHandler: options.stuckHandler, + workLogger, + workers, + }); +} + +/** + * Maintenance settings for the configured job timeout. Unless configured, + * the rescuer waits an hour past an explicitly configured job timeout, and + * it may never rescue sooner than the timeout. + */ +function resolveMaintenanceSettings( + value: MaintenanceSettings | undefined, + timeoutMs: number | null, + jobTimeoutConfigured: boolean +): MaintenanceSettings { + if ( + value?.rescueAfterMs !== undefined && + timeoutMs !== null && + value.rescueAfterMs < timeoutMs + ) { + throw new ValidationError( + "maintenance.rescueAfter cannot be shorter than the longest job timeout" + ); + } + if ( + value?.rescueAfterMs !== undefined || + !jobTimeoutConfigured || + timeoutMs === null + ) { + return value ?? {}; + } + return { + ...value, + rescueAfterMs: timeoutMs + 3_600_000, + }; +} diff --git a/js/src/runtime/work-context.ts b/js/src/runtime/work-context.ts new file mode 100644 index 000000000..77deaa935 --- /dev/null +++ b/js/src/runtime/work-context.ts @@ -0,0 +1,244 @@ +/** + * The work context of a running attempt: async-local lookup, attempt + * metadata and output, and the decoded context handed to handlers. + */ +import { AsyncLocalStorage } from "node:async_hooks"; +import { Buffer } from "node:buffer"; +import { channel } from "node:diagnostics_channel"; +import type { JobCompletionCommand, RuntimeDriver } from "../driver.js"; +import { LifecycleError, ValidationError } from "../errors.js"; +import type { ErrorHandlerContext, WorkAttemptResult } from "../extensions.js"; +import type { JobRow } from "../job.js"; +import type { JsonObject, JsonValue } from "../json.js"; +import { stringifyJson, toJsonObject, toJsonValue } from "../json.js"; +import { Resumable } from "../resumable.js"; +import type { WorkAttemptContext, WorkContext } from "../worker.js"; +import type { PilotOperations } from "./pilot-operations.js"; + +/** The work context `currentWorkContext()` returns inside a job. */ +export type CurrentWorkContext = WorkAttemptContext | WorkContext; + +/** Either context shape a handler, hook, or middleware may hold. */ +export type AnyWorkContext = CurrentWorkContext; + +/** Output recorded on an attempt; `output` is absent until something is recorded. */ +export interface WorkOutputState { + output?: JsonValue; +} + +/** Metadata an attempt accumulates through `setMetadata`. */ +export interface WorkMetadataState { + /** Cleared when the attempt ends, after which writes are rejected. */ + active: boolean; + readonly values: JsonObject; +} + +const MAX_OUTPUT_BYTES = 32 * 1024 * 1024; + +const workChannel = channel("riverqueue:work"); +const workMetadata = new WeakMap(); +const workOutput = new WeakMap(); +const workStorage = new AsyncLocalStorage(); + +/** + * Start tracking metadata and output for a new attempt's context. The caller + * marks the returned state inactive once the attempt ends. + */ +export function beginWorkAttempt( + context: AnyWorkContext, + output: WorkOutputState +): WorkMetadataState { + const metadata: WorkMetadataState = { + active: true, + values: Object.create(null) as JsonObject, + }; + workMetadata.set(context, metadata); + workOutput.set(context, output); + return metadata; +} + +/** + * The state of the attempt `context` belongs to, shared by its raw and + * decoded contexts, or undefined for a context River didn't create. + */ +export function workAttemptState( + context: AnyWorkContext +): WorkMetadataState | undefined { + return workMetadata.get(context); +} + +/** Publish a settled attempt on the `riverqueue:work` diagnostics channel. */ +export function publishWorkResult( + context: AnyWorkContext, + result: WorkAttemptResult +): void { + workChannel.publish({ context, result }); +} + +/** Run `callback` with `context` as the current work context. */ +export function runInWorkContext( + context: AnyWorkContext, + callback: () => T +): T { + return workStorage.run(context, callback); +} + +/** + * Let a derived context (the decoded context handed to handlers) share the + * metadata and output its raw attempt context tracks. + */ +export function shareWorkAttempt( + from: AnyWorkContext, + to: AnyWorkContext, + output: WorkOutputState +): void { + const metadata = workMetadata.get(from); + if (metadata !== undefined) workMetadata.set(to, metadata); + workOutput.set(to, output); +} + +/** + * Return the context of the job attempt the caller is running inside, or + * undefined outside a job. River establishes it with `AsyncLocalStorage`, so + * code called from a handler can reach the job without passing `ctx` along. + */ +export function currentWorkContext(): CurrentWorkContext | undefined { + return workStorage.getStore(); +} + +/** Record JSON output on the current attempt, with last write winning. */ +export function recordOutput(value: JsonValue): void { + const context = currentWorkContext(); + if (context === undefined) { + throw new LifecycleError("recordOutput must be called while working a job"); + } + context.recordOutput(value); +} + +/** Merge one JSON value into metadata on the current work attempt. */ +export function setMetadata(key: string, value: JsonValue): void { + const context = currentWorkContext(); + if (context === undefined) { + throw new LifecycleError("setMetadata must be called while working a job"); + } + context.setMetadata(key, value); +} + +/** Validate recorded output as River JSON no larger than 32 MiB. */ +export function normalizeOutput(value: JsonValue): JsonValue { + const output = toJsonValue(value); + if (Buffer.byteLength(stringifyJson(output), "utf8") > MAX_OUTPUT_BYTES) { + throw new ValidationError("job output must not exceed 32 MiB"); + } + return output; +} + +/** Attach the output recorded on `context`, if any, to `result`. */ +export function resultWithOutput( + result: WorkAttemptResult, + context: AnyWorkContext +): WorkAttemptResult { + const outputState = workOutput.get(context); + return outputState !== undefined && "output" in outputState + ? { ...result, output: outputState.output } + : result; +} + +/** Merge the metadata recorded on `context` into `result`. */ +export function resultWithMetadata( + result: WorkAttemptResult, + context: AnyWorkContext +): WorkAttemptResult { + const metadata = snapshotWorkMetadata(context); + if (Object.keys(metadata).length === 0) return result; + return { + ...result, + metadata: toJsonObject({ ...(result.metadata ?? {}), ...metadata }), + }; +} + +/** Record one metadata value on an active attempt's context. */ +export function setWorkMetadata( + context: AnyWorkContext, + key: string, + value: JsonValue +): void { + if (typeof key !== "string") { + throw new ValidationError("metadata key must be a string"); + } + if (key.startsWith("river:")) { + throw new ValidationError( + "metadata keys prefixed with river: are reserved for River" + ); + } + const metadata = workMetadata.get(context); + if (metadata === undefined || !metadata.active) { + throw new LifecycleError("work metadata is no longer available"); + } + metadata.values[key] = toJsonValue(value); +} + +/** Copy the metadata recorded so far on `context`. */ +export function snapshotWorkMetadata(context: AnyWorkContext): JsonObject { + return toJsonObject(workMetadata.get(context)?.values ?? {}); +} + +/** + * Build the context handed to a handler: the raw attempt context plus the + * decoded arguments, a resumable, and transactional completion. + */ +export function makeDecodedWorkContext( + rawContext: WorkAttemptContext, + decodedArgs: unknown, + row: JobRow, + outputState: WorkOutputState, + clientId: string, + driver: RuntimeDriver, + operations: PilotOperations +): WorkContext { + const context: WorkContext = { + ...rawContext, + completeTx: async (tx, options = {}) => { + const explicitOutput = options.output !== undefined; + const outputSet = explicitOutput || "output" in outputState; + const output = explicitOutput + ? normalizeOutput(options.output) + : (outputState.output ?? null); + const command: JobCompletionCommand = { + attempt: row.attempt, + attemptedBy: clientId, + error: null, + finalizedAt: Temporal.Now.instant(), + id: row.id, + kind: "complete", + metadata: snapshotWorkMetadata(rawContext), + output, + outputSet, + scheduledAt: null, + }; + const result = (await operations.complete(driver, [command], { tx }))[0]; + if (result?.status !== "applied" || result.job === null) { + throw new LifecycleError( + `job ${row.id} attempt ${row.attempt} is no longer running` + ); + } + return result.job; + }, + job: { + ...row, + args: decodedArgs, + rawArgs: row.args, + }, + resumable: new Resumable(rawContext.client, row), + }; + return context; +} + +/** The attempt context an error handler sees, without `setMetadata`. */ +export function errorHandlerContext( + context: WorkAttemptContext +): ErrorHandlerContext { + const { setMetadata, ...visible } = context; + void setMetadata; + return Object.freeze(visible); +} diff --git a/js/src/services.ts b/js/src/services.ts new file mode 100644 index 000000000..aa0c32df7 --- /dev/null +++ b/js/src/services.ts @@ -0,0 +1,1513 @@ +import type { Client, InsertManyItem } from "./client.js"; +import type { + LeaderTerm, + RuntimeDriver, + RuntimeJobCleanupParams, + RuntimeJobRescue, + RuntimeLeader, +} from "./driver.js"; +import { ConfigurationError } from "./errors.js"; +import type { RiverEvent } from "./events.js"; +import type { JobRow } from "./job.js"; +import type { PeriodicJobs, PeriodicJobsStartParams } from "./periodic.js"; +import { + advancePeriodicJobs, + buildPeriodicInsert, + nextPeriodicRunAt, + periodicJobIds, + resetPeriodicJobs, + setPeriodicJobsChangeHandler, +} from "./periodic.js"; +import type { PeriodicJobStore } from "./periodic-job-store.js"; +import type { InternalLogger } from "./logger.js"; +import { internalLogger, resolveLogger } from "./logger.js"; +import { + interruptibleDelay, + raceWithAbort, + unrefTimeout, +} from "./internal/abort.js"; +import type { PilotService } from "./pilot.js"; +import { + superviseService, + type SupervisorOptions, +} from "./runtime/service-supervisor.js"; +import { + BACKGROUND_BACKOFF, + exponentialBackoffMs, + SYSTEM_TIMER, + type BackoffPolicy, + type OperationTimeout, + type RuntimeTimer, +} from "./internal/backoff.js"; +import { + MAINTENANCE_TIMEOUT_DEFAULT_MS, + MaintenanceBatcher, +} from "./internal/maintenance-batch.js"; +import { PilotOperations } from "./runtime/pilot-operations.js"; + +const PERIODIC_KEEP_ALIVE_INTERVAL_MS = 10 * 60_000; +const LEADER_LOCAL_DEADLINE_SAFETY_MS = 2_000; +const LEADER_TTL_PADDING_MS = 10_000; +const LEADER_RESIGN_ATTEMPTS = 3; +/** + * Attempts to start a term's periodic job enqueuer before resigning the + * term, like River for Go's `queueMaintainerMaxStartAttempts`. + */ +const MAINTENANCE_START_ATTEMPTS = 3; +/** Waits between those attempts: about 1 s, then 2 s, like Go's. */ +const MAINTENANCE_START_BACKOFF: BackoffPolicy = Object.freeze({ + baseMs: 1_000, + maxMs: 30_000, +}); +const RESCUE_DECISION_CONCURRENCY = 32; +const SCHEDULER_NOTIFICATION_LOOKAHEAD_MS = 5; + +/** PostgreSQL indexes River rebuilds by default to control table bloat. */ +export const REINDEXER_INDEX_NAMES_DEFAULT = Object.freeze([ + "river_job_args_index", + "river_job_kind", + "river_job_metadata_index", + "river_job_pkey", + "river_job_prioritized_fetching_index", + "river_job_state_and_finalized_at_index", + "river_job_unique_idx", +] as const); + +/** Returns when the reindexer should next run after `after`. */ +export type ReindexerSchedule = (after: Temporal.Instant) => Temporal.Instant; + +export interface MaintenanceSettings { + readonly cancelledJobRetentionMs?: number | null; + readonly completedJobRetentionMs?: number | null; + readonly discardedJobRetentionMs?: number | null; + readonly electionIntervalMs?: number; + readonly jobCleanerIntervalMs?: number; + readonly jobCleanerTimeoutMs?: number | null; + /** + * How much shorter than the lease this client trusts its own term, so it + * stops acting as leader before the lease can expire. Defaults to 2 s. + * @internal + */ + readonly leaderDeadlineSafetyMs?: number; + /** + * How much longer than the election interval a lease lasts. Defaults to + * 10 s. + * @internal + */ + readonly leaderTtlPaddingMs?: number; + /** + * Timeout of the first attempt to resign leadership; later attempts wait + * two and three times as long, like Go's elector. + * @internal + */ + readonly leaderResignTimeoutMs?: number; + /** + * Timeout of one batch for the scheduler, the rescuer's reads, and the + * queue and notification cleaners. Defaults to Go River's 30 s. + * @internal + */ + readonly maintenanceTimeoutMs?: number; + readonly notificationCleanerIntervalMs?: number; + readonly notificationRetentionMs?: number; + readonly queueCleanerIntervalMs?: number; + readonly queueRetentionMs?: number; + readonly reindexerIndexNames?: readonly string[]; + readonly reindexerSchedule?: ReindexerSchedule; + readonly reindexerTimeoutMs?: number | null; + readonly rescueAfterMs?: number; + readonly rescuerIntervalMs?: number; + readonly schedulerIntervalMs?: number; +} + +/** The leader-owned maintenance services' state in {@link RunDiagnostics}. */ +export interface MaintenanceDiagnostics { + readonly isLeader: boolean; + readonly lastError: string | null; + readonly leader: LeaderTerm | null; + readonly runs: Readonly>; +} + +/** The maintenance services a leader runs. */ +export type MaintenanceServiceName = + | "job_cleaner" + | "notification_cleaner" + | "periodic" + | "queue_cleaner" + | "reindexer" + | "rescuer" + | "scheduler"; + +interface NormalizedMaintenanceOptions { + readonly cancelledJobRetentionMs: number | null; + readonly completedJobRetentionMs: number | null; + readonly discardedJobRetentionMs: number | null; + readonly electionIntervalMs: number; + readonly jobCleanerIntervalMs: number; + readonly jobCleanerTimeoutMs: number | null; + readonly leaderDeadlineSafetyMs: number; + readonly leaderResignTimeoutMs: number; + readonly leaderTtlPaddingMs: number; + readonly maintenanceTimeoutMs: number; + readonly notificationCleanerIntervalMs: number; + readonly notificationRetentionMs: number; + readonly queueCleanerIntervalMs: number; + readonly queueRetentionMs: number; + readonly reindexerIndexNames: readonly string[]; + readonly reindexerSchedule: ReindexerSchedule; + readonly reindexerTimeoutMs: number | null; + readonly rescueAfterMs: number; + readonly rescuerIntervalMs: number; + readonly schedulerIntervalMs: number; +} + +interface RuntimeServicesOptions { + /** + * The client's insert notification limiter, for scheduled jobs. Defaults + * to notifying every queue. + */ + readonly allowInsertNotifications?: ( + queues: readonly string[] + ) => readonly string[]; + readonly client: Client; + readonly clientId: string; + readonly driver: RuntimeDriver; + readonly emit: (event: RiverEvent) => Promise; + /** Receives leadership and maintenance failures. Defaults to no logging. */ + readonly logger?: Pick; + readonly jobCleanerQueuesExcluded?: readonly string[]; + readonly maintenance: MaintenanceSettings; + readonly now: () => Temporal.Instant; + /** + * The rescuer's reads and updates, which the client's pilot may intercept. + * Defaults to the driver's own. + */ + readonly operations?: PilotOperations; + /** Invoked when a leadership term starts enqueuing periodic jobs. */ + readonly onPeriodicJobsStart?: ( + params: PeriodicJobsStartParams + ) => Promise; + readonly periodicJobStore?: PeriodicJobStore | undefined; + readonly periodicJobs: PeriodicJobs; + /** Jitter source for leadership retry backoff. Defaults to `Math.random`. */ + readonly random?: () => number; + readonly rescue: ( + job: JobRow, + now: Temporal.Instant, + signal: AbortSignal + ) => Promise; + /** + * A pilot's services run once per leadership term while this client + * leads. Default: none. + */ + readonly termServices?: readonly PilotService[]; + /** Delays for restarting term services. Default: real time. */ + readonly timer?: RuntimeTimer; +} + +/** @internal Supervised leader election and leader-owned common services. */ +export class RuntimeServices { + readonly #allowInsertNotifications: ( + queues: readonly string[] + ) => readonly string[]; + readonly #client: Client; + readonly #clientId: string; + readonly #driver: RuntimeDriver; + readonly #emit: (event: RiverEvent) => Promise; + readonly #logger: Pick; + readonly #maintenance: NormalizedMaintenanceOptions; + readonly #now: () => Temporal.Instant; + readonly #operations: PilotOperations; + readonly #onPeriodicJobsStart: ( + params: PeriodicJobsStartParams + ) => Promise; + readonly #jobCleanerQueuesExcluded: readonly string[]; + readonly #periodicJobStore: PeriodicJobStore | undefined; + readonly #periodicJobs: PeriodicJobs; + readonly #random: () => number; + readonly #termServices: readonly PilotService[]; + readonly #timer: RuntimeTimer; + /** Ends the term once the local trust deadline passes. */ + #trustDeadline: OperationTimeout | undefined; + readonly #rescue: ( + job: JobRow, + now: Temporal.Instant, + signal: AbortSignal + ) => Promise; + readonly #runs: Record = { + job_cleaner: 0, + notification_cleaner: 0, + periodic: 0, + queue_cleaner: 0, + reindexer: 0, + rescuer: 0, + scheduler: 0, + }; + /** Each batched service's batch size, timeout, and backoff. */ + readonly #batchers: Record< + | "job_cleaner" + | "notification_cleaner" + | "queue_cleaner" + | "rescuer" + | "scheduler", + MaintenanceBatcher + >; + #lastError: string | null = null; + /** + * The term the trust deadline ended. A late renewal of it resigns + * instead of reviving it. + */ + #expiredTerm: RuntimeLeader | null = null; + /** Ends a follower's wait for its next election, while it waits. */ + #electionWake: (() => void) | null = null; + #leader: RuntimeLeader | null = null; + #leaderAbort: AbortController | null = null; + #leaderTrustedUntil = Number.NEGATIVE_INFINITY; + #leadershipOperations: Promise = Promise.resolve(); + readonly #serviceWaiters = new Set<() => void>(); + + constructor(options: RuntimeServicesOptions) { + requireMaintenanceCapabilities(options.driver); + this.#allowInsertNotifications = + options.allowInsertNotifications ?? ((queues) => [...new Set(queues)]); + this.#client = options.client; + this.#clientId = options.clientId; + this.#driver = options.driver; + this.#emit = options.emit; + this.#logger = options.logger ?? internalLogger(resolveLogger(false)); + this.#maintenance = normalizeMaintenance(options.maintenance); + this.#now = options.now; + this.#operations = options.operations ?? new PilotOperations(); + this.#onPeriodicJobsStart = + options.onPeriodicJobsStart ?? (() => Promise.resolve()); + this.#periodicJobStore = options.periodicJobStore; + this.#periodicJobs = options.periodicJobs; + this.#random = options.random ?? Math.random; + const batcher = (timeoutMs: number | null) => + new MaintenanceBatcher({ random: this.#random, timeoutMs }); + const timeoutMs = this.#maintenance.maintenanceTimeoutMs; + this.#batchers = { + job_cleaner: batcher(this.#maintenance.jobCleanerTimeoutMs), + notification_cleaner: batcher(timeoutMs), + queue_cleaner: batcher(timeoutMs), + rescuer: batcher(timeoutMs), + scheduler: batcher(timeoutMs), + }; + setPeriodicJobsChangeHandler(this.#periodicJobs, () => + this.#wakeServiceLoops() + ); + this.#rescue = options.rescue; + this.#jobCleanerQueuesExcluded = Object.freeze([ + ...(options.jobCleanerQueuesExcluded ?? []), + ]); + this.#termServices = options.termServices ?? []; + this.#timer = options.timer ?? SYSTEM_TIMER; + } + + get diagnostics(): MaintenanceDiagnostics { + return { + isLeader: this.#leader !== null && this.#hasTrustedLeadership(), + lastError: this.#lastError, + leader: this.#leader, + runs: { ...this.#runs }, + }; + } + + /** + * Another client resigned leadership. Like River for Go's elector, a + * follower waiting for its next election bids after a random 0 to 50 ms + * instead of waiting out its interval. + */ + leaderResigned(): void { + this.#electionWake?.(); + } + + /** Resign this runtime's current exact leadership term, if it owns one. */ + resignLeadership(): Promise { + return this.#withLeadershipOperation(() => this.#loseLeadership(true)); + } + + async run(signal: AbortSignal): Promise { + const tasks = [ + this.#leadershipLoop(signal), + this.#periodicLoop(signal), + this.#serviceLoop( + "scheduler", + this.#maintenance.schedulerIntervalMs, + signal, + (now, term, termSignal) => this.#drainScheduler(now, termSignal, term) + ), + this.#serviceLoop( + "rescuer", + this.#maintenance.rescuerIntervalMs, + signal, + (now, term, termSignal) => this.#drainRescuer(now, termSignal, term) + ), + this.#serviceLoop( + "job_cleaner", + this.#maintenance.jobCleanerIntervalMs, + signal, + (now, term, termSignal) => this.#drainJobCleaner(now, termSignal, term) + ), + this.#serviceLoop( + "queue_cleaner", + this.#maintenance.queueCleanerIntervalMs, + signal, + (now, term, termSignal) => + this.#drainQueueCleaner(now, termSignal, term) + ), + ]; + if (this.#driver.maintenanceCleanNotifications !== undefined) { + tasks.push( + this.#serviceLoop( + "notification_cleaner", + this.#maintenance.notificationCleanerIntervalMs, + signal, + (now, term, termSignal) => + this.#drainNotificationCleaner(now, termSignal, term) + ) + ); + } + if (this.#driver.maintenanceReindex !== undefined) { + tasks.push(this.#reindexerLoop(signal)); + } + if (this.#termServices.length > 0) { + tasks.push(this.#termServicesLoop(signal)); + } + try { + await Promise.all(tasks); + } finally { + setPeriodicJobsChangeHandler(this.#periodicJobs, undefined); + } + } + + async #leadershipLoop(signal: AbortSignal): Promise { + let failures = 0; + try { + while (!signal.aborted) { + const now = this.#now(); + const refreshed = await this.#withLeadershipOperation(() => + this.#refreshLeadership(now, signal) + ); + failures = refreshed ? 0 : failures + 1; + const intervalMs = this.#maintenance.electionIntervalMs; + if (failures > 0) { + // Retry a failed election sooner, never later, than the normal + // interval so a leader renews its lease before the TTL expires. + await interruptibleDelay( + Math.min( + intervalMs, + exponentialBackoffMs(failures, BACKGROUND_BACKOFF, this.#random) + ), + signal + ); + } else if (this.#leader !== null) { + await interruptibleDelay(intervalMs, signal); + } else if ( + await this.#waitForElection( + // Like River for Go's elector, a follower's interval is + // jittered so clients started together don't bid in lockstep, + // by up to a fifth of it (Go's 1 second over 5). + intervalMs + Math.floor(this.#random() * (intervalMs / 5)), + signal + ) + ) { + await interruptibleDelay(Math.floor(this.#random() * 50), signal); + } + } + } finally { + await this.#withLeadershipOperation(() => this.#loseLeadership(true)); + } + } + + /** Acquire or renew leadership; false when the attempt failed. */ + async #refreshLeadership( + now: Temporal.Instant, + signal: AbortSignal + ): Promise { + const attemptStarted = this.#timer.now(); + const ttlMs = + this.#maintenance.electionIntervalMs + + this.#maintenance.leaderTtlPaddingMs; + try { + // A stop ends the wait for a connection during an outage, but never + // abandons an election that has started. + const leader = await this.#driver.maintenanceLeaderAcquire?.( + this.#clientId, + now, + ttlMs, + this.#leader, + { signal } + ); + this.#lastError = null; + if (leader === null || leader === undefined) { + if (this.#leader !== null && !this.#hasTrustedLeadership()) { + await this.#loseLeadership(false); + } + return true; + } + const expired = this.#expiredTerm; + if ( + expired !== null && + expired.leaderId === leader.leaderId && + expired.electedAt.equals(leader.electedAt) + ) { + // The local deadline already ended this term. A late renewal never + // revives it; resign so another client can lead. + this.#expiredTerm = null; + await this.#resign(leader); + return true; + } + const newlyElected = + this.#leader === null || + !this.#leader.electedAt.equals(leader.electedAt); + if (newlyElected) this.#expiredTerm = null; + this.#leader = leader; + this.#leaderTrustedUntil = + attemptStarted + + Math.max(0, ttlMs - this.#maintenance.leaderDeadlineSafetyMs); + if (newlyElected) { + this.#leaderAbort?.abort(leadershipLostError()); + this.#leaderAbort = new AbortController(); + } + this.#armTrustDeadline(); + if (newlyElected) { + resetPeriodicJobs(this.#periodicJobs); + this.#wakeServiceLoops(); + await this.#emit({ + at: now, + kind: "leader_acquired", + leader, + }); + } + return true; + } catch (error: unknown) { + if (signal.aborted) return false; + this.#lastError = errorMessage(error); + this.#logger.warn("River leader election failed; retrying", { + error: this.#lastError, + leader: this.#leader !== null, + }); + if (this.#leader !== null && !this.#hasTrustedLeadership()) { + await this.#loseLeadership(false); + } + return false; + } + } + + async #loseLeadership(resign: boolean): Promise { + const leader = this.#leader; + if (leader === null) return false; + this.#leader = null; + this.#leaderAbort?.abort(leadershipLostError()); + this.#leaderAbort = null; + this.#trustDeadline?.dispose(); + this.#trustDeadline = undefined; + this.#leaderTrustedUntil = Number.NEGATIVE_INFINITY; + resetPeriodicJobs(this.#periodicJobs); + this.#wakeServiceLoops(); + const resigned = resign ? await this.#resign(leader) : false; + await this.#emit({ + at: this.#now(), + kind: "leader_lost", + leader, + }); + return resigned; + } + + /** + * Resign `leader`'s term like Go's elector: up to three attempts bounded + * by one, two, and three times the resign timeout, so a stop during an + * outage doesn't wait on the database. The lease's expiry covers a failed + * resignation. + */ + async #resign(leader: RuntimeLeader): Promise { + for (let attempt = 1; attempt <= LEADER_RESIGN_ATTEMPTS; attempt++) { + const timeout = AbortSignal.timeout( + attempt * this.#maintenance.leaderResignTimeoutMs + ); + try { + return ( + (await raceWithAbort( + this.#driver.maintenanceLeaderResign?.(leader), + timeout + )) ?? false + ); + } catch (error: unknown) { + this.#lastError = errorMessage(error); + this.#logger.warn("River leadership resignation failed", { + attempt, + error: this.#lastError, + }); + } + } + return false; + } + + /** + * Resign `term` if this runtime still leads it, like River for Go's + * elector honoring a local resignation request for one term. + */ + #resignTerm(term: RuntimeLeader): Promise { + return this.#withLeadershipOperation(async () => { + const leader = this.#leader; + if ( + leader?.leaderId !== term.leaderId || + !leader.electedAt.equals(term.electedAt) + ) { + return false; + } + return this.#loseLeadership(true); + }); + } + + #withLeadershipOperation(callback: () => Promise): Promise { + const operation = this.#leadershipOperations.then(callback, callback); + this.#leadershipOperations = operation.then( + () => undefined, + () => undefined + ); + return operation; + } + + async #runService( + name: MaintenanceServiceName, + run: () => Promise, + emitEmpty = true, + signal?: AbortSignal + ): Promise { + try { + const count = await run(); + this.#runs[name]++; + this.#lastError = null; + if (emitEmpty || count > 0) { + await this.#emit({ + at: this.#now(), + count, + kind: "maintenance_succeeded", + service: name, + }); + } + } catch (error: unknown) { + if (signal?.aborted === true && Object.is(error, signal.reason)) return; + this.#lastError = errorMessage(error); + // Maintenance retries on its own interval, which already bounds the + // retry rate, exactly like River's other runtimes. + this.#logger.error("River maintenance service failed", { + error: this.#lastError, + service: name, + }); + await this.#emit({ + at: this.#now(), + error, + kind: "maintenance_failed", + service: name, + }); + } + } + + async #serviceLoop( + name: MaintenanceServiceName, + intervalMs: number, + signal: AbortSignal, + run: ( + now: Temporal.Instant, + term: RuntimeLeader, + signal: AbortSignal + ) => Promise + ): Promise { + let lastRun = Number.NEGATIVE_INFINITY; + let lastTerm = ""; + while (!signal.aborted) { + const now = this.#now(); + const nowMs = now.epochMilliseconds; + const term = this.#leader; + if (term !== null && this.#ownsLeadership(term)) { + const termKey = `${term.leaderId}\0${term.electedAt.toString()}`; + if (termKey !== lastTerm || nowMs - lastRun >= intervalMs) { + lastTerm = termKey; + lastRun = nowMs; + const termSignal = this.#termSignal(term, signal); + await this.#runService( + name, + () => run(now, term, termSignal), + true, + termSignal + ); + } + } + const elapsed = this.#now().epochMilliseconds - lastRun; + const untilDue = + term === null || !Number.isFinite(elapsed) + ? this.#maintenance.electionIntervalMs + : Math.max(1, intervalMs - elapsed); + await this.#waitForServiceLoop( + Math.min(untilDue, this.#maintenance.electionIntervalMs), + signal + ); + } + } + + /** + * Insert periodic jobs while this runtime leads, like Go River's periodic + * job enqueuer: each leadership term seeds next runs from a durable store + * (when an extension configures one), runs the start hooks, and then + * inserts due occurrences in batches. A failed occurrence is logged and + * dropped rather than retried, so a failing constructor or database cannot + * wedge the schedule. + */ + async #periodicLoop(signal: AbortSignal): Promise { + let startedTerm: RuntimeLeader | null = null; + let durableNextRuns = new Map(); + let keepAliveDue = 0; + let failedStarts = 0; + let failedTerm: RuntimeLeader | null = null; + while (!signal.aborted) { + const term = this.#leader; + if (term === null || !this.#ownsLeadership(term)) { + startedTerm = null; + } else { + const termSignal = this.#termSignal(term, signal); + if ( + startedTerm === null || + !startedTerm.electedAt.equals(term.electedAt) + ) { + const seeded = await this.#startPeriodicTerm(termSignal); + if (seeded !== null) { + startedTerm = term; + durableNextRuns = seeded; + keepAliveDue = 0; + failedStarts = 0; + failedTerm = null; + } else if (!termSignal.aborted) { + failedStarts = + failedTerm?.electedAt.equals(term.electedAt) === true + ? failedStarts + 1 + : 1; + failedTerm = term; + if (failedStarts < MAINTENANCE_START_ATTEMPTS) { + await interruptibleDelay( + exponentialBackoffMs( + failedStarts, + MAINTENANCE_START_BACKOFF, + this.#random + ), + termSignal + ); + continue; + } + // Resign locally rather than through a notification, which a + // client without notifications wouldn't hear, and only this + // term, so a late failure can't resign a newer one. + this.#logger.error( + "River maintenance failed to start after all attempts; resigning leadership", + { error: this.#lastError } + ); + failedStarts = 0; + failedTerm = null; + await this.#resignTerm(term); + continue; + } + } + if (startedTerm !== null) { + await this.#runService( + "periodic", + () => this.#enqueuePeriodic(term, durableNextRuns, termSignal), + false, + termSignal + ); + if (performance.now() >= keepAliveDue) { + keepAliveDue = performance.now() + PERIODIC_KEEP_ALIVE_INTERVAL_MS; + await this.#keepPeriodicJobsAlive(termSignal); + } + } + } + const nextRunAt = nextPeriodicRunAt(this.#periodicJobs); + const untilDue = + startedTerm === null || nextRunAt === null + ? this.#maintenance.electionIntervalMs + : Math.max( + 0, + Number( + (nextRunAt.epochNanoseconds - this.#now().epochNanoseconds) / + 1_000_000n + ) + ); + await this.#waitForServiceLoop( + Math.min(untilDue, this.#maintenance.electionIntervalMs), + signal + ); + } + } + + /** + * Begin a leadership term's periodic enqueuing: read durable next runs and + * run `onPeriodicJobsStart` hooks. Returns null (and retries next loop) + * when either fails, as Go River refuses to start its enqueuer. + */ + async #startPeriodicTerm( + signal: AbortSignal + ): Promise | null> { + try { + const durableJobs = + this.#periodicJobStore === undefined + ? [] + : await this.#periodicJobStore.getAll({ signal }); + await this.#onPeriodicJobsStart({ + durableJobs: Object.freeze([...durableJobs]), + periodicJobs: this.#periodicJobs, + }); + resetPeriodicJobs(this.#periodicJobs); + return new Map(durableJobs.map(({ id, nextRunAt }) => [id, nextRunAt])); + } catch (error: unknown) { + if (signal.aborted) return null; + this.#lastError = errorMessage(error); + this.#logger.error("River periodic job enqueuer failed to start", { + error: this.#lastError, + }); + return null; + } + } + + async #enqueuePeriodic( + term: RuntimeLeader, + durableNextRuns: Map, + signal: AbortSignal + ): Promise { + const now = this.#now(); + const batch = advancePeriodicJobs( + this.#periodicJobs, + now, + durableNextRuns, + (job, error) => { + this.#logger.error( + "River periodic job schedule failed; the job will not run again until it is re-registered", + { + error: errorMessage(error), + ...(job.id === null ? {} : { id: job.id }), + kind: job.job.kind, + } + ); + } + ); + const items: InsertManyItem[] = []; + for (const occurrence of batch.occurrences) { + try { + const item = await abortable(buildPeriodicInsert(occurrence), signal); + if (item !== null) items.push(item); + } catch (error: unknown) { + if (signal.aborted) throw signal.reason; + this.#logger.error("River periodic job constructor failed", { + error: errorMessage(error), + ...(occurrence.job.id === null ? {} : { id: occurrence.job.id }), + kind: occurrence.job.job.kind, + }); + } + } + if (items.length === 0 && batch.durableUpdates.length === 0) return 0; + if (!this.#ownsLeadership(term)) return 0; + try { + const store = this.#periodicJobStore; + const updatedAt = this.#now(); + const upserts = batch.durableUpdates.map((update) => ({ + ...update, + updatedAt, + })); + if (store !== undefined && upserts.length > 0) { + const scope = this.#driver.operationScope?.bind(this.#driver); + if (scope === undefined) { + throw new ConfigurationError( + "a periodic job store requires a driver with operation scopes" + ); + } + // Like River for Go, the batch's jobs (with their insert middleware + // and hooks) and their next-run times commit together or not at all. + await scope(undefined, async (tx) => { + if (items.length > 0) await this.#client.insertMany(items, { tx }); + await store.upsertMany(tx, upserts); + }); + } else if (items.length > 0) { + await this.#client.insertMany(items); + } + } catch (error: unknown) { + if (signal.aborted) throw signal.reason; + // Like Go River, drop the batch's occurrences: their next runs have + // already advanced, and the schedule continues. The service runner + // logs the failure and emits `maintenance_failed`. + throw error; + } + return items.length; + } + + async #keepPeriodicJobsAlive(signal: AbortSignal): Promise { + const store = this.#periodicJobStore; + if (store === undefined) return; + const ids = periodicJobIds(this.#periodicJobs); + if (ids.length === 0) return; + try { + await store.keepAliveAndReap(ids, { signal }); + } catch (error: unknown) { + if (signal.aborted) return; + this.#logger.error("River periodic job keep-alive failed", { + error: errorMessage(error), + }); + } + } + + /** + * Abort the current term's signal when the local trust deadline passes, + * whether or not a renewal is still in flight. + */ + #armTrustDeadline(): void { + this.#trustDeadline?.dispose(); + const controller = this.#leaderAbort; + const deadline = this.#timer.timeout( + Math.max(0, this.#leaderTrustedUntil - this.#timer.now()), + leadershipLostError + ); + this.#trustDeadline = deadline; + deadline.signal.addEventListener( + "abort", + () => { + if (this.#trustDeadline !== deadline) return; + this.#trustDeadline = undefined; + if (this.#leaderAbort !== controller || controller === null) return; + if (this.#hasTrustedLeadership()) { + this.#armTrustDeadline(); + return; + } + this.#expireLeadership(controller); + }, + { once: true } + ); + } + + /** + * End the current term at its trust deadline, even while a renewal is + * in flight: its signal aborts, this client stops reporting itself as + * leader, and `leader_lost` is emitted, like River for Go's elector. + */ + #expireLeadership(controller: AbortController): void { + const leader = this.#leader; + if (leader === null) return; + this.#expiredTerm = leader; + this.#leader = null; + this.#leaderAbort = null; + this.#leaderTrustedUntil = Number.NEGATIVE_INFINITY; + controller.abort(leadershipLostError()); + resetPeriodicJobs(this.#periodicJobs); + this.#wakeServiceLoops(); + this.#logger.warn("River leadership expired before it could be renewed", { + leaderId: leader.leaderId, + }); + void this.#emit({ at: this.#now(), kind: "leader_lost", leader }).catch( + (error: unknown) => { + this.#logger.error("River failed to report lost leadership", { + error: errorMessage(error), + }); + } + ); + } + + /** + * Run the pilot's term services while this client leads: once per term, + * with the term's signal, starting a new term's only after the previous + * term's settled. + */ + async #termServicesLoop(signal: AbortSignal): Promise { + const supervisor: SupervisorOptions = { + logger: this.#logger, + random: this.#random, + timer: this.#timer, + }; + let running: Promise | undefined; + let runningTerm: RuntimeLeader | undefined; + try { + while (!signal.aborted) { + const term = this.#leader; + const current = + term !== null && + this.#ownsLeadership(term) && + runningTerm !== undefined && + runningTerm.electedAt.equals(term.electedAt); + if (!current) { + if (running !== undefined) { + // Services of an ended or replaced term settle first. + await running; + running = undefined; + runningTerm = undefined; + continue; + } + if (term !== null && this.#ownsLeadership(term)) { + const termSignal = this.#termSignal(term, signal); + runningTerm = term; + running = Promise.all( + this.#termServices.map((service) => + superviseService(service, term, termSignal, supervisor) + ) + ).then(() => undefined); + continue; + } + } + await this.#waitForServiceLoop( + this.#maintenance.electionIntervalMs, + signal + ); + } + } finally { + await running; + } + } + + #ownsLeadership(term: RuntimeLeader): boolean { + return ( + this.#hasTrustedLeadership() && + this.#leader?.leaderId === term.leaderId && + this.#leader.electedAt.equals(term.electedAt) + ); + } + + #hasTrustedLeadership(): boolean { + return this.#timer.now() < this.#leaderTrustedUntil; + } + + #termSignal(term: RuntimeLeader, runSignal: AbortSignal): AbortSignal { + const controller = this.#leaderAbort; + if (!this.#ownsLeadership(term) || controller === null) { + return AbortSignal.abort(leadershipLostError()); + } + // A few per service interval, so `AbortSignal.any` stays cheap, and the + // signal still aborts with the term after the service run returns. + // eslint-disable-next-line no-restricted-properties + return AbortSignal.any([runSignal, controller.signal]); + } + + /** + * Wait for a follower's next election, resolving true when another + * client's resignation ended the wait early. + */ + #waitForElection( + milliseconds: number, + signal: AbortSignal + ): Promise { + if (signal.aborted) return Promise.resolve(false); + return new Promise((resolve) => { + const finish = (woken: boolean): void => { + cancel(); + signal.removeEventListener("abort", onAbort); + if (this.#electionWake === wake) this.#electionWake = null; + resolve(woken); + }; + const wake = (): void => { + finish(true); + }; + const onAbort = (): void => { + finish(false); + }; + const cancel = unrefTimeout(() => { + finish(false); + }, milliseconds); + this.#electionWake = wake; + signal.addEventListener("abort", onAbort, { once: true }); + }); + } + + #wakeServiceLoops(): void { + for (const wake of [...this.#serviceWaiters]) wake(); + } + + #waitForServiceLoop( + milliseconds: number, + signal: AbortSignal + ): Promise { + if (signal.aborted) return Promise.resolve(); + return new Promise((resolve) => { + const cancel = unrefTimeout(done, milliseconds); + const onAbort = () => done(); + const waiters = this.#serviceWaiters; + waiters.add(done); + signal.addEventListener("abort", onAbort, { once: true }); + function done() { + cancel(); + signal.removeEventListener("abort", onAbort); + waiters.delete(done); + resolve(); + } + }); + } + + async #drainScheduler( + now: Temporal.Instant, + signal: AbortSignal, + term: RuntimeLeader + ) { + const scheduledAtHorizon = now.add({ + milliseconds: this.#maintenance.schedulerIntervalMs, + }); + const notificationHorizon = now.add({ + milliseconds: SCHEDULER_NOTIFICATION_LOOKAHEAD_MS, + }); + const batcher = this.#batchers.scheduler; + let total = 0; + while (!signal.aborted && this.#ownsLeadership(term)) { + const limit = batcher.batchSize; + const count = await batcher.run( + signal, + async (batch) => + (await this.#driver.maintenanceSchedule?.( + term, + { + allowInsertNotifications: this.#allowInsertNotifications, + limit, + notificationHorizon, + now, + scheduledAtHorizon, + }, + batch + )) ?? 0 + ); + total += count; + if (count < limit) break; + await batcher.backoff(signal); + } + return total; + } + + async #drainRescuer( + now: Temporal.Instant, + signal: AbortSignal, + term: RuntimeLeader + ) { + const attemptedBefore = subtractMilliseconds( + now, + this.#maintenance.rescueAfterMs + ); + const batcher = this.#batchers.rescuer; + let afterId = 0n; + let total = 0; + while (!signal.aborted && this.#ownsLeadership(term)) { + const limit = batcher.batchSize; + // Like Go, the timeout bounds reading stuck jobs, not rescuing them. + const jobs = await batcher.run(signal, (batch) => + this.#operations.getStuck( + this.#driver, + term, + attemptedBefore, + afterId, + limit, + batch + ) + ); + if (jobs.length === 0) break; + const decisions = await mapConcurrentOrdered( + jobs, + RESCUE_DECISION_CONCURRENCY, + signal, + (job) => this.#rescue(job, now, signal) + ); + const rescues = decisions.filter( + (decision): decision is RuntimeJobRescue => decision !== null + ); + if (rescues.length > 0) { + total += await this.#operations.rescue( + this.#driver, + term, + attemptedBefore, + rescues, + signal + ); + } + afterId = jobs.at(-1)?.id ?? afterId; + if (jobs.length < limit) break; + await batcher.backoff(signal); + } + return total; + } + + async #drainJobCleaner( + now: Temporal.Instant, + signal: AbortSignal, + term: RuntimeLeader + ) { + const batcher = this.#batchers.job_cleaner; + const horizons = { + cancelledBefore: horizon(now, this.#maintenance.cancelledJobRetentionMs), + completedBefore: horizon(now, this.#maintenance.completedJobRetentionMs), + discardedBefore: horizon(now, this.#maintenance.discardedJobRetentionMs), + }; + let total = 0; + while (!signal.aborted && this.#ownsLeadership(term)) { + const params: RuntimeJobCleanupParams = { + ...horizons, + limit: batcher.batchSize, + ...(this.#jobCleanerQueuesExcluded.length === 0 + ? {} + : { queuesExcluded: this.#jobCleanerQueuesExcluded }), + }; + const count = await batcher.run( + signal, + async (batch) => + (await this.#driver.maintenanceCleanJobs?.( + term, + params, + batch.timeoutMs, + batch.signal + )) ?? 0 + ); + total += count; + if (count < params.limit) break; + await batcher.backoff(signal); + } + return total; + } + + async #drainQueueCleaner( + now: Temporal.Instant, + signal: AbortSignal, + term: RuntimeLeader + ) { + const horizon = subtractMilliseconds( + now, + this.#maintenance.queueRetentionMs + ); + const batcher = this.#batchers.queue_cleaner; + let total = 0; + while (!signal.aborted && this.#ownsLeadership(term)) { + const limit = batcher.batchSize; + const count = await batcher.run( + signal, + async (batch) => + (await this.#driver.maintenanceCleanQueues?.( + term, + horizon, + limit, + batch + )) ?? 0 + ); + total += count; + if (count < limit) break; + await batcher.backoff(signal); + } + return total; + } + + async #drainNotificationCleaner( + now: Temporal.Instant, + signal: AbortSignal, + term: RuntimeLeader + ) { + const horizon = subtractMilliseconds( + now, + this.#maintenance.notificationRetentionMs + ); + const batcher = this.#batchers.notification_cleaner; + let total = 0; + while (!signal.aborted && this.#ownsLeadership(term)) { + const limit = batcher.batchSize; + const count = await batcher.run( + signal, + async (batch) => + (await this.#driver.maintenanceCleanNotifications?.( + term, + horizon, + limit, + batch + )) ?? 0 + ); + total += count; + if (count < limit) break; + await batcher.backoff(signal); + } + return total; + } + + async #reindexerLoop(signal: AbortSignal): Promise { + let nextRunAt: Temporal.Instant | null = null; + let termKey = ""; + while (!signal.aborted) { + const now = this.#now(); + const term = this.#leader; + if (term === null) { + nextRunAt = null; + termKey = ""; + } else { + const currentTermKey = `${term.leaderId}\0${term.electedAt.toString()}`; + if (currentTermKey !== termKey || nextRunAt === null) { + termKey = currentTermKey; + nextRunAt = nextReindexAt(this.#maintenance.reindexerSchedule, now); + } + if (Temporal.Instant.compare(now, nextRunAt) >= 0) { + const scheduledAt = nextRunAt; + const termSignal = this.#termSignal(term, signal); + await this.#runService( + "reindexer", + async () => { + if (!this.#ownsLeadership(term)) return 0; + return ( + (await this.#driver.maintenanceReindex?.( + term, + this.#maintenance.reindexerIndexNames, + this.#maintenance.reindexerTimeoutMs, + termSignal + )) ?? 0 + ); + }, + true, + termSignal + ); + nextRunAt = nextReindexAt( + this.#maintenance.reindexerSchedule, + scheduledAt + ); + } + } + const waitMs = + nextRunAt === null + ? this.#maintenance.electionIntervalMs + : Math.max( + 1, + Math.min( + this.#maintenance.electionIntervalMs, + Number( + (nextRunAt.epochNanoseconds - this.#now().epochNanoseconds) / + 1_000_000n + ) + ) + ); + await this.#waitForServiceLoop(waitMs, signal); + } + } +} + +/** @internal Whether a backend supplies the complete common maintenance SPI. */ +export function supportsMaintenance(driver: RuntimeDriver): boolean { + return ( + driver.maintenanceLeaderAcquire !== undefined && + driver.maintenanceLeaderResign !== undefined && + driver.maintenanceSchedule !== undefined && + driver.maintenanceGetStuck !== undefined && + driver.maintenanceRescue !== undefined && + driver.maintenanceCleanJobs !== undefined && + driver.maintenanceCleanQueues !== undefined + ); +} + +function requireMaintenanceCapabilities(driver: RuntimeDriver): void { + const required = [ + "maintenanceLeaderAcquire", + "maintenanceLeaderResign", + "maintenanceSchedule", + "maintenanceGetStuck", + "maintenanceRescue", + "maintenanceCleanJobs", + "maintenanceCleanQueues", + ] as const; + const missing = required.filter((name) => driver[name] === undefined); + if (missing.length > 0) { + throw new ConfigurationError( + `runtime backend lacks maintenance capabilities: ${missing.join(", ")}` + ); + } +} + +function normalizeMaintenance( + options: MaintenanceSettings +): NormalizedMaintenanceOptions { + return { + cancelledJobRetentionMs: optionalDuration( + "cancelledJobRetentionMs", + options.cancelledJobRetentionMs, + 86_400_000 + ), + completedJobRetentionMs: optionalDuration( + "completedJobRetentionMs", + options.completedJobRetentionMs, + 86_400_000 + ), + discardedJobRetentionMs: optionalDuration( + "discardedJobRetentionMs", + options.discardedJobRetentionMs, + 604_800_000 + ), + electionIntervalMs: duration( + "electionIntervalMs", + options.electionIntervalMs, + 5_000 + ), + jobCleanerIntervalMs: duration( + "jobCleanerIntervalMs", + options.jobCleanerIntervalMs, + 30_000 + ), + jobCleanerTimeoutMs: optionalDuration( + "jobCleanerTimeoutMs", + options.jobCleanerTimeoutMs, + 30_000 + ), + leaderDeadlineSafetyMs: duration( + "leaderDeadlineSafetyMs", + options.leaderDeadlineSafetyMs, + LEADER_LOCAL_DEADLINE_SAFETY_MS + ), + leaderResignTimeoutMs: duration( + "leaderResignTimeoutMs", + options.leaderResignTimeoutMs, + 1_000 + ), + leaderTtlPaddingMs: duration( + "leaderTtlPaddingMs", + options.leaderTtlPaddingMs, + LEADER_TTL_PADDING_MS + ), + maintenanceTimeoutMs: duration( + "maintenanceTimeoutMs", + options.maintenanceTimeoutMs, + MAINTENANCE_TIMEOUT_DEFAULT_MS + ), + notificationCleanerIntervalMs: duration( + "notificationCleanerIntervalMs", + options.notificationCleanerIntervalMs, + 60_000 + ), + notificationRetentionMs: duration( + "notificationRetentionMs", + options.notificationRetentionMs, + 300_000 + ), + queueCleanerIntervalMs: duration( + "queueCleanerIntervalMs", + options.queueCleanerIntervalMs, + 3_600_000 + ), + queueRetentionMs: duration( + "queueRetentionMs", + options.queueRetentionMs, + 86_400_000 + ), + reindexerIndexNames: Object.freeze( + [...(options.reindexerIndexNames ?? REINDEXER_INDEX_NAMES_DEFAULT)].map( + (name) => { + if (typeof name !== "string" || name.length === 0) { + throw new ConfigurationError( + "reindexerIndexNames must contain nonempty strings" + ); + } + return name; + } + ) + ), + reindexerSchedule: options.reindexerSchedule ?? nextMidnightUtc, + reindexerTimeoutMs: optionalDuration( + "reindexerTimeoutMs", + options.reindexerTimeoutMs, + 60_000 + ), + rescueAfterMs: duration("rescueAfterMs", options.rescueAfterMs, 3_600_000), + rescuerIntervalMs: duration( + "rescuerIntervalMs", + options.rescuerIntervalMs, + 30_000 + ), + schedulerIntervalMs: duration( + "schedulerIntervalMs", + options.schedulerIntervalMs, + 5_000 + ), + }; +} + +function duration(name: string, value: number | undefined, fallback: number) { + const selected = value ?? fallback; + if (!Number.isSafeInteger(selected) || selected < 1) { + throw new ConfigurationError(`${name} must be a positive safe integer`); + } + return selected; +} + +function optionalDuration( + name: string, + value: number | null | undefined, + fallback: number +) { + return value === null ? null : duration(name, value, fallback); +} + +function horizon(now: Temporal.Instant, retentionMs: number | null) { + return retentionMs === null ? null : subtractMilliseconds(now, retentionMs); +} + +function subtractMilliseconds(now: Temporal.Instant, milliseconds: number) { + return now.subtract({ milliseconds }); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function leadershipLostError(): Error { + return new Error("River leadership term lost"); +} + +async function mapConcurrentOrdered( + items: readonly T[], + concurrency: number, + signal: AbortSignal, + map: (item: T, index: number) => Promise +): Promise { + const results = new Array(items.length); + let nextIndex = 0; + const workers = Array.from( + { length: Math.min(concurrency, items.length) }, + async () => { + while (true) { + signal.throwIfAborted(); + const index = nextIndex++; + if (index >= items.length) return; + results[index] = await map(items[index] as T, index); + } + } + ); + await Promise.all(workers); + return results; +} + +function nextMidnightUtc(after: Temporal.Instant): Temporal.Instant { + return after + .toZonedDateTimeISO("UTC") + .startOfDay() + .add({ days: 1 }) + .toInstant(); +} + +function nextReindexAt( + schedule: ReindexerSchedule, + after: Temporal.Instant +): Temporal.Instant { + const next = schedule(after); + if ( + !(next instanceof Temporal.Instant) || + Temporal.Instant.compare(next, after) <= 0 + ) { + throw new ConfigurationError( + "reindexerSchedule must return a Temporal.Instant after its input" + ); + } + return next; +} + +function abortable(operation: Promise, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(signal.reason); + return new Promise((resolve, reject) => { + const onAbort = () => reject(signal.reason); + signal.addEventListener("abort", onAbort, { once: true }); + operation.then( + (value) => { + signal.removeEventListener("abort", onAbort); + resolve(value); + }, + (error: unknown) => { + signal.removeEventListener("abort", onAbort); + reject(error); + } + ); + }); +} diff --git a/js/src/unique-bitmask.test.ts b/js/src/unique-bitmask.test.ts index a715886e9..1a7f5ea44 100644 --- a/js/src/unique-bitmask.test.ts +++ b/js/src/unique-bitmask.test.ts @@ -4,42 +4,33 @@ import { uniqueBitmaskToStates, } from "./unique-bitmask.js"; import type { JobState } from "./job.js"; -import { - JOB_STATE_AVAILABLE, - JOB_STATE_CANCELLED, - JOB_STATE_COMPLETED, - JOB_STATE_DISCARDED, - JOB_STATE_PENDING, - JOB_STATE_RETRYABLE, - JOB_STATE_RUNNING, - JOB_STATE_SCHEDULED, -} from "./job.js"; +import { JOB_STATE } from "./job.js"; describe("uniqueBitmaskFromStates", () => { it("produces correct bitmask for individual states", () => { // Bit positions (in the 8-char string, left to right): // 0=scheduled, 1=running, 2=retryable, 3=pending, // 4=discarded, 5=completed, 6=cancelled, 7=available - expect(uniqueBitmaskFromStates([JOB_STATE_AVAILABLE])).toBe("00000001"); - expect(uniqueBitmaskFromStates([JOB_STATE_CANCELLED])).toBe("00000010"); - expect(uniqueBitmaskFromStates([JOB_STATE_COMPLETED])).toBe("00000100"); - expect(uniqueBitmaskFromStates([JOB_STATE_DISCARDED])).toBe("00001000"); - expect(uniqueBitmaskFromStates([JOB_STATE_PENDING])).toBe("00010000"); - expect(uniqueBitmaskFromStates([JOB_STATE_RETRYABLE])).toBe("00100000"); - expect(uniqueBitmaskFromStates([JOB_STATE_RUNNING])).toBe("01000000"); - expect(uniqueBitmaskFromStates([JOB_STATE_SCHEDULED])).toBe("10000000"); + expect(uniqueBitmaskFromStates([JOB_STATE.available])).toBe("00000001"); + expect(uniqueBitmaskFromStates([JOB_STATE.cancelled])).toBe("00000010"); + expect(uniqueBitmaskFromStates([JOB_STATE.completed])).toBe("00000100"); + expect(uniqueBitmaskFromStates([JOB_STATE.discarded])).toBe("00001000"); + expect(uniqueBitmaskFromStates([JOB_STATE.pending])).toBe("00010000"); + expect(uniqueBitmaskFromStates([JOB_STATE.retryable])).toBe("00100000"); + expect(uniqueBitmaskFromStates([JOB_STATE.running])).toBe("01000000"); + expect(uniqueBitmaskFromStates([JOB_STATE.scheduled])).toBe("10000000"); }); it("combines multiple states", () => { expect( - uniqueBitmaskFromStates([JOB_STATE_AVAILABLE, JOB_STATE_SCHEDULED]) + uniqueBitmaskFromStates([JOB_STATE.available, JOB_STATE.scheduled]) ).toBe("10000001"); expect( uniqueBitmaskFromStates([ - JOB_STATE_AVAILABLE, - JOB_STATE_RUNNING, - JOB_STATE_SCHEDULED, + JOB_STATE.available, + JOB_STATE.running, + JOB_STATE.scheduled, ]) ).toBe("11000001"); }); @@ -47,26 +38,26 @@ describe("uniqueBitmaskFromStates", () => { it("produces correct bitmask for default unique states", () => { // Default: available, completed, pending, retryable, running, scheduled const defaults: JobState[] = [ - JOB_STATE_AVAILABLE, - JOB_STATE_COMPLETED, - JOB_STATE_PENDING, - JOB_STATE_RETRYABLE, - JOB_STATE_RUNNING, - JOB_STATE_SCHEDULED, + JOB_STATE.available, + JOB_STATE.completed, + JOB_STATE.pending, + JOB_STATE.retryable, + JOB_STATE.running, + JOB_STATE.scheduled, ]; expect(uniqueBitmaskFromStates(defaults)).toBe("11110101"); }); it("produces correct bitmask for all states", () => { const all: JobState[] = [ - JOB_STATE_AVAILABLE, - JOB_STATE_CANCELLED, - JOB_STATE_COMPLETED, - JOB_STATE_DISCARDED, - JOB_STATE_PENDING, - JOB_STATE_RETRYABLE, - JOB_STATE_RUNNING, - JOB_STATE_SCHEDULED, + JOB_STATE.available, + JOB_STATE.cancelled, + JOB_STATE.completed, + JOB_STATE.discarded, + JOB_STATE.pending, + JOB_STATE.retryable, + JOB_STATE.running, + JOB_STATE.scheduled, ]; expect(uniqueBitmaskFromStates(all)).toBe("11111111"); }); @@ -78,14 +69,14 @@ describe("uniqueBitmaskFromStates", () => { describe("uniqueBitmaskToStates", () => { it("decodes individual bits", () => { - expect(uniqueBitmaskToStates(0b00000001)).toEqual([JOB_STATE_AVAILABLE]); - expect(uniqueBitmaskToStates(0b10000000)).toEqual([JOB_STATE_SCHEDULED]); + expect(uniqueBitmaskToStates(0b00000001)).toEqual([JOB_STATE.available]); + expect(uniqueBitmaskToStates(0b10000000)).toEqual([JOB_STATE.scheduled]); }); it("decodes combined bitmask", () => { // available + scheduled expect(uniqueBitmaskToStates(0b10000001)).toEqual( - [JOB_STATE_AVAILABLE, JOB_STATE_SCHEDULED].sort() + [JOB_STATE.available, JOB_STATE.scheduled].sort() ); }); @@ -103,9 +94,9 @@ describe("uniqueBitmaskToStates", () => { describe("round-trip", () => { it("fromStates then toStates returns original states sorted", () => { const states: JobState[] = [ - JOB_STATE_RUNNING, - JOB_STATE_AVAILABLE, - JOB_STATE_PENDING, + JOB_STATE.running, + JOB_STATE.available, + JOB_STATE.pending, ]; const bitmask = uniqueBitmaskFromStates(states); const result = uniqueBitmaskToStates(parseInt(bitmask, 2)); @@ -114,14 +105,14 @@ describe("round-trip", () => { it("round-trips all states", () => { const all: JobState[] = [ - JOB_STATE_AVAILABLE, - JOB_STATE_CANCELLED, - JOB_STATE_COMPLETED, - JOB_STATE_DISCARDED, - JOB_STATE_PENDING, - JOB_STATE_RETRYABLE, - JOB_STATE_RUNNING, - JOB_STATE_SCHEDULED, + JOB_STATE.available, + JOB_STATE.cancelled, + JOB_STATE.completed, + JOB_STATE.discarded, + JOB_STATE.pending, + JOB_STATE.retryable, + JOB_STATE.running, + JOB_STATE.scheduled, ]; const bitmask = uniqueBitmaskFromStates(all); const result = uniqueBitmaskToStates(parseInt(bitmask, 2)); diff --git a/js/src/unique-bitmask.ts b/js/src/unique-bitmask.ts index 40130dd0e..83fb42413 100644 --- a/js/src/unique-bitmask.ts +++ b/js/src/unique-bitmask.ts @@ -1,28 +1,19 @@ import type { JobState } from "./job.js"; -import { - JOB_STATE_AVAILABLE, - JOB_STATE_CANCELLED, - JOB_STATE_COMPLETED, - JOB_STATE_DISCARDED, - JOB_STATE_PENDING, - JOB_STATE_RETRYABLE, - JOB_STATE_RUNNING, - JOB_STATE_SCHEDULED, -} from "./job.js"; +import { JOB_STATE } from "./job.js"; const JOB_STATE_BIT_POSITIONS: Record = { - [JOB_STATE_AVAILABLE]: 7, - [JOB_STATE_CANCELLED]: 6, - [JOB_STATE_COMPLETED]: 5, - [JOB_STATE_DISCARDED]: 4, - [JOB_STATE_PENDING]: 3, - [JOB_STATE_RETRYABLE]: 2, - [JOB_STATE_RUNNING]: 1, - [JOB_STATE_SCHEDULED]: 0, + [JOB_STATE.available]: 7, + [JOB_STATE.cancelled]: 6, + [JOB_STATE.completed]: 5, + [JOB_STATE.discarded]: 4, + [JOB_STATE.pending]: 3, + [JOB_STATE.retryable]: 2, + [JOB_STATE.running]: 1, + [JOB_STATE.scheduled]: 0, }; /** Convert an array of job states to an 8-bit bitmask string. */ -export function uniqueBitmaskFromStates(states: JobState[]): string { +export function uniqueBitmaskFromStates(states: readonly JobState[]): string { let val = 0; for (const state of states) { const bitIndex = JOB_STATE_BIT_POSITIONS[state]; diff --git a/js/src/unstable-driver.ts b/js/src/unstable-driver.ts new file mode 100644 index 000000000..efb3133f6 --- /dev/null +++ b/js/src/unstable-driver.ts @@ -0,0 +1,169 @@ +/// +// The public declarations use the global `Temporal` types. The preserved +// reference loads them for TypeScript consumers without a `lib` setting. + +/** + * Explicitly unstable semantic SPI for first-party River backend packages. + * + * Application code must not implement or call these operations. This subpath + * may change incompatibly before River's JavaScript implementation reaches + * 1.0, independently of the ordinary producer and worker API. + */ +export { jobCompletionKey } from "./driver.js"; +export { + driverMigrationTarget, + registerDriver, +} from "./internal/driver-registry.js"; +export { + POSTGRES_CAPABILITIES_SQL, + postgresCapabilitiesFromRow, + UNIQUE_INSERT_NONCE_KEY, + uniqueInsertConflictSql, +} from "./internal/postgres-capabilities.js"; +export type { + PostgresCapabilities, + UniqueInsertMode, +} from "./internal/postgres-capabilities.js"; +export { recordQueueMetadataText } from "./internal/queue-metadata-text.js"; +export { queueMetadataUpdate } from "./internal/queue-metadata-update.js"; +export { + abortableDelay, + interruptibleDelay, + LinkedAbortSignal, +} from "./internal/abort.js"; +export { quoteIdentifier } from "./internal/sql.js"; +export type { OperationTimeout, RuntimeTimer } from "./internal/backoff.js"; +export { ManualTimer } from "./internal/manual-timer.js"; +export type { + ManualTimeout, + ManualTimerEntry, +} from "./internal/manual-timer.js"; +export { overrideRuntimeTiming } from "./runtime.js"; +export type { RuntimeTiming } from "./runtime.js"; +export { PilotClient } from "./pilot-client.js"; +export { workerRegistration } from "./worker.js"; +export type { + PilotClientConstructor, + PilotClientOptions, +} from "./pilot-client.js"; +export type { + DriverMigrationTarget, + DriverRecord, + FinalizedJobDeleteParams, + PeerClaimContext, + PeerOutcome, + Pilot, + PilotAttempts, + PilotCompleteContext, + PilotDatabase, + PilotFactory, + PilotHost, + PilotInsertContext, + PilotInsertReplacement, + PilotInterceptors, + PilotJobContext, + PilotQueueOptions, + PilotService, + PilotRescueContext, + PilotStuckContext, + PilotTransactionContext, + PreparedInsertParams, + ProducerClaimContext, + ProducerClaimNext, + ProducerConfiguration, + ProducerKeepAliveContext, + ProducerShutdownContext, + ProducerStartContext, + PilotProducer, +} from "./pilot.js"; +export { + decodeAttemptError, + decodeAttemptErrors, + decodeJobState, +} from "./driver-codecs.js"; +export { createJobArgsTransformPlugin } from "./job-args-transform.js"; +export { createJobInsertMetadataTransformPlugin } from "./job-insert-metadata-transform.js"; +export type { + JobInsertMetadataTransformInput, + JobInsertMetadataTransformer, + JobInsertMetadataTransformPlugin, + JobInsertMetadataTransformResult, +} from "./job-insert-metadata-transform.js"; +export { decodeJobArgs } from "./job-definition.js"; +export { + toMilliseconds as durationToMilliseconds, + type DurationInput, + type DurationRules, +} from "./internal/duration.js"; +export { postgresTimestamp } from "./internal/timestamp.js"; +export type { + DurablePeriodicJobUpsert, + PeriodicJobStore, +} from "./periodic-job-store.js"; +export { + canonicalDecimal, + canonicalEqualityDecimal, + compareUtf8, + jsonValuesEqual, + numberRoundTrips, + sjsonKey, +} from "./json.js"; +export { + decodeJobListCursor, + encodeJobListCursor, + jobListCursorValue, + jobListKeyset, + jobListKeysetSql, +} from "./query.js"; +export { createResumable, finishResumable } from "./resumable.js"; +export { buildUniqueKey, encodeUniqueArgs } from "./client.js"; +export { + uniqueBitmaskFromStates, + uniqueBitmaskToStates, +} from "./unique-bitmask.js"; +export type { + BackendResult, + DriverAttemptError, + DriverInsertResult, + InsertDriver, + InsertDriverOptions, + JobCancellationNotice, + JobClaimOptions, + JobClaimParams, + JobClaimQueue, + JobClaimResult, + JobCompletionCommand, + JobCompletionResult, + JobDeleteManyParams, + JobDeleteResult, + JobInsertParams, + JobListAfter, + JobListCursorValue, + JobListKeyset, + JobListOrderBy, + JobListParams, + JobListTimeField, + JobUpdateParams, + LeaderTerm, + RuntimeJobCleanupParams, + RuntimeJobRescue, + RuntimeLeader, + RuntimeMaintenanceBatch, + RuntimeNotification, + RuntimeScheduleParams, + RuntimeWaitOptions, + QueueListParams, + QueueRow, + QueueUpdateParams, + RuntimeDriver, + SortDirection, +} from "./driver.js"; +export type { + JobArgsInsertTransformInput, + JobArgsInsertTransformOutput, + JobArgsReadTransformInput, + JobArgsTransformer, + JobArgsTransformPlugin, + ReadonlyJsonObject, + ReadonlyJsonValue, +} from "./job-args-transform.js"; diff --git a/js/src/worker.ts b/js/src/worker.ts new file mode 100644 index 000000000..ac93794e7 --- /dev/null +++ b/js/src/worker.ts @@ -0,0 +1,539 @@ +import type { Client } from "./client.js"; +import type { RegisteredTransaction } from "./driver.js"; +import type { WorkLogger } from "./logger.js"; +import { + millisecondsToDuration, + toDuration, + toMilliseconds, + type DurationInput, +} from "./internal/duration.js"; +import { ConfigurationError, ValidationError } from "./errors.js"; +import type { JobDefinition, JobDefinitionArgs } from "./job-definition.js"; +import type { JobRow } from "./job.js"; +import { isJobArgsTransformPlugin } from "./job-args-transform.js"; +import type { JsonObject, JsonValue } from "./json.js"; +import { toJsonValue } from "./json.js"; +import type { Resumable } from "./resumable.js"; +import type { RetryPolicy } from "./runtime/settings.js"; +import type { WorkAttemptResult, WorkMiddleware } from "./extensions.js"; + +/** A worked job with decoded args and transformed JSON before decoding. */ +export type Job = Omit< + JobRow, + "args" +> & { + readonly args: JobDefinitionArgs; + readonly rawArgs: JsonObject; +}; + +/** Raw attempt context passed to middleware and pre-work hooks. */ +export interface WorkAttemptContext { + /** The client working this job, for inserting follow-up jobs. */ + readonly client: Client; + readonly execution: WorkExecution; + readonly job: Readonly; + /** + * The client's logger with `jobId`, `jobKind`, and `attempt` attached. + * Call it as `logger.info("message")` or `logger.info({ key }, "message")`. + */ + readonly logger: WorkLogger; + /** Record JSON output for this attempt, including on failure or cancellation. */ + readonly recordOutput: (value: JsonValue) => void; + /** Merge one JSON value into persisted metadata for this attempt. */ + readonly setMetadata: (key: string, value: JsonValue) => void; + readonly signal: AbortSignal; +} + +/** Decoded context passed to a registered job handler. */ +export interface WorkContext< + Definition extends JobDefinition = JobDefinition, + Transaction = RegisteredTransaction, +> extends Omit, "job"> { + /** + * Complete this exact attempt inside a caller-owned transaction, so the + * job's completion commits or rolls back with the handler's own writes. + * `Transaction` defaults to the transaction types of installed drivers. + */ + readonly completeTx: ( + tx: Transaction, + options?: { readonly output?: JsonValue } + ) => Promise; + readonly job: Job; + /** Named steps whose progress is checkpointed when an attempt fails. */ + readonly resumable: Resumable; +} + +/** Identity and timing metadata for one claimed attempt. */ +export interface WorkExecution { + readonly attemptedBy: string; + readonly startedAt: Temporal.Instant; +} + +/** Job-kind-specific work lifecycle hooks. */ +export interface WorkerHooks { + afterWork?( + context: WorkContext, + result: WorkAttemptResult + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- returning nothing preserves the prior result + ): PromiseLike | WorkAttemptResult | void; + beforeWork?(context: WorkAttemptContext): PromiseLike | void; +} + +/** Named job-kind-specific work extensions. */ +export interface WorkerPlugin { + readonly hooks?: WorkerHooks; + readonly middleware?: readonly WorkMiddleware[]; + readonly name: string; +} + +/** + * Permanent cancellation without another retry, like Go's `river.JobCancel`. + * The reason is recorded as the attempt error. + */ +export interface CancelOutcome { + readonly reason?: string; + readonly type: "cancel"; +} + +/** Explicit successful completion, optionally recording JSON output. */ +export interface CompleteOutcome { + readonly output?: JsonValue; + readonly type: "complete"; +} + +/** Explicit discard without another retry. */ +export interface DiscardOutcome { + readonly reason?: string; + readonly type: "discard"; +} + +/** Reschedule work without consuming an attempt. */ +export interface SnoozeOutcome { + /** How long to snooze, rounded up to whole milliseconds by {@link snooze}. */ + readonly duration: Temporal.Duration; + readonly type: "snooze"; +} + +/** + * An explicit handler outcome, built by `complete()`, `snooze()`, + * `discard()`, or `cancel()`. + */ +export type WorkOutcome = + CancelOutcome | CompleteOutcome | DiscardOutcome | SnoozeOutcome; + +/** A handler succeeds by returning nothing or an explicit River outcome. */ +/* eslint-disable @typescript-eslint/no-invalid-void-type -- ordinary and async no-return handlers are valid */ +export type WorkHandler< + Definition extends JobDefinition, + Transaction = RegisteredTransaction, +> = ( + context: WorkContext +) => PromiseLike | WorkOutcome | void; +/* eslint-enable @typescript-eslint/no-invalid-void-type */ + +/** + * Builds a job's handler from the definition it is registered with, for an + * integration whose handler needs the definition, such as to decode other + * jobs of the same kind. `Workers.add` calls `createWorkHandler` once. + * + * ```ts + * workers.add(definition, integrationWorker(options)); + * ``` + */ +export interface WorkHandlerFactory< + Definition extends JobDefinition, + Transaction = RegisteredTransaction, +> { + readonly createWorkHandler: ( + definition: Definition + ) => WorkHandler; +} + +/** Per-handler runtime policy. */ +export interface WorkerOptions { + /** Job-kind-specific hooks, ordered after global hooks. */ + hooks?: WorkerHooks; + /** Job-kind-specific work middleware, wrapping global middleware. */ + middleware?: readonly WorkMiddleware[]; + /** Named job-kind-specific extensions. */ + plugins?: readonly WorkerPlugin[]; + /** + * When this worker's failed jobs run next, like River for Go's + * `Worker.NextRetry`. Consulted before the client's `retryPolicy` once a + * job's arguments decode, both after a failed attempt and when the rescuer + * retries a stuck job. When it throws or returns something other than a + * `Temporal.Instant`, the client's policy decides; a time in the past + * uses River's default schedule. + */ + retryPolicy?: RetryPolicy; + /** + * Cooperative timeout for this worker's attempts, such as `{ minutes: 5 }`. + * Overrides the client's `jobTimeout`; `null` disables it. + */ + timeout?: DurationInput | null; +} + +/** A worker's options after validation. */ +export interface NormalizedWorkerOptions extends Omit< + WorkerOptions, + "timeout" +> { + /** Validated timeout, or null when disabled for this worker. */ + timeout?: Temporal.Duration | null; +} + +interface WorkerRegistrationBase< + Definition extends JobDefinition = JobDefinition, +> { + readonly definition: Definition; + readonly options: Readonly; +} + +/** An ordinary handler that executes on the River runtime's event loop. */ +export interface InProcessWorkerRegistration< + Definition extends JobDefinition = JobDefinition, +> extends WorkerRegistrationBase { + readonly handler: WorkHandler; + readonly type: "in_process"; +} + +/** A handler owned by an optional executor integration. */ +export interface ExecutorWorkerRegistration< + Definition extends JobDefinition = JobDefinition, +> extends WorkerRegistrationBase { + readonly target: WorkExecutorTarget; + readonly type: "executor"; +} + +/** A worker registered in {@link Workers}, run in process or by an executor. */ +export type WorkerRegistration< + Definition extends JobDefinition = JobDefinition, +> = + | InProcessWorkerRegistration + | ExecutorWorkerRegistration; + +/** How River asks a {@link WorkExecutor} to stop one attempt. */ +export interface WorkExecutorAbortOptions { + /** + * How long the handler has to settle after its signal aborts before the + * executor may end it by force. River passes the client's + * `jobStuckThreshold`. + */ + readonly gracePeriod: Temporal.Duration; +} + +/** The result of asking a {@link WorkExecutor} to stop one attempt. */ +export interface WorkExecutorAbortResult { + /** + * Whether the executor stopped the attempt itself, by removing it before + * it began or by ending its handler by force, rather than the handler + * settling on its own. True only once the handler no longer executes. + * When the abort came from stopping the client, rather than from the + * job's cancellation or timeout, an attempt ended by force after it began + * fails with a `JobAbortedError`, so its attempt counts. + */ + readonly terminated: boolean; +} + +/** One running attempt owned by a pluggable executor. */ +export interface WorkExecutorHandle { + readonly result: PromiseLike; + /** + * Settles once the attempt begins executing, for executors that queue + * attempts for capacity of their own. River arms the job timeout and stuck + * detection only after it resolves, so waiting for capacity does not spend + * the attempt's time. Omit it when attempts start immediately. + */ + readonly started?: PromiseLike; + /** + * Abort the handler's signal with `reason`. An executor that can end a + * handler by force should wait `options.gracePeriod` for it to settle + * first. + */ + abort( + reason: unknown, + options: WorkExecutorAbortOptions + ): PromiseLike; +} + +/** + * Runs handlers somewhere other than River's own event loop, such as in + * worker threads (`@riverqueue/worker-threads`). + * + * An executor belongs to the application that constructs it and may serve + * several clients or runtimes. River never closes an executor; stopping a + * runtime only aborts that runtime's own attempts. + */ +export interface WorkExecutor { + readonly name: string; + diagnostics?(): JsonObject; + start(context: WorkContext, handler: unknown): WorkExecutorHandle; +} + +/** Opaque handler registration produced by an optional executor package. */ +export interface WorkExecutorTarget { + readonly executor: WorkExecutor; + readonly handler: unknown; +} + +/** Reads a registry's private registrations; set by {@link Workers}. */ +let readRegistration: ( + workers: Workers, + kind: string +) => WorkerRegistration | undefined; + +/** + * Typed registry of River job handlers. + * + * `Transaction` types `ctx.client` and `ctx.completeTx` inside handlers. It + * defaults to the transaction types of installed drivers; narrow it with + * `new Workers()` when a codebase uses one driver. + */ +export class Workers { + readonly #registrations = new Map(); + + static { + readRegistration = (workers, kind) => { + if (!(#registrations in workers)) { + throw new ConfigurationError( + "a Workers registry from another installed copy of riverqueue " + + "can't be used; check `npm ls riverqueue`" + ); + } + return workers.#registrations.get(kind); + }; + } + + /** + * Register exactly one handler for a job definition: a handler function, + * or a {@link WorkHandlerFactory} that builds one for the definition. + */ + add( + definition: Definition, + handler: + | WorkHandler + | WorkHandlerFactory, + options: WorkerOptions = {} + ): this { + this.#requireUnregistered(definition); + + if ( + typeof handler === "object" && + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- validates untyped JavaScript input + handler !== null && + typeof handler.createWorkHandler === "function" + ) { + handler = handler.createWorkHandler(definition); + } + if (typeof handler !== "function") { + throw new ConfigurationError("worker handler must be a function"); + } + + const normalizedOptions = normalizeWorkerOptions(options); + + this.#register( + definition, + Object.freeze({ + definition, + handler, + options: Object.freeze(normalizedOptions), + type: "in_process", + }) + ); + return this; + } + + /** Register a handler produced by an optional executor integration. */ + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -- keeps the published signature + addExecutor( + definition: Definition, + target: WorkExecutorTarget, + options: WorkerOptions = {} + ): this { + this.#requireUnregistered(definition); + if ( + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- validates untyped JavaScript input + target === null || + typeof target !== "object" || + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- validates untyped JavaScript input + target.executor === null || + typeof target.executor !== "object" || + target.executor.name.length === 0 || + typeof target.executor.start !== "function" + ) { + throw new ConfigurationError("invalid work executor target"); + } + + this.#register( + definition, + Object.freeze({ + definition, + options: Object.freeze(normalizeWorkerOptions(options)), + target: Object.freeze({ ...target }), + type: "executor", + }) + ); + return this; + } + + /** Return whether this registry contains a job kind. */ + has(kind: string): boolean { + return this.#registrations.has(kind); + } + + /** + * Return registered job kinds, each definition's kind aliases included, in + * registration order. + */ + kinds(): readonly string[] { + return Object.freeze([...this.#registrations.keys()]); + } + + /** Number of registered job kinds, kind aliases included. */ + get size(): number { + return this.#registrations.size; + } + + /** Register a worker under its definition's kind and kind aliases, like Go. */ + #register(definition: JobDefinition, registration: WorkerRegistration): void { + for (const kind of definitionKinds(definition)) { + this.#registrations.set(kind, registration); + } + } + + #requireUnregistered(definition: JobDefinition): void { + for (const kind of definitionKinds(definition)) { + if (this.#registrations.has(kind)) { + throw new ConfigurationError( + `worker already registered for job kind ${JSON.stringify(kind)}` + ); + } + } + } +} + +/** + * The registration for `kind` in `workers`, or undefined for an unknown + * kind, for River's runtime and test packages. + */ +export function workerRegistration( + workers: Workers, + kind: string +): WorkerRegistration | undefined { + return readRegistration(workers, kind); +} + +/** A definition's kind followed by its kind aliases. */ +function definitionKinds(definition: JobDefinition): readonly string[] { + return [definition.kind, ...(definition.kindAliases ?? [])]; +} + +function normalizeWorkerOptions( + options: WorkerOptions +): NormalizedWorkerOptions { + const normalizedOptions: NormalizedWorkerOptions = {}; + if (options.hooks !== undefined) { + normalizedOptions.hooks = Object.freeze({ ...options.hooks }); + } + if (options.middleware !== undefined) { + normalizedOptions.middleware = Object.freeze([...options.middleware]); + } + if (options.plugins !== undefined) { + const names = new Set(); + normalizedOptions.plugins = Object.freeze( + options.plugins.map((plugin) => { + if (isJobArgsTransformPlugin(plugin)) { + throw new ConfigurationError( + "job argument transform plugins must be configured on Client" + ); + } + if (plugin.name.length === 0) { + throw new ConfigurationError("worker plugin name is empty"); + } + if (names.has(plugin.name)) { + throw new ConfigurationError( + `duplicate worker plugin name ${JSON.stringify(plugin.name)}` + ); + } + names.add(plugin.name); + return Object.freeze({ + ...(plugin.hooks === undefined + ? {} + : { hooks: Object.freeze({ ...plugin.hooks }) }), + ...(plugin.middleware === undefined + ? {} + : { middleware: Object.freeze([...plugin.middleware]) }), + name: plugin.name, + }); + }) + ); + } + if (options.retryPolicy !== undefined) { + if (typeof options.retryPolicy !== "function") { + throw new ConfigurationError("worker retryPolicy must be a function"); + } + normalizedOptions.retryPolicy = options.retryPolicy; + } + if (options.timeout !== undefined) { + normalizedOptions.timeout = + options.timeout === null + ? null + : toDuration("worker timeout", options.timeout); + } + return normalizedOptions; +} + +/** + * Construct an outcome that cancels the job permanently, like Go's + * `river.JobCancel`. The job moves to `cancelled` without another retry and + * `JobCancelError: ` is recorded as the attempt error. + */ +export function cancel( + options: { readonly reason?: string } = {} +): CancelOutcome { + if (options.reason === undefined) return Object.freeze({ type: "cancel" }); + if (typeof options.reason !== "string" || options.reason.length === 0) { + throw new ValidationError("cancel reason must be a non-empty string"); + } + return Object.freeze({ reason: options.reason, type: "cancel" }); +} + +/** Construct an explicit successful-completion outcome. */ +export function complete( + options: { readonly output?: JsonValue } = {} +): CompleteOutcome { + if (options.output === undefined) return Object.freeze({ type: "complete" }); + return Object.freeze({ + output: toJsonValue(options.output), + type: "complete", + }); +} + +/** Construct an explicit discard outcome. */ +export function discard( + options: { readonly reason?: string } = {} +): DiscardOutcome { + if (options.reason === undefined) return Object.freeze({ type: "discard" }); + if (options.reason.length === 0) { + throw new ValidationError("discard reason must not be empty"); + } + return Object.freeze({ reason: options.reason, type: "discard" }); +} + +/** + * Snooze the job: run it again after `duration` without using an attempt, + * for example `return snooze({ minutes: 5 })`. Like River for Go, a snooze + * no longer than the scheduler interval (including zero) is stored as + * available with its future scheduled time, so it runs on time without + * waiting for the scheduler. + */ +export function snooze(duration: DurationInput): SnoozeOutcome { + const milliseconds = toMilliseconds("snooze duration", duration, { + allowZero: true, + error: ValidationError, + }); + return Object.freeze({ + duration: millisecondsToDuration(milliseconds), + type: "snooze", + }); +} diff --git a/js/tsconfig.base.json b/js/tsconfig.base.json index 47564ade4..220a50f63 100644 --- a/js/tsconfig.base.json +++ b/js/tsconfig.base.json @@ -7,13 +7,17 @@ "moduleResolution": "NodeNext", "declaration": true, "declarationMap": true, + "exactOptionalPropertyTypes": true, "sourceMap": true, + "stripInternal": true, "strict": true, "esModuleInterop": true, "noImplicitOverride": true, + "noUncheckedIndexedAccess": true, "noUncheckedSideEffectImports": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "useUnknownInCatchVariables": true + "useUnknownInCatchVariables": true, + "verbatimModuleSyntax": true } } From 98b347ad7297988e3d2467af798bdcd285900be8 Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sun, 4 Oct 2026 17:16:20 -0500 Subject: [PATCH 21/43] test job definitions, codecs, unique keys, and events Cover `defineJob` with Zod and Valibot schemas, decoders, and the compile-time JSON checks on producer and worker types; the exact JSON round trip of job rows; attempt error decoding with Go's `encoding/json` leniency; transform plugin payload privacy; insert notification limiting; PostgreSQL capability detection; and event subscriptions. A property test checks every unique option combination against a reference key, including key ordering, `byArgs` path selection, rejection of paths Go reads as array indexes, and UTC period windows. --- js/src/driver-codecs.test.ts | 251 ++++++++ js/src/events.test.ts | 103 ++++ js/src/internal/insert-notify-limiter.test.ts | 32 + js/src/internal/postgres-capabilities.test.ts | 72 +++ js/src/job-definition.test.ts | 278 +++++++++ js/src/job.test.ts | 87 +++ js/src/unique-key.property.test.ts | 576 ++++++++++++++++++ js/src/unstable-driver.test.ts | 152 +++++ 8 files changed, 1551 insertions(+) create mode 100644 js/src/driver-codecs.test.ts create mode 100644 js/src/events.test.ts create mode 100644 js/src/internal/insert-notify-limiter.test.ts create mode 100644 js/src/internal/postgres-capabilities.test.ts create mode 100644 js/src/job-definition.test.ts create mode 100644 js/src/job.test.ts create mode 100644 js/src/unique-key.property.test.ts create mode 100644 js/src/unstable-driver.test.ts diff --git a/js/src/driver-codecs.test.ts b/js/src/driver-codecs.test.ts new file mode 100644 index 000000000..07d6959e9 --- /dev/null +++ b/js/src/driver-codecs.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, it } from "vitest"; + +import { decodeAttemptError, decodeAttemptErrors } from "./driver-codecs.js"; + +const ZERO = Temporal.Instant.from("0001-01-01T00:00:00Z"); +const ATTEMPT_AT = Temporal.Instant.from("2024-01-02T03:04:05.123456Z"); + +function attemptError( + fields: Partial> +): ReturnType { + return { at: ZERO, attempt: 0, error: "", trace: "", ...fields }; +} + +describe("decodeAttemptError", () => { + it("rejects text that isn't valid JSON", () => { + expect(() => decodeAttemptError(`{"at":`)).toThrow(SyntaxError); + }); + + it("decodes the shape River writes exactly", () => { + expect( + decodeAttemptError( + `{"at":"2024-01-02T03:04:05.123456Z","attempt":3,"error":"job failed","trace":"goroutine 1 [running]:"}` + ) + ).toEqual({ + at: ATTEMPT_AT, + attempt: 3, + error: "job failed", + trace: "goroutine 1 [running]:", + }); + }); + + it("decodes missing fields and nulls as Go's encoding/json does", () => { + expect(decodeAttemptError(`{"attempt":2,"error":null}`)).toEqual( + attemptError({ attempt: 2 }) + ); + expect(decodeAttemptError(`null`)).toEqual(attemptError({})); + }); + + // The cases of River for Go's `TestUnmarshalAttemptError`. + it.each([ + [ + "AtInvalid", + `{"at":"not a time","attempt":2,"error":"err"}`, + { attempt: 2, error: "err" }, + ], + [ + "AtNoOffset", + `{"at":"2024-01-02T03:04:05.123456","attempt":2}`, + { attempt: 2 }, + ], + ["AtNumber", `{"at":1704164645,"attempt":2}`, { attempt: 2 }], + [ + "AtPostgresText", + `{"at":"2024-01-02 03:04:05.123456+00","attempt":2}`, + { attempt: 2 }, + ], + [ + "AtRFC3339WithOtherInvalidField", + `{"at":"2024-01-02T03:04:05.123456Z","attempt":"2"}`, + { at: ATTEMPT_AT, attempt: 2 }, + ], + [ + "AtSpaceNoOffset", + `{"at":"2024-01-02 03:04:05.123456","attempt":2}`, + { attempt: 2 }, + ], + [ + "AttemptFloat", + `{"attempt":3.0,"error":"err"}`, + { attempt: 3, error: "err" }, + ], + ["AttemptFractional", `{"attempt":3.5,"error":"err"}`, { error: "err" }], + ["AttemptObject", `{"attempt":{},"error":"err"}`, { error: "err" }], + [ + "AttemptString", + `{"attempt":" 3 ","error":"err"}`, + { attempt: 3, error: "err" }, + ], + [ + "AttemptStringInvalid", + `{"attempt":"three","error":"err"}`, + { error: "err" }, + ], + ["ElementArray", `[1, "two"]`, { error: `[1,"two"]` }], + ["ElementNumber", `123`, { error: "123" }], + ["ElementString", `"job failed"`, { error: "job failed" }], + [ + "ErrorObject", + `{"attempt":1,"error":{"message": "boom", "code": 7}}`, + { attempt: 1, error: `{"message":"boom","code":7}` }, + ], + [ + "TraceArray", + `{"attempt":1,"error":"err","trace":["frame1", "frame2"]}`, + { attempt: 1, error: "err", trace: `["frame1","frame2"]` }, + ], + [ + "TraceNullWithInvalidField", + `{"attempt":"x","error":null,"trace":null}`, + {}, + ], + ])( + "decodes an unexpected shape leniently like Go: %s", + (_name, json, expected) => { + expect(decodeAttemptError(json)).toEqual(attemptError(expected)); + } + ); + + // Go's `time.Time` reads `at` with `time.Parse` and its RFC 3339 layout, + // from the string as written (Go 1.26 doesn't unescape it). + it.each([ + [ + "OneDigitHour", + "2024-01-02T3:04:05.123456Z", + "2024-01-02T03:04:05.123456Z", + ], + [ + "CommaFraction", + "2024-01-02T03:04:05,123456Z", + "2024-01-02T03:04:05.123456Z", + ], + [ + "FractionPastNanoseconds", + "2024-01-02T03:04:05.1234567891Z", + "2024-01-02T03:04:05.123456789Z", + ], + [ + "Offset", + "2024-01-02T05:34:05.123456+02:30", + "2024-01-02T03:04:05.123456Z", + ], + [ + "NegativeZeroOffset", + "2024-01-02T03:04:05.123456-00:00", + "2024-01-02T03:04:05.123456Z", + ], + [ + "LargestOffset", + "2024-01-03T04:04:05.123456+24:60", + "2024-01-02T03:04:05.123456Z", + ], + ["YearZero", "0000-01-02T03:04:05Z", "0000-01-02T03:04:05Z"], + ["LeapDay", "2024-02-29T03:04:05Z", "2024-02-29T03:04:05Z"], + ["EscapedZone", "2024-01-02T03:04:05BSLu005a", null], + ["OffsetMinutePastRange", "2024-01-02T03:04:05+24:61", null], + ["OffsetWithoutColon", "2024-01-02T03:04:05+0100", null], + ["OffsetHourOnly", "2024-01-02T03:04:05+01", null], + ["FractionWithoutDigits", "2024-01-02T03:04:05.Z", null], + ["Hour24", "2024-01-02T24:00:00Z", null], + ["LeapSecond", "2024-01-02T03:04:60Z", null], + ["OneDigitMinute", "2024-01-02T03:4:05Z", null], + ["NotALeapDay", "2023-02-29T03:04:05Z", null], + ["LowerCase", "2024-01-02t03:04:05z", null], + ["Padded", " 2024-01-02T03:04:05Z", null], + ])("reads `at` like Go: %s", (_name, at, expected) => { + expect( + decodeAttemptError( + `{"at":${JSON.stringify(at).replaceAll("BSL", "\\")},"attempt":1}` + ).at + ).toEqual(expected === null ? ZERO : Temporal.Instant.from(expected)); + }); + + // Go reads a lenient `attempt` with `strconv`, which takes digit + // separators, hexadecimal floats, and signs. + it.each([ + ["Exponent", `1E2`, 100], + ["NegativeZero", `-0`, 0], + ["BeyondInt64", `9223372036854775808`, 0], + ["Separators", `"1_000"`, 1000], + ["SeparatorAfterPoint", `"1_0.0"`, 10], + ["MisplacedSeparator", `"1__0"`, 0], + ["HexFloat", `"0x1.8p1"`, 3], + ["HexFloatSeparatorAfterPrefix", `"0x_1p4"`, 16], + ["HexWithoutExponent", `"0x10"`, 0], + ["PlusSign", `"+3"`, 3], + ["Infinity", `"Inf"`, 0], + ["Boolean", `true`, 0], + ])("reads `attempt` like Go: %s", (_name, attempt, expected) => { + expect( + decodeAttemptError(`{"attempt":${attempt},"error":"err"}`).attempt + ).toBe(expected); + }); + + it("matches field names case-insensitively, keeping the last", () => { + expect( + decodeAttemptError( + `{"AT":"2024-01-02T03:04:05.123456Z","Attempt":2,"ERROR":"first","error":"last","Trace":"t"}` + ) + ).toEqual({ at: ATTEMPT_AT, attempt: 2, error: "last", trace: "t" }); + }); + + it("lets a later null leave a field unless the element is lenient", () => { + // Go's encoding/json leaves a field as it was for `null`. + expect(decodeAttemptError(`{"error":"kept","Error":null}`)).toEqual( + attemptError({ error: "kept" }) + ); + // Go's lenient fallback takes each field's last value. + expect( + decodeAttemptError(`{"error":"kept","Error":null,"attempt":"2"}`) + ).toEqual(attemptError({ attempt: 2 })); + }); + + it("keeps other values' JSON text as written, without white space", () => { + expect( + decodeAttemptError( + `{"error":{"message": "BSLu00e9", "code": 1.50},"trace":["BSLud800"]}`.replaceAll( + "BSL", + "\\" + ) + ) + ).toEqual( + attemptError({ + error: `{"message":"BSLu00e9","code":1.50}`.replaceAll("BSL", "\\"), + trace: `["BSLud800"]`.replaceAll("BSL", "\\"), + }) + ); + }); + + it("replaces an unpaired surrogate in a string like Go", () => { + expect( + decodeAttemptError(`{"error":"aBSLud800b"}`.replaceAll("BSL", "\\")).error + ).toBe("a\ufffdb"); + }); +}); + +describe("decodeAttemptErrors", () => { + it("decodes an empty array and null as empty", () => { + expect(decodeAttemptErrors(`[]`)).toEqual([]); + expect(decodeAttemptErrors(`null`)).toEqual([]); + }); + + it("rejects text that isn't valid JSON or isn't an array", () => { + expect(() => decodeAttemptErrors(`[{"at":`)).toThrow(SyntaxError); + expect(() => decodeAttemptErrors(`{"error":"not an array"}`)).toThrow( + "JSON is not an array" + ); + }); + + it("decodes each element on its own, like Go", () => { + expect( + decodeAttemptErrors( + ` [ {"at":"2024-01-02T03:04:05.123456Z","attempt":1,"error":"err1","trace":""} , "err2", {"at":"invalid","attempt":"2","error":"err"}, {"error":"next","Error":null} ] ` + ) + ).toEqual([ + attemptError({ at: ATTEMPT_AT, attempt: 1, error: "err1" }), + attemptError({ error: "err2" }), + attemptError({ attempt: 2, error: "err" }), + attemptError({ error: "next" }), + ]); + }); +}); diff --git a/js/src/events.test.ts b/js/src/events.test.ts new file mode 100644 index 000000000..34fef5f0d --- /dev/null +++ b/js/src/events.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, expectTypeOf, test } from "vitest"; + +import { JobStuckError } from "./errors.js"; +import { + EventHub, + jobEvent, + type JobFailedEvent, + type SubscriptionLagEvent, +} from "./events.js"; +import type { JobRow } from "./job.js"; + +describe("EventSubscription", () => { + test("narrows events by kind and closes with using", async () => { + const hub = new EventHub(); + const job = { id: 1n } as JobRow; + { + using failures = hub.subscribe({ kinds: ["job_failed"] }); + expectTypeOf>>().toEqualTypeOf< + IteratorResult + >(); + hub.publish(jobEvent("job_completed", Temporal.Now.instant(), job, null)); + hub.publish( + jobEvent("job_failed", Temporal.Now.instant(), job, new Error("boom")) + ); + const next = await failures.next(); + expect(next.value).toMatchObject({ error: new Error("boom"), job }); + } + // `using` closed and unregistered the subscription. + hub.publish(jobEvent("job_failed", Temporal.Now.instant(), job, null)); + }); + + test("builds job events whose fields match their kind", () => { + const at = Temporal.Now.instant(); + const job = { id: 1n } as JobRow; + expect(jobEvent("job_completed", at, job, new Error("ignored"))).toEqual({ + at, + job, + kind: "job_completed", + }); + expect(jobEvent("job_cancelled", at, job, undefined)).toEqual({ + at, + job, + kind: "job_cancelled", + }); + const stuck = new JobStuckError( + 1n, + Temporal.Duration.from({ milliseconds: 10 }), + Temporal.Duration.from({ milliseconds: 20 }) + ); + expect(jobEvent("job_stuck", at, job, stuck)).toMatchObject({ + error: stuck, + }); + }); + + test("closing a lagging subscription makes every subsequent next done", async () => { + const hub = new EventHub(); + const subscription = hub.subscribe({ capacity: 1 }); + for (const queueName of ["one", "two"]) + hub.publish({ + at: Temporal.Now.instant(), + kind: "queue_removed", + queueName, + }); + await subscription.return(); + await expect(subscription.next()).resolves.toEqual({ + done: true, + value: undefined, + }); + await expect(subscription.next()).resolves.toEqual({ + done: true, + value: undefined, + }); + }); + test("a broken for-await loop closes and unregisters the subscription", async () => { + const hub = new EventHub(); + const subscription = hub.subscribe(); + const consumed: string[] = []; + const loop = (async () => { + for await (const event of subscription) { + consumed.push(event.kind); + break; + } + })(); + + hub.publish({ + at: Temporal.Now.instant(), + kind: "queue_removed", + queueName: "first", + }); + await loop; + hub.publish({ + at: Temporal.Now.instant(), + kind: "queue_removed", + queueName: "second", + }); + + expect(consumed).toEqual(["queue_removed"]); + await expect(subscription.next()).resolves.toEqual({ + done: true, + value: undefined, + }); + }); +}); diff --git a/js/src/internal/insert-notify-limiter.test.ts b/js/src/internal/insert-notify-limiter.test.ts new file mode 100644 index 000000000..caee31fa7 --- /dev/null +++ b/js/src/internal/insert-notify-limiter.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { InsertNotifyLimiter } from "./insert-notify-limiter.js"; + +describe("InsertNotifyLimiter", () => { + const setup = (cooldownMs = 100) => { + const clock = { now: 1_000 }; + const limiter = new InsertNotifyLimiter(cooldownMs, () => clock.now); + return { clock, limiter }; + }; + + it("allows each queue once, in first-seen order", () => { + const { limiter } = setup(); + + expect(limiter.allow(["beta", "alpha", "beta"])).toEqual(["beta", "alpha"]); + expect(limiter.allow([])).toEqual([]); + }); + + it("suppresses a queue through its cooldown, like River for Go", () => { + const { clock, limiter } = setup(); + + expect(limiter.allow(["alpha"])).toEqual(["alpha"]); + clock.now += 50; + expect(limiter.allow(["alpha", "beta"])).toEqual(["beta"]); + // Go allows a queue only once its last notification is strictly older + // than the cooldown. + clock.now += 50; + expect(limiter.allow(["alpha"])).toEqual([]); + clock.now += 1; + expect(limiter.allow(["alpha", "beta"])).toEqual(["alpha"]); + }); +}); diff --git a/js/src/internal/postgres-capabilities.test.ts b/js/src/internal/postgres-capabilities.test.ts new file mode 100644 index 000000000..318e9c2dd --- /dev/null +++ b/js/src/internal/postgres-capabilities.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; + +import { + postgresCapabilities, + postgresCapabilitiesFromRow, + uniqueInsertConflictSql, +} from "./postgres-capabilities.js"; + +describe("postgresCapabilities", () => { + it.each([ + ["PostgreSQL 15.12", 150_012, false, true, "xmax"], + ["PostgreSQL 17.4 on aarch64-apple-darwin", 170_004, false, true, "xmax"], + ["PostgreSQL 18.0", 180_000, false, true, "returning_old"], + [ + "PostgreSQL 15.12-YB-2025.2.1.0-b1", + 150_012, + false, + false, + "metadata_nonce", + ], + [ + "PostgreSQL 15.12-YB-2025.2.3.0-b1", + 150_012, + false, + false, + "metadata_nonce", + ], + [ + "PostgreSQL 15.12-YB-2025.2.3.0-b1", + 150_012, + true, + true, + "metadata_nonce", + ], + ["YugabyteDB", 150_012, false, false, "metadata_nonce"], + ] as const)( + "detects %s (%i, yb_enable_listen_notify %s) like River for Go", + (product, versionNum, ybListenNotify, listenNotify, mode) => { + expect(postgresCapabilities(product, versionNum, ybListenNotify)).toEqual( + { supportsListenNotify: listenNotify, uniqueInsertMode: mode } + ); + } + ); + + it("decodes a detection row whose version arrives as text", () => { + expect( + postgresCapabilitiesFromRow({ + product: "PostgreSQL 18.1", + version_num: "180001", + yb_listen_notify_enabled: false, + }) + ).toEqual({ + supportsListenNotify: true, + uniqueInsertMode: "returning_old", + }); + expect(() => + postgresCapabilitiesFromRow({ + product: null, + version_num: 180_001, + yb_listen_notify_enabled: false, + }) + ).toThrow(TypeError); + }); + + it("renders each mode's conflict expression", () => { + expect(uniqueInsertConflictSql("metadata_nonce")).toBe("false"); + expect(uniqueInsertConflictSql("returning_old")).toBe( + "(OLD.id IS NOT NULL)" + ); + expect(uniqueInsertConflictSql("xmax")).toBe("(xmax != 0)"); + }); +}); diff --git a/js/src/job-definition.test.ts b/js/src/job-definition.test.ts new file mode 100644 index 000000000..dc469d4d1 --- /dev/null +++ b/js/src/job-definition.test.ts @@ -0,0 +1,278 @@ +import * as v from "valibot"; +import { describe, expect, expectTypeOf, it } from "vitest"; +import { z } from "zod"; + +import { PayloadValidationError } from "./errors.js"; +import type { JsonObject } from "./json.js"; +import { + decodeJobArgs, + defineJob, + isJobDefinition, + prepareJobInput, + type JobDefinitionArgs, + type JobDefinitionInput, + type StandardSchemaV1, +} from "./job-definition.js"; + +describe("defineJob", () => { + it("creates an immutable definition and snapshots defaults", () => { + const tags = ["one-tag"]; + const definition = defineJob({ + defaults: { queue: "work", tags }, + kind: "immutable", + }); + tags.push("later-tag"); + + expect(Object.isFrozen(definition)).toBe(true); + expect(Object.isFrozen(definition.defaults)).toBe(true); + expect(definition.defaults.tags).toEqual(["one-tag"]); + expect(isJobDefinition(definition)).toBe(true); + expect(isJobDefinition({ defaults: {}, kind: "immutable" })).toBe(false); + }); + + it("types unchecked definitions as JSON objects on both sides", async () => { + const definition = defineJob({ kind: "unchecked" }); + const decoded = await decodeJobArgs(definition, { value: "hello" }); + + expectTypeOf(definition.kind).toEqualTypeOf<"unchecked">(); + expectTypeOf< + JobDefinitionInput + >().toEqualTypeOf(); + expectTypeOf(decoded).toEqualTypeOf(); + expect(decoded).toEqual({ value: "hello" }); + + // Without a type argument, the curried form takes JSON objects too. + const curried = defineJob()({ kind: "curried_unchecked" }); + expect(curried.kind).toBe("curried_unchecked"); + expectTypeOf< + JobDefinitionInput + >().toEqualTypeOf(); + }); + + it("keeps worker args unvalidated when only a producer type is declared", async () => { + interface Input { + value: string; + } + const definition = defineJob()({ kind: "declared" }); + const decoded = await decodeJobArgs(definition, { value: 1 }); + + expectTypeOf(definition.kind).toEqualTypeOf<"declared">(); + expectTypeOf< + JobDefinitionInput + >().toEqualTypeOf(); + expectTypeOf(decoded).toEqualTypeOf(); + expect(decoded).toEqual({ value: 1 }); + }); + + it("infers Standard Schema input and transformed worker output", async () => { + const definition = defineJob({ + kind: "standard", + schema: { + "~standard": { + types: undefined as unknown as { + input: { count: number }; + output: { count: number; doubled: number }; + }, + validate(value: unknown) { + const input = value as { count?: unknown }; + return typeof input.count === "number" + ? { value: { count: input.count, doubled: input.count * 2 } } + : { + issues: [ + { message: "count must be a number", path: ["count"] }, + ], + }; + }, + vendor: "river-test", + version: 1 as const, + }, + }, + }); + + const persisted = await prepareJobInput(definition, { count: 2 }); + const decoded = await decodeJobArgs(definition, persisted); + expectTypeOf(decoded).toEqualTypeOf<{ count: number; doubled: number }>(); + expect(persisted).toEqual({ count: 2 }); + expect(decoded).toEqual({ count: 2, doubled: 4 }); + }); + + it("works with Zod and Valibot schemas", async () => { + const zodJob = defineJob({ + kind: "zod_job", + schema: z.object({ + count: z.number().int().default(1), + to: z.email(), + }), + }); + const valibotJob = defineJob({ + kind: "valibot_job", + schema: v.object({ + to: v.pipe( + v.string(), + v.email(), + v.transform((value) => value.toLowerCase()) + ), + }), + }); + + expectTypeOf>().toEqualTypeOf<{ + count?: number | undefined; + to: string; + }>(); + expectTypeOf>().toEqualTypeOf<{ + count: number; + to: string; + }>(); + expectTypeOf>().toEqualTypeOf<{ + to: string; + }>(); + + await expect( + decodeJobArgs(zodJob, { to: "someone@example.com" }) + ).resolves.toEqual({ count: 1, to: "someone@example.com" }); + await expect( + decodeJobArgs(valibotJob, { to: "Someone@Example.com" }) + ).resolves.toEqual({ to: "someone@example.com" }); + await expect( + prepareJobInput(zodJob, { to: "not-an-email" }) + ).rejects.toMatchObject({ + code: "payload_validation", + kind: "zod_job", + phase: "insert", + }); + await expect(decodeJobArgs(valibotJob, { to: 5 })).rejects.toMatchObject({ + code: "payload_validation", + phase: "work", + }); + }); + + it("rejects schemas whose input is not JSON at definition time", () => { + expect(() => + defineJob({ + kind: "not_json", + // @ts-expect-error -- Date values cannot be persisted as River JSON. + schema: v.object({ at: v.date() }), + }) + ).not.toThrow(); + }); + + it("accepts interface producer types", () => { + interface EmailArgs { + readonly cc?: readonly string[]; + readonly to: string; + } + const definition = defineJob({ + decode(value): EmailArgs { + if (typeof value.to !== "string") throw new TypeError("to required"); + return { to: value.to }; + }, + kind: "interface_args", + }); + + expect(definition.kind).toBe("interface_args"); + expectTypeOf< + JobDefinitionInput + >().toEqualTypeOf(); + expectTypeOf< + JobDefinitionArgs + >().toEqualTypeOf(); + }); + + it("requires an explicit producer type when a decoder returns non-JSON args", async () => { + const definition = defineJob({ + decode(value) { + if (typeof value.at !== "string") throw new TypeError("at required"); + return { at: Temporal.Instant.from(value.at) }; + }, + kind: "non_json_args", + }); + const input = { at: "2026-01-01T00:00:00Z" }; + // @ts-expect-error -- producers must declare a JSON input type. + await prepareJobInput(definition, input); + + const declared = defineJob<{ at: string }>()({ + decode(value) { + if (typeof value.at !== "string") throw new TypeError("at required"); + return { at: Temporal.Instant.from(value.at) }; + }, + kind: "declared_non_json", + }); + await expect(prepareJobInput(declared, input)).resolves.toEqual(input); + const decoded = await decodeJobArgs(declared, input); + expectTypeOf(decoded).toEqualTypeOf<{ at: Temporal.Instant }>(); + expect(decoded.at.epochMilliseconds).toBe(Date.UTC(2026, 0, 1)); + }); + + it("wraps decoder failures as payload validation errors", async () => { + const definition = defineJob({ + async decode(value) { + await Promise.resolve(); + if (typeof value.value !== "string") { + throw new TypeError("value must be a string"); + } + return { length: value.value.length }; + }, + kind: "decoded", + }); + + const decoded = await decodeJobArgs(definition, { value: "river" }); + expectTypeOf(decoded).toEqualTypeOf<{ length: number }>(); + expect(decoded).toEqual({ length: 5 }); + + const failure = await decodeJobArgs(definition, { value: 1 }).catch( + (error: unknown) => error + ); + expect(failure).toBeInstanceOf(PayloadValidationError); + expect(failure).toMatchObject({ + cause: expect.any(TypeError), + message: 'invalid payload for job kind "decoded": value must be a string', + phase: "work", + }); + }); + + it("rejects invalid definitions and payloads", async () => { + expect(() => defineJob({ kind: "" })).toThrow( + "start with a letter, number, or underscore" + ); + expect(() => defineJob({ kind: " river" })).toThrow( + "start with a letter, number, or underscore" + ); + expect(() => defineJob({ kind: "a" })).toThrow("at least 2 characters"); + expect(() => defineJob({ kind: "has,comma" })).toThrow( + "start with a letter, number, or underscore" + ); + expect(() => defineJob({ kind: ":leading" })).toThrow( + "start with a letter, number, or underscore" + ); + expect(() => defineJob({ kind: "with[brackets]" })).not.toThrow(); + expect(() => defineJob({ kind: "river_internal_task" })).toThrow( + "reserved" + ); + expect(() => + defineJob({ + kind: "bad_schema", + schema: {} as unknown as StandardSchemaV1, + }) + ).toThrow("Standard Schema"); + + const definition = defineJob({ + kind: "invalid_payload", + schema: { + "~standard": { + validate: () => ({ issues: [{ message: "no", path: ["a", 0] }] }), + vendor: "river-test", + version: 1 as const, + }, + }, + }); + await expect(prepareJobInput(definition, {})).rejects.toMatchObject({ + code: "payload_validation", + kind: "invalid_payload", + message: 'invalid payload for job kind "invalid_payload": a.0: no', + phase: "insert", + }); + await expect( + prepareJobInput(defineJob({ kind: "plain" }), [] as unknown as JsonObject) + ).rejects.toMatchObject({ code: "validation" }); + }); +}); diff --git a/js/src/job.test.ts b/js/src/job.test.ts new file mode 100644 index 000000000..d63725b24 --- /dev/null +++ b/js/src/job.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; + +import { JOB_STATE, jobFromJsonValue, jobToJsonValue } from "./job.js"; +import type { JobRow } from "./job.js"; + +describe("job JSON helpers", () => { + it("round trips exact IDs, nanosecond instants, bytes, and JSON", () => { + const job: JobRow = { + args: { message: "hello" }, + attempt: 1, + attemptedAt: Temporal.Instant.from("2026-08-30T12:34:56.123456789Z"), + attemptedBy: ["client-a"], + createdAt: Temporal.Instant.from("2026-08-30T12:00:00.000001Z"), + errors: [ + { + at: Temporal.Instant.from("2026-08-30T12:30:00.000002Z"), + attempt: 1, + error: "failed", + trace: "trace", + }, + ], + finalizedAt: null, + id: 9_223_372_036_854_775_807n, + kind: "email", + maxAttempts: 25, + metadata: { nested: { value: true } }, + priority: 1, + queue: "default", + scheduledAt: Temporal.Instant.from("2026-08-30T12:00:00.000003Z"), + state: JOB_STATE.available, + tags: ["tag-one"], + uniqueKey: Uint8Array.of(0, 15, 255), + uniqueStates: null, + }; + + const json = jobToJsonValue(job); + expect(json.id).toBe("9223372036854775807"); + expect(json.attemptedAt).toBe("2026-08-30T12:34:56.123456789Z"); + expect(json.uniqueKey).toBe("000fff"); + expect(json.uniqueStates).toBeNull(); + expect(() => JSON.stringify(json)).not.toThrow(); + + const decoded = jobFromJsonValue(JSON.parse(JSON.stringify(json))); + expect(decoded).toEqual(job); + expect(decoded.id).toBe(9_223_372_036_854_775_807n); + expect(decoded.createdAt.epochNanoseconds).toBe( + job.createdAt.epochNanoseconds + ); + }); + + it("rejects lossy or unknown wire values", () => { + const valid = jobToJsonValue({ + args: {}, + attempt: 0, + attemptedAt: null, + attemptedBy: [], + createdAt: Temporal.Instant.from("2026-01-01T00:00:00Z"), + errors: [], + finalizedAt: null, + id: 1n, + kind: "test", + maxAttempts: 1, + metadata: {}, + priority: 1, + queue: "default", + scheduledAt: Temporal.Instant.from("2026-01-01T00:00:00Z"), + state: JOB_STATE.available, + tags: [], + uniqueKey: null, + uniqueStates: null, + }); + + // Go's SQLite driver accepts rows with zero max attempts; so does River + // JS, so their JSON form must round-trip too. + expect(jobFromJsonValue({ ...valid, maxAttempts: 0 }).maxAttempts).toBe(0); + expect(() => jobFromJsonValue({ ...valid, maxAttempts: -1 })).toThrow(); + expect(() => jobFromJsonValue({ ...valid, id: 1 })).toThrow( + "job.id: must be a string" + ); + expect(() => jobFromJsonValue({ ...valid, state: "future_state" })).toThrow( + "unknown River job state" + ); + expect(() => jobFromJsonValue({ ...valid, uniqueKey: "ABC" })).toThrow( + "lowercase hexadecimal" + ); + }); +}); diff --git a/js/src/unique-key.property.test.ts b/js/src/unique-key.property.test.ts new file mode 100644 index 000000000..a17512c84 --- /dev/null +++ b/js/src/unique-key.property.test.ts @@ -0,0 +1,576 @@ +import { Buffer } from "node:buffer"; +import { createHash } from "node:crypto"; + +import fc from "fast-check"; +import { describe, expect, it } from "vitest"; + +import { buildUniqueKey } from "./client.js"; +import { ValidationError } from "./errors.js"; +import type { UniqueOptions } from "./insert-options.js"; +import { exactJsonNumber, isExactJsonNumber, toJsonObject } from "./json.js"; +import type { JsonObject, JsonValue } from "./json.js"; + +// Go's time.Truncate aligns periods to 0001-01-01T00:00:00Z, not to the Unix +// epoch, so periods that do not divide a day still match Go exactly. +const YEAR_ONE_TO_UNIX_EPOCH_NS = 62_135_596_800n * 1_000_000_000n; +const SECOND_NS = 1_000_000_000n; + +// Selected path tests use plain segments; arbitrary top-level argument keys +// are tested separately, including gjson/sjson path punctuation. +const hashableKey = (key: string): boolean => + key.length > 0 && !key.startsWith(":") && !/[.*?|#@\\]/.test(key); + +// A key usable as a selected path segment: also not an array index. +const selectableKey = (key: string): boolean => + hashableKey(key) && !/^[0-9]+$/.test(key) && key !== "-1"; + +const keyArbitrary = fc.oneof( + fc.constantFrom( + "__proto__", + "a", + "b", + "id", + "10", + "2", + "\u00e9", + "\u{1f600}" + ), + fc.string({ maxLength: 5 }).filter(hashableKey) +); + +const leafArbitrary: fc.Arbitrary = fc.oneof( + fc.constant(null), + fc.boolean(), + fc.integer(), + fc.double({ max: 1e6, min: -1e6, noNaN: true }), + fc + .bigInt({ max: 2n ** 63n - 1n, min: -(2n ** 63n) }) + .map((value) => exactJsonNumber(value.toString())), + fc.string({ maxLength: 6 }), + fc.constantFrom("<&>", "\u2028", "\u{1f600}") +); + +function objectFromEntries( + entries: readonly (readonly [string, JsonValue])[] +): JsonObject { + const result = Object.create(null) as JsonObject; + for (const [key, value] of entries) result[key] = value; + return result; +} + +const { object: argsArbitrary } = fc.letrec<{ + json: JsonValue; + object: JsonObject; +}>((tie) => ({ + json: fc.oneof( + { depthSize: "small", withCrossShrink: true }, + leafArbitrary, + fc.array(tie("json"), { maxLength: 3 }), + tie("object") + ), + object: fc + .uniqueArray(fc.tuple(keyArbitrary, tie("json")), { + maxLength: 5, + selector: ([key]) => key, + }) + .map(objectFromEntries), +})); + +const instantArbitrary = fc + .bigInt({ + // 0001-01-01 through 9999-12-31, the RFC 3339 range Go formats. + max: 253_402_300_799n * SECOND_NS, + min: -YEAR_ONE_TO_UNIX_EPOCH_NS, + }) + .map((nanoseconds) => Temporal.Instant.fromEpochNanoseconds(nanoseconds)); + +// Periods from one second to ten days, including ones that do not divide a +// minute, an hour, or a day, and sub-second remainders. +const periodArbitrary = fc.oneof( + fc + .constantFrom(1n, 60n, 3_600n, 86_400n, 7n, 90n, 5_400n, 100_000n) + .map((seconds) => seconds * SECOND_NS), + fc.bigInt({ max: 864_000n * SECOND_NS, min: SECOND_NS }) +); + +const compareUtf8 = (left: string, right: string) => + Buffer.compare(Buffer.from(left), Buffer.from(right)); + +/** Go's HTML-safe string escaping. */ +function goString(text: string): string { + return JSON.stringify(text).replace( + /[<>&\u2028\u2029]/g, + (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}` + ); +} + +/** How sjson writes a key: verbatim when printable ASCII without `"` or `\`. */ +function sjsonKey(key: string): string { + return /^[\x20-\x7f]*$/.test(key) && !/["\\]/.test(key) + ? `"${key}"` + : goString(key); +} + +/** + * Reference encoder for River's unique args: top-level keys sorted by UTF-8 + * bytes (as Go sorts `@keys`) and written by sjson, nested objects in their + * original order with encoding/json keys. + */ +function referenceArgs(value: JsonValue, sortKeys: boolean): string { + if (value === null || typeof value === "boolean") return String(value); + if (typeof value === "string") return goString(value); + if (typeof value === "number") { + return Object.is(value, -0) ? "-0" : JSON.stringify(value); + } + if (isExactJsonNumber(value)) return value.rawJSON; + if (Array.isArray(value)) { + return `[${value.map((item) => referenceArgs(item, false)).join(",")}]`; + } + const keys = Object.keys(value); + if (sortKeys) keys.sort(compareUtf8); + const writeKey = sortKeys ? sjsonKey : goString; + return `{${keys + .map( + (key) => `${writeKey(key)}:${referenceArgs(value[key] ?? null, false)}` + ) + .join(",")}}`; +} + +/** + * Reference encoder for a selection: River's assembled objects keep sorted + * path order and sjson keys, selected values keep their own encoding. + */ +function referenceSelected( + value: JsonObject, + assembled: ReadonlySet +): string { + return `{${Object.keys(value) + .map((key) => { + const child = value[key] ?? null; + return `${sjsonKey(key)}:${ + typeof child === "object" && child !== null && assembled.has(child) + ? referenceSelected(child as JsonObject, assembled) + : referenceArgs(child, false) + }`; + }) + .join(",")}}`; +} + +/** Reference selection of dotted byArgs paths, set in sorted path order. */ +function referenceSelection( + args: JsonObject, + paths: readonly string[], + assembled: Set +): JsonObject { + const selected = Object.create(null) as JsonObject; + assembled.add(selected); + const chosen: string[] = []; + for (const path of [...new Set(paths)].sort(compareUtf8)) { + if (chosen.some((prefix) => path.startsWith(`${prefix}.`))) continue; + const segments = path.split("."); + let value: JsonValue | undefined = args; + for (const segment of segments) { + value = + value !== null && + typeof value === "object" && + !Array.isArray(value) && + !isExactJsonNumber(value) && + Object.hasOwn(value, segment) + ? value[segment] + : undefined; + } + if (value === undefined) continue; + let target = selected; + for (const segment of segments.slice(0, -1)) { + if (target[segment] === undefined) { + const child = Object.create(null) as JsonObject; + assembled.add(child); + target[segment] = child; + } + target = target[segment] as JsonObject; + } + target[segments.at(-1) as string] = value; + chosen.push(path); + } + return selected; +} + +function referencePeriodLabel( + scheduledAt: Temporal.Instant, + periodNanoseconds: bigint +): string { + const sinceYearOne = scheduledAt.epochNanoseconds + YEAR_ONE_TO_UNIX_EPOCH_NS; + // Every representable instant is after year one, so plain division floors. + const start = + (sinceYearOne / periodNanoseconds) * periodNanoseconds - + YEAR_ONE_TO_UNIX_EPOCH_NS; + // RFC 3339 in UTC at second precision, as Go's time.RFC3339 layout. + return Temporal.Instant.fromEpochNanoseconds(start).toString({ + smallestUnit: "second", + }); +} + +interface KeyInput { + readonly args: JsonObject; + readonly kind: string; + readonly queue: string; + readonly scheduledAt: Temporal.Instant; +} + +function keyHex(input: KeyInput, options: UniqueOptions): string { + const [key] = buildUniqueKey(input, options); + return Buffer.from(key).toString("hex"); +} + +function referenceKeyHex( + input: KeyInput, + options: UniqueOptions & { readonly periodNanoseconds?: bigint } +): string { + let source = ""; + if (options.excludeKind !== true) source += `&kind=${input.kind}`; + if (options.byArgs === true) { + source += `&args=${referenceArgs(input.args, true)}`; + } else if (options.byArgs !== undefined) { + const assembled = new Set(); + const selection = referenceSelection(input.args, options.byArgs, assembled); + source += `&args=${ + Object.keys(selection).length === 0 + ? "" + : referenceSelected(selection, assembled) + }`; + } + if (options.periodNanoseconds !== undefined) { + source += `&period=${referencePeriodLabel( + input.scheduledAt, + options.periodNanoseconds + )}`; + } + if (options.byQueue === true) source += `&queue=${input.queue}`; + return createHash("sha256").update(source).digest("hex"); +} + +function period(nanoseconds: bigint): Temporal.Duration { + return Temporal.Duration.from({ + nanoseconds: Number(nanoseconds % 1_000n), + microseconds: Number((nanoseconds / 1_000n) % 1_000n), + milliseconds: Number((nanoseconds / 1_000_000n) % 1_000n), + seconds: Number(nanoseconds / SECOND_NS), + }); +} + +/** + * Every valid dotted byArgs path to a value in `args`, one level of nesting + * deep. River rejects paths with an empty or array-index segment. + */ +function pathsOf(args: JsonObject): string[] { + return Object.entries(args) + .flatMap(([key, value]) => + value !== null && + typeof value === "object" && + !Array.isArray(value) && + !isExactJsonNumber(value) + ? [key, ...Object.keys(value).map((child) => `${key}.${child}`)] + : [key] + ) + .filter((path) => path.split(".").every(selectableKey)); +} + +const inputArbitrary: fc.Arbitrary = fc.record({ + args: argsArbitrary, + kind: fc.constantFrom("email", "sync_account", "a"), + queue: fc.constantFrom("default", "critical", "q\u00e9"), + scheduledAt: instantArbitrary, +}); + +describe("unique key properties", () => { + it("matches the reference key for every option combination", () => { + fc.assert( + fc.property( + inputArbitrary, + fc.boolean(), + fc.boolean(), + fc.option(periodArbitrary, { nil: undefined }), + fc.option( + fc.oneof( + fc.constant(true as const), + fc.array(fc.string({ maxLength: 3 }).filter(selectableKey), { + minLength: 1, + }) + ), + { nil: undefined } + ), + (input, excludeKind, byQueue, periodNanoseconds, byArgsChoice) => { + const byArgs = + byArgsChoice === true || byArgsChoice === undefined + ? byArgsChoice + : [...byArgsChoice, ...pathsOf(input.args)]; + const options: UniqueOptions = { + byQueue, + excludeKind, + ...(byArgs === undefined || + (Array.isArray(byArgs) && byArgs.length === 0) + ? {} + : { byArgs }), + ...(periodNanoseconds === undefined + ? {} + : { byPeriod: period(periodNanoseconds) }), + }; + // Like Go, a key without the kind needs another dimension. + if ( + excludeKind && + !byQueue && + options.byArgs === undefined && + periodNanoseconds === undefined + ) { + expect(() => keyHex(input, options)).toThrow( + "unique.excludeKind requires byArgs, byQueue, or byPeriod" + ); + return; + } + const expected = referenceKeyHex(input, { + ...options, + ...(periodNanoseconds === undefined ? {} : { periodNanoseconds }), + }); + expect(keyHex(input, options)).toBe(expected); + // Deterministic for an identical, independently copied input. + expect( + keyHex({ ...input, args: toJsonObject(input.args) }, options) + ).toBe(expected); + } + ), + { numRuns: 400 } + ); + }); + + it("ignores top-level key order but not nested key order", () => { + // JavaScript enumerates integer-like keys first whatever their insertion + // order, so only other keys can carry a distinct nested order. + const nestedArbitrary = fc + .uniqueArray( + fc.tuple( + keyArbitrary.filter((key) => !/^(?:0|[1-9]\d*)$/.test(key)), + leafArbitrary + ), + { + minLength: 2, + maxLength: 4, + selector: ([key]) => key, + } + ) + .map(objectFromEntries); + fc.assert( + fc.property( + inputArbitrary, + keyArbitrary.filter(selectableKey), + nestedArbitrary, + (input, nestedKey, nested) => { + const args = objectFromEntries([ + ...Object.entries(input.args).filter(([key]) => key !== nestedKey), + [nestedKey, nested], + ]); + const topReversed = objectFromEntries(Object.entries(args).reverse()); + const nestedReversed = objectFromEntries([ + ...Object.entries(args).filter(([key]) => key !== nestedKey), + [nestedKey, objectFromEntries(Object.entries(nested).reverse())], + ]); + const keyFor = ( + value: JsonObject, + byArgs: NonNullable + ) => keyHex({ ...input, args: value }, { byArgs }); + + for (const byArgs of [true, [nestedKey]] as const) { + expect(keyFor(topReversed, byArgs)).toBe(keyFor(args, byArgs)); + expect(keyFor(nestedReversed, byArgs)).not.toBe( + keyFor(args, byArgs) + ); + } + } + ), + { numRuns: 300 } + ); + }); + + it("depends only on the selected byArgs paths, in any order", () => { + fc.assert( + fc.property( + inputArbitrary, + fc.nat(), + leafArbitrary, + (input, seed, replacement) => { + const paths = pathsOf(input.args); + fc.pre(paths.length > 0); + const selectedPaths = paths.filter((_, index) => (seed >> index) & 1); + fc.pre(selectedPaths.length > 0); + const shuffled = [...selectedPaths].reverse(); + const duplicated = [...selectedPaths, ...selectedPaths]; + const base = keyHex(input, { byArgs: selectedPaths }); + expect(keyHex(input, { byArgs: shuffled })).toBe(base); + expect(keyHex(input, { byArgs: duplicated })).toBe(base); + expect( + keyHex(input, { byArgs: [...selectedPaths, "missing\u0000field"] }) + ).toBe(base); + + // Changing an unselected top-level field leaves the key alone; + // changing a selected one does not. + const topLevel = Object.keys(input.args); + const unselected = topLevel.find( + (key) => + !selectedPaths.some( + (path) => path === key || path.startsWith(`${key}.`) + ) + ); + if (unselected !== undefined) { + const changed = objectFromEntries([ + ...Object.entries(input.args), + [unselected, [replacement]], + ]); + expect( + keyHex({ ...input, args: changed }, { byArgs: selectedPaths }) + ).toBe(base); + } + const selectedTop = selectedPaths.find((path) => !path.includes(".")); + if (selectedTop !== undefined) { + const changed = objectFromEntries([ + ...Object.entries(input.args), + [selectedTop, [input.args[selectedTop] ?? null]], + ]); + expect( + keyHex({ ...input, args: changed }, { byArgs: selectedPaths }) + ).not.toBe(base); + } + } + ), + { numRuns: 300 } + ); + }); + + it("hashes all top-level keys literally and rejects malformed paths", () => { + const syntaxKey = fc + .tuple( + fc.string({ maxLength: 3 }), + fc.constantFrom(".", "*", "?", "|", "#", "@", "\\"), + fc.string({ maxLength: 3 }) + ) + .map(([before, syntax, after]) => `${before}${syntax}${after}`); + fc.assert( + fc.property(inputArbitrary, syntaxKey, (input, key) => { + const args = objectFromEntries([ + ...Object.entries(input.args), + [key, 1], + ]); + expect(keyHex({ ...input, args }, { byArgs: true })).toBe( + referenceKeyHex({ ...input, args }, { byArgs: true }) + ); + // Nested keys are hashed verbatim and stay unrestricted. + expect(() => + buildUniqueKey( + { ...input, args: objectFromEntries([["nested", args]]) }, + { byArgs: true } + ) + ).not.toThrow(); + }), + { numRuns: 200 } + ); + for (const path of ["", "a..b", "a\\"]) { + expect(() => + buildUniqueKey( + { + args: objectFromEntries([["a", 1]]), + kind: "email", + queue: "default", + scheduledAt: Temporal.Instant.fromEpochMilliseconds(0), + }, + { byArgs: [path] } + ) + ).toThrow(ValidationError); + } + }); + + it("rejects any selected path with a segment Go reads as an array index", () => { + // sjson builds a JSON array for an unsigned integer or `-1` segment, + // escaped or not, so River rejects the path rather than hash an object. + const indexSegment = fc.oneof( + fc.nat().map(String), + fc.stringMatching(/^[0-9]{1,24}$/), + fc.constant("-1") + ); + const plainSegments = fc.array( + fc.string({ maxLength: 3 }).filter(selectableKey), + { maxLength: 2 } + ); + fc.assert( + fc.property( + inputArbitrary, + plainSegments, + indexSegment, + fc.boolean(), + plainSegments, + (input, before, index, escape, after) => { + const segment = escape ? `\\${index}` : index; + const path = [...before, segment, ...after].join("."); + expect(() => buildUniqueKey(input, { byArgs: [path] })).toThrow( + ValidationError + ); + expect(() => + buildUniqueKey(input, { byArgs: [...pathsOf(input.args), path] }) + ).toThrow(ValidationError); + } + ), + { numRuns: 300 } + ); + }); + + it("shares one key per Go-aligned period window, labelled in UTC", () => { + fc.assert( + fc.property( + instantArbitrary, + periodArbitrary, + fc.bigInt({ max: 10n ** 18n, min: 0n }), + (scheduledAt, periodNanoseconds, offsetSeed) => { + const input = { + args: Object.create(null) as JsonObject, + kind: "periodic", + queue: "default", + scheduledAt, + }; + const options = { byPeriod: period(periodNanoseconds) }; + const key = keyHex(input, options); + expect(key).toBe( + referenceKeyHex(input, { ...options, periodNanoseconds }) + ); + + const sinceYearOne = + scheduledAt.epochNanoseconds + YEAR_ONE_TO_UNIX_EPOCH_NS; + const start = + sinceYearOne - + (sinceYearOne % periodNanoseconds) - + YEAR_ONE_TO_UNIX_EPOCH_NS; + const inWindow = start + (offsetSeed % periodNanoseconds); + expect( + keyHex( + { + ...input, + scheduledAt: Temporal.Instant.fromEpochNanoseconds(inWindow), + }, + options + ) + ).toBe(key); + if (start - 1n >= -YEAR_ONE_TO_UNIX_EPOCH_NS) { + expect( + keyHex( + { + ...input, + scheduledAt: Temporal.Instant.fromEpochNanoseconds( + start - 1n + ), + }, + options + ) + ).not.toBe(key); + } + } + ), + { numRuns: 400 } + ); + }); +}); diff --git a/js/src/unstable-driver.test.ts b/js/src/unstable-driver.test.ts new file mode 100644 index 000000000..a658bd729 --- /dev/null +++ b/js/src/unstable-driver.test.ts @@ -0,0 +1,152 @@ +import { createHash } from "node:crypto"; + +import { describe, expect, it } from "vitest"; + +import { Client } from "./client.js"; +import { ValidationError } from "./errors.js"; +import { registerDriver } from "./internal/driver-registry.js"; +import type { JsonObject } from "./json.js"; +import { + buildUniqueKey, + createJobArgsTransformPlugin, + createJobInsertMetadataTransformPlugin, + encodeUniqueArgs, +} from "./unstable-driver.js"; + +/** A registered insert-only driver that never inserts. */ +class UnusedDriver { + declare readonly "~river"?: { + readonly capability: "insert"; + readonly transaction: never; + }; + + jobInsert(): never { + throw new Error("not used"); + } + + jobInsertMany(): never { + throw new Error("not used"); + } +} +const driver = new UnusedDriver(); +registerDriver(driver, { + backend: "fake", + capability: "insert", + operations: driver, +}); + +describe("transform plugins", () => { + it("keep their transformers off the plugin object", () => { + const plugins = [ + createJobArgsTransformPlugin({ + name: "args", + onRead: ({ args }) => args, + }), + createJobInsertMetadataTransformPlugin({ + name: "metadata", + onInsert: ({ metadata }) => ({ metadata }), + }), + ]; + for (const plugin of plugins) { + // The name, and a brand without the transformer. + const keys = Reflect.ownKeys(plugin); + expect(keys).toHaveLength(2); + expect(keys[0]).toBe("name"); + expect(Reflect.get(plugin, keys[1] as symbol)).toBe(true); + expect(Object.isFrozen(plugin)).toBe(true); + } + }); + + it("reject plugins from another installed copy, and spread copies", async () => { + // Second copies of the plugin modules, as another installation loads. + const argsSpecifier = "./job-args-transform.js?second-copy"; + const metadataSpecifier = "./job-insert-metadata-transform.js?second-copy"; + const foreignArgs = (await import( + /* @vite-ignore */ argsSpecifier + )) as typeof import("./job-args-transform.js"); + const foreignMetadata = (await import( + /* @vite-ignore */ metadataSpecifier + )) as typeof import("./job-insert-metadata-transform.js"); + expect(foreignArgs.createJobArgsTransformPlugin).not.toBe( + createJobArgsTransformPlugin + ); + expect(foreignMetadata.createJobInsertMetadataTransformPlugin).not.toBe( + createJobInsertMetadataTransformPlugin + ); + const plugins = [ + foreignArgs.createJobArgsTransformPlugin({ + name: "foreign-args", + onInsert: ({ args, encodedArgs }) => ({ args, encodedArgs }), + onRead: ({ args }) => args, + }), + foreignMetadata.createJobInsertMetadataTransformPlugin({ + name: "foreign-metadata", + onInsert: ({ metadata }) => ({ metadata }), + }), + { + ...createJobArgsTransformPlugin({ + name: "spread", + onRead: ({ args }) => args, + }), + }, + ]; + for (const plugin of plugins) { + expect(() => new Client(driver, { plugins: [plugin] })).toThrow( + /another installed copy of riverqueue; check `npm ls riverqueue`/ + ); + } + }); +}); + +describe("encodeUniqueArgs", () => { + const args = { "": 3, a: { b: 1, z: [2] }, "a-c": 2, id: "x" }; + + it.each([ + [true as const, '{"":3,"a":{"b":1,"z":[2]},"a-c":2,"id":"x"}'], + [["a.b", "a-c", ""], '{"":3,"a-c":2,"a":{"b":1}}'], + [["missing"], ""], + ])("encodes %j exactly as unique keys hash it", (byArgs, expected) => { + expect(encodeUniqueArgs(args, byArgs)).toBe(expected); + const [key] = buildUniqueKey( + { + args, + kind: "k", + queue: "default", + scheduledAt: Temporal.Instant.fromEpochMilliseconds(0), + }, + { byArgs, excludeKind: true } + ); + expect(Buffer.from(key).toString("hex")).toBe( + createHash("sha256").update(`&args=${expected}`).digest("hex") + ); + }); + + it("distinguishes literal dotted keys from nested paths", () => { + const dotted = { "a.b": 1, a: { b: 2 }, ":lead": 3 }; + expect(encodeUniqueArgs(dotted, ["a\\.b"])).toBe('{"a.b":1}'); + expect(encodeUniqueArgs(dotted, ["a.b"])).toBe('{"a":{"b":2}}'); + expect(encodeUniqueArgs(dotted, ["a\\.b", "a.b"])).toBe( + '{"a":{"b":2},"a.b":1}' + ); + expect(encodeUniqueArgs(dotted, [":lead"])).toBe('{":lead":3}'); + expect(encodeUniqueArgs(dotted, ["\\:lead"])).toBe('{":lead":3}'); + expect(() => encodeUniqueArgs(args, [])).toThrow(ValidationError); + expect(encodeUniqueArgs({ "a#b": 1 }, true)).toBe('{"a#b":1}'); + }); + + it("rejects non-object arguments like Go", () => { + // Go hashes an empty array as `{}` only when every argument is hashed. + expect(encodeUniqueArgs([] as unknown as JsonObject, true)).toBe("{}"); + for (const value of [[1], [[]], [{}], 1, true, null, "text", []]) { + const nonObject = value as unknown as JsonObject; + if (!Array.isArray(value) || value.length > 0) { + expect(() => encodeUniqueArgs(nonObject, true)).toThrow( + new ValidationError("unique args must encode a JSON object") + ); + } + expect(() => encodeUniqueArgs(nonObject, ["a"])).toThrow( + new ValidationError("unique args must encode a JSON object") + ); + } + }); +}); From ff753f2ce856dd74f804d31af658b1d5d4974047 Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sun, 4 Oct 2026 17:16:38 -0500 Subject: [PATCH 22/43] test query normalization, list cursors, and maintenance batching Cover job and queue query normalization, metadata-and-output-only job updates, bounded bulk deletion, and the keyset SQL each job list order renders. Job list cursors decode either base64 alphabet, treat Go's zero time as no time, and reject times Go can't encode; property tests check that every cursor round-trips and that edited or arbitrary input fails only with a cursor validation error. Cover the maintenance batcher's circuit breaker, which switches a service to reduced batches after consecutive timed-out batches like Go. --- js/src/internal/maintenance-batch.test.ts | 252 ++++++++++ js/src/query.property.test.ts | 306 +++++++++++++ js/src/query.test.ts | 532 ++++++++++++++++++++++ 3 files changed, 1090 insertions(+) create mode 100644 js/src/internal/maintenance-batch.test.ts create mode 100644 js/src/query.property.test.ts create mode 100644 js/src/query.test.ts diff --git a/js/src/internal/maintenance-batch.test.ts b/js/src/internal/maintenance-batch.test.ts new file mode 100644 index 000000000..d8e798221 --- /dev/null +++ b/js/src/internal/maintenance-batch.test.ts @@ -0,0 +1,252 @@ +import { describe, expect, it } from "vitest"; + +import { + BATCH_BACKOFF_MAX_MS, + BATCH_BACKOFF_MIN_MS, + BATCH_SIZE_DEFAULT, + BATCH_SIZE_REDUCED, + CircuitBreaker, + MaintenanceBatcher, + MaintenanceBatchTimeoutError, +} from "./maintenance-batch.js"; + +// Ported from Go River's `rivershared/circuitbreaker` tests. +describe("CircuitBreaker", () => { + const limit = 5; + const windowMs = 60_000; + + function setup() { + const clock = { now: 1_000_000 }; + const breaker = new CircuitBreaker({ limit, windowMs }, () => clock.now); + return { breaker, clock }; + } + + it("is configured", () => { + expect(setup().breaker.limit).toBe(limit); + }); + + it("opens at its limit", () => { + const { breaker } = setup(); + + for (let index = 0; index < limit - 1; index++) { + expect(breaker.trip()).toBe(false); + expect(breaker.open).toBe(false); + } + expect(breaker.trip()).toBe(true); + expect(breaker.open).toBe(true); + expect(breaker.trip()).toBe(true); + expect(breaker.open).toBe(true); + }); + + it("counts a trip exactly at the window's edge", () => { + const { breaker, clock } = setup(); + const start = clock.now; + + for (let index = 0; index < limit - 2; index++) { + expect(breaker.trip()).toBe(false); + } + clock.now = start + windowMs - 1_000; + expect(breaker.trip()).toBe(false); + clock.now = start + windowMs; + expect(breaker.trip()).toBe(true); + }); + + it("drops trips that fall out of the window", () => { + const { breaker, clock } = setup(); + const start = clock.now; + + expect(breaker.trip()).toBe(false); + clock.now = start + windowMs - 1_000; + for (let index = 0; index < limit - 2; index++) { + expect(breaker.trip()).toBe(false); + } + // The first trip has fallen out of the window. + clock.now = start + windowMs + 1_000; + expect(breaker.trip()).toBe(false); + }); + + it("drops several trips that fall out of the window at once", () => { + const { breaker, clock } = setup(); + const start = clock.now; + + for (let index = 0; index < limit - 1; index++) { + expect(breaker.trip()).toBe(false); + } + clock.now = start + windowMs + 1_000; + expect(breaker.trip()).toBe(false); + }); + + it("resets only while closed", () => { + const { breaker } = setup(); + + for (let index = 0; index < limit - 1; index++) { + expect(breaker.trip()).toBe(false); + } + expect(breaker.resetIfNotOpen()).toBe(true); + for (let index = 0; index < limit - 1; index++) { + expect(breaker.trip()).toBe(false); + } + expect(breaker.trip()).toBe(true); + expect(breaker.resetIfNotOpen()).toBe(false); + expect(breaker.trip()).toBe(true); + }); + + it("rejects an invalid configuration", () => { + expect(() => new CircuitBreaker({ limit: 0, windowMs: 1 })).toThrow( + RangeError + ); + expect(() => new CircuitBreaker({ limit: 1, windowMs: 0 })).toThrow( + RangeError + ); + }); +}); + +describe("MaintenanceBatcher", () => { + const signal = new AbortController().signal; + + function setup(timeoutMs: number | null = 1_000) { + const clock = { now: 0 }; + const batcher = new MaintenanceBatcher({ + now: () => clock.now, + random: () => 0, + timeoutMs, + }); + return { batcher, clock }; + } + + /** A batch that runs until its bounds' signal aborts, then fails. */ + function hangingBatch(bounds: { signal: AbortSignal }): Promise { + return new Promise((_resolve, reject) => { + bounds.signal.addEventListener( + "abort", + () => { + reject(new Error("statement timeout")); + }, + { once: true } + ); + }); + } + + it("trips to the reduced batch size after consecutive timeouts", async () => { + const batcher = new MaintenanceBatcher({ random: () => 0, timeoutMs: 1 }); + const limit = batcher.breaker.limit; + + expect(batcher.batchSize).toBe(BATCH_SIZE_DEFAULT); + for (let index = 0; index < limit - 1; index++) { + await expect(batcher.run(signal, hangingBatch)).rejects.toThrow( + "statement timeout" + ); + expect(batcher.batchSize).toBe(BATCH_SIZE_DEFAULT); + } + await expect(batcher.run(signal, hangingBatch)).rejects.toThrow( + "statement timeout" + ); + expect(batcher.batchSize).toBe(BATCH_SIZE_REDUCED); + + // Once tripped, successful batches keep the reduced size. + for (let index = 0; index < 2; index++) { + await expect(batcher.run(signal, () => 0)).resolves.toBe(0); + expect(batcher.batchSize).toBe(BATCH_SIZE_REDUCED); + } + }); + + it("resets the breaker when a batch succeeds", async () => { + const batcher = new MaintenanceBatcher({ random: () => 0, timeoutMs: 1 }); + const limit = batcher.breaker.limit; + + for (let round = 0; round < 2; round++) { + for (let index = 0; index < limit - 1; index++) { + await expect(batcher.run(signal, hangingBatch)).rejects.toThrow(); + expect(batcher.batchSize).toBe(BATCH_SIZE_DEFAULT); + } + await expect(batcher.run(signal, () => 1)).resolves.toBe(1); + expect(batcher.batchSize).toBe(BATCH_SIZE_DEFAULT); + } + }); + + it("ignores failures that aren't timeouts", async () => { + const { batcher } = setup(); + const cancelled = new AbortController(); + cancelled.abort(new Error("term ended")); + + for (let index = 0; index < batcher.breaker.limit; index++) { + await expect( + batcher.run(signal, () => { + throw new Error("delete failed"); + }) + ).rejects.toThrow("delete failed"); + await expect(batcher.run(cancelled.signal, () => 0)).rejects.toThrow( + "term ended" + ); + } + expect(batcher.batchSize).toBe(BATCH_SIZE_DEFAULT); + }); + + it("counts a batch that overruns its timeout, keeping its result", async () => { + // A SQLite statement can't be interrupted, so it finishes late. + const { batcher, clock } = setup(1_000); + const overrun = () => { + clock.now += 1_000; + return 7; + }; + + for (let index = 0; index < batcher.breaker.limit - 1; index++) { + await expect(batcher.run(signal, overrun)).resolves.toBe(7); + expect(batcher.batchSize).toBe(BATCH_SIZE_DEFAULT); + } + await expect(batcher.run(signal, overrun)).resolves.toBe(7); + expect(batcher.batchSize).toBe(BATCH_SIZE_REDUCED); + }); + + it("passes the batch its timeout and a signal that aborts at it", async () => { + const batcher = new MaintenanceBatcher({ timeoutMs: 5 }); + let reason: unknown; + + await expect( + batcher.run(signal, async (bounds) => { + expect(bounds.timeoutMs).toBe(5); + await hangingBatch(bounds).catch(() => undefined); + reason = bounds.signal.reason; + return 0; + }) + ).resolves.toBe(0); + expect(reason).toBeInstanceOf(MaintenanceBatchTimeoutError); + }); + + it("runs without a timeout when none is configured", async () => { + const { batcher, clock } = setup(null); + + for (let index = 0; index < batcher.breaker.limit; index++) { + await batcher.run(signal, (bounds) => { + expect(bounds.timeoutMs).toBeNull(); + clock.now += 3_600_000; + return 0; + }); + } + expect(batcher.batchSize).toBe(BATCH_SIZE_DEFAULT); + }); + + it("backs off a random 50 ms to 1 s between batches", async () => { + const pause = (random: number) => + new MaintenanceBatcher({ + random: () => random, + timeoutMs: null, + }).backoffMs(); + + expect(pause(0)).toBe(BATCH_BACKOFF_MIN_MS); + expect(pause(0.5)).toBe(525); + expect(pause(0.999_999)).toBe(BATCH_BACKOFF_MAX_MS - 1); + + // A pause ends as soon as the pass is cancelled. + const controller = new AbortController(); + const batcher = new MaintenanceBatcher({ + random: () => 0.999_999, + timeoutMs: null, + }); + const startedAt = performance.now(); + const backoff = batcher.backoff(controller.signal); + controller.abort(); + await backoff; + expect(performance.now() - startedAt).toBeLessThan(BATCH_BACKOFF_MIN_MS); + }); +}); diff --git a/js/src/query.property.test.ts b/js/src/query.property.test.ts new file mode 100644 index 000000000..d5a37a05f --- /dev/null +++ b/js/src/query.property.test.ts @@ -0,0 +1,306 @@ +import { Buffer } from "node:buffer"; + +import fc from "fast-check"; +import { describe, expect, it } from "vitest"; + +import type { JobListCursorValue, JobListOrderBy } from "./driver.js"; +import { ValidationError } from "./errors.js"; +import type { JobRow, JobState } from "./job.js"; +import { + encodeJobListCursor, + encodeJobListCursorValue, + encodeQueueCursor, + normalizeJobListOptions, + normalizeQueueListOptions, +} from "./query.js"; + +const INT8_MAX = 9_223_372_036_854_775_807n; +const rawJson = (JSON as unknown as { rawJSON(text: string): unknown }).rawJSON; + +// Years 0000 through 9999, which Go's time JSON supports, except Go's zero +// time: a cursor carries that for no time. +const GO_ZERO_TIME_NS = -62_135_596_800n * 10n ** 9n; +const instantArbitrary = fc + .bigInt({ + max: 253_402_300_799_999_999_999n, + min: -62_167_219_200n * 10n ** 9n, + }) + .filter((nanoseconds) => nanoseconds !== GO_ZERO_TIME_NS) + .map((nanoseconds) => Temporal.Instant.fromEpochNanoseconds(nanoseconds)); + +const idArbitrary = fc.oneof( + fc.bigInt({ max: INT8_MAX, min: 1n }), + fc.bigInt({ max: 4096n, min: 0n }).map((offset) => INT8_MAX - offset), + fc + .bigInt({ max: 4096n, min: -4096n }) + .map((offset) => BigInt(Number.MAX_SAFE_INTEGER) + offset) +); + +const nameArbitrary = fc.oneof( + fc.string({ minLength: 1, maxLength: 12 }), + fc.string({ minLength: 1, maxLength: 6, unit: "binary" }) +); + +const cursorValueArbitrary: fc.Arbitrary = fc.oneof( + fc.record({ + id: idArbitrary, + kind: nameArbitrary, + queue: nameArbitrary, + sortField: fc.constant("id" as const), + time: fc.constant(null), + }), + fc.record({ + id: idArbitrary, + kind: nameArbitrary, + queue: nameArbitrary, + sortField: fc.constantFrom( + "finalizedAt", + "scheduledAt", + "time" + ), + time: instantArbitrary, + }) +); + +const stateArbitrary = fc.constantFrom( + "available", + "cancelled", + "completed", + "discarded", + "pending", + "retryable", + "running", + "scheduled" +); + +function decode(after: string, orderBy: JobListOrderBy): JobListCursorValue { + const { after: decoded } = normalizeJobListOptions({ + after, + orderBy, + // finalizedAt ordering is only valid over finalized states. + ...(orderBy === "finalizedAt" + ? { states: ["cancelled", "completed", "discarded"] } + : {}), + }); + if (decoded === null) throw new Error("cursor decoded to null"); + return decoded; +} + +/** Only a rejected cursor may throw, and only as a River validation error. */ +function decodeOrReject(after: string): JobListCursorValue | ValidationError { + for (const orderBy of ["finalizedAt", "id", "scheduledAt", "time"] as const) { + try { + return decode(after, orderBy); + } catch (error: unknown) { + if (!(error instanceof ValidationError)) throw error; + if (!/cursor/.test(error.message)) throw error; + } + } + return new ValidationError("invalid job list cursor"); +} + +describe("job list cursor properties", () => { + it("round-trips every exact cursor value", () => { + fc.assert( + fc.property(cursorValueArbitrary, (value) => { + const cursor = encodeJobListCursorValue(value); + // Go's padded URL-safe base64. + expect(cursor).toMatch(/^[A-Za-z0-9_-]+={0,2}$/); + expect(cursor.length % 4).toBe(0); + expect(decode(cursor, value.sortField)).toEqual(value); + for (const other of ["finalizedAt", "id", "scheduledAt", "time"]) { + if (other === value.sortField) continue; + expect(() => decode(cursor, other as JobListOrderBy)).toThrow( + ValidationError + ); + } + }), + { numRuns: 500 } + ); + }); + + it("encodes the time field each ordering sorts by, for every listed state", () => { + const finalizedStates: readonly JobState[] = [ + "cancelled", + "completed", + "discarded", + ]; + fc.assert( + fc.property( + idArbitrary, + stateArbitrary, + fc.uniqueArray(stateArbitrary), + fc.option(instantArbitrary, { nil: null }), + instantArbitrary, + instantArbitrary, + (id, state, states, attemptedAt, scheduledAt, finalizedAt) => { + const finalized = finalizedStates.includes(state); + const job = { + attemptedAt, + finalizedAt: finalized ? finalizedAt : null, + id, + kind: "kind", + queue: "queue", + scheduledAt, + state, + } as unknown as JobRow; + // Like Go, `time` orders every listed job by the first listed + // state's field, whatever the job's own state. + const first = states[0]; + const timeField = + first === "running" + ? "attemptedAt" + : first !== undefined && finalizedStates.includes(first) + ? "finalizedAt" + : "scheduledAt"; + const onlyFinalized = + states.length > 0 && + states.every((listed) => finalizedStates.includes(listed)); + for (const orderBy of [ + "finalizedAt", + "id", + "scheduledAt", + "time", + ] as const) { + const field = orderBy === "time" ? timeField : orderBy; + const params = { sortField: orderBy, states }; + if (field === "id") { + expect(decode(encodeJobListCursor(job, params), orderBy)).toEqual( + { + id, + kind: "kind", + queue: "queue", + sortField: "id", + time: null, + } + ); + continue; + } + const time = job[field]; + const nullable = + field === "attemptedAt" || + (field === "finalizedAt" && !onlyFinalized); + if (time === null && !nullable) { + expect(() => encodeJobListCursor(job, params)).toThrow( + ValidationError + ); + continue; + } + const cursor = encodeJobListCursor(job, params); + if (orderBy === "finalizedAt" && !onlyFinalized) continue; + expect(decode(cursor, orderBy)).toEqual({ + id, + kind: "kind", + queue: "queue", + sortField: orderBy, + time, + }); + } + } + ), + { numRuns: 300 } + ); + }); + + it("rejects arbitrary input only with a cursor validation error", () => { + const payloadArbitrary = fc.oneof( + fc.string(), + fc.string({ unit: "binary" }), + fc.json(), + fc + .record( + { + id: fc.oneof( + fc.bigInt().map((value) => rawJson(value.toString())), + fc.bigInt().map(String), + fc.string(), + fc.integer(), + fc.double(), + fc.constant(null) + ), + kind: fc.oneof(fc.string(), fc.integer()), + queue: fc.oneof(fc.string(), fc.constant(null)), + sort_field: fc.oneof( + fc.constantFrom("finalized_at", "id", "scheduled_at", "time"), + fc.string() + ), + time: fc.oneof( + fc.constant(null), + instantArbitrary.map(String), + fc.constant("0001-01-01T00:00:00Z"), + fc.string() + ), + unknown: fc.json(), + }, + { requiredKeys: [] } + ) + .map((value) => JSON.stringify(value)) + ); + const cursorArbitrary = fc.oneof( + fc.string({ maxLength: 40 }), + payloadArbitrary.map((payload) => + Buffer.from(payload).toString("base64url") + ), + payloadArbitrary.map((payload) => Buffer.from(payload).toString("base64")) + ); + fc.assert( + fc.property(cursorArbitrary, (cursor) => { + const result = decodeOrReject(cursor); + if (result instanceof ValidationError) return; + // Anything accepted is a well-formed cursor value. + expect( + decode(encodeJobListCursorValue(result), result.sortField) + ).toEqual(result); + }), + { numRuns: 1_000 } + ); + }); + + it("accepts an edited cursor only when it still encodes a valid value", () => { + fc.assert( + fc.property( + cursorValueArbitrary, + fc.nat(), + fc.string({ maxLength: 2 }), + (value, position, insertion) => { + const cursor = encodeJobListCursorValue(value); + const at = position % (cursor.length + 1); + const edited = `${cursor.slice(0, at)}${insertion}${cursor.slice(at + 1)}`; + const result = decodeOrReject(edited); + if (result instanceof ValidationError) return; + // An accepted edit is at most a different spelling of a valid + // cursor (for example an escaped character), never a lossy one. + expect(() => + new TextDecoder("utf-8", { fatal: true }).decode( + Buffer.from(edited, "base64url") + ) + ).not.toThrow(); + expect( + decode(encodeJobListCursorValue(result), result.sortField) + ).toEqual(result); + } + ), + { numRuns: 500 } + ); + }); + + it("round-trips queue cursors and rejects other payloads", () => { + fc.assert( + fc.property(nameArbitrary, fc.string(), (name, garbage) => { + const cursor = encodeQueueCursor({ name } as never); + expect(normalizeQueueListOptions({ after: cursor }).nameAfter).toBe( + name + ); + try { + const decoded = normalizeQueueListOptions({ after: garbage }); + expect(encodeQueueCursor({ name: decoded.nameAfter } as never)).toBe( + garbage + ); + } catch (error: unknown) { + expect(error).toBeInstanceOf(ValidationError); + } + }), + { numRuns: 300 } + ); + }); +}); diff --git a/js/src/query.test.ts b/js/src/query.test.ts new file mode 100644 index 000000000..e69476f01 --- /dev/null +++ b/js/src/query.test.ts @@ -0,0 +1,532 @@ +import { Buffer } from "node:buffer"; + +import { describe, expect, it } from "vitest"; + +import type { JobListOrderBy, JobListParams } from "./driver.js"; +import { ValidationError } from "./errors.js"; +import { JOB_STATE, type JobState } from "./job.js"; +import { + decodeJobListCursor, + encodeJobListCursor, + encodeQueueCursor, + jobListKeyset, + jobListKeysetSql, + normalizeJobDeleteManyOptions, + normalizeJobListOptions, + normalizeQueueListOptions, + normalizeJobUpdateOptions, +} from "./query.js"; + +const ALL_STATES = Object.values(JOB_STATE); + +describe("query normalization", () => { + it("normalizes metadata containment and output independently", () => { + expect( + normalizeJobListOptions({ metadata: { tenant: "acme" } }) + ).toMatchObject({ + metadata: { tenant: "acme" }, + }); + expect( + normalizeJobUpdateOptions({ + metadata: { preserved: true }, + output: null, + }) + ).toEqual({ metadata: { preserved: true }, output: null }); + }); + + it("accepts only metadata and output in job updates, like Go and Rust", () => { + expect(normalizeJobUpdateOptions({})).toEqual({}); + for (const key of [ + "maxAttempts", + "priority", + "queue", + "scheduledAt", + "state", + "tags", + ]) { + expect(() => normalizeJobUpdateOptions({ [key]: 1 })).toThrow( + new ValidationError( + `job update ${key} is not an option; expected one of metadata, output` + ) + ); + } + }); + + it("requires explicit bounded authorization for bulk deletion", () => { + expect(() => normalizeJobDeleteManyOptions({})).toThrow( + "requires a filter or all: true" + ); + expect(() => + normalizeJobDeleteManyOptions({ all: true, ids: [1n] }) + ).toThrow("cannot be combined"); + expect(normalizeJobDeleteManyOptions({ ids: [1n], limit: 10 })).toEqual({ + all: false, + ids: [1n], + kinds: [], + limit: 10, + priorities: [], + queues: [], + states: [], + }); + }); + + // Generated by River for Go's `JobListCursor.MarshalText` for jobs with + // the same fields, in UTC as Go's drivers return times, listed in + // `states` (every state when absent). + const goCursors: readonly { + readonly cursor: string; + readonly job: Record; + readonly name: string; + readonly orderBy: JobListOrderBy; + readonly states?: readonly JobState[]; + readonly time: string | null; + }[] = [ + { + cursor: + "eyJpZCI6MSwia2luZCI6ImNvbmZvcm1hbmNlX2VjaG8iLCJxdWV1ZSI6ImRlZmF1bHQiLCJzb3J0X2ZpZWxkIjoiaWQiLCJ0aW1lIjoiMDAwMS0wMS0wMVQwMDowMDowMFoifQ==", + job: { id: 1n, kind: "conformance_echo", queue: "default" }, + name: "an ID cursor", + orderBy: "id", + time: null, + }, + { + cursor: + "eyJpZCI6OTIyMzM3MjAzNjg1NDc3NTgwNywia2luZCI6ImFcdTAwM2NiXHUwMDNlXHUwMDI2Y1x1MjAyOFx1MjAyOVx1MDAwMVx0XCJcXCIsInF1ZXVlIjoiccOp8J-YgCIsInNvcnRfZmllbGQiOiJpZCIsInRpbWUiOiIwMDAxLTAxLTAxVDAwOjAwOjAwWiJ9", + job: { + id: 9_223_372_036_854_775_807n, + kind: 'a&c\u2028\u2029\u0001\t"\\', + queue: "q\u00e9\u{1f600}", + }, + name: "Go's string escaping and the largest ID", + orderBy: "id", + time: null, + }, + { + cursor: + "eyJpZCI6NDIsImtpbmQiOiJlbWFpbCIsInF1ZXVlIjoiZGVmYXVsdCIsInNvcnRfZmllbGQiOiJzY2hlZHVsZWRfYXQiLCJ0aW1lIjoiMjAyNi0wMS0wMlQwMzowNDowNS4xMjM0NTY3ODlaIn0=", + job: { + id: 42n, + kind: "email", + queue: "default", + scheduledAt: "2026-01-02T03:04:05.123456789Z", + }, + name: "a nanosecond scheduled time", + orderBy: "scheduledAt", + time: "2026-01-02T03:04:05.123456789Z", + }, + { + cursor: + "eyJpZCI6OTAwNzE5OTI1NDc0MDk5Mywia2luZCI6ImVtYWlsIiwicXVldWUiOiJjcml0aWNhbCIsInNvcnRfZmllbGQiOiJzY2hlZHVsZWRfYXQiLCJ0aW1lIjoiMjA5OS0xMi0zMVQyMzo1OTo1OS4xMloifQ==", + job: { + id: 9_007_199_254_740_993n, + kind: "email", + queue: "critical", + scheduledAt: "2099-12-31T23:59:59.120Z", + }, + name: "trailing fractional zeros", + orderBy: "scheduledAt", + time: "2099-12-31T23:59:59.12Z", + }, + { + cursor: + "eyJpZCI6Nywia2luZCI6InJlcG9ydD94PTEiLCJxdWV1ZSI6ImRlZmF1bHQiLCJzb3J0X2ZpZWxkIjoiZmluYWxpemVkX2F0IiwidGltZSI6IjIwMjYtMDEtMDJUMDM6MDQ6MDYuNVoifQ==", + job: { + finalizedAt: "2026-01-02T03:04:06.5Z", + id: 7n, + kind: "report?x=1", + queue: "default", + state: "completed", + }, + name: "a finalized time", + orderBy: "finalizedAt", + states: ["completed"], + time: "2026-01-02T03:04:06.5Z", + }, + { + cursor: + "eyJpZCI6OCwia2luZCI6InN5bmN-IiwicXVldWUiOiJkZWZhdWx0Iiwic29ydF9maWVsZCI6InRpbWUiLCJ0aW1lIjoiMjAyNi0wMS0wMlQwMzowNDowNy4wMDAwMDFaIn0=", + job: { + attemptedAt: "2026-01-02T03:04:07.000001Z", + id: 8n, + kind: "sync~", + queue: "default", + state: "running", + }, + name: "a running job's attempt time", + orderBy: "time", + states: ["running"], + time: "2026-01-02T03:04:07.000001Z", + }, + { + cursor: + "eyJpZCI6OSwia2luZCI6InN5bmMiLCJxdWV1ZSI6ImRlZmF1bHQiLCJzb3J0X2ZpZWxkIjoidGltZSIsInRpbWUiOiIwMDAxLTAxLTAxVDAwOjAwOjAwWiJ9", + job: { + attemptedAt: null, + createdAt: "2026-01-01T00:00:00.25Z", + id: 9n, + kind: "sync", + queue: "default", + state: "running", + }, + name: "a running job without an attempt time", + orderBy: "time", + states: ["running"], + time: null, + }, + { + cursor: + "eyJpZCI6MTAsImtpbmQiOiJzeW5jIiwicXVldWUiOiJkZWZhdWx0Iiwic29ydF9maWVsZCI6InRpbWUiLCJ0aW1lIjoiMDAwMS0wMS0wMVQwMDowMDowMFoifQ==", + job: { + createdAt: "2026-01-01T00:00:01Z", + finalizedAt: null, + id: 10n, + kind: "sync", + queue: "default", + state: "completed", + }, + name: "a completed job without a finalized time among unfinalized states", + orderBy: "time", + states: ["completed", "running"], + time: null, + }, + { + cursor: + "eyJpZCI6MTIsImtpbmQiOiJzeW5jIiwicXVldWUiOiJkZWZhdWx0Iiwic29ydF9maWVsZCI6InRpbWUiLCJ0aW1lIjoiMjAyNi0wMS0wMlQwMzowNDowNS41WiJ9", + job: { + attemptedAt: "2026-01-02T03:04:05.5Z", + finalizedAt: "2026-01-02T03:04:09Z", + id: 12n, + kind: "sync", + queue: "default", + state: "completed", + }, + name: "a completed job listed by the first state's field", + orderBy: "time", + states: ["running", "completed"], + time: "2026-01-02T03:04:05.5Z", + }, + { + cursor: + "eyJpZCI6MTMsImtpbmQiOiJzeW5jIiwicXVldWUiOiJkZWZhdWx0Iiwic29ydF9maWVsZCI6InRpbWUiLCJ0aW1lIjoiMDAwMS0wMS0wMVQwMDowMDowMFoifQ==", + job: { + finalizedAt: null, + id: 13n, + kind: "sync", + queue: "default", + scheduledAt: "2026-01-02T03:04:05Z", + state: "available", + }, + name: "an unfinalized job listed by finalized time", + orderBy: "time", + states: ["completed", "available"], + time: null, + }, + { + cursor: + "eyJpZCI6MTQsImtpbmQiOiJzeW5jIiwicXVldWUiOiJkZWZhdWx0Iiwic29ydF9maWVsZCI6InRpbWUiLCJ0aW1lIjoiMjAyNi0wMS0wMlQwMzowNDowNVoifQ==", + job: { + id: 14n, + kind: "sync", + queue: "default", + scheduledAt: "2026-01-02T03:04:05Z", + state: "running", + }, + name: "time ordering without a state filter", + orderBy: "time", + states: [], + time: "2026-01-02T03:04:05Z", + }, + { + cursor: + "eyJpZCI6MTEsImtpbmQiOiJzeW5jIiwicXVldWUiOiJkZWZhdWx0Iiwic29ydF9maWVsZCI6InRpbWUiLCJ0aW1lIjoiMDAwMS0wMS0wMVQwMDowMDowMC4wMDAwMDAwMDFaIn0=", + job: { + id: 11n, + kind: "sync", + queue: "default", + scheduledAt: "0001-01-01T00:00:00.000000001Z", + state: "available", + }, + name: "a time just after Go's zero time", + orderBy: "time", + time: "0001-01-01T00:00:00.000000001Z", + }, + ]; + + it.each(goCursors.map((golden) => [golden.name, golden] as const))( + "encodes job list cursors byte for byte like Go: %s", + (_name, { cursor, job, orderBy, states }) => { + const row = Object.fromEntries( + Object.entries(job).map(([key, value]) => [ + key, + key.endsWith("At") && typeof value === "string" + ? Temporal.Instant.from(value) + : value, + ]) + ); + expect( + encodeJobListCursor(row as never, { + sortField: orderBy, + states: states ?? ALL_STATES, + }) + ).toBe(cursor); + } + ); + + it.each(goCursors.map((golden) => [golden.name, golden] as const))( + "decodes Go cursors without losing signed 64-bit IDs: %s", + (_name, { cursor, job, orderBy, states, time }) => { + const expected = { + id: job.id, + kind: job.kind, + queue: job.queue, + sortField: orderBy, + time: time === null ? null : Temporal.Instant.from(time), + }; + expect(decodeJobListCursor(cursor)).toEqual(expected); + const { after } = normalizeJobListOptions({ + after: cursor, + orderBy, + ...(states === undefined ? {} : { states }), + }); + expect(after).toEqual(expected); + } + ); + + it("decodes cursors in either base64 alphabet, with or without padding", () => { + // Go's cursor for this job contains `-`, so its alphabets differ. + const cursor = goCursors.find( + ({ name }) => name === "a running job's attempt time" + )!.cursor; + expect(cursor).toMatch(/-/); + const expected = decodeJobListCursor(cursor); + const standard = cursor.replaceAll("-", "+").replaceAll("_", "/"); + for (const spelling of [ + standard, + cursor.replace(/=+$/, ""), + standard.replace(/=+$/, ""), + ]) { + expect(decodeJobListCursor(spelling)).toEqual(expected); + } + }); + + it("decodes Go's zero time as no time and ignores unknown fields", () => { + const encode = (payload: string) => + Buffer.from(payload, "utf8").toString("base64"); + expect( + decodeJobListCursor( + encode( + '{"id":5,"kind":"k","queue":"q","sort_field":"scheduled_at",' + + '"time":"0001-01-01T00:00:00Z","extra":true}' + ) + ) + ).toEqual({ + id: 5n, + kind: "k", + queue: "q", + sortField: "scheduledAt", + time: null, + }); + // ID ordering ignores the time, and offsets are normalized. + expect( + decodeJobListCursor( + encode( + '{"id":-1,"kind":"","queue":"","sort_field":"id",' + + '"time":"2026-01-02T04:04:05+01:00"}' + ) + ) + ).toEqual({ id: -1n, kind: "", queue: "", sortField: "id", time: null }); + expect( + decodeJobListCursor( + encode( + '{"id":5,"kind":"k","queue":"q","sort_field":"time",' + + '"time":"2026-01-02T04:04:05+01:00"}' + ) + ).time + ).toEqual(Temporal.Instant.from("2026-01-02T03:04:05Z")); + }); + + it("rejects job times Go can't encode in a cursor", () => { + expect(() => + encodeJobListCursor( + { + id: 1n, + kind: "k", + queue: "q", + scheduledAt: Temporal.Instant.from("+010000-01-01T00:00:00Z"), + } as never, + { sortField: "scheduledAt", states: ALL_STATES } + ) + ).toThrow(ValidationError); + }); + + const goPayload = (fields: string) => + Buffer.from(`{${fields}}`, "utf8").toString("base64url"); + it.each([ + ["text that isn't base64", "not base64!"], + ["mixed base64 alphabets", "eyJp-+"], + ["misplaced padding", "eyJ=pZCI6"], + [ + "River for JavaScript's earlier cursor envelope", + Buffer.from( + '{"id":"1","kind":"x","queue":"q","sortField":"id","time":null,"v":1}' + ).toString("base64url"), + ], + [ + "a string ID", + goPayload( + '"id":"1","kind":"x","queue":"q","sort_field":"id","time":"0001-01-01T00:00:00Z"' + ), + ], + [ + "a fractional ID", + goPayload( + '"id":1.5,"kind":"x","queue":"q","sort_field":"id","time":"0001-01-01T00:00:00Z"' + ), + ], + [ + "an ID beyond int64", + goPayload( + '"id":9223372036854775808,"kind":"x","queue":"q","sort_field":"id","time":"0001-01-01T00:00:00Z"' + ), + ], + [ + "an unknown sort field", + goPayload( + '"id":1,"kind":"x","queue":"q","sort_field":"created_at","time":"0001-01-01T00:00:00Z"' + ), + ], + [ + "a missing time", + goPayload('"id":1,"kind":"x","queue":"q","sort_field":"id"'), + ], + [ + "a year Go can't parse", + goPayload( + '"id":1,"kind":"x","queue":"q","sort_field":"time","time":"+010000-01-01T00:00:00Z"' + ), + ], + [ + "invalid UTF-8", + Buffer.concat([ + Buffer.from('{"id":1,"kind":"'), + Buffer.from([0xf0, 0x90, 0x30, 0x80]), + Buffer.from( + '","queue":"q","sort_field":"id","time":"0001-01-01T00:00:00Z"}' + ), + ]).toString("base64url"), + ], + ["a queue cursor", encodeQueueCursor({ name: "default" } as never)], + ])("rejects a job list cursor with %s", (_name, after) => { + expect(() => normalizeJobListOptions({ after })).toThrow( + /invalid .*cursor/ + ); + }); + + it("round-trips strict queue cursors", () => { + const cursor = encodeQueueCursor({ name: "default" } as never); + expect(normalizeQueueListOptions({ after: cursor })).toMatchObject({ + nameAfter: "default", + }); + }); + + it("renders each job list keyset as SQL like Go", () => { + const time = Temporal.Instant.from("2026-01-02T03:04:05Z"); + const render = ( + params: Pick< + JobListParams, + "after" | "sortDirection" | "sortField" | "states" + > + ) => { + const bound: unknown[] = []; + const sql = jobListKeysetSql(jobListKeyset(params), (value) => { + bound.push(value); + return `$${bound.length}`; + }); + return { ...sql, bound }; + }; + const cursor = (value: Temporal.Instant | null) => ({ + id: 7n, + kind: "k", + queue: "q", + sortField: "time" as const, + time: value, + }); + + expect( + render({ + after: null, + sortDirection: "asc", + sortField: "id", + states: [], + }) + ).toEqual({ after: null, bound: [], orderBy: "id ASC" }); + // Nulls sort last ascending and first descending when the field may be + // null for a listed state. + expect( + render({ + after: cursor(time), + sortDirection: "asc", + sortField: "time", + states: ["completed", "available"], + }) + ).toEqual({ + after: + "(finalized_at > $1 OR (finalized_at = $2 AND id > $3) OR finalized_at IS NULL)", + bound: [time, time, 7n], + orderBy: "finalized_at ASC NULLS LAST, id ASC", + }); + expect( + render({ + after: cursor(time), + sortDirection: "desc", + sortField: "time", + states: ["running"], + }) + ).toEqual({ + after: "(attempted_at < $1 OR (attempted_at = $2 AND id < $3))", + bound: [time, time, 7n], + orderBy: "attempted_at DESC NULLS FIRST, id DESC", + }); + // A cursor without a time resumes within the nulls, or by ID alone + // when the field can't be null. + expect( + render({ + after: cursor(null), + sortDirection: "asc", + sortField: "time", + states: ["running"], + }) + ).toMatchObject({ after: "(attempted_at IS NULL AND id > $1)" }); + expect( + render({ + after: cursor(null), + sortDirection: "desc", + sortField: "time", + states: ["running"], + }) + ).toMatchObject({ after: "(attempted_at IS NOT NULL OR id < $1)" }); + expect( + render({ + after: cursor(null), + sortDirection: "asc", + sortField: "time", + states: [], + }) + ).toEqual({ + after: "id > $1", + bound: [7n], + orderBy: "scheduled_at ASC, id ASC", + }); + expect( + render({ + after: { ...cursor(time), sortField: "finalizedAt" }, + sortDirection: "asc", + sortField: "finalizedAt", + states: ["completed"], + }) + ).toMatchObject({ + after: "(finalized_at > $1 OR (finalized_at = $2 AND id > $3))", + orderBy: "finalized_at ASC, id ASC", + }); + }); +}); From 1172ae33e40b78130b61509675f8144a1ec2c763 Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sun, 4 Oct 2026 17:17:30 -0500 Subject: [PATCH 23/43] test the worker runtime against a fake driver Exercise the runtime end to end: claims and fetch cooldowns, work outcomes and retry delays, payload decoding before work, cooperative cancellation and remote cancellation after a timeout, stuck-job handling, hooks and event delivery off the completion path, completion timeouts and persistence failures, notification stream recovery, `fetchOnlyKnownKinds`, clients without leader election, graceful and forced shutdown, and unknown-option rejection. A stress suite races claims, cancellations, and stops, and a fault suite injects database failures. Cover `Workers` registration (definitions, handler factories, kind aliases), outcome values, resumable steps, and snooze counting, which matches River Go's executor on River's shared fixture cases. --- js/src/resumable.test.ts | 169 + js/src/runtime.test.ts | 4781 +++++++++++++++++++++ js/src/runtime/completion-command.test.ts | 83 + js/src/testdata/snooze-counters.json | 117 + js/src/worker.test.ts | 223 + 5 files changed, 5373 insertions(+) create mode 100644 js/src/resumable.test.ts create mode 100644 js/src/runtime.test.ts create mode 100644 js/src/runtime/completion-command.test.ts create mode 100644 js/src/testdata/snooze-counters.json create mode 100644 js/src/worker.test.ts diff --git a/js/src/resumable.test.ts b/js/src/resumable.test.ts new file mode 100644 index 000000000..e8ad51c62 --- /dev/null +++ b/js/src/resumable.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { Client } from "./client.js"; +import type { JobRow } from "./job.js"; +import type { JsonObject } from "./json.js"; +import { Resumable } from "./resumable.js"; + +describe("Resumable", () => { + it.each([{}, { "river:resumable_step": "later" }])( + "rejects duplicate names even among skipped steps: %j", + async (metadata) => { + const resumable = new Resumable(client(), job(metadata)); + const callback = vi.fn(); + await resumable.step("first", callback); + await expect(resumable.stepWithCursor("first", callback)).rejects.toThrow( + 'duplicate resumable step name "first"' + ); + expect(callback).toHaveBeenCalledTimes( + metadata["river:resumable_step"] === undefined ? 1 : 0 + ); + expect(resumable.finish(false).error).not.toBeNull(); + } + ); + + it("treats an empty persisted step as no checkpoint", async () => { + const resumable = new Resumable( + client(), + job({ "river:resumable_step": "" }) + ); + const callback = vi.fn(); + await resumable.step("first", callback); + expect(callback).toHaveBeenCalledOnce(); + expect(resumable.finish(false).error).toBeNull(); + }); + + it("restores the enclosing step after a nested step", async () => { + const resumable = new Resumable(client(), job({})); + await resumable + .stepWithCursor("outer", async () => { + await resumable.step("inner", () => undefined); + resumable.setCursor({ offset: 7 }); + throw new Error("retry outer"); + }) + .catch(() => undefined); + expect(resumable.finish(false).metadata).toEqual({ + "river:resumable_step": "inner", + "river:resumable_cursor": { outer: { offset: 7 } }, + }); + }); + + it("retains progress and the original cause when a step error is caught", async () => { + const resumable = new Resumable(client(), job({})); + const cause = new Error("service unavailable"); + await resumable.step("first", () => undefined); + await resumable + .step("second", () => { + throw cause; + }) + .catch(() => undefined); + const finished = resumable.finish(false); + expect(finished.error?.cause).toBe(cause); + expect(finished.metadata).toEqual({ "river:resumable_step": "first" }); + }); + + it("rejects the malformed cursor arrays rejected by Go", () => { + expect( + () => new Resumable(client(), job({ "river:resumable_cursor": [1, 2] })) + ).toThrow("river:resumable_cursor must be an object"); + }); + + it("fails a successful worker that never declares its resume step", () => { + const resumable = new Resumable( + client(), + job({ "river:resumable_step": "missing" }) + ); + + expect(resumable.finish(false).error?.message).toContain( + 'resumable step "missing" not found in worker' + ); + }); + + it("resumes cursor steps and clears consumed cursor metadata", async () => { + const resumable = new Resumable( + client(), + job({ + "river:resumable_cursor": { process: { offset: 2 } }, + "river:resumable_step": "process", + }) + ); + const visited: string[] = []; + + await resumable.step("before", () => { + visited.push("before"); + }); + await resumable.stepWithCursor("process", (cursor) => { + expect(cursor).toEqual({ offset: 2 }); + visited.push("process"); + }); + await expect( + resumable.step("after", () => { + throw new Error("retry"); + }) + ).rejects.toThrow('resumable step "after" failed'); + + expect(visited).toEqual(["process"]); + expect(resumable.finish(true).metadata).toEqual({ + "river:resumable_cursor": null, + "river:resumable_step": "process", + }); + }); + + it("persists an explicit checkpoint through the caller transaction", async () => { + const update = vi.fn().mockResolvedValue(job({})); + const resumable = new Resumable(client(update), job({})); + const tx = { id: "transaction" }; + + await resumable + .stepWithCursor("process", async () => { + await resumable.checkpoint({ cursor: { offset: 3 }, tx }); + throw new Error("retry after checkpoint"); + }) + .catch(() => undefined); + + expect(update).toHaveBeenCalledWith( + 1n, + { + metadata: { + "river:resumable_cursor": { process: { offset: 3 } }, + "river:resumable_step": "process", + }, + }, + { tx } + ); + expect(resumable.finish(true).metadata).toEqual({ + "river:resumable_cursor": { process: { offset: 3 } }, + "river:resumable_step": "process", + }); + }); +}); + +function client( + update: (...args: readonly unknown[]) => unknown = () => job({}) +): Client { + return { jobs: { update } } as unknown as Client; +} + +function job(metadata: JsonObject): JobRow { + const now = Temporal.Instant.from("2026-09-01T12:00:00Z"); + return { + args: {}, + attempt: 1, + attemptedAt: now, + attemptedBy: ["test"], + createdAt: now, + errors: [], + finalizedAt: null, + id: 1n, + kind: "test", + maxAttempts: 25, + metadata, + priority: 1, + queue: "default", + scheduledAt: now, + state: "running", + tags: [], + uniqueKey: null, + uniqueStates: [], + }; +} diff --git a/js/src/runtime.test.ts b/js/src/runtime.test.ts new file mode 100644 index 000000000..24340a2d4 --- /dev/null +++ b/js/src/runtime.test.ts @@ -0,0 +1,4781 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod"; + +import { Client } from "./client.js"; +import type { + DriverInsertResult, + JobCompletionCommand, + JobCompletionResult, + JobDeleteResult, + JobClaimParams, + JobClaimResult, + JobInsertParams, + JobListParams, + QueueRow, + RuntimeDriver, + RuntimeJobRescue, + RuntimeLeader, + RuntimeNotification, +} from "./driver.js"; +import { jobCompletionKey } from "./driver.js"; +import { + ConfigurationError, + DatabaseOperationError, + JobAttemptFinishedError, + JobStuckError, + JobTimeoutError, + ValidationError, +} from "./errors.js"; +import { registerDriver } from "./internal/driver-registry.js"; +import { defineJob } from "./job-definition.js"; +import type { RiverEvent } from "./events.js"; +import type { WorkMiddleware } from "./extensions.js"; +import type { JobRow } from "./job.js"; +import { createJobArgsTransformPlugin } from "./job-args-transform.js"; +import type { JsonObject } from "./json.js"; +import type { OperationTimeout, RuntimeTimer } from "./internal/backoff.js"; +import type { RiverMetric } from "./metrics.js"; +import type { MaintenanceOptions } from "./options.js"; +import { periodicJob } from "./periodic.js"; +import { + currentWorkContext, + defaultNextRetry, + overrideRuntimeTiming, + recordOutput, + setMetadata, +} from "./runtime.js"; +import type { ClientOptions } from "./options.js"; +import type { QueueRuntimeDiagnostics } from "./runtime.js"; +import { cancel, snooze, Workers } from "./worker.js"; +import type { WorkExecutor } from "./worker.js"; + +class FakeRuntimeDriver implements RuntimeDriver { + declare readonly "~river"?: { + readonly capability: "runtime"; + readonly transaction: unknown; + }; + + constructor() { + registerDriver(this, { + backend: "fake", + capability: "runtime", + operations: this, + }); + } + + readonly completions: JobCompletionCommand[] = []; + readonly completionBatches: JobCompletionCommand[][] = []; + completionCalls = 0; + completionError: Error | undefined; + completionGate: Promise | undefined; + completionOverride: + ((command: JobCompletionCommand) => JobCompletionResult) | undefined; + completionFailures = 0; + readonly completionSignals: (AbortSignal | undefined)[] = []; + claim: JobRow[] = []; + claimFailures = 0; + /** Errors thrown by successive claims before the claim queue is consulted. */ + readonly claimFaults: unknown[] = []; + /** Replaces the default never-yielding notification stream. */ + notificationStream: + | (( + topics: readonly RuntimeNotification["topic"][], + signal: AbortSignal, + ready: () => void + ) => AsyncIterable) + | undefined; + queueGetCalls = 0; + /** Errors thrown by successive queue reads. */ + readonly queueGetFaults: unknown[] = []; + readonly claimed: JobRow[] = []; + readonly claimRequests: number[] = []; + readonly claimQueues: string[] = []; + readonly claimStartedAtMs: number[] = []; + cancelled: JobRow | null = null; + deleteResult: JobDeleteResult = { status: "not_found" }; + lastClaim: JobClaimParams | undefined; + lastList: JobListParams | undefined; + listRows: JobRow[] = []; + readonly queues = new Map(); + /** Decode errors the fake reports for claimed jobs, by ID. */ + readonly decodeErrors = new Map(); + notificationSubscriptions = 0; + notificationReadyGate: Promise | undefined; + leadershipResignRequests = 0; + leadershipResignTransaction: unknown; + + jobCancel(): JobRow | null { + return this.cancelled; + } + + /** Holds the next claim's result until it resolves, once. */ + claimGate: Promise | undefined; + + jobClaim(params: JobClaimParams): JobClaimResult | Promise { + const result = this.#claim(params); + const gate = this.claimGate; + if (gate === undefined || result.jobs.length === 0) return result; + this.claimGate = undefined; + return gate.then(() => result); + } + + #claim(params: JobClaimParams): JobClaimResult { + this.claimQueues.push(params.queues[0]?.name ?? ""); + this.claimStartedAtMs.push(performance.now()); + this.lastClaim = params; + if (this.claimFaults.length > 0) throw this.claimFaults.shift(); + if (this.claimFailures > 0) { + this.claimFailures -= 1; + throw new DatabaseOperationError("temporary claim failure", { + backend: "fake", + operation: "jobClaim", + retryable: true, + }); + } + const limit = params.queues.reduce((sum, queue) => sum + queue.limit, 0); + this.claimRequests.push(limit); + const rows = this.claim.splice(0, limit); + this.claimed.push(...rows); + const decodeErrors = new Map( + [...this.decodeErrors].filter(([id]) => rows.some((row) => row.id === id)) + ); + return { decodeErrors, jobs: rows }; + } + + async jobCompleteMany( + commands: readonly JobCompletionCommand[], + options?: { readonly signal?: AbortSignal } + ): Promise { + this.completionCalls += 1; + this.completionSignals.push(options?.signal); + if (this.completionFailures > 0) { + this.completionFailures -= 1; + throw new DatabaseOperationError("temporary completion failure", { + backend: "fake", + operation: "jobCompleteMany", + retryable: true, + }); + } + await this.completionGate; + if (this.completionError !== undefined) throw this.completionError; + this.completionBatches.push([...commands]); + this.completions.push(...commands); + return commands.map( + (command) => + this.completionOverride?.(command) ?? { + job: applyCompletion( + this.claimed.findLast(({ id }) => id === command.id) ?? + fakeJob("test", command.id), + command + ), + key: jobCompletionKey(command), + status: "applied", + } + ); + } + + jobDelete(): JobDeleteResult { + return this.deleteResult; + } + + jobDeleteMany(): readonly JobRow[] { + return []; + } + + jobGet(id: bigint): JobRow | null { + return this.listRows.find((job) => job.id === id) ?? null; + } + + jobInsert(params: JobInsertParams): DriverInsertResult { + return { job: fakeJob(params.kind), status: "inserted" }; + } + + jobInsertMany(params: readonly JobInsertParams[]): DriverInsertResult[] { + return params.map((item) => this.jobInsert(item)); + } + + jobList(params: JobListParams): readonly JobRow[] { + this.lastList = params; + return this.listRows; + } + + jobRetry(): JobRow | null { + return null; + } + + jobUpdate(): JobRow | null { + return null; + } + + queueGet(name: string): QueueRow | null { + this.queueGetCalls += 1; + if (this.queueGetFaults.length > 0) throw this.queueGetFaults.shift(); + return this.queues.get(name) ?? null; + } + + queueList(): readonly QueueRow[] { + return []; + } + + queuePause(_name: string): QueueRow | null { + void _name; + return null; + } + + queueResume(_name: string): QueueRow | null { + void _name; + return null; + } + + queueUpdate(): QueueRow | null { + return null; + } + + runtimeQueueUpsert(name: string, now: Temporal.Instant): QueueRow { + const existing = this.queues.get(name); + if (existing !== undefined) { + const refreshed = { ...existing, updatedAt: now }; + this.queues.set(name, refreshed); + return refreshed; + } + const queue = { + createdAt: now, + metadata: {}, + name, + pausedAt: null, + updatedAt: now, + }; + this.queues.set(name, queue); + return queue; + } + + runtimeRequestLeadershipResignation(options?: { tx?: unknown }): void { + this.leadershipResignRequests += 1; + this.leadershipResignTransaction = options?.tx; + } + + async *runtimeNotificationSubscribe( + topics: readonly ("control" | "insert" | "leadership")[], + signal: AbortSignal, + ready: () => void + ): AsyncGenerator { + if (this.notificationStream !== undefined) { + this.notificationSubscriptions++; + yield* this.notificationStream(topics, signal, ready); + return; + } + this.notificationSubscriptions++; + await this.notificationReadyGate; + ready(); + await new Promise((resolve) => + signal.addEventListener("abort", () => resolve(), { once: true }) + ); + if (!signal.aborted) { + yield { payload: "{}", topic: "insert" as const }; + } + } +} + +/** + * Deterministic runtime timer. Backoff delays resolve on the next macrotask + * instead of after wall-clock time, and operation timeouts fire only when a + * test calls `expireTimeouts`. + */ +class FakeTimer implements RuntimeTimer { + readonly delays: number[] = []; + readonly #timeouts = new Set<{ + controller: AbortController; + reason: () => unknown; + }>(); + + get activeTimeouts(): number { + return this.#timeouts.size; + } + + delay(milliseconds: number, signal: AbortSignal): Promise { + this.delays.push(milliseconds); + if (signal.aborted) return Promise.reject(signal.reason); + return new Promise((resolve, reject) => { + const onAbort = () => { + clearImmediate(immediate); + reject(signal.reason); + }; + const immediate = setImmediate(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }); + signal.addEventListener("abort", onAbort, { once: true }); + }); + } + + now(): number { + return performance.now(); + } + + expireTimeouts(): void { + for (const timeout of [...this.#timeouts]) { + this.#timeouts.delete(timeout); + timeout.controller.abort(timeout.reason()); + } + } + + timeout(_milliseconds: number, reason: () => unknown): OperationTimeout { + const entry = { controller: new AbortController(), reason }; + this.#timeouts.add(entry); + return { + dispose: () => this.#timeouts.delete(entry), + signal: entry.controller.signal, + }; + } +} + +interface LogEntry { + readonly attributes: Readonly> | undefined; + readonly level: "debug" | "error" | "info" | "warn"; + readonly message: string; +} + +function durationsInMilliseconds( + diagnostics: QueueRuntimeDiagnostics | undefined +): Record | undefined { + return ( + diagnostics && { + ...diagnostics, + fetchCooldown: diagnostics.fetchCooldown.total("milliseconds"), + pollInterval: diagnostics.pollInterval.total("milliseconds"), + } + ); +} + +function recordingLogger(entries: LogEntry[]) { + const log = + (level: LogEntry["level"]) => + (attributes: Readonly>, message: string) => { + entries.push({ attributes, level, message }); + }; + return { + debug: log("debug"), + error: log("error"), + info: log("info"), + warn: log("warn"), + }; +} + +/** + * Apply a completion the way River's backends do for the attempt that still + * owns a running row: the target state follows the command, snoozes and + * interruptions refund the attempt, errors append, and metadata merges. + */ +function applyCompletion(row: JobRow, command: JobCompletionCommand): JobRow { + const cancelRequested = row.metadata.cancel_attempted_at !== undefined; + const nonTerminal = + command.kind === "interrupt" || + command.kind === "retry" || + command.kind === "snooze"; + const state: JobRow["state"] = + nonTerminal && cancelRequested + ? "cancelled" + : command.available === true + ? "available" + : { + cancel: "cancelled" as const, + complete: "completed" as const, + discard: "discarded" as const, + interrupt: "available" as const, + retry: "retryable" as const, + snooze: "scheduled" as const, + }[command.kind]; + const refund = + (command.kind === "interrupt" || command.kind === "snooze") && + !cancelRequested; + return { + ...row, + attempt: refund ? Math.max(row.attempt - 1, 0) : row.attempt, + errors: + command.error === null + ? row.errors + : [...row.errors, { ...command.error, attempt: command.attempt }], + finalizedAt: + state === "cancelled" || state === "completed" || state === "discarded" + ? (command.finalizedAt ?? Temporal.Now.instant()) + : null, + metadata: { + ...row.metadata, + ...command.metadata, + ...(command.outputSet ? { output: command.output } : {}), + }, + scheduledAt: + nonTerminal && cancelRequested + ? row.scheduledAt + : (command.scheduledAt ?? row.scheduledAt), + state, + }; +} + +function fakeJob(kind = "test", id = 101n): JobRow { + const now = Temporal.Instant.from("2026-08-30T12:00:00.123456789Z"); + return { + args: { value: "work" }, + attempt: 1, + attemptedAt: now, + attemptedBy: ["runtime-test"], + createdAt: now, + errors: [], + finalizedAt: null, + id, + kind, + maxAttempts: 3, + metadata: {}, + priority: 1, + queue: "default", + scheduledAt: now, + state: "running", + tags: [], + uniqueKey: null, + uniqueStates: null, + }; +} + +describe("Client runtime", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("derives default retry delay from persisted error count", () => { + const now = Temporal.Instant.from("2026-08-30T12:00:00Z"); + const job = { + ...fakeJob(), + attempt: 99, + errors: [ + { at: now, attempt: 1, error: "one", trace: "" }, + { at: now, attempt: 2, error: "two", trace: "" }, + ], + maxAttempts: 100, + }; + + expect(defaultNextRetry(job, now, () => 0.5)).toEqual( + now.add({ seconds: 81 }) + ); + }); + + it("caps the default retry delay at exactly Go's maximum duration", () => { + const now = Temporal.Instant.from("2026-08-30T12:00:00Z"); + const errors = Array.from({ length: 309 }, (_, index) => ({ + at: now, + attempt: index + 1, + error: "failed", + trace: "", + })); + const job = { ...fakeJob(), attempt: 310, errors, maxAttempts: 1_000 }; + + // From the 310th error on, like Go's `secondsAsCappedDuration`. + for (const random of [0, 0.5, 0.999]) { + expect( + defaultNextRetry(job, now, () => random).epochNanoseconds - + now.epochNanoseconds + ).toBe(9_223_372_036_854_775_807n); + } + }); + + it("throws a structured error instead of reporting a running job as deleted", async () => { + const driver = new FakeRuntimeDriver(); + driver.deleteResult = { job: fakeJob(), status: "running" }; + const client = new Client(driver); + + await expect(client.jobs.delete(101n)).rejects.toMatchObject({ + code: "job_running", + jobId: 101n, + name: "JobRunningError", + }); + }); + + it("works and completes a claimed job with ordered extensions", async () => { + const driver = new FakeRuntimeDriver(); + const definition = defineJob({ + kind: "test", + schema: z.object({ value: z.string() }), + }); + const order: string[] = []; + const workers = new Workers().add(definition, ({ job }) => { + order.push(`handler:${job.args.value}`); + }); + driver.claim = [{ ...fakeJob(), args: { envelope: { value: "work" } } }]; + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + hooks: { + onEvent: (event) => { + order.push(`commit:${event.kind}`); + }, + afterWork: () => { + order.push("after"); + }, + beforeWork: (context) => { + expect(context.job.args).toEqual({ value: "work" }); + order.push("before"); + }, + }, + middleware: [ + async (context, next) => { + order.push(`middleware-before:${context.job.id}`); + const result = await next(); + order.push("middleware-after"); + return result; + }, + ], + plugins: [ + createJobArgsTransformPlugin({ + name: "envelope", + onRead: ({ args }) => args.envelope as JsonObject, + }), + ], + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }, + workers, + }); + const events = client.subscribe({ capacity: 8 }); + const run = await client.start(); + + await waitUntil(() => driver.completions.length === 1); + await run.stop({ mode: "graceful" }); + + expect(driver.completions[0]).toMatchObject({ + attempt: 1, + attemptedBy: "runtime-test", + id: 101n, + kind: "complete", + }); + expect(order).toEqual([ + "commit:job_started", + "middleware-before:101", + "before", + "handler:work", + "after", + "middleware-after", + "commit:job_completed", + ]); + expect((await events.next()).value.kind).toBe("job_started"); + expect((await events.next()).value.kind).toBe("job_completed"); + expect(run.state).toBe("stopped"); + events.close(); + }); + + it("fails malformed transformed payloads before work extensions", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = [fakeJob()]; + const definition = defineJob({ kind: "test" }); + let beforeWorkCalls = 0; + let handlerCalls = 0; + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + errorHandler: (_context, error) => { + expect(error).toMatchObject({ message: "ciphertext is malformed" }); + }, + hooks: { + beforeWork: () => { + beforeWorkCalls += 1; + }, + }, + leaderElectionDisabled: true, + plugins: [ + createJobArgsTransformPlugin({ + name: "malformed", + onRead: () => { + throw new Error("ciphertext is malformed"); + }, + }), + ], + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }, + workers: new Workers().add(definition, () => { + handlerCalls += 1; + }), + }); + const run = await client.start(); + + await waitUntil(() => driver.completions.length === 1); + await run.stop(); + + expect(beforeWorkCalls).toBe(0); + expect(handlerCalls).toBe(0); + expect(driver.completions[0]).toMatchObject({ + id: 101n, + kind: "retry", + }); + }); + + it("retries explicitly retryable claim and completion failures", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = [fakeJob()]; + driver.claimFailures = 2; + driver.completionFailures = 2; + const timer = new FakeTimer(); + const definition = defineJob({ kind: "test" }); + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + leaderElectionDisabled: true, + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }, + workers: new Workers().add(definition, () => undefined), + }); + overrideRuntimeTiming(client, { random: () => 0.5, timer }); + const run = await client.start(); + + await waitUntil(() => driver.completions.length === 1); + await run.stop(); + + expect(driver.claimFailures).toBe(0); + expect(driver.completionFailures).toBe(0); + expect(driver.completionCalls).toBe(3); + expect(driver.completions[0]?.kind).toBe("complete"); + expect(timer.delays.slice(-2)).toEqual([1_000, 2_000]); + expect(timer.activeTimeouts).toBe(0); + expect(run.state).toBe("stopped"); + }); + + it("batches completions beyond one queue's worker capacity", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = Array.from({ length: 8 }, (_, index) => + fakeJob("test", BigInt(index + 1)) + ); + const definition = defineJob({ kind: "test" }); + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 4, + completionFlushInterval: { milliseconds: 10_000 }, + leaderElectionDisabled: true, + queues: { + default: { maxWorkers: 2, pollInterval: { milliseconds: 10_000 } }, + }, + workers: new Workers().add(definition, () => undefined), + }); + const run = await client.start(); + + await waitUntil(() => driver.completions.length === 8); + await run.stop(); + + expect(driver.completionBatches.map((batch) => batch.length)).toEqual([ + 4, 4, + ]); + expect(driver.claimRequests.length).toBeLessThanOrEqual(5); + }); + + it("aborts a handler's signal once its attempt finished, like Go", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = [fakeJob("test", 7n)]; + const definition = defineJob({ kind: "test" }); + let signal: AbortSignal | undefined; + let abortedDuringWork: boolean | undefined; + const client = new Client(driver, { + clientId: "runtime-test", + leaderElectionDisabled: true, + queues: { default: { maxWorkers: 1 } }, + workers: new Workers().add(definition, (context) => { + signal = context.signal; + abortedDuringWork = context.signal.aborted; + }), + }); + const run = await client.start(); + + await waitUntil(() => driver.completions.length === 1); + await waitUntil(() => signal?.aborted === true); + await run.stop(); + + expect(abortedDuringWork).toBe(false); + expect(signal?.reason).toBeInstanceOf(JobAttemptFinishedError); + expect(signal?.reason).toMatchObject({ + code: "job_attempt_finished", + jobId: 7n, + }); + }); + + it("leaves no listener on its long-lived signals once attempts settle", async () => { + // Links from settled attempts and completions would keep a listener on + // the runtime's run and claim signals; count the links' live listeners. + const live = new Set(); + const ids = new WeakMap(); + let nextId = 0; + const key = (target: object, listener: unknown): string => { + if (!ids.has(target)) ids.set(target, nextId++); + if (!ids.has(listener as object)) ids.set(listener as object, nextId++); + return `${ids.get(target)}:${ids.get(listener as object)}`; + }; + const isLink = (type: string, listener: unknown): boolean => + type === "abort" && + typeof listener === "function" && + listener.name === "#onAbort"; + const add = EventTarget.prototype.addEventListener; + const remove = EventTarget.prototype.removeEventListener; + vi.spyOn(EventTarget.prototype, "addEventListener").mockImplementation( + function (this: EventTarget, type, listener, options) { + if (isLink(type, listener)) live.add(key(this, listener)); + add.call(this, type, listener, options); + } + ); + vi.spyOn(EventTarget.prototype, "removeEventListener").mockImplementation( + function (this: EventTarget, type, listener, options) { + if (isLink(type, listener)) live.delete(key(this, listener)); + remove.call(this, type, listener, options); + } + ); + const any = vi.spyOn(AbortSignal, "any"); + const driver = new FakeRuntimeDriver(); + driver.claim = Array.from({ length: 200 }, (_, index) => + fakeJob("test", BigInt(index + 1)) + ); + const definition = defineJob({ kind: "test" }); + const client = new Client(driver, { + clientId: "runtime-test", + leaderElectionDisabled: true, + queues: { default: { maxWorkers: 50 } }, + workers: new Workers().add(definition, () => undefined), + }); + const run = await client.start(); + + await waitUntil(() => driver.completions.length === 200); + const working = live.size; + await run.stop(); + + // Only the queue loop's link, on the claim signal and its generation's. + expect(working).toBe(2); + expect(live.size).toBe(0); + // Nothing per attempt or completion uses `AbortSignal.any`. + expect(any).not.toHaveBeenCalled(); + }); + + it("bounds completion ownership and backpressures saturated attempts", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = Array.from({ length: 6 }, (_, index) => + fakeJob("test", BigInt(index + 1)) + ); + let releaseCompletions!: () => void; + driver.completionGate = new Promise((resolve) => { + releaseCompletions = resolve; + }); + const definition = defineJob({ kind: "test" }); + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + completionFlushInterval: { milliseconds: 10_000 }, + leaderElectionDisabled: true, + queues: { + default: { maxWorkers: 4, pollInterval: { milliseconds: 10_000 } }, + }, + workers: new Workers().add(definition, () => undefined), + }); + const run = await client.start(); + + await waitUntil( + () => + run.diagnostics.completionQueries === 2 && + run.diagnostics.activeAttempts === 2 + ); + expect(run.diagnostics).toMatchObject({ + activeAttempts: 2, + completionCapacity: 2, + completionQueries: 2, + pendingCompletions: 2, + }); + + releaseCompletions(); + await waitUntil(() => driver.completions.length === 6); + await run.stop(); + }); + + it("drops a non-retryable completion batch and keeps working", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = [fakeJob("test", 1n)]; + driver.completionError = new DatabaseOperationError("constraint failed", { + backend: "fake", + operation: "jobCompleteMany", + }); + const logs: LogEntry[] = []; + const metrics: RiverMetric[] = []; + const timer = new FakeTimer(); + const definition = defineJob({ kind: "test" }); + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + hooks: { onMetric: (metric) => void metrics.push(metric) }, + logger: recordingLogger(logs), + leaderElectionDisabled: true, + queues: { + default: { + fetchCooldown: { milliseconds: 1 }, + maxWorkers: 1, + pollInterval: { milliseconds: 10 }, + }, + }, + workers: new Workers().add(definition, () => undefined), + }); + overrideRuntimeTiming(client, { random: () => 0.5, timer }); + const run = await client.start(); + + await waitUntil(() => + metrics.some(({ name }) => name === "job_completion_dropped") + ); + // Go's completer makes three bounded attempts before giving up. + expect(driver.completionCalls).toBe(3); + expect(run.state).toBe("running"); + expect(run.diagnostics.pendingCompletions).toBe(0); + expect(logs.filter(({ level }) => level === "error")).toEqual([ + expect.objectContaining({ + attributes: { error: "constraint failed", jobs: 1 }, + message: expect.stringContaining("dropped completions"), + }), + ]); + + // The runtime keeps claiming and completing new work afterwards. + driver.completionError = undefined; + driver.claim = [fakeJob("test", 2n)]; + await waitUntil(() => driver.completions.some(({ id }) => id === 2n)); + await run.stop(); + expect(run.state).toBe("stopped"); + expect(metrics).toContainEqual({ + count: 1, + name: "job_completion_dropped", + }); + }); + + it("requeues retryable completion failures until persistence recovers", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = [fakeJob()]; + driver.completionFailures = 5; + const metrics: RiverMetric[] = []; + const timer = new FakeTimer(); + const definition = defineJob({ kind: "test" }); + const events: string[] = []; + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + hooks: { + onEvent: ({ kind }) => void events.push(kind), + onMetric: (metric) => void metrics.push(metric), + }, + leaderElectionDisabled: true, + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }, + workers: new Workers().add(definition, () => undefined), + }); + overrideRuntimeTiming(client, { random: () => 0.5, timer }); + const run = await client.start(); + + await waitUntil(() => driver.completions.length === 1); + await run.stop(); + + expect(driver.completionCalls).toBe(6); + expect(metrics).toContainEqual({ + count: 1, + name: "job_completion_requeued", + }); + // Every failed attempt backs off, including the last before a requeue. + expect(timer.delays).toEqual([1_000, 2_000, 4_000, 1_000, 2_000]); + expect(events).toEqual(["job_started", "job_completed"]); + }); + + it("bounds a hung completion query with a per-attempt timeout", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = [fakeJob()]; + driver.completionGate = new Promise(() => undefined); + const logs: LogEntry[] = []; + const timer = new FakeTimer(); + const definition = defineJob({ kind: "test" }); + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + logger: recordingLogger(logs), + leaderElectionDisabled: true, + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }, + workers: new Workers().add(definition, () => undefined), + }); + overrideRuntimeTiming(client, { random: () => 0.5, timer }); + const run = await client.start(); + + await waitUntil(() => driver.completionCalls === 1); + expect(timer.activeTimeouts).toBe(1); + const signal = driver.completionSignals[0]; + driver.completionGate = undefined; + timer.expireTimeouts(); + + await waitUntil(() => driver.completions.length === 1); + expect(signal?.aborted).toBe(true); + expect(signal?.reason).toMatchObject({ + name: "DatabaseOperationError", + operation: "jobCompleteMany", + retryable: true, + }); + expect(logs).toContainEqual( + expect.objectContaining({ + attributes: expect.objectContaining({ + attempt: 1, + error: "River completion persistence timed out after 10000 ms", + retryable: true, + }), + level: "warn", + }) + ); + await run.stop(); + expect(run.state).toBe("stopped"); + }); + + it("reports a completion that committed after its attempt timed out", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = [fakeJob("test", 1n), fakeJob("test", 2n)]; + // The first attempt commits both completions, but its reply arrives only + // after the attempt timed out. The retry then finds the rows finished. + // Job 2 was meanwhile cancelled by another process, which is a race. + const completeMany = driver.jobCompleteMany.bind(driver); + driver.jobCompleteMany = async (commands, options) => { + await completeMany(commands, options); + if (driver.completionCalls === 1) { + driver.listRows = commands.map((command) => { + const row = applyCompletion( + driver.claimed.findLast(({ id }) => id === command.id)!, + command + ); + return command.id === 2n + ? { + ...row, + finalizedAt: Temporal.Now.instant(), + state: "cancelled", + } + : row; + }); + await new Promise((_resolve, reject) => { + options?.signal?.addEventListener( + "abort", + () => reject(options.signal?.reason), + { once: true } + ); + }); + } + return commands.map((command) => ({ + job: null, + key: jobCompletionKey(command), + status: "applied" as const, + })); + }; + const events: RiverEvent[] = []; + const timer = new FakeTimer(); + const definition = defineJob({ kind: "test" }); + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 2, + hooks: { onEvent: (event) => void events.push(event) }, + leaderElectionDisabled: true, + queues: { + default: { maxWorkers: 2, pollInterval: { milliseconds: 10_000 } }, + }, + workers: new Workers().add(definition, () => undefined), + }); + overrideRuntimeTiming(client, { random: () => 0.5, timer }); + const run = await client.start(); + + await waitUntil( + () => timer.activeTimeouts === 1 && driver.listRows.length === 2 + ); + timer.expireTimeouts(); + await waitUntil(() => driver.completionCalls === 2 && events.length >= 2); + await run.stop(); + + const outcomes = events + .filter(({ kind }) => kind === "job_completed" || kind === "job_race") + .map((event) => [ + "job" in event ? event.job.id : undefined, + event.kind, + "job" in event ? event.job.state : undefined, + ]); + expect(outcomes).toEqual( + expect.arrayContaining([ + [1n, "job_completed", "completed"], + [2n, "job_race", "running"], + ]) + ); + expect(outcomes).toHaveLength(2); + // Both rows are read back in one listing, not one read per job. + expect(driver.lastList).toMatchObject({ ids: [1n, 2n], limit: 2 }); + }); + + it("finishes a graceful stop during a completion outage", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = [fakeJob("test", 1n), fakeJob("test", 2n)]; + driver.completionFailures = Number.POSITIVE_INFINITY; + const metrics: RiverMetric[] = []; + const timer = new FakeTimer(); + const definition = defineJob({ kind: "test" }); + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + hooks: { onMetric: (metric) => void metrics.push(metric) }, + leaderElectionDisabled: true, + queues: { + default: { maxWorkers: 2, pollInterval: { milliseconds: 10_000 } }, + }, + workers: new Workers().add(definition, () => undefined), + }); + overrideRuntimeTiming(client, { random: () => 0.5, timer }); + const run = await client.start(); + + await waitUntil(() => + metrics.some(({ name }) => name === "job_completion_requeued") + ); + await run.stop(); + + expect(run.state).toBe("stopped"); + expect(driver.completions).toEqual([]); + expect( + metrics + .filter(({ name }) => name === "job_completion_dropped") + .reduce( + (total, metric) => total + ("count" in metric ? metric.count : 0), + 0 + ) + ).toBe(2); + }); + + it("releases completion capacity held by a dropped batch", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = Array.from({ length: 6 }, (_, index) => + fakeJob("test", BigInt(index + 1)) + ); + driver.completionError = new Error("driver invariant broke"); + const timer = new FakeTimer(); + const definition = defineJob({ kind: "test" }); + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + completionFlushInterval: { milliseconds: 0 }, + leaderElectionDisabled: true, + queues: { + default: { maxWorkers: 4, pollInterval: { milliseconds: 10_000 } }, + }, + workers: new Workers().add(definition, () => undefined), + }); + overrideRuntimeTiming(client, { random: () => 0.5, timer }); + const run = await client.start(); + + // Six attempts through a two-item completion capacity can only finish if + // every dropped batch releases its ownership. + await waitUntil(() => driver.completionCalls === 18); + await waitUntil( + () => + run.diagnostics.activeAttempts === 0 && + run.diagnostics.pendingCompletions === 0 + ); + expect(run.state).toBe("running"); + await run.stop(); + expect(run.state).toBe("stopped"); + }); + + it("releases worker and completion capacity while a hook runs but drains events on stop", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = [fakeJob()]; + let releaseCompletion!: () => void; + driver.completionGate = new Promise((resolve) => { + releaseCompletion = resolve; + }); + let releaseCompletionEvent!: () => void; + const completionEventGate = new Promise((resolve) => { + releaseCompletionEvent = resolve; + }); + const order: string[] = []; + const definition = defineJob({ kind: "test" }); + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + hooks: { + afterWork: () => { + order.push("after-work"); + }, + onEvent: async ({ kind }) => { + order.push(`event:${kind}`); + if (kind === "job_completed") await completionEventGate; + }, + }, + leaderElectionDisabled: true, + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }, + workers: new Workers().add(definition, () => undefined), + }); + const run = await client.start(); + + await waitUntil(() => driver.completionCalls === 1); + await waitUntil(() => run.diagnostics.activeAttempts === 0); + expect(order).toEqual(["event:job_started", "after-work"]); + let stopped = false; + const stopping = run.stop().then(() => { + stopped = true; + }); + await Promise.resolve(); + expect(stopped).toBe(false); + + releaseCompletion(); + await waitUntil(() => order.includes("event:job_completed")); + // A slow onEvent hook no longer holds completion capacity. + await waitUntil(() => run.diagnostics.pendingCompletions === 0); + expect(stopped).toBe(false); + releaseCompletionEvent(); + await stopping; + expect(order).toEqual([ + "event:job_started", + "after-work", + "event:job_completed", + ]); + }); + + it("refills a full worker pool with coalesced bounded claim batches", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = Array.from({ length: 8 }, (_, index) => + fakeJob("test", BigInt(index + 1)) + ); + const definition = defineJob({ kind: "test" }); + const releases: Array<() => void> = []; + let hold = true; + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 4, + completionFlushInterval: { milliseconds: 0 }, + leaderElectionDisabled: true, + queues: { + default: { maxWorkers: 4, pollInterval: { milliseconds: 10_000 } }, + }, + workers: new Workers().add(definition, async () => { + if (!hold) return; + await new Promise((resolve) => releases.push(resolve)); + }), + }); + const run = await client.start(); + await waitUntil(() => releases.length === 4); + + for (const release of releases.slice(0, 3)) release(); + await waitUntil(() => driver.claimRequests.length >= 2); + + expect(driver.claimRequests.slice(0, 2)).toEqual([4, 3]); + hold = false; + for (const release of releases) release(); + await waitUntil(() => driver.completions.length === 8); + await run.stop(); + expect(driver.claimRequests.length).toBeLessThanOrEqual(3); + }); + + it("claims into a slot freed after a full claim while another job runs on", async () => { + // A claim that fills every slot suggests more jobs wait. Like River for + // Go, the next claim follows the first freed slot, not the long job. + const driver = new FakeRuntimeDriver(); + driver.claim = [fakeJob("test", 1n), fakeJob("test", 2n)]; + const definition = defineJob({ kind: "test" }); + const releases = new Map void>(); + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + completionFlushInterval: { milliseconds: 0 }, + leaderElectionDisabled: true, + queues: { + default: { + fetchCooldown: { milliseconds: 1 }, + maxWorkers: 2, + pollInterval: { milliseconds: 60_000 }, + }, + }, + workers: new Workers().add(definition, async ({ job }) => { + await new Promise((resolve) => releases.set(job.id, resolve)); + }), + }); + const run = await client.start(); + await waitUntil(() => releases.size === 2); + driver.claim.push(fakeJob("test", 3n)); + + releases.get(2n)?.(); + await waitUntil(() => releases.has(3n)); + + expect(driver.claimRequests).toEqual([2, 1]); + releases.get(1n)?.(); + releases.get(3n)?.(); + await waitUntil(() => driver.completions.length === 3); + await run.stop(); + }); + + it("refills low-volume work while an earlier job remains active", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = [fakeJob("test", 1n)]; + const definition = defineJob({ kind: "test" }); + let releaseFirst: (() => void) | undefined; + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + completionFlushInterval: { milliseconds: 0 }, + leaderElectionDisabled: true, + queues: { + default: { + fetchCooldown: { milliseconds: 1 }, + maxWorkers: 4, + pollInterval: { milliseconds: 20 }, + }, + }, + workers: new Workers().add(definition, async ({ job }) => { + if (job.id === 1n) { + await new Promise((resolve) => { + releaseFirst = resolve; + }); + } + }), + }); + const run = await client.start(); + await waitUntil(() => releaseFirst !== undefined); + + driver.claim.push(fakeJob("test", 2n)); + await waitUntil(() => driver.claimed.some(({ id }) => id === 2n)); + + expect(run.diagnostics.activeAttempts).toBeGreaterThanOrEqual(1); + expect(driver.claimRequests).toContain(3); + releaseFirst?.(); + await waitUntil(() => driver.completions.length === 2); + await run.stop(); + }); + + it("applies fetch cooldowns independently to every queue", async () => { + const driver = new FakeRuntimeDriver(); + const definition = defineJob({ kind: "test" }); + const client = new Client(driver, { + clientId: "runtime-test", + leaderElectionDisabled: true, + queues: { + default: { + fetchCooldown: { milliseconds: 10_000 }, + maxWorkers: 1, + pollInterval: { milliseconds: 10_000 }, + }, + other: { + fetchCooldown: { milliseconds: 10_000 }, + maxWorkers: 1, + pollInterval: { milliseconds: 10_000 }, + }, + }, + workers: new Workers().add(definition, () => undefined), + }); + const run = await client.start(); + + await waitUntil(() => driver.claimQueues.length >= 2); + await run.stop(); + + expect(driver.claimQueues).toEqual( + expect.arrayContaining(["default", "other"]) + ); + }); + + it("bounds full-batch refills with the queue fetch cooldown", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = [fakeJob("test", 101n), fakeJob("test", 102n)]; + const definition = defineJob({ kind: "test" }); + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + completionFlushInterval: { milliseconds: 0 }, + leaderElectionDisabled: true, + queues: { + default: { + fetchCooldown: { milliseconds: 30 }, + maxWorkers: 1, + pollInterval: { milliseconds: 1_000 }, + }, + }, + workers: new Workers().add(definition, () => undefined), + }); + const run = await client.start(); + + await waitUntil(() => driver.completions.length === 2); + await run.stop(); + + expect(driver.claimStartedAtMs).toHaveLength(2); + expect( + (driver.claimStartedAtMs[1] ?? 0) - (driver.claimStartedAtMs[0] ?? 0) + ).toBeGreaterThanOrEqual(25); + }); + + it("aborts an outstanding fetch cooldown during shutdown", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = [fakeJob()]; + const definition = defineJob({ kind: "test" }); + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + leaderElectionDisabled: true, + queues: { + default: { + fetchCooldown: { milliseconds: 10_000 }, + maxWorkers: 1, + pollInterval: { milliseconds: 10_000 }, + }, + }, + workers: new Workers().add(definition, () => undefined), + }); + const run = await client.start(); + + await waitUntil(() => driver.completions.length === 1); + await expect( + run.stop({ timeout: { milliseconds: 250 } }) + ).resolves.toBeUndefined(); + + expect(driver.claimStartedAtMs).toHaveLength(1); + }); + + it("rejects millisecond option names", async () => { + const definition = defineJob({ kind: "test" }); + const workers = new Workers().add(definition, () => undefined); + const construct = (options: object) => () => + new Client(new FakeRuntimeDriver(), { + workers, + ...(options as ClientOptions), + }); + + expect(construct({ jobTimeoutMs: 1_000 })).toThrow( + "jobTimeoutMs is not an option; use jobTimeout with a Temporal duration such as { seconds: 5 }" + ); + expect(construct({ completionFlushIntervalMs: 10 })).toThrow( + "completionFlushIntervalMs is not an option; use completionFlushInterval" + ); + expect( + construct({ queues: { default: { maxWorkers: 1, pollIntervalMs: 50 } } }) + ).toThrow("queue pollIntervalMs is not an option; use queue pollInterval"); + expect(construct({ maintenance: { rescueAfterMs: 1_000 } })).toThrow( + "maintenance.rescueAfterMs is not an option; use maintenance.rescueAfter" + ); + expect(construct({ eventLoopDelay: { resolutionMs: 10 } })).toThrow( + "eventLoopDelay.resolutionMs is not an option; use eventLoopDelay.resolution" + ); + + const driver = new FakeRuntimeDriver(); + const client = new Client(driver, { + clientId: "runtime-test", + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }, + workers, + }); + const run = await client.start(); + await expect( + run.updateQueue("default", { + maxWorkers: 1, + ...({ fetchCooldownMs: 10 } as object), + }) + ).rejects.toThrow("queue fetchCooldownMs is not an option"); + await expect( + run.stop({ ...({ timeoutMs: 5_000 } as object) }) + ).rejects.toThrow("stop timeoutMs is not an option; use stop timeout"); + await run.stop(); + }); + + it("rejects option names River doesn't know", async () => { + const definition = defineJob({ kind: "test" }); + const workers = new Workers().add(definition, () => undefined); + const construct = (options: object) => () => + new Client(new FakeRuntimeDriver(), { + workers, + ...(options as ClientOptions), + }); + + expect(construct({ jobTimout: { seconds: 1 } })).toThrow(ValidationError); + expect(construct({ jobTimout: { seconds: 1 } })).toThrow( + 'client has no option "jobTimout"' + ); + expect(construct({ maintenance: { rescueAftr: { hours: 2 } } })).toThrow( + 'maintenance has no option "rescueAftr"' + ); + expect( + construct({ eventLoopDelay: { resolutin: { seconds: 1 } } }) + ).toThrow('eventLoopDelay has no option "resolutin"'); + + const client = new Client(new FakeRuntimeDriver(), { + clientId: "runtime-test", + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }, + workers, + }); + const run = await client.start(); + await expect( + run.stop({ ...({ timout: { seconds: 5 } } as object) }) + ).rejects.toThrow('stop has no option "timout"'); + await run.stop(); + }); + + it("validates the job stuck threshold", () => { + const definition = defineJob({ kind: "test" }); + const workers = new Workers().add(definition, () => undefined); + const construct = (jobStuckThreshold: unknown) => () => + new Client(new FakeRuntimeDriver(), { + jobStuckThreshold: jobStuckThreshold as Temporal.DurationLike, + workers, + }); + + expect(construct({ seconds: -1 })).toThrow( + "jobStuckThreshold must not be negative" + ); + expect(construct(null)).toThrow( + "jobStuckThreshold must be a Temporal duration" + ); + expect(construct({ seconds: 0 })).not.toThrow(); + }); + + it("validates queue fetch timing and worker bounds", () => { + const definition = defineJob({ kind: "test" }); + const workers = new Workers().add(definition, () => undefined); + + expect( + () => + new Client(new FakeRuntimeDriver(), { + queues: { + default: { + fetchCooldown: { milliseconds: 20 }, + maxWorkers: 1, + pollInterval: { milliseconds: 10 }, + }, + }, + workers, + }) + ).toThrow( + "queue pollInterval cannot be shorter than fetchCooldown, which defaults to the client's fetchCooldown" + ); + expect( + () => + new Client(new FakeRuntimeDriver(), { + queues: { + default: { fetchCooldown: { milliseconds: 0 }, maxWorkers: 1 }, + }, + workers, + }) + ).toThrow("queue fetchCooldown must be positive"); + expect( + () => + new Client(new FakeRuntimeDriver(), { + queues: { default: { maxWorkers: 10_001 } }, + workers, + }) + ).toThrow("maxWorkers must be at most 10000"); + expect( + () => + new Client(new FakeRuntimeDriver(), { + queues: { "Invalid Queue": { maxWorkers: 1 } }, + workers, + }) + ).toThrow("queue name must contain lowercase letters"); + expect( + () => + new Client(new FakeRuntimeDriver(), { + clientId: "a".repeat(101), + queues: { default: { maxWorkers: 1 } }, + workers, + }) + ).toThrow("between 1 and 100 characters"); + }); + + it("requests the currently available capacity for a 2,000-worker queue", async () => { + const driver = new FakeRuntimeDriver(); + const definition = defineJob({ kind: "test" }); + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 100, + leaderElectionDisabled: true, + queues: { + default: { maxWorkers: 2_000, pollInterval: { milliseconds: 10_000 } }, + }, + workers: new Workers().add(definition, () => undefined), + }); + const run = await client.start(); + + await waitUntil(() => driver.claimRequests.length > 0); + expect(driver.claimRequests[0]).toBe(2_000); + await run.stop(); + }); + + it("emits compatible fetch metrics without blocking claims", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = [fakeJob()]; + const definition = defineJob({ kind: "test" }); + const metrics: RiverMetric[] = []; + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + hooks: { + onMetric: (metric) => { + metrics.push(metric); + }, + }, + leaderElectionDisabled: true, + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }, + workers: new Workers().add(definition, () => undefined), + }); + const run = await client.start(); + + await waitUntil(() => driver.completions.length === 1); + await run.stop(); + + expect(metrics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + duration: expect.any(Temporal.Duration), + name: "job_get_available_duration", + queue: "default", + }), + { + count: 1, + name: "job_get_available_count", + queue: "default", + }, + ]) + ); + }); + + it("claims and fails an unregistered kind compatibly", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = [ + { + ...fakeJob("conformance_unregistered"), + maxAttempts: 1, + }, + ]; + const known = defineJob({ kind: "known" }); + const extensionOrder: string[] = []; + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + errorHandler: (_context, error) => { + extensionOrder.push(`error:${(error as Error).name}`); + }, + hooks: { + afterWork: () => { + extensionOrder.push("after"); + }, + beforeWork: () => { + extensionOrder.push("before"); + }, + }, + leaderElectionDisabled: true, + middleware: [ + (_context, next) => { + extensionOrder.push("middleware"); + return next(); + }, + ], + plugins: [ + createJobArgsTransformPlugin({ + name: "must-not-run-for-unknown-kind", + onRead: () => { + throw new Error("unknown kinds must not transform arguments"); + }, + }), + ], + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }, + workers: new Workers().add(known, () => undefined), + }); + const run = await client.start(); + + await waitUntil(() => driver.completions.length === 1); + await run.stop(); + + expect(driver.lastClaim?.kinds).toEqual([]); + expect(driver.completions[0]).toMatchObject({ + error: { + error: + "job kind is not registered in the client's Workers bundle: conformance_unregistered", + }, + finalizedAt: expect.any(Temporal.Instant), + id: 101n, + kind: "discard", + }); + expect(extensionOrder).toEqual(["error:UnknownJobKindError"]); + expect(run.state).toBe("stopped"); + }); + + it("fails the attempts of undecodable claimed rows without working them", async () => { + const driver = new FakeRuntimeDriver(); + const kind = defineJob({ kind: "known" }); + driver.claim = [ + { ...fakeJob("known", 302n), maxAttempts: 5 }, + fakeJob("known", 301n), + { ...fakeJob("known", 303n), attempt: 1, maxAttempts: 1 }, + ]; + for (const id of [302n, 303n]) { + driver.decodeErrors.set( + id, + new TypeError("could not decode `metadata`: not an object") + ); + } + const handled: string[] = []; + const worked: bigint[] = []; + const retryAt = Temporal.Instant.from("2030-01-01T00:00:00Z"); + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + errorHandler: ({ job }, error) => { + handled.push(`${job.id.toString()}:${(error as Error).message}`); + }, + hooks: { + beforeWork: () => { + handled.push("before"); + }, + }, + leaderElectionDisabled: true, + queues: { + default: { maxWorkers: 3, pollInterval: { milliseconds: 10_000 } }, + }, + retryPolicy: () => retryAt, + workers: new Workers().add(kind, ({ job }) => { + worked.push(job.id); + }), + }); + const failed = client.subscribe({ kinds: ["job_failed"] }); + const run = await client.start(); + + await waitUntil(() => driver.completions.length === 3); + await run.stop(); + + const message = + "job row couldn't be decoded: could not decode `metadata`: not an object"; + expect(worked).toEqual([301n]); + expect(handled.filter((entry) => entry !== "before").sort()).toEqual([ + `302:${message}`, + `303:${message}`, + ]); + const byId = new Map(driver.completions.map((c) => [c.id, c])); + expect(byId.get(302n)).toMatchObject({ + error: { error: message }, + kind: "retry", + scheduledAt: retryAt, + }); + expect(byId.get(303n)).toMatchObject({ + error: { error: message }, + kind: "discard", + }); + const events: bigint[] = []; + for await (const event of failed) { + if (event.kind === "job_failed") events.push(event.job.id); + if (events.length === 2) break; + } + expect(events.sort()).toEqual([302n, 303n]); + }); + + it("makes compatible per-job rescue decisions", async () => { + const driver = new FakeRuntimeDriver(); + const startedAt = Temporal.Now.instant(); + const oldAttempt = startedAt.subtract({ hours: 2 }); + const recentAttempt = startedAt.subtract({ milliseconds: 1 }); + const storedJobs = [ + { + ...fakeJob("known", 201n), + attemptedAt: oldAttempt, + metadata: { cancel_attempted_at: startedAt.toString() }, + }, + { ...fakeJob("unknown", 202n), attemptedAt: oldAttempt }, + { + ...fakeJob("known", 203n), + args: { invalid: true }, + attemptedAt: recentAttempt, + }, + { + ...fakeJob("known", 204n), + args: { invalid: true }, + attempt: 3, + attemptedAt: oldAttempt, + maxAttempts: 3, + }, + { ...fakeJob("known", 205n), attemptedAt: recentAttempt }, + { ...fakeJob("known", 206n), attemptedAt: oldAttempt }, + { + ...fakeJob("known", 207n), + attempt: 3, + attemptedAt: oldAttempt, + maxAttempts: 3, + }, + // A kind with a disabled timeout may legitimately run for hours. + { ...fakeJob("unbounded", 208n), attemptedAt: oldAttempt }, + // An undecodable payload is rescued even when the timeout is disabled. + { + ...fakeJob("unbounded", 209n), + args: { invalid: true }, + attemptedAt: oldAttempt, + }, + ]; + const jobs = storedJobs.map((job) => + job.kind === "unknown" ? job : { ...job, args: { envelope: job.args } } + ); + let electedAt: Temporal.Instant | null = null; + let delivered = false; + let rescues: readonly RuntimeJobRescue[] = []; + Object.assign(driver, { + maintenanceCleanJobs: () => 0, + maintenanceCleanQueues: () => 0, + maintenanceGetStuck: () => { + if (delivered) return []; + delivered = true; + return jobs; + }, + maintenanceLeaderAcquire: ( + leaderId: string, + now: Temporal.Instant, + ttlMs: number + ): RuntimeLeader => { + electedAt ??= now; + return { + electedAt, + expiresAt: now.add({ milliseconds: ttlMs }), + leaderId, + }; + }, + maintenanceLeaderResign: () => true, + maintenanceRescue: ( + _leader: RuntimeLeader, + _attemptedBefore: Temporal.Instant, + decisions: readonly RuntimeJobRescue[] + ) => { + rescues = decisions; + return decisions.length; + }, + maintenanceSchedule: () => 0, + }); + const definition = defineJob({ + decode: (value: { invalid?: boolean; value?: string }) => { + if (value.invalid === true) throw new Error("invalid payload"); + return value; + }, + kind: "known", + }); + const retryPolicyArgs = new Map(); + const client = new Client(driver, { + clientId: "runtime-test", + jobTimeout: { milliseconds: 10 }, + maintenance: { + electionInterval: { milliseconds: 1 }, + jobCleanerInterval: { milliseconds: 60_000 }, + queueCleanerInterval: { milliseconds: 60_000 }, + rescueAfter: { milliseconds: 10 }, + rescuerInterval: { milliseconds: 1 }, + schedulerInterval: { milliseconds: 60_000 }, + }, + plugins: [ + createJobArgsTransformPlugin({ + name: "envelope", + onRead: ({ args }) => args.envelope as JsonObject, + }), + ], + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }, + retryPolicy: (job, now) => { + retryPolicyArgs.set(job.id, job.args); + return now.add({ seconds: 3 }); + }, + workers: new Workers() + .add(definition, () => undefined, { + // Like Go's rescuer, the worker's policy decides once the + // arguments decode, and the client's otherwise. + retryPolicy: (_job, now) => now.add({ seconds: 7 }), + timeout: { milliseconds: 10 }, + }) + .add( + defineJob({ + decode: (value: { invalid?: boolean }) => { + if (value.invalid === true) throw new Error("invalid payload"); + return value; + }, + kind: "unbounded", + }), + () => undefined, + { timeout: null } + ), + }); + // Freeze the runtime clock: job 205's attempt must stay 1 ms old no + // matter how long the rescuer takes to run under load. + overrideRuntimeTiming(client, { now: () => startedAt }); + const run = await client.start(); + + await waitUntil(() => rescues.length === 7); + await run.stop(); + + expect(rescues).toMatchObject([ + { + finalizedAt: expect.any(Temporal.Instant), + id: 201n, + state: "cancelled", + }, + { + finalizedAt: expect.any(Temporal.Instant), + id: 202n, + state: "discarded", + }, + { finalizedAt: null, id: 203n, state: "retryable" }, + { + finalizedAt: expect.any(Temporal.Instant), + id: 204n, + state: "discarded", + }, + { finalizedAt: null, id: 206n, state: "retryable" }, + { + finalizedAt: expect.any(Temporal.Instant), + id: 207n, + state: "discarded", + }, + { finalizedAt: null, id: 209n, state: "retryable" }, + ]); + expect(rescues.some(({ id }) => id === 205n)).toBe(false); + expect(rescues.some(({ id }) => id === 208n)).toBe(false); + expect( + rescues.every( + ({ error }) => error.error === "Stuck job rescued by JobRescuer" + ) + ).toBe(true); + const retry = rescues.find(({ id }) => id === 206n); + expect(retry).toBeDefined(); + if (retry === undefined) throw new Error("missing retry rescue decision"); + expect( + retry.scheduledAt.epochNanoseconds - retry.error.at.epochNanoseconds + ).toBe(7_000_000_000n); + expect(retryPolicyArgs.has(206n)).toBe(false); + expect(retryPolicyArgs.get(203n)).toEqual({ invalid: true }); + expect(retryPolicyArgs.get(209n)).toEqual({ invalid: true }); + }); + + it("completes a remotely cancelled attempt whose handler still succeeds", async () => { + // Go's executor only replaces a returned error with the cancellation + // (`RemoteCancellationJobNotCancelledIfNoErrorReturned`). + const driver = new FakeRuntimeDriver(); + driver.claim = [fakeJob()]; + driver.cancelled = { ...fakeJob(), state: "cancelled" }; + const definition = defineJob({ kind: "test" }); + const finish = Promise.withResolvers(); + const workers = new Workers().add(definition, () => + finish.promise.then(() => undefined) + ); + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }, + workers, + }); + const events: string[] = []; + const subscription = client.subscribe(); + const run = await client.start(); + await waitUntil(() => run.diagnostics.activeAttempts === 1); + + await client.jobs.cancel(101n); + expect(run.diagnostics.activeAttempts).toBe(1); + finish.resolve(undefined); + await waitUntil(() => run.diagnostics.activeAttempts === 0); + await run.stop({ mode: "graceful" }); + + expect(driver.completions).toMatchObject([ + { error: null, kind: "complete" }, + ]); + for await (const event of subscription) { + events.push(`${event.kind}:${"job" in event ? event.job.state : ""}`); + if (events.length === 2) break; + } + expect(events).toEqual(["job_started:running", "job_completed:completed"]); + }); + + it("emits job_cancelled once, after a remote cancellation commits", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = [fakeJob()]; + driver.cancelled = { ...fakeJob(), state: "cancelled" }; + const definition = defineJob({ kind: "test" }); + const release = Promise.withResolvers(); + const workers = new Workers().add(definition, async ({ signal }) => { + await release.promise; + signal.throwIfAborted(); + }); + const events: string[] = []; + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + hooks: { + onEvent: (event) => + void events.push( + `${event.kind}:${"job" in event ? event.job.state : ""}` + ), + }, + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }, + workers, + }); + const run = await client.start(); + await waitUntil(() => run.diagnostics.activeAttempts === 1); + + await client.jobs.cancel(101n); + // The handler has not settled, so nothing has been persisted or emitted. + await new Promise((resolve) => setImmediate(resolve)); + expect(events).toEqual(["job_started:running"]); + release.resolve(undefined); + await waitUntil(() => driver.completions.length === 1); + await run.stop({ mode: "graceful" }); + + expect(driver.completions).toMatchObject([ + { + error: { error: "JobCancelError: job cancelled remotely" }, + kind: "cancel", + }, + ]); + expect(events).toEqual(["job_started:running", "job_cancelled:cancelled"]); + }); + + it("cancels a remotely cancelled attempt that snoozes", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = [fakeJob()]; + driver.cancelled = { ...fakeJob(), state: "cancelled" }; + const definition = defineJob({ kind: "test" }); + const release = Promise.withResolvers(); + const workers = new Workers().add(definition, async () => { + await release.promise; + return snooze({ seconds: 60 }); + }); + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }, + workers, + }); + const run = await client.start(); + await waitUntil(() => run.diagnostics.activeAttempts === 1); + + await client.jobs.cancel(101n); + release.resolve(undefined); + await waitUntil(() => driver.completions.length === 1); + await run.stop(); + + expect(driver.completions).toMatchObject([{ kind: "cancel" }]); + }); + + it.each([ + { + calls: [], + name: "an ordinary error", + thrown: new Error("cleanup failed"), + }, + { + calls: ["TypeError"], + name: "a runtime fault", + thrown: new TypeError("cleanup failed"), + }, + ])( + "gives the error handler $name after a remote cancellation like Go", + async ({ calls, thrown }) => { + // Go replaces a returned error with the cancellation before its error + // handler runs, but still hands a panic to `HandlePanic`. + const driver = new FakeRuntimeDriver(); + driver.claim = [fakeJob()]; + driver.cancelled = { ...fakeJob(), state: "cancelled" }; + const definition = defineJob({ kind: "test" }); + const release = Promise.withResolvers(); + const workers = new Workers().add(definition, async () => { + await release.promise; + throw thrown; + }); + const handled: string[] = []; + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + errorHandler: (_context, error) => { + handled.push((error as Error).name); + }, + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }, + workers, + }); + const run = await client.start(); + await waitUntil(() => run.diagnostics.activeAttempts === 1); + + await client.jobs.cancel(101n); + release.resolve(undefined); + await waitUntil(() => driver.completions.length === 1); + await run.stop(); + + expect(handled).toEqual(calls); + expect(driver.completions).toMatchObject([ + { + error: { error: "JobCancelError: job cancelled remotely" }, + kind: "cancel", + }, + ]); + } + ); + + it("gives the error handler an attempt stopped by its timeout like Go", async () => { + // A Go worker that returns its context's deadline error reaches + // `HandleError`, which may cancel the job. + const driver = new FakeRuntimeDriver(); + driver.claim = [fakeJob()]; + const definition = defineJob({ kind: "test" }); + const workers = new Workers().add( + definition, + async ({ signal }) => { + await new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => { + reject(signal.reason as Error); + }); + }); + }, + { timeout: { milliseconds: 20 } } + ); + const handled: unknown[] = []; + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + errorHandler: (_context, error) => { + handled.push(error); + return { cancel: true }; + }, + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }, + workers, + }); + const run = await client.start(); + + await waitUntil(() => driver.completions.length === 1); + await run.stop(); + + expect(handled).toHaveLength(1); + expect(handled[0]).toBeInstanceOf(JobTimeoutError); + expect(driver.completions).toMatchObject([ + { + error: { error: "River job 101 exceeded its 20 ms timeout" }, + kind: "cancel", + }, + ]); + }); + + it("cancels an attempt remotely cancelled after its timeout like Go", async () => { + // Go checks the remote cancellation on the attempt's outer context, so it + // still wins after the attempt's own timeout expired. + const driver = new FakeRuntimeDriver(); + driver.claim = [fakeJob()]; + driver.cancelled = { ...fakeJob(), state: "cancelled" }; + const definition = defineJob({ kind: "test" }); + const timedOut = Promise.withResolvers(); + const release = Promise.withResolvers(); + const workers = new Workers().add( + definition, + async ({ signal }) => { + await new Promise((resolve) => { + signal.addEventListener("abort", resolve); + }); + timedOut.resolve(undefined); + await release.promise; + throw signal.reason as Error; + }, + { timeout: { milliseconds: 20 } } + ); + let errorHandlerCalls = 0; + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + errorHandler: () => { + errorHandlerCalls += 1; + }, + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }, + workers, + }); + const run = await client.start(); + await timedOut.promise; + + await client.jobs.cancel(101n); + release.resolve(undefined); + await waitUntil(() => driver.completions.length === 1); + await run.stop(); + + expect(errorHandlerCalls).toBe(0); + expect(driver.completions).toMatchObject([ + { + error: { error: "JobCancelError: job cancelled remotely" }, + kind: "cancel", + }, + ]); + }); + + it("interrupts only attempts stopped by the graceful deadline", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = [ + fakeJob("test", 1n), + fakeJob("test", 2n), + fakeJob("test", 3n), + ]; + const definition = defineJob({ kind: "test" }); + const workers = new Workers().add(definition, ({ job, signal }) => { + return new Promise((resolve, reject) => { + signal.addEventListener( + "abort", + () => { + if (job.id === 1n) reject(signal.reason as Error); + else if (job.id === 2n) reject(new Error("flush failed")); + else resolve(); + }, + { once: true } + ); + }); + }); + const events: string[] = []; + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + hooks: { + onEvent: (event) => { + if ("job" in event && event.kind !== "job_started") { + events.push(`${event.job.id}:${event.kind}:${event.job.state}`); + } + }, + }, + leaderElectionDisabled: true, + queues: { + default: { maxWorkers: 3, pollInterval: { milliseconds: 10_000 } }, + }, + workers, + }); + const run = await client.start(); + await waitUntil(() => run.diagnostics.activeAttempts === 3); + + await expect(run.stop({ timeout: { milliseconds: 1 } })).rejects.toThrow( + "River runtime stop timed out" + ); + await run.completed; + + const byId = new Map(driver.completions.map((item) => [item.id, item])); + // Stopped by the shutdown abort: available again, attempt refunded. + expect(byId.get(1n)).toMatchObject({ error: null, kind: "interrupt" }); + // A genuine failure during shutdown is recorded and retried like Go; + // the first retry is due within a scheduler pass, so it is available. + expect(byId.get(2n)).toMatchObject({ + available: true, + error: { error: "flush failed" }, + kind: "retry", + }); + // Finishing successfully after the deadline still completes the job. + expect(byId.get(3n)).toMatchObject({ error: null, kind: "complete" }); + expect(events.sort()).toEqual([ + "1:job_interrupted:available", + "2:job_failed:available", + "3:job_completed:completed", + ]); + }); + + it("does not abort a local attempt before transactional cancellation commits", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = [fakeJob()]; + driver.cancelled = { ...fakeJob(), state: "cancelled" }; + const definition = defineJob({ kind: "test" }); + let aborted = false; + let finish!: () => void; + const handler = new Promise((resolve) => { + finish = resolve; + }); + const workers = new Workers().add(definition, ({ signal }) => { + signal.addEventListener("abort", () => { + aborted = true; + }); + return handler; + }); + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }, + workers, + }); + const run = await client.start(); + await waitUntil(() => run.diagnostics.activeAttempts === 1); + + await client.jobs.cancel(101n, { tx: {} }); + expect(aborted).toBe(false); + finish(); + await waitUntil(() => run.diagnostics.activeAttempts === 0); + await run.stop(); + }); + + it("retains queue capacity until an ignored abort actually settles", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = [ + fakeJob("test", 101n), + fakeJob("test", 102n), + fakeJob("test", 103n), + ]; + const definition = defineJob({ kind: "test" }); + const releases: Array<() => void> = []; + let activeHandlers = 0; + let maximumActiveHandlers = 0; + const workers = new Workers().add( + definition, + () => + new Promise((resolve) => { + activeHandlers++; + maximumActiveHandlers = Math.max( + maximumActiveHandlers, + activeHandlers + ); + releases.push(() => { + activeHandlers--; + resolve(undefined); + }); + }), + { timeout: { milliseconds: 1 } } + ); + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + queues: { + default: { + fetchCooldown: { milliseconds: 1 }, + maxWorkers: 1, + pollInterval: { milliseconds: 1 }, + }, + }, + workers, + }); + const run = await client.start(); + + await waitUntil(() => releases.length === 1); + // Polling every millisecond, a queue that released its slot early would + // claim the next job well within this window. + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(driver.claimed.map(({ id }) => id)).toEqual([101n]); + expect(driver.completions).toEqual([]); + expect(run.diagnostics.activeAttempts).toBe(1); + + releases[0]?.(); + await waitUntil(() => releases.length === 2); + const stopping = run.stop({ mode: "cancel" }); + releases[1]?.(); + await stopping; + + expect(driver.claimed.map(({ id }) => id)).toEqual([101n, 102n]); + expect(driver.completions.map(({ kind }) => kind)).toEqual([ + "complete", + "complete", + ]); + expect(maximumActiveHandlers).toBe(1); + expect(run.state).toBe("stopped"); + }); + + it("replaces capacity only when the stuck handler requests it", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = [fakeJob("test", 101n), fakeJob("test", 102n)]; + const definition = defineJob({ kind: "test" }); + const releases: Array<() => void> = []; + const seenTotals: number[] = []; + const workers = new Workers().add( + definition, + () => + new Promise((resolve) => { + releases.push(() => resolve(undefined)); + }), + { timeout: { milliseconds: 1 } } + ); + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + queues: { + default: { + fetchCooldown: { milliseconds: 1 }, + maxWorkers: 1, + pollInterval: { milliseconds: 1 }, + }, + }, + stuckHandler: ({ totalStuckJobs }) => { + seenTotals.push(totalStuckJobs); + return { addWorkerSlot: totalStuckJobs === 1 }; + }, + jobStuckThreshold: { milliseconds: 1 }, + workers, + }); + const run = await client.start(); + + await waitUntil(() => releases.length === 2); + expect(driver.claimed.map(({ id }) => id)).toEqual([101n, 102n]); + expect(seenTotals[0]).toBe(1); + expect(run.diagnostics.activeAttempts).toBe(2); + + releases.forEach((release) => release()); + await waitUntil(() => driver.completions.length === 2); + await run.stop(); + }); + + it("retains attempt ownership until an async decoder settles", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = [fakeJob("test", 101n), fakeJob("test", 102n)]; + let releaseDecode!: () => void; + const decoderBlocked = new Promise((resolve) => { + releaseDecode = resolve; + }); + const definition = defineJob({ + decode: async (input) => { + await decoderBlocked; + return input; + }, + kind: "test", + }); + let handlerCalled = false; + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + queues: { + default: { + fetchCooldown: { milliseconds: 1 }, + maxWorkers: 1, + pollInterval: { milliseconds: 1 }, + }, + }, + workers: new Workers().add(definition, ({ signal }) => { + // The stop cancelled the attempt while its decoder ran. + handlerCalled = signal.aborted; + signal.throwIfAborted(); + }), + }); + const run = await client.start(); + await waitUntil(() => run.diagnostics.activeAttempts === 1); + + await expect( + run.stop({ mode: "cancel", timeout: { milliseconds: 1 } }) + ).rejects.toThrow("timed out"); + expect(driver.claimed.map(({ id }) => id)).toEqual([101n]); + expect(run.diagnostics.activeAttempts).toBe(1); + + releaseDecode(); + await run.stop(); + + expect(handlerCalled).toBe(true); + expect(driver.completions.map(({ kind }) => kind)).toEqual(["interrupt"]); + }); + + it("records last-write-wins output on error-handler cancellation", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = [fakeJob()]; + const definition = defineJob({ kind: "test" }); + const order: string[] = []; + const workers = new Workers().add(definition, (context) => { + recordOutput({ source: "handler" }); + context.recordOutput({ source: "last-handler-write" }); + order.push("worker"); + // eslint-disable-next-line @typescript-eslint/only-throw-error -- exercises a non-Error failure + throw "non-Error failure"; + }); + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + errorHandler: (context, error) => { + order.push(`error:${String(error)}`); + context.recordOutput({ source: "error-handler" }); + return { cancel: true }; + }, + hooks: { + afterWork: (_context, result) => { + order.push(`after:${result.cancel}`); + }, + }, + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }, + workers, + }); + const run = await client.start(); + + await waitUntil(() => driver.completions.length === 1); + await run.stop(); + + expect(driver.completions[0]).toMatchObject({ + error: { + error: "non-Error failure", + trace: "", + }, + kind: "cancel", + output: { source: "error-handler" }, + outputSet: true, + }); + expect(order).toEqual([ + "worker", + "after:undefined", + "error:non-Error failure", + ]); + }); + + it("cancels a stuck-job decoder during shutdown", async () => { + const driver = new FakeRuntimeDriver(); + const now = Temporal.Now.instant(); + let decodeStarted = false; + let delivered = false; + let electedAt: Temporal.Instant | null = null; + Object.assign(driver, { + maintenanceCleanJobs: () => 0, + maintenanceCleanQueues: () => 0, + maintenanceGetStuck: () => { + if (delivered) return []; + delivered = true; + return [ + { + ...fakeJob("known", 208n), + attemptedAt: now.subtract({ hours: 2 }), + }, + ]; + }, + maintenanceLeaderAcquire: ( + leaderId: string, + acquiredAt: Temporal.Instant, + ttlMs: number + ): RuntimeLeader => { + electedAt ??= acquiredAt; + return { + electedAt, + expiresAt: acquiredAt.add({ milliseconds: ttlMs }), + leaderId, + }; + }, + maintenanceLeaderResign: () => true, + maintenanceRescue: () => 0, + maintenanceSchedule: () => 0, + }); + const definition = defineJob({ + decode: async (value: { value?: string }) => { + decodeStarted = true; + await new Promise(() => undefined); + return value; + }, + kind: "known", + }); + const client = new Client(driver, { + clientId: "runtime-test", + jobTimeout: null, + maintenance: { + electionInterval: { milliseconds: 1 }, + jobCleanerInterval: { milliseconds: 60_000 }, + queueCleanerInterval: { milliseconds: 60_000 }, + rescueAfter: { milliseconds: 1 }, + rescuerInterval: { milliseconds: 1 }, + schedulerInterval: { milliseconds: 60_000 }, + }, + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }, + workers: new Workers().add(definition, () => undefined), + }); + const run = await client.start(); + + await waitUntil(() => decodeStarted); + + await expect( + run.stop({ mode: "cancel", timeout: { milliseconds: 500 } }) + ).resolves.toBeUndefined(); + }); + + it("treats decoder and error-handler failures as nonfatal attempts", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = [fakeJob()]; + const definition = defineJob({ + decode: () => { + throw new Error("invalid persisted payload"); + }, + kind: "test", + }); + let handlerCalled = false; + const logged: string[] = []; + const workers = new Workers().add(definition, () => { + handlerCalled = true; + }); + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + errorHandler: () => { + throw new Error("application error handler failed"); + }, + logger: { + debug: () => undefined, + error: (_attributes, message) => logged.push(message), + info: () => undefined, + warn: () => undefined, + }, + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }, + workers, + }); + const run = await client.start(); + + await waitUntil(() => driver.completions.length === 1); + await run.stop(); + + expect(handlerCalled).toBe(false); + expect(driver.completions[0]).toMatchObject({ + kind: "retry", + outputSet: false, + }); + expect(logged).toEqual(["River error handler failed"]); + expect(run.state).toBe("stopped"); + }); + + it("reports a stuck attempt with its worker's timeout, not the client's", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = [fakeJob()]; + const definition = defineJob({ kind: "test" }); + let finish!: () => void; + const blocked = new Promise((resolve) => { + finish = resolve; + }); + const warnings: { message: string; timeoutMs: unknown }[] = []; + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + jobTimeout: { minutes: 1 }, + logger: { + debug: () => undefined, + error: () => undefined, + info: () => undefined, + warn: (attributes, message) => { + warnings.push({ message, timeoutMs: attributes.timeoutMs }); + }, + }, + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }, + jobStuckThreshold: { milliseconds: 1 }, + workers: new Workers().add( + definition, + () => blocked.then(() => undefined), + { timeout: { milliseconds: 5 } } + ), + }); + const events = client.subscribe({ kinds: ["job_stuck"] }); + const run = await client.start(); + + const event = (await events.next()).value; + finish(); + await waitUntil(() => driver.completions.length === 1); + await run.stop(); + + expect(String((event.error as JobStuckError).timeout)).toBe("PT0.005S"); + expect(warnings).toContainEqual({ + message: "River job appears to be stuck", + timeoutMs: 5, + }); + }); + + it("reports an attempt once after its timeout and stuck margin", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = [fakeJob()]; + const definition = defineJob({ kind: "test" }); + let finish!: () => void; + const blocked = new Promise((resolve) => { + finish = resolve; + }); + const workers = new Workers().add(definition, () => + blocked.then(() => undefined) + ); + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + jobTimeout: { milliseconds: 1 }, + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }, + jobStuckThreshold: { milliseconds: 1 }, + workers, + }); + const events = client.subscribe({ kinds: ["job_stuck"] }); + const run = await client.start(); + + const event = (await events.next()).value; + expect(event).toMatchObject({ job: { id: 101n }, kind: "job_stuck" }); + expect(event.error).toMatchObject({ + jobId: 101n, + message: + "River job 101 remained unsettled for 1 ms after its 1 ms timeout", + name: "JobStuckError", + }); + const stuck = event.error as JobStuckError; + expect([stuck.threshold, stuck.timeout].map(String)).toEqual([ + "PT0.001S", + "PT0.001S", + ]); + expect(driver.completions).toEqual([]); + + finish(); + await waitUntil(() => driver.completions.length === 1); + await run.stop(); + events.close(); + }); + + it("arms the job timeout only once an executor starts the attempt", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = [fakeJob()]; + const definition = defineJob({ kind: "test" }); + const aborts: unknown[] = []; + const gracePeriods: Temporal.Duration[] = []; + let executorStarted = false; + let markStarted!: () => void; + const started = new Promise((resolve) => { + markStarted = resolve; + }); + const executor: WorkExecutor = { + name: "deferred", + start: () => { + executorStarted = true; + let finish!: () => void; + const result = new Promise((resolve) => { + finish = () => resolve(undefined); + }); + return { + abort: (reason, { gracePeriod }) => { + aborts.push(reason); + gracePeriods.push(gracePeriod); + finish(); + return Promise.resolve({ terminated: true }); + }, + result, + started, + }; + }, + }; + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + jobTimeout: { milliseconds: 1 }, + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }, + workers: new Workers().addExecutor(definition, { + executor, + handler: null, + }), + }); + const run = await client.start(); + + await waitUntil(() => executorStarted); + // Waiting for executor capacity must not spend the attempt's timeout. + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(aborts).toEqual([]); + + markStarted(); + await waitUntil(() => aborts.length === 1); + expect(aborts[0]).toBeInstanceOf(JobTimeoutError); + // The executor may end the handler by force after the stuck threshold, + // which defaults to 10 seconds. + expect(gracePeriods.map((period) => period.total("milliseconds"))).toEqual([ + 10_000, + ]); + await waitUntil(() => driver.completions.length === 1); + await run.stop(); + }); + + it("keeps shutdown observable after a graceful stop bound expires", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = [fakeJob()]; + const definition = defineJob({ kind: "test" }); + let finish!: () => void; + const blocked = new Promise((resolve) => { + finish = resolve; + }); + let aborted = false; + const cooperativeWorkers = new Workers().add(definition, ({ signal }) => { + signal.addEventListener("abort", () => { + aborted = true; + }); + return blocked.then(() => undefined); + }); + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }, + workers: cooperativeWorkers, + }); + const run = await client.start(); + await waitUntil(() => run.diagnostics.activeAttempts === 1); + + await expect(run.stop({ timeout: { milliseconds: 1 } })).rejects.toThrow( + "timed out" + ); + expect(aborted).toBe(true); + expect(run.state).toBe("stopping"); + expect(run.diagnostics.activeAttempts).toBe(1); + + finish(); + await expect(run.stop()).resolves.toBeUndefined(); + await expect(run.completed).resolves.toBeUndefined(); + expect(run.state).toBe("stopped"); + }); + + it.each([ + { + expectedOrder: ["before", "error:Error"], + makeOptions: (order: string[]): ClientOptions => ({ + errorHandler: (_context, error) => { + order.push(`error:${(error as Error).name}`); + }, + hooks: { + afterWork: (_context, result) => { + order.push(`after:${result.status}`); + }, + beforeWork: () => { + order.push("before"); + throw new Error("before failed"); + }, + }, + }), + name: "beforeWork", + }, + { + expectedOrder: ["middleware", "error:Error"], + makeOptions: (order: string[]): ClientOptions => ({ + errorHandler: (_context, error) => { + order.push(`error:${(error as Error).name}`); + }, + hooks: { + afterWork: (_context, result) => { + order.push(`after:${result.status}`); + }, + beforeWork: () => { + order.push("before"); + }, + }, + middleware: [ + () => { + order.push("middleware"); + throw new Error("middleware failed"); + }, + ], + }), + name: "middleware", + }, + { + expectedOrder: ["before", "handler", "after:succeeded", "error:Error"], + makeOptions: (order: string[]): ClientOptions => ({ + errorHandler: (_context, error) => { + order.push(`error:${(error as Error).name}`); + }, + hooks: { + afterWork: (_context, result) => { + order.push(`after:${result.status}`); + throw new Error("after failed"); + }, + beforeWork: () => { + order.push("before"); + }, + }, + }), + name: "afterWork", + }, + ])( + "persists $name extension failures as ordinary attempts", + async (testCase) => { + const driver = new FakeRuntimeDriver(); + driver.claim = [fakeJob()]; + const definition = defineJob({ kind: "test" }); + const order: string[] = []; + const workers = new Workers().add(definition, () => { + order.push("handler"); + }); + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + ...testCase.makeOptions(order), + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }, + workers, + }); + const run = await client.start(); + + await waitUntil(() => driver.completions.length === 1); + await run.stop(); + + expect(driver.completions[0]?.kind).toBe("retry"); + expect(order).toEqual(testCase.expectedOrder); + expect(run.state).toBe("stopped"); + } + ); + + it("matches Go work-extension nesting across global and job-kind scopes", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = [fakeJob()]; + const order: string[] = []; + const definition = defineJob({ + decode: (input) => { + order.push("decode"); + return input; + }, + kind: "test", + }); + const around = + (name: string): WorkMiddleware => + async (_context, next) => { + order.push(`${name}-before`); + const result = await next(); + order.push(`${name}-after`); + return result; + }; + const workers = new Workers().add( + definition, + () => { + order.push("handler"); + }, + { + hooks: { + afterWork: () => { + order.push("kind-hook-after"); + }, + beforeWork: () => { + order.push("kind-hook-before"); + }, + }, + middleware: [around("worker")], + plugins: [ + { + hooks: { + afterWork: () => { + order.push("kind-plugin-after"); + }, + beforeWork: () => { + order.push("kind-plugin-before"); + }, + }, + middleware: [around("kind-plugin")], + name: "kind-plugin", + }, + ], + } + ); + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + hooks: { + afterWork: () => { + order.push("global-after"); + }, + beforeWork: () => { + order.push("global-before"); + }, + }, + middleware: [around("global")], + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }, + workers, + }); + const run = await client.start(); + + await waitUntil(() => driver.completions.length === 1); + await run.stop(); + + expect(order).toEqual([ + "worker-before", + "global-before", + "kind-plugin-before", + "global-before", + "kind-plugin-before", + "kind-hook-before", + "decode", + "handler", + "global-after", + "kind-plugin-after", + "kind-hook-after", + "kind-plugin-after", + "global-after", + "worker-after", + ]); + }); + + it("snapshots mutable runtime and job-kind configuration", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = [fakeJob()]; + const definition = defineJob({ kind: "test" }); + const order: string[] = []; + const globalMiddleware: WorkMiddleware[] = [ + async (_context, next) => { + order.push("global"); + return next(); + }, + ]; + const kindMiddleware: WorkMiddleware[] = [ + async (_context, next) => { + order.push("kind"); + return next(); + }, + ]; + const queues = { + default: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }; + const workers = new Workers().add( + definition, + () => { + order.push("handler"); + }, + { middleware: kindMiddleware } + ); + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + middleware: globalMiddleware, + queues, + workers, + }); + + globalMiddleware.push(() => { + order.push("mutated-global"); + }); + kindMiddleware.push(() => { + order.push("mutated-kind"); + }); + queues.default.maxWorkers = 50; + + const run = await client.start(); + await waitUntil(() => driver.completions.length === 1); + await run.stop(); + + expect(order).toEqual(["kind", "global", "handler"]); + expect(run.diagnostics.queues.default?.maxWorkers).toBe(1); + }); + + it("threads afterWork replacements through every hook", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = [fakeJob()]; + const definition = defineJob({ kind: "test" }); + const seen: string[] = []; + let errorHandlerCalled = false; + const workers = new Workers().add( + definition, + () => { + throw new Error("handler failed"); + }, + { + hooks: { + afterWork: (context, result) => { + seen.push(`kind:${result.status}`); + context.setMetadata("kind", true); + return { status: "succeeded" }; + }, + }, + plugins: [ + { + hooks: { + afterWork: (context, result) => { + seen.push(`plugin:${result.status}`); + context.setMetadata("plugin", true); + return { + error: new Error("plugin reintroduced failure"), + status: "failed", + }; + }, + }, + name: "kind-plugin", + }, + ], + } + ); + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + errorHandler: () => { + errorHandlerCalled = true; + }, + hooks: { + afterWork: (context, result) => { + seen.push(`global:${result.status}`); + context.setMetadata("global", true); + return { status: "succeeded" }; + }, + }, + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }, + workers, + }); + const run = await client.start(); + + await waitUntil(() => driver.completions.length === 1); + await run.stop(); + + expect(seen).toEqual(["global:failed", "plugin:succeeded", "kind:failed"]); + expect(errorHandlerCalled).toBe(false); + expect(driver.completions[0]).toMatchObject({ + kind: "complete", + metadata: { global: true, kind: true, plugin: true }, + }); + }); + + it("keeps metadata independent across the complete work lifecycle", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = [fakeJob()]; + const definition = defineJob({ kind: "test" }); + const workers = new Workers().add(definition, async (context) => { + await Promise.resolve(); + setMetadata("phase", "handler-helper"); + context.setMetadata("handler", true); + context.setMetadata("__proto__", { safe: true }); + }); + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + hooks: { + afterWork: (context) => { + context.setMetadata("phase", "after"); + context.setMetadata("after", true); + }, + beforeWork: (context) => { + context.setMetadata("phase", "before"); + setMetadata("before", true); + }, + }, + middleware: [ + async (context, next) => { + context.setMetadata("phase", "middleware-before"); + const outcome = await next(); + setMetadata("phase", "middleware-after"); + return outcome; + }, + ], + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }, + workers, + }); + const run = await client.start(); + + await waitUntil(() => driver.completions.length === 1); + await run.stop(); + + const metadata = driver.completions[0]?.metadata; + expect(metadata).toMatchObject({ + after: true, + before: true, + handler: true, + phase: "middleware-after", + }); + expect(Object.hasOwn(metadata ?? {}, "__proto__")).toBe(true); + expect(metadata?.__proto__).toEqual({ safe: true }); + expect(() => setMetadata("outside", true)).toThrow("while working a job"); + }); + + it("lets outer middleware suppress decode failures without running afterWork", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = [fakeJob()]; + const order: string[] = []; + const definition = defineJob({ + decode: () => { + order.push("decode"); + throw new Error("bad persisted args"); + }, + kind: "test", + }); + const workers = new Workers().add( + definition, + () => { + order.push("handler"); + }, + { + hooks: { + afterWork: () => { + order.push("after"); + }, + beforeWork: (context) => { + context.setMetadata("before", true); + order.push("before"); + }, + }, + middleware: [ + async (_context, next) => { + order.push("middleware-before"); + try { + await next(); + } catch { + order.push("middleware-caught"); + } + }, + ], + } + ); + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }, + workers, + }); + const run = await client.start(); + + await waitUntil(() => driver.completions.length === 1); + await run.stop(); + + expect(order).toEqual([ + "middleware-before", + "before", + "decode", + "middleware-caught", + ]); + expect(driver.completions[0]).toMatchObject({ + kind: "complete", + metadata: { before: true }, + }); + }); + + it("keeps ALS and metadata mutation out of the error handler", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = [fakeJob()]; + const definition = defineJob({ kind: "test" }); + let hasSetter = true; + let storedContext: unknown; + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + errorHandler: (context) => { + hasSetter = "setMetadata" in context; + storedContext = currentWorkContext(); + context.recordOutput({ handled: true }); + }, + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }, + workers: new Workers().add(definition, () => { + throw new Error("failure"); + }), + }); + const run = await client.start(); + + await waitUntil(() => driver.completions.length === 1); + await run.stop(); + + expect(hasSetter).toBe(false); + expect(storedContext).toBeUndefined(); + expect(driver.completions[0]).toMatchObject({ + kind: "retry", + output: { handled: true }, + outputSet: true, + }); + }); + + it("prefers a worker's retry policy over the client's, like Go's Worker.NextRetry", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = [ + fakeJob("own", 1n), + fakeJob("throws", 2n), + fakeJob("past", 3n), + fakeJob("undecodable", 4n), + ]; + const now = Temporal.Instant.from("2026-09-01T00:00:00Z"); + const fail = () => { + throw new Error("try again"); + }; + const workerRetryAt = now.add({ hours: 1 }); + const clientRetryAt = now.add({ hours: 2 }); + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + leaderElectionDisabled: true, + queues: { + default: { maxWorkers: 4, pollInterval: { milliseconds: 10_000 } }, + }, + retryPolicy: () => clientRetryAt, + workers: new Workers() + .add(defineJob({ kind: "own" }), fail, { + retryPolicy: () => workerRetryAt, + }) + .add(defineJob({ kind: "past" }), fail, { + retryPolicy: (_job, at) => at.subtract({ seconds: 1 }), + }) + .add(defineJob({ kind: "throws" }), fail, { + retryPolicy: () => { + throw new Error("no policy"); + }, + }) + .add( + defineJob({ + decode: (): never => { + throw new Error("invalid payload"); + }, + kind: "undecodable", + }), + fail, + { retryPolicy: () => workerRetryAt } + ), + }); + overrideRuntimeTiming(client, { now: () => now }); + const run = await client.start(); + + await waitUntil(() => driver.completions.length === 4); + await run.stop(); + + const scheduledAt = new Map( + driver.completions.map((item) => [item.id, item.scheduledAt]) + ); + expect(scheduledAt.get(1n)).toEqual(workerRetryAt); + expect(scheduledAt.get(2n)).toEqual(clientRetryAt); + // A time in the past uses River's default schedule, about a second for + // a first attempt. + const pastRetry = scheduledAt.get(3n)!; + expect(Temporal.Instant.compare(pastRetry, now)).toBe(1); + expect(Temporal.Instant.compare(pastRetry, now.add({ seconds: 2 }))).toBe( + -1 + ); + // Arguments that don't decode leave the decision to the client. + expect(scheduledAt.get(4n)).toEqual(clientRetryAt); + }); + + it("rejects a worker retry policy that isn't a function", () => { + expect(() => + new Workers().add(defineJob({ kind: "test" }), () => undefined, { + retryPolicy: "soon" as never, + }) + ).toThrow(new ConfigurationError("worker retryPolicy must be a function")); + }); + + it("persists retries and snoozes due within a scheduler pass as available", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = [ + fakeJob("test", 1n), + fakeJob("test", 2n), + fakeJob("test", 3n), + fakeJob("test", 4n), + ]; + const definition = defineJob({ kind: "test" }); + const now = Temporal.Instant.from("2026-09-01T00:00:00Z"); + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + leaderElectionDisabled: true, + queues: { + default: { maxWorkers: 4, pollInterval: { milliseconds: 10_000 } }, + }, + // Jobs 1 and 2 fail; job 1's retry lands exactly on River's default + // five second scheduler interval and job 2's just beyond it. + retryPolicy: (job, at) => + at.add({ milliseconds: job.id === 1n ? 5_000 : 5_001 }), + workers: new Workers().add(definition, ({ job }) => { + switch (job.id) { + case 1n: + case 2n: + throw new Error("try again"); + case 3n: + return snooze({ seconds: 5 }); + default: + return snooze({ milliseconds: 5_001 }); + } + }), + }); + overrideRuntimeTiming(client, { now: () => now }); + const run = await client.start(); + + await waitUntil(() => driver.completions.length === 4); + await run.stop(); + + const byId = new Map(driver.completions.map((item) => [item.id, item])); + expect(byId.get(1n)).toMatchObject({ available: true, kind: "retry" }); + expect(byId.get(2n)).not.toHaveProperty("available"); + expect(byId.get(3n)).toMatchObject({ available: true, kind: "snooze" }); + expect(byId.get(4n)).not.toHaveProperty("available"); + // The fast path changes only the state: an error keeps its attempt and + // a snooze refunds it. + const states = driver.completionBatches + .flat() + .map((command) => applyCompletion(fakeJob("test", command.id), command)) + .map(({ attempt, id, state }) => [id, state, attempt]); + expect( + states.sort(([left], [right]) => Number(left) - Number(right)) + ).toEqual([ + [1n, "available", 1], + [2n, "retryable", 1], + [3n, "available", 0], + [4n, "scheduled", 0], + ]); + }); + + it("records attempt errors at the attempt start with traces only for faults", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = [ + fakeJob("test", 1n), + fakeJob("test", 2n), + fakeJob("test", 3n), + ]; + const definition = defineJob({ kind: "test" }); + let clock = Temporal.Instant.from("2026-09-01T00:00:00Z"); + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + leaderElectionDisabled: true, + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }, + workers: new Workers().add(definition, ({ job }) => { + // Every attempt takes a minute of runtime-clock time. + clock = clock.add({ minutes: 1 }); + if (job.id === 1n) throw new Error("expected failure"); + if (job.id === 2n) { + const missing = undefined as unknown as { readonly field: number }; + return void missing.field; + } + throw new (class ValidationFailure extends TypeError {})( + "deliberate subclass" + ); + }), + }); + overrideRuntimeTiming(client, { now: () => clock }); + const run = await client.start(); + + await waitUntil(() => driver.completions.length === 3); + await run.stop(); + + const errors = new Map( + driver.completions.map(({ error, id }) => [id, error]) + ); + const start = Temporal.Instant.from("2026-09-01T00:00:00Z"); + expect(errors.get(1n)).toEqual({ + at: start, + error: "expected failure", + trace: "", + }); + // Reading a property of undefined is a runtime fault: River's panic. + expect(errors.get(2n)?.at).toEqual(start.add({ minutes: 1 })); + expect(errors.get(2n)?.trace).toContain("TypeError"); + expect(errors.get(3n)).toEqual({ + at: start.add({ minutes: 2 }), + error: "deliberate subclass", + trace: "", + }); + }); + + it("cancels a job permanently when its handler returns cancel()", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = [fakeJob("test", 1n), fakeJob("test", 2n)]; + const definition = defineJob({ kind: "test" }); + const startedAt = Temporal.Instant.from("2026-09-01T00:00:00Z"); + const events: string[] = []; + let errorHandlerCalls = 0; + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + errorHandler: () => { + errorHandlerCalls += 1; + }, + hooks: { + onEvent: (event) => { + if ("job" in event) { + events.push(`${event.job.id}:${event.kind}:${event.job.state}`); + } + }, + }, + leaderElectionDisabled: true, + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }, + workers: new Workers().add(definition, ({ job }) => + job.id === 1n ? cancel({ reason: "account closed" }) : cancel() + ), + }); + overrideRuntimeTiming(client, { now: () => startedAt }); + const run = await client.start(); + + await waitUntil(() => driver.completions.length === 2); + await run.stop(); + + // Like Go's `river.JobCancel`, the reason is the attempt error. + expect(driver.completions).toMatchObject([ + { + error: { + at: startedAt, + error: "JobCancelError: account closed", + trace: "", + }, + finalizedAt: startedAt, + id: 1n, + kind: "cancel", + }, + { + error: { error: "JobCancelError: ", trace: "" }, + id: 2n, + kind: "cancel", + }, + ]); + expect(errorHandlerCalls).toBe(0); + expect(events).toEqual([ + "1:job_started:running", + "1:job_cancelled:cancelled", + "2:job_started:running", + "2:job_cancelled:cancelled", + ]); + }); + + it("increments canonical persisted snooze metadata", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = [ + { + ...fakeJob(), + metadata: { snoozes: "2", user: true }, + }, + ]; + const definition = defineJob({ kind: "test" }); + const beforeSnooze = Temporal.Now.instant(); + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }, + workers: new Workers().add(definition, () => snooze({ milliseconds: 1 })), + }); + const run = await client.start(); + + await waitUntil(() => driver.completions.length === 1); + await run.stop(); + + expect(driver.completions[0]).toMatchObject({ + kind: "snooze", + metadata: { snoozes: 3 }, + }); + const scheduledAt = driver.completions[0]?.scheduledAt; + expect(scheduledAt).not.toBeNull(); + expect( + scheduledAt!.epochNanoseconds - beforeSnooze.epochNanoseconds + ).toBeGreaterThanOrEqual(1_000_000n); + }); + + it("persists and dynamically reconfigures runtime queues", async () => { + const driver = new FakeRuntimeDriver(); + const definition = defineJob({ kind: "test" }); + const client = new Client(driver, { + leaderElectionDisabled: true, + queues: { default: { maxWorkers: 1 } }, + workers: new Workers().add(definition, () => undefined), + }); + const run = await client.start(); + + expect(driver.queues.has("default")).toBe(true); + expect( + durationsInMilliseconds(run.diagnostics.queues.default) + ).toMatchObject({ + fetchCooldown: 100, + pollInterval: 1_000, + }); + await run.addQueue("extra", { + fetchCooldown: { milliseconds: 5 }, + maxWorkers: 2, + pollInterval: { milliseconds: 25 }, + }); + expect(driver.queues.has("extra")).toBe(true); + expect(durationsInMilliseconds(run.diagnostics.queues.extra)).toMatchObject( + { + fetchCooldown: 5, + maxWorkers: 2, + paused: false, + pollInterval: 25, + } + ); + + await run.updateQueue("extra", { + fetchCooldown: { milliseconds: 10 }, + maxWorkers: 3, + pollInterval: { milliseconds: 50 }, + }); + expect(durationsInMilliseconds(run.diagnostics.queues.extra)).toMatchObject( + { + fetchCooldown: 10, + maxWorkers: 3, + pollInterval: 50, + } + ); + await expect(run.removeQueue("extra")).resolves.toBe(true); + expect(run.diagnostics.queues.extra).toBeUndefined(); + + await run.stop(); + expect(driver.queues.has("default")).toBe(true); + }); + + it("broadcasts leadership resignation without requiring a local term", async () => { + const driver = new FakeRuntimeDriver(); + const definition = defineJob({ kind: "test" }); + const client = new Client(driver, { + leaderElectionDisabled: true, + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 100 } }, + }, + workers: new Workers().add(definition, () => undefined), + }); + const transaction = { exact: true }; + await expect( + client.requestLeadershipResignation({ tx: transaction }) + ).resolves.toBeUndefined(); + expect(driver.leadershipResignRequests).toBe(1); + expect(driver.leadershipResignTransaction).toBe(transaction); + + const run = await client.start(); + + await expect(run.requestLeadershipResignation()).resolves.toBeUndefined(); + expect(driver.leadershipResignRequests).toBe(2); + await run.stop(); + }); + + it("treats onEvent observer failures as nonfatal after publication", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = [fakeJob()]; + const definition = defineJob({ kind: "test" }); + const errors: string[] = []; + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + hooks: { + onEvent: () => { + throw new Error("observer failed"); + }, + }, + logger: { + debug: () => undefined, + error: (_attributes, message) => errors.push(message), + info: () => undefined, + warn: () => undefined, + }, + leaderElectionDisabled: true, + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }, + workers: new Workers().add(definition, () => undefined), + }); + const events = client.subscribe({ kinds: ["job_completed"] }); + const run = await client.start(); + + await waitUntil(() => driver.completions.length === 1); + expect((await events.next()).value).toMatchObject({ + kind: "job_completed", + }); + await run.stop(); + + expect(run.state).toBe("stopped"); + expect(errors).toContain("River onEvent hook failed"); + }); + + it("reports persisted queue pause transitions recovered by polling", async () => { + const driver = new FakeRuntimeDriver(); + const definition = defineJob({ kind: "test" }); + const observed: string[] = []; + const client = new Client(driver, { + hooks: { + onEvent: ({ kind }) => { + observed.push(kind); + }, + }, + leaderElectionDisabled: true, + pollOnly: true, + queueControlPollInterval: { milliseconds: 1 }, + queueHeartbeatInterval: { milliseconds: 60_000 }, + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }, + workers: new Workers().add(definition, () => undefined), + }); + const events = client.subscribe({ + kinds: ["queue_paused", "queue_resumed"], + }); + const run = await client.start(); + const queue = driver.queues.get("default")!; + const pausedAt = Temporal.Now.instant(); + + driver.queues.set("default", { ...queue, pausedAt, updatedAt: pausedAt }); + await waitUntil(() => run.diagnostics.queues.default?.paused === true); + expect((await events.next()).value).toMatchObject({ + kind: "queue_paused", + queue: { name: "default", pausedAt }, + }); + + const resumedAt = Temporal.Now.instant(); + driver.queues.set("default", { + ...queue, + pausedAt: null, + updatedAt: resumedAt, + }); + await waitUntil(() => run.diagnostics.queues.default?.paused === false); + expect((await events.next()).value).toMatchObject({ + kind: "queue_resumed", + queue: { name: "default", pausedAt: null }, + }); + expect(observed.filter((kind) => kind === "queue_paused")).toHaveLength(1); + expect(observed.filter((kind) => kind === "queue_resumed")).toHaveLength(1); + + events.close(); + await run.stop(); + }); + + it("publishes same-client queue control exactly once", async () => { + const driver = new FakeRuntimeDriver(); + const definition = defineJob({ kind: "test" }); + const observed: string[] = []; + const client = new Client(driver, { + hooks: { + onEvent: ({ kind }) => { + observed.push(kind); + }, + }, + leaderElectionDisabled: true, + pollOnly: true, + queueControlPollInterval: { milliseconds: 1 }, + queueHeartbeatInterval: { milliseconds: 60_000 }, + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }, + workers: new Workers().add(definition, () => undefined), + }); + const run = await client.start(); + driver.queuePause = (name) => { + const queue = driver.queues.get(name); + if (queue === undefined) return null; + const pausedAt = Temporal.Now.instant(); + const paused = { ...queue, pausedAt, updatedAt: pausedAt }; + driver.queues.set(name, paused); + return paused; + }; + driver.queueResume = (name) => { + const queue = driver.queues.get(name); + if (queue === undefined) return null; + const updatedAt = Temporal.Now.instant(); + const resumed = { ...queue, pausedAt: null, updatedAt }; + driver.queues.set(name, resumed); + return resumed; + }; + + await client.queues.pause("default"); + expect(run.diagnostics.queues.default?.paused).toBe(true); + await client.queues.resume("default"); + expect(run.diagnostics.queues.default?.paused).toBe(false); + // Let the control poller read the queue several times, so it would have + // published a duplicate event by now. + const polls = driver.queueGetCalls; + await waitUntil(() => driver.queueGetCalls >= polls + 3); + + expect(observed.filter((kind) => kind === "queue_paused")).toHaveLength(1); + expect(observed.filter((kind) => kind === "queue_resumed")).toHaveLength(1); + await run.stop(); + }); + + it("applies a pause of every queue to this client's queues at once", async () => { + const driver = new FakeRuntimeDriver(); + const definition = defineJob({ kind: "test" }); + const client = new Client(driver, { + leaderElectionDisabled: true, + pollOnly: true, + queueControlPollInterval: { seconds: 60 }, + queueHeartbeatInterval: { milliseconds: 60_000 }, + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }, + workers: new Workers().add(definition, () => undefined), + }); + const run = await client.start(); + // Like Go, "*" updates every queue row and returns none. + const setPaused = (paused: boolean) => (name: string) => { + expect(name).toBe("*"); + const at = Temporal.Now.instant(); + for (const [queueName, queue] of driver.queues) { + driver.queues.set(queueName, { + ...queue, + pausedAt: paused ? at : null, + updatedAt: at, + }); + } + return null; + }; + driver.queuePause = setPaused(true); + driver.queueResume = setPaused(false); + + await expect(client.queues.pause("*")).resolves.toBeNull(); + await waitUntil(() => run.diagnostics.queues.default?.paused === true); + await expect(client.queues.resume("*")).resolves.toBeNull(); + await waitUntil(() => run.diagnostics.queues.default?.paused === false); + await run.stop(); + }); + + it("looks queues up by any name like Go, finding invalid names absent", async () => { + const driver = new FakeRuntimeDriver(); + const looked: string[] = []; + Object.assign(driver, { + queueGet: (name: string) => (looked.push(`get:${name}`), null), + queuePause: (name: string) => (looked.push(`pause:${name}`), null), + queueResume: (name: string) => (looked.push(`resume:${name}`), null), + queueUpdate: (name: string) => (looked.push(`update:${name}`), null), + }); + const client = new Client(driver, { clientId: "runtime-test" }); + + for (const name of ["Not A Queue!", "", "x".repeat(200)]) { + await expect(client.queues.get(name)).resolves.toBeNull(); + await expect(client.queues.pause(name)).resolves.toBeNull(); + await expect(client.queues.resume(name)).resolves.toBeNull(); + await expect( + client.queues.update(name, { metadata: {} }) + ).resolves.toBeNull(); + } + expect(looked).toHaveLength(12); + expect(looked).toContain("pause:Not A Queue!"); + }); + + it("reports an externally discarded stale completion as failed", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = [fakeJob()]; + driver.completionOverride = (command) => ({ + job: { + ...fakeJob(), + finalizedAt: Temporal.Now.instant(), + state: "discarded", + }, + key: jobCompletionKey(command), + status: "stale", + }); + const definition = defineJob({ kind: "test" }); + const client = new Client(driver, { + completionBatchSize: 1, + leaderElectionDisabled: true, + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }, + workers: new Workers().add(definition, () => undefined), + }); + const events = client.subscribe(); + const run = await client.start(); + + await waitUntil(() => driver.completions.length === 1); + await run.stop(); + + expect((await events.next()).value.kind).toBe("job_started"); + expect((await events.next()).value.kind).toBe("job_failed"); + events.close(); + }); + + it("wraps ordinary and periodic insertions with insert extensions", async () => { + const driver = new FakeRuntimeDriver(); + const definition = defineJob({ kind: "insert-extension" }); + const order: string[] = []; + const client = new Client(driver, { + hooks: { + afterInsert: (context) => { + order.push(`after:${context.operation}`); + }, + beforeInsert: (context) => { + order.push(`before:${context.operation}`); + }, + }, + insertMiddleware: [ + async (context, next) => { + order.push(`middleware-before:${context.operation}`); + const results = await next(); + order.push(`middleware-after:${context.operation}`); + return results; + }, + ], + }); + + await client.insert(definition, {}); + await client.insertMany([ + { args: {}, job: definition }, + { args: {}, job: definition }, + ]); + + // Like River for Go, insert hooks run inside the innermost middleware. + expect(order).toEqual([ + "middleware-before:insert", + "before:insert", + "after:insert", + "middleware-after:insert", + "middleware-before:insertMany", + "before:insertMany", + "after:insertMany", + "middleware-after:insertMany", + ]); + }); + + it("nests work hooks inside the innermost work middleware like Go", async () => { + const driver = new FakeRuntimeDriver(); + const definition = defineJob({ kind: "nested" }); + driver.claim = [fakeJob("nested", 401n), fakeJob("nested", 402n)]; + const order: string[] = []; + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + hooks: { + afterWork: ({ job }, result) => { + order.push(`hook:end:${job.id}`); + // A work-end hook's result replaces the worker's. + return job.id === 402n + ? { error: new Error("replaced"), status: "failed" } + : result; + }, + beforeWork: ({ job }) => { + order.push(`hook:begin:${job.id}`); + }, + }, + leaderElectionDisabled: true, + middleware: [ + async ({ job }, next) => { + order.push(`outer:before:${job.id}`); + const outcome = await next(); + order.push(`outer:after:${job.id}`); + return outcome; + }, + async ({ job }, next) => { + order.push(`inner:before:${job.id}`); + const outcome = await next(); + order.push(`inner:after:${job.id}`); + return outcome; + }, + ], + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }, + workers: new Workers().add(definition, ({ job }) => { + order.push(`worker:${job.id}`); + }), + }); + const run = await client.start(); + + await waitUntil(() => driver.completions.length === 2); + await run.stop(); + + expect(order.filter((entry) => entry.endsWith(":401"))).toEqual([ + "outer:before:401", + "inner:before:401", + "hook:begin:401", + "worker:401", + "hook:end:401", + "inner:after:401", + "outer:after:401", + ]); + const byId = new Map(driver.completions.map((c) => [c.id, c])); + expect(byId.get(401n)?.kind).toBe("complete"); + expect(byId.get(402n)).toMatchObject({ + error: { error: "replaced" }, + kind: "retry", + }); + }); + + it("runs without opening notification streams in poll-only mode", async () => { + const driver = new FakeRuntimeDriver(); + const definition = defineJob({ kind: "test" }); + const client = new Client(driver, { + leaderElectionDisabled: true, + pollOnly: true, + queues: { + default: { + fetchCooldown: { milliseconds: 1 }, + maxWorkers: 1, + pollInterval: { milliseconds: 10 }, + }, + }, + workers: new Workers().add(definition, () => undefined), + }); + const run = await client.start(); + await run.stop(); + + expect(driver.notificationSubscriptions).toBe(0); + }); + + it("claims only the kinds its workers had at start with fetchOnlyKnownKinds", async () => { + const claims: (readonly string[])[] = []; + for (const fetchOnlyKnownKinds of [true, false]) { + const driver = new FakeRuntimeDriver(); + const workers = new Workers() + .add( + defineJob({ kind: "second", kindAliases: ["alias"] }), + () => undefined + ) + .add(defineJob({ kind: "first" }), () => undefined); + const client = new Client(driver, { + fetchOnlyKnownKinds, + leaderElectionDisabled: true, + queues: { + default: { + fetchCooldown: { milliseconds: 1 }, + maxWorkers: 1, + pollInterval: { milliseconds: 5 }, + }, + }, + workers, + }); + const run = await client.start(); + // Like Go, kinds registered after the start don't change claims. + workers.add(defineJob({ kind: "later" }), () => undefined); + await waitUntil(() => driver.claimRequests.length >= 2); + await run.stop(); + claims.push(driver.lastClaim!.kinds); + } + + // Like Go, a worker's kind aliases are known kinds too. + expect(claims).toEqual([["alias", "first", "second"], []]); + }); + + it("rejects invalid leaderElectionDisabled and maintenance options", () => { + expect( + () => + new Client(new FakeRuntimeDriver(), { + fetchOnlyKnownKinds: "yes" as unknown as boolean, + }) + ).toThrow(new ValidationError("fetchOnlyKnownKinds must be a boolean")); + expect( + () => + new Client(new FakeRuntimeDriver(), { + leaderElectionDisabled: "yes" as unknown as boolean, + }) + ).toThrow(new ValidationError("leaderElectionDisabled must be a boolean")); + expect( + () => + new Client(new FakeRuntimeDriver(), { + maintenance: false as unknown as MaintenanceOptions, + }) + ).toThrow(new ValidationError("maintenance must be an object")); + }); + + it("rejects periodic jobs on a client with leader election disabled", () => { + const definition = defineJob({ kind: "test" }); + const periodic = periodicJob({ + args: {}, + every: { minutes: 1 }, + job: definition, + }); + + expect( + () => + new Client(new FakeRuntimeDriver(), { + leaderElectionDisabled: true, + periodicJobs: [periodic], + }) + ).toThrow( + new ConfigurationError( + "periodicJobs must be empty when leaderElectionDisabled is true, because this client never leads" + ) + ); + expect( + new Client(new FakeRuntimeDriver(), { + leaderElectionDisabled: true, + periodicJobs: [], + }).periodicJobs.size + ).toBe(0); + }); + + it("rejects periodic job changes on a client with leader election disabled", () => { + const definition = defineJob({ kind: "test" }); + const periodic = periodicJob({ + args: {}, + every: { minutes: 1 }, + id: "disabled", + job: definition, + }); + const enabled = new Client(new FakeRuntimeDriver()); + const handle = enabled.periodicJobs.add(periodic); + const client = new Client(new FakeRuntimeDriver(), { + leaderElectionDisabled: true, + }); + + for (const modify of [ + () => client.periodicJobs.add(periodic), + () => client.periodicJobs.addMany([]), + () => { + client.periodicJobs.clear(); + }, + () => client.periodicJobs.remove(handle), + () => client.periodicJobs.removeById("disabled"), + ]) { + expect(modify).toThrow( + new ConfigurationError( + "cannot modify periodic jobs when leaderElectionDisabled is true, because this client never leads" + ) + ); + } + expect(client.periodicJobs.size).toBe(0); + expect(enabled.periodicJobs.removeById("disabled")).toBe(true); + }); + + it("works jobs without leader election", async () => { + const driver = new FakeRuntimeDriver(); + let leaderAcquisitions = 0; + Object.assign(driver, { + maintenanceLeaderAcquire: () => { + leaderAcquisitions++; + return null; + }, + }); + driver.claim = [fakeJob()]; + const definition = defineJob({ kind: "test" }); + const client = new Client(driver, { + completionBatchSize: 1, + leaderElectionDisabled: true, + // Maintenance settings are accepted but have nothing to apply to. + maintenance: { rescuerInterval: { seconds: 1 } }, + queues: { + default: { + fetchCooldown: { milliseconds: 1 }, + maxWorkers: 1, + pollInterval: { milliseconds: 10 }, + }, + }, + workers: new Workers().add(definition, () => undefined), + }); + const run = await client.start(); + + await waitUntil(() => driver.completions.length === 1); + expect(driver.completions[0]).toMatchObject({ id: 101n, kind: "complete" }); + expect(run.diagnostics.maintenance).toBeNull(); + await run.stop(); + expect(leaderAcquisitions).toBe(0); + expect(driver.notificationSubscriptions).toBe(1); + }); + + it("waits for the notification subscription before start resolves", async () => { + const driver = new FakeRuntimeDriver(); + const gate = Promise.withResolvers(); + driver.notificationReadyGate = gate.promise; + const definition = defineJob({ kind: "test" }); + const client = new Client(driver, { + leaderElectionDisabled: true, + queues: { default: { maxWorkers: 1 } }, + workers: new Workers().add(definition, () => undefined), + }); + let started = false; + const starting = client.start().then((run) => { + started = true; + return run; + }); + + await expect.poll(() => driver.notificationSubscriptions).toBe(1); + expect(started).toBe(false); + gate.resolve(undefined); + const run = await starting; + expect(started).toBe(true); + await run.stop(); + }); + + it("persists resumable progress only when an attempt fails", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = [ + { + ...fakeJob(), + metadata: { "river:resumable_step": "first" }, + }, + ]; + const definition = defineJob({ kind: "test" }); + const order: string[] = []; + const client = new Client(driver, { + completionBatchSize: 1, + leaderElectionDisabled: true, + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 100 } }, + }, + workers: new Workers().add(definition, async ({ resumable }) => { + await resumable.step("first", () => { + order.push("first"); + }); + await resumable.step("second", () => { + order.push("second"); + throw new Error("retry after checkpoint"); + }); + }), + }); + const run = await client.start(); + await waitUntil(() => driver.completions.length === 1); + await run.stop(); + + expect(order).toEqual(["second"]); + expect(driver.completions[0]).toMatchObject({ + kind: "retry", + metadata: { "river:resumable_step": "first" }, + }); + }); + + it("round-trips exact job cursors through the semantic backend", async () => { + const driver = new FakeRuntimeDriver(); + driver.listRows = [fakeJob("test", 9_007_199_254_740_993n)]; + const client = new Client(driver); + + const first = await client.jobs.list({ limit: 1, orderBy: "scheduledAt" }); + expect(first.nextCursor).not.toBeNull(); + await client.jobs.list({ + after: first.nextCursor!, + limit: 1, + orderBy: "scheduledAt", + }); + + expect(driver.lastList?.after).toMatchObject({ + id: 9_007_199_254_740_993n, + sortField: "scheduledAt", + time: Temporal.Instant.from("2026-08-30T12:00:00.123456789Z"), + }); + }); + + it("reports explicit lag on bounded subscriptions", async () => { + const driver = new FakeRuntimeDriver(); + const client = new Client(driver); + const subscription = client.subscribe({ capacity: 1 }); + + await client.queues.pause("one"); + driver.cancelled = fakeJob(); + await client.jobs.cancel(101n); + // queuePause returns null in the fake, but cancellation doesn't emit a + // public event without a matching local runtime. Exercise the hub through + // a queue transition that returns a row. + const now = Temporal.Now.instant(); + driver.queuePause = () => ({ + createdAt: now, + metadata: {}, + name: "one", + pausedAt: now, + updatedAt: now, + }); + driver.queueResume = () => ({ + createdAt: now, + metadata: {}, + name: "one", + pausedAt: null, + updatedAt: now, + }); + await client.queues.pause("one"); + await client.queues.resume("one"); + + const lag = (await subscription.next()).value; + expect(lag).toMatchObject({ dropped: 1, kind: "subscription_lag" }); + expect((await subscription.next()).value.kind).toBe("queue_resumed"); + subscription.close(); + }); +}); + +describe("Client runtime stress", () => { + it("settles racing completions and cancellations exactly once", async () => { + // Bounded, deterministic repetition of the races between a handler + // settling, a remote cancellation, and transient completion failures. + // Every attempt must persist exactly one outcome and emit exactly one + // post-commit event, with no leaked promise. + const iterations = 50; + const jobsPerIteration = 4; + for (let iteration = 0; iteration < iterations; iteration++) { + const driver = new FakeRuntimeDriver(); + const ids = Array.from({ length: jobsPerIteration }, (_, index) => + BigInt(iteration * jobsPerIteration + index + 1) + ); + driver.claim = ids.map((id) => fakeJob("test", id)); + driver.completionFailures = iteration % 7 === 0 ? 2 : 0; + Object.defineProperty(driver, "jobCancel", { + value: (id: bigint) => ({ ...fakeJob("test", id), state: "running" }), + }); + const gates = new Map( + ids.map((id) => [id, Promise.withResolvers()]) + ); + const behavior = (id: bigint) => + (Number(id) + iteration) % 4 === 0 + ? "succeed-after-cancel" + : (Number(id) + iteration) % 4 === 1 + ? "succeed-before-cancel" + : (Number(id) + iteration) % 4 === 2 + ? "stop-on-cancel" + : "fail-while-cancelled"; + const events = new Map(); + const definition = defineJob({ kind: "test" }); + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 2, + completionFlushInterval: { milliseconds: 0 }, + hooks: { + onEvent: (event) => { + if (!("job" in event) || event.kind === "job_started") return; + const seen = events.get(event.job.id) ?? []; + seen.push(event.kind); + events.set(event.job.id, seen); + }, + }, + leaderElectionDisabled: true, + queues: { + default: { + maxWorkers: jobsPerIteration, + pollInterval: { milliseconds: 10_000 }, + }, + }, + workers: new Workers().add(definition, async ({ job, signal }) => { + await gates.get(job.id)?.promise; + switch (behavior(job.id)) { + case "stop-on-cancel": + signal.throwIfAborted(); + return; + case "fail-while-cancelled": + throw new Error("handler failed"); + default: + return; + } + }), + }); + overrideRuntimeTiming(client, { + random: () => 0.5, + timer: new FakeTimer(), + }); + const run = await client.start(); + await waitUntil(() => run.diagnostics.activeAttempts === ids.length); + + for (const id of ids) { + const gate = gates.get(id); + if (behavior(id) === "succeed-before-cancel") { + gate?.resolve(undefined); + await client.jobs.cancel(id); + } else { + await client.jobs.cancel(id); + gate?.resolve(undefined); + } + } + await waitUntil(() => driver.completions.length === ids.length); + await run.stop(); + + expect(run.state).toBe("stopped"); + const kinds = new Map( + driver.completions.map((command) => [command.id, command.kind]) + ); + expect(kinds.size).toBe(ids.length); + for (const id of ids) { + const expected = + behavior(id) === "stop-on-cancel" || + behavior(id) === "fail-while-cancelled" + ? "cancel" + : "complete"; + expect(kinds.get(id), `${iteration}:${id}`).toBe(expected); + expect(events.get(id), `${iteration}:${id}`).toEqual([ + expected === "cancel" ? "job_cancelled" : "job_completed", + ]); + } + } + }); +}); + +describe("Client runtime database faults", () => { + const nonRetryable = () => + new DatabaseOperationError("relation is locked in an unexpected way", { + backend: "fake", + operation: "fake", + }); + const retryable = () => + new DatabaseOperationError("statement timeout", { + backend: "fake", + operation: "fake", + retryable: true, + }); + + function faultClient( + driver: FakeRuntimeDriver, + logs: LogEntry[], + options: Partial = {} + ) { + const timer = new FakeTimer(); + const definition = defineJob({ kind: "test" }); + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + logger: recordingLogger(logs), + leaderElectionDisabled: true, + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }, + workers: new Workers().add(definition, () => undefined), + ...options, + }); + overrideRuntimeTiming(client, { random: () => 0.5, timer }); + return { client, timer }; + } + + it("jitters the queue poll interval by up to a tenth, like Go", async () => { + const driver = new FakeRuntimeDriver(); + const logs: LogEntry[] = []; + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); + const { client } = faultClient(driver, logs, { + queues: { + default: { + fetchCooldown: { milliseconds: 1 }, + maxWorkers: 1, + pollInterval: { milliseconds: 50 }, + }, + other: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }, + }); + const run = await client.start(); + await waitUntil(() => driver.claimRequests.length >= 2); + await run.stop(); + const delays = setTimeoutSpy.mock.calls.map(([, delay]) => delay); + setTimeoutSpy.mockRestore(); + + // With random() at 0.5: half of the 10 ms minimum, and half of a tenth. + expect(delays).toContain(55); + expect(delays).toContain(10_500); + }); + + it("backs off failed claims and keeps working", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = [fakeJob()]; + driver.claimFaults.push(nonRetryable(), retryable(), new Error("decode")); + const logs: LogEntry[] = []; + const { client, timer } = faultClient(driver, logs); + const run = await client.start(); + + await waitUntil(() => driver.completions.length === 1); + await run.stop(); + + expect(run.state).toBe("stopped"); + expect(timer.delays).toEqual([250, 500, 1_000]); + expect( + logs.map(({ attributes, level }) => [ + level, + attributes?.queue, + attributes?.retryable, + ]) + ).toEqual([ + ["error", "default", false], + ["warn", "default", true], + ["error", "default", false], + ]); + }); + + it("fails the runtime when claims reveal a configuration error", async () => { + const driver = new FakeRuntimeDriver(); + driver.claimFaults.push(new ConfigurationError("pool is too small")); + const { client } = faultClient(driver, []); + const run = await client.start(); + + await expect(run.completed).rejects.toThrow("River background task failed"); + expect(run.state).toBe("failed"); + await expect(run.stop()).rejects.toThrow("River background task failed"); + }); + + it("keeps polling queue controls through database failures", async () => { + const driver = new FakeRuntimeDriver(); + const logs: LogEntry[] = []; + const events: string[] = []; + const { client, timer } = faultClient(driver, logs, { + hooks: { onEvent: ({ kind }) => void events.push(kind) }, + queueControlPollInterval: { milliseconds: 1 }, + }); + const run = await client.start(); + await waitUntil(() => driver.queueGetCalls > 0); + + driver.queueGetFaults.push(nonRetryable(), retryable()); + const queue = driver.queues.get("default"); + if (queue === undefined) throw new Error("queue was not persisted"); + driver.queues.set("default", { + ...queue, + pausedAt: Temporal.Now.instant(), + }); + + await waitUntil(() => events.includes("queue_paused")); + expect(run.diagnostics.queues.default?.paused).toBe(true); + expect(run.state).toBe("running"); + expect(timer.delays).toEqual([250, 500]); + expect(logs.map(({ message }) => message)).toEqual([ + "River queue control poll failed; retrying after backoff", + "River queue control poll failed; retrying after backoff", + ]); + await run.stop(); + }); + + it("cancels a job whose cancellation arrived while its claim was in flight", async () => { + const driver = new FakeRuntimeDriver(); + driver.claim = [fakeJob()]; + const release = Promise.withResolvers(); + driver.claimGate = release.promise; + const handled = Promise.withResolvers(); + driver.notificationStream = (_topics, signal, ready) => ({ + [Symbol.asyncIterator]: () => { + ready(); + let sent = false; + return { + next: async (): Promise> => { + if (!sent) { + // Once the claim took the job, and before it returns. + await waitUntil(() => driver.claimed.length === 1); + sent = true; + return { + done: false, + value: { + payload: '{"action":"cancel","job_id":101,"queue":"default"}', + topic: "control", + }, + }; + } + // The pump asks for more only after handling the cancellation. + handled.resolve(undefined); + await new Promise((resolve) => + signal.addEventListener("abort", () => resolve(), { once: true }) + ); + return { done: true, value: undefined }; + }, + }; + }, + }); + // Like River for Go, the worker starts with its cancellation applied. + let startedCancelled: boolean | undefined; + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }, + workers: new Workers().add(defineJob({ kind: "test" }), ({ signal }) => { + startedCancelled = signal.aborted; + signal.throwIfAborted(); + }), + }); + const run = await client.start(); + + await handled.promise; + release.resolve(undefined); + await waitUntil(() => driver.completions.length === 1); + await run.stop(); + + expect(startedCancelled).toBe(true); + expect(driver.completions[0]).toMatchObject({ id: 101n, kind: "cancel" }); + }); + + it("fails to start before claiming when the notification stream can't subscribe", async () => { + const driver = new FakeRuntimeDriver(); + const failure = new DatabaseOperationError("LISTEN is not supported", { + backend: "fake", + operation: "listen", + retryable: true, + }); + driver.notificationStream = () => ({ + [Symbol.asyncIterator]: () => ({ + next: () => Promise.reject(failure), + }), + }); + driver.claim = [fakeJob()]; + const logs: LogEntry[] = []; + const { client } = faultClient(driver, logs); + + await expect(client.start()).rejects.toBe(failure); + expect(driver.claimRequests).toEqual([]); + expect(driver.completions).toEqual([]); + // The completer did nothing wrong, so it reports no failure of its own. + expect(logs.map(({ message }) => message)).not.toContain( + "River completer failed while stopping" + ); + }); + + it("resubscribes a failed notification stream and polls for missed work", async () => { + const driver = new FakeRuntimeDriver(); + const listenerLost = Promise.withResolvers(); + let subscriptions = 0; + // Each subscription yields nothing: the first fails once the listener is + // lost, the second ends unexpectedly, and the third lasts until shutdown. + driver.notificationStream = (_topics, signal, ready) => ({ + [Symbol.asyncIterator]: () => { + subscriptions++; + const subscription = subscriptions; + ready(); + return { + next: async (): Promise> => { + if (subscription === 1) { + await listenerLost.promise; + throw new DatabaseOperationError("listener connection lost", { + backend: "fake", + operation: "listen", + retryable: true, + }); + } + if (subscription > 2) { + await new Promise((resolve) => + signal.addEventListener("abort", () => resolve(), { + once: true, + }) + ); + } + return { done: true, value: undefined }; + }, + }; + }, + }); + const logs: LogEntry[] = []; + const { client, timer } = faultClient(driver, logs); + const run = await client.start(); + await waitUntil(() => driver.claimRequests.length === 1); + + // This job's insert notification was lost with the listener. With a 10 + // second poll interval it is claimed promptly only because every + // successful resubscription polls. + driver.claim = [fakeJob()]; + listenerLost.resolve(undefined); + + await waitUntil( + () => driver.completions.length === 1 && subscriptions === 3 + ); + expect(run.state).toBe("running"); + expect(timer.delays).toEqual([250, 250]); + expect(logs.map(({ level, message }) => [level, message])).toEqual([ + [ + "warn", + "River runtime notification stream failed; retrying after backoff", + ], + [ + "error", + "River runtime notification stream failed; retrying after backoff", + ], + ]); + await run.stop(); + expect(run.state).toBe("stopped"); + }); + + it("resubscribes a failed remote cancellation stream", async () => { + const driver = new FakeRuntimeDriver(); + Object.defineProperty(driver, "runtimeNotificationSubscribe", { + value: undefined, + }); + let subscriptions = 0; + let handlerSignal: AbortSignal | undefined; + const handlerStarted = Promise.withResolvers(); + Object.defineProperty(driver, "jobCancellationSubscribe", { + value: async function* (attemptedBy: string, signal: AbortSignal) { + subscriptions++; + if (subscriptions === 1) throw new Error("listener lost"); + await handlerStarted.promise; + yield { attemptedBy, id: 101n }; + await new Promise((resolve) => + signal.addEventListener("abort", () => resolve(), { once: true }) + ); + }, + }); + driver.claim = [fakeJob()]; + const logs: LogEntry[] = []; + const timer = new FakeTimer(); + const definition = defineJob({ kind: "test" }); + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + logger: recordingLogger(logs), + leaderElectionDisabled: true, + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }, + workers: new Workers().add(definition, async ({ signal }) => { + handlerSignal = signal; + handlerStarted.resolve(undefined); + await new Promise((resolve) => + signal.addEventListener("abort", () => resolve(), { once: true }) + ); + signal.throwIfAborted(); + }), + }); + overrideRuntimeTiming(client, { random: () => 0.5, timer }); + const run = await client.start(); + + await waitUntil(() => driver.completions.length === 1); + expect(handlerSignal?.reason).toMatchObject({ name: "JobCancelledError" }); + expect(driver.completions[0]?.kind).toBe("cancel"); + expect(subscriptions).toBe(2); + expect(logs).toHaveLength(1); + await run.stop(); + }); + + for (const [stream, read] of [ + ["remote cancellation", "each row"], + ["runtime notification", "each row"], + ["runtime notification", "batched cancellation requests"], + ] as const) { + it(`cancels an attempt whose notice was lost while the ${stream} stream reconnected, reading ${read}`, async () => { + const driver = new FakeRuntimeDriver(); + if (read === "batched cancellation requests") { + Object.assign(driver, { + jobGet: () => { + throw new Error("recovery reads cancellation requests in batches"); + }, + jobGetCancelRequested: (ids: readonly bigint[]) => + ids.filter((id) => + driver.listRows.some( + (row) => + row.id === id && + row.state === "running" && + row.metadata.cancel_attempted_at !== undefined + ) + ), + }); + } + let subscriptions = 0; + const handlerStarted = Promise.withResolvers(); + // The first subscription fails once the job runs; the job is cancelled + // before the second one connects, so its notice never arrives. + const failThenIdle = async (signal: AbortSignal) => { + subscriptions++; + if (subscriptions === 1) { + await handlerStarted.promise; + driver.listRows = driver.claimed.map((job) => ({ + ...job, + metadata: { + ...job.metadata, + cancel_attempted_at: "2026-01-02T03:04:05Z", + }, + })); + throw new Error("listener lost"); + } + await new Promise((resolve) => + signal.addEventListener("abort", () => resolve(), { once: true }) + ); + }; + if (stream === "remote cancellation") { + Object.defineProperty(driver, "runtimeNotificationSubscribe", { + value: undefined, + }); + Object.defineProperty(driver, "jobCancellationSubscribe", { + // eslint-disable-next-line require-yield -- never yields a notice + value: async function* ( + _attemptedBy: string, + signal: AbortSignal, + ready: () => void + ) { + ready(); + await failThenIdle(signal); + }, + }); + } else { + driver.notificationStream = (_topics, signal, ready) => ({ + [Symbol.asyncIterator]: () => { + ready(); + return { + next: async (): Promise> => { + await failThenIdle(signal); + return { done: true, value: undefined }; + }, + }; + }, + }); + } + driver.claim = [fakeJob()]; + let handlerSignal: AbortSignal | undefined; + const timer = new FakeTimer(); + const definition = defineJob({ kind: "test" }); + const client = new Client(driver, { + clientId: "runtime-test", + completionBatchSize: 1, + leaderElectionDisabled: true, + logger: recordingLogger([]), + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }, + workers: new Workers().add(definition, async ({ signal }) => { + handlerSignal = signal; + handlerStarted.resolve(undefined); + await new Promise((resolve) => + signal.addEventListener("abort", () => resolve(), { once: true }) + ); + signal.throwIfAborted(); + }), + }); + overrideRuntimeTiming(client, { random: () => 0.5, timer }); + const run = await client.start(); + + await waitUntil(() => driver.completions.length === 1); + expect(handlerSignal?.reason).toMatchObject({ + name: "JobCancelledError", + }); + expect(driver.completions[0]?.kind).toBe("cancel"); + expect(subscriptions).toBe(2); + await run.stop(); + }); + } + + it("stops without waiting for a database connection during an outage", async () => { + const driver = new FakeRuntimeDriver(); + // Every operation waits for a pool connection that never comes. A claim + // that honors its signal stops waiting when the runtime stops. + const outage = new Promise(() => undefined); + const claimSignals: (AbortSignal | undefined)[] = []; + Object.defineProperty(driver, "jobClaim", { + value: ( + _params: JobClaimParams, + options?: { readonly signal?: AbortSignal } + ) => { + claimSignals.push(options?.signal); + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener( + "abort", + () => reject(options.signal?.reason), + { once: true } + ); + }); + }, + }); + const { client } = faultClient(driver, [], { + queueControlPollInterval: { milliseconds: 1 }, + }); + const run = await client.start(); + await waitUntil(() => claimSignals.length === 1); + let hungReads = 0; + const hang = () => { + hungReads++; + return outage; + }; + Object.defineProperty(driver, "queueGet", { value: hang }); + // A heartbeat only stops waiting for its connection. + Object.defineProperty(driver, "runtimeQueueUpsert", { + value: ( + _name: string, + _now: Temporal.Instant, + options?: { readonly signal?: AbortSignal } + ) => + new Promise((_resolve, reject) => { + hungReads++; + options?.signal?.addEventListener( + "abort", + () => reject(options.signal?.reason), + { once: true } + ); + }), + }); + await waitUntil(() => hungReads === 1); + + await run.stop({ timeout: { seconds: 2 } }); + + expect(run.state).toBe("stopped"); + expect(claimSignals[0]?.aborted).toBe(true); + }); + + it("isolates a hung claim to its own queue", async () => { + const driver = new FakeRuntimeDriver(); + const hung = Promise.withResolvers(); + const claim = driver.jobClaim.bind(driver); + Object.defineProperty(driver, "jobClaim", { + value: (params: JobClaimParams) => + params.queues[0]?.name === "stuck" ? hung.promise : claim(params), + }); + driver.claim = [fakeJob()]; + const { client } = faultClient(driver, [], { + queues: { + default: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + stuck: { maxWorkers: 1, pollInterval: { milliseconds: 10_000 } }, + }, + }); + const run = await client.start(); + + await waitUntil(() => driver.completions.length === 1); + await expect(run.stop({ timeout: { milliseconds: 20 } })).rejects.toThrow( + "River runtime stop timed out" + ); + hung.resolve({ jobs: [] }); + await run.completed; + expect(run.state).toBe("stopped"); + }); +}); + +async function waitUntil(predicate: () => boolean): Promise { + const deadline = Date.now() + 2_000; + while (!predicate()) { + if (Date.now() > deadline) + throw new Error("timed out waiting for condition"); + await new Promise((resolve) => setTimeout(resolve, 1)); + } +} diff --git a/js/src/runtime/completion-command.test.ts b/js/src/runtime/completion-command.test.ts new file mode 100644 index 000000000..fb95ded76 --- /dev/null +++ b/js/src/runtime/completion-command.test.ts @@ -0,0 +1,83 @@ +import { readFile } from "node:fs/promises"; + +import { describe, expect, it } from "vitest"; + +import type { JobRow } from "../job.js"; +import { + isExactJsonNumber, + parseJson, + type JsonObject, + type JsonValue, +} from "../json.js"; +import { snooze } from "../worker.js"; +import { completionCommand } from "./completion-command.js"; + +interface SnoozeCounterCase { + readonly expected_snoozes: JsonValue; + readonly metadata: JsonObject; + readonly name: string; +} + +/** River Go's snooze counter goldens, recorded from its executor. */ +const GOLDENS = new URL("../testdata/snooze-counters.json", import.meta.url); + +describe("completionCommand", () => { + it("counts snoozes like River for Go's executor", async () => { + // Parse with River's exact JSON so integers beyond 2^53 stay exact. + const golden = parseJson(await readFile(GOLDENS, "utf8")) as unknown as { + readonly snooze_counters: readonly SnoozeCounterCase[]; + }; + const now = Temporal.Instant.from("2026-09-01T00:00:00Z"); + + const results = golden.snooze_counters.map(({ metadata, name }) => { + const command = completionCommand( + job(metadata), + "client", + { outcome: snooze({ seconds: 30 }), status: "succeeded" }, + now, + now, + now, + 5_000 + ); + return [name, numberText(command.metadata?.snoozes)]; + }); + + expect(results).toEqual( + golden.snooze_counters.map(({ expected_snoozes, name }) => [ + name, + numberText(expected_snoozes), + ]) + ); + }); +}); + +function job(metadata: JsonObject): JobRow { + const at = Temporal.Instant.from("2026-09-01T00:00:00Z"); + return { + args: {}, + attempt: 1, + attemptedAt: at, + attemptedBy: ["client"], + createdAt: at, + errors: [], + finalizedAt: null, + id: 1n, + kind: "snooze", + maxAttempts: 25, + metadata, + priority: 1, + queue: "default", + scheduledAt: at, + state: "running", + tags: [], + uniqueKey: null, + uniqueStates: null, + }; +} + +/** A JSON number's exact text, so ordinary and exact numbers compare. */ +function numberText(value: JsonValue | undefined): string { + if (typeof value === "number") return String(value); + if (isExactJsonNumber(value)) return value.rawJSON; + throw new Error(`not a JSON number: ${JSON.stringify(value)}`); +} diff --git a/js/src/testdata/snooze-counters.json b/js/src/testdata/snooze-counters.json new file mode 100644 index 000000000..3c9b5439a --- /dev/null +++ b/js/src/testdata/snooze-counters.json @@ -0,0 +1,117 @@ +{ + "$comment": "River Go's snooze counter goldens, recorded from its executor's rule in internal/jobexecutor/job_executor.go, which reads each count with gjson. They match the Rust port's rust/riverqueue/tests/fixtures/maintenance_values.json.", + "snooze_counters": [ + { + "expected_snoozes": 1, + "metadata": {}, + "name": "absent" + }, + { + "expected_snoozes": 3, + "metadata": { + "snoozes": 2 + }, + "name": "integer" + }, + { + "expected_snoozes": 3, + "metadata": { + "snoozes": 2.9 + }, + "name": "fraction_truncates" + }, + { + "expected_snoozes": -1, + "metadata": { + "snoozes": -2.5 + }, + "name": "negative_fraction_truncates_toward_zero" + }, + { + "expected_snoozes": 1001, + "metadata": { + "snoozes": 1000.0 + }, + "name": "exponent" + }, + { + "expected_snoozes": 9007199254740994, + "metadata": { + "snoozes": 9007199254740993 + }, + "name": "beyond_float_precision" + }, + { + "expected_snoozes": 5, + "metadata": { + "snoozes": "4" + }, + "name": "numeric_string" + }, + { + "expected_snoozes": -6, + "metadata": { + "snoozes": "-7" + }, + "name": "negative_numeric_string" + }, + { + "expected_snoozes": 1, + "metadata": { + "snoozes": "4.5" + }, + "name": "fractional_string_is_zero" + }, + { + "expected_snoozes": 1, + "metadata": { + "snoozes": " 5" + }, + "name": "padded_string_is_zero" + }, + { + "expected_snoozes": 1, + "metadata": { + "snoozes": "abc" + }, + "name": "non_numeric_string_is_zero" + }, + { + "expected_snoozes": 2, + "metadata": { + "snoozes": true + }, + "name": "true_is_one" + }, + { + "expected_snoozes": 1, + "metadata": { + "snoozes": false + }, + "name": "false_is_zero" + }, + { + "expected_snoozes": 1, + "metadata": { + "snoozes": null + }, + "name": "null_is_zero" + }, + { + "expected_snoozes": 1, + "metadata": { + "snoozes": [3] + }, + "name": "array_is_zero" + }, + { + "expected_snoozes": 1, + "metadata": { + "snoozes": { + "count": 3 + } + }, + "name": "object_is_zero" + } + ] +} diff --git a/js/src/worker.test.ts b/js/src/worker.test.ts new file mode 100644 index 000000000..0dce833f3 --- /dev/null +++ b/js/src/worker.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, expectTypeOf, it } from "vitest"; + +import { defineJob, type JobDefinition } from "./job-definition.js"; +import { createJobArgsTransformPlugin } from "./job-args-transform.js"; +import type { JsonObject } from "./json.js"; +import { + cancel, + complete, + discard, + snooze, + workerRegistration, + Workers, + type WorkContext, + type WorkHandlerFactory, +} from "./worker.js"; + +describe("Workers", () => { + it("types completeTx with the declared transaction", () => { + const definition = defineJob({ kind: "typed_tx" }); + new Workers<{ readonly name: string }>().add( + definition, + async ({ client, completeTx }) => { + await completeTx({ name: "application transaction" }); + await client.insert(definition, {}, { tx: { name: "same" } }); + // @ts-expect-error -- narrowed to the declared transaction type. + await completeTx({ other: true }); + } + ); + }); + + it("infers validated handler args and stores immutable policy", () => { + const definition = defineJob({ + decode(value) { + if (typeof value.message !== "string") { + throw new TypeError("message must be a string"); + } + return { message: value.message, validated: true as const }; + }, + kind: "email", + }); + const workers = new Workers(); + + workers.add( + definition, + ({ job, signal }) => { + expectTypeOf(job.args).toEqualTypeOf<{ + message: string; + validated: true; + }>(); + expectTypeOf(job.rawArgs).toEqualTypeOf(); + expectTypeOf(signal).toEqualTypeOf(); + }, + { timeout: { milliseconds: 1_000 } } + ); + + expect(workers.kinds()).toEqual(["email"]); + expect( + workerRegistration(workers, "email")?.options.timeout?.total("seconds") + ).toBe(1); + expect(Object.isFrozen(workerRegistration(workers, "email")?.options)).toBe( + true + ); + }); + + it("registers a handler a factory builds for the definition", () => { + const definition = defineJob({ + decode(value) { + if (typeof value.message !== "string") { + throw new TypeError("message must be a string"); + } + return { message: value.message }; + }, + kind: "factory", + }); + // An integration that needs the definition its handler is registered + // with, typed from that definition at the call site. + const seen: unknown[] = []; + function integrationWorker( + handle: (context: WorkContext, kind: string) => void + ): WorkHandlerFactory { + return { + createWorkHandler(registered) { + seen.push(registered); + return (context) => { + handle(context, registered.kind); + }; + }, + }; + } + const workers = new Workers(); + + workers.add( + definition, + integrationWorker((context, kind) => { + expectTypeOf(context.job.args).toEqualTypeOf<{ message: string }>(); + expectTypeOf(kind).toEqualTypeOf(); + }) + ); + + expect(seen).toEqual([definition]); + const registration = workerRegistration(workers, "factory"); + expect(registration?.type).toBe("in_process"); + expect( + registration?.type === "in_process" && typeof registration.handler + ).toBe("function"); + expect(() => + new Workers().add(definition, { + createWorkHandler: () => "not a handler", + } as never) + ).toThrow("worker handler must be a function"); + expect("get" in workers).toBe(false); + }); + + it("registers a worker under its definition's kind aliases, like Go", () => { + const renamed = defineJob({ + kind: "new_name", + kindAliases: ["old_name"], + }); + const workers = new Workers().add(renamed, () => undefined); + + expect(workers.kinds()).toEqual(["new_name", "old_name"]); + expect(workerRegistration(workers, "old_name")).toBe( + workerRegistration(workers, "new_name") + ); + expect(workerRegistration(workers, "old_name")?.definition).toBe(renamed); + // An alias can't take a kind another worker already has, or the reverse. + expect(() => + workers.add(defineJob({ kind: "old_name" }), () => undefined) + ).toThrow('worker already registered for job kind "old_name"'); + expect(() => + new Workers() + .add(defineJob({ kind: "old_name" }), () => undefined) + .add(renamed, () => undefined) + ).toThrow('worker already registered for job kind "old_name"'); + expect(() => defineJob({ kind: "same", kindAliases: ["same"] })).toThrow( + 'job kind alias "same" repeats a kind of the same job' + ); + expect(() => defineJob({ kind: "bad_alias", kindAliases: ["x"] })).toThrow( + "job kind must be at least 2 characters" + ); + expect(defineJob({ kind: "plain" }).kindAliases).toEqual([]); + }); + + it("rejects duplicates and invalid timeouts", () => { + const definition = defineJob({ kind: "duplicate" }); + const workers = new Workers().add(definition, () => undefined); + + expect(() => workers.add(definition, () => undefined)).toThrow( + "worker already registered" + ); + expect(() => + new Workers().add(definition, () => undefined, { + timeout: { milliseconds: 0 }, + }) + ).toThrow("worker timeout must be positive"); + expect(() => + new Workers().add(definition, () => undefined, { + // @ts-expect-error -- bare numbers are ambiguous and rejected. + timeout: 1_000, + }) + ).toThrow("not a bare number"); + expect( + workerRegistration( + new Workers().add(defineJob({ kind: "no_timeout" }), () => undefined, { + timeout: null, + }), + "no_timeout" + )?.options.timeout + ).toBeNull(); + expect(() => + new Workers().add(definition, () => undefined, { + plugins: [ + createJobArgsTransformPlugin({ + name: "wrong-scope", + onRead: ({ args }) => args, + }), + ], + }) + ).toThrow("must be configured on Client"); + }); +}); + +describe("work outcomes", () => { + it("constructs closed immutable discriminated values", () => { + expect(cancel()).toEqual({ type: "cancel" }); + expect(cancel({ reason: "account closed" })).toEqual({ + reason: "account closed", + type: "cancel", + }); + expect(Object.isFrozen(cancel({ reason: "account closed" }))).toBe(true); + expect(complete()).toEqual({ type: "complete" }); + expect(complete({ output: { id: "provider-id" } })).toEqual({ + output: { id: "provider-id" }, + type: "complete", + }); + expect(discard({ reason: "not found" })).toEqual({ + reason: "not found", + type: "discard", + }); + const snoozed = (duration: Temporal.DurationLike) => + snooze(duration).duration.total("milliseconds"); + expect(snooze({ seconds: 30 })).toEqual({ + duration: Temporal.Duration.from({ seconds: 30 }), + type: "snooze", + }); + expect(snoozed({ milliseconds: 1 })).toBe(1); + expect(snoozed({ minutes: 1, seconds: 30 })).toBe(90_000); + expect(snoozed(Temporal.Duration.from({ hours: 1 }))).toBe(3_600_000); + expect(snoozed({ seconds: 0 })).toBe(0); + // Like the runtime, a snooze rounds up to whole milliseconds. + expect(snoozed({ microseconds: 1 })).toBe(1); + expect(Object.isFrozen(snooze({ seconds: 30 }))).toBe(true); + }); + + it("validates outcome values", () => { + expect(() => snooze({ seconds: -1 })).toThrow("must not be negative"); + expect(() => snooze({ months: 1 })).toThrow("calendar units"); + // @ts-expect-error -- bare numbers are ambiguous and rejected. + expect(() => snooze(1_000)).toThrow("not a bare number"); + expect(() => discard({ reason: "" })).toThrow("must not be empty"); + expect(() => cancel({ reason: "" })).toThrow("non-empty string"); + }); +}); From 49aa5e1d19e61c7951b839d3111e1d6fa72ee51a Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sun, 4 Oct 2026 17:18:02 -0500 Subject: [PATCH 24/43] test leadership, maintenance services, and periodic jobs Cover leader election and term renewal, including bidding within 50 ms of another client's resignation and ending a term at its local deadline while renewal hangs; the scheduler, rescuer, and cleaners with their batch bounds, Go-compatible promotion horizons, and cancellation with the leadership term; reindexing on its schedule; and the periodic job enqueuer with durable records, hooks, and dropped failures. A property test checks that the periodic registry advances like Go's periodic job enqueuer under any order of commands. --- js/src/periodic.property.test.ts | 309 +++++++ js/src/periodic.test.ts | 287 +++++++ js/src/services.test.ts | 1313 ++++++++++++++++++++++++++++++ 3 files changed, 1909 insertions(+) create mode 100644 js/src/periodic.property.test.ts create mode 100644 js/src/periodic.test.ts create mode 100644 js/src/services.test.ts diff --git a/js/src/periodic.property.test.ts b/js/src/periodic.property.test.ts new file mode 100644 index 000000000..49da16d80 --- /dev/null +++ b/js/src/periodic.property.test.ts @@ -0,0 +1,309 @@ +import fc from "fast-check"; +import { describe, expect, it } from "vitest"; + +import { defineJob } from "./job-definition.js"; +import { + advancePeriodicJobs, + hasUninitializedPeriodicJobs, + nextPeriodicRunAt, + periodicJob, + periodicJobIds, + PeriodicJobs, + resetPeriodicJobs, + setPeriodicJobsChangeHandler, +} from "./periodic.js"; +import type { PeriodicJob, PeriodicJobHandle } from "./periodic.js"; + +const report = defineJob<{ scope: string }>()({ kind: "periodic_property" }); + +/** Go River's margin for inserting occurrences due in the near future. */ +const DUE_MARGIN_NS = 100_000_000n; +const MILLISECOND_NS = 1_000_000n; + +interface ModelEntry { + readonly durableSeedNs: bigint | undefined; + readonly everyNs: bigint; + readonly handle: PeriodicJobHandle; + readonly id: string | null; + initialized: boolean; + readonly job: PeriodicJob; + nextRunNs: bigint | null; + readonly runOnStart: boolean; +} + +/** + * A straightforward model of Go River's periodic job enqueuer: a new or + * reset entry is initialized on the next tick (from a durable next run when + * one is known) and inserts once if it runs on start; afterwards each tick + * inserts at most one occurrence per due entry and advances it by exactly + * one interval from its scheduled time. + */ +interface Model { + changes: number; + entries: ModelEntry[]; + nextId: number; + nowNs: bigint; +} + +interface Real { + changes: number; + readonly registry: PeriodicJobs; +} + +type PeriodicCommand = fc.Command; + +/** Registry queries agree with the model after every command. */ +function assertQueries(model: Readonly, real: Real): void { + const { registry } = real; + expect(registry.size).toBe(model.entries.length); + expect(real.changes).toBe(model.changes); + expect(hasUninitializedPeriodicJobs(registry)).toBe( + model.entries.some((entry) => !entry.initialized) + ); + expect(periodicJobIds(registry)).toEqual( + model.entries.flatMap((entry) => (entry.id === null ? [] : [entry.id])) + ); + const scheduled = model.entries + .map((entry) => entry.nextRunNs) + .filter((next): next is bigint => next !== null); + expect(nextPeriodicRunAt(registry)?.epochNanoseconds).toBe( + scheduled.length === 0 + ? undefined + : scheduled.reduce((min, next) => (next < min ? next : min)) + ); +} + +class AddCommand implements PeriodicCommand { + constructor( + readonly everyMs: number, + readonly runOnStart: boolean, + readonly withId: boolean, + readonly durableSeedMs: number | undefined + ) {} + + check(): boolean { + return true; + } + + run(model: Model, real: Real): void { + const id = this.withId ? `job_${model.nextId++}` : null; + const job = periodicJob({ + args: { scope: "all" }, + every: { milliseconds: this.everyMs }, + job: report, + runOnStart: this.runOnStart, + ...(id === null ? {} : { id }), + }); + const handle = real.registry.add(job); + model.changes++; + model.entries.push({ + durableSeedNs: + id === null || this.durableSeedMs === undefined + ? undefined + : model.nowNs + BigInt(this.durableSeedMs) * MILLISECOND_NS, + everyNs: BigInt(this.everyMs) * MILLISECOND_NS, + handle, + id, + initialized: false, + job, + nextRunNs: null, + runOnStart: this.runOnStart, + }); + assertQueries(model, real); + } + + toString(): string { + return `add(every=${this.everyMs}ms, runOnStart=${this.runOnStart}, id=${this.withId}, durable=${this.durableSeedMs})`; + } +} + +class AdvanceCommand implements PeriodicCommand { + constructor(readonly elapsedMs: number) {} + + check(): boolean { + return true; + } + + run(model: Model, real: Real): void { + model.nowNs += BigInt(this.elapsedMs) * MILLISECOND_NS; + const now = Temporal.Instant.fromEpochNanoseconds(model.nowNs); + const durable = new Map(); + for (const entry of model.entries) { + if ( + !entry.initialized && + entry.id !== null && + entry.durableSeedNs !== undefined + ) { + durable.set( + entry.id, + Temporal.Instant.fromEpochNanoseconds(entry.durableSeedNs) + ); + } + } + const errors: unknown[] = []; + const batch = advancePeriodicJobs(real.registry, now, durable, (_, error) => + errors.push(error) + ); + + const expectedOccurrences: [PeriodicJob, bigint][] = []; + const expectedUpdates: [string, bigint][] = []; + for (const entry of model.entries) { + if (!entry.initialized) { + entry.initialized = true; + entry.nextRunNs = entry.durableSeedNs ?? model.nowNs + entry.everyNs; + if (entry.id !== null) + expectedUpdates.push([entry.id, entry.nextRunNs]); + if (entry.runOnStart) + expectedOccurrences.push([entry.job, model.nowNs]); + continue; + } + if ( + entry.nextRunNs === null || + entry.nextRunNs >= model.nowNs + DUE_MARGIN_NS + ) { + continue; + } + expectedOccurrences.push([entry.job, entry.nextRunNs]); + entry.nextRunNs += entry.everyNs; + if (entry.id !== null) expectedUpdates.push([entry.id, entry.nextRunNs]); + } + + expect(errors).toEqual([]); + expect( + batch.occurrences.map(({ job, scheduledAt }) => [ + job, + scheduledAt.epochNanoseconds, + ]) + ).toEqual(expectedOccurrences); + expect( + batch.durableUpdates.map(({ id, nextRunAt }) => [ + id, + nextRunAt.epochNanoseconds, + ]) + ).toEqual(expectedUpdates); + // Every durable seed is consumed by the entry it initialized. + expect([...durable.keys()]).toEqual([]); + assertQueries(model, real); + } + + toString(): string { + return `advance(${this.elapsedMs}ms)`; + } +} + +class ClearCommand implements PeriodicCommand { + check(): boolean { + return true; + } + + run(model: Model, real: Real): void { + real.registry.clear(); + model.entries = []; + model.changes++; + assertQueries(model, real); + } + + toString(): string { + return "clear()"; + } +} + +class RemoveCommand implements PeriodicCommand { + constructor( + readonly position: number, + readonly byId: boolean + ) {} + + check(model: Readonly): boolean { + return model.entries.length > 0; + } + + run(model: Model, real: Real): void { + const index = this.position % model.entries.length; + const [entry] = model.entries.splice(index, 1); + if (entry === undefined) throw new Error("model entry missing"); + if (this.byId && entry.id !== null) { + expect(real.registry.removeById(entry.id)).toBe(true); + expect(real.registry.removeById(entry.id)).toBe(false); + } else { + expect(real.registry.remove(entry.handle)).toBe(true); + expect(real.registry.remove(entry.handle)).toBe(false); + } + model.changes++; + assertQueries(model, real); + } + + toString(): string { + return `remove(${this.position}, byId=${this.byId})`; + } +} + +/** Leadership changes forget scheduling state but keep registrations. */ +class ResetCommand implements PeriodicCommand { + check(): boolean { + return true; + } + + run(model: Model, real: Real): void { + resetPeriodicJobs(real.registry); + for (const entry of model.entries) { + entry.initialized = false; + entry.nextRunNs = null; + } + assertQueries(model, real); + } + + toString(): string { + return "reset()"; + } +} + +const commandsArbitrary = fc.commands( + [ + fc + .tuple( + fc.integer({ max: 5_000, min: 1 }), + fc.boolean(), + fc.boolean(), + fc.option(fc.integer({ max: 5_000, min: -5_000 }), { nil: undefined }) + ) + .map( + ([everyMs, runOnStart, withId, durableSeedMs]) => + new AddCommand(everyMs, runOnStart, withId, durableSeedMs) + ), + fc + .oneof( + fc.integer({ max: 150, min: 0 }), + fc.integer({ max: 20_000, min: 0 }) + ) + .map((elapsedMs) => new AdvanceCommand(elapsedMs)), + fc + .tuple(fc.nat(), fc.boolean()) + .map(([position, byId]) => new RemoveCommand(position, byId)), + fc.constant(new ClearCommand()), + fc.constant(new ResetCommand()), + ], + { maxCommands: 60 } +); + +describe("periodic registry properties", () => { + it("advances like Go's periodic job enqueuer under any command order", () => { + fc.assert( + fc.property(commandsArbitrary, (commands) => { + const registry = new PeriodicJobs(); + const real: Real = { changes: 0, registry }; + setPeriodicJobsChangeHandler(registry, () => { + real.changes++; + }); + const model: Model = { + changes: 0, + entries: [], + nextId: 0, + nowNs: Temporal.Instant.from("2026-09-01T00:00:00Z").epochNanoseconds, + }; + fc.modelRun(() => ({ model, real }), commands); + }), + { numRuns: 200 } + ); + }); +}); diff --git a/js/src/periodic.test.ts b/js/src/periodic.test.ts new file mode 100644 index 000000000..1f5f40538 --- /dev/null +++ b/js/src/periodic.test.ts @@ -0,0 +1,287 @@ +import { describe, expect, it } from "vitest"; + +import { defineJob } from "./job-definition.js"; +import { + advancePeriodicJobs, + buildPeriodicInsert, + nextPeriodicRunAt, + periodicJob, + periodicJobIds, + PeriodicJobs, + resetPeriodicJobs, + setPeriodicJobsChangeHandler, + type PeriodicJob, +} from "./periodic.js"; + +const report = defineJob<{ scope: string }>()({ kind: "periodic_report" }); +const start = Temporal.Instant.from("2026-08-30T12:00:00Z"); + +function advance( + jobs: PeriodicJobs, + now: Temporal.Instant, + durable = new Map() +) { + const errors: [PeriodicJob, unknown][] = []; + const batch = advancePeriodicJobs(jobs, now, durable, (job, error) => + errors.push([job, error]) + ); + return { ...batch, errors }; +} + +describe("periodicJob", () => { + it("validates its configuration", () => { + expect(() => + periodicJob({ args: { scope: "a" }, every: { hours: 1 }, job: report }) + ).not.toThrow(); + expect(() => + // @ts-expect-error -- every and schedule are mutually exclusive. + periodicJob({ + args: { scope: "a" }, + every: { hours: 1 }, + job: report, + schedule: { next: () => null }, + }) + ).toThrow("exactly one of every or schedule"); + expect(() => + // @ts-expect-error -- args and construct are mutually exclusive. + periodicJob({ + args: { scope: "a" }, + construct: () => null, + every: { hours: 1 }, + job: report, + }) + ).toThrow("exactly one of args or construct"); + expect(() => + periodicJob({ args: { scope: "a" }, every: { months: 1 }, job: report }) + ).toThrow("calendar units"); + expect(() => + periodicJob({ args: { scope: "a" }, every: { seconds: 0 }, job: report }) + ).toThrow("positive"); + expect(() => + periodicJob({ + args: { scope: "a" }, + every: { hours: 1 }, + id: "has space", + job: report, + }) + ).toThrow("contain only letters, numbers, and _-[]<>/.·:+"); + expect(() => + periodicJob({ + args: { scope: "a" }, + every: { hours: 1 }, + id: "x", + job: report, + }) + ).toThrow("2 to 127 characters"); + expect(() => + periodicJob({ + args: {}, + every: { hours: 1 }, + job: { defaults: {}, kind: "fake" }, + }) + ).toThrow("defineJob"); + expect(() => + periodicJob({ + // @ts-expect-error -- producer input is typed by the definition. + args: { other: 1 }, + every: { hours: 1 }, + job: report, + }) + ).not.toThrow(); + }); + + it("treats a day as 24 hours", () => { + const job = periodicJob({ + args: { scope: "a" }, + every: { days: 1 }, + job: report, + }); + expect(job.schedule.next(start)).toEqual(start.add({ hours: 24 })); + }); +}); + +describe("PeriodicJobs", () => { + it("adds, removes, and rejects duplicate IDs", () => { + const hourly = periodicJob({ + args: { scope: "a" }, + every: { hours: 1 }, + id: "hourly", + job: report, + }); + const jobs = new PeriodicJobs([hourly]); + let changes = 0; + setPeriodicJobsChangeHandler(jobs, () => changes++); + + expect(() => jobs.add(hourly)).toThrow("duplicate periodic job id"); + const handle = jobs.add( + periodicJob({ args: { scope: "b" }, every: { hours: 2 }, job: report }) + ); + expect(jobs.size).toBe(2); + expect(periodicJobIds(jobs)).toEqual(["hourly"]); + expect(jobs.remove(handle)).toBe(true); + expect(jobs.remove(handle)).toBe(false); + expect(jobs.removeById("hourly")).toBe(true); + expect(jobs.removeById("hourly")).toBe(false); + jobs.clear(); + expect(changes).toBe(4); + expect(() => + jobs.add({ + id: null, + job: report, + runOnStart: false, + schedule: { next: () => null }, + }) + ).toThrow("periodicJob()"); + }); +}); + +describe("advancePeriodicJobs", () => { + it("runs on start, then inserts occurrences from their scheduled time", () => { + const jobs = new PeriodicJobs([ + periodicJob({ + args: { scope: "a" }, + every: { seconds: 10 }, + id: "heartbeat", + job: report, + runOnStart: true, + }), + ]); + + const first = advance(jobs, start); + expect(first.occurrences.map(({ scheduledAt }) => scheduledAt)).toEqual([ + start, + ]); + expect(first.durableUpdates).toEqual([ + { id: "heartbeat", nextRunAt: start.add({ seconds: 10 }) }, + ]); + expect(nextPeriodicRunAt(jobs)).toEqual(start.add({ seconds: 10 })); + + // Not due yet, except within Go River's 100 ms margin. + expect(advance(jobs, start.add({ seconds: 9 })).occurrences).toEqual([]); + const early = advance(jobs, start.add({ milliseconds: 9_950 })); + expect(early.occurrences.map(({ scheduledAt }) => scheduledAt)).toEqual([ + start.add({ seconds: 10 }), + ]); + expect(nextPeriodicRunAt(jobs)).toEqual(start.add({ seconds: 20 })); + + // A leader that fell behind catches up one occurrence per pass. + const late = start.add({ seconds: 45 }); + expect(advance(jobs, late).occurrences[0]?.scheduledAt).toEqual( + start.add({ seconds: 20 }) + ); + expect(advance(jobs, late).occurrences[0]?.scheduledAt).toEqual( + start.add({ seconds: 30 }) + ); + }); + + it("seeds next runs from durable records and initializes added jobs", () => { + const jobs = new PeriodicJobs([ + periodicJob({ + args: { scope: "a" }, + every: { hours: 1 }, + id: "durable", + job: report, + }), + ]); + const durable = new Map([["durable", start.add({ minutes: 5 })]]); + + expect(advance(jobs, start, durable)).toMatchObject({ + durableUpdates: [{ id: "durable", nextRunAt: start.add({ minutes: 5 }) }], + occurrences: [], + }); + expect(durable.size).toBe(0); + + jobs.add( + periodicJob({ + args: { scope: "b" }, + every: { minutes: 1 }, + job: report, + runOnStart: true, + }) + ); + const later = start.add({ minutes: 1 }); + expect(advance(jobs, later).occurrences).toHaveLength(1); + expect(nextPeriodicRunAt(jobs)).toEqual(later.add({ minutes: 1 })); + + resetPeriodicJobs(jobs); + expect(nextPeriodicRunAt(jobs)).toBeNull(); + }); + + it("stops scheduling a job whose schedule throws without affecting others", () => { + const broken = periodicJob({ + args: { scope: "broken" }, + job: report, + schedule: { + next: () => { + throw new Error("bad cron"); + }, + }, + }); + const healthy = periodicJob({ + args: { scope: "ok" }, + every: { minutes: 1 }, + job: report, + }); + const jobs = new PeriodicJobs([broken, healthy]); + + const batch = advance(jobs, start); + + expect(batch.errors.map(([job]) => job)).toEqual([broken]); + expect(nextPeriodicRunAt(jobs)).toEqual(start.add({ minutes: 1 })); + }); +}); + +describe("buildPeriodicInsert", () => { + it("adds periodic metadata and the occurrence time", async () => { + const job = periodicJob({ + args: { scope: "a" }, + every: { hours: 1 }, + id: "report", + job: report, + options: { metadata: { owner: "ops" }, queue: "reports" }, + }); + + await expect( + buildPeriodicInsert({ job, scheduledAt: start }) + ).resolves.toEqual({ + args: { scope: "a" }, + job: report, + options: { + metadata: { + owner: "ops", + periodic: true, + "river:periodic_job_id": "report", + }, + queue: "reports", + scheduledAt: start, + }, + }); + }); + + it("skips null occurrences and propagates constructor errors", async () => { + let calls = 0; + const job = periodicJob({ + construct: () => { + calls += 1; + if (calls === 1) return null; + if (calls === 2) throw new Error("constructor failed"); + return { args: { scope: "c" }, options: { delay: { minutes: 1 } } }; + }, + every: { hours: 1 }, + job: report, + }); + + await expect( + buildPeriodicInsert({ job, scheduledAt: start }) + ).resolves.toBe(null); + await expect( + buildPeriodicInsert({ job, scheduledAt: start }) + ).rejects.toThrow("constructor failed"); + // An explicit delay wins over the occurrence time. + await expect( + buildPeriodicInsert({ job, scheduledAt: start }) + ).resolves.toMatchObject({ + options: { delay: { minutes: 1 }, metadata: { periodic: true } }, + }); + }); +}); diff --git a/js/src/services.test.ts b/js/src/services.test.ts new file mode 100644 index 000000000..fcd4a2448 --- /dev/null +++ b/js/src/services.test.ts @@ -0,0 +1,1313 @@ +import { describe, expect, it } from "vitest"; + +import type { Client } from "./client.js"; +import type { + RuntimeDriver, + RuntimeJobRescue, + RuntimeLeader, + RuntimeMaintenanceBatch, +} from "./driver.js"; +import type { RiverEvent } from "./events.js"; +import { ManualTimer } from "./internal/manual-timer.js"; +import type { JobRow } from "./job.js"; +import { defineJob } from "./job-definition.js"; +import { periodicJob, PeriodicJobs } from "./periodic.js"; +import { RuntimeServices } from "./services.js"; + +function leadershipEvents(events: readonly RiverEvent[]): string[] { + return events + .map(({ kind }) => kind) + .filter((kind) => kind.startsWith("leader_")); +} + +class SharedLeadership { + leader: RuntimeLeader | null = null; +} + +function serviceDriver(shared: SharedLeadership): RuntimeDriver { + return { + maintenanceCleanJobs: () => 0, + maintenanceCleanQueues: () => 0, + maintenanceGetStuck: () => [], + // Mirrors Go River's elector: renew only the exact held term, and elect + // only when no unexpired term exists, whoever holds it. + maintenanceLeaderAcquire: ( + candidate: string, + now: Temporal.Instant, + ttlMs: number, + held: RuntimeLeader | null + ) => { + const current = shared.leader; + const live = + current !== null && + Temporal.Instant.compare(current.expiresAt, now) >= 0; + if (held !== null) { + if ( + !live || + current.leaderId !== candidate || + !current.electedAt.equals(held.electedAt) + ) { + return null; + } + shared.leader = { + ...current, + expiresAt: now.add({ milliseconds: ttlMs }), + }; + return shared.leader; + } + if (live) return null; + shared.leader = { + electedAt: now, + expiresAt: now.add({ milliseconds: ttlMs }), + leaderId: candidate, + }; + return shared.leader; + }, + maintenanceLeaderResign: (leader: RuntimeLeader) => { + const current = shared.leader; + if ( + current?.leaderId !== leader.leaderId || + !current.electedAt.equals(leader.electedAt) + ) { + return false; + } + shared.leader = null; + return true; + }, + maintenanceRescue: () => 0, + maintenanceSchedule: () => 0, + } as unknown as RuntimeDriver; +} + +describe("RuntimeServices", () => { + it("enqueues periodic jobs with a durable store, hooks, and dropped failures", async () => { + const shared = new SharedLeadership(); + const definition = defineJob<{ n: number }>()({ kind: "periodic" }); + const now = Temporal.Instant.from("2026-08-30T12:00:00Z"); + const events: string[] = []; + const inserted: unknown[] = []; + const transactions: string[] = []; + let failNextInsert = false; + const driver: RuntimeDriver = { + ...serviceDriver(shared), + operationScope: async ( + tx: unknown, + callback: (tx: unknown) => Promise + ) => { + expect(tx).toBeUndefined(); + transactions.push("begin"); + try { + const result = await callback("tx"); + transactions.push("commit"); + return result; + } catch (error: unknown) { + transactions.push("rollback"); + throw error; + } + }, + }; + const client = { + insertMany: (items: readonly unknown[], options?: { tx?: unknown }) => { + if (failNextInsert) { + failNextInsert = false; + return Promise.reject(new Error("insert failed")); + } + inserted.push(...items.map((item) => [item, options?.tx])); + return Promise.resolve([]); + }, + } as unknown as Client; + const upserts: unknown[] = []; + const kept: (readonly string[])[] = []; + const periodicJobs = new PeriodicJobs([ + periodicJob({ + args: { n: 1 }, + every: { minutes: 1 }, + id: "durable", + job: definition, + }), + periodicJob({ + args: { n: 2 }, + every: { hours: 1 }, + job: definition, + runOnStart: true, + }), + ]); + const logged: string[] = []; + const services = new RuntimeServices({ + client, + clientId: "leader", + driver, + emit: () => Promise.resolve(), + logger: { + error: (message) => logged.push(message), + warn: (message) => logged.push(message), + }, + maintenance: { + electionIntervalMs: 5, + jobCleanerIntervalMs: 60_000, + queueCleanerIntervalMs: 60_000, + rescuerIntervalMs: 60_000, + schedulerIntervalMs: 60_000, + }, + now: () => now, + onPeriodicJobsStart: ({ durableJobs }) => { + events.push(`start:${durableJobs.map(({ id }) => id).join(",")}`); + return Promise.resolve(); + }, + periodicJobStore: { + getAll: () => + Promise.resolve([ + { + createdAt: now, + id: "durable", + nextRunAt: now.subtract({ seconds: 1 }), + updatedAt: now, + }, + ]), + keepAliveAndReap: (ids) => { + kept.push(ids); + return Promise.resolve(); + }, + upsertMany: (tx, jobs) => { + upserts.push([tx, jobs.map(({ id, nextRunAt }) => [id, nextRunAt])]); + return Promise.resolve(); + }, + }, + periodicJobs, + rescue: () => Promise.resolve(null), + }); + failNextInsert = true; + const controller = new AbortController(); + const running = services.run(controller.signal); + + // The first batch (run-on-start plus the seeded durable next run) fails + // and is dropped; the overdue durable occurrence is then inserted. + await waitUntil(() => inserted.length >= 1); + controller.abort(); + await running; + + expect(events).toEqual(["start:durable"]); + expect(kept).toEqual([["durable"]]); + expect(transactions.slice(0, 2)).toEqual(["begin", "rollback"]); + expect(logged).toContain("River maintenance service failed"); + expect(inserted[0]).toEqual([ + expect.objectContaining({ + args: { n: 1 }, + options: expect.objectContaining({ + metadata: { periodic: true, "river:periodic_job_id": "durable" }, + scheduledAt: now.subtract({ seconds: 1 }), + }), + }), + "tx", + ]); + expect(upserts.at(-1)).toEqual([ + "tx", + [["durable", now.subtract({ seconds: 1 }).add({ minutes: 1 })]], + ]); + }); + + it("resigns its own term once maintenance fails to start three times, like Go", async () => { + const shared = new SharedLeadership(); + const base = serviceDriver(shared); + const resigned: RuntimeLeader[] = []; + const notified: string[] = []; + const driver: RuntimeDriver = { + ...base, + maintenanceLeaderResign: (leader: RuntimeLeader) => { + resigned.push(leader); + return base.maintenanceLeaderResign?.(leader) ?? false; + }, + runtimeRequestLeadershipResignation: () => { + notified.push("request_resign"); + }, + }; + const terms: Temporal.Instant[] = []; + const logged: string[] = []; + let now = Temporal.Instant.from("2026-08-30T12:00:00Z"); + const services = new RuntimeServices({ + client: {} as Client, + clientId: "leader", + driver, + emit: () => Promise.resolve(), + logger: { + error: (message) => logged.push(message), + warn: (message) => logged.push(message), + }, + maintenance: { + electionIntervalMs: 5, + jobCleanerIntervalMs: 60_000, + queueCleanerIntervalMs: 60_000, + rescuerIntervalMs: 60_000, + schedulerIntervalMs: 60_000, + }, + now: () => { + // Each election starts a distinct term. + now = now.add({ milliseconds: 1 }); + return now; + }, + onPeriodicJobsStart: () => { + if (shared.leader !== null) terms.push(shared.leader.electedAt); + return Promise.reject(new Error("start hook failed")); + }, + periodicJobs: new PeriodicJobs(), + random: () => 0, + rescue: () => Promise.resolve(null), + }); + const controller = new AbortController(); + const running = services.run(controller.signal); + + await waitUntil(() => resigned.length >= 1, 5_000); + controller.abort(); + await running; + + // Three attempts of the first term, a second apart then two, before it + // resigns that exact term without asking other clients to. + expect(terms.slice(0, 3)).toEqual([terms[0], terms[0], terms[0]]); + expect(resigned[0]?.electedAt).toEqual(terms[0]); + expect(notified).toEqual([]); + expect(logged).toContain( + "River maintenance failed to start after all attempts; resigning leadership" + ); + }); + + it("passes a bounded timeout and leadership signal to the job cleaner", async () => { + const shared = new SharedLeadership(); + let observed: + | { readonly signal: AbortSignal; readonly timeoutMs: number | null } + | undefined; + const driver: RuntimeDriver = { + ...serviceDriver(shared), + maintenanceCleanJobs: (_leader, _params, timeoutMs, signal) => { + observed = { signal, timeoutMs }; + return 0; + }, + }; + const services = new RuntimeServices({ + client: {} as Client, + clientId: "leader", + driver, + emit: () => Promise.resolve(), + maintenance: { + electionIntervalMs: 1, + jobCleanerIntervalMs: 1, + jobCleanerTimeoutMs: 321, + queueCleanerIntervalMs: 60_000, + rescuerIntervalMs: 60_000, + schedulerIntervalMs: 60_000, + }, + now: () => Temporal.Now.instant(), + periodicJobs: new PeriodicJobs(), + rescue: () => Promise.resolve(null), + }); + const controller = new AbortController(); + const running = services.run(controller.signal); + + await waitUntil(() => observed !== undefined); + controller.abort(); + await running; + + expect(observed?.timeoutMs).toBe(321); + expect(observed?.signal.aborted).toBe(true); + }); + + it("bounds rescue decisions and cancels them with the leadership term", async () => { + const shared = new SharedLeadership(); + const jobs = Array.from( + { length: 100 }, + (_, index) => ({ id: BigInt(index + 1) }) as JobRow + ); + let delivered = false; + let active = 0; + let maximum = 0; + const driver: RuntimeDriver = { + ...serviceDriver(shared), + maintenanceGetStuck: () => { + if (delivered) return []; + delivered = true; + return jobs; + }, + }; + const services = new RuntimeServices({ + client: {} as Client, + clientId: "leader", + driver, + emit: () => Promise.resolve(), + maintenance: { + electionIntervalMs: 1, + jobCleanerIntervalMs: 60_000, + queueCleanerIntervalMs: 60_000, + rescuerIntervalMs: 1, + schedulerIntervalMs: 60_000, + }, + now: () => Temporal.Now.instant(), + periodicJobs: new PeriodicJobs(), + rescue: async (_job, _now, signal) => { + active++; + maximum = Math.max(maximum, active); + try { + await new Promise((_resolve, reject) => + signal.addEventListener("abort", () => reject(signal.reason), { + once: true, + }) + ); + } finally { + active--; + } + return null; + }, + }); + const controller = new AbortController(); + const running = services.run(controller.signal); + + await waitUntil(() => maximum === 32); + controller.abort(new Error("test shutdown")); + await running; + + expect(maximum).toBe(32); + expect(active).toBe(0); + }); + + it("logs leadership and maintenance failures and keeps running", async () => { + const shared = new SharedLeadership(); + const baseDriver = serviceDriver(shared); + const acquire = baseDriver.maintenanceLeaderAcquire!; + let acquireFailures = 2; + let scheduleFailures = 1; + let scheduled = 0; + const driver: RuntimeDriver = { + ...baseDriver, + maintenanceLeaderAcquire: (...args) => { + if (acquireFailures > 0) { + acquireFailures -= 1; + throw new Error("could not obtain lock on river_leader"); + } + return acquire(...args); + }, + maintenanceSchedule: () => { + if (scheduleFailures > 0) { + scheduleFailures -= 1; + throw new Error("canceling statement due to statement timeout"); + } + scheduled += 1; + return 0; + }, + }; + const events: RiverEvent[] = []; + const logs: [ + string, + string, + Readonly> | undefined, + ][] = []; + const services = new RuntimeServices({ + client: {} as Client, + clientId: "leader", + driver, + emit: (event) => { + events.push(event); + return Promise.resolve(); + }, + logger: { + error: (message, attributes) => + logs.push(["error", message, attributes]), + warn: (message, attributes) => logs.push(["warn", message, attributes]), + }, + maintenance: { + electionIntervalMs: 1, + jobCleanerIntervalMs: 60_000, + queueCleanerIntervalMs: 60_000, + rescuerIntervalMs: 60_000, + schedulerIntervalMs: 1, + }, + now: () => Temporal.Now.instant(), + periodicJobs: new PeriodicJobs(), + random: () => 0.5, + rescue: () => Promise.resolve(null), + }); + const controller = new AbortController(); + const running = services.run(controller.signal); + + await waitUntil(() => scheduled > 0); + controller.abort(); + await running; + + expect(logs).toEqual([ + [ + "warn", + "River leader election failed; retrying", + { error: "could not obtain lock on river_leader", leader: false }, + ], + [ + "warn", + "River leader election failed; retrying", + { error: "could not obtain lock on river_leader", leader: false }, + ], + [ + "error", + "River maintenance service failed", + { + error: "canceling statement due to statement timeout", + service: "scheduler", + }, + ], + ]); + expect( + events.filter(({ kind }) => kind === "maintenance_failed") + ).toMatchObject([{ service: "scheduler" }]); + expect(events.some(({ kind }) => kind === "leader_acquired")).toBe(true); + }); + + it("guards each rescue with the horizon used to select it", async () => { + const shared = new SharedLeadership(); + const now = Temporal.Instant.from("2026-09-01T12:00:00Z"); + const stuck = { id: 7n } as JobRow; + let selectedBefore: Temporal.Instant | undefined; + let rescuedBefore: Temporal.Instant | undefined; + let rescued: readonly RuntimeJobRescue[] = []; + const driver: RuntimeDriver = { + ...serviceDriver(shared), + maintenanceGetStuck: (_leader, attemptedBefore, afterId) => { + selectedBefore = attemptedBefore; + return afterId === 0n ? [stuck] : []; + }, + maintenanceRescue: (_leader, attemptedBefore, jobs) => { + rescuedBefore = attemptedBefore; + rescued = jobs; + return jobs.length; + }, + }; + let clock = now; + const services = new RuntimeServices({ + client: {} as Client, + clientId: "leader", + driver, + emit: () => Promise.resolve(), + maintenance: { + electionIntervalMs: 1, + jobCleanerIntervalMs: 60_000, + queueCleanerIntervalMs: 60_000, + rescueAfterMs: 3_600_000, + rescuerIntervalMs: 60_000, + schedulerIntervalMs: 60_000, + }, + // Time advances while the pass runs; the rescue must still use the + // horizon computed when the pass selected its jobs. + now: () => { + clock = clock.add({ seconds: 1 }); + return clock; + }, + periodicJobs: new PeriodicJobs(), + rescue: (job, at) => + Promise.resolve({ + error: { at, attempt: 1, error: "stuck", trace: "" }, + finalizedAt: null, + id: job.id, + scheduledAt: at, + state: "retryable", + }), + }); + const controller = new AbortController(); + const running = services.run(controller.signal); + + await waitUntil(() => rescued.length === 1); + controller.abort(); + await running; + + expect(rescuedBefore).toBeDefined(); + expect(rescuedBefore?.equals(selectedBefore ?? now)).toBe(true); + expect(rescued[0]?.id).toBe(7n); + }); + + it("passes Go-compatible promotion and notification horizons", async () => { + const shared = new SharedLeadership(); + const now = Temporal.Instant.from("2026-08-30T12:00:00Z"); + let observed: + | Parameters>[1] + | undefined; + const driver: RuntimeDriver = { + ...serviceDriver(shared), + maintenanceSchedule: (_leader, params) => { + observed = params; + return 0; + }, + }; + const services = new RuntimeServices({ + client: {} as Client, + clientId: "leader", + driver, + emit: () => Promise.resolve(), + maintenance: { + electionIntervalMs: 60_000, + jobCleanerIntervalMs: 60_000, + queueCleanerIntervalMs: 60_000, + rescuerIntervalMs: 60_000, + schedulerIntervalMs: 5_000, + }, + now: () => now, + periodicJobs: new PeriodicJobs(), + rescue: () => Promise.resolve(null), + }); + const controller = new AbortController(); + const running = services.run(controller.signal); + + await waitUntil(() => observed !== undefined); + controller.abort(); + await running; + + expect(observed?.now.toString()).toBe(now.toString()); + expect(observed?.notificationHorizon.toString()).toBe( + "2026-08-30T12:00:00.005Z" + ); + expect(observed?.scheduledAtHorizon.toString()).toBe( + "2026-08-30T12:00:05Z" + ); + }); + + it("cleans notifications in batches against one horizon until a short batch", async () => { + const shared = new SharedLeadership(); + const now = Temporal.Instant.from("2026-08-30T12:00:00Z"); + const calls: { createdBefore: string; limit: number }[] = []; + const calledAt: number[] = []; + const deleted = [10_000, 10_000, 3]; + const driver: RuntimeDriver = { + ...serviceDriver(shared), + maintenanceCleanNotifications: (_leader, createdBefore, limit) => { + calls.push({ createdBefore: createdBefore.toString(), limit }); + calledAt.push(performance.now()); + return deleted[calls.length - 1] ?? 0; + }, + }; + const services = new RuntimeServices({ + client: {} as Client, + clientId: "leader", + driver, + emit: () => Promise.resolve(), + maintenance: { + electionIntervalMs: 60_000, + jobCleanerIntervalMs: 60_000, + notificationCleanerIntervalMs: 60_000, + notificationRetentionMs: 3_600_000, + queueCleanerIntervalMs: 60_000, + rescuerIntervalMs: 60_000, + schedulerIntervalMs: 60_000, + }, + now: () => now, + periodicJobs: new PeriodicJobs(), + random: () => 0, + rescue: () => Promise.resolve(null), + }); + const controller = new AbortController(); + const running = services.run(controller.signal); + + await waitUntil(() => calls.length === 3); + // Two full batches followed by a short one end the pass. + await new Promise((resolve) => setTimeout(resolve, 20)); + controller.abort(); + await running; + + expect(calls).toEqual( + Array.from({ length: 3 }, () => ({ + createdBefore: "2026-08-30T11:00:00Z", + limit: 10_000, + })) + ); + // Like Go, the pass pauses at least 50 ms between batches. + expect(calledAt[1]! - calledAt[0]!).toBeGreaterThanOrEqual(49); + expect(calledAt[2]! - calledAt[1]!).toBeGreaterThanOrEqual(49); + }); + + it("switches to reduced batches after three timed-out batches in a row", async () => { + const shared = new SharedLeadership(); + const limits: number[] = []; + let failing = true; + const timedOutBatch = (batch: RuntimeMaintenanceBatch | undefined) => { + expect(batch?.timeoutMs).toBe(5); + // Like PostgreSQL's statement_timeout, the backend stops the batch at + // its timeout. + return new Promise((resolve, reject) => { + if (!failing) { + resolve(0); + return; + } + batch?.signal.addEventListener( + "abort", + () => + reject(new Error("canceling statement due to statement timeout")), + { once: true } + ); + }); + }; + const driver: RuntimeDriver = { + ...serviceDriver(shared), + maintenanceCleanNotifications: ( + _leader, + _createdBefore, + limit, + batch + ) => { + limits.push(limit); + return timedOutBatch(batch); + }, + }; + const services = new RuntimeServices({ + client: {} as Client, + clientId: "leader", + driver, + emit: () => Promise.resolve(), + maintenance: { + electionIntervalMs: 60_000, + jobCleanerIntervalMs: 60_000, + maintenanceTimeoutMs: 5, + notificationCleanerIntervalMs: 1, + queueCleanerIntervalMs: 60_000, + rescuerIntervalMs: 60_000, + schedulerIntervalMs: 60_000, + }, + now: () => Temporal.Now.instant(), + periodicJobs: new PeriodicJobs(), + random: () => 0, + rescue: () => Promise.resolve(null), + }); + const controller = new AbortController(); + const running = services.run(controller.signal); + + await waitUntil(() => limits.length === 4); + failing = false; + await waitUntil(() => limits.length === 6); + controller.abort(); + await running; + + // Each timed-out pass fails; the third in a row opens the breaker, and + // later passes keep the reduced size even when they succeed. + expect(limits).toEqual([10_000, 10_000, 10_000, 1_000, 1_000, 1_000]); + expect(services.diagnostics.runs.notification_cleaner).toBe(2); + }); + + it("bounds each scheduler, rescuer, and queue cleaner batch", async () => { + const shared = new SharedLeadership(); + const observed = new Map(); + const driver: RuntimeDriver = { + ...serviceDriver(shared), + maintenanceCleanQueues: (_leader, _before, _limit, batch) => { + observed.set("queue_cleaner", batch?.timeoutMs); + return 0; + }, + maintenanceGetStuck: (_leader, _before, _afterId, _limit, batch) => { + observed.set("rescuer", batch?.timeoutMs); + return []; + }, + maintenanceSchedule: (_leader, _params, batch) => { + observed.set("scheduler", batch?.timeoutMs); + return 0; + }, + }; + const services = new RuntimeServices({ + client: {} as Client, + clientId: "leader", + driver, + emit: () => Promise.resolve(), + maintenance: { + electionIntervalMs: 1, + jobCleanerIntervalMs: 60_000, + queueCleanerIntervalMs: 1, + rescuerIntervalMs: 1, + schedulerIntervalMs: 1, + }, + now: () => Temporal.Now.instant(), + periodicJobs: new PeriodicJobs(), + rescue: () => Promise.resolve(null), + }); + const controller = new AbortController(); + const running = services.run(controller.signal); + + await waitUntil(() => observed.size === 3); + controller.abort(); + await running; + + // Go River's default maintenance timeout. + expect(Object.fromEntries(observed)).toEqual({ + queue_cleaner: 30_000, + rescuer: 30_000, + scheduler: 30_000, + }); + }); + + it("stops cleaning notifications between batches when cancelled", async () => { + const shared = new SharedLeadership(); + const controller = new AbortController(); + let calls = 0; + const driver: RuntimeDriver = { + ...serviceDriver(shared), + maintenanceCleanNotifications: () => { + calls += 1; + controller.abort(); + return 10_000; + }, + }; + const services = new RuntimeServices({ + client: {} as Client, + clientId: "leader", + driver, + emit: () => Promise.resolve(), + maintenance: { + electionIntervalMs: 60_000, + jobCleanerIntervalMs: 60_000, + notificationCleanerIntervalMs: 1, + queueCleanerIntervalMs: 60_000, + rescuerIntervalMs: 60_000, + schedulerIntervalMs: 60_000, + }, + now: () => Temporal.Now.instant(), + periodicJobs: new PeriodicJobs(), + rescue: () => Promise.resolve(null), + }); + + await services.run(controller.signal); + + expect(calls).toBe(1); + }); + + it("aborts backend work immediately when its exact term is resigned", async () => { + const shared = new SharedLeadership(); + const signals: AbortSignal[] = []; + const events: RiverEvent[] = []; + const driver: RuntimeDriver = { + ...serviceDriver(shared), + maintenanceReindex: (_leader, _indexes, _timeoutMs, signal) => { + signals.push(signal); + return new Promise((_resolve, reject) => + signal.addEventListener("abort", () => reject(signal.reason), { + once: true, + }) + ); + }, + }; + const services = new RuntimeServices({ + client: {} as Client, + clientId: "leader", + driver, + emit: (event) => { + events.push(event); + return Promise.resolve(); + }, + maintenance: { + electionIntervalMs: 60_000, + jobCleanerIntervalMs: 60_000, + queueCleanerIntervalMs: 60_000, + reindexerSchedule: (after) => after.add({ milliseconds: 1 }), + rescuerIntervalMs: 60_000, + schedulerIntervalMs: 60_000, + }, + now: () => Temporal.Now.instant(), + periodicJobs: new PeriodicJobs(), + rescue: () => Promise.resolve(null), + }); + const controller = new AbortController(); + const running = services.run(controller.signal); + + await waitUntil(() => signals.length === 1); + await expect(services.resignLeadership()).resolves.toBe(true); + expect(signals[0]?.aborted).toBe(true); + controller.abort(); + await running; + + expect(events.some(({ kind }) => kind === "maintenance_failed")).toBe( + false + ); + }); + + it("runs backend-specific reindex maintenance on its configured schedule", async () => { + const shared = new SharedLeadership(); + const calls: Array<{ + indexes: readonly string[]; + signal: AbortSignal; + timeoutMs: number | null; + }> = []; + const driver: RuntimeDriver = { + ...serviceDriver(shared), + maintenanceReindex: (_leader, indexes, timeoutMs, signal) => { + calls.push({ indexes, signal, timeoutMs }); + return 2; + }, + }; + const events: RiverEvent[] = []; + const services = new RuntimeServices({ + client: {} as Client, + clientId: "leader", + driver, + emit: (event) => { + events.push(event); + return Promise.resolve(); + }, + maintenance: { + electionIntervalMs: 1, + jobCleanerIntervalMs: 60_000, + queueCleanerIntervalMs: 60_000, + reindexerIndexNames: ["river_one", "river_two"], + reindexerSchedule: (after) => after.add({ milliseconds: 1 }), + reindexerTimeoutMs: 321, + rescuerIntervalMs: 60_000, + schedulerIntervalMs: 60_000, + }, + now: () => Temporal.Now.instant(), + periodicJobs: new PeriodicJobs(), + rescue: () => Promise.resolve(null), + }); + const controller = new AbortController(); + const running = services.run(controller.signal); + + await waitUntil(() => calls.length === 1); + controller.abort(); + await running; + + expect(calls[0]).toMatchObject({ + indexes: ["river_one", "river_two"], + signal: controller.signal, + timeoutMs: 321, + }); + expect( + events.some( + (event) => + event.kind === "maintenance_succeeded" && + event.service === "reindexer" && + event.count === 2 + ) + ).toBe(true); + }); + + it("renews leadership while another maintenance service is blocked", async () => { + const shared = new SharedLeadership(); + const baseDriver = serviceDriver(shared); + const acquire = baseDriver.maintenanceLeaderAcquire!; + let acquireCalls = 0; + let releaseScheduler!: () => void; + let schedulerStarted = false; + const schedulerGate = new Promise((resolve) => { + releaseScheduler = () => resolve(0); + }); + const driver: RuntimeDriver = { + ...baseDriver, + maintenanceLeaderAcquire: (...args) => { + acquireCalls += 1; + return acquire(...args); + }, + maintenanceSchedule: () => { + schedulerStarted = true; + return schedulerGate; + }, + }; + const services = new RuntimeServices({ + client: {} as Client, + clientId: "leader", + driver, + emit: () => Promise.resolve(), + maintenance: { + electionIntervalMs: 1, + jobCleanerIntervalMs: 60_000, + queueCleanerIntervalMs: 60_000, + rescuerIntervalMs: 60_000, + schedulerIntervalMs: 1, + }, + now: () => Temporal.Now.instant(), + periodicJobs: new PeriodicJobs(), + rescue: () => Promise.resolve(null), + }); + const controller = new AbortController(); + const running = services.run(controller.signal); + + await waitUntil(() => schedulerStarted); + await waitUntil(() => acquireCalls >= 3); + controller.abort(); + releaseScheduler(); + await running; + + expect(acquireCalls).toBeGreaterThanOrEqual(3); + expect(shared.leader).toBeNull(); + }); + + it("stops during an outage without waiting on election or resignation", async () => { + const shared = new SharedLeadership(); + const base = serviceDriver(shared); + let outage = false; + let resignAttempts = 0; + const hang = new Promise(() => undefined); + // A connection that never comes; the wait ends when its signal aborts. + const waitForConnection = (signal: AbortSignal | undefined) => + new Promise((_resolve, reject) => { + signal?.addEventListener("abort", () => reject(signal.reason), { + once: true, + }); + }); + const driver: RuntimeDriver = { + ...base, + maintenanceLeaderAcquire: (leaderId, now, ttlMs, held, options) => + outage + ? waitForConnection(options?.signal) + : base.maintenanceLeaderAcquire!(leaderId, now, ttlMs, held), + maintenanceLeaderResign: (leader) => { + if (!outage) return base.maintenanceLeaderResign!(leader); + resignAttempts++; + return hang; + }, + }; + const services = new RuntimeServices({ + client: {} as Client, + clientId: "leader", + driver, + emit: () => Promise.resolve(), + maintenance: { + electionIntervalMs: 1, + jobCleanerIntervalMs: 60_000, + leaderResignTimeoutMs: 5, + queueCleanerIntervalMs: 60_000, + rescuerIntervalMs: 60_000, + schedulerIntervalMs: 60_000, + }, + now: () => Temporal.Now.instant(), + periodicJobs: new PeriodicJobs(), + rescue: () => Promise.resolve(null), + }); + const controller = new AbortController(); + const running = services.run(controller.signal); + await waitUntil(() => services.diagnostics.isLeader); + + // The database stops answering: the next election and the resignation + // on stop wait for a connection that never comes. + outage = true; + await new Promise((resolve) => setTimeout(resolve, 10)); + controller.abort(); + await running; + + // Like Go, three bounded resignation attempts, then the lease expires. + expect(resignAttempts).toBe(3); + }); + + it("bids within 50 ms of another client's resignation, like Go", async () => { + const shared = new SharedLeadership(); + const options = { + // Long enough that only the resignation can explain a prompt bid. + electionIntervalMs: 60_000, + jobCleanerIntervalMs: 60_000, + queueCleanerIntervalMs: 60_000, + rescuerIntervalMs: 60_000, + schedulerIntervalMs: 60_000, + }; + const services = (clientId: string) => + new RuntimeServices({ + client: {} as Client, + clientId, + driver: serviceDriver(shared), + emit: () => Promise.resolve(), + maintenance: options, + now: () => Temporal.Now.instant(), + periodicJobs: new PeriodicJobs(), + random: () => 0.99, + rescue: () => Promise.resolve(null), + }); + const first = services("first"); + const second = services("second"); + const firstAbort = new AbortController(); + const secondAbort = new AbortController(); + const firstRun = first.run(firstAbort.signal); + await waitUntil(() => first.diagnostics.isLeader); + const secondRun = second.run(secondAbort.signal); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(second.diagnostics.isLeader).toBe(false); + + firstAbort.abort(); + await firstRun; + expect(shared.leader).toBeNull(); + second.leaderResigned(); + await waitUntil(() => second.diagnostics.isLeader, 500); + + secondAbort.abort(); + await secondRun; + }); + + it("fences leadership, resigns, and permits failover without leaked loops", async () => { + const shared = new SharedLeadership(); + const firstEvents: RiverEvent[] = []; + const secondEvents: RiverEvent[] = []; + const options = { + electionIntervalMs: 1, + jobCleanerIntervalMs: 60_000, + queueCleanerIntervalMs: 60_000, + rescuerIntervalMs: 60_000, + schedulerIntervalMs: 60_000, + }; + const first = new RuntimeServices({ + client: {} as Client, + clientId: "first", + driver: serviceDriver(shared), + emit: (event) => { + firstEvents.push(event); + return Promise.resolve(); + }, + maintenance: options, + now: () => Temporal.Now.instant(), + periodicJobs: new PeriodicJobs(), + rescue: () => Promise.resolve(null), + }); + const second = new RuntimeServices({ + client: {} as Client, + clientId: "second", + driver: serviceDriver(shared), + emit: (event) => { + secondEvents.push(event); + return Promise.resolve(); + }, + maintenance: options, + now: () => Temporal.Now.instant(), + periodicJobs: new PeriodicJobs(), + rescue: () => Promise.resolve(null), + }); + const firstAbort = new AbortController(); + const secondAbort = new AbortController(); + const firstRun = first.run(firstAbort.signal); + await waitUntil(() => first.diagnostics.isLeader); + const secondRun = second.run(secondAbort.signal); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(second.diagnostics.isLeader).toBe(false); + + await expect(second.resignLeadership()).resolves.toBe(false); + await expect(first.resignLeadership()).resolves.toBe(true); + firstAbort.abort(); + await firstRun; + await waitUntil(() => second.diagnostics.isLeader); + expect(firstEvents.some(({ kind }) => kind === "leader_lost")).toBe(true); + expect(second.diagnostics.leader?.leaderId).toBe("second"); + secondAbort.abort(); + await secondRun; + + expect(firstEvents.some(({ kind }) => kind === "leader_acquired")).toBe( + true + ); + expect(firstEvents.some(({ kind }) => kind === "leader_lost")).toBe(true); + expect(secondEvents.some(({ kind }) => kind === "leader_acquired")).toBe( + true + ); + expect(shared.leader).toBeNull(); + }); + + it("runs term services once per term, never overlapping terms", async () => { + const shared = new SharedLeadership(); + const runs: { + release: () => void; + readonly term: RuntimeLeader; + ended: boolean; + }[] = []; + const base = serviceDriver(shared); + let acquisitions = 0; + const services = new RuntimeServices({ + client: {} as Client, + clientId: "leader", + driver: { + ...base, + maintenanceLeaderAcquire: (...args) => { + acquisitions++; + return base.maintenanceLeaderAcquire?.(...args) ?? null; + }, + }, + emit: () => Promise.resolve(), + maintenance: { + electionIntervalMs: 1, + jobCleanerIntervalMs: 60_000, + queueCleanerIntervalMs: 60_000, + rescuerIntervalMs: 60_000, + schedulerIntervalMs: 60_000, + }, + now: () => Temporal.Now.instant(), + periodicJobs: new PeriodicJobs(), + rescue: () => Promise.resolve(null), + termServices: [ + { + name: "term", + run: ({ signal, term }) => { + const run: (typeof runs)[number] = { + ended: false, + release: () => undefined, + term, + }; + runs.push(run); + return new Promise((resolve) => { + signal.addEventListener("abort", () => { + run.ended = true; + // The service settles only when the test releases it. + run.release = () => { + resolve(); + }; + }); + }); + }, + }, + ], + }); + const controller = new AbortController(); + const running = services.run(controller.signal); + + await waitUntil(() => runs.length === 1); + expect(runs[0]?.term.leaderId).toBe("leader"); + await services.resignLeadership(); + expect(runs[0]?.ended).toBe(true); + // A new term is elected, but its services wait for the old term's. + await waitUntil( + () => + shared.leader !== null && + !shared.leader.electedAt.equals( + runs[0]?.term.electedAt as Temporal.Instant + ) + ); + // Leadership keeps renewing, and the loop keeps waiting. + const renewals = acquisitions; + await waitUntil(() => acquisitions >= renewals + 3); + expect(runs).toHaveLength(1); + runs[0]?.release(); + await waitUntil(() => runs.length === 2); + expect( + runs[1]?.term.electedAt.equals( + shared.leader?.electedAt as Temporal.Instant + ) + ).toBe(true); + + controller.abort(); + await waitUntil(() => runs[1]?.ended === true); + runs[1]?.release(); + await running; + }); + + it("ends a term at its local deadline while renewal hangs, and never revives it", async () => { + const shared = new SharedLeadership(); + const base = serviceDriver(shared); + const renewal = Promise.withResolvers(); + let acquisitions = 0; + const driver: RuntimeDriver = { + ...base, + maintenanceLeaderAcquire: (...args) => { + acquisitions++; + if (acquisitions === 1) + return base.maintenanceLeaderAcquire?.(...args) ?? null; + if (acquisitions === 2) return renewal.promise; + return null; + }, + }; + const signals: AbortSignal[] = []; + const events: RiverEvent[] = []; + const timer = new ManualTimer(); + const services = new RuntimeServices({ + client: {} as Client, + clientId: "leader", + driver, + emit: (event) => { + events.push(event); + return Promise.resolve(); + }, + maintenance: { + electionIntervalMs: 1, + jobCleanerIntervalMs: 60_000, + leaderDeadlineSafetyMs: 1, + leaderTtlPaddingMs: 30, + queueCleanerIntervalMs: 60_000, + rescuerIntervalMs: 60_000, + schedulerIntervalMs: 60_000, + }, + now: () => Temporal.Now.instant(), + periodicJobs: new PeriodicJobs(), + rescue: () => Promise.resolve(null), + termServices: [ + { + name: "term", + run: ({ signal }) => { + signals.push(signal); + return new Promise((resolve) => { + signal.addEventListener("abort", () => { + resolve(); + }); + }); + }, + }, + ], + timer, + }); + const controller = new AbortController(); + const running = services.run(controller.signal); + + await waitUntil(() => signals.length === 1 && acquisitions === 2); + // The renewal hangs; the term still ends at its local deadline: the + // TTL (the election interval plus padding) less the safety margin. + await timer.advance(29); + expect(signals[0]?.aborted).toBe(false); + expect(services.diagnostics.isLeader).toBe(true); + await timer.advance(1); + expect(signals[0]?.aborted).toBe(true); + // Like Go's elector, the client stops leading and says so at once. + expect(services.diagnostics).toMatchObject({ + isLeader: false, + leader: null, + }); + expect(leadershipEvents(events)).toEqual([ + "leader_acquired", + "leader_lost", + ]); + const term = shared.leader as RuntimeLeader; + renewal.resolve({ + ...term, + expiresAt: Temporal.Now.instant().add({ minutes: 1 }), + }); + // The late renewal resigns instead of reviving the term. + await waitUntil(() => shared.leader === null); + expect(signals).toHaveLength(1); + expect(leadershipEvents(events)).toEqual([ + "leader_acquired", + "leader_lost", + ]); + + controller.abort(); + await running; + }); + + it("leaves a pilot's queues to its own job cleaner", async () => { + const shared = new SharedLeadership(); + let excluded: readonly string[] | undefined; + const services = new RuntimeServices({ + client: {} as Client, + clientId: "leader", + driver: { + ...serviceDriver(shared), + maintenanceCleanJobs: (_leader, params) => { + excluded = params.queuesExcluded; + return 0; + }, + }, + emit: () => Promise.resolve(), + jobCleanerQueuesExcluded: ["own_cleaner"], + maintenance: { + electionIntervalMs: 1, + jobCleanerIntervalMs: 1, + queueCleanerIntervalMs: 60_000, + rescuerIntervalMs: 60_000, + schedulerIntervalMs: 60_000, + }, + now: () => Temporal.Now.instant(), + periodicJobs: new PeriodicJobs(), + rescue: () => Promise.resolve(null), + }); + const controller = new AbortController(); + const running = services.run(controller.signal); + + await waitUntil(() => excluded !== undefined); + controller.abort(); + await running; + + expect(excluded).toEqual(["own_cleaner"]); + }); +}); + +async function waitUntil( + predicate: () => boolean, + timeoutMs = 1_000 +): Promise { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() > deadline) throw new Error("timed out waiting for service"); + await new Promise((resolve) => setTimeout(resolve, 1)); + } +} From f948c35e518332e544772743b79a4107dc7b9cec Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sun, 4 Oct 2026 17:18:26 -0500 Subject: [PATCH 25/43] test the pilot seam's operations and producer sessions Cover the operations a pilot can intercept through `riverqueue/unstable-driver`: continuations that run River's own operation at most once, replacement of stuck-job reads and rescues, rejection of rows River can't insert, and each row's arguments from before the client's argument transforms, which the insert interceptor sees beside the transformed rows River stores. Drive the runtime through a pilot's producer sessions: claims bounded by queue capacity and limited to known kinds, fixed-rate producer reports that never overlap, queue names reserved until their generation stops, supervised services restarted after backoff, and peer attempts that accept outcomes only for their own peers and retry failed peers on their worker's retry policy, like Go. A claim that breaks its contract stops the runtime after its running attempts finish. --- js/src/pilot-runtime.test.ts | 2182 +++++++++++++++++++++++ js/src/runtime/pilot-operations.test.ts | 655 +++++++ 2 files changed, 2837 insertions(+) create mode 100644 js/src/pilot-runtime.test.ts create mode 100644 js/src/runtime/pilot-operations.test.ts diff --git a/js/src/pilot-runtime.test.ts b/js/src/pilot-runtime.test.ts new file mode 100644 index 000000000..c60213955 --- /dev/null +++ b/js/src/pilot-runtime.test.ts @@ -0,0 +1,2182 @@ +import { describe, expect, it } from "vitest"; + +import type { + JobClaimParams, + JobClaimResult, + JobCompletionCommand, + JobCompletionResult, + QueueRow, + RuntimeDriver, +} from "./driver.js"; +import type { ClientOptions } from "./options.js"; +import { ExtensionError, LifecycleError, ValidationError } from "./errors.js"; +import { registerDriver } from "./internal/driver-registry.js"; +import { ManualTimer } from "./internal/manual-timer.js"; +import { createJobArgsTransformPlugin } from "./job-args-transform.js"; +import { defineJob } from "./job-definition.js"; +import type { JobRow } from "./job.js"; +import type { Logger } from "./logger.js"; +import type { QueueConfig } from "./options.js"; +import type { + Pilot, + PilotAttempts, + PilotDatabase, + PilotHost, + PilotProducer, + ProducerConfiguration, + ProducerStartContext, +} from "./pilot.js"; +import { PilotClient } from "./pilot-client.js"; +import { overrideRuntimeTiming } from "./runtime.js"; +import { + complete, + snooze, + Workers, + type WorkContext, + type WorkerOptions, +} from "./worker.js"; + +type Transaction = { readonly tx: string }; + +/** An in-memory runtime backend with one queue table and one job table. */ +class FakeDriver implements RuntimeDriver { + declare readonly "~river"?: { + readonly capability: "runtime"; + readonly transaction: Transaction; + }; + readonly available: JobRow[] = []; + readonly claims: { readonly limit: number; readonly tx: unknown }[] = []; + readonly queues = new Map(); + + jobCancel(): null { + return null; + } + + leader: { electedAt: Temporal.Instant; leaderId: string } | null = null; + + maintenanceCleanJobs(): number { + return 0; + } + + maintenanceCleanQueues(): number { + return 0; + } + + maintenanceGetStuck(): readonly JobRow[] { + return []; + } + + maintenanceLeaderAcquire( + leaderId: string, + now: Temporal.Instant, + ttlMs: number, + held: { readonly electedAt: Temporal.Instant } | null + ) { + if (this.leader === null) this.leader = { electedAt: now, leaderId }; + if (this.leader.leaderId !== leaderId) return null; + if (held !== null && !held.electedAt.equals(this.leader.electedAt)) { + return null; + } + return { ...this.leader, expiresAt: now.add({ milliseconds: ttlMs }) }; + } + + maintenanceLeaderResign(): boolean { + this.leader = null; + return true; + } + + maintenanceRescue(): number { + return 0; + } + + maintenanceSchedule(): number { + return 0; + } + + jobClaim( + params: JobClaimParams, + options?: { readonly tx?: Transaction } + ): JobClaimResult { + const queue = params.queues[0]; + if (queue === undefined) return { jobs: [] }; + this.claims.push({ limit: queue.limit, tx: options?.tx }); + const jobs: JobRow[] = []; + for (const job of [...this.available]) { + if (jobs.length >= queue.limit || job.queue !== queue.name) continue; + this.available.splice(this.available.indexOf(job), 1); + jobs.push({ + ...job, + attempt: job.attempt + 1, + attemptedBy: [...job.attemptedBy, params.attemptedBy], + state: "running", + }); + } + return { jobs }; + } + + /** Holds background completion batches, which have no transaction. */ + completionGate: Promise | undefined; + readonly completionCalls: { readonly tx: unknown }[] = []; + /** Every completion command, background or transactional. */ + readonly completionCommands: JobCompletionCommand[] = []; + /** IDs of the jobs background completion batches persisted. */ + readonly completedIds: bigint[] = []; + completionsInFlight = 0; + maxCompletionsInFlight = 0; + + async jobCompleteMany( + commands: readonly JobCompletionCommand[], + options?: { readonly tx?: Transaction } + ): Promise { + this.completionCalls.push({ tx: options?.tx }); + this.completionCommands.push(...commands); + if (options?.tx === undefined) { + this.completionsInFlight++; + this.maxCompletionsInFlight = Math.max( + this.maxCompletionsInFlight, + this.completionsInFlight + ); + try { + await this.completionGate; + } finally { + this.completionsInFlight--; + } + this.completedIds.push(...commands.map(({ id }) => id)); + } + return commands.map((command) => ({ + job: null, + key: `${command.id}:${command.attempt}:${command.attemptedBy}`, + status: "stale", + })); + } + + jobDelete() { + return { status: "not_found" as const }; + } + + jobDeleteMany(): readonly JobRow[] { + return []; + } + + jobGet(): null { + return null; + } + + jobInsert(): never { + throw new Error("not used"); + } + + jobInsertMany(): never { + throw new Error("not used"); + } + + jobList(): readonly JobRow[] { + return []; + } + + jobRetry(): null { + return null; + } + + jobUpdate(): null { + return null; + } + + queueGets = 0; + + queueGet(name: string): QueueRow | null { + this.queueGets++; + return this.queues.get(name) ?? null; + } + + queueList(): readonly QueueRow[] { + return []; + } + + queuePause(): null { + return null; + } + + queueResume(): null { + return null; + } + + queueUpdate(): null { + return null; + } + + runtimeQueueUpsert(name: string, now: Temporal.Instant): QueueRow { + const row = this.queues.get(name) ?? { + createdAt: now, + metadata: {}, + name, + pausedAt: null, + updatedAt: now, + }; + this.queues.set(name, row); + return row; + } +} + +const database: PilotDatabase = { + backend: "fake", + connection: (callback) => Promise.resolve(callback({ tx: "connection" })), + deleteFinalizedJobs: () => Promise.resolve(0), + loadClaimed: () => Promise.resolve({ jobs: [] }), + notify: () => Promise.resolve(), + schema: null, + transaction: (callback) => Promise.resolve(callback({ tx: "claim" })), +}; + +class TestClient extends PilotClient {} + +const job = defineJob({ kind: "lifetime_job" }); + +function jobRow( + id: bigint, + kind: string = job.kind, + queue = "default" +): JobRow { + const now = Temporal.Now.instant(); + return { + args: {}, + attempt: 0, + attemptedAt: null, + attemptedBy: [], + createdAt: now, + errors: [], + finalizedAt: null, + id, + kind, + maxAttempts: 25, + metadata: {}, + priority: 1, + queue, + scheduledAt: now, + state: "available", + tags: [], + uniqueKey: null, + uniqueStates: null, + }; +} + +interface Deferred { + readonly promise: Promise; + resolve(value: T): void; + reject(reason: unknown): void; +} + +function deferred(): Deferred { + return Promise.withResolvers(); +} + +async function waitUntil(predicate: () => boolean): Promise { + for (let turn = 0; turn < 10_000; turn++) { + if (predicate()) return; + await new Promise((resolve) => setImmediate(resolve)); + } + throw new Error("condition was not reached"); +} + +const silentLogger: Logger = { + debug: () => undefined, + error: () => undefined, + info: () => undefined, + warn: () => undefined, +}; + +interface Setup { + readonly client: TestClient; + readonly driver: FakeDriver; + readonly errors: string[]; + readonly log: string[]; + readonly timer: ManualTimer; +} + +function setup( + producer: ( + context: ProducerStartContext, + log: string[] + ) => PilotProducer | Promise>, + options: { + readonly client?: Partial>; + readonly completionBatchSize?: number; + /** The pilot's database; default: one whose transactions always commit. */ + readonly database?: PilotDatabase; + readonly maintenance?: boolean; + readonly pilot?: Partial>; + readonly queueControlPollInterval?: { readonly milliseconds: number }; + readonly queues?: Readonly>; + readonly workers?: Workers; + } = {} +): Setup { + const driver = new FakeDriver(); + registerDriver(driver, { + backend: "fake", + capability: "runtime", + database: options.database ?? database, + operations: driver, + }); + const log: string[] = []; + const errors: string[] = []; + const timer = new ManualTimer(); + const client = new TestClient( + driver, + { + clientId: "lifetime-client", + ...(options.completionBatchSize === undefined + ? {} + : { completionBatchSize: options.completionBatchSize }), + eventLoopDelay: false, + leaderElectionDisabled: options.maintenance !== true, + ...(options.maintenance === true + ? { + maintenance: { + electionInterval: { milliseconds: 5 }, + rescuerInterval: { hours: 1 }, + }, + } + : {}), + logger: { + ...silentLogger, + error: (attributes, message) => { + errors.push(`${message}: ${String(attributes.error)}`); + }, + }, + pollOnly: true, + ...(options.queueControlPollInterval === undefined + ? {} + : { queueControlPollInterval: options.queueControlPollInterval }), + queues: options.queues ?? { + default: { maxWorkers: 5, pollInterval: { minutes: 1 } }, + }, + workers: options.workers ?? new Workers().add(job, () => undefined), + ...options.client, + }, + () => ({ + ...options.pilot, + startProducer: async (context) => { + log.push(`start ${context.queue.name}`); + return producer(context, log); + }, + }) + ); + overrideRuntimeTiming(client, { random: () => 0.5, timer }); + return { client, driver, errors, log, timer }; +} + +describe("pilot producer sessions", () => { + it("claims through the session and finishes each handed-off job once", async () => { + const finished: JobRow[] = []; + const claimed: JobRow[] = []; + const blockers = new Map(); + const { client, driver } = setup( + () => ({ + async claim(context, next) { + const result = await database.transaction((tx) => next({ tx })); + claimed.push(...result.jobs); + expect(context.kinds).toEqual([]); + expect(context.limit).toBe(5); + expect(context.retrySignal.aborted).toBe(false); + return result; + }, + jobFinished(row) { + finished.push(row); + }, + }), + { + workers: new Workers().add(job, async ({ job: row }) => { + await blockers.get(row.id)?.promise; + }), + } + ); + driver.available.push(jobRow(1n), jobRow(2n, "unknown_kind"), jobRow(3n)); + blockers.set(3n, deferred()); + + const run = await client.start(); + await waitUntil(() => finished.length === 2); + // Job 3 is still being worked. + expect(finished.map(({ id }) => id).sort()).toEqual([1n, 2n]); + blockers.get(3n)?.resolve(undefined); + await waitUntil(() => finished.length === 3); + await run.stop(); + + expect(driver.claims[0]?.tx).toEqual({ tx: "claim" }); + // Each is the row as claimed, reported exactly once. + expect(finished.length).toBe(3); + for (const row of finished) expect(claimed).toContain(row); + }); + + it("hands a session the kinds a client with fetchOnlyKnownKinds claims", async () => { + const kinds: (readonly string[])[] = []; + const { client } = setup( + () => ({ + async claim(context, next) { + kinds.push(context.kinds); + return database.transaction((tx) => next({ tx })); + }, + }), + { client: { fetchOnlyKnownKinds: true } } + ); + + const run = await client.start(); + await waitUntil(() => kinds.length > 0); + await run.stop(); + + expect(kinds[0]).toEqual([job.kind]); + }); + + it("finishes an undecodable job once, with its fallback row", async () => { + const finished: JobRow[] = []; + const fallback = { + ...jobRow(7n), + attempt: 1, + attemptedBy: ["lifetime-client"], + state: "running" as const, + }; + const { client } = setup(() => { + let served = false; + return { + claim() { + if (served) return Promise.resolve({ jobs: [] }); + served = true; + return Promise.resolve({ + decodeErrors: new Map([[fallback.id, new Error("bad args")]]), + jobs: [fallback], + }); + }, + jobFinished(row) { + finished.push(row); + }, + }; + }); + + const run = await client.start(); + await waitUntil(() => finished.length === 1); + await run.stop(); + + expect(finished).toEqual([fallback]); + expect(finished[0]).toBe(fallback); + }); + + it("retries a rejected claim after backoff and finishes nothing for it", async () => { + const finished: bigint[] = []; + let attempts = 0; + const { client, driver, timer } = setup(() => ({ + async claim(_context, next) { + attempts++; + if (attempts === 1) throw new Error("claim transaction failed"); + return database.transaction((tx) => next({ tx })); + }, + jobFinished(row) { + finished.push(row.id); + }, + })); + driver.available.push(jobRow(1n)); + + const run = await client.start(); + const backoff = await timer.waitFor(({ kind }) => kind === "delay"); + expect(attempts).toBe(1); + // The claim backs off like River's own claim failures. + await timer.advance(backoff.ms); + await waitUntil(() => finished.length === 1); + await run.stop(); + + expect(attempts).toBe(2); + expect(finished).toEqual([1n]); + }); + + it("stops the runtime when a claim breaks its contract", async () => { + const finished: bigint[] = []; + const worked: bigint[] = []; + const foreign = { + ...jobRow(9n, job.kind, "other"), + attempt: 1, + attemptedBy: ["lifetime-client"], + state: "running" as const, + }; + const { client } = setup( + () => ({ + claim: () => Promise.resolve({ jobs: [foreign] }), + jobFinished(row) { + finished.push(row.id); + }, + }), + { + workers: new Workers().add(job, ({ job: row }) => { + worked.push(row.id); + }), + } + ); + + const run = await client.start(); + await expect(run.completed).rejects.toMatchObject({ + cause: expect.objectContaining({ + message: expect.stringContaining("job 9 of another queue") as unknown, + }) as unknown, + }); + + expect(worked).toEqual([]); + expect(finished).toEqual([]); + }); + + it("stops the runtime when a claim breaks its contract during a stop", async () => { + const entered = deferred(); + const gate = deferred(); + const { client, errors } = setup(() => ({ + claim: async () => { + entered.resolve(undefined); + await gate.promise; + return { + jobs: [ + { + ...jobRow(9n, job.kind, "other"), + attempt: 1, + attemptedBy: ["lifetime-client"], + state: "running" as const, + }, + ], + }; + }, + })); + + const run = await client.start(); + await entered.promise; + const stopping = run.stop(); + gate.resolve(undefined); + await expect(run.completed).rejects.toMatchObject({ + cause: expect.objectContaining({ + message: expect.stringContaining("job 9 of another queue") as unknown, + }) as unknown, + }); + await stopping.catch(() => undefined); + + expect(errors).toContainEqual( + expect.stringContaining("River producer's claim broke its contract") + ); + }); + + it("rejects claims that break the rules River checks", async () => { + const running = (id: bigint, overrides: Partial = {}): JobRow => ({ + ...jobRow(id), + attempt: 1, + attemptedBy: ["lifetime-client"], + state: "running", + ...overrides, + }); + const withoutId = Object.fromEntries( + Object.entries(running(1n)).filter(([key]) => key !== "id") + ); + for (const [result, reason] of [ + [{ jobs: [running(1n), running(1n)] }, "job 1 twice"], + [{ jobs: [running(1n, { attempt: 0 })] }, "job 1, which has no attempt"], + [{ jobs: [withoutId] }, "a job without an ID"], + [{ decodeErrors: [] }, "a result without a job list"], + [ + { decodeErrors: new Map([[1n, "bad"]]), jobs: [running(1n)] }, + "a decode error that isn't an Error keyed by a job ID", + ], + [ + { decodeErrors: new Map([[2n, new Error("bad")]]), jobs: [] }, + "a decode error for job 2, which it didn't return", + ], + [{ jobs: [running(1n, { queue: "other" })] }, "job 1 of another queue"], + [{ jobs: [running(1n, { state: "available" })] }, "isn't running"], + [{ jobs: [running(1n, { attemptedBy: ["other"] })] }, "another client"], + [ + { + jobs: Array.from({ length: 6 }, (_, index) => + running(BigInt(index + 1)) + ), + }, + "6 jobs for a limit of 5", + ], + [null, "no claim result"], + ] as const) { + const { client } = setup(() => ({ + claim: () => Promise.resolve(result as unknown as JobClaimResult), + })); + const run = await client.start(); + await expect(run.completed).rejects.toMatchObject({ + cause: expect.objectContaining({ + message: expect.stringContaining(reason) as unknown, + }) as unknown, + }); + } + }); + + it("rejects a claimed job this client is already working", async () => { + const release = deferred(); + const row: JobRow = { + ...jobRow(1n), + attempt: 1, + attemptedBy: ["lifetime-client"], + state: "running", + }; + let claims = 0; + const { client } = setup( + () => ({ + claim: () => { + claims++; + return Promise.resolve({ + jobs: claims <= 2 ? [row] : [], + }); + }, + }), + { + queues: { + default: { maxWorkers: 5, pollInterval: { milliseconds: 100 } }, + }, + workers: new Workers().add(job, async () => { + await release.promise; + }), + } + ); + + const run = await client.start(); + let rejection: unknown; + void run.completed.catch((error: unknown) => { + rejection = error; + }); + await waitUntil(() => claims >= 2); + release.resolve(undefined); + await expect(run.completed).rejects.toMatchObject({ + cause: expect.objectContaining({ + message: expect.stringContaining( + "job 1, which this client is already working" + ) as unknown, + }) as unknown, + }); + expect(rejection).toBeDefined(); + }); + + it("applies configuration between claims, validating before River changes anything", async () => { + const configurations: ProducerConfiguration[] = []; + const claimGate = deferred(); + let claims = 0; + const { client, driver, errors, log } = setup( + (context, sessionLog) => { + configurations.push(context); + return { + async claim(_context, next) { + claims++; + sessionLog.push(`claim ${claims}`); + if (claims === 1) await claimGate.promise; + sessionLog.push(`claimed ${claims}`); + return database.transaction((tx) => next({ tx })); + }, + configurationChanged(configuration) { + sessionLog.push(`configure ${configuration.maxWorkers}`); + expect(Object.isFrozen(configuration.queue)).toBe(true); + expect(Object.isFrozen(configuration.queue.metadata)).toBe(true); + if (configuration.queue.metadata.bad === true) { + sessionLog.push("offered bad"); + } + if (configuration.queue.metadata.stale === true) { + sessionLog.push("offered stale"); + } + if (configuration.maxWorkers === 13) { + throw new ValidationError("13 workers are unlucky"); + } + if (configuration.queue.metadata.bad === true) { + throw new ValidationError("bad queue metadata"); + } + configurations.push(configuration); + }, + }; + }, + { + pilot: { + queueOptions: { + keys: ["limit"], + parse: (_queue, config) => ({ limit: config.limit ?? null }), + }, + }, + queueControlPollInterval: { milliseconds: 1 }, + queues: { + default: { + limit: 1, + maxWorkers: 5, + pollInterval: { minutes: 1 }, + } as QueueConfig, + }, + } + ); + + const run = await client.start(); + await waitUntil(() => log.includes("claim 1")); + const update = run.updateQueue("default", { + limit: 2, + maxWorkers: 3, + } as QueueConfig); + await new Promise((resolve) => setImmediate(resolve)); + // The configuration waits for the claim in flight. + expect(log).not.toContain("configure 3"); + claimGate.resolve(undefined); + await update; + expect(log.indexOf("claimed 1")).toBeLessThan(log.indexOf("configure 3")); + + await expect( + run.updateQueue("default", { maxWorkers: 13 }) + ).rejects.toThrow("13 workers are unlucky"); + expect(run.diagnostics.queues.default?.maxWorkers).toBe(3); + + // A persisted metadata change reaches the session too, and one it + // rejects is logged and ignored. + const row = driver.queues.get("default") as QueueRow; + driver.queues.set("default", { ...row, metadata: { region: "west" } }); + await waitUntil(() => + configurations.some(({ queue }) => queue.metadata.region === "west") + ); + driver.queues.set("default", { ...row, metadata: { bad: true } }); + const rejections = () => + errors.filter((line) => + line.startsWith( + "River producer rejected the queue's persisted configuration" + ) + ).length; + await waitUntil(() => rejections() === 1); + // Later polls don't offer, or log, the same rejected value again. + const polls = driver.queueGets; + await waitUntil(() => driver.queueGets >= polls + 5); + expect(rejections()).toBe(1); + expect(log.filter((entry) => entry === "offered bad")).toHaveLength(1); + // A row written before the one the session has is never offered. + driver.queues.set("default", { + ...row, + metadata: { stale: true }, + updatedAt: row.updatedAt.subtract({ seconds: 1 }), + }); + const stalePolls = driver.queueGets; + await waitUntil(() => driver.queueGets >= stalePolls + 5); + expect(log).not.toContain("offered stale"); + await run.stop(); + + expect( + configurations.map(({ maxWorkers, settings }) => [maxWorkers, settings]) + ).toEqual([ + [5, { limit: 1 }], + [3, { limit: 2 }], + [3, { limit: 2 }], + ]); + expect(configurations.at(-1)?.queue.metadata).toEqual({ region: "west" }); + }); + + it("keeps reporting through a long drain, then stops reports before shutdown", async () => { + const release = deferred(); + const reports: Temporal.Instant[] = []; + let reportSignal: AbortSignal | undefined; + let reportsAtShutdown: unknown; + const { client, driver, log, timer } = setup( + (_context, sessionLog) => ({ + keepAlive({ signal, staleBefore }) { + reports.push(staleBefore); + reportSignal = signal; + sessionLog.push("report"); + return Promise.resolve(); + }, + shutdown() { + sessionLog.push("shutdown"); + // Reports stopped before shutdown: none is scheduled, and the + // last one's signal aborted. + reportsAtShutdown = { + aborted: reportSignal?.aborted, + scheduled: timer.pending().some(({ ms }) => ms === 30_000), + }; + return Promise.resolve(); + }, + }), + { + workers: new Workers().add(job, async () => { + log.push("working"); + await release.promise; + log.push("worked"); + }), + } + ); + driver.available.push(jobRow(1n)); + + const run = await client.start(); + // The first report follows the initial jitter (half of one second with + // this randomness), later ones the report interval (30 s by default). + await timer.waitFor(({ ms }) => ms === 500); + await timer.advance(500); + await waitUntil(() => log.includes("report") && log.includes("working")); + const stopping = run.stop(); + await timer.waitFor(({ ms }) => ms === 30_000); + await timer.advance(30_000); + await waitUntil( + () => log.filter((entry) => entry === "report").length === 2 + ); + await timer.waitFor(({ ms }) => ms === 30_000); + await timer.advance(30_000); + await waitUntil( + () => log.filter((entry) => entry === "report").length === 3 + ); + expect(log).not.toContain("shutdown"); + release.resolve(undefined); + await stopping; + + expect(log.slice(-2)).toEqual(["worked", "shutdown"]); + expect(reportsAtShutdown).toEqual({ aborted: true, scheduled: false }); + const now = Temporal.Now.instant(); + for (const staleBefore of reports) { + expect(now.since(staleBefore).total("minutes")).toBeGreaterThanOrEqual(5); + } + }); + + it("reports at a fixed rate, never overlapping a slow report", async () => { + const reports: Deferred[] = []; + const { client, timer } = setup(() => ({ + keepAlive() { + const report = deferred(); + reports.push(report); + return report.promise; + }, + })); + + const run = await client.start(); + await timer.waitFor(({ ms }) => ms === 500); + await timer.advance(500); + expect(reports).toHaveLength(1); + // A report that takes 4 s delays the next by the rest of the interval. + await timer.advance(4_000); + reports[0]?.resolve(undefined); + await timer.waitFor(({ kind }) => kind === "delay"); + await timer.advance(25_999); + expect(reports).toHaveLength(1); + await timer.advance(1); + expect(reports).toHaveLength(2); + // One slower than the interval runs the next as soon as it settles, + // never alongside it. + await timer.advance(40_000); + expect(reports).toHaveLength(2); + reports[1]?.resolve(undefined); + await timer.waitFor(({ kind, ms }) => kind === "delay" && ms === 0); + await timer.advance(0); + expect(reports).toHaveLength(3); + reports[2]?.resolve(undefined); + await run.stop(); + }); + + it("tries shutdown four times with growing deadlines, one after another", async () => { + const attempts: Deferred[] = []; + const { client, errors, timer } = setup(() => ({ + shutdown({ signal }) { + const attempt = deferred(); + attempts.push(attempt); + // A noncooperative shutdown settles only when the test says so. + signal.addEventListener("abort", () => undefined); + return attempt.promise; + }, + })); + + const run = await client.start(); + const stopped = run.stop(); + for (const [index, deadline] of [100, 500, 2_500, 12_500].entries()) { + await waitUntil(() => attempts.length === index + 1); + await timer.waitFor( + ({ kind, ms }) => kind === "timeout" && ms === deadline + ); + await timer.advance(deadline); + await new Promise((resolve) => setImmediate(resolve)); + // The next attempt waits for this one to settle. + expect(attempts).toHaveLength(index + 1); + attempts[index]?.reject(new Error(`attempt ${index + 1} failed`)); + } + await stopped; + + expect(attempts).toHaveLength(4); + expect( + errors.filter((line) => line.startsWith("River producer shutdown failed")) + ).toHaveLength(4); + }); +}); + +describe("pilot queue generations", () => { + it("reserves a queue's name until its generation stopped", async () => { + const shutdowns: Deferred[] = []; + const { client, log } = setup((context, sessionLog) => ({ + shutdown() { + sessionLog.push(`shutdown ${context.queue.name}`); + const done = deferred(); + shutdowns.push(done); + return done.promise; + }, + })); + + const run = await client.start(); + await run.addQueue("extra", { maxWorkers: 1 }); + await expect( + run.addQueue("extra", { maxWorkers: 1 }) + ).rejects.toBeInstanceOf(ValidationError); + + const removal = run.removeQueue("extra"); + // A second removal joins the first. + const second = run.removeQueue("extra"); + await waitUntil(() => log.includes("shutdown extra")); + const readd = run.addQueue("extra", { maxWorkers: 2 }); + await new Promise((resolve) => setImmediate(resolve)); + // The new generation doesn't overlap the old one's shutdown. + expect(log.filter((entry) => entry === "start extra")).toHaveLength(1); + shutdowns[0]?.resolve(undefined); + await expect(removal).resolves.toBe(true); + await expect(second).resolves.toBe(true); + await readd; + expect(log.filter((entry) => entry === "start extra")).toHaveLength(2); + expect(run.diagnostics.queues.extra?.maxWorkers).toBe(2); + + const stopping = run.stop(); + await waitUntil(() => shutdowns.length === 3); + for (const done of shutdowns) done.resolve(undefined); + await stopping; + }); + + it("releases a queue whose producer failed to start, leaving the others running", async () => { + let fail = true; + const { client, log } = setup((context) => { + if (context.queue.name === "flaky" && fail) { + return Promise.reject(new Error("producer failed to start")); + } + return {}; + }); + + const run = await client.start(); + await expect(run.addQueue("flaky", { maxWorkers: 1 })).rejects.toThrow( + "producer failed to start" + ); + expect(Object.keys(run.diagnostics.queues)).toEqual(["default"]); + fail = false; + await run.addQueue("flaky", { maxWorkers: 1 }); + expect(Object.keys(run.diagnostics.queues).sort()).toEqual([ + "default", + "flaky", + ]); + await run.stop(); + + expect(log).toEqual(["start default", "start flaky", "start flaky"]); + }); + + it("shuts started producers down when the initial start fails", async () => { + const { client, log } = setup( + (context, sessionLog) => { + if (context.queue.name === "second") { + return Promise.reject(new Error("second producer failed")); + } + return { + shutdown() { + sessionLog.push(`shutdown ${context.queue.name}`); + return Promise.resolve(); + }, + }; + }, + { + queues: { + first: { maxWorkers: 1 }, + second: { maxWorkers: 1 }, + }, + } + ); + + await expect(client.start()).rejects.toThrow("second producer failed"); + + expect(log).toEqual(["start first", "start second", "shutdown first"]); + }); + + it("claims no more than the queue's current capacity", async () => { + const limits: number[] = []; + const { client, driver } = setup(() => ({ + claim(context, next) { + limits.push(context.limit); + return database.transaction((tx) => next({ tx })); + }, + })); + + const run = await client.start(); + await waitUntil(() => limits.length === 1); + driver.available.push(jobRow(1n)); + // Updating wakes the queue, which claims with its new capacity. + await run.updateQueue("default", { maxWorkers: 2 }); + await waitUntil(() => limits.length === 2); + await run.stop(); + + expect(limits.slice(0, 2)).toEqual([5, 2]); + }); +}); + +describe("pilot services and teardown", () => { + it("starts services before queues and ends them after producers drained", async () => { + const { client, log } = setup( + (context, sessionLog) => ({ + shutdown() { + sessionLog.push(`shutdown ${context.queue.name}`); + return Promise.resolve(); + }, + }), + { + pilot: { + services: () => [ + { + name: "watcher", + run: ({ signal }) => + new Promise((resolve) => { + log.push("service started"); + signal.addEventListener("abort", () => { + log.push("service stopped"); + resolve(); + }); + }), + }, + ], + }, + } + ); + + const run = await client.start(); + await run.stop(); + + expect(log).toEqual([ + "service started", + "start default", + "shutdown default", + "service stopped", + ]); + }); + + it("restarts a service that fails or returns early, after backoff that a healthy run resets", async () => { + let runs = 0; + const longRun = deferred(); + const { client, errors, timer } = setup(() => ({}), { + pilot: { + services: () => [ + { + name: "flaky", + run: ({ signal }) => { + runs++; + if (runs === 1) + return Promise.reject(new Error("service failed")); + if (runs === 2) return Promise.resolve(); + if (runs === 3) return longRun.promise; + return new Promise((resolve) => { + signal.addEventListener("abort", () => { + resolve(); + }); + }); + }, + }, + ], + }, + }); + + const run = await client.start(); + const first = await timer.waitFor(({ kind }) => kind === "delay"); + expect(runs).toBe(1); + await timer.advance(first.ms); + await waitUntil(() => runs === 2); + const second = await timer.waitFor(({ kind }) => kind === "delay"); + // Backoff grows while the service keeps failing quickly. + expect(second.ms).toBeGreaterThan(first.ms); + await timer.advance(second.ms); + await waitUntil(() => runs === 3); + // A failure after a healthy minute starts backoff over. + await timer.advance(60_000); + longRun.reject(new Error("service failed after a while")); + const third = await timer.waitFor(({ kind }) => kind === "delay"); + expect(third.ms).toBe(first.ms); + await timer.advance(third.ms); + await waitUntil(() => runs === 4); + await run.stop(); + + expect(runs).toBe(4); + expect( + errors.filter((line) => + line.startsWith("River service failed; restarting after backoff") + ) + ).toHaveLength(3); + }); + + it("doesn't restart a service once the runtime stopped", async () => { + let runs = 0; + const { client, timer } = setup(() => ({}), { + pilot: { + services: () => [ + { + name: "failing", + run: () => { + runs++; + return Promise.reject(new Error("service failed")); + }, + }, + ], + }, + }); + + const run = await client.start(); + await timer.waitFor(({ kind }) => kind === "delay"); + await run.stop(); + + expect(runs).toBe(1); + expect(timer.pending().filter(({ kind }) => kind === "delay")).toEqual([]); + }); + + it("shares one teardown between concurrent stops", async () => { + const release = deferred(); + let shutdowns = 0; + const { client } = setup(() => ({ + async shutdown() { + shutdowns++; + await release.promise; + }, + })); + + const run = await client.start(); + const first = run.stop(); + const second = run.stop({ mode: "cancel" }); + let settled = false; + void Promise.all([first, second]).then(() => { + settled = true; + }); + await waitUntil(() => shutdowns === 1); + await new Promise((resolve) => setImmediate(resolve)); + expect(settled).toBe(false); + release.resolve(undefined); + await Promise.all([first, second]); + + expect(shutdowns).toBe(1); + expect(run.state).toBe("stopped"); + }); + + it("waits for every running attempt after a fatal failure before shutting producers down", async () => { + const log: string[] = []; + const finished: bigint[] = []; + const brokenClaim = deferred(); + const slow = deferred(); + let working = 0; + const { client, driver } = setup( + (context) => ({ + ...(context.queue.name === "broken" + ? { + claim: async () => { + await brokenClaim.promise; + return { + jobs: [ + { + ...jobRow(9n, job.kind, "other"), + attempt: 1, + attemptedBy: ["lifetime-client"], + state: "running" as const, + }, + ], + }; + }, + } + : {}), + jobFinished(row) { + finished.push(row.id); + }, + shutdown() { + log.push(`shutdown ${context.queue.name}`); + return Promise.resolve(); + }, + }), + { + queues: { + broken: { maxWorkers: 1, pollInterval: { minutes: 1 } }, + default: { maxWorkers: 3, pollInterval: { minutes: 1 } }, + }, + workers: new Workers().add(job, async ({ job: row, signal }) => { + working++; + if (row.id === 1n) { + // This attempt ends, and fails to persist, as soon as the + // runtime fails. + await new Promise((resolve) => { + signal.addEventListener("abort", () => { + resolve(); + }); + }); + log.push("job 1 done"); + return; + } + await slow.promise; + log.push("job 2 done"); + }), + } + ); + driver.available.push(jobRow(1n), jobRow(2n)); + + const run = await client.start(); + await waitUntil(() => working === 2); + let settled = false; + void run.completed.catch(() => { + settled = true; + }); + brokenClaim.resolve(undefined); + await waitUntil(() => log.includes("job 1 done")); + for (let turn = 0; turn < 20; turn++) { + await new Promise((resolve) => setImmediate(resolve)); + } + // The other attempt is still running, so nothing shut down yet. + expect(log).not.toContain("shutdown default"); + expect(settled).toBe(false); + slow.resolve(undefined); + await expect(run.completed).rejects.toBeInstanceOf(Error); + + expect(log.indexOf("job 2 done")).toBeLessThan( + log.indexOf("shutdown default") + ); + expect(finished.toSorted()).toEqual([1n, 2n]); + }); + + it("tears down after a fatal failure before completed rejects", async () => { + const release = deferred(); + const log: string[] = []; + const foreign = { + ...jobRow(9n, job.kind, "other"), + attempt: 1, + attemptedBy: ["lifetime-client"], + state: "running" as const, + }; + const { client } = setup( + (context) => ({ + ...(context.queue.name === "broken" + ? { + claim: () => Promise.resolve({ jobs: [foreign] }), + } + : {}), + async shutdown() { + log.push(`shutdown ${context.queue.name}`); + if (context.queue.name === "default") await release.promise; + }, + }), + { + pilot: { + services: () => [ + { + name: "watcher", + run: ({ signal }) => + new Promise((resolve) => { + signal.addEventListener("abort", () => { + log.push("service stopped"); + resolve(); + }); + }), + }, + ], + }, + queues: { + broken: { maxWorkers: 1, pollInterval: { minutes: 1 } }, + default: { maxWorkers: 1, pollInterval: { minutes: 1 } }, + }, + } + ); + + const run = await client.start(); + let rejected: unknown; + void run.completed.catch((error: unknown) => { + rejected = error; + }); + await waitUntil(() => log.includes("shutdown default")); + await new Promise((resolve) => setImmediate(resolve)); + expect(run.state).toBe("failed"); + expect(rejected).toBeUndefined(); + release.resolve(undefined); + await expect(run.completed).rejects.toMatchObject({ + cause: expect.objectContaining({ + message: expect.stringContaining("job 9 of another queue") as unknown, + }) as unknown, + }); + await expect(run.stop()).rejects.toBe(rejected); + + expect(log.sort()).toEqual([ + "service stopped", + "shutdown broken", + "shutdown default", + ]); + }); + + it("limits background completion batches but never a transactional completion", async () => { + const gate = deferred(); + const txJob = defineJob({ kind: "tx_completion" }); + const completedInTx = deferred(); + const { client, driver } = setup(() => ({}), { + completionBatchSize: 1, + pilot: { completionConcurrency: 1 }, + workers: new Workers() + .add(job, () => undefined) + .add(txJob, async ({ completeTx }) => { + await completeTx({ tx: "application" }).catch(() => undefined); + completedInTx.resolve(undefined); + }), + }); + driver.completionGate = gate.promise; + driver.available.push(jobRow(1n), jobRow(2n), jobRow(3n, txJob.kind)); + + const run = await client.start(); + await waitUntil(() => driver.completionsInFlight === 1); + // A second batch waits for the permit; the transactional completion + // doesn't. + await completedInTx.promise; + expect(driver.maxCompletionsInFlight).toBe(1); + gate.resolve(undefined); + await run.stop(); + + expect(driver.maxCompletionsInFlight).toBe(1); + expect(driver.completionCalls).toContainEqual({ + tx: { tx: "application" }, + }); + expect( + driver.completionCalls.filter(({ tx }) => tx === undefined).length + ).toBeGreaterThanOrEqual(2); + }); + + it("holds a completion permit until a timed-out batch's query settles, and never times out a batch waiting for one", async () => { + const gate = deferred(); + let worked = 0; + const { client, driver, timer } = setup(() => ({}), { + completionBatchSize: 1, + pilot: { completionConcurrency: 1 }, + workers: new Workers().add(job, () => { + worked++; + }), + }); + driver.completionGate = gate.promise; + driver.available.push(jobRow(1n), jobRow(2n)); + const completionTimeouts = () => + timer + .pending() + .filter(({ kind, ms }) => kind === "timeout" && ms === 10_000).length; + + const run = await client.start(); + await waitUntil(() => driver.completionsInFlight === 1 && worked === 2); + for (let turn = 0; turn < 10; turn++) { + await new Promise((resolve) => setImmediate(resolve)); + } + // Only the batch in the database has a deadline; the other waits for + // the permit without one. + expect(completionTimeouts()).toBe(1); + const stopping = run.stop(); + // The first batch's attempt times out while its query still runs, so + // it keeps the permit. + await timer.advance(10_000); + const backoff = await timer.waitFor(({ kind }) => kind === "delay"); + await timer.advance(backoff.ms); + await timer.advance(60_000); + expect(driver.completionsInFlight).toBe(1); + expect( + driver.completionCalls.filter(({ tx }) => tx === undefined) + ).toHaveLength(1); + gate.resolve(undefined); + await stopping; + + expect(driver.maxCompletionsInFlight).toBe(1); + // A stop persisted both, though one waited far past a batch's deadline. + expect(driver.completedIds).toEqual(expect.arrayContaining([1n, 2n])); + }); + + it("rejects pilot settings River can't use", () => { + for (const pilot of [ + { completionConcurrency: 0 }, + { jobCleanerQueuesExcluded: "default" }, + { periodicJobs: {} }, + { services: [] }, + ]) { + expect(() => + setup(() => ({}), { + pilot: pilot as unknown as Partial>, + }) + ).toThrow("a pilot's"); + } + }); + + it("ends leadership and maintenance as a stop begins, while producers keep reporting through the drain", async () => { + const release = deferred(); + const log: string[] = []; + const { client, driver, timer } = setup( + (context) => ({ + keepAlive() { + log.push("report"); + return Promise.resolve(); + }, + shutdown() { + log.push(`shutdown ${context.queue.name}`); + return Promise.resolve(); + }, + }), + { + maintenance: true, + pilot: { + maintenanceServices: () => [ + { + name: "term", + run: ({ signal }) => { + log.push("term started"); + return new Promise((resolve) => { + signal.addEventListener("abort", () => { + log.push("term ended"); + resolve(); + }); + }); + }, + }, + ], + }, + workers: new Workers().add(job, async () => { + await release.promise; + log.push("worked"); + }), + } + ); + driver.available.push(jobRow(1n)); + + const run = await client.start(); + await waitUntil(() => log.includes("term started")); + await waitUntil(() => driver.available.length === 0); + await timer.waitFor(({ ms }) => ms === 500); + await timer.advance(500); + const stopping = run.stop(); + // Like River for Go, leadership ends before the drain does. + await waitUntil(() => log.includes("term ended")); + await waitUntil(() => driver.leader === null); + await timer.waitFor(({ ms }) => ms === 30_000); + await timer.advance(30_000); + release.resolve(undefined); + await stopping; + + expect(log).toEqual([ + "term started", + "report", + "term ended", + "report", + "worked", + "shutdown default", + ]); + }); +}); + +describe("pilot peer attempts", () => { + /** A job this client claimed as a peer, on attempt `attempt`. */ + function peerRow(id: bigint, attempt = 1, owner = "lifetime-client"): JobRow { + return { ...jobRow(id), attempt, attemptedBy: [owner], state: "running" }; + } + + /** A claim callback resolving with `jobs` and their decode errors. */ + function claimOf( + jobs: readonly JobRow[], + decodeErrors?: JobClaimResult["decodeErrors"] + ) { + return () => + Promise.resolve( + decodeErrors === undefined ? { jobs } : { decodeErrors, jobs } + ); + } + + type Work = ( + context: WorkContext, + attempts: PilotAttempts + ) => Promise | void; + + /** + * A client whose job handler gets the pilot's peer attempts, and whose + * pilot database logs its transactions. `commit` runs before a commit. + */ + function setupPeers( + work: Work, + options: { + readonly client?: Partial>; + readonly commit?: (tx: string) => Promise; + readonly pilot?: Partial>; + readonly producer?: ( + context: ProducerStartContext + ) => PilotProducer; + readonly worker?: WorkerOptions; + } = {} + ) { + const hosts: PilotHost[] = []; + const transactions: string[] = []; + let begun = 0; + const peerDatabase: PilotDatabase = { + ...database, + transaction: async (callback, transactionOptions = {}) => { + const signal = transactionOptions.signal; + signal?.throwIfAborted(); + const name = `tx${++begun}`; + transactions.push(`begin ${name}`); + try { + const result = await callback({ tx: name }); + // Like River's databases, an aborted signal rolls back. + signal?.throwIfAborted(); + await options.commit?.(name); + transactions.push(`commit ${name}`); + return result; + } catch (error: unknown) { + transactions.push(`rollback ${name}`); + throw error; + } + }, + }; + const bundle = setup((context) => options.producer?.(context) ?? {}, { + client: { + completionFlushInterval: { milliseconds: 0 }, + ...options.client, + }, + database: peerDatabase, + pilot: { + ...options.pilot, + init(host) { + hosts.push(host); + }, + }, + workers: new Workers().add( + job, + (context) => { + const host = hosts[0]; + if (host === undefined) throw new Error("the pilot has no host"); + return work(context, host.attempts); + }, + options.worker + ), + }); + return { + ...bundle, + /** The pilot's peer attempts. */ + attempts: () => { + const host = hosts[0]; + if (host === undefined) throw new Error("the pilot has no host"); + return host.attempts; + }, + /** The completion command persisted for job `id`. */ + command: (id: bigint) => + bundle.driver.completionCommands.find((command) => command.id === id), + transactions, + }; + } + + it("claims peers in its transaction and completes them like the attempt's own outcome", async () => { + const handled: bigint[] = []; + const intercepted: bigint[] = []; + let claimTx: unknown; + const { client, command, driver, transactions } = setupPeers( + async (context, attempts) => { + const claimed = await attempts.claim(context, ({ tx }) => { + claimTx = tx; + return Promise.resolve({ + jobs: [peerRow(2n), peerRow(3n), peerRow(4n)], + }); + }); + expect(claimed.map(({ id }) => id)).toEqual([2n, 3n, 4n]); + context.setMetadata("shared", true); + await attempts.complete(context, [ + { + job: claimed[0] as JobRow, + result: { + metadata: { item: "complete" }, + outcome: complete({ output: { value: "done" } }), + status: "succeeded", + }, + }, + { + job: claimed[1] as JobRow, + result: { error: new Error("peer failed"), status: "failed" }, + }, + { + job: claimed[2] as JobRow, + result: { outcome: snooze({ seconds: 30 }), status: "succeeded" }, + }, + ]); + }, + { + client: { + errorHandler: (context) => { + handled.push(context.job.id); + return { cancel: context.job.id === 3n }; + }, + }, + pilot: { + intercept: { + async complete(context, next) { + intercepted.push(...context.commands.map(({ id }) => id)); + return next(); + }, + }, + }, + } + ); + driver.available.push(jobRow(1n)); + + const run = await client.start(); + await waitUntil(() => command(1n) !== undefined); + await run.stop(); + + expect(claimTx).toEqual({ tx: "tx1" }); + expect(transactions.slice(0, 2)).toEqual(["begin tx1", "commit tx1"]); + expect(handled).toEqual([3n]); + expect(command(2n)).toMatchObject({ + kind: "complete", + metadata: { item: "complete", shared: true }, + output: { value: "done" }, + }); + expect(command(3n)).toMatchObject({ + kind: "cancel", + metadata: { shared: true }, + }); + expect(command(4n)).toMatchObject({ kind: "snooze" }); + // Every outcome goes through the pilot's completion interceptor, and + // the peers' persist before the attempt's own. + expect(intercepted.toSorted()).toEqual([1n, 2n, 3n, 4n]); + expect(driver.completionCommands.at(-1)?.id).toBe(1n); + }); + + it("rolls back a peer claim of jobs the attempt can't own", async () => { + const outcomes: string[] = []; + const secondClaimed = deferred(); + const release = deferred(); + const { client, command, driver, transactions } = setupPeers( + async (context, attempts) => { + if (context.job.id === 8n) { + // A second coordinator owns job 20 until the test ends. + await attempts.claim(context, claimOf([peerRow(20n)])); + secondClaimed.resolve(undefined); + await release.promise; + return; + } + await secondClaimed.promise; + const attempt = async (name: string, jobs: readonly JobRow[]) => { + try { + await attempts.claim(context, claimOf(jobs)); + outcomes.push(`${name}: claimed`); + } catch (error: unknown) { + expect(error).toBeInstanceOf(ExtensionError); + outcomes.push(`${name}: ${(error as Error).message}`); + } + }; + await attempt("twice", [peerRow(2n), peerRow(2n)]); + await attempt("own", [peerRow(1n)]); + await attempt("foreign", [peerRow(3n, 1, "other-client")]); + await attempt("available", [{ ...peerRow(4n), state: "available" }]); + await attempt("no attempt", [peerRow(5n, 0)]); + await attempt("worked", [peerRow(8n)]); + await attempt("owned", [peerRow(20n)]); + await attempt("first", [peerRow(9n)]); + await attempts.complete(context, [ + { job: peerRow(9n), result: { status: "succeeded" } }, + ]); + await attempt("stale", [peerRow(9n)]); + await attempt("retried", [peerRow(9n, 2)]); + release.resolve(undefined); + } + ); + driver.available.push(jobRow(1n), jobRow(8n)); + + const run = await client.start(); + await waitUntil(() => command(1n) !== undefined); + await run.stop(); + + expect(outcomes).toEqual([ + "twice: a peer claim returned job 2 twice", + "own: a peer claim returned job 1, the claiming attempt's own job", + "foreign: a peer claim returned job 3, which another client claimed", + "available: a peer claim returned job 4, which isn't running", + "no attempt: a peer claim returned job 5, which has no attempt", + "worked: a peer claim returned job 8, which this client already works", + "owned: a peer claim returned job 20, which this client already works as a peer", + "first: claimed", + "stale: a peer claim returned job 9 at attempt 1, which already ended here", + "retried: claimed", + ]); + // Every rejected claim rolled back; the rest committed. + expect( + transactions.filter((line) => line.startsWith("rollback")).length + ).toBe(8); + expect(driver.completionCommands.map(({ id }) => id)).not.toContain(2n); + }); + + it("accepts outcomes only for the attempt's own peers, once each", async () => { + const outcomes: string[] = []; + const secondClaimed = deferred(); + const release = deferred(); + const { client, command, driver } = setupPeers( + async (context, attempts) => { + if (context.job.id === 8n) { + await attempts.claim(context, claimOf([peerRow(20n)])); + secondClaimed.resolve(undefined); + await release.promise; + return; + } + await secondClaimed.promise; + const [peer] = await attempts.claim(context, claimOf([peerRow(2n)])); + if (peer === undefined) throw new Error("no peer"); + const succeeded = { status: "succeeded" } as const; + const attempt = async ( + name: string, + outcome: Parameters["complete"]>[1] + ) => { + try { + await attempts.complete(context, outcome); + outcomes.push(`${name}: completed`); + } catch (error: unknown) { + outcomes.push( + `${name}: ${(error as Error).constructor.name} ${(error as Error).message}` + ); + } + }; + await attempt("unclaimed", [{ job: peerRow(30n), result: succeeded }]); + await attempt("another's", [{ job: peerRow(20n), result: succeeded }]); + await attempt("stale", [ + { job: { ...peer, attempt: 2 }, result: succeeded }, + ]); + await attempt("another client's", [ + { + job: { ...peer, attemptedBy: ["other-client"] }, + result: succeeded, + }, + ]); + await attempt("twice", [ + { job: peer, result: succeeded }, + { job: peer, result: succeeded }, + ]); + await attempt("invalid", [ + { job: peer, result: { status: "unknown" } as never }, + ]); + // Two overlapping submissions: River accepts the first only. + const first = attempts.complete(context, [ + { job: peer, result: succeeded }, + ]); + const second = attempts.complete(context, [ + { job: peer, result: { error: new Error("late"), status: "failed" } }, + ]); + await expect(second).rejects.toThrow("job 2 already has an outcome"); + await first; + await attempt("again", [{ job: peer, result: succeeded }]); + release.resolve(undefined); + } + ); + driver.available.push(jobRow(1n), jobRow(8n)); + + const run = await client.start(); + await waitUntil(() => command(1n) !== undefined); + await run.stop(); + + expect(outcomes).toEqual([ + "unclaimed: ExtensionError job 30 isn't a peer of the attempt completing it", + "another's: ExtensionError job 20 isn't a peer of the attempt completing it", + "stale: ExtensionError job 2 attempt 2 isn't the peer attempt 1 this attempt owns", + "another client's: ExtensionError job 2 attempt 1 isn't the peer attempt 1 this attempt owns", + "twice: ExtensionError job 2 has two outcomes", + "invalid: ValidationError unknown work attempt result status", + "again: ExtensionError job 2 already has an outcome", + ]); + // The first overlapping outcome is the only one persisted. + expect( + driver.completionCommands.filter(({ id }) => id === 2n) + ).toMatchObject([{ kind: "complete" }]); + }); + + it("rejects a peer claim once the attempt ended, even while its own outcome persists", async () => { + let ended: WorkContext | undefined; + let late: Promise | undefined; + let peers: PilotAttempts | undefined; + const { client, command, driver, transactions } = setupPeers( + (context, peerAttempts) => { + ended = context; + peers = peerAttempts; + throw new Error("the attempt fails"); + }, + { + client: { + // Runs while River persists the attempt's own outcome. + retryPolicy: (_job, now) => { + late = peers?.claim(ended as WorkContext, claimOf([peerRow(3n)])); + void late?.catch(() => undefined); + return now; + }, + }, + } + ); + driver.available.push(jobRow(1n)); + + const run = await client.start(); + await waitUntil(() => command(1n) !== undefined); + await run.stop(); + + expect(late).toBeDefined(); + await expect(late).rejects.toBeInstanceOf(LifecycleError); + // No claim began, so nothing was claimed or left owned. + expect(transactions).toEqual([]); + expect(command(3n)).toBeUndefined(); + }); + + it("lets a producer claim a peer again as soon as its outcome persisted", async () => { + const delivered = deferred(); + const handedOff: bigint[] = []; + let reclaim = false; + const { client, command, driver, errors } = setupPeers( + async (context, attempts) => { + if (context.job.id !== 1n) return; + const [peer] = await attempts.claim(context, claimOf([peerRow(2n)])); + // Due again at once, and claimed while its event is delivered. + reclaim = true; + await attempts.complete(context, [ + { + job: peer as JobRow, + result: { outcome: snooze({ seconds: 0 }), status: "succeeded" }, + }, + ]); + }, + { + client: { + hooks: { + onEvent: async (event) => { + if (event.kind === "job_snoozed") await delivered.promise; + }, + }, + queues: { + default: { + fetchCooldown: { milliseconds: 1 }, + maxWorkers: 5, + pollInterval: { milliseconds: 5 }, + }, + }, + }, + producer: () => ({ + claim: async (_context, next) => { + if ( + reclaim && + command(2n) !== undefined && + handedOff.length === 0 + ) { + handedOff.push(2n); + return { jobs: [peerRow(2n)] }; + } + return database.transaction((tx) => next({ tx })); + }, + }), + } + ); + driver.available.push(jobRow(1n)); + + const run = await client.start(); + await waitUntil(() => handedOff.length === 1); + delivered.resolve(undefined); + await waitUntil( + () => driver.completionCommands.filter(({ id }) => id === 2n).length === 2 + ); + await run.stop(); + + expect(run.state).toBe("stopped"); + expect(errors).toEqual([]); + }); + + it("stops the runtime when a producer claim hands off a job owned as a peer", async () => { + const claimed = deferred(); + const release = deferred(); + let handOff = false; + const { client, driver, errors, timer } = setupPeers( + async (context, attempts) => { + await attempts.claim(context, claimOf([peerRow(2n)])); + handOff = true; + claimed.resolve(undefined); + await release.promise; + }, + { + client: { + queues: { + default: { + fetchCooldown: { milliseconds: 1 }, + maxWorkers: 5, + pollInterval: { milliseconds: 5 }, + }, + }, + }, + producer: () => ({ + claim: async (_context, next) => { + if (handOff) { + handOff = false; + return { jobs: [peerRow(2n)] }; + } + return database.transaction((tx) => next({ tx })); + }, + }), + } + ); + driver.available.push(jobRow(1n)); + + const run = await client.start(); + await claimed.promise; + // The next poll claims again, breaking the claim's contract; the + // runtime fails once the coordinator ends. + for (let turn = 0; turn < 100 && errors.length === 0; turn++) { + await timer.advance(5); + await new Promise((resolve) => setTimeout(resolve, 5)); + } + expect(errors).toEqual([ + `River producer's claim broke its contract; the runtime stops: a producer's claim for queue "default" returned job 2, which this client is already working`, + ]); + release.resolve(undefined); + await expect(run.completed).rejects.toMatchObject({ + cause: { message: expect.stringContaining("already working") }, + }); + }); + + it("fails peers the attempt left without an outcome before its own", async () => { + const handled: bigint[] = []; + const { client, command, driver } = setupPeers( + async (context, attempts) => { + const [peer] = await attempts.claim( + context, + claimOf([peerRow(2n), peerRow(3n)]) + ); + await attempts.complete(context, [ + { job: peer as JobRow, result: { status: "succeeded" } }, + ]); + }, + { + client: { + errorHandler: (context) => { + handled.push(context.job.id); + }, + }, + } + ); + driver.available.push(jobRow(1n)); + + const run = await client.start(); + await waitUntil(() => command(1n) !== undefined); + await run.stop(); + + expect(command(2n)).toMatchObject({ kind: "complete" }); + expect(command(3n)).toMatchObject({ kind: "retry" }); + expect(command(3n)?.error?.error).toContain( + "the attempt of job 1 ended without an outcome for this job" + ); + expect(handled).toEqual([3n]); + expect(driver.completionCommands.map(({ id }) => id)).toEqual([2n, 3n, 1n]); + }); + + it("interrupts peers when the runtime interrupts their attempt", async () => { + const claimed = deferred(); + const { client, command, driver } = setupPeers( + async (context, attempts) => { + await attempts.claim(context, claimOf([peerRow(2n)])); + claimed.resolve(undefined); + await new Promise((resolve) => { + context.signal.addEventListener("abort", resolve); + }); + context.signal.throwIfAborted(); + } + ); + driver.available.push(jobRow(1n)); + + const run = await client.start(); + await claimed.promise; + await run.stop({ mode: "cancel" }); + + expect(command(2n)).toMatchObject({ kind: "interrupt" }); + expect(command(1n)).toMatchObject({ kind: "interrupt" }); + }); + + it("waits for a claim still in flight when the attempt ends", async () => { + const started = deferred(); + const release = deferred(); + let ended: WorkContext | undefined; + let claim: Promise | undefined; + const { attempts, client, command, driver } = setupPeers( + (context, peerAttempts) => { + ended = context; + claim = peerAttempts.claim(context, async () => { + started.resolve(undefined); + await release.promise; + return { jobs: [peerRow(2n)] }; + }); + // The handler returns while its claim is still running. + } + ); + driver.available.push(jobRow(1n)); + + const run = await client.start(); + await started.promise; + for (let turn = 0; turn < 20; turn++) { + await new Promise((resolve) => setImmediate(resolve)); + } + // The attempt's own outcome waits for the claim, and the attempt + // accepts no new peer operation meanwhile. + expect(driver.completionCommands).toEqual([]); + await expect( + attempts().claim(ended as WorkContext, claimOf([peerRow(3n)])) + ).rejects.toBeInstanceOf(LifecycleError); + release.resolve(undefined); + await expect(claim).resolves.toMatchObject([{ id: 2n }]); + await waitUntil(() => command(1n) !== undefined); + await expect( + attempts().complete(ended as WorkContext, []) + ).rejects.toBeInstanceOf(LifecycleError); + await run.stop(); + + // The claimed peer got the outcome the attempt didn't give it. + expect(command(2n)).toMatchObject({ kind: "retry" }); + expect(driver.completionCommands.map(({ id }) => id)).toEqual([2n, 1n]); + }); + + it("owns nothing from a claim the attempt's cancellation rolled back", async () => { + const started = deferred(); + const release = deferred(); + let claim: Promise | undefined; + const { client, command, driver, transactions } = setupPeers( + async (context, attempts) => { + claim = attempts.claim(context, async () => { + started.resolve(undefined); + await release.promise; + return { jobs: [peerRow(2n)] }; + }); + await claim.catch(() => undefined); + context.signal.throwIfAborted(); + } + ); + driver.available.push(jobRow(1n)); + + const run = await client.start(); + await started.promise; + const stopping = run.stop({ mode: "cancel" }); + release.resolve(undefined); + await stopping; + + await expect(claim).rejects.toBeInstanceOf(LifecycleError); + expect(transactions).toEqual(["begin tx1", "rollback tx1"]); + expect(command(2n)).toBeUndefined(); + expect(command(1n)).toMatchObject({ kind: "interrupt" }); + }); + + it("owns the peers of a claim that committed as the attempt was cancelled", async () => { + const committing = deferred(); + const commit = deferred(); + const { client, command, driver } = setupPeers( + async (context, attempts) => { + const claimed = await attempts.claim(context, claimOf([peerRow(2n)])); + expect(claimed).toMatchObject([{ id: 2n }]); + context.signal.throwIfAborted(); + }, + { + commit: async () => { + committing.resolve(undefined); + await commit.promise; + }, + } + ); + driver.available.push(jobRow(1n)); + + const run = await client.start(); + await committing.promise; + const stopping = run.stop({ mode: "cancel" }); + commit.resolve(undefined); + await stopping; + + expect(command(2n)).toMatchObject({ kind: "interrupt" }); + expect(command(1n)).toMatchObject({ kind: "interrupt" }); + }); + + it("fails peers it can't decode or transform instead of returning them", async () => { + const handled: bigint[] = []; + let returned: readonly JobRow[] = []; + const { client, command, driver } = setupPeers( + async (context, attempts) => { + returned = await attempts.claim( + context, + claimOf( + [ + peerRow(2n), + { ...peerRow(3n), args: { malformed: true } }, + peerRow(4n), + ], + new Map([[4n, new Error("bad bytes")]]) + ) + ); + await attempts.complete( + context, + returned.map((job) => ({ job, result: { status: "succeeded" } })) + ); + }, + { + client: { + errorHandler: (context) => { + handled.push(context.job.id); + }, + plugins: [ + createJobArgsTransformPlugin({ + name: "envelope", + onRead: ({ args }) => { + if (args.malformed === true) throw new Error("malformed"); + return { ...args, read: true }; + }, + }), + ], + }, + } + ); + driver.available.push(jobRow(1n)); + + const run = await client.start(); + await waitUntil(() => command(1n) !== undefined); + await run.stop(); + + expect(returned).toMatchObject([{ args: { read: true }, id: 2n }]); + expect(command(2n)).toMatchObject({ kind: "complete" }); + expect(command(3n)).toMatchObject({ kind: "retry" }); + expect(command(3n)?.error?.error).toContain("malformed"); + expect(command(4n)).toMatchObject({ kind: "retry" }); + expect(command(4n)?.error?.error).toContain( + "job row couldn't be decoded: bad bytes" + ); + expect(handled.toSorted()).toEqual([3n, 4n]); + }); + + it("rejects peer operations outside a running attempt of this client", async () => { + let other: WorkContext | undefined; + const { attempts, client, command, driver } = setupPeers((context) => { + other = { ...context, client: {} as WorkContext["client"] }; + }); + // A context River didn't create for a running attempt. + const idle = { client, job: jobRow(1n) } as unknown as WorkContext; + await expect(attempts().claim(idle, claimOf([]))).rejects.toBeInstanceOf( + LifecycleError + ); + driver.available.push(jobRow(1n)); + + const run = await client.start(); + await waitUntil(() => command(1n) !== undefined); + await expect( + attempts().claim(other as WorkContext, claimOf([])) + ).rejects.toBeInstanceOf(ValidationError); + await expect(attempts().claim(idle, claimOf([]))).rejects.toBeInstanceOf( + LifecycleError + ); + await expect( + attempts().complete(null as unknown as WorkContext, []) + ).rejects.toBeInstanceOf(ValidationError); + await run.stop(); + }); + + it("retries a failed peer on its worker's retry policy, like Go", async () => { + const retryAt = Temporal.Now.instant().add({ hours: 1 }); + const { client, command, driver } = setupPeers( + async (context, attempts) => { + if (context.job.id !== 1n) return; + const [peer] = await attempts.claim(context, claimOf([peerRow(2n)])); + await attempts.complete(context, [ + { + job: peer as JobRow, + result: { error: new Error("peer failed"), status: "failed" }, + }, + ]); + }, + { worker: { retryPolicy: () => retryAt } } + ); + driver.available.push(jobRow(1n)); + + const run = await client.start(); + await waitUntil(() => command(1n) !== undefined); + await run.stop(); + + const retry = command(2n); + expect(retry).toMatchObject({ kind: "retry" }); + expect(retry?.scheduledAt?.equals(retryAt)).toBe(true); + }); +}); diff --git a/js/src/runtime/pilot-operations.test.ts b/js/src/runtime/pilot-operations.test.ts new file mode 100644 index 000000000..f3aaf015c --- /dev/null +++ b/js/src/runtime/pilot-operations.test.ts @@ -0,0 +1,655 @@ +import { describe, expect, it } from "vitest"; + +import type { + DriverInsertResult, + JobCompletionCommand, + JobCompletionResult, + JobInsertParams, + RuntimeDriver, + RuntimeLeader, +} from "../driver.js"; +import { ExtensionError, ValidationError } from "../errors.js"; +import type { JobRow } from "../job.js"; +import type { + Pilot, + PilotDatabase, + PilotInterceptors, + PreparedInsertParams, +} from "../pilot.js"; +import { PilotOperations, validatePreparedParams } from "./pilot-operations.js"; + +describe("PilotOperations", () => { + type Transaction = string; + + interface TestBundle { + readonly database: PilotDatabase; + readonly driver: RuntimeDriver; + /** Transaction boundaries and standard operations, in order. */ + readonly log: string[]; + } + + const setup = (): TestBundle => { + const log: string[] = []; + let transactions = 0; + const database: PilotDatabase = { + backend: "test", + connection: (callback) => Promise.resolve(callback("connection")), + deleteFinalizedJobs: () => Promise.resolve(0), + loadClaimed: () => Promise.resolve({ jobs: [] }), + notify: () => Promise.resolve(), + schema: null, + async transaction(callback, options = {}) { + // Like River's drivers, run directly in a caller's transaction, + // without a savepoint. + if (options.tx !== undefined) return callback(options.tx); + const tx = `tx${++transactions}`; + log.push(`begin ${tx}`); + try { + const result = await callback(tx); + log.push(`commit ${tx}`); + return result; + } catch (error: unknown) { + log.push(`rollback ${tx}`); + throw error; + } + }, + }; + const standard = async (name: string, value: T): Promise => { + log.push(`start ${name}`); + // A macrotask, so an interceptor that doesn't await `next` settles + // first. + await new Promise((resolve) => setTimeout(resolve, 1)); + log.push(`end ${name}`); + return value; + }; + const driver = { + jobCancel: (id: bigint, options?: { readonly tx?: Transaction }) => + standard(`cancel in ${options?.tx}`, jobRow(id, "cancelled")), + jobCompleteMany: ( + commands: readonly JobCompletionCommand[], + options?: { readonly tx?: Transaction } + ) => + standard( + `complete in ${options?.tx}`, + commands.map((command): JobCompletionResult => ({ + job: jobRow(command.id, "completed"), + key: `${command.id}:${command.attempt}:${command.attemptedBy}`, + status: "applied", + })) + ), + jobRetry: (id: bigint, options?: { readonly tx?: Transaction }) => + standard(`retry in ${options?.tx}`, jobRow(id, "available")), + maintenanceGetStuck: () => + standard("getStuck", [jobRow(5n, "running"), jobRow(6n, "running")]), + maintenanceRescue: ( + _leader: RuntimeLeader, + _before: Temporal.Instant, + jobs: readonly unknown[], + options?: { readonly tx?: Transaction } + ) => standard(`rescue in ${options?.tx}`, jobs.length), + } as unknown as RuntimeDriver; + return { database, driver, log }; + }; + + const operations = ( + bundle: TestBundle, + intercept: PilotInterceptors + ): PilotOperations => + new PilotOperations( + { intercept } satisfies Pilot, + bundle.database + ); + + const insertStandard = + (bundle: TestBundle) => + async ( + rows: readonly JobInsertParams[], + tx: Transaction | undefined + ): Promise => { + bundle.log.push( + `insert ${rows.map(({ kind }) => kind).join(",")} in ${tx}` + ); + await Promise.resolve(); + return rows.map((row, index) => ({ + job: { ...jobRow(BigInt(index + 1), "available"), kind: row.kind }, + status: "inserted" as const, + })); + }; + + it("runs the standard operation directly without an interceptor", async () => { + const bundle = setup(); + const ops = operations(bundle, {}); + + await expect( + ops.cancel(bundle.driver, 1n, undefined) + ).resolves.toMatchObject({ id: 1n }); + await expect( + ops.insert("insert", [params("a")], "caller", insertStandard(bundle)) + ).resolves.toHaveLength(1); + + expect(bundle.log).toEqual([ + "start cancel in undefined", + "end cancel in undefined", + "insert a in caller", + ]); + }); + + it("binds next to the operation's transaction", async () => { + const bundle = setup(); + const seen: string[] = []; + const ops = operations(bundle, { + async insert(context, next) { + seen.push(`${context.operation} ${context.tx}`); + const results = await next(); + seen.push(`after ${results.length}`); + return results; + }, + }); + + const results = await ops.insert( + "insertMany", + [params("a"), params("b")], + "caller", + insertStandard(bundle) + ); + + expect(results).toHaveLength(2); + expect(Object.isFrozen(results)).toBe(true); + expect(seen).toEqual(["insertMany caller", "after 2"]); + expect(bundle.log).toEqual(["insert a,b in caller"]); + }); + + it("inserts in the caller's transaction and leaves its commit or rollback to the caller", async () => { + for (const fail of [false, true]) { + const bundle = setup(); + const seen: string[] = []; + const ops = operations(bundle, { + async insert(context, next) { + seen.push(`interceptor in ${context.tx}`); + const results = await next(); + if (fail) throw new Error("interceptor failed"); + return results; + }, + }); + + const inserted = ops.insert( + "insertMany", + [params("a"), params("b")], + "caller", + insertStandard(bundle) + ); + + if (fail) { + await expect(inserted).rejects.toThrow("interceptor failed"); + } else { + await expect(inserted).resolves.toHaveLength(2); + } + expect(seen).toEqual(["interceptor in caller"]); + // No transaction of River's: no begin, commit, or rollback. + expect(bundle.log).toEqual(["insert a,b in caller"]); + } + }); + + it("passes the interceptor each row's arguments from before the argument transforms", async () => { + const bundle = setup(); + const seen: (readonly string[])[] = []; + const ops = operations(bundle, { + insert(context, next) { + seen.push(context.originalEncodedArgs); + return next(); + }, + }); + + await ops.insert( + "insertMany", + [params("a"), params("b")], + "caller", + insertStandard(bundle), + new AbortController().signal, + ['{"a":1}', '{"b":2}'] + ); + // Without them, the prepared rows' own arguments. + await ops.insert("insert", [params("c")], "caller", insertStandard(bundle)); + + expect(seen).toEqual([['{"a":1}', '{"b":2}'], ["{}"]]); + expect(Object.isFrozen(seen[0])).toBe(true); + }); + + it("inserts replacement rows one for one", async () => { + const bundle = setup(); + const ops = operations(bundle, { + insert: (context, next) => + next({ + params: context.params.map((row) => ({ + ...row, + kind: `${row.kind}2`, + })), + }), + }); + + await ops.insert( + "insertMany", + [params("a"), params("b")], + "caller", + insertStandard(bundle) + ); + + expect(bundle.log).toContain("insert a2,b2 in caller"); + }); + + it("rejects replacement rows that don't match the prepared rows", async () => { + for (const replacement of [ + { params: [params("a")] }, + { params: [params("a"), { ...params("b"), priority: 9 }] }, + {}, + ]) { + const bundle = setup(); + const ops = operations(bundle, { + insert: (_context, next) => + next(replacement as { readonly params: readonly JobInsertParams[] }), + }); + + await expect( + ops.insert( + "insertMany", + [params("a"), params("b")], + "caller", + insertStandard(bundle) + ) + ).rejects.toBeInstanceOf(ExtensionError); + expect(bundle.log).toEqual([]); + } + }); + + it("requires insert, complete, cancel, and retry to call next", async () => { + const bundle = setup(); + const skip = () => Promise.resolve(null as never); + const ops = operations(bundle, { + cancel: skip, + complete: skip, + insert: skip, + retry: skip, + }); + + await expect(ops.cancel(bundle.driver, 1n, undefined)).rejects.toThrow( + "cancel interceptor must call next() exactly once" + ); + await expect(ops.retry(bundle.driver, 1n, "caller")).rejects.toThrow( + "retry interceptor must call next() exactly once" + ); + await expect( + ops.complete(bundle.driver, [command(1n)], {}) + ).rejects.toThrow("complete interceptor must call next() exactly once"); + await expect( + ops.insert("insert", [params("a")], "caller", insertStandard(bundle)) + ).rejects.toThrow("insert interceptor must call next() exactly once"); + expect(bundle.log).toEqual([ + "begin tx1", + "rollback tx1", + "begin tx2", + "rollback tx2", + ]); + }); + + it("rejects a second call of next without running it", async () => { + const bundle = setup(); + let second: unknown; + const ops = operations(bundle, { + async cancel(_context, next) { + const job = await next(); + second = await next().catch((error: unknown) => error); + return job; + }, + }); + + await expect( + ops.cancel(bundle.driver, 1n, undefined) + ).resolves.toMatchObject({ id: 1n }); + + expect(second).toBeInstanceOf(ExtensionError); + expect((second as Error).message).toBe( + "cancel interceptor called next() more than once" + ); + expect(bundle.log.filter((line) => line.startsWith("start"))).toHaveLength( + 1 + ); + }); + + it("rejects next called after the interceptor settled", async () => { + const bundle = setup(); + let captured: (() => Promise) | undefined; + const ops = operations(bundle, { + getStuck(_context, next) { + captured = next; + return Promise.resolve([]); + }, + }); + + await expect( + ops.getStuck( + bundle.driver, + leader(), + Temporal.Now.instant(), + 0n, + 10, + batch() + ) + ).resolves.toEqual([]); + + await expect(captured?.()).rejects.toThrow( + "getStuck interceptor called next() after it settled" + ); + expect(bundle.log).toEqual([]); + }); + + it("awaits an unawaited next before failing, and never commits early", async () => { + const bundle = setup(); + const ops = operations(bundle, { + complete(_context, next) { + void next(); + return Promise.resolve([]); + }, + }); + + await expect( + ops.complete(bundle.driver, [command(1n)], {}) + ).rejects.toThrow( + "complete interceptor settled before its next() continuation did; await it" + ); + + expect(bundle.log).toEqual([ + "begin tx1", + "start complete in tx1", + "end complete in tx1", + "rollback tx1", + ]); + }); + + it("rejects a result other than the continuation's own", async () => { + const bundle = setup(); + const ops = operations(bundle, { + async complete(_context, next) { + const results = await next(); + return results.map((result) => ({ ...result })); + }, + }); + + await expect( + ops.complete(bundle.driver, [command(1n)], { + signal: new AbortController().signal, + }) + ).rejects.toThrow( + "complete interceptor must resolve with the result of next()" + ); + expect(bundle.log.at(-1)).toBe("rollback tx1"); + }); + + it("rejects a continuation result the interceptor changed", async () => { + const bundle = setup(); + const ops = operations(bundle, { + async retry(_context, next) { + const job = await next(); + if (job !== null) (job as { state: string }).state = "running"; + return job; + }, + async complete(_context, next) { + const results = await next(); + (results[0] as { status: string }).status = "stale"; + return results; + }, + }); + + await expect(ops.retry(bundle.driver, 1n, undefined)).rejects.toThrow( + "retry interceptor changed the result of next()" + ); + await expect( + ops.complete(bundle.driver, [command(1n)], {}) + ).rejects.toThrow("complete interceptor changed the result of next()"); + expect( + bundle.log.filter((line) => line.startsWith("rollback")) + ).toHaveLength(2); + }); + + it("fails with River's error when an interceptor swallows it", async () => { + const bundle = setup(); + const failure = new Error("standard failed"); + const ops = operations(bundle, { + async cancel(_context, next) { + await next().catch(() => undefined); + return null; + }, + }); + const driver = { + ...bundle.driver, + jobCancel: () => Promise.reject(failure), + } as RuntimeDriver; + + await expect(ops.cancel(driver, 1n, undefined)).rejects.toBe(failure); + expect(bundle.log).toEqual(["begin tx1", "rollback tx1"]); + }); + + it("lets getStuck and rescue replace River's operation", async () => { + const bundle = setup(); + const replaced = [jobRow(7n, "running"), jobRow(9n, "running")]; + const ops = operations(bundle, { + getStuck: () => Promise.resolve(replaced), + rescue: (context) => Promise.resolve(context.jobs.length - 1), + }); + + await expect( + ops.getStuck( + bundle.driver, + leader(), + Temporal.Now.instant(), + 6n, + 2, + batch() + ) + ).resolves.toEqual(replaced); + await expect( + ops.rescue( + bundle.driver, + leader(), + Temporal.Now.instant(), + [rescue(7n), rescue(9n)], + new AbortController().signal + ) + ).resolves.toBe(1); + expect(bundle.log).toEqual(["begin tx1", "commit tx1"]); + }); + + it("validates rows and counts that replace River's operation", async () => { + for (const rows of [ + [jobRow(7n, "running"), jobRow(7n, "running")], + [jobRow(5n, "running")], + [jobRow(7n, "available")], + [jobRow(7n, "running"), jobRow(8n, "running"), jobRow(9n, "running")], + ]) { + const bundle = setup(); + const ops = operations(bundle, { getStuck: () => Promise.resolve(rows) }); + await expect( + ops.getStuck( + bundle.driver, + leader(), + Temporal.Now.instant(), + 6n, + 2, + batch() + ) + ).rejects.toBeInstanceOf(ExtensionError); + } + for (const count of [-1, 3, 1.5, Number.NaN]) { + const bundle = setup(); + const ops = operations(bundle, { rescue: () => Promise.resolve(count) }); + await expect( + ops.rescue( + bundle.driver, + leader(), + Temporal.Now.instant(), + [rescue(7n), rescue(9n)], + new AbortController().signal + ) + ).rejects.toBeInstanceOf(ExtensionError); + expect(bundle.log).toEqual(["begin tx1", "rollback tx1"]); + } + }); + + it("returns what River's rescue returned when the interceptor calls next", async () => { + const bundle = setup(); + const ops = operations(bundle, { + rescue: (_context, next) => next(), + }); + + await expect( + ops.rescue( + bundle.driver, + leader(), + Temporal.Now.instant(), + [rescue(7n)], + new AbortController().signal + ) + ).resolves.toBe(1); + expect(bundle.log).toEqual([ + "begin tx1", + "start rescue in tx1", + "end rescue in tx1", + "commit tx1", + ]); + }); + + it("calls interceptors with intercept as this", async () => { + const bundle = setup(); + const intercept: PilotInterceptors & { seen?: unknown } = { + cancel(this: unknown, _context, next) { + intercept.seen = this; + return next(); + }, + }; + const ops = operations(bundle, intercept); + + await ops.cancel(bundle.driver, 1n, undefined); + + expect(intercept.seen).toBe(intercept); + }); +}); + +describe("validatePreparedParams", () => { + it("accepts prepared rows and snapshots the list", () => { + const rows = [stored("a"), { ...stored("b"), encodedArgs: "[1,null]" }]; + const validated = validatePreparedParams(rows); + + expect(validated).toEqual(rows); + expect(validated).not.toBe(rows); + expect(Object.isFrozen(validated)).toBe(true); + }); + + it("rejects rows River can't insert", () => { + for (const change of [ + // Arguments come from `encodedArgs` alone. + { args: {} }, + { encodedArgs: 1 }, + { encodedArgs: "{not json" }, + { kind: "" }, + { queue: "" }, + { maxAttempts: 0 }, + { priority: 5 }, + { metadata: null }, + { scheduledAt: "2026-01-01T00:00:00Z" }, + { createdAt: null }, + { state: "done" }, + { tags: [1] }, + { uniqueKey: "key" }, + { uniqueStates: ["done"] }, + ]) { + expect(() => + validatePreparedParams([{ ...stored("a"), ...change }]) + ).toThrow(ValidationError); + } + }); +}); + +/** A reinserted job's stored fields. */ +function stored(kind: string): PreparedInsertParams { + const { args, ...fields } = params(kind); + void args; + return fields; +} + +function params(kind: string): JobInsertParams { + return { + args: {}, + encodedArgs: "{}", + kind, + maxAttempts: 25, + metadata: {}, + priority: 1, + queue: "default", + scheduledAt: Temporal.Now.instant(), + state: "available", + tags: [], + uniqueKey: null, + uniqueStates: null, + }; +} + +function command(id: bigint): JobCompletionCommand { + return { + attempt: 1, + attemptedBy: "client", + error: null, + finalizedAt: Temporal.Now.instant(), + id, + kind: "complete", + output: null, + outputSet: false, + scheduledAt: null, + }; +} + +function jobRow(id: bigint, state: JobRow["state"]): JobRow { + const now = Temporal.Now.instant(); + return { + args: {}, + attempt: 1, + attemptedAt: now, + attemptedBy: ["client"], + createdAt: now, + errors: [], + finalizedAt: null, + id, + kind: "test", + maxAttempts: 25, + metadata: {}, + priority: 1, + queue: "default", + scheduledAt: now, + state, + tags: [], + uniqueKey: null, + uniqueStates: null, + }; +} + +function leader(): RuntimeLeader { + const now = Temporal.Now.instant(); + return { electedAt: now, expiresAt: now.add({ seconds: 30 }), leaderId: "l" }; +} + +function batch() { + return { signal: new AbortController().signal, timeoutMs: null }; +} + +function rescue(id: bigint) { + return { + error: { + at: Temporal.Now.instant(), + attempt: 1, + error: "stuck", + trace: "", + }, + finalizedAt: null, + id, + scheduledAt: Temporal.Now.instant(), + state: "retryable" as const, + }; +} From ad1ee320fda0a1335bc8fef4694c2d872a499c12 Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sun, 4 Oct 2026 17:18:54 -0500 Subject: [PATCH 26/43] add cron schedules that fire like River Go's `cron()` builds a periodic schedule from a cron expression parsed exactly as River Go parses it, with robfig/cron's `ParseStandard`: five-field expressions, descriptors such as `@daily`, `@every` with Go's `time.ParseDuration` syntax, and `CRON_TZ=` prefixes. Next times match Go's, including across daylight saving transitions. Expressions without a `CRON_TZ=` prefix or `timeZone` option use the process's local time zone, as in Go. Tests run River's shared cron fixtures, copied with their provenance, plus property tests that occurrences are strictly increasing whole seconds and that each is found again from just before it. --- js/src/cron.property.test.ts | 156 +++++ js/src/cron.test.ts | 921 ++++++++++++++++++++++++++++ js/src/cron.ts | 736 ++++++++++++++++++++++ js/src/index.ts | 2 + js/src/internal/go-duration.test.ts | 130 ++++ js/src/internal/go-duration.ts | 146 +++++ js/src/testdata/cron-goldens.json | 564 +++++++++++++++++ 7 files changed, 2655 insertions(+) create mode 100644 js/src/cron.property.test.ts create mode 100644 js/src/cron.test.ts create mode 100644 js/src/cron.ts create mode 100644 js/src/internal/go-duration.test.ts create mode 100644 js/src/internal/go-duration.ts create mode 100644 js/src/testdata/cron-goldens.json diff --git a/js/src/cron.property.test.ts b/js/src/cron.property.test.ts new file mode 100644 index 000000000..3c978059e --- /dev/null +++ b/js/src/cron.property.test.ts @@ -0,0 +1,156 @@ +import fc from "fast-check"; +import { describe, expect, it } from "vitest"; + +import { cron } from "./cron.js"; + +/** + * Zones whose transitions skip or repeat whole wall-clock hours away from + * midnight, plus fixed and fractional offsets. + */ +const HOUR_ALIGNED_TIME_ZONES = [ + "America/New_York", + "America/St_Johns", + "Asia/Kolkata", + "Europe/Berlin", + "Europe/London", + "UTC", + "+05:45", +] as const; + +/** + * Zones that shift by half an hour, at a quarter past, or at midnight. Here + * robfig's hour and minute loops can step across a skipped wall time without + * checking earlier fields again, so River Go may fire at a time that does not + * match its own expression, such as 02:40 for hours `1,3` on Lord Howe + * Island. The port keeps that behavior. + */ +const IRREGULAR_TIME_ZONES = [ + "America/Sao_Paulo", + "Australia/Lord_Howe", + "Pacific/Chatham", +] as const; + +const MONTH_NAMES = ["jan", "FEB", "Mar", "jun", "Oct", "dec"] as const; +const WEEKDAY_NAMES = ["sun", "MON", "Tue", "fri", "sat"] as const; + +/** One list item: a value or range, optionally stepped, or a wildcard. */ +function rangeExpression( + minimum: number, + maximum: number, + names: readonly string[] = [] +): fc.Arbitrary { + const value = fc.integer({ max: maximum, min: minimum }); + const bound = + names.length === 0 + ? value.map(String) + : fc.oneof(value.map(String), fc.constantFrom(...names)); + const step = fc.option(fc.integer({ max: maximum + 1, min: 1 }), { + nil: undefined, + }); + const base = fc.oneof( + fc.constantFrom("*", "?"), + bound, + fc + .tuple(value, value) + .map(([low, high]) => `${Math.min(low, high)}-${Math.max(low, high)}`) + ); + return fc + .tuple(base, step) + .map(([range, every]) => + every === undefined ? range : `${range}/${every}` + ); +} + +function field( + minimum: number, + maximum: number, + names: readonly string[] = [] +): fc.Arbitrary { + return fc + .array(rangeExpression(minimum, maximum, names), { + maxLength: 3, + minLength: 1, + }) + .map((items) => items.join(",")); +} + +/** Valid five-field expressions River Go accepts. */ +const fieldsExpression = fc + .tuple( + field(0, 59), + field(0, 23), + fc.oneof(fc.constant("*"), field(1, 31)), + fc.oneof(fc.constant("*"), field(1, 12, MONTH_NAMES)), + fc.oneof(fc.constant("*"), field(0, 6, WEEKDAY_NAMES)) + ) + .map((fields) => fields.join(" ")); + +/** Valid expressions, including descriptors. */ +const expression = fc.oneof( + { arbitrary: fieldsExpression, weight: 4 }, + { + arbitrary: fc.constantFrom( + "@yearly", + "@monthly", + "@weekly", + "@daily", + "@hourly", + "@every 1h30m", + "@every 90s", + "@every 1.5s" + ), + weight: 1, + } +); + +/** Instants from 2000 to 2040 with arbitrary sub-second parts. */ +const instant = fc + .bigInt({ max: 2_208_988_800_000_000_000n, min: 946_684_800_000_000_000n }) + .map((nanoseconds) => Temporal.Instant.fromEpochNanoseconds(nanoseconds)); + +describe("cron properties", () => { + it("returns strictly increasing whole-second occurrences", () => { + fc.assert( + fc.property( + expression, + fc.constantFrom(...HOUR_ALIGNED_TIME_ZONES, ...IRREGULAR_TIME_ZONES), + instant, + (text, timeZone, from) => { + const schedule = cron(text, { timeZone }); + let previous = from; + for (let index = 0; index < 4; index++) { + const next = schedule.next(previous); + if (next === null) break; + expect(Temporal.Instant.compare(next, previous)).toBe(1); + expect(next.epochNanoseconds % 1_000_000_000n).toBe(0n); + previous = next; + } + } + ), + { numRuns: 300 } + ); + }); + + it("finds each occurrence again from just before it", () => { + fc.assert( + fc.property( + fieldsExpression, + fc.constantFrom(...HOUR_ALIGNED_TIME_ZONES), + instant, + (text, timeZone, from) => { + const schedule = cron(text, { timeZone }); + let previous = from; + for (let index = 0; index < 4; index++) { + const next = schedule.next(previous); + if (next === null) break; + expect( + schedule.next(next.subtract({ nanoseconds: 1 }))?.equals(next) + ).toBe(true); + previous = next; + } + } + ), + { numRuns: 300 } + ); + }); +}); diff --git a/js/src/cron.test.ts b/js/src/cron.test.ts new file mode 100644 index 000000000..d5b2f144d --- /dev/null +++ b/js/src/cron.test.ts @@ -0,0 +1,921 @@ +import { readFile } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; + +import { + allCronBits, + cron, + cronBits, + DAYS_OF_MONTH, + DAYS_OF_WEEK, + HOURS, + MINUTES, + MONTHS, + parseCronExpression, + parseCronField, + parseCronRange, + STAR_BIT, + type CronSchedule, + type CronSpec, +} from "./cron.js"; +import { ConfigurationError } from "./errors.js"; +import { defineJob } from "./job-definition.js"; +import { periodicJob } from "./periodic.js"; + +interface CronGoldens { + readonly cron_cases: readonly { + readonly expression: string; + readonly from: string; + readonly name: string; + readonly next: readonly string[]; + }[]; + readonly cron_invalid: readonly string[]; + readonly cron_named_zone_cases: readonly { + readonly expression: string; + readonly from: string; + readonly name: string; + readonly next: readonly string[]; + }[]; +} + +/** River Go's cron goldens, recorded with robfig/cron; see the file. */ +const GOLDENS = new URL("./testdata/cron-goldens.json", import.meta.url); + +/** Up to `count` successive occurrences after `from`, like robfig's tests. */ +function occurrences( + schedule: CronSchedule, + from: Temporal.Instant, + count: number +): Temporal.Instant[] { + const result: Temporal.Instant[] = []; + let current = from; + while (result.length < count) { + const next = schedule.next(current); + if (next === null) break; + result.push(next); + current = next; + } + return result; +} + +/** RFC 3339 in `timeZone`, with Go's `Z` for a zero offset. */ +function format(instant: Temporal.Instant, timeZone: string): string { + return instant + .toZonedDateTimeISO(timeZone) + .toString({ timeZoneName: "never" }) + .replace(/\+00:00$/, "Z"); +} + +/** The fixed offset an RFC 3339 time was written in, as a time zone. */ +function offsetZone(rfc3339: string): string { + const offset = /(?:Z|[+-]\d{2}:\d{2})$/i.exec(rfc3339)?.[0]; + if (offset === undefined) throw new Error(`no offset in ${rfc3339}`); + return offset.toUpperCase() === "Z" ? "UTC" : offset; +} + +/** + * Next occurrence after `from` (RFC 3339, evaluated in its offset), or null. + * robfig's tests run in `time.Local`; these use UTC for the same wall times. + */ +function nextAfter(expression: string, from: string, timeZone?: string) { + const zone = timeZone ?? offsetZone(from); + const next = cron(expression, { timeZone: zone }).next( + Temporal.Instant.from(from) + ); + return next === null ? null : format(next, zone); +} + +function spec(expression: string): CronSpec { + return parseCronExpression(expression).spec; +} + +function fieldsSpec(fields: { + dom?: bigint; + dow?: bigint; + hour?: bigint; + minute: bigint; + month?: bigint; +}): CronSpec { + return { + dom: fields.dom ?? allCronBits(DAYS_OF_MONTH), + dow: fields.dow ?? allCronBits(DAYS_OF_WEEK), + hour: fields.hour ?? allCronBits(HOURS), + kind: "fields", + minute: fields.minute, + month: fields.month ?? allCronBits(MONTHS), + }; +} + +const midnight = fieldsSpec({ hour: 1n, minute: 1n }); + +describe("cron", () => { + describe("River Go goldens", () => { + const load = async (): Promise => + JSON.parse(await readFile(GOLDENS, "utf8")) as CronGoldens; + + it("returns robfig's successive occurrences in the reference offset", async () => { + const goldens = await load(); + expect(goldens.cron_cases.length).toBeGreaterThan(0); + for (const golden of goldens.cron_cases) { + const zone = offsetZone(golden.from); + const schedule = cron(golden.expression, { timeZone: zone }); + expect( + occurrences(schedule, Temporal.Instant.from(golden.from), 5).map( + (next) => format(next, zone) + ), + golden.name + ).toEqual(golden.next); + } + }); + + it("resolves IANA CRON_TZ zones across DST like Go's time.Date", async () => { + const goldens = await load(); + expect(goldens.cron_named_zone_cases.length).toBeGreaterThan(0); + for (const golden of goldens.cron_named_zone_cases) { + // The prefix names the zone; any default zone must not matter. + const zone = offsetZone(golden.from); + const schedule = cron(golden.expression, { + timeZone: "Pacific/Chatham", + }); + expect( + occurrences(schedule, Temporal.Instant.from(golden.from), 5).map( + (next) => format(next, zone) + ), + golden.name + ).toEqual(golden.next); + } + }); + + it("rejects every expression robfig rejects", async () => { + const goldens = await load(); + expect(goldens.cron_invalid.length).toBeGreaterThan(0); + for (const expression of goldens.cron_invalid) { + expect(() => cron(expression), JSON.stringify(expression)).toThrow( + ConfigurationError + ); + } + }); + }); + + // robfig/cron v3.0.1 parser_test.go and spec_test.go, limited to the + // standard five-field parser River Go documents. + describe("robfig parser tests", () => { + it.each<[string, number, number, bigint, string]>([ + ["5", 0, 7, 1n << 5n, ""], + ["0", 0, 7, 1n << 0n, ""], + ["7", 0, 7, 1n << 7n, ""], + ["5-5", 0, 7, 1n << 5n, ""], + ["5-6", 0, 7, (1n << 5n) | (1n << 6n), ""], + ["5-7", 0, 7, (1n << 5n) | (1n << 6n) | (1n << 7n), ""], + ["5-6/2", 0, 7, 1n << 5n, ""], + ["5-7/2", 0, 7, (1n << 5n) | (1n << 7n), ""], + ["5-7/1", 0, 7, (1n << 5n) | (1n << 6n) | (1n << 7n), ""], + ["*", 1, 3, (1n << 1n) | (1n << 2n) | (1n << 3n) | STAR_BIT, ""], + ["*/2", 1, 3, (1n << 1n) | (1n << 3n), ""], + ["5--5", 0, 0, 0n, "too many hyphens"], + ["jan-x", 0, 0, 0n, "failed to parse int from"], + ["2-x", 1, 5, 0n, "failed to parse int from"], + ["*/-12", 0, 0, 0n, "negative number"], + ["*//2", 0, 0, 0n, "too many slashes"], + ["1", 3, 5, 0n, "below minimum"], + ["6", 3, 5, 0n, "above maximum"], + ["5-3", 3, 5, 0n, "beyond end of range"], + ["*/0", 0, 0, 0n, "should be a positive number"], + ])( + "parses range %j in [%i, %i]", + (expression, minimum, maximum, bits, error) => { + const parse = () => parseCronRange(expression, { maximum, minimum }); + if (error === "") { + expect(parse()).toBe(bits); + } else { + expect(parse).toThrow(error); + } + } + ); + + it.each<[string, number, number, bigint]>([ + ["5", 1, 7, 1n << 5n], + ["5,6", 1, 7, (1n << 5n) | (1n << 6n)], + ["5,6,7", 1, 7, (1n << 5n) | (1n << 6n) | (1n << 7n)], + ["1,5-7/2,3", 1, 7, (1n << 1n) | (1n << 5n) | (1n << 7n) | (1n << 3n)], + ])("parses field %j in [%i, %i]", (expression, minimum, maximum, bits) => { + expect(parseCronField(expression, { maximum, minimum })).toBe(bits); + }); + + it("sets every value and the star bit for a wildcard", () => { + expect(allCronBits(MINUTES)).toBe(0xfffffffffffffffn | STAR_BIT); + expect(allCronBits(HOURS)).toBe(0xffffffn | STAR_BIT); + expect(allCronBits(DAYS_OF_MONTH)).toBe(0xfffffffen | STAR_BIT); + expect(allCronBits(MONTHS)).toBe(0x1ffen | STAR_BIT); + expect(allCronBits(DAYS_OF_WEEK)).toBe(0x7fn | STAR_BIT); + }); + + it.each<[number, number, number, bigint]>([ + [0, 0, 1, 0x1n], + [1, 1, 1, 0x2n], + [1, 5, 2, 0x2an], + [1, 4, 2, 0xan], + ])("sets bits %i-%i/%i", (minimum, maximum, step, bits) => { + expect(cronBits(minimum, maximum, step)).toBe(bits); + }); + + it("parses schedules, descriptors, and time zone prefixes", () => { + const every5min = fieldsSpec({ minute: 1n << 5n }); + expect(parseCronExpression("5 * * * *")).toEqual({ + spec: every5min, + timeZone: undefined, + }); + expect(parseCronExpression("CRON_TZ=UTC 5 * * * *")).toEqual({ + spec: every5min, + timeZone: "UTC", + }); + expect(parseCronExpression("CRON_TZ=Asia/Tokyo 5 * * * *")).toEqual({ + spec: every5min, + timeZone: "Asia/Tokyo", + }); + expect(spec("@every 5m")).toEqual({ + delayNanoseconds: 300_000_000_000n, + kind: "every", + }); + expect(spec("@midnight")).toEqual(midnight); + expect(parseCronExpression("TZ=UTC @midnight")).toEqual({ + spec: midnight, + timeZone: "UTC", + }); + expect(parseCronExpression("TZ=Asia/Tokyo @midnight")).toEqual({ + spec: midnight, + timeZone: "Asia/Tokyo", + }); + const annual = fieldsSpec({ + dom: 1n << 1n, + hour: 1n, + minute: 1n, + month: 1n << 1n, + }); + expect(spec("@yearly")).toEqual(annual); + expect(spec("@annually")).toEqual(annual); + expect(spec("@monthly")).toEqual( + fieldsSpec({ dom: 1n << 1n, hour: 1n, minute: 1n }) + ); + expect(spec("@weekly")).toEqual( + fieldsSpec({ dow: 1n, hour: 1n, minute: 1n }) + ); + expect(spec("@daily")).toEqual(midnight); + expect(spec("@hourly")).toEqual(fieldsSpec({ minute: 1n })); + }); + + it.each([ + ["5 j * * *", "failed to parse int from"], + ["* * * *", "expected exactly 5 fields"], + ["* 5 j * * *", "expected exactly 5 fields"], + ["@every Xm", "failed to parse duration"], + ["@unrecognized", "unrecognized descriptor"], + ["", "empty spec string"], + ["xyz", "expected exactly 5 fields"], + ["60 0 * * *", "above maximum"], + ["0 60 * * *", "above maximum"], + ["0 0 * * XYZ", "failed to parse int from"], + // robfig issue 144: a zero step must not hang. + ["TZ=America/New_York 15/0 * * * *", "should be a positive number"], + ])("rejects %j", (expression, message) => { + expect(() => cron(expression)).toThrow(message); + }); + }); + + describe("robfig Next tests", () => { + it.each<[string, string, boolean]>([ + // Every fifteen minutes. + ["2012-07-09T15:00:00Z", "0/15 * * * *", true], + ["2012-07-09T15:45:00Z", "0/15 * * * *", true], + ["2012-07-09T15:40:00Z", "0/15 * * * *", false], + // Every fifteen minutes, starting at 5 minutes. + ["2012-07-09T15:05:00Z", "5/15 * * * *", true], + ["2012-07-09T15:20:00Z", "5/15 * * * *", true], + ["2012-07-09T15:50:00Z", "5/15 * * * *", true], + // Named months. + ["2012-07-15T15:00:00Z", "0/15 * * Jul *", true], + ["2012-07-15T15:00:00Z", "0/15 * * Jun *", false], + // Everything set. + ["2012-07-15T08:30:00Z", "30 08 ? Jul Sun", true], + ["2012-07-15T08:30:00Z", "30 08 15 Jul ?", true], + ["2012-07-16T08:30:00Z", "30 08 ? Jul Sun", false], + ["2012-07-16T08:30:00Z", "30 08 15 Jul ?", false], + // Predefined schedules. + ["2012-07-09T15:00:00Z", "@hourly", true], + ["2012-07-09T15:04:00Z", "@hourly", false], + ["2012-07-09T15:00:00Z", "@daily", false], + ["2012-07-09T00:00:00Z", "@daily", true], + ["2012-07-09T00:00:00Z", "@weekly", false], + ["2012-07-08T00:00:00Z", "@weekly", true], + ["2012-07-08T01:00:00Z", "@weekly", false], + ["2012-07-08T00:00:00Z", "@monthly", false], + ["2012-07-01T00:00:00Z", "@monthly", true], + // When both day fields are restricted, only one needs to match. + ["2012-07-15T00:00:00Z", "* * 1,15 * Sun", true], + ["2012-06-15T00:00:00Z", "* * 1,15 * Sun", true], + ["2012-08-01T00:00:00Z", "* * 1,15 * Sun", true], + ["2012-07-15T00:00:00Z", "* * */10 * Sun", true], + // When either is a wildcard, both need to match. + ["2012-07-15T00:00:00Z", "* * * * Mon", false], + ["2012-07-09T00:00:00Z", "* * 1,15 * *", false], + ["2012-07-15T00:00:00Z", "* * 1,15 * *", true], + ["2012-07-15T00:00:00Z", "* * */2 * Sun", true], + ])("activates at %s for %j: %s", (time, expression, expected) => { + const before = Temporal.Instant.from(time).subtract({ seconds: 1 }); + const next = nextAfter(expression, before.toString(), "UTC"); + expect(next === time).toBe(expected); + }); + + // robfig's seconds-field cases are omitted; a leading `0` seconds field + // is dropped from the rest. + it.each<[string, string, string | null, string?]>([ + // Simple cases. + ["2012-07-09T14:45:00Z", "0/15 * * * *", "2012-07-09T15:00:00Z"], + ["2012-07-09T14:59:00Z", "0/15 * * * *", "2012-07-09T15:00:00Z"], + ["2012-07-09T14:59:59Z", "0/15 * * * *", "2012-07-09T15:00:00Z"], + // Wrap around hours. + ["2012-07-09T15:45:00Z", "20-35/15 * * * *", "2012-07-09T16:20:00Z"], + // Wrap around days. + ["2012-07-09T23:46:00Z", "*/15 * * * *", "2012-07-10T00:00:00Z"], + ["2012-07-09T23:45:00Z", "20-35/15 * * * *", "2012-07-10T00:20:00Z"], + // Wrap around months. + ["2012-07-09T23:35:00Z", "0 0 9 Apr-Oct ?", "2012-08-09T00:00:00Z"], + [ + "2012-07-09T23:35:00Z", + "0 0 */5 Apr,Aug,Oct Mon", + "2012-08-01T00:00:00Z", + ], + ["2012-07-09T23:35:00Z", "0 0 */5 Oct Mon", "2012-10-01T00:00:00Z"], + // Wrap around years. + ["2012-07-09T23:35:00Z", "0 0 * Feb Mon", "2013-02-04T00:00:00Z"], + ["2012-07-09T23:35:00Z", "0 0 * Feb Mon/2", "2013-02-01T00:00:00Z"], + // Wrap around minute, hour, day, month, and year. + ["2012-12-31T23:59:45Z", "* * * * *", "2013-01-01T00:00:00Z"], + // Leap year. + ["2012-07-09T23:35:00Z", "0 0 29 Feb ?", "2016-02-29T00:00:00Z"], + // Daylight saving time 2am EST (-5) -> 3am EDT (-4). + [ + "2012-03-11T00:00:00-05:00", + "TZ=America/New_York 30 2 11 Mar ?", + "2013-03-11T02:30:00-04:00", + ], + // Hourly job. + [ + "2012-03-11T00:00:00-05:00", + "TZ=America/New_York 0 * * * ?", + "2012-03-11T01:00:00-05:00", + ], + [ + "2012-03-11T01:00:00-05:00", + "TZ=America/New_York 0 * * * ?", + "2012-03-11T03:00:00-04:00", + ], + [ + "2012-03-11T03:00:00-04:00", + "TZ=America/New_York 0 * * * ?", + "2012-03-11T04:00:00-04:00", + ], + [ + "2012-03-11T04:00:00-04:00", + "TZ=America/New_York 0 * * * ?", + "2012-03-11T05:00:00-04:00", + ], + // Hourly job using CRON_TZ. + [ + "2012-03-11T00:00:00-05:00", + "CRON_TZ=America/New_York 0 * * * ?", + "2012-03-11T01:00:00-05:00", + ], + [ + "2012-03-11T01:00:00-05:00", + "CRON_TZ=America/New_York 0 * * * ?", + "2012-03-11T03:00:00-04:00", + ], + [ + "2012-03-11T03:00:00-04:00", + "CRON_TZ=America/New_York 0 * * * ?", + "2012-03-11T04:00:00-04:00", + ], + [ + "2012-03-11T04:00:00-04:00", + "CRON_TZ=America/New_York 0 * * * ?", + "2012-03-11T05:00:00-04:00", + ], + // 1am nightly job. + [ + "2012-03-11T00:00:00-05:00", + "TZ=America/New_York 0 1 * * ?", + "2012-03-11T01:00:00-05:00", + ], + [ + "2012-03-11T01:00:00-05:00", + "TZ=America/New_York 0 1 * * ?", + "2012-03-12T01:00:00-04:00", + ], + // 2am nightly job (skipped). + [ + "2012-03-11T00:00:00-05:00", + "TZ=America/New_York 0 2 * * ?", + "2012-03-12T02:00:00-04:00", + ], + // Daylight saving time 2am EDT (-4) -> 1am EST (-5). + [ + "2012-11-04T00:00:00-04:00", + "TZ=America/New_York 30 2 04 Nov ?", + "2012-11-04T02:30:00-05:00", + ], + [ + "2012-11-04T01:45:00-04:00", + "TZ=America/New_York 30 1 04 Nov ?", + "2012-11-04T01:30:00-05:00", + ], + // Hourly job. + [ + "2012-11-04T00:00:00-04:00", + "TZ=America/New_York 0 * * * ?", + "2012-11-04T01:00:00-04:00", + ], + [ + "2012-11-04T01:00:00-04:00", + "TZ=America/New_York 0 * * * ?", + "2012-11-04T01:00:00-05:00", + ], + [ + "2012-11-04T01:00:00-05:00", + "TZ=America/New_York 0 * * * ?", + "2012-11-04T02:00:00-05:00", + ], + // 1am nightly job (runs twice). + [ + "2012-11-04T00:00:00-04:00", + "TZ=America/New_York 0 1 * * ?", + "2012-11-04T01:00:00-04:00", + ], + [ + "2012-11-04T01:00:00-04:00", + "TZ=America/New_York 0 1 * * ?", + "2012-11-04T01:00:00-05:00", + ], + [ + "2012-11-04T01:00:00-05:00", + "TZ=America/New_York 0 1 * * ?", + "2012-11-05T01:00:00-05:00", + ], + // 2am nightly job. + [ + "2012-11-04T00:00:00-04:00", + "TZ=America/New_York 0 2 * * ?", + "2012-11-04T02:00:00-05:00", + ], + [ + "2012-11-04T02:00:00-05:00", + "TZ=America/New_York 0 2 * * ?", + "2012-11-05T02:00:00-05:00", + ], + // 3am nightly job. + [ + "2012-11-04T00:00:00-04:00", + "TZ=America/New_York 0 3 * * ?", + "2012-11-04T03:00:00-05:00", + ], + [ + "2012-11-04T03:00:00-05:00", + "TZ=America/New_York 0 3 * * ?", + "2012-11-05T03:00:00-05:00", + ], + // The same jobs in the reference time's zone instead of a prefix. + [ + "2012-11-04T00:00:00-04:00", + "0 * * * ?", + "2012-11-04T01:00:00-04:00", + "America/New_York", + ], + [ + "2012-11-04T01:00:00-04:00", + "0 * * * ?", + "2012-11-04T01:00:00-05:00", + "America/New_York", + ], + [ + "2012-11-04T01:00:00-05:00", + "0 * * * ?", + "2012-11-04T02:00:00-05:00", + "America/New_York", + ], + [ + "2012-11-04T00:00:00-04:00", + "0 1 * * ?", + "2012-11-04T01:00:00-04:00", + "America/New_York", + ], + [ + "2012-11-04T01:00:00-04:00", + "0 1 * * ?", + "2012-11-04T01:00:00-05:00", + "America/New_York", + ], + [ + "2012-11-04T01:00:00-05:00", + "0 1 * * ?", + "2012-11-05T01:00:00-05:00", + "America/New_York", + ], + [ + "2012-11-04T00:00:00-04:00", + "0 2 * * ?", + "2012-11-04T02:00:00-05:00", + "America/New_York", + ], + [ + "2012-11-04T02:00:00-05:00", + "0 2 * * ?", + "2012-11-05T02:00:00-05:00", + "America/New_York", + ], + [ + "2012-11-04T00:00:00-04:00", + "0 3 * * ?", + "2012-11-04T03:00:00-05:00", + "America/New_York", + ], + [ + "2012-11-04T03:00:00-05:00", + "0 3 * * ?", + "2012-11-05T03:00:00-05:00", + "America/New_York", + ], + // Unsatisfiable. + ["2012-07-09T23:35:00Z", "0 0 30 Feb ?", null], + ["2012-07-09T23:35:00Z", "0 0 31 Apr ?", null], + // Monthly job. + [ + "2012-11-04T00:00:00-04:00", + "0 3 3 * ?", + "2012-12-03T03:00:00-05:00", + "America/New_York", + ], + // DST making midnight invalid (robfig issue 157). + [ + "2018-10-17T05:00:00-04:00", + "TZ=America/Sao_Paulo 0 9 10 * ?", + "2018-11-10T06:00:00-05:00", + ], + [ + "2018-02-14T05:00:00-05:00", + "TZ=America/Sao_Paulo 0 9 22 * ?", + "2018-02-22T07:00:00-05:00", + ], + // The reference time's own fixed offset (robfig TestNextWithTz). + ["2016-01-03T13:09:03+05:30", "14 14 * * *", "2016-01-03T14:14:00+05:30"], + ["2016-01-03T04:09:03+05:30", "14 14 * * ?", "2016-01-03T14:14:00+05:30"], + ["2016-01-03T14:09:03+05:30", "14 14 * * *", "2016-01-03T14:14:00+05:30"], + ["2016-01-03T14:00:00+05:30", "14 14 * * ?", "2016-01-03T14:14:00+05:30"], + ])("from %s, %j is next at %s", (from, expression, expected, timeZone) => { + // Results are compared as instants in the reference time's offset. + const next = nextAfter(expression, from, timeZone); + if (expected === null) { + expect(next).toBeNull(); + } else { + expect(next === null ? null : Temporal.Instant.from(next)).toEqual( + Temporal.Instant.from(expected) + ); + } + }); + + // robfig's constantdelay_test.go, through `@every`. + it.each<[string, string, string]>([ + ["2012-07-09T14:45:00Z", "15m50ns", "2012-07-09T15:00:00Z"], + ["2012-07-09T14:59:00Z", "15m", "2012-07-09T15:14:00Z"], + ["2012-07-09T14:59:59Z", "15m", "2012-07-09T15:14:59Z"], + ["2012-07-09T15:45:00Z", "35m", "2012-07-09T16:20:00Z"], + ["2012-07-09T23:46:00Z", "14m", "2012-07-10T00:00:00Z"], + ["2012-07-09T23:45:00Z", "35m", "2012-07-10T00:20:00Z"], + ["2012-07-09T23:35:51Z", "44m24s", "2012-07-10T00:20:15Z"], + ["2012-07-09T23:35:51Z", "25h44m24s", "2012-07-11T01:20:15Z"], + ["2012-07-09T23:35:00Z", "2184h25m", "2012-10-09T00:00:00Z"], + ["2012-12-31T23:59:45Z", "15s", "2013-01-01T00:00:00Z"], + ["2012-07-09T14:45:00Z", "15ms", "2012-07-09T14:45:01Z"], + ["2012-07-09T14:45:00.005Z", "15m", "2012-07-09T15:00:00Z"], + ["2012-07-09T14:45:00.005Z", "15m50ns", "2012-07-09T15:00:00Z"], + ])("from %s, @every %s is next at %s", (from, duration, expected) => { + expect(nextAfter(`@every ${duration}`, from)).toBe(expected); + }); + }); + + // Expectations generated with robfig/cron v3.0.1's `Next` in each zone. + describe("daylight saving transitions", () => { + it.each<[string, string, string, string[]]>([ + // Berlin springs forward from 02:00 to 03:00: 02:30 is skipped. + [ + "30 2 * * *", + "Europe/Berlin", + "2026-03-28T12:00:00Z", + [ + "2026-03-30T02:30:00+02:00", + "2026-03-31T02:30:00+02:00", + "2026-04-01T02:30:00+02:00", + "2026-04-02T02:30:00+02:00", + ], + ], + // Berlin falls back from 03:00 to 02:00: 02:30 runs twice. + [ + "30 2 * * *", + "Europe/Berlin", + "2026-10-24T12:00:00Z", + [ + "2026-10-25T02:30:00+02:00", + "2026-10-25T02:30:00+01:00", + "2026-10-26T02:30:00+01:00", + "2026-10-27T02:30:00+01:00", + ], + ], + [ + "30 1 * * *", + "Europe/London", + "2026-03-28T12:00:00Z", + [ + "2026-03-30T01:30:00+01:00", + "2026-03-31T01:30:00+01:00", + "2026-04-01T01:30:00+01:00", + "2026-04-02T01:30:00+01:00", + ], + ], + [ + "30 1 * * *", + "Europe/London", + "2026-10-24T12:00:00Z", + [ + "2026-10-25T01:30:00+01:00", + "2026-10-25T01:30:00+00:00", + "2026-10-26T01:30:00+00:00", + "2026-10-27T01:30:00+00:00", + ], + ], + // Lord Howe shifts by half an hour. + [ + "15 2 * * *", + "Australia/Lord_Howe", + "2026-10-03T00:00:00Z", + [ + "2026-10-05T02:15:00+11:00", + "2026-10-06T02:15:00+11:00", + "2026-10-07T02:15:00+11:00", + "2026-10-08T02:15:00+11:00", + ], + ], + [ + "*/20 1-2 * * *", + "Australia/Lord_Howe", + "2026-04-04T12:00:00Z", + [ + "2026-04-05T01:00:00+11:00", + "2026-04-05T01:20:00+11:00", + "2026-04-05T01:40:00+11:00", + "2026-04-05T01:40:00+10:30", + ], + ], + // Midnight does not exist on 2018-11-04 in São Paulo. + [ + "0 0 * * *", + "America/Sao_Paulo", + "2018-11-02T12:00:00Z", + [ + "2018-11-03T00:00:00-03:00", + "2018-11-05T00:00:00-02:00", + "2018-11-06T00:00:00-02:00", + "2018-11-07T00:00:00-02:00", + ], + ], + [ + "0 * * * *", + "America/New_York", + "2026-03-08T05:30:00Z", + [ + "2026-03-08T01:00:00-05:00", + "2026-03-08T03:00:00-04:00", + "2026-03-08T04:00:00-04:00", + "2026-03-08T05:00:00-04:00", + ], + ], + [ + "30 * * * *", + "America/New_York", + "2026-11-01T04:00:00Z", + [ + "2026-11-01T00:30:00-04:00", + "2026-11-01T01:30:00-04:00", + "2026-11-01T01:30:00-05:00", + "2026-11-01T02:30:00-05:00", + ], + ], + // Samoa skipped 2011-12-30 entirely. + [ + "0 0 * * *", + "Pacific/Apia", + "2011-12-27T00:00:00Z", + [ + "2011-12-27T00:00:00-10:00", + "2011-12-28T00:00:00-10:00", + "2011-12-29T00:00:00-10:00", + "2011-12-31T00:00:00+14:00", + ], + ], + ])("%j in %s from %s", (expression, timeZone, from, expected) => { + const schedule = cron(expression, { timeZone }); + expect( + occurrences(schedule, Temporal.Instant.from(from), 4).map((next) => + next.toZonedDateTimeISO(timeZone).toString({ timeZoneName: "never" }) + ) + ).toEqual(expected); + }); + + it("fails where robfig would never return across a skipped day", () => { + // robfig's day loop resolves the missing 2011-12-30 midnight back to + // the 29th and spins forever. + const schedule = cron("0 0 31 * *", { timeZone: "Pacific/Apia" }); + const error = captureError(() => + schedule.next(Temporal.Instant.from("2011-12-27T00:00:00Z")) + ); + expect(error).toBeInstanceOf(ConfigurationError); + expect(error).toMatchObject({ + details: { day: "2011-12-30", timeZone: "Pacific/Apia" }, + }); + }); + }); + + describe("time zones", () => { + const from = Temporal.Instant.from("2026-03-07T13:00:00Z"); + + it("defaults to the process's local time zone", () => { + const schedule = cron("0 9 * * *"); + expect(schedule.timeZone).toBe(Temporal.Now.timeZoneId()); + expect(cron("CRON_TZ=Local 0 9 * * *").timeZone).toBe( + Temporal.Now.timeZoneId() + ); + }); + + it("uses the timeZone option for unprefixed expressions", () => { + const schedule = cron("0 9 * * *", { timeZone: "America/New_York" }); + expect(schedule.timeZone).toBe("America/New_York"); + expect(schedule.next(from)?.toString()).toBe("2026-03-07T14:00:00Z"); + expect( + cron("0 9 * * *", { timeZone: "+05:30" }).next(from)?.toString() + ).toBe("2026-03-08T03:30:00Z"); + // Temporal normalizes the case of IANA names in the option. + expect(cron("0 9 * * *", { timeZone: "america/chicago" }).timeZone).toBe( + "America/Chicago" + ); + }); + + it("prefers a CRON_TZ prefix, where Local defers to the option", () => { + const options = { timeZone: "America/New_York" }; + const utc = cron("CRON_TZ=UTC 0 9 * * *", options); + expect(utc.timeZone).toBe("UTC"); + expect(utc.next(from)?.toString()).toBe("2026-03-08T09:00:00Z"); + expect(cron("TZ= 0 9 * * *", options).timeZone).toBe("UTC"); + expect(cron("TZ=Asia/Tokyo 0 9 * * *", options).timeZone).toBe( + "Asia/Tokyo" + ); + expect(cron("CRON_TZ=Local 0 9 * * *", options).timeZone).toBe( + "America/New_York" + ); + }); + + it.each([ + // Go's zoneinfo lookup is case-sensitive on Linux. + "CRON_TZ=america/new_york 0 9 * * *", + "CRON_TZ=utc 0 9 * * *", + // Temporal accepts these as time zones; Go does not. + "CRON_TZ=+05:00 0 9 * * *", + "CRON_TZ=2020-01-01[UTC] 0 9 * * *", + "CRON_TZ=Nowhere/Invalid 0 9 * * *", + // robfig panics without a space after the prefix. + "CRON_TZ=UTC", + "TZ=UTC\t0 9 * * *", + "CRON_TZ=UTC ", + ])("rejects %j", (expression) => { + expect(() => cron(expression)).toThrow(ConfigurationError); + }); + + it("rejects an unknown timeZone option", () => { + expect(() => cron("0 9 * * *", { timeZone: "Nowhere/Invalid" })).toThrow( + 'cron timeZone "Nowhere/Invalid" is not a known time zone' + ); + }); + }); + + describe("syntax edge cases", () => { + const from = "2026-01-02T03:04:05Z"; + + it("splits and trims on Go's whitespace, not JavaScript's", () => { + expect(nextAfter("0\u00859 * *\u3000*", from)).toBe( + "2026-01-02T09:00:00Z" + ); + expect(() => cron("0\ufeff 9 * * * *")).toThrow(ConfigurationError); + expect(() => cron(" @daily")).toThrow("expected exactly 5 fields"); + expect(() => cron("@daily ")).toThrow("unrecognized descriptor"); + expect(cron("CRON_TZ=UTC \u00a0@daily\u2003").timeZone).toBe("UTC"); + }); + + it("parses integers with Go's strconv.Atoi", () => { + expect(nextAfter("+5 * * * *", from)).toBe("2026-01-02T03:05:00Z"); + expect(nextAfter("007 * * * *", from)).toBe("2026-01-02T03:07:00Z"); + // A huge step selects only the start. + expect(nextAfter("0/99999999999 * * * *", from)).toBe( + "2026-01-02T04:00:00Z" + ); + expect(() => cron("0/9223372036854775808 * * * *")).toThrow( + "value out of range" + ); + expect(() => cron("0/1_0 * * * *")).toThrow("invalid syntax"); + expect(() => cron("\uff15 * * * *")).toThrow("invalid syntax"); + }); + + it("lowercases names like Go", () => { + // Go's `strings.ToLower` maps U+0130 to "i". + expect(nextAfter("0 9 * * FR\u0130", from)).toBe("2026-01-02T09:00:00Z"); + expect(nextAfter("0 9 1 MAY *", from)).toBe("2026-05-01T09:00:00Z"); + expect(() => cron("0 9 mon * *")).toThrow("failed to parse int from"); + }); + + it("skips empty list items and never fires an empty field", () => { + expect(nextAfter("1,,2 * * * *", from)).toBe("2026-01-02T04:01:00Z"); + expect(nextAfter(", * * * *", from)).toBeNull(); + expect(nextAfter("0 9 * * ,", from)).toBeNull(); + expect(nextAfter("0 9 , * 1", from)).toBe("2026-01-05T09:00:00Z"); + }); + + it("rounds @every like robfig", () => { + expect(nextAfter("@every -5m", from)).toBe("2026-01-02T03:04:06Z"); + expect(nextAfter("@every 0", from)).toBe("2026-01-02T03:04:06Z"); + expect(nextAfter("@every 1.9999999999s", from)).toBe( + "2026-01-02T03:04:06Z" + ); + expect(() => cron("@every")).toThrow("unrecognized descriptor"); + expect(() => cron("@every 1d")).toThrow( + 'failed to parse duration @every 1d: time: unknown unit "d" in duration "1d"' + ); + }); + + it("stops before Temporal's representable range ends", () => { + const latest = + Temporal.Instant.fromEpochNanoseconds(8_640_000_000_000_000_000_000n); + expect(cron("@daily", { timeZone: "UTC" }).next(latest)).toBeNull(); + expect(cron("@every 1s").next(latest)).toBeNull(); + expect( + cron("@every 2562047h").next( + Temporal.Instant.from("+275700-01-01T00:00:00Z") + ) + ).toBeNull(); + }); + }); + + describe("API", () => { + it("returns a frozen schedule that describes itself", () => { + const schedule = cron("0 9 * * 1", { timeZone: "UTC" }); + expect(Object.isFrozen(schedule)).toBe(true); + expect(schedule).toMatchObject({ + expression: "0 9 * * 1", + timeZone: "UTC", + }); + }); + + it("reports invalid expressions as configuration errors", () => { + const error = captureError(() => cron("0 9 * * 7")); + expect(error).toBeInstanceOf(ConfigurationError); + expect(error).toMatchObject({ + details: { expression: "0 9 * * 7" }, + message: + 'invalid cron expression "0 9 * * 7": end of range (7) above maximum (6): 7', + }); + expect((error as ConfigurationError).cause).toBeInstanceOf(Error); + }); + + it("rejects non-string expressions and non-object options", () => { + expect(() => cron(9 as unknown as string)).toThrow( + "cron expression must be a string" + ); + expect(() => cron("0 9 * * *", null as unknown as undefined)).toThrow( + "cron options must be an object" + ); + }); + + it("schedules a periodic job", () => { + const schedule = cron("0 9 * * mon-fri", { timeZone: "UTC" }); + const job = periodicJob({ + args: { scope: "all" }, + job: defineJob<{ scope: string }>()({ kind: "cron_report" }), + schedule, + }); + expect(job.schedule).toBe(schedule); + }); + }); +}); + +function captureError(callback: () => unknown): unknown { + try { + callback(); + } catch (error: unknown) { + return error; + } + throw new Error("expected an error"); +} diff --git a/js/src/cron.ts b/js/src/cron.ts new file mode 100644 index 000000000..7d8800dd5 --- /dev/null +++ b/js/src/cron.ts @@ -0,0 +1,736 @@ +import { ConfigurationError } from "./errors.js"; +import { parseGoDuration } from "./internal/go-duration.js"; +import type { PeriodicSchedule } from "./periodic.js"; + +/** Options for {@link cron}. */ +export interface CronOptions { + /** + * Time zone the expression is evaluated in when it has no `CRON_TZ=` or + * `TZ=` prefix: an IANA name such as `"America/Chicago"`, `"UTC"`, or a + * fixed offset such as `"+05:30"`. Defaults to the process's local time + * zone, `Temporal.Now.timeZoneId()`, which is what River Go uses. + */ + readonly timeZone?: string; +} + +/** A standard cron schedule created by {@link cron}. */ +export interface CronSchedule extends PeriodicSchedule { + /** The expression the schedule was parsed from. */ + readonly expression: string; + /** + * Time zone occurrences are computed in: the expression's `CRON_TZ=` + * prefix, else {@link CronOptions.timeZone}, else the process's local + * zone when the schedule was created. + */ + readonly timeZone: string; +} + +/** + * Parse a standard cron expression into a periodic schedule that fires at + * exactly the times River Go would. + * + * River Go documents robfig/cron's `ParseStandard` syntax, and this is a + * faithful port of that parser and its `Next` algorithm, so one expression + * string behaves the same whichever language holds leadership: + * + * - five fields: minute (0-59), hour (0-23), day of month (1-31), month + * (1-12 or `jan`-`dec`), and day of week (0-6 from Sunday, or + * `sun`-`sat`); names are case-insensitive and `7` is not Sunday; + * - `*` or `?` for every value, lists (`1,15`), ranges (`9-17`), and steps + * on wildcards, values, or ranges (`5/15` is `5-59/15`); + * - when both day of month and day of week are restricted, a day matching + * either one fires; when either is `*` or `?`, both must match. A wildcard + * with a step greater than one counts as restricted; + * - the descriptors `@yearly` (or `@annually`), `@monthly`, `@weekly`, + * `@daily` (or `@midnight`), and `@hourly`; + * - `@every ` with Go's duration syntax (`1h30m`, `1.5h`, `90s`), + * measured from the previous occurrence, truncated to whole seconds, and + * at least one second; + * - a leading `CRON_TZ=` or `TZ=` naming an IANA time zone. + * + * Seconds fields, `L`, `W`, `#`, and `@reboot` are rejected, as in Go. + * + * Occurrences follow wall-clock time in the schedule's time zone, including + * robfig's handling of daylight saving time: a time skipped by a transition + * does not fire that day, and a time repeated by one can fire twice. + * + * The time zone is the expression's `CRON_TZ=` prefix, else + * `options.timeZone`, else the process's local time zone, matching River Go, + * which evaluates unprefixed expressions in `time.Local`. Periodic jobs run + * on whichever client holds leadership, so every process in a fleet, + * including Go and Rust ones, must resolve the same zone. Pin it explicitly, + * preferably with a prefix such as `CRON_TZ=UTC` that every language reads + * from the shared expression, or with `options.timeZone`. + * + * @param expression - A standard cron expression or descriptor. + * @param options - Optional default time zone. + * @returns A schedule for {@link periodicJob}'s `schedule` option. + * @throws ConfigurationError when the expression is not valid River Go cron + * syntax or the time zone is unknown. The schedule's `next()` also throws + * it when an occurrence would have to cross a calendar day the zone + * skipped, as Samoa skipped 2011-12-30; River Go never returns there. + * + * @example + * ```ts + * const weekdayReport = periodicJob({ + * args: { scope: "all" }, + * id: "weekday_report", + * job: buildReport, + * // 09:00 New York time, Monday through Friday. + * schedule: cron("CRON_TZ=America/New_York 0 9 * * mon-fri"), + * }); + * ``` + */ +export function cron(expression: string, options?: CronOptions): CronSchedule { + if (typeof expression !== "string") { + throw new ConfigurationError("cron expression must be a string"); + } + if ( + options !== undefined && + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- validates untyped JavaScript input + (options === null || typeof options !== "object") + ) { + throw new ConfigurationError("cron options must be an object"); + } + let parsed: ParsedCronExpression; + try { + parsed = parseCronExpression(expression); + } catch (cause: unknown) { + throw new ConfigurationError( + `invalid cron expression ${JSON.stringify(expression)}: ${(cause as Error).message}`, + { cause, details: { expression } } + ); + } + const timeZone = parsed.timeZone ?? resolveTimeZoneOption(options?.timeZone); + const spec = parsed.spec; + return Object.freeze({ + expression, + next: (after: Temporal.Instant) => + nextCronOccurrence(spec, timeZone, after), + timeZone, + }); +} + +/** @internal A parsed field: value bits plus {@link STAR_BIT}. */ +export type CronBits = bigint; + +/** @internal robfig's `SpecSchedule` or `ConstantDelaySchedule`. */ +export type CronSpec = + | { + /** Whole-second delay between occurrences. */ + readonly delayNanoseconds: bigint; + readonly kind: "every"; + } + | { + readonly dom: CronBits; + readonly dow: CronBits; + readonly hour: CronBits; + readonly kind: "fields"; + readonly minute: CronBits; + readonly month: CronBits; + }; + +/** @internal Result of {@link parseCronExpression}. */ +export interface ParsedCronExpression { + readonly spec: CronSpec; + /** Zone from a `CRON_TZ=`/`TZ=` prefix; undefined without one or for `Local`. */ + readonly timeZone: string | undefined; +} + +/** @internal Inclusive bounds and names of one cron field. */ +export interface CronBounds { + readonly maximum: number; + readonly minimum: number; + readonly names?: ReadonlyMap; +} + +/** @internal Set when a field was written as `*` or `?` (robfig's `starBit`). */ +export const STAR_BIT: CronBits = 1n << 63n; + +/** @internal */ +export const MINUTES: CronBounds = { maximum: 59, minimum: 0 }; +/** @internal */ +export const HOURS: CronBounds = { maximum: 23, minimum: 0 }; +/** @internal */ +export const DAYS_OF_MONTH: CronBounds = { maximum: 31, minimum: 1 }; +/** @internal */ +export const MONTHS: CronBounds = { + maximum: 12, + minimum: 1, + names: new Map([ + ["jan", 1], + ["feb", 2], + ["mar", 3], + ["apr", 4], + ["may", 5], + ["jun", 6], + ["jul", 7], + ["aug", 8], + ["sep", 9], + ["oct", 10], + ["nov", 11], + ["dec", 12], + ]), +}; +/** @internal */ +export const DAYS_OF_WEEK: CronBounds = { + maximum: 6, + minimum: 0, + names: new Map([ + ["sun", 0], + ["mon", 1], + ["tue", 2], + ["wed", 3], + ["thu", 4], + ["fri", 5], + ["sat", 6], + ]), +}; + +/** + * Characters Go's `unicode.IsSpace` accepts, which `strings.Fields` and + * `strings.TrimSpace` split and trim on. Unlike JavaScript's `\s`, this + * includes U+0085 and excludes U+FEFF. + */ +const GO_SPACE = + "\\t\\n\\v\\f\\r \\u0085\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000"; +const GO_FIELDS = new RegExp(`[^${GO_SPACE}]+`, "g"); +const GO_TRIM = new RegExp(`^[${GO_SPACE}]+|[${GO_SPACE}]+$`, "g"); + +const NANOSECONDS_PER_SECOND = 1_000_000_000n; + +/** + * Latest instant from which an occurrence is computed: robfig searches up to + * five years ahead, and Temporal cannot represent instants past + * +275760-09-13. + */ +const LATEST_INSTANT_NANOSECONDS = 8_640_000_000_000_000_000_000n; +const LATEST_EVALUABLE_NANOSECONDS = + LATEST_INSTANT_NANOSECONDS - 7n * 366n * 86_400n * NANOSECONDS_PER_SECOND; + +/** + * @internal Parse a cron expression exactly like robfig/cron's + * `ParseStandard`, throwing an `Error` with robfig's message on rejection. + */ +export function parseCronExpression(expression: string): ParsedCronExpression { + if (expression.length === 0) throw new Error("empty spec string"); + + let spec = expression; + let timeZone: string | undefined; + if (spec.startsWith("TZ=") || spec.startsWith("CRON_TZ=")) { + const space = spec.indexOf(" "); + // robfig slices up to the first space and panics without one. + if (space < 0) { + throw new Error("time zone prefix must be followed by a schedule"); + } + const name = spec.slice(spec.indexOf("=") + 1, space); + timeZone = loadGoLocation(name); + spec = spec.slice(space).replace(GO_TRIM, ""); + } + + if (spec.startsWith("@")) return { spec: parseDescriptor(spec), timeZone }; + + const fields = spec.match(GO_FIELDS) ?? []; + const [minute, hour, dom, month, dow] = fields; + if ( + fields.length !== 5 || + minute === undefined || + hour === undefined || + dom === undefined || + month === undefined || + dow === undefined + ) { + throw new Error( + `expected exactly 5 fields, found ${fields.length}: [${fields.join(" ")}]` + ); + } + return { + spec: { + dom: parseCronField(dom, DAYS_OF_MONTH), + dow: parseCronField(dow, DAYS_OF_WEEK), + hour: parseCronField(hour, HOURS), + kind: "fields", + minute: parseCronField(minute, MINUTES), + month: parseCronField(month, MONTHS), + }, + timeZone, + }; +} + +/** + * @internal robfig's `getField`: a comma-separated list of ranges, skipping + * empty items like Go's `strings.FieldsFunc`. + */ +export function parseCronField(field: string, bounds: CronBounds): CronBits { + let bits = 0n; + for (const range of field.split(",")) { + if (range !== "") bits |= parseCronRange(range, bounds); + } + return bits; +} + +/** + * @internal robfig's `getRange`: `*`, `?`, a number or name, or a range, + * each optionally followed by `/step`. + */ +export function parseCronRange( + expression: string, + bounds: CronBounds +): CronBits { + const rangeAndStep = expression.split("/"); + const lowAndHigh = (rangeAndStep[0] ?? "").split("-"); + const low = lowAndHigh[0] ?? ""; + const single = lowAndHigh.length === 1; + + let start: number; + let end: number; + let extra = 0n; + if (low === "*" || low === "?") { + start = bounds.minimum; + end = bounds.maximum; + extra = STAR_BIT; + } else { + start = parseIntOrName(low, bounds.names); + if (lowAndHigh.length === 1) { + end = start; + } else if (lowAndHigh.length === 2) { + end = parseIntOrName(lowAndHigh[1] ?? "", bounds.names); + } else { + throw new Error(`too many hyphens: ${expression}`); + } + } + + let step: number; + if (rangeAndStep.length === 1) { + step = 1; + } else if (rangeAndStep.length === 2) { + step = parseNonNegativeInt(rangeAndStep[1] ?? ""); + // "N/step" means "N-max/step". + if (single) end = bounds.maximum; + // A real step makes a wildcard a restriction for the day rule. + if (step > 1) extra = 0n; + } else { + throw new Error(`too many slashes: ${expression}`); + } + + if (start < bounds.minimum) { + throw new Error( + `beginning of range (${start}) below minimum (${bounds.minimum}): ${expression}` + ); + } + if (end > bounds.maximum) { + throw new Error( + `end of range (${end}) above maximum (${bounds.maximum}): ${expression}` + ); + } + if (start > end) { + throw new Error( + `beginning of range (${start}) beyond end of range (${end}): ${expression}` + ); + } + if (step === 0) { + throw new Error(`step of range should be a positive number: ${expression}`); + } + return cronBits(start, end, step) | extra; +} + +/** @internal robfig's `getBits`: every `step`th value in `[minimum, maximum]`. */ +export function cronBits( + minimum: number, + maximum: number, + step: number +): CronBits { + let bits = 0n; + for (let value = minimum; value <= maximum; value += step) { + bits |= 1n << BigInt(value); + } + return bits; +} + +/** @internal robfig's `all`: every value in `bounds`, plus the star bit. */ +export function allCronBits(bounds: CronBounds): CronBits { + return cronBits(bounds.minimum, bounds.maximum, 1) | STAR_BIT; +} + +function parseDescriptor(descriptor: string): CronSpec { + const fields = ( + overrides: Partial> + ): CronSpec => ({ + dom: overrides.dom ?? allCronBits(DAYS_OF_MONTH), + dow: overrides.dow ?? allCronBits(DAYS_OF_WEEK), + hour: overrides.hour ?? 1n << BigInt(HOURS.minimum), + kind: "fields", + minute: 1n << BigInt(MINUTES.minimum), + month: overrides.month ?? allCronBits(MONTHS), + }); + switch (descriptor) { + case "@yearly": + case "@annually": + return fields({ + dom: 1n << BigInt(DAYS_OF_MONTH.minimum), + month: 1n << BigInt(MONTHS.minimum), + }); + case "@monthly": + return fields({ dom: 1n << BigInt(DAYS_OF_MONTH.minimum) }); + case "@weekly": + return fields({ dow: 1n << BigInt(DAYS_OF_WEEK.minimum) }); + case "@daily": + case "@midnight": + return fields({}); + case "@hourly": + return fields({ hour: allCronBits(HOURS) }); + } + + const every = "@every "; + if (!descriptor.startsWith(every)) { + throw new Error(`unrecognized descriptor: ${descriptor}`); + } + let duration: bigint; + try { + duration = parseGoDuration(descriptor.slice(every.length)); + } catch (cause: unknown) { + throw new Error( + `failed to parse duration ${descriptor}: ${(cause as Error).message}`, + { cause } + ); + } + // robfig's `Every` rounds up to one second and drops subseconds. + if (duration < NANOSECONDS_PER_SECOND) duration = NANOSECONDS_PER_SECOND; + return { + delayNanoseconds: duration - (duration % NANOSECONDS_PER_SECOND), + kind: "every", + }; +} + +/** robfig's `parseIntOrName`, with Go's lowercasing of names. */ +function parseIntOrName( + expression: string, + names: ReadonlyMap | undefined +): number { + if (names !== undefined) { + // Go's `strings.ToLower` maps U+0130 to a plain "i"; JavaScript adds a + // combining dot. No other non-ASCII letter lowercases into a name. + const named = names.get(expression.replaceAll("\u0130", "i").toLowerCase()); + if (named !== undefined) return named; + } + return parseNonNegativeInt(expression); +} + +/** + * robfig's `mustParseInt`: Go's `strconv.Atoi` (an optional sign and ASCII + * digits within int64) and then a non-negative check. Values past any field's + * range are clamped, since they are rejected or only ever used as a step. + */ +function parseNonNegativeInt(expression: string): number { + if (!/^[+-]?[0-9]+$/.test(expression)) { + throw new Error( + `failed to parse int from ${expression}: strconv.Atoi: parsing ${JSON.stringify(expression)}: invalid syntax` + ); + } + const value = BigInt(expression); + if ( + value > 9_223_372_036_854_775_807n || + value < -9_223_372_036_854_775_808n + ) { + throw new Error( + `failed to parse int from ${expression}: strconv.Atoi: parsing ${JSON.stringify(expression)}: value out of range` + ); + } + if (value < 0n) { + throw new Error(`negative number (${value}) not allowed: ${expression}`); + } + return Number(value > 4_294_967_295n ? 4_294_967_295n : value); +} + +/** + * Go's `time.LoadLocation` for a `CRON_TZ=` name: `""` and `UTC` are UTC, + * `Local` defers to the default zone, and anything else must be an IANA name + * spelled exactly as the time zone database spells it. + */ +function loadGoLocation(name: string): string | undefined { + if (name === "" || name === "UTC") return "UTC"; + if (name === "Local") return undefined; + // Temporal also accepts offsets, bracketed date-times, and names in any + // case; Go's zoneinfo lookup accepts none of them. + if (!name.startsWith("+") && !name.startsWith("-")) { + const resolved = timeZoneId(name); + if (resolved === name) return resolved; + } + throw new Error(`provided bad location ${name}: unknown time zone ${name}`); +} + +function resolveTimeZoneOption(timeZone: string | undefined): string { + if (timeZone === undefined) return Temporal.Now.timeZoneId(); + const resolved = + typeof timeZone === "string" ? timeZoneId(timeZone) : undefined; + if (resolved === undefined) { + throw new ConfigurationError( + `cron timeZone ${JSON.stringify(timeZone)} is not a known time zone` + ); + } + return resolved; +} + +function timeZoneId(name: string): string | undefined { + try { + return new Temporal.ZonedDateTime(0n, name).timeZoneId; + } catch { + return undefined; + } +} + +/** + * @internal robfig's `SpecSchedule.Next` or `ConstantDelaySchedule.Next`, + * evaluated in `timeZone`. Returns null when nothing matches within five + * years, where robfig returns Go's zero time, and when the occurrence would + * fall outside Temporal's range. + */ +function nextCronOccurrence( + spec: CronSpec, + timeZone: string, + after: Temporal.Instant +): Temporal.Instant | null { + const afterNanoseconds = after.epochNanoseconds; + if (afterNanoseconds > LATEST_EVALUABLE_NANOSECONDS) return null; + const subsecond = + ((afterNanoseconds % NANOSECONDS_PER_SECOND) + NANOSECONDS_PER_SECOND) % + NANOSECONDS_PER_SECOND; + if (spec.kind === "every") { + const next = afterNanoseconds + spec.delayNanoseconds - subsecond; + return next > LATEST_INSTANT_NANOSECONDS + ? null + : Temporal.Instant.fromEpochNanoseconds(next); + } + if (!canMatch(spec)) return null; + + const zone = new GoZone(timeZone); + // Start at the earliest possible time (the upcoming second). Every later + // step keeps whole seconds, so `time` is in epoch seconds. + let time = + Number((afterNanoseconds - subsecond) / NANOSECONDS_PER_SECOND) + 1; + let added = false; + const yearLimit = zone.wall(time).year + 5; + + // Each `continue wrap` is robfig's `goto WRAP`: a field rolled over, so + // every earlier field must be checked again. + wrap: for (;;) { + let wall = zone.wall(time); + if (wall.year > yearLimit) return null; + + while (!hasBit(spec.month, wall.month)) { + if (!added) { + added = true; + time = zone.date(wall.year, wall.month, 1, 0, 0, 0); + wall = zone.wall(time); + } + // `t.AddDate(0, 1, 0)`, normalizing an overflowing day. + time = zone.date( + wall.year, + wall.month + 1, + wall.day, + wall.hour, + wall.minute, + wall.second + ); + wall = zone.wall(time); + if (wall.month === 1) continue wrap; + } + + while (!dayMatches(spec, wall)) { + if (!added) { + added = true; + time = zone.date(wall.year, wall.month, wall.day, 0, 0, 0); + wall = zone.wall(time); + } + const previous = time; + const previousWall = wall; + time = zone.date( + wall.year, + wall.month, + wall.day + 1, + wall.hour, + wall.minute, + wall.second + ); + wall = zone.wall(time); + // Midnight may not exist on a daylight saving transition. + if (wall.hour !== 0) { + time += wall.hour > 12 ? (24 - wall.hour) * 3600 : -wall.hour * 3600; + wall = zone.wall(time); + } + // A zone that skips a whole calendar day, as Pacific/Apia did on + // 2011-12-30, resolves the skipped midnight back to the day before, + // and robfig loops forever. Fail instead of blocking the event loop. + if (time <= previous) throw skippedDayError(timeZone, previousWall); + if (wall.day === 1) continue wrap; + } + + while (!hasBit(spec.hour, wall.hour)) { + if (!added) { + added = true; + time = zone.date(wall.year, wall.month, wall.day, wall.hour, 0, 0); + } + time += 3600; + wall = zone.wall(time); + if (wall.hour === 0) continue wrap; + } + + while (!hasBit(spec.minute, wall.minute)) { + if (!added) { + added = true; + // `t.Truncate(time.Minute)` rounds absolute time, not wall time. + time -= floorMod(time, 60); + } + time += 60; + wall = zone.wall(time); + if (wall.minute === 0) continue wrap; + } + + // Standard expressions always fire at second zero. Times are already + // whole seconds, so robfig's `t.Truncate(time.Second)` is a no-op. + while (wall.second !== 0) { + added = true; + time += 1; + wall = zone.wall(time); + if (wall.second === 0) continue wrap; + } + return Temporal.Instant.fromEpochMilliseconds(time * 1000); + } +} + +function skippedDayError( + timeZone: string, + wall: WallClock +): ConfigurationError { + const day = Temporal.PlainDate.from({ + day: wall.day, + month: wall.month, + year: wall.year, + }).add({ days: 1 }); + return new ConfigurationError( + `cron schedule cannot pass ${day.toString()} in ${timeZone}, a day the time zone skips; River Go never returns from this schedule`, + { details: { day: day.toString(), timeZone } } + ); +} + +interface WallClock { + readonly day: number; + readonly hour: number; + readonly minute: number; + readonly month: number; + readonly second: number; + /** Go's `Weekday`: 0 is Sunday. */ + readonly weekday: number; + readonly year: number; +} + +/** Go's view of a `*time.Location`, over epoch seconds. */ +class GoZone { + readonly #timeZone: string; + + constructor(timeZone: string) { + this.#timeZone = timeZone; + } + + /** + * Go's `time.Date`: overflowing months and days roll forward, and a wall + * time a transition skips or repeats resolves exactly as Go resolves it, + * which depends on the direction of the zone's offset. + */ + date( + year: number, + month: number, + day: number, + hour: number, + minute: number, + second: number + ): number { + const monthIndex = month - 1; + const normalizedYear = year + Math.floor(monthIndex / 12); + const normalizedMonth = floorMod(monthIndex, 12) + 1; + const local = + (daysFromCivil(normalizedYear, normalizedMonth, 1) + day - 1) * 86_400 + + hour * 3600 + + minute * 60 + + second; + // Go looks up the offset at the local time read as UTC, then again at + // the UTC time that offset implies, and uses the second offset. + return local - this.#offset(local - this.#offset(local)); + } + + wall(epochSeconds: number): WallClock { + const zoned = this.#zoned(epochSeconds); + return { + day: zoned.day, + hour: zoned.hour, + minute: zoned.minute, + month: zoned.month, + second: zoned.second, + weekday: zoned.dayOfWeek % 7, + year: zoned.year, + }; + } + + #offset(epochSeconds: number): number { + return this.#zoned(epochSeconds).offsetNanoseconds / 1_000_000_000; + } + + #zoned(epochSeconds: number): Temporal.ZonedDateTime { + return Temporal.Instant.fromEpochMilliseconds( + epochSeconds * 1000 + ).toZonedDateTimeISO(this.#timeZone); + } +} + +/** + * Whether any time can satisfy every field. A field left empty by an + * expression like `,` never matches; robfig then scans five years minute by + * minute before giving up, and skipping that scan returns the same null. + */ +function canMatch(spec: CronSpec & { kind: "fields" }): boolean { + const values = (bits: CronBits) => (bits & ~STAR_BIT) !== 0n; + const days = + (spec.dom & STAR_BIT) !== 0n || (spec.dow & STAR_BIT) !== 0n + ? values(spec.dom) && values(spec.dow) + : values(spec.dom) || values(spec.dow); + return values(spec.minute) && values(spec.hour) && values(spec.month) && days; +} + +/** + * robfig's `dayMatches`: when either day field is a wildcard both must + * match; otherwise either may. + */ +function dayMatches( + spec: CronSpec & { kind: "fields" }, + wall: WallClock +): boolean { + const dom = hasBit(spec.dom, wall.day); + const dow = hasBit(spec.dow, wall.weekday); + if ((spec.dom & STAR_BIT) !== 0n || (spec.dow & STAR_BIT) !== 0n) { + return dom && dow; + } + return dom || dow; +} + +function hasBit(bits: CronBits, value: number): boolean { + return (bits & (1n << BigInt(value))) !== 0n; +} + +/** Days from 1970-01-01 to a proleptic Gregorian date. */ +function daysFromCivil(year: number, month: number, day: number): number { + const shifted = month <= 2 ? year - 1 : year; + const era = Math.floor(shifted / 400); + const yearOfEra = shifted - era * 400; + const dayOfYear = + Math.floor((153 * (month + (month > 2 ? -3 : 9)) + 2) / 5) + day - 1; + const dayOfEra = + yearOfEra * 365 + + Math.floor(yearOfEra / 4) - + Math.floor(yearOfEra / 100) + + dayOfYear; + return era * 146_097 + dayOfEra - 719_468; +} + +function floorMod(value: number, divisor: number): number { + return ((value % divisor) + divisor) % divisor; +} diff --git a/js/src/index.ts b/js/src/index.ts index 18dabeb47..ed04a2d6d 100644 --- a/js/src/index.ts +++ b/js/src/index.ts @@ -14,6 +14,8 @@ export type { QueueOperations, TransactionOptions, } from "./client.js"; +export { cron } from "./cron.js"; +export type { CronOptions, CronSchedule } from "./cron.js"; export type { ClientDriver, DriverCapability, diff --git a/js/src/internal/go-duration.test.ts b/js/src/internal/go-duration.test.ts new file mode 100644 index 000000000..4afdbb634 --- /dev/null +++ b/js/src/internal/go-duration.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; + +import { parseGoDuration } from "./go-duration.js"; + +const NANOSECOND = 1n; +const MICROSECOND = 1_000n; +const MILLISECOND = 1_000_000n; +const SECOND = 1_000_000_000n; +const MINUTE = 60n * SECOND; +const HOUR = 60n * MINUTE; +const MAX_INT64 = (1n << 63n) - 1n; +const MIN_INT64 = -(1n << 63n); + +describe("parseGoDuration", () => { + // Go's `parseDurationTests` from time/time_test.go. + it.each<[string, bigint]>([ + // simple + ["0", 0n], + ["5s", 5n * SECOND], + ["30s", 30n * SECOND], + ["1478s", 1478n * SECOND], + // sign + ["-5s", -5n * SECOND], + ["+5s", 5n * SECOND], + ["-0", 0n], + ["+0", 0n], + // decimal + ["5.0s", 5n * SECOND], + ["5.6s", 5n * SECOND + 600n * MILLISECOND], + ["5.s", 5n * SECOND], + [".5s", 500n * MILLISECOND], + ["1.0s", SECOND], + ["1.00s", SECOND], + ["1.004s", SECOND + 4n * MILLISECOND], + ["1.0040s", SECOND + 4n * MILLISECOND], + ["100.00100s", 100n * SECOND + MILLISECOND], + // different units + ["10ns", 10n * NANOSECOND], + ["11us", 11n * MICROSECOND], + ["12\u00b5s", 12n * MICROSECOND], + ["12\u03bcs", 12n * MICROSECOND], + ["13ms", 13n * MILLISECOND], + ["14s", 14n * SECOND], + ["15m", 15n * MINUTE], + ["16h", 16n * HOUR], + // composite durations + ["3h30m", 3n * HOUR + 30n * MINUTE], + ["10.5s4m", 4n * MINUTE + 10n * SECOND + 500n * MILLISECOND], + ["-2m3.4s", -(2n * MINUTE + 3n * SECOND + 400n * MILLISECOND)], + [ + "1h2m3s4ms5us6ns", + HOUR + + 2n * MINUTE + + 3n * SECOND + + 4n * MILLISECOND + + 5n * MICROSECOND + + 6n, + ], + [ + "39h9m14.425s", + 39n * HOUR + 9n * MINUTE + 14n * SECOND + 425n * MILLISECOND, + ], + // large value + ["52763797000ns", 52_763_797_000n], + // more than 9 digits after the decimal point + ["0.3333333333333333333h", 20n * MINUTE], + // 2^53 + 1 cannot be stored precisely in a float64 + ["9007199254740993ns", (1n << 53n) + 1n], + // largest duration an int64 of nanoseconds represents + ["9223372036854775807ns", MAX_INT64], + ["9223372036854775.807us", MAX_INT64], + ["9223372036s854ms775us807ns", MAX_INT64], + ["-9223372036854775808ns", MIN_INT64], + ["-9223372036854775.808us", MIN_INT64], + ["-9223372036s854ms775us808ns", MIN_INT64], + // largest negative round trip value + ["-2562047h47m16.854775808s", MIN_INT64], + // huge fraction + ["0.100000000000000000000h", 6n * MINUTE], + // the first overflow check in leadingFraction + ["0.830103483285477580700h", 49n * MINUTE + 48n * SECOND + 372_539_827n], + ])("parses %j", (text, expected) => { + expect(parseGoDuration(text)).toBe(expected); + }); + + // Go's `parseDurationErrorTests` from time/time_test.go. + it.each([ + "", + "3", + "-", + "s", + ".", + "-.", + ".s", + "+.s", + "1d", + "\u0085\u0085", + "\ufffd", + "\ufffd hello \ufffd world", + // overflow + "9223372036854775810ns", + "9223372036854775808ns", + "-9223372036854775809ns", + "9223372036854776us", + "3000000h", + "9223372036854775.808us", + "9223372036854ms775us808ns", + ])("rejects %j", (text) => { + // Like Go's test, require only that the error quotes the input. + expect(() => parseGoDuration(text)).toThrow(JSON.stringify(text)); + }); + + it("reports missing and unknown units like Go", () => { + expect(() => parseGoDuration("1h5")).toThrow( + 'time: missing unit in duration "1h5"' + ); + expect(() => parseGoDuration("5x")).toThrow( + 'time: unknown unit "x" in duration "5x"' + ); + expect(() => parseGoDuration("1h ")).toThrow( + 'time: unknown unit "h " in duration "1h "' + ); + }); + + it("wraps a uint64 sum of exactly 2^64 to zero as Go does", () => { + expect(parseGoDuration("9223372036854775808ns9223372036854775808ns")).toBe( + 0n + ); + }); +}); diff --git a/js/src/internal/go-duration.ts b/js/src/internal/go-duration.ts new file mode 100644 index 000000000..45dc59e6f --- /dev/null +++ b/js/src/internal/go-duration.ts @@ -0,0 +1,146 @@ +/** + * Go's `time.ParseDuration`, ported so cron `@every` descriptors accept and + * reject exactly what River Go accepts. + */ + +const MAX_UINT64 = (1n << 64n) - 1n; +const OVERFLOW = 1n << 63n; + +/** Nanoseconds per unit, keyed by Go's unit spellings. */ +const UNITS: ReadonlyMap = new Map([ + ["ns", 1n], + ["us", 1_000n], + ["\u00b5s", 1_000n], // U+00B5 MICRO SIGN + ["\u03bcs", 1_000n], // U+03BC GREEK SMALL LETTER MU + ["ms", 1_000_000n], + ["s", 1_000_000_000n], + ["m", 60_000_000_000n], + ["h", 3_600_000_000_000n], +]); + +/** + * Parse a Go duration string such as `1h30m` or `-1.5s` into signed + * nanoseconds, with Go's syntax, overflow rules, and floating-point handling + * of fractions. Throws an `Error` whose message matches Go's. + */ +export function parseGoDuration(text: string): bigint { + const invalid = () => new Error(`time: invalid duration ${quote(text)}`); + let rest = text; + let negative = false; + if (rest.startsWith("-") || rest.startsWith("+")) { + negative = rest.startsWith("-"); + rest = rest.slice(1); + } + // Special case: a bare "0" needs no unit. + if (rest === "0") return 0n; + if (rest === "") throw invalid(); + + // Go accumulates in a uint64, so a sum reaching 2^64 wraps silently. + let total = 0n; + while (rest !== "") { + // The next character must be [0-9.]. + if (!(rest.startsWith(".") || isDigit(rest, 0))) throw invalid(); + + const integerText = leadingDigits(rest); + rest = rest.slice(integerText.length); + const integer = leadingInteger(integerText); + if (integer === null) throw invalid(); + + let fraction = 0n; + let scale = 1; + let fractionText = ""; + if (rest.startsWith(".")) { + rest = rest.slice(1); + fractionText = leadingDigits(rest); + rest = rest.slice(fractionText.length); + [fraction, scale] = leadingFraction(fractionText); + } + // No digits at all, as in ".s" or "-.s". + if (integerText === "" && fractionText === "") throw invalid(); + + let unitLength = 0; + while ( + unitLength < rest.length && + rest[unitLength] !== "." && + !isDigit(rest, unitLength) + ) { + unitLength++; + } + if (unitLength === 0) { + throw new Error(`time: missing unit in duration ${quote(text)}`); + } + const unitText = rest.slice(0, unitLength); + rest = rest.slice(unitLength); + const unit = UNITS.get(unitText); + if (unit === undefined) { + throw new Error( + `time: unknown unit ${quote(unitText)} in duration ${quote(text)}` + ); + } + + if (integer > OVERFLOW / unit) throw invalid(); + let value = integer * unit; + if (fraction > 0n) { + // Go uses float64 here to stay nanosecond-accurate for fractional + // hours; truncating the product toward zero matches `uint64(...)`. + value += BigInt(Math.trunc(Number(fraction) * (Number(unit) / scale))); + if (value > OVERFLOW) throw invalid(); + } + total = (total + value) & MAX_UINT64; + if (total > OVERFLOW) throw invalid(); + } + if (negative) return BigInt.asIntN(64, -total); + if (total > OVERFLOW - 1n) throw invalid(); + return total; +} + +function isDigit(text: string, index: number): boolean { + const code = text.charCodeAt(index); + return code >= 48 && code <= 57; +} + +function leadingDigits(text: string): string { + let length = 0; + while (length < text.length && isDigit(text, length)) length++; + return text.slice(0, length); +} + +/** Go's `leadingInt`: null when the digits overflow 2^63. */ +function leadingInteger(digits: string): bigint | null { + let value = 0n; + for (const digit of digits) { + if (value > OVERFLOW / 10n) return null; + value = value * 10n + BigInt(digit); + if (value > OVERFLOW) return null; + } + return value; +} + +/** + * Go's `leadingFraction`: the fraction's digits as an integer and the power + * of ten dividing them, silently dropping precision on overflow. + */ +function leadingFraction(digits: string): [bigint, number] { + let value = 0n; + let scale = 1; + let overflow = false; + for (const digit of digits) { + if (overflow) continue; + if (value > (OVERFLOW - 1n) / 10n) { + overflow = true; + continue; + } + const next = value * 10n + BigInt(digit); + if (next > OVERFLOW) { + overflow = true; + continue; + } + value = next; + scale *= 10; + } + return [value, scale]; +} + +function quote(text: string): string { + return JSON.stringify(text); +} diff --git a/js/src/testdata/cron-goldens.json b/js/src/testdata/cron-goldens.json new file mode 100644 index 000000000..00038a70c --- /dev/null +++ b/js/src/testdata/cron-goldens.json @@ -0,0 +1,564 @@ +{ + "$comment": "River Go's cron schedule goldens, recorded with robfig/cron v3.0.1, the parser River Go uses. They match the Rust port's rust/riverqueue/tests/fixtures/maintenance_values.json.", + "cron_cases": [ + { + "expression": "* * * * *", + "from": "2026-01-02T03:04:05.6789Z", + "name": "every_minute", + "next": [ + "2026-01-02T03:05:00Z", + "2026-01-02T03:06:00Z", + "2026-01-02T03:07:00Z", + "2026-01-02T03:08:00Z", + "2026-01-02T03:09:00Z" + ] + }, + { + "expression": "30 * * * *", + "from": "2026-01-02T03:04:05.6789Z", + "name": "half_past_every_hour", + "next": [ + "2026-01-02T03:30:00Z", + "2026-01-02T04:30:00Z", + "2026-01-02T05:30:00Z", + "2026-01-02T06:30:00Z", + "2026-01-02T07:30:00Z" + ] + }, + { + "expression": "0 9 * * 1", + "from": "2026-01-02T03:04:05.6789Z", + "name": "monday_numeric_weekday", + "next": [ + "2026-01-05T09:00:00Z", + "2026-01-12T09:00:00Z", + "2026-01-19T09:00:00Z", + "2026-01-26T09:00:00Z", + "2026-02-02T09:00:00Z" + ] + }, + { + "expression": "0 9 * * mon", + "from": "2026-01-02T03:04:05.6789Z", + "name": "monday_named_weekday", + "next": [ + "2026-01-05T09:00:00Z", + "2026-01-12T09:00:00Z", + "2026-01-19T09:00:00Z", + "2026-01-26T09:00:00Z", + "2026-02-02T09:00:00Z" + ] + }, + { + "expression": "0 0 * * 0", + "from": "2026-01-02T03:04:05.6789Z", + "name": "sunday_is_zero", + "next": [ + "2026-01-04T00:00:00Z", + "2026-01-11T00:00:00Z", + "2026-01-18T00:00:00Z", + "2026-01-25T00:00:00Z", + "2026-02-01T00:00:00Z" + ] + }, + { + "expression": "0 0 * * SUN", + "from": "2026-01-02T03:04:05.6789Z", + "name": "weekday_names_ignore_case", + "next": [ + "2026-01-04T00:00:00Z", + "2026-01-11T00:00:00Z", + "2026-01-18T00:00:00Z", + "2026-01-25T00:00:00Z", + "2026-02-01T00:00:00Z" + ] + }, + { + "expression": "*/15 9-17 * * mon-fri", + "from": "2026-01-02T03:04:05.6789Z", + "name": "business_hours_steps", + "next": [ + "2026-01-02T09:00:00Z", + "2026-01-02T09:15:00Z", + "2026-01-02T09:30:00Z", + "2026-01-02T09:45:00Z", + "2026-01-02T10:00:00Z" + ] + }, + { + "expression": "0 0 1 * *", + "from": "2026-01-02T03:04:05.6789Z", + "name": "first_of_month", + "next": [ + "2026-02-01T00:00:00Z", + "2026-03-01T00:00:00Z", + "2026-04-01T00:00:00Z", + "2026-05-01T00:00:00Z", + "2026-06-01T00:00:00Z" + ] + }, + { + "expression": "0 0 1 jan,JUL *", + "from": "2026-01-02T03:04:05.6789Z", + "name": "named_months", + "next": [ + "2026-07-01T00:00:00Z", + "2027-01-01T00:00:00Z", + "2027-07-01T00:00:00Z", + "2028-01-01T00:00:00Z", + "2028-07-01T00:00:00Z" + ] + }, + { + "expression": "0 0 29 2 *", + "from": "2026-01-02T03:04:05.6789Z", + "name": "leap_day", + "next": [ + "2028-02-29T00:00:00Z", + "2032-02-29T00:00:00Z", + "2036-02-29T00:00:00Z", + "2040-02-29T00:00:00Z", + "2044-02-29T00:00:00Z" + ] + }, + { + "expression": "0 0 30 2 *", + "from": "2026-01-02T03:04:05.6789Z", + "name": "impossible_date_never_runs", + "next": [] + }, + { + "expression": "0 12 1,15 * 5", + "from": "2026-01-02T03:04:05.6789Z", + "name": "day_of_month_or_weekday", + "next": [ + "2026-01-02T12:00:00Z", + "2026-01-09T12:00:00Z", + "2026-01-15T12:00:00Z", + "2026-01-16T12:00:00Z", + "2026-01-23T12:00:00Z" + ] + }, + { + "expression": "0 12 * * 5", + "from": "2026-01-02T03:04:05.6789Z", + "name": "wildcard_day_of_month_and_weekday", + "next": [ + "2026-01-02T12:00:00Z", + "2026-01-09T12:00:00Z", + "2026-01-16T12:00:00Z", + "2026-01-23T12:00:00Z", + "2026-01-30T12:00:00Z" + ] + }, + { + "expression": "0 12 ? * 5", + "from": "2026-01-02T03:04:05.6789Z", + "name": "question_mark_wildcard", + "next": [ + "2026-01-02T12:00:00Z", + "2026-01-09T12:00:00Z", + "2026-01-16T12:00:00Z", + "2026-01-23T12:00:00Z", + "2026-01-30T12:00:00Z" + ] + }, + { + "expression": "0 12 */2 * 5", + "from": "2026-01-02T03:04:05.6789Z", + "name": "stepped_day_of_month_or_weekday", + "next": [ + "2026-01-02T12:00:00Z", + "2026-01-03T12:00:00Z", + "2026-01-05T12:00:00Z", + "2026-01-07T12:00:00Z", + "2026-01-09T12:00:00Z" + ] + }, + { + "expression": "0 12 */1 * 5", + "from": "2026-01-02T03:04:05.6789Z", + "name": "unit_step_keeps_wildcard", + "next": [ + "2026-01-02T12:00:00Z", + "2026-01-09T12:00:00Z", + "2026-01-16T12:00:00Z", + "2026-01-23T12:00:00Z", + "2026-01-30T12:00:00Z" + ] + }, + { + "expression": "5/15 * * * *", + "from": "2026-01-02T03:04:05.6789Z", + "name": "start_with_step", + "next": [ + "2026-01-02T03:05:00Z", + "2026-01-02T03:20:00Z", + "2026-01-02T03:35:00Z", + "2026-01-02T03:50:00Z", + "2026-01-02T04:05:00Z" + ] + }, + { + "expression": "0-10/5 * * * *", + "from": "2026-01-02T03:04:05.6789Z", + "name": "range_with_step", + "next": [ + "2026-01-02T03:05:00Z", + "2026-01-02T03:10:00Z", + "2026-01-02T04:00:00Z", + "2026-01-02T04:05:00Z", + "2026-01-02T04:10:00Z" + ] + }, + { + "expression": "59 23 31 12 *", + "from": "2026-01-02T03:04:05.6789Z", + "name": "year_end", + "next": [ + "2026-12-31T23:59:00Z", + "2027-12-31T23:59:00Z", + "2028-12-31T23:59:00Z", + "2029-12-31T23:59:00Z", + "2030-12-31T23:59:00Z" + ] + }, + { + "expression": "@hourly", + "from": "2026-01-02T03:04:05.6789Z", + "name": "descriptor_hourly", + "next": [ + "2026-01-02T04:00:00Z", + "2026-01-02T05:00:00Z", + "2026-01-02T06:00:00Z", + "2026-01-02T07:00:00Z", + "2026-01-02T08:00:00Z" + ] + }, + { + "expression": "@daily", + "from": "2026-01-02T03:04:05.6789Z", + "name": "descriptor_daily", + "next": [ + "2026-01-03T00:00:00Z", + "2026-01-04T00:00:00Z", + "2026-01-05T00:00:00Z", + "2026-01-06T00:00:00Z", + "2026-01-07T00:00:00Z" + ] + }, + { + "expression": "@midnight", + "from": "2026-01-02T03:04:05.6789Z", + "name": "descriptor_midnight", + "next": [ + "2026-01-03T00:00:00Z", + "2026-01-04T00:00:00Z", + "2026-01-05T00:00:00Z", + "2026-01-06T00:00:00Z", + "2026-01-07T00:00:00Z" + ] + }, + { + "expression": "@weekly", + "from": "2026-01-02T03:04:05.6789Z", + "name": "descriptor_weekly", + "next": [ + "2026-01-04T00:00:00Z", + "2026-01-11T00:00:00Z", + "2026-01-18T00:00:00Z", + "2026-01-25T00:00:00Z", + "2026-02-01T00:00:00Z" + ] + }, + { + "expression": "@monthly", + "from": "2026-01-02T03:04:05.6789Z", + "name": "descriptor_monthly", + "next": [ + "2026-02-01T00:00:00Z", + "2026-03-01T00:00:00Z", + "2026-04-01T00:00:00Z", + "2026-05-01T00:00:00Z", + "2026-06-01T00:00:00Z" + ] + }, + { + "expression": "@yearly", + "from": "2026-01-02T03:04:05.6789Z", + "name": "descriptor_yearly", + "next": [ + "2027-01-01T00:00:00Z", + "2028-01-01T00:00:00Z", + "2029-01-01T00:00:00Z", + "2030-01-01T00:00:00Z", + "2031-01-01T00:00:00Z" + ] + }, + { + "expression": "@annually", + "from": "2026-01-02T03:04:05.6789Z", + "name": "descriptor_annually", + "next": [ + "2027-01-01T00:00:00Z", + "2028-01-01T00:00:00Z", + "2029-01-01T00:00:00Z", + "2030-01-01T00:00:00Z", + "2031-01-01T00:00:00Z" + ] + }, + { + "expression": "@every 1h30m", + "from": "2026-01-02T03:04:05.6789Z", + "name": "every_compound_duration", + "next": [ + "2026-01-02T04:34:05Z", + "2026-01-02T06:04:05Z", + "2026-01-02T07:34:05Z", + "2026-01-02T09:04:05Z", + "2026-01-02T10:34:05Z" + ] + }, + { + "expression": "@every 1.5h", + "from": "2026-01-02T03:04:05.6789Z", + "name": "every_fractional_duration", + "next": [ + "2026-01-02T04:34:05Z", + "2026-01-02T06:04:05Z", + "2026-01-02T07:34:05Z", + "2026-01-02T09:04:05Z", + "2026-01-02T10:34:05Z" + ] + }, + { + "expression": "@every 90s", + "from": "2026-01-02T03:04:05.6789Z", + "name": "every_seconds", + "next": [ + "2026-01-02T03:05:35Z", + "2026-01-02T03:07:05Z", + "2026-01-02T03:08:35Z", + "2026-01-02T03:10:05Z", + "2026-01-02T03:11:35Z" + ] + }, + { + "expression": "@every 500ms", + "from": "2026-01-02T03:04:05.6789Z", + "name": "every_rounds_up_to_one_second", + "next": [ + "2026-01-02T03:04:06Z", + "2026-01-02T03:04:07Z", + "2026-01-02T03:04:08Z", + "2026-01-02T03:04:09Z", + "2026-01-02T03:04:10Z" + ] + }, + { + "expression": "@every 1500ms", + "from": "2026-01-02T03:04:05.6789Z", + "name": "every_truncates_subseconds", + "next": [ + "2026-01-02T03:04:06Z", + "2026-01-02T03:04:07Z", + "2026-01-02T03:04:08Z", + "2026-01-02T03:04:09Z", + "2026-01-02T03:04:10Z" + ] + }, + { + "expression": "0 9 * * *", + "from": "2026-03-07T08:00:00-05:00", + "name": "reference_time_offset", + "next": [ + "2026-03-07T09:00:00-05:00", + "2026-03-08T09:00:00-05:00", + "2026-03-09T09:00:00-05:00", + "2026-03-10T09:00:00-05:00", + "2026-03-11T09:00:00-05:00" + ] + }, + { + "expression": "30 0 * * *", + "from": "2026-03-07T23:45:00+05:30", + "name": "reference_time_half_hour_offset", + "next": [ + "2026-03-08T00:30:00+05:30", + "2026-03-09T00:30:00+05:30", + "2026-03-10T00:30:00+05:30", + "2026-03-11T00:30:00+05:30", + "2026-03-12T00:30:00+05:30" + ] + }, + { + "expression": "CRON_TZ=UTC 0 9 * * *", + "from": "2026-03-07T08:00:00-05:00", + "name": "cron_tz_utc_prefix", + "next": [ + "2026-03-08T04:00:00-05:00", + "2026-03-09T04:00:00-05:00", + "2026-03-10T04:00:00-05:00", + "2026-03-11T04:00:00-05:00", + "2026-03-12T04:00:00-05:00" + ] + }, + { + "expression": "TZ=UTC 0 9 * * *", + "from": "2026-03-07T08:00:00-05:00", + "name": "tz_utc_prefix", + "next": [ + "2026-03-08T04:00:00-05:00", + "2026-03-09T04:00:00-05:00", + "2026-03-10T04:00:00-05:00", + "2026-03-11T04:00:00-05:00", + "2026-03-12T04:00:00-05:00" + ] + }, + { + "expression": " 0 9 * * 1 ", + "from": "2026-01-02T03:04:05.6789Z", + "name": "extra_whitespace", + "next": [ + "2026-01-05T09:00:00Z", + "2026-01-12T09:00:00Z", + "2026-01-19T09:00:00Z", + "2026-01-26T09:00:00Z", + "2026-02-02T09:00:00Z" + ] + } + ], + "cron_invalid": [ + "", + "* * * *", + "* * * * * *", + "0 9 * * 7", + "60 * * * *", + "* 24 * * *", + "* * 0 * *", + "* * 32 * *", + "* * * 0 *", + "* * * 13 *", + "-1 * * * *", + "5-1 * * * *", + "1-2-3 * * * *", + "1/2/3 * * * *", + "*/0 * * * *", + "*/x * * * *", + "0 9 * * funday", + "@every", + "@every 5x", + "@reboot", + "CRON_TZ=Nowhere/Invalid 0 9 * * *" + ], + "cron_named_zone_cases": [ + { + "expression": "CRON_TZ=America/New_York 0 9 * * *", + "from": "2026-03-06T12:00:00Z", + "name": "new_york_across_dst_start", + "next": [ + "2026-03-06T14:00:00Z", + "2026-03-07T14:00:00Z", + "2026-03-08T13:00:00Z", + "2026-03-09T13:00:00Z", + "2026-03-10T13:00:00Z" + ] + }, + { + "expression": "CRON_TZ=America/New_York 30 2 * * *", + "from": "2026-03-06T12:00:00Z", + "name": "new_york_skipped_wall_time", + "next": [ + "2026-03-07T07:30:00Z", + "2026-03-09T06:30:00Z", + "2026-03-10T06:30:00Z", + "2026-03-11T06:30:00Z", + "2026-03-12T06:30:00Z" + ] + }, + { + "expression": "CRON_TZ=America/New_York 30 1 * * *", + "from": "2026-10-30T12:00:00Z", + "name": "new_york_repeated_wall_time", + "next": [ + "2026-10-31T05:30:00Z", + "2026-11-01T05:30:00Z", + "2026-11-01T06:30:00Z", + "2026-11-02T06:30:00Z", + "2026-11-03T06:30:00Z" + ] + }, + { + "expression": "CRON_TZ=America/New_York 0 * * * *", + "from": "2026-11-01T04:30:00Z", + "name": "new_york_hourly_across_dst_end", + "next": [ + "2026-11-01T05:00:00Z", + "2026-11-01T06:00:00Z", + "2026-11-01T07:00:00Z", + "2026-11-01T08:00:00Z", + "2026-11-01T09:00:00Z" + ] + }, + { + "expression": "CRON_TZ=Europe/London 0 0 * * *", + "from": "2026-10-23T12:00:00Z", + "name": "london_across_dst_end", + "next": [ + "2026-10-23T23:00:00Z", + "2026-10-24T23:00:00Z", + "2026-10-26T00:00:00Z", + "2026-10-27T00:00:00Z", + "2026-10-28T00:00:00Z" + ] + }, + { + "expression": "CRON_TZ=America/Santiago 0 0 * * *", + "from": "2026-09-03T12:00:00Z", + "name": "santiago_skipped_midnight", + "next": [ + "2026-09-04T04:00:00Z", + "2026-09-05T04:00:00Z", + "2026-09-07T03:00:00Z", + "2026-09-08T03:00:00Z", + "2026-09-09T03:00:00Z" + ] + }, + { + "expression": "CRON_TZ=America/Santiago 0 12 * * *", + "from": "2026-09-03T12:00:00Z", + "name": "santiago_day_after_skipped_midnight", + "next": [ + "2026-09-03T16:00:00Z", + "2026-09-04T16:00:00Z", + "2026-09-05T16:00:00Z", + "2026-09-06T15:00:00Z", + "2026-09-07T15:00:00Z" + ] + }, + { + "expression": "CRON_TZ=America/Santiago 30 23 * * *", + "from": "2026-04-02T12:00:00Z", + "name": "santiago_repeated_hour_before_midnight", + "next": [ + "2026-04-03T02:30:00Z", + "2026-04-04T02:30:00Z", + "2026-04-05T02:30:00Z", + "2026-04-05T03:30:00Z", + "2026-04-06T03:30:00Z" + ] + }, + { + "expression": "TZ=Asia/Kolkata 0 9 * * mon", + "from": "2026-01-02T03:04:05-05:00", + "name": "kolkata_tz_prefix", + "next": [ + "2026-01-04T22:30:00-05:00", + "2026-01-11T22:30:00-05:00", + "2026-01-18T22:30:00-05:00", + "2026-01-25T22:30:00-05:00", + "2026-02-01T22:30:00-05:00" + ] + } + ] +} From 703717b6f7f30224f8d1ba9bf7ac5244a0282c07 Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sun, 4 Oct 2026 17:20:32 -0500 Subject: [PATCH 27/43] add @riverqueue/migrate with River's canonical migrations Bundle River's PostgreSQL and SQLite `main` migration lines, versions 1 through 8, copied byte for byte from River with a manifest of SHA-256 digests that the loader verifies before running anything. `createMigrator` takes the driver a client uses, or a bare connection (`{ pool, schema }`, `{ client, schema }`, or `{ database }`), and plans and applies migrations up or down with River's version bookkeeping. Each step runs in a transaction under a lock: a transaction-scoped advisory lock on PostgreSQL, skipped on YugabyteDB like Go's migrator, and `BEGIN IMMEDIATE` on SQLite, retried asynchronously while another connection holds the write lock. `generate:migrations` copies the migrations from River's Go drivers, and `verify:migrations` checks the committed copy and its manifest against them. --- js/migrate/migrations/manifest.json | 141 +++++ .../main/001_create_river_migration.down.sql | 1 + .../main/001_create_river_migration.up.sql | 8 + .../postgres/main/002_initial_schema.down.sql | 5 + .../postgres/main/002_initial_schema.up.sql | 96 ++++ .../main/003_river_job_tags_non_null.down.sql | 3 + .../main/003_river_job_tags_non_null.up.sql | 3 + .../main/004_pending_and_more.down.sql | 42 ++ .../postgres/main/004_pending_and_more.up.sql | 45 ++ .../main/005_migration_unique_client.down.sql | 57 ++ .../main/005_migration_unique_client.up.sql | 79 +++ .../postgres/main/006_bulk_unique.down.sql | 16 + .../postgres/main/006_bulk_unique.up.sql | 40 ++ ...tbox_sqlite_jsonb_and_sql_cleanup.down.sql | 56 ++ ...outbox_sqlite_jsonb_and_sql_cleanup.up.sql | 44 ++ .../main/008_job_id_autoincrement.down.sql | 3 + .../main/008_job_id_autoincrement.up.sql | 3 + .../main/001_create_river_migration.down.sql | 1 + .../main/001_create_river_migration.up.sql | 8 + .../sqlite/main/002_initial_schema.down.sql | 8 + .../sqlite/main/002_initial_schema.up.sql | 19 + .../main/003_river_job_tags_non_null.down.sql | 6 + .../main/003_river_job_tags_non_null.up.sql | 6 + .../sqlite/main/004_pending_and_more.down.sql | 26 + .../sqlite/main/004_pending_and_more.up.sql | 33 ++ .../main/005_migration_unique_client.down.sql | 37 ++ .../main/005_migration_unique_client.up.sql | 64 +++ .../sqlite/main/006_bulk_unique.down.sql | 7 + .../sqlite/main/006_bulk_unique.up.sql | 63 ++ ...tbox_sqlite_jsonb_and_sql_cleanup.down.sql | 255 ++++++++ ...outbox_sqlite_jsonb_and_sql_cleanup.up.sql | 261 +++++++++ .../main/008_job_id_autoincrement.down.sql | 121 ++++ .../main/008_job_id_autoincrement.up.sql | 123 ++++ js/migrate/package.json | 69 +++ js/migrate/src/bundle.ts | 118 ++++ js/migrate/src/index.test.ts | 21 + js/migrate/src/index.ts | 32 ++ js/migrate/src/migrator.integration.test.ts | 376 ++++++++++++ js/migrate/src/migrator.test.ts | 542 ++++++++++++++++++ js/migrate/src/migrator.ts | 469 +++++++++++++++ js/migrate/src/plan.test.ts | 157 +++++ js/migrate/src/plan.ts | 193 +++++++ js/migrate/src/postgres.ts | 298 ++++++++++ js/migrate/src/sqlite.ts | 204 +++++++ js/migrate/src/storage.ts | 33 ++ js/migrate/tsconfig.json | 9 + js/package.json | 16 +- js/pnpm-lock.yaml | 15 + js/pnpm-workspace.yaml | 1 + js/scripts/sync-migrations.mjs | 91 +++ js/tsconfig.tests.json | 6 +- 51 files changed, 4322 insertions(+), 8 deletions(-) create mode 100644 js/migrate/migrations/manifest.json create mode 100644 js/migrate/migrations/postgres/main/001_create_river_migration.down.sql create mode 100644 js/migrate/migrations/postgres/main/001_create_river_migration.up.sql create mode 100644 js/migrate/migrations/postgres/main/002_initial_schema.down.sql create mode 100644 js/migrate/migrations/postgres/main/002_initial_schema.up.sql create mode 100644 js/migrate/migrations/postgres/main/003_river_job_tags_non_null.down.sql create mode 100644 js/migrate/migrations/postgres/main/003_river_job_tags_non_null.up.sql create mode 100644 js/migrate/migrations/postgres/main/004_pending_and_more.down.sql create mode 100644 js/migrate/migrations/postgres/main/004_pending_and_more.up.sql create mode 100644 js/migrate/migrations/postgres/main/005_migration_unique_client.down.sql create mode 100644 js/migrate/migrations/postgres/main/005_migration_unique_client.up.sql create mode 100644 js/migrate/migrations/postgres/main/006_bulk_unique.down.sql create mode 100644 js/migrate/migrations/postgres/main/006_bulk_unique.up.sql create mode 100644 js/migrate/migrations/postgres/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql create mode 100644 js/migrate/migrations/postgres/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql create mode 100644 js/migrate/migrations/postgres/main/008_job_id_autoincrement.down.sql create mode 100644 js/migrate/migrations/postgres/main/008_job_id_autoincrement.up.sql create mode 100644 js/migrate/migrations/sqlite/main/001_create_river_migration.down.sql create mode 100644 js/migrate/migrations/sqlite/main/001_create_river_migration.up.sql create mode 100644 js/migrate/migrations/sqlite/main/002_initial_schema.down.sql create mode 100644 js/migrate/migrations/sqlite/main/002_initial_schema.up.sql create mode 100644 js/migrate/migrations/sqlite/main/003_river_job_tags_non_null.down.sql create mode 100644 js/migrate/migrations/sqlite/main/003_river_job_tags_non_null.up.sql create mode 100644 js/migrate/migrations/sqlite/main/004_pending_and_more.down.sql create mode 100644 js/migrate/migrations/sqlite/main/004_pending_and_more.up.sql create mode 100644 js/migrate/migrations/sqlite/main/005_migration_unique_client.down.sql create mode 100644 js/migrate/migrations/sqlite/main/005_migration_unique_client.up.sql create mode 100644 js/migrate/migrations/sqlite/main/006_bulk_unique.down.sql create mode 100644 js/migrate/migrations/sqlite/main/006_bulk_unique.up.sql create mode 100644 js/migrate/migrations/sqlite/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql create mode 100644 js/migrate/migrations/sqlite/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql create mode 100644 js/migrate/migrations/sqlite/main/008_job_id_autoincrement.down.sql create mode 100644 js/migrate/migrations/sqlite/main/008_job_id_autoincrement.up.sql create mode 100644 js/migrate/package.json create mode 100644 js/migrate/src/bundle.ts create mode 100644 js/migrate/src/index.test.ts create mode 100644 js/migrate/src/index.ts create mode 100644 js/migrate/src/migrator.integration.test.ts create mode 100644 js/migrate/src/migrator.test.ts create mode 100644 js/migrate/src/migrator.ts create mode 100644 js/migrate/src/plan.test.ts create mode 100644 js/migrate/src/plan.ts create mode 100644 js/migrate/src/postgres.ts create mode 100644 js/migrate/src/sqlite.ts create mode 100644 js/migrate/src/storage.ts create mode 100644 js/migrate/tsconfig.json create mode 100644 js/scripts/sync-migrations.mjs diff --git a/js/migrate/migrations/manifest.json b/js/migrate/migrations/manifest.json new file mode 100644 index 000000000..02ce02607 --- /dev/null +++ b/js/migrate/migrations/manifest.json @@ -0,0 +1,141 @@ +{ + "backends": { + "postgres": [ + { + "file": "001_create_river_migration.down.sql", + "sha256": "34c87dc594bf7520bc3ae69f6f0da8d2d9a472616ab38b37e63d4e3838da06d2" + }, + { + "file": "001_create_river_migration.up.sql", + "sha256": "79def9ab1643beee7776c499559ec199a03b5b26036c122dc3ba13ec3d078dc0" + }, + { + "file": "002_initial_schema.down.sql", + "sha256": "8e7e73755b3e9cd1d46f0dffeadd427b86af13cea2f41f3d30af1624329db9b9" + }, + { + "file": "002_initial_schema.up.sql", + "sha256": "8915c00d08ed98625865c705b6fd0bd14c113b7cdd0cb218ee894eca1d32ad03" + }, + { + "file": "003_river_job_tags_non_null.down.sql", + "sha256": "bca44f6f0e926411c9e26e7ce2598bbdb5102b286f380135d9a5bcd96a77cbb8" + }, + { + "file": "003_river_job_tags_non_null.up.sql", + "sha256": "dedb183bb302c005bc72caf2901ff693bbab11413308c5e0567ddffb51e667ef" + }, + { + "file": "004_pending_and_more.down.sql", + "sha256": "91b5ced7b9d707a0de73f5b312596935950b70229f58aa9bf3ca362aa7a408c8" + }, + { + "file": "004_pending_and_more.up.sql", + "sha256": "3f7418b0cf78ede9a9ec730bdfc4389a84e05989531b205fc4ece0d2bb10e390" + }, + { + "file": "005_migration_unique_client.down.sql", + "sha256": "de84dca49a5d618d2a4973b13a69830fbebb0f9635babfa50c6f19577193425b" + }, + { + "file": "005_migration_unique_client.up.sql", + "sha256": "b760f487152c7d92102869d46b8a64dc1e2094d5675e690ffbe52a747eee8431" + }, + { + "file": "006_bulk_unique.down.sql", + "sha256": "726483f6e5aa7dd02cdd974cd7bf716973a8d0a97ba6dbc5dc5304aaf54c7ad7" + }, + { + "file": "006_bulk_unique.up.sql", + "sha256": "3b133f7ce4662d3dc8bd4a57628e0e116a300b2635a79315557aa1369849f0fb" + }, + { + "file": "007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql", + "sha256": "9131aae235187dbdaaa822dab2a475a884e917d9af05e3c98fb95c152eaa769a" + }, + { + "file": "007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql", + "sha256": "47ec8031b88e69004de2def5bc3109d969f71ee4c33a1e7dac2fb8c9dd19182d" + }, + { + "file": "008_job_id_autoincrement.down.sql", + "sha256": "0c3750a947d6494db07d56f5d3735a5e49a2cbfa1f7a09771227c31c8147bf70" + }, + { + "file": "008_job_id_autoincrement.up.sql", + "sha256": "0c3750a947d6494db07d56f5d3735a5e49a2cbfa1f7a09771227c31c8147bf70" + } + ], + "sqlite": [ + { + "file": "001_create_river_migration.down.sql", + "sha256": "34c87dc594bf7520bc3ae69f6f0da8d2d9a472616ab38b37e63d4e3838da06d2" + }, + { + "file": "001_create_river_migration.up.sql", + "sha256": "d15597cb0bb884fb0727d2a29ad8313842708b55fd561a5fe62e37aad5f34298" + }, + { + "file": "002_initial_schema.down.sql", + "sha256": "900508ba08d0ca3c8451eb2854cd9ab837166ef55736393524253b6228438470" + }, + { + "file": "002_initial_schema.up.sql", + "sha256": "58bc64db39fa813ab1eee92b5c3f6e4463f88ac1de85df731d959cdede7d4f35" + }, + { + "file": "003_river_job_tags_non_null.down.sql", + "sha256": "223eb849addf451228e7f057c2e29b005aaf63ddf25c51a7a088426d139d9dd9" + }, + { + "file": "003_river_job_tags_non_null.up.sql", + "sha256": "ae9961ea15b2fbe88298c687dd524ada29e018f8fbc493db517e988d9d0b61c2" + }, + { + "file": "004_pending_and_more.down.sql", + "sha256": "28065bbe82dbaa187d8705861d8fec558f210eb739feba63e7913a09a5e9ae3f" + }, + { + "file": "004_pending_and_more.up.sql", + "sha256": "8c11c8d2bf63200e2cfe58dca1e5d30131fed0f99cb74146b3116fa1286a93f5" + }, + { + "file": "005_migration_unique_client.down.sql", + "sha256": "9960dc49a2293a9bdbdb32ca61dc2971cde5b49ac658ef21ed7f96ec6e011997" + }, + { + "file": "005_migration_unique_client.up.sql", + "sha256": "67c32e81494b62baf1e6b0fb6025e882a7b1be7c9ed2236799d17d00912213c4" + }, + { + "file": "006_bulk_unique.down.sql", + "sha256": "b9e778134d15e815cf0694f06444f738cde072c2d297978eb30d7bd7bdb2802c" + }, + { + "file": "006_bulk_unique.up.sql", + "sha256": "96713f4832bcf9343df30b62c6c35b03daca23aa9a02adbd7eb0091556df3659" + }, + { + "file": "007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql", + "sha256": "55bffeb528b40dffc0cbec2f22d1463aef37a8b3d7977f83729cc505bb77c745" + }, + { + "file": "007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql", + "sha256": "441a05e1d9aa4f151877ccf725b0b0a86f27013442297d4b621c05676270d0b0" + }, + { + "file": "008_job_id_autoincrement.down.sql", + "sha256": "04871283fe5d4cab4ac70da28d8509aa7ba765994d528c2dcc7ebe0ee596710c" + }, + { + "file": "008_job_id_autoincrement.up.sql", + "sha256": "049c9bf615f24a326bcc31ddc87b46f76e3de11e3eea3b2b9e1358131b3f3bed" + } + ] + }, + "format": 1, + "sources": { + "postgres": "riverdriver/riverpgxv5/migration/main", + "sqlite": "riverdriver/riversqlite/migration/main" + } +} diff --git a/js/migrate/migrations/postgres/main/001_create_river_migration.down.sql b/js/migrate/migrations/postgres/main/001_create_river_migration.down.sql new file mode 100644 index 000000000..8bfe82027 --- /dev/null +++ b/js/migrate/migrations/postgres/main/001_create_river_migration.down.sql @@ -0,0 +1 @@ +DROP TABLE /* TEMPLATE: schema */river_migration; \ No newline at end of file diff --git a/js/migrate/migrations/postgres/main/001_create_river_migration.up.sql b/js/migrate/migrations/postgres/main/001_create_river_migration.up.sql new file mode 100644 index 000000000..27006d562 --- /dev/null +++ b/js/migrate/migrations/postgres/main/001_create_river_migration.up.sql @@ -0,0 +1,8 @@ +CREATE TABLE /* TEMPLATE: schema */river_migration( + id bigserial PRIMARY KEY, + created_at timestamptz NOT NULL DEFAULT NOW(), + version bigint NOT NULL, + CONSTRAINT version CHECK (version >= 1) +); + +CREATE UNIQUE INDEX ON /* TEMPLATE: schema */river_migration USING btree(version); \ No newline at end of file diff --git a/js/migrate/migrations/postgres/main/002_initial_schema.down.sql b/js/migrate/migrations/postgres/main/002_initial_schema.down.sql new file mode 100644 index 000000000..d334d8a65 --- /dev/null +++ b/js/migrate/migrations/postgres/main/002_initial_schema.down.sql @@ -0,0 +1,5 @@ +DROP TABLE /* TEMPLATE: schema */river_job; +DROP FUNCTION /* TEMPLATE: schema */river_job_notify; +DROP TYPE /* TEMPLATE: schema */river_job_state; + +DROP TABLE /* TEMPLATE: schema */river_leader; \ No newline at end of file diff --git a/js/migrate/migrations/postgres/main/002_initial_schema.up.sql b/js/migrate/migrations/postgres/main/002_initial_schema.up.sql new file mode 100644 index 000000000..7fbca71b4 --- /dev/null +++ b/js/migrate/migrations/postgres/main/002_initial_schema.up.sql @@ -0,0 +1,96 @@ +CREATE TYPE /* TEMPLATE: schema */river_job_state AS ENUM( + 'available', + 'cancelled', + 'completed', + 'discarded', + 'retryable', + 'running', + 'scheduled' +); + +CREATE TABLE /* TEMPLATE: schema */river_job( + -- 8 bytes + id bigserial PRIMARY KEY, + + -- 8 bytes (4 bytes + 2 bytes + 2 bytes) + -- + -- `state` is kept near the top of the table for operator convenience -- when + -- looking at jobs with `SELECT *` it'll appear first after ID. The other two + -- fields aren't as important but are kept adjacent to `state` for alignment + -- to get an 8-byte block. + state /* TEMPLATE: schema */river_job_state NOT NULL DEFAULT 'available', + attempt smallint NOT NULL DEFAULT 0, + max_attempts smallint NOT NULL, + + -- 8 bytes each (no alignment needed) + attempted_at timestamptz, + created_at timestamptz NOT NULL DEFAULT NOW(), + finalized_at timestamptz, + scheduled_at timestamptz NOT NULL DEFAULT NOW(), + + -- 2 bytes (some wasted padding probably) + priority smallint NOT NULL DEFAULT 1, + + -- types stored out-of-band + args jsonb, + attempted_by text[], + errors jsonb[], + kind text NOT NULL, + metadata jsonb NOT NULL DEFAULT '{}', + queue text NOT NULL DEFAULT 'default', + tags varchar(255)[], + + CONSTRAINT finalized_or_finalized_at_null CHECK ((state IN ('cancelled', 'completed', 'discarded') AND finalized_at IS NOT NULL) OR finalized_at IS NULL), + CONSTRAINT max_attempts_is_positive CHECK (max_attempts > 0), + CONSTRAINT priority_in_range CHECK (priority >= 1 AND priority <= 4), + CONSTRAINT queue_length CHECK (char_length(queue) > 0 AND char_length(queue) < 128), + CONSTRAINT kind_length CHECK (char_length(kind) > 0 AND char_length(kind) < 128) +); + +-- We may want to consider adding another property here after `kind` if it seems +-- like it'd be useful for something. +CREATE INDEX river_job_kind ON /* TEMPLATE: schema */river_job USING btree(kind); + +CREATE INDEX river_job_state_and_finalized_at_index ON /* TEMPLATE: schema */river_job USING btree(state, finalized_at) WHERE finalized_at IS NOT NULL; + +CREATE INDEX river_job_prioritized_fetching_index ON /* TEMPLATE: schema */river_job USING btree(state, queue, priority, scheduled_at, id); + +CREATE INDEX river_job_args_index ON /* TEMPLATE: schema */river_job USING GIN(args); + +CREATE INDEX river_job_metadata_index ON /* TEMPLATE: schema */river_job USING GIN(metadata); + +CREATE OR REPLACE FUNCTION /* TEMPLATE: schema */river_job_notify() + RETURNS TRIGGER + AS $$ +DECLARE + payload json; +BEGIN + IF NEW.state = 'available' THEN + -- Notify will coalesce duplicate notifications within a transaction, so + -- keep these payloads generalized: + payload = json_build_object('queue', NEW.queue); + PERFORM + pg_notify('river_insert', payload::text); + END IF; + RETURN NULL; +END; +$$ +LANGUAGE plpgsql; + +CREATE TRIGGER river_notify + AFTER INSERT ON /* TEMPLATE: schema */river_job + FOR EACH ROW + EXECUTE PROCEDURE /* TEMPLATE: schema */river_job_notify(); + +CREATE UNLOGGED TABLE /* TEMPLATE: schema */river_leader( + -- 8 bytes each (no alignment needed) + elected_at timestamptz NOT NULL, + expires_at timestamptz NOT NULL, + + -- types stored out-of-band + leader_id text NOT NULL, + name text PRIMARY KEY, + + CONSTRAINT name_length CHECK (char_length(name) > 0 AND char_length(name) < 128), + CONSTRAINT leader_id_length CHECK (char_length(leader_id) > 0 AND char_length(leader_id) < 128) +); diff --git a/js/migrate/migrations/postgres/main/003_river_job_tags_non_null.down.sql b/js/migrate/migrations/postgres/main/003_river_job_tags_non_null.down.sql new file mode 100644 index 000000000..acef65cb9 --- /dev/null +++ b/js/migrate/migrations/postgres/main/003_river_job_tags_non_null.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE /* TEMPLATE: schema */river_job + ALTER COLUMN tags DROP NOT NULL, + ALTER COLUMN tags DROP DEFAULT; diff --git a/js/migrate/migrations/postgres/main/003_river_job_tags_non_null.up.sql b/js/migrate/migrations/postgres/main/003_river_job_tags_non_null.up.sql new file mode 100644 index 000000000..0a472dde4 --- /dev/null +++ b/js/migrate/migrations/postgres/main/003_river_job_tags_non_null.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE /* TEMPLATE: schema */river_job ALTER COLUMN tags SET DEFAULT '{}'; +UPDATE /* TEMPLATE: schema */river_job SET tags = '{}' WHERE tags IS NULL; +ALTER TABLE /* TEMPLATE: schema */river_job ALTER COLUMN tags SET NOT NULL; diff --git a/js/migrate/migrations/postgres/main/004_pending_and_more.down.sql b/js/migrate/migrations/postgres/main/004_pending_and_more.down.sql new file mode 100644 index 000000000..1b7ec7e84 --- /dev/null +++ b/js/migrate/migrations/postgres/main/004_pending_and_more.down.sql @@ -0,0 +1,42 @@ +ALTER TABLE /* TEMPLATE: schema */river_job ALTER COLUMN args DROP NOT NULL; + +ALTER TABLE /* TEMPLATE: schema */river_job ALTER COLUMN metadata DROP NOT NULL; +ALTER TABLE /* TEMPLATE: schema */river_job ALTER COLUMN metadata DROP DEFAULT; + +-- It is not possible to safely remove 'pending' from the river_job_state enum, +-- so leave it in place. + +ALTER TABLE /* TEMPLATE: schema */river_job DROP CONSTRAINT finalized_or_finalized_at_null; +ALTER TABLE /* TEMPLATE: schema */river_job ADD CONSTRAINT finalized_or_finalized_at_null CHECK ( + (state IN ('cancelled', 'completed', 'discarded') AND finalized_at IS NOT NULL) OR finalized_at IS NULL +); + +CREATE OR REPLACE FUNCTION /* TEMPLATE: schema */river_job_notify() + RETURNS TRIGGER + AS $$ +DECLARE + payload json; +BEGIN + IF NEW.state = 'available' THEN + -- Notify will coalesce duplicate notifications within a transaction, so + -- keep these payloads generalized: + payload = json_build_object('queue', NEW.queue); + PERFORM + pg_notify('river_insert', payload::text); + END IF; + RETURN NULL; +END; +$$ +LANGUAGE plpgsql; + +CREATE TRIGGER river_notify + AFTER INSERT ON /* TEMPLATE: schema */river_job + FOR EACH ROW + EXECUTE PROCEDURE /* TEMPLATE: schema */river_job_notify(); + +DROP TABLE /* TEMPLATE: schema */river_queue; + +ALTER TABLE /* TEMPLATE: schema */river_leader + ALTER COLUMN name DROP DEFAULT, + DROP CONSTRAINT name_length, + ADD CONSTRAINT name_length CHECK (char_length(name) > 0 AND char_length(name) < 128); \ No newline at end of file diff --git a/js/migrate/migrations/postgres/main/004_pending_and_more.up.sql b/js/migrate/migrations/postgres/main/004_pending_and_more.up.sql new file mode 100644 index 000000000..9f5e47bb1 --- /dev/null +++ b/js/migrate/migrations/postgres/main/004_pending_and_more.up.sql @@ -0,0 +1,45 @@ +-- The args column never had a NOT NULL constraint or default value at the +-- database level, though we tried to ensure one at the application level. +ALTER TABLE /* TEMPLATE: schema */river_job ALTER COLUMN args SET DEFAULT '{}'; +UPDATE /* TEMPLATE: schema */river_job SET args = '{}' WHERE args IS NULL; +ALTER TABLE /* TEMPLATE: schema */river_job ALTER COLUMN args SET NOT NULL; +ALTER TABLE /* TEMPLATE: schema */river_job ALTER COLUMN args DROP DEFAULT; + +-- The metadata column never had a NOT NULL constraint or default value at the +-- database level, though we tried to ensure one at the application level. +ALTER TABLE /* TEMPLATE: schema */river_job ALTER COLUMN metadata SET DEFAULT '{}'; +UPDATE /* TEMPLATE: schema */river_job SET metadata = '{}' WHERE metadata IS NULL; +ALTER TABLE /* TEMPLATE: schema */river_job ALTER COLUMN metadata SET NOT NULL; + +-- The 'pending' job state will be used for upcoming functionality: +ALTER TYPE /* TEMPLATE: schema */river_job_state ADD VALUE IF NOT EXISTS 'pending' AFTER 'discarded'; + +ALTER TABLE /* TEMPLATE: schema */river_job DROP CONSTRAINT finalized_or_finalized_at_null; +ALTER TABLE /* TEMPLATE: schema */river_job ADD CONSTRAINT finalized_or_finalized_at_null CHECK ( + (finalized_at IS NULL AND state NOT IN ('cancelled', 'completed', 'discarded')) OR + (finalized_at IS NOT NULL AND state IN ('cancelled', 'completed', 'discarded')) +); + +DROP TRIGGER river_notify ON /* TEMPLATE: schema */river_job; +DROP FUNCTION /* TEMPLATE: schema */river_job_notify; + +-- +-- Create table `river_queue`. +-- + +CREATE TABLE /* TEMPLATE: schema */river_queue ( + name text PRIMARY KEY NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + metadata jsonb NOT NULL DEFAULT '{}' ::jsonb, + paused_at timestamptz, + updated_at timestamptz NOT NULL +); + +-- +-- Alter `river_leader` to add a default value of 'default` to `name`. +-- + +ALTER TABLE /* TEMPLATE: schema */river_leader + ALTER COLUMN name SET DEFAULT 'default', + DROP CONSTRAINT name_length, + ADD CONSTRAINT name_length CHECK (name = 'default'); \ No newline at end of file diff --git a/js/migrate/migrations/postgres/main/005_migration_unique_client.down.sql b/js/migrate/migrations/postgres/main/005_migration_unique_client.down.sql new file mode 100644 index 000000000..b8e041d54 --- /dev/null +++ b/js/migrate/migrations/postgres/main/005_migration_unique_client.down.sql @@ -0,0 +1,57 @@ +-- +-- Revert to migration table based only on `(version)`. +-- +-- If any non-main migrations are present, 005 is considered irreversible. +-- + +DO +$body$ +BEGIN + -- Tolerate users who may be using their own migration system rather than + -- River's. If they are, they will have skipped version 001 containing + -- `CREATE TABLE river_migration`, so this table won't exist. + IF (SELECT to_regclass('/* TEMPLATE: schema */river_migration') IS NOT NULL) THEN + IF EXISTS ( + SELECT * + FROM /* TEMPLATE: schema */river_migration + WHERE line <> 'main' + ) THEN + RAISE EXCEPTION 'Found non-main migration lines in the database; version 005 migration is irreversible because it would result in loss of migration information.'; + END IF; + + ALTER TABLE /* TEMPLATE: schema */river_migration + RENAME TO river_migration_old; + + CREATE TABLE /* TEMPLATE: schema */river_migration( + id bigserial PRIMARY KEY, + created_at timestamptz NOT NULL DEFAULT NOW(), + version bigint NOT NULL, + CONSTRAINT version CHECK (version >= 1) + ); + + CREATE UNIQUE INDEX ON /* TEMPLATE: schema */river_migration USING btree(version); + + INSERT INTO /* TEMPLATE: schema */river_migration + (created_at, version) + SELECT created_at, version + FROM /* TEMPLATE: schema */river_migration_old; + + DROP TABLE /* TEMPLATE: schema */river_migration_old; + END IF; +END; +$body$ +LANGUAGE 'plpgsql'; + +-- +-- Drop `river_job.unique_key`. +-- + +ALTER TABLE /* TEMPLATE: schema */river_job + DROP COLUMN unique_key; + +-- +-- Drop `river_client` and derivative. +-- + +DROP TABLE /* TEMPLATE: schema */river_client_queue; +DROP TABLE /* TEMPLATE: schema */river_client; diff --git a/js/migrate/migrations/postgres/main/005_migration_unique_client.up.sql b/js/migrate/migrations/postgres/main/005_migration_unique_client.up.sql new file mode 100644 index 000000000..e0f1711ec --- /dev/null +++ b/js/migrate/migrations/postgres/main/005_migration_unique_client.up.sql @@ -0,0 +1,79 @@ +-- +-- Rebuild the migration table so it's based on `(line, version)`. +-- + +DO +$body$ +BEGIN + -- Tolerate users who may be using their own migration system rather than + -- River's. If they are, they will have skipped version 001 containing + -- `CREATE TABLE river_migration`, so this table won't exist. + IF (SELECT to_regclass('/* TEMPLATE: schema */river_migration') IS NOT NULL) THEN + ALTER TABLE /* TEMPLATE: schema */river_migration + RENAME TO river_migration_old; + + CREATE TABLE /* TEMPLATE: schema */river_migration( + line TEXT NOT NULL, + version bigint NOT NULL, + created_at timestamptz NOT NULL DEFAULT NOW(), + CONSTRAINT line_length CHECK (char_length(line) > 0 AND char_length(line) < 128), + CONSTRAINT version_gte_1 CHECK (version >= 1), + PRIMARY KEY (line, version) + ); + + INSERT INTO /* TEMPLATE: schema */river_migration + (created_at, line, version) + SELECT created_at, 'main', version + FROM /* TEMPLATE: schema */river_migration_old; + + DROP TABLE /* TEMPLATE: schema */river_migration_old; + END IF; +END; +$body$ +LANGUAGE 'plpgsql'; + +-- +-- Add `river_job.unique_key` and bring up an index on it. +-- + +-- These statements use `IF NOT EXISTS` to allow users with a `river_job` table +-- of non-trivial size to build the index `CONCURRENTLY` out of band of this +-- migration, then follow by completing the migration. +ALTER TABLE /* TEMPLATE: schema */river_job + ADD COLUMN IF NOT EXISTS unique_key bytea; + +CREATE UNIQUE INDEX IF NOT EXISTS river_job_kind_unique_key_idx ON /* TEMPLATE: schema */river_job (kind, unique_key) WHERE unique_key IS NOT NULL; + +-- +-- Create `river_client` and derivative. +-- +-- This feature hasn't quite yet been implemented, but we're taking advantage of +-- the migration to add the schema early so that we can add it later without an +-- additional migration. +-- + +CREATE UNLOGGED TABLE /* TEMPLATE: schema */river_client ( + id text PRIMARY KEY NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + metadata jsonb NOT NULL DEFAULT '{}', + paused_at timestamptz, + updated_at timestamptz NOT NULL, + CONSTRAINT name_length CHECK (char_length(id) > 0 AND char_length(id) < 128) +); + +-- Differs from `river_queue` in that it tracks the queue state for a particular +-- active client. +CREATE UNLOGGED TABLE /* TEMPLATE: schema */river_client_queue ( + river_client_id text NOT NULL REFERENCES /* TEMPLATE: schema */river_client (id) ON DELETE CASCADE, + name text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + max_workers bigint NOT NULL DEFAULT 0, + metadata jsonb NOT NULL DEFAULT '{}', + num_jobs_completed bigint NOT NULL DEFAULT 0, + num_jobs_running bigint NOT NULL DEFAULT 0, + updated_at timestamptz NOT NULL, + PRIMARY KEY (river_client_id, name), + CONSTRAINT name_length CHECK (char_length(name) > 0 AND char_length(name) < 128), + CONSTRAINT num_jobs_completed_zero_or_positive CHECK (num_jobs_completed >= 0), + CONSTRAINT num_jobs_running_zero_or_positive CHECK (num_jobs_running >= 0) +); \ No newline at end of file diff --git a/js/migrate/migrations/postgres/main/006_bulk_unique.down.sql b/js/migrate/migrations/postgres/main/006_bulk_unique.down.sql new file mode 100644 index 000000000..26cd84345 --- /dev/null +++ b/js/migrate/migrations/postgres/main/006_bulk_unique.down.sql @@ -0,0 +1,16 @@ + +-- +-- Drop `river_job.unique_states` and its index. +-- + +DROP INDEX /* TEMPLATE: schema */river_job_unique_idx; + +ALTER TABLE /* TEMPLATE: schema */river_job + DROP COLUMN unique_states; + +CREATE UNIQUE INDEX IF NOT EXISTS river_job_kind_unique_key_idx ON /* TEMPLATE: schema */river_job (kind, unique_key) WHERE unique_key IS NOT NULL; + +-- +-- Drop `river_job_state_in_bitmask` function. +-- +DROP FUNCTION /* TEMPLATE: schema */river_job_state_in_bitmask; diff --git a/js/migrate/migrations/postgres/main/006_bulk_unique.up.sql b/js/migrate/migrations/postgres/main/006_bulk_unique.up.sql new file mode 100644 index 000000000..ef96a19f9 --- /dev/null +++ b/js/migrate/migrations/postgres/main/006_bulk_unique.up.sql @@ -0,0 +1,40 @@ +CREATE OR REPLACE FUNCTION /* TEMPLATE: schema */river_job_state_in_bitmask(bitmask BIT(8), state /* TEMPLATE: schema */river_job_state) +RETURNS boolean +LANGUAGE SQL +IMMUTABLE +AS $$ + SELECT CASE state + WHEN 'available' THEN get_bit(bitmask, 7) + WHEN 'cancelled' THEN get_bit(bitmask, 6) + WHEN 'completed' THEN get_bit(bitmask, 5) + WHEN 'discarded' THEN get_bit(bitmask, 4) + WHEN 'pending' THEN get_bit(bitmask, 3) + WHEN 'retryable' THEN get_bit(bitmask, 2) + WHEN 'running' THEN get_bit(bitmask, 1) + WHEN 'scheduled' THEN get_bit(bitmask, 0) + ELSE 0 + END = 1; +$$; + +-- +-- Add `river_job.unique_states` and bring up an index on it. +-- +-- This column may exist already if users manually created the column and index +-- as instructed in the changelog so the index could be created `CONCURRENTLY`. +-- +ALTER TABLE /* TEMPLATE: schema */river_job ADD COLUMN IF NOT EXISTS unique_states BIT(8); + +-- This statement uses `IF NOT EXISTS` to allow users with a `river_job` table +-- of non-trivial size to build the index `CONCURRENTLY` out of band of this +-- migration, then follow by completing the migration. +CREATE UNIQUE INDEX IF NOT EXISTS river_job_unique_idx ON /* TEMPLATE: schema */river_job (unique_key) + WHERE unique_key IS NOT NULL + AND unique_states IS NOT NULL + AND /* TEMPLATE: schema */river_job_state_in_bitmask(unique_states, state); + +-- Remove the old unique index. Users who are actively using the unique jobs +-- feature and who wish to avoid deploy downtime may want od drop this in a +-- subsequent migration once all jobs using the old unique system have been +-- completed (i.e. no more rows with non-null unique_key and null +-- unique_states). +DROP INDEX /* TEMPLATE: schema */river_job_kind_unique_key_idx; diff --git a/js/migrate/migrations/postgres/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql b/js/migrate/migrations/postgres/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql new file mode 100644 index 000000000..bed717f87 --- /dev/null +++ b/js/migrate/migrations/postgres/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql @@ -0,0 +1,56 @@ +-- +-- SQL cleanup rollback. +-- + +-- +-- Add back unused tables `river_client` and `river_client_queue`. +-- + +CREATE UNLOGGED TABLE /* TEMPLATE: schema */river_client ( + id text PRIMARY KEY NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + metadata jsonb NOT NULL DEFAULT '{}', + paused_at timestamptz, + updated_at timestamptz NOT NULL, + CONSTRAINT name_length CHECK (char_length(id) > 0 AND char_length(id) < 128) +); + +CREATE UNLOGGED TABLE /* TEMPLATE: schema */river_client_queue ( + river_client_id text NOT NULL REFERENCES /* TEMPLATE: schema */river_client (id) ON DELETE CASCADE, + name text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + max_workers bigint NOT NULL DEFAULT 0, + metadata jsonb NOT NULL DEFAULT '{}', + num_jobs_completed bigint NOT NULL DEFAULT 0, + num_jobs_running bigint NOT NULL DEFAULT 0, + updated_at timestamptz NOT NULL, + PRIMARY KEY (river_client_id, name), + CONSTRAINT name_length CHECK (char_length(name) > 0 AND char_length(name) < 128), + CONSTRAINT num_jobs_completed_zero_or_positive CHECK (num_jobs_completed >= 0), + CONSTRAINT num_jobs_running_zero_or_positive CHECK (num_jobs_running >= 0) +); + +-- +-- Revert addition of `DEFAULT 25` to `river_job.max_attempts`. +-- + +ALTER TABLE /* TEMPLATE: schema */river_job + ALTER COLUMN max_attempts DROP DEFAULT; + +-- +-- Changes `river_queue.updated_at` to revert the default of `CURRENT_TIMESTAMP`. +-- + +ALTER TABLE /* TEMPLATE: schema */river_queue + ALTER COLUMN updated_at DROP DEFAULT; + +-- +-- SQLite JSONB conversion rollback. +-- +-- No-op. PostgreSQL already stores River JSON columns as jsonb. + +-- +-- Notification outbox rollback. +-- + +DROP TABLE /* TEMPLATE: schema */river_notification; diff --git a/js/migrate/migrations/postgres/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql b/js/migrate/migrations/postgres/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql new file mode 100644 index 000000000..39e3249c9 --- /dev/null +++ b/js/migrate/migrations/postgres/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql @@ -0,0 +1,44 @@ +-- +-- Notification outbox. +-- + +CREATE TABLE /* TEMPLATE: schema */river_notification ( + id bigserial PRIMARY KEY, + created_at timestamptz NOT NULL DEFAULT now(), + payload text NOT NULL, + topic text NOT NULL, + CONSTRAINT topic_length CHECK (length(topic) > 0 AND length(topic) < 128) +); + +CREATE INDEX river_notification_created_at_idx ON /* TEMPLATE: schema */river_notification (created_at); +CREATE INDEX river_notification_topic_id_idx ON /* TEMPLATE: schema */river_notification (topic, id); + +-- +-- SQLite JSONB conversion. +-- +-- No-op. PostgreSQL already stores River JSON columns as jsonb. + +-- +-- SQL cleanup. +-- + +-- +-- Drop unused tables `river_client` and `river_client_queue`. +-- + +DROP TABLE /* TEMPLATE: schema */river_client_queue; +DROP TABLE /* TEMPLATE: schema */river_client; + +-- +-- Adds `DEFAULT 25` to `river_job.max_attempts`. +-- + +ALTER TABLE /* TEMPLATE: schema */river_job + ALTER COLUMN max_attempts SET DEFAULT 25; + +-- +-- Changes `river_queue.updated_at` to have a default of `CURRENT_TIMESTAMP`. +-- + +ALTER TABLE /* TEMPLATE: schema */river_queue + ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP; diff --git a/js/migrate/migrations/postgres/main/008_job_id_autoincrement.down.sql b/js/migrate/migrations/postgres/main/008_job_id_autoincrement.down.sql new file mode 100644 index 000000000..695357bb8 --- /dev/null +++ b/js/migrate/migrations/postgres/main/008_job_id_autoincrement.down.sql @@ -0,0 +1,3 @@ +-- No-op. PostgreSQL sequences already prevent automatically generated job IDs +-- from being reused. +SELECT 1; diff --git a/js/migrate/migrations/postgres/main/008_job_id_autoincrement.up.sql b/js/migrate/migrations/postgres/main/008_job_id_autoincrement.up.sql new file mode 100644 index 000000000..695357bb8 --- /dev/null +++ b/js/migrate/migrations/postgres/main/008_job_id_autoincrement.up.sql @@ -0,0 +1,3 @@ +-- No-op. PostgreSQL sequences already prevent automatically generated job IDs +-- from being reused. +SELECT 1; diff --git a/js/migrate/migrations/sqlite/main/001_create_river_migration.down.sql b/js/migrate/migrations/sqlite/main/001_create_river_migration.down.sql new file mode 100644 index 000000000..8bfe82027 --- /dev/null +++ b/js/migrate/migrations/sqlite/main/001_create_river_migration.down.sql @@ -0,0 +1 @@ +DROP TABLE /* TEMPLATE: schema */river_migration; \ No newline at end of file diff --git a/js/migrate/migrations/sqlite/main/001_create_river_migration.up.sql b/js/migrate/migrations/sqlite/main/001_create_river_migration.up.sql new file mode 100644 index 000000000..bdaf09339 --- /dev/null +++ b/js/migrate/migrations/sqlite/main/001_create_river_migration.up.sql @@ -0,0 +1,8 @@ +CREATE TABLE /* TEMPLATE: schema */river_migration ( + id integer PRIMARY KEY, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + version integer NOT NULL, + CONSTRAINT version CHECK (version >= 1) +); + +CREATE UNIQUE INDEX /* TEMPLATE: schema */river_migration_version_idx ON river_migration (version); \ No newline at end of file diff --git a/js/migrate/migrations/sqlite/main/002_initial_schema.down.sql b/js/migrate/migrations/sqlite/main/002_initial_schema.down.sql new file mode 100644 index 000000000..cbdd56dea --- /dev/null +++ b/js/migrate/migrations/sqlite/main/002_initial_schema.down.sql @@ -0,0 +1,8 @@ +-- +-- Normally `river_job` and `river_job_notify()` are dropped here, but since +-- SQLite was added well after 002 came about, we push that to version 006 index. +-- + +DROP TABLE /* TEMPLATE: schema */river_job; + +DROP TABLE /* TEMPLATE: schema */river_leader; \ No newline at end of file diff --git a/js/migrate/migrations/sqlite/main/002_initial_schema.up.sql b/js/migrate/migrations/sqlite/main/002_initial_schema.up.sql new file mode 100644 index 000000000..043facf29 --- /dev/null +++ b/js/migrate/migrations/sqlite/main/002_initial_schema.up.sql @@ -0,0 +1,19 @@ +-- +-- Normally `river_job` and `river_job_notify()` are raised here, but since +-- SQLite was added well after 002 came about, we push that to version 006 index. +-- + +-- Dummy `river_job` table so that there's something to truncate in tests when +-- migrated to this version specifically. +CREATE TABLE /* TEMPLATE: schema */river_job ( + id integer PRIMARY KEY +); + +CREATE TABLE /* TEMPLATE: schema */river_leader ( + elected_at timestamp NOT NULL, + expires_at timestamp NOT NULL, + leader_id text NOT NULL, + name text PRIMARY KEY NOT NULL, + CONSTRAINT name_length CHECK (length(name) > 0 AND length(name) < 128), + CONSTRAINT leader_id_length CHECK (length(leader_id) > 0 AND length(leader_id) < 128) +); diff --git a/js/migrate/migrations/sqlite/main/003_river_job_tags_non_null.down.sql b/js/migrate/migrations/sqlite/main/003_river_job_tags_non_null.down.sql new file mode 100644 index 000000000..8d314cf06 --- /dev/null +++ b/js/migrate/migrations/sqlite/main/003_river_job_tags_non_null.down.sql @@ -0,0 +1,6 @@ +-- +-- Normally `river_job.tags` is set back to nullable here, but since SQLite was +-- added well after 003 came about, we push that to version 006 index. +-- + +SELECT 1; diff --git a/js/migrate/migrations/sqlite/main/003_river_job_tags_non_null.up.sql b/js/migrate/migrations/sqlite/main/003_river_job_tags_non_null.up.sql new file mode 100644 index 000000000..d4e1e2404 --- /dev/null +++ b/js/migrate/migrations/sqlite/main/003_river_job_tags_non_null.up.sql @@ -0,0 +1,6 @@ +-- +-- Normally `river_job.tags` is set to `NOT NULL` with a `DEFAULT` here, but since +-- SQLite was added well after 003 came about, we push that to version 006 index. +-- + +SELECT 1; diff --git a/js/migrate/migrations/sqlite/main/004_pending_and_more.down.sql b/js/migrate/migrations/sqlite/main/004_pending_and_more.down.sql new file mode 100644 index 000000000..c64554441 --- /dev/null +++ b/js/migrate/migrations/sqlite/main/004_pending_and_more.down.sql @@ -0,0 +1,26 @@ +-- +-- Normally, args and metadata both become `NOT NULL`, `pending` is added, and +-- the constraint `finalized_at` is changed, but because SQLite was added later, +-- we've just pushed all of this to an initial `river_job` creation in 006. +-- + +-- +-- Drop `river_queue`. +-- + +DROP TABLE /* TEMPLATE: schema */river_queue; + +-- +-- Reverse changes to `river_leader`. +-- + +DROP TABLE /* TEMPLATE: schema */river_leader; + +CREATE TABLE /* TEMPLATE: schema */river_leader ( + elected_at timestamp NOT NULL, + expires_at timestamp NOT NULL, + leader_id text NOT NULL, + name text PRIMARY KEY NOT NULL, + CONSTRAINT name_length CHECK (length(name) > 0 AND length(name) < 128), + CONSTRAINT leader_id_length CHECK (length(leader_id) > 0 AND length(leader_id) < 128) +); \ No newline at end of file diff --git a/js/migrate/migrations/sqlite/main/004_pending_and_more.up.sql b/js/migrate/migrations/sqlite/main/004_pending_and_more.up.sql new file mode 100644 index 000000000..254e1f7a3 --- /dev/null +++ b/js/migrate/migrations/sqlite/main/004_pending_and_more.up.sql @@ -0,0 +1,33 @@ +-- +-- Normally, args and metadata both become `NOT NULL`, `pending` is added, and +-- the constraint `finalized_at` is changed, but because SQLite was added later, +-- we've just pushed all of this to an initial `river_job` creation in 006. +-- + +-- +-- Create table `river_queue`. +-- + +CREATE TABLE /* TEMPLATE: schema */river_queue ( + name text PRIMARY KEY NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + metadata blob NOT NULL DEFAULT (json('{}')), + paused_at timestamp, + updated_at timestamp NOT NULL +); + +-- +-- Alter `river_leader` to add a default value of 'default` to `name`. SQLite +-- doesn't allow schema modifications, so this redefines the table entirely. +-- + +DROP TABLE /* TEMPLATE: schema */river_leader; + +CREATE TABLE /* TEMPLATE: schema */river_leader ( + elected_at timestamp NOT NULL, + expires_at timestamp NOT NULL, + leader_id text NOT NULL, + name text PRIMARY KEY NOT NULL DEFAULT 'default' CHECK (name = 'default'), + CONSTRAINT name_length CHECK (length(name) > 0 AND length(name) < 128), + CONSTRAINT leader_id_length CHECK (length(leader_id) > 0 AND length(leader_id) < 128) +); \ No newline at end of file diff --git a/js/migrate/migrations/sqlite/main/005_migration_unique_client.down.sql b/js/migrate/migrations/sqlite/main/005_migration_unique_client.down.sql new file mode 100644 index 000000000..d94787d3f --- /dev/null +++ b/js/migrate/migrations/sqlite/main/005_migration_unique_client.down.sql @@ -0,0 +1,37 @@ +-- +-- Revert to migration table based only on `(version)`. +-- +-- If any non-main migrations are present, 005 is considered irreversible. +-- + +ALTER TABLE /* TEMPLATE: schema */river_migration + RENAME TO river_migration_old; + +CREATE TABLE /* TEMPLATE: schema */river_migration ( + id integer PRIMARY KEY, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + version integer NOT NULL, + CONSTRAINT version CHECK (version >= 1) +); + +CREATE UNIQUE INDEX /* TEMPLATE: schema */river_migration_version_idx ON river_migration (version); + +INSERT INTO /* TEMPLATE: schema */river_migration + (created_at, version) +SELECT created_at, version +FROM /* TEMPLATE: schema */river_migration_old; + +DROP TABLE /* TEMPLATE: schema */river_migration_old; + +-- +-- Normally, `unique_key` and an index are added here, but because SQLite was +-- added later, we've just pushed all of this to an initial `river_job` creation +-- in 006. +-- + +-- +-- Drop `river_client` and derivative. +-- + +DROP TABLE /* TEMPLATE: schema */river_client_queue; +DROP TABLE /* TEMPLATE: schema */river_client; diff --git a/js/migrate/migrations/sqlite/main/005_migration_unique_client.up.sql b/js/migrate/migrations/sqlite/main/005_migration_unique_client.up.sql new file mode 100644 index 000000000..dc3273349 --- /dev/null +++ b/js/migrate/migrations/sqlite/main/005_migration_unique_client.up.sql @@ -0,0 +1,64 @@ +-- +-- Rebuild the migration table so it's based on `(line, version)`. +-- + +DROP INDEX /* TEMPLATE: schema */river_migration_version_idx; + +ALTER TABLE /* TEMPLATE: schema */river_migration + RENAME TO river_migration_old; + +CREATE TABLE /* TEMPLATE: schema */river_migration ( + line text NOT NULL, + version integer NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT line_length CHECK (length(line) > 0 AND length(line) < 128), + CONSTRAINT version_gte_1 CHECK (version >= 1), + PRIMARY KEY (line, version) +); + +INSERT INTO /* TEMPLATE: schema */river_migration + (created_at, line, version) +SELECT created_at, 'main', version +FROM /* TEMPLATE: schema */river_migration_old; + +DROP TABLE /* TEMPLATE: schema */river_migration_old; + +-- +-- Normally, `unique_key` and an index are added here, but because SQLite was +-- added later, we've just pushed all of this to an initial `river_job` creation +-- in 006. +-- + +-- +-- Create `river_client` and derivative. +-- +-- This feature hasn't quite yet been implemented, but we're taking advantage of +-- the migration to add the schema early so that we can add it later without an +-- additional migration. +-- + +CREATE TABLE /* TEMPLATE: schema */river_client ( + id text PRIMARY KEY NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + metadata blob NOT NULL DEFAULT (json('{}')), + paused_at timestamp, + updated_at timestamp NOT NULL, + CONSTRAINT name_length CHECK (length(id) > 0 AND length(id) < 128) +); + +-- Differs from `river_queue` in that it tracks the queue state for a particular +-- active client. +CREATE TABLE /* TEMPLATE: schema */river_client_queue ( + river_client_id text NOT NULL REFERENCES river_client (id) ON DELETE CASCADE, + name text NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + max_workers integer NOT NULL DEFAULT 0, + metadata blob NOT NULL DEFAULT (json('{}')), + num_jobs_completed integer NOT NULL DEFAULT 0, + num_jobs_running integer NOT NULL DEFAULT 0, + updated_at timestamp NOT NULL, + PRIMARY KEY (river_client_id, name), + CONSTRAINT name_length CHECK (length(name) > 0 AND length(name) < 128), + CONSTRAINT num_jobs_completed_zero_or_positive CHECK (num_jobs_completed >= 0), + CONSTRAINT num_jobs_running_zero_or_positive CHECK (num_jobs_running >= 0) +); \ No newline at end of file diff --git a/js/migrate/migrations/sqlite/main/006_bulk_unique.down.sql b/js/migrate/migrations/sqlite/main/006_bulk_unique.down.sql new file mode 100644 index 000000000..a8d273f84 --- /dev/null +++ b/js/migrate/migrations/sqlite/main/006_bulk_unique.down.sql @@ -0,0 +1,7 @@ +DROP TABLE /* TEMPLATE: schema */river_job; + +-- Dummy `river_job` table so that there's something to truncate in tests when +-- migrated to this version specifically. +CREATE TABLE /* TEMPLATE: schema */river_job ( + id integer PRIMARY KEY +); diff --git a/js/migrate/migrations/sqlite/main/006_bulk_unique.up.sql b/js/migrate/migrations/sqlite/main/006_bulk_unique.up.sql new file mode 100644 index 000000000..528a4680e --- /dev/null +++ b/js/migrate/migrations/sqlite/main/006_bulk_unique.up.sql @@ -0,0 +1,63 @@ +-- Only drops the trivial `river_job` we created in 002 which puts a placeholder +-- in place so that the right tables exist in the right versions. We don't +-- bother migrating any job data because it's not possible to have had any real +-- jobs by that point because this version (006) preexists the addition of SQLite. +DROP TABLE /* TEMPLATE: schema */river_job; + +CREATE TABLE /* TEMPLATE: schema */river_job ( + id integer PRIMARY KEY, -- SQLite aliases this to ROWID, which may reuse deleted IDs. + args blob NOT NULL DEFAULT '{}', + attempt integer NOT NULL DEFAULT 0, + attempted_at timestamp, + attempted_by blob, -- json + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + errors blob, -- json + finalized_at timestamp, + kind text NOT NULL, + max_attempts integer NOT NULL, + metadata blob NOT NULL DEFAULT (json('{}')), + priority integer NOT NULL DEFAULT 1, + queue text NOT NULL DEFAULT 'default', + state text NOT NULL DEFAULT 'available', + scheduled_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + tags blob NOT NULL DEFAULT (json('[]')), + unique_key blob, + unique_states integer, + CONSTRAINT finalized_or_finalized_at_null CHECK ( + (finalized_at IS NULL AND state NOT IN ('cancelled', 'completed', 'discarded')) OR + (finalized_at IS NOT NULL AND state IN ('cancelled', 'completed', 'discarded')) + ), + CONSTRAINT priority_in_range CHECK (priority >= 1 AND priority <= 4), + CONSTRAINT queue_length CHECK (length(queue) > 0 AND length(queue) < 128), + CONSTRAINT kind_length CHECK (length(kind) > 0 AND length(kind) < 128), + CONSTRAINT state_valid CHECK (state IN ('available', 'cancelled', 'completed', 'discarded', 'pending', 'retryable', 'running', 'scheduled')) +); + +-- All these indexes are normally brought up in version 002. +CREATE INDEX /* TEMPLATE: schema */river_job_kind ON river_job (kind); +CREATE INDEX /* TEMPLATE: schema */river_job_state_and_finalized_at_index ON river_job (state, finalized_at) WHERE finalized_at IS NOT NULL; +CREATE INDEX /* TEMPLATE: schema */river_job_prioritized_fetching_index ON river_job (state, queue, priority, scheduled_at, id); + +-- Not raised because SQLite doesn't support Gin indexes. These aren't used in +-- River anyway. +-- CREATE INDEX river_job_args_index ON /* TEMPLATE: schema */river_job USING GIN(args); +-- CREATE INDEX river_job_metadata_index ON /* TEMPLATE: schema */river_job USING GIN(metadata); + +-- SQLite doesn't support SQL functions, so where the bit extraction logic below +-- goes in the `river_job_state_in_bitmask` function in Postgres, here it's +-- baked right into the index. Use of helpers that don't exist in SQLite like +-- `get_bit` are also dropped by necessity. +CREATE UNIQUE INDEX /* TEMPLATE: schema */river_job_unique_idx ON river_job (unique_key) + WHERE unique_key IS NOT NULL + AND unique_states IS NOT NULL + AND CASE state + WHEN 'available' THEN unique_states & (1 << 0) + WHEN 'cancelled' THEN unique_states & (1 << 1) + WHEN 'completed' THEN unique_states & (1 << 2) + WHEN 'discarded' THEN unique_states & (1 << 3) + WHEN 'pending' THEN unique_states & (1 << 4) + WHEN 'retryable' THEN unique_states & (1 << 5) + WHEN 'running' THEN unique_states & (1 << 6) + WHEN 'scheduled' THEN unique_states & (1 << 7) + ELSE 0 + END >= 1; diff --git a/js/migrate/migrations/sqlite/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql b/js/migrate/migrations/sqlite/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql new file mode 100644 index 000000000..1e3bcffb0 --- /dev/null +++ b/js/migrate/migrations/sqlite/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql @@ -0,0 +1,255 @@ +-- +-- SQL cleanup rollback. +-- + +-- +-- Add back unused tables `river_client` and `river_client_queue`. +-- + +CREATE TABLE /* TEMPLATE: schema */river_client ( + id text PRIMARY KEY NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + metadata blob NOT NULL DEFAULT (jsonb('{}')), + paused_at timestamp, + updated_at timestamp NOT NULL, + CONSTRAINT name_length CHECK (length(id) > 0 AND length(id) < 128) +); + +CREATE TABLE /* TEMPLATE: schema */river_client_queue ( + river_client_id text NOT NULL REFERENCES river_client (id) ON DELETE CASCADE, + name text NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + max_workers integer NOT NULL DEFAULT 0, + metadata blob NOT NULL DEFAULT (jsonb('{}')), + num_jobs_completed integer NOT NULL DEFAULT 0, + num_jobs_running integer NOT NULL DEFAULT 0, + updated_at timestamp NOT NULL, + PRIMARY KEY (river_client_id, name), + CONSTRAINT name_length CHECK (length(name) > 0 AND length(name) < 128), + CONSTRAINT num_jobs_completed_zero_or_positive CHECK (num_jobs_completed >= 0), + CONSTRAINT num_jobs_running_zero_or_positive CHECK (num_jobs_running >= 0) +); + +-- +-- SQLite JSONB conversion rollback. +-- +-- Convert JSONB binary columns back to JSON text format and restore json() +-- defaults. The `river_job` rebuild also reverts the addition of `DEFAULT 25` +-- to `river_job.max_attempts`. +-- +-- SQLite doesn't allow `ALTER TABLE ADD COLUMN` with non-constant defaults like +-- `json('{}')`, so rebuild each affected table instead. +-- + +-- +-- river_job +-- + +DROP INDEX /* TEMPLATE: schema */river_job_kind; +DROP INDEX /* TEMPLATE: schema */river_job_state_and_finalized_at_index; +DROP INDEX /* TEMPLATE: schema */river_job_prioritized_fetching_index; +DROP INDEX /* TEMPLATE: schema */river_job_unique_idx; + +ALTER TABLE /* TEMPLATE: schema */river_job RENAME TO river_job_old; + +CREATE TABLE /* TEMPLATE: schema */river_job ( + id integer PRIMARY KEY, -- SQLite aliases this to ROWID, which may reuse deleted IDs. + args blob NOT NULL DEFAULT '{}', + attempt integer NOT NULL DEFAULT 0, + attempted_at timestamp, + attempted_by blob, -- json + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + errors blob, -- json + finalized_at timestamp, + kind text NOT NULL, + max_attempts integer NOT NULL, + metadata blob NOT NULL DEFAULT (json('{}')), + priority integer NOT NULL DEFAULT 1, + queue text NOT NULL DEFAULT 'default', + state text NOT NULL DEFAULT 'available', + scheduled_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + tags blob NOT NULL DEFAULT (json('[]')), + unique_key blob, + unique_states integer, + CONSTRAINT finalized_or_finalized_at_null CHECK ( + (finalized_at IS NULL AND state NOT IN ('cancelled', 'completed', 'discarded')) OR + (finalized_at IS NOT NULL AND state IN ('cancelled', 'completed', 'discarded')) + ), + CONSTRAINT priority_in_range CHECK (priority >= 1 AND priority <= 4), + CONSTRAINT queue_length CHECK (length(queue) > 0 AND length(queue) < 128), + CONSTRAINT kind_length CHECK (length(kind) > 0 AND length(kind) < 128), + CONSTRAINT state_valid CHECK (state IN ('available', 'cancelled', 'completed', 'discarded', 'pending', 'retryable', 'running', 'scheduled')) +); + +INSERT INTO /* TEMPLATE: schema */river_job ( + id, + args, + attempt, + attempted_at, + attempted_by, + created_at, + errors, + finalized_at, + kind, + max_attempts, + metadata, + priority, + queue, + state, + scheduled_at, + tags, + unique_key, + unique_states +) +SELECT + id, + json(args), + attempt, + attempted_at, + CASE WHEN attempted_by IS NULL THEN NULL ELSE json(attempted_by) END, + created_at, + CASE WHEN errors IS NULL THEN NULL ELSE json(errors) END, + finalized_at, + kind, + max_attempts, + json(metadata), + priority, + queue, + state, + scheduled_at, + json(tags), + unique_key, + unique_states +FROM /* TEMPLATE: schema */river_job_old; + +DROP TABLE /* TEMPLATE: schema */river_job_old; + +CREATE INDEX /* TEMPLATE: schema */river_job_kind ON river_job (kind); +CREATE INDEX /* TEMPLATE: schema */river_job_state_and_finalized_at_index ON river_job (state, finalized_at) WHERE finalized_at IS NOT NULL; +CREATE INDEX /* TEMPLATE: schema */river_job_prioritized_fetching_index ON river_job (state, queue, priority, scheduled_at, id); +CREATE UNIQUE INDEX /* TEMPLATE: schema */river_job_unique_idx ON river_job (unique_key) + WHERE unique_key IS NOT NULL + AND unique_states IS NOT NULL + AND CASE state + WHEN 'available' THEN unique_states & (1 << 0) + WHEN 'cancelled' THEN unique_states & (1 << 1) + WHEN 'completed' THEN unique_states & (1 << 2) + WHEN 'discarded' THEN unique_states & (1 << 3) + WHEN 'pending' THEN unique_states & (1 << 4) + WHEN 'retryable' THEN unique_states & (1 << 5) + WHEN 'running' THEN unique_states & (1 << 6) + WHEN 'scheduled' THEN unique_states & (1 << 7) + ELSE 0 + END >= 1; + +-- +-- river_queue +-- + +ALTER TABLE /* TEMPLATE: schema */river_queue RENAME TO river_queue_old; + +CREATE TABLE /* TEMPLATE: schema */river_queue ( + name text PRIMARY KEY NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + metadata blob NOT NULL DEFAULT (json('{}')), + paused_at timestamp, + updated_at timestamp NOT NULL +); + +INSERT INTO /* TEMPLATE: schema */river_queue ( + name, + created_at, + metadata, + paused_at, + updated_at +) +SELECT + name, + created_at, + json(metadata), + paused_at, + updated_at +FROM /* TEMPLATE: schema */river_queue_old; + +DROP TABLE /* TEMPLATE: schema */river_queue_old; + +-- +-- river_client +-- + +ALTER TABLE /* TEMPLATE: schema */river_client RENAME TO river_client_old; + +CREATE TABLE /* TEMPLATE: schema */river_client ( + id text PRIMARY KEY NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + metadata blob NOT NULL DEFAULT (json('{}')), + paused_at timestamp, + updated_at timestamp NOT NULL, + CONSTRAINT name_length CHECK (length(id) > 0 AND length(id) < 128) +); + +INSERT INTO /* TEMPLATE: schema */river_client ( + id, + created_at, + metadata, + paused_at, + updated_at +) +SELECT + id, + created_at, + json(metadata), + paused_at, + updated_at +FROM /* TEMPLATE: schema */river_client_old; + +-- +-- river_client_queue +-- + +ALTER TABLE /* TEMPLATE: schema */river_client_queue RENAME TO river_client_queue_old; + +CREATE TABLE /* TEMPLATE: schema */river_client_queue ( + river_client_id text NOT NULL REFERENCES river_client (id) ON DELETE CASCADE, + name text NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + max_workers integer NOT NULL DEFAULT 0, + metadata blob NOT NULL DEFAULT (json('{}')), + num_jobs_completed integer NOT NULL DEFAULT 0, + num_jobs_running integer NOT NULL DEFAULT 0, + updated_at timestamp NOT NULL, + PRIMARY KEY (river_client_id, name), + CONSTRAINT name_length CHECK (length(name) > 0 AND length(name) < 128), + CONSTRAINT num_jobs_completed_zero_or_positive CHECK (num_jobs_completed >= 0), + CONSTRAINT num_jobs_running_zero_or_positive CHECK (num_jobs_running >= 0) +); + +INSERT INTO /* TEMPLATE: schema */river_client_queue ( + river_client_id, + name, + created_at, + max_workers, + metadata, + num_jobs_completed, + num_jobs_running, + updated_at +) +SELECT + river_client_id, + name, + created_at, + max_workers, + json(metadata), + num_jobs_completed, + num_jobs_running, + updated_at +FROM /* TEMPLATE: schema */river_client_queue_old; + +DROP TABLE /* TEMPLATE: schema */river_client_queue_old; +DROP TABLE /* TEMPLATE: schema */river_client_old; + +-- +-- Notification outbox rollback. +-- + +DROP TABLE /* TEMPLATE: schema */river_notification; diff --git a/js/migrate/migrations/sqlite/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql b/js/migrate/migrations/sqlite/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql new file mode 100644 index 000000000..b1ca9479e --- /dev/null +++ b/js/migrate/migrations/sqlite/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql @@ -0,0 +1,261 @@ +-- +-- Notification outbox. +-- + +CREATE TABLE /* TEMPLATE: schema */river_notification ( + id integer PRIMARY KEY AUTOINCREMENT, + created_at timestamp NOT NULL DEFAULT (datetime('now', 'subsec')), + payload text NOT NULL, + topic text NOT NULL, + CONSTRAINT topic_length CHECK (length(topic) > 0 AND length(topic) < 128) +); + +CREATE INDEX /* TEMPLATE: schema */river_notification_created_at_idx ON river_notification (created_at); +CREATE INDEX /* TEMPLATE: schema */river_notification_topic_id_idx ON river_notification (topic, id); + +-- +-- SQLite JSONB conversion. +-- +-- Convert JSON text columns to JSONB binary format for more efficient storage +-- and processing, and update column defaults from json() to jsonb(). +-- +-- SQLite doesn't allow `ALTER TABLE ADD COLUMN` with non-constant defaults like +-- `jsonb('{}')`, so rebuild each affected table instead. +-- + +-- +-- river_job +-- + +DROP INDEX /* TEMPLATE: schema */river_job_kind; +DROP INDEX /* TEMPLATE: schema */river_job_state_and_finalized_at_index; +DROP INDEX /* TEMPLATE: schema */river_job_prioritized_fetching_index; +DROP INDEX /* TEMPLATE: schema */river_job_unique_idx; + +ALTER TABLE /* TEMPLATE: schema */river_job RENAME TO river_job_old; + +CREATE TABLE /* TEMPLATE: schema */river_job ( + id integer PRIMARY KEY, -- SQLite aliases this to ROWID, which may reuse deleted IDs. + args blob NOT NULL DEFAULT (jsonb('{}')), + attempt integer NOT NULL DEFAULT 0, + attempted_at timestamp, + attempted_by blob, -- json + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + errors blob, -- json + finalized_at timestamp, + kind text NOT NULL, + max_attempts integer NOT NULL, + metadata blob NOT NULL DEFAULT (jsonb('{}')), + priority integer NOT NULL DEFAULT 1, + queue text NOT NULL DEFAULT 'default', + state text NOT NULL DEFAULT 'available', + scheduled_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + tags blob NOT NULL DEFAULT (jsonb('[]')), + unique_key blob, + unique_states integer, + CONSTRAINT finalized_or_finalized_at_null CHECK ( + (finalized_at IS NULL AND state NOT IN ('cancelled', 'completed', 'discarded')) OR + (finalized_at IS NOT NULL AND state IN ('cancelled', 'completed', 'discarded')) + ), + CONSTRAINT priority_in_range CHECK (priority >= 1 AND priority <= 4), + CONSTRAINT queue_length CHECK (length(queue) > 0 AND length(queue) < 128), + CONSTRAINT kind_length CHECK (length(kind) > 0 AND length(kind) < 128), + CONSTRAINT state_valid CHECK (state IN ('available', 'cancelled', 'completed', 'discarded', 'pending', 'retryable', 'running', 'scheduled')) +); + +INSERT INTO /* TEMPLATE: schema */river_job ( + id, + args, + attempt, + attempted_at, + attempted_by, + created_at, + errors, + finalized_at, + kind, + max_attempts, + metadata, + priority, + queue, + state, + scheduled_at, + tags, + unique_key, + unique_states +) +SELECT + id, + jsonb(args), + attempt, + attempted_at, + CASE WHEN attempted_by IS NULL THEN NULL ELSE jsonb(attempted_by) END, + created_at, + CASE WHEN errors IS NULL THEN NULL ELSE jsonb(errors) END, + finalized_at, + kind, + max_attempts, + jsonb(metadata), + priority, + queue, + state, + scheduled_at, + jsonb(tags), + unique_key, + unique_states +FROM /* TEMPLATE: schema */river_job_old; + +DROP TABLE /* TEMPLATE: schema */river_job_old; + +CREATE INDEX /* TEMPLATE: schema */river_job_kind ON river_job (kind); +CREATE INDEX /* TEMPLATE: schema */river_job_state_and_finalized_at_index ON river_job (state, finalized_at) WHERE finalized_at IS NOT NULL; +CREATE INDEX /* TEMPLATE: schema */river_job_prioritized_fetching_index ON river_job (state, queue, priority, scheduled_at, id); +CREATE UNIQUE INDEX /* TEMPLATE: schema */river_job_unique_idx ON river_job (unique_key) + WHERE unique_key IS NOT NULL + AND unique_states IS NOT NULL + AND CASE state + WHEN 'available' THEN unique_states & (1 << 0) + WHEN 'cancelled' THEN unique_states & (1 << 1) + WHEN 'completed' THEN unique_states & (1 << 2) + WHEN 'discarded' THEN unique_states & (1 << 3) + WHEN 'pending' THEN unique_states & (1 << 4) + WHEN 'retryable' THEN unique_states & (1 << 5) + WHEN 'running' THEN unique_states & (1 << 6) + WHEN 'scheduled' THEN unique_states & (1 << 7) + ELSE 0 + END >= 1; + +-- +-- river_queue +-- + +ALTER TABLE /* TEMPLATE: schema */river_queue RENAME TO river_queue_old; + +CREATE TABLE /* TEMPLATE: schema */river_queue ( + name text PRIMARY KEY NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + metadata blob NOT NULL DEFAULT (jsonb('{}')), + paused_at timestamp, + updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +INSERT INTO /* TEMPLATE: schema */river_queue ( + name, + created_at, + metadata, + paused_at, + updated_at +) +SELECT + name, + created_at, + jsonb(metadata), + paused_at, + updated_at +FROM /* TEMPLATE: schema */river_queue_old; + +DROP TABLE /* TEMPLATE: schema */river_queue_old; + +-- +-- river_client +-- + +ALTER TABLE /* TEMPLATE: schema */river_client RENAME TO river_client_old; + +CREATE TABLE /* TEMPLATE: schema */river_client ( + id text PRIMARY KEY NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + metadata blob NOT NULL DEFAULT (jsonb('{}')), + paused_at timestamp, + updated_at timestamp NOT NULL, + CONSTRAINT name_length CHECK (length(id) > 0 AND length(id) < 128) +); + +INSERT INTO /* TEMPLATE: schema */river_client ( + id, + created_at, + metadata, + paused_at, + updated_at +) +SELECT + id, + created_at, + jsonb(metadata), + paused_at, + updated_at +FROM /* TEMPLATE: schema */river_client_old; + +-- +-- river_client_queue +-- + +ALTER TABLE /* TEMPLATE: schema */river_client_queue RENAME TO river_client_queue_old; + +CREATE TABLE /* TEMPLATE: schema */river_client_queue ( + river_client_id text NOT NULL REFERENCES river_client (id) ON DELETE CASCADE, + name text NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + max_workers integer NOT NULL DEFAULT 0, + metadata blob NOT NULL DEFAULT (jsonb('{}')), + num_jobs_completed integer NOT NULL DEFAULT 0, + num_jobs_running integer NOT NULL DEFAULT 0, + updated_at timestamp NOT NULL, + PRIMARY KEY (river_client_id, name), + CONSTRAINT name_length CHECK (length(name) > 0 AND length(name) < 128), + CONSTRAINT num_jobs_completed_zero_or_positive CHECK (num_jobs_completed >= 0), + CONSTRAINT num_jobs_running_zero_or_positive CHECK (num_jobs_running >= 0) +); + +INSERT INTO /* TEMPLATE: schema */river_client_queue ( + river_client_id, + name, + created_at, + max_workers, + metadata, + num_jobs_completed, + num_jobs_running, + updated_at +) +SELECT + river_client_id, + name, + created_at, + max_workers, + jsonb(metadata), + num_jobs_completed, + num_jobs_running, + updated_at +FROM /* TEMPLATE: schema */river_client_queue_old; + +DROP TABLE /* TEMPLATE: schema */river_client_queue_old; +DROP TABLE /* TEMPLATE: schema */river_client_old; + +-- +-- SQL cleanup. +-- + +-- +-- Drop unused tables `river_client` and `river_client_queue`. +-- + +DROP TABLE /* TEMPLATE: schema */river_client_queue; +DROP TABLE /* TEMPLATE: schema */river_client; + +-- +-- Adds `DEFAULT 25` to `river_job.max_attempts`. +-- + +-- This may look odd in that we're adding a brand new column, but it's because +-- SQLite doesn't support anything beyond the most trivial DDL. + +ALTER TABLE /* TEMPLATE: schema */river_job + RENAME COLUMN max_attempts TO max_attempts_old; + +ALTER TABLE /* TEMPLATE: schema */river_job + ADD COLUMN max_attempts integer NOT NULL DEFAULT 25; + +UPDATE /* TEMPLATE: schema */river_job +SET max_attempts = max_attempts_old; + +ALTER TABLE /* TEMPLATE: schema */river_job + DROP COLUMN max_attempts_old; diff --git a/js/migrate/migrations/sqlite/main/008_job_id_autoincrement.down.sql b/js/migrate/migrations/sqlite/main/008_job_id_autoincrement.down.sql new file mode 100644 index 000000000..aad4f366b --- /dev/null +++ b/js/migrate/migrations/sqlite/main/008_job_id_autoincrement.down.sql @@ -0,0 +1,121 @@ +-- Rebuild river_job to restore SQLite's default ROWID allocation behavior. + +-- Rebuilding river_job would discard schema installed by River Pro. Check +-- schema objects instead of migration records to also catch manually applied +-- Pro migrations and the legacy workflow migration line. +CREATE TEMP TABLE river_job_pro_schema_guard ( + id integer NOT NULL +); + +CREATE TEMP TRIGGER river_job_pro_schema_guard_enforce + BEFORE INSERT ON river_job_pro_schema_guard + WHEN EXISTS ( + SELECT 1 + FROM /* TEMPLATE: schema */sqlite_master + WHERE name IN ('river_job_sequence', 'river_job_workflow_scheduling', 'river_workflow') + ) +BEGIN + SELECT RAISE(ABORT, 'River SQLite migration 008 cannot run while River Pro schema is installed'); +END; + +INSERT INTO river_job_pro_schema_guard (id) VALUES (1); + +DROP TRIGGER river_job_pro_schema_guard_enforce; +DROP TABLE river_job_pro_schema_guard; + +DROP INDEX /* TEMPLATE: schema */river_job_kind; +DROP INDEX /* TEMPLATE: schema */river_job_state_and_finalized_at_index; +DROP INDEX /* TEMPLATE: schema */river_job_prioritized_fetching_index; +DROP INDEX /* TEMPLATE: schema */river_job_unique_idx; + +ALTER TABLE /* TEMPLATE: schema */river_job RENAME TO river_job_old; + +CREATE TABLE /* TEMPLATE: schema */river_job ( + id integer PRIMARY KEY, + args blob NOT NULL DEFAULT (jsonb('{}')), + attempt integer NOT NULL DEFAULT 0, + attempted_at timestamp, + attempted_by blob, -- json + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + errors blob, -- json + finalized_at timestamp, + kind text NOT NULL, + max_attempts integer NOT NULL DEFAULT 25, + metadata blob NOT NULL DEFAULT (jsonb('{}')), + priority integer NOT NULL DEFAULT 1, + queue text NOT NULL DEFAULT 'default', + state text NOT NULL DEFAULT 'available', + scheduled_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + tags blob NOT NULL DEFAULT (jsonb('[]')), + unique_key blob, + unique_states integer, + CONSTRAINT finalized_or_finalized_at_null CHECK ( + (finalized_at IS NULL AND state NOT IN ('cancelled', 'completed', 'discarded')) OR + (finalized_at IS NOT NULL AND state IN ('cancelled', 'completed', 'discarded')) + ), + CONSTRAINT priority_in_range CHECK (priority >= 1 AND priority <= 4), + CONSTRAINT queue_length CHECK (length(queue) > 0 AND length(queue) < 128), + CONSTRAINT kind_length CHECK (length(kind) > 0 AND length(kind) < 128), + CONSTRAINT state_valid CHECK (state IN ('available', 'cancelled', 'completed', 'discarded', 'pending', 'retryable', 'running', 'scheduled')) +); + +INSERT INTO /* TEMPLATE: schema */river_job ( + id, + args, + attempt, + attempted_at, + attempted_by, + created_at, + errors, + finalized_at, + kind, + max_attempts, + metadata, + priority, + queue, + state, + scheduled_at, + tags, + unique_key, + unique_states +) +SELECT + id, + args, + attempt, + attempted_at, + attempted_by, + created_at, + errors, + finalized_at, + kind, + max_attempts, + metadata, + priority, + queue, + state, + scheduled_at, + tags, + unique_key, + unique_states +FROM /* TEMPLATE: schema */river_job_old; + +DROP TABLE /* TEMPLATE: schema */river_job_old; + +CREATE INDEX /* TEMPLATE: schema */river_job_kind ON river_job (kind); +CREATE INDEX /* TEMPLATE: schema */river_job_state_and_finalized_at_index ON river_job (state, finalized_at) WHERE finalized_at IS NOT NULL; +CREATE INDEX /* TEMPLATE: schema */river_job_prioritized_fetching_index ON river_job (state, queue, priority, scheduled_at, id); +CREATE UNIQUE INDEX /* TEMPLATE: schema */river_job_unique_idx ON river_job (unique_key) + WHERE unique_key IS NOT NULL + AND unique_states IS NOT NULL + AND CASE state + WHEN 'available' THEN unique_states & (1 << 0) + WHEN 'cancelled' THEN unique_states & (1 << 1) + WHEN 'completed' THEN unique_states & (1 << 2) + WHEN 'discarded' THEN unique_states & (1 << 3) + WHEN 'pending' THEN unique_states & (1 << 4) + WHEN 'retryable' THEN unique_states & (1 << 5) + WHEN 'running' THEN unique_states & (1 << 6) + WHEN 'scheduled' THEN unique_states & (1 << 7) + ELSE 0 + END >= 1; diff --git a/js/migrate/migrations/sqlite/main/008_job_id_autoincrement.up.sql b/js/migrate/migrations/sqlite/main/008_job_id_autoincrement.up.sql new file mode 100644 index 000000000..c7de15deb --- /dev/null +++ b/js/migrate/migrations/sqlite/main/008_job_id_autoincrement.up.sql @@ -0,0 +1,123 @@ +-- Rebuild river_job so automatically generated IDs are never reused after the +-- job holding the largest ID is deleted. Unlike PostgreSQL sequences, SQLite's +-- default ROWID allocator may otherwise reuse that deleted ID. + +-- Rebuilding river_job would discard schema installed by River Pro. Check +-- schema objects instead of migration records to also catch manually applied +-- Pro migrations and the legacy workflow migration line. +CREATE TEMP TABLE river_job_pro_schema_guard ( + id integer NOT NULL +); + +CREATE TEMP TRIGGER river_job_pro_schema_guard_enforce + BEFORE INSERT ON river_job_pro_schema_guard + WHEN EXISTS ( + SELECT 1 + FROM /* TEMPLATE: schema */sqlite_master + WHERE name IN ('river_job_sequence', 'river_job_workflow_scheduling', 'river_workflow') + ) +BEGIN + SELECT RAISE(ABORT, 'River SQLite migration 008 cannot run while River Pro schema is installed'); +END; + +INSERT INTO river_job_pro_schema_guard (id) VALUES (1); + +DROP TRIGGER river_job_pro_schema_guard_enforce; +DROP TABLE river_job_pro_schema_guard; + +DROP INDEX /* TEMPLATE: schema */river_job_kind; +DROP INDEX /* TEMPLATE: schema */river_job_state_and_finalized_at_index; +DROP INDEX /* TEMPLATE: schema */river_job_prioritized_fetching_index; +DROP INDEX /* TEMPLATE: schema */river_job_unique_idx; + +ALTER TABLE /* TEMPLATE: schema */river_job RENAME TO river_job_old; + +CREATE TABLE /* TEMPLATE: schema */river_job ( + id integer PRIMARY KEY AUTOINCREMENT, + args blob NOT NULL DEFAULT (jsonb('{}')), + attempt integer NOT NULL DEFAULT 0, + attempted_at timestamp, + attempted_by blob, -- json + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + errors blob, -- json + finalized_at timestamp, + kind text NOT NULL, + max_attempts integer NOT NULL DEFAULT 25, + metadata blob NOT NULL DEFAULT (jsonb('{}')), + priority integer NOT NULL DEFAULT 1, + queue text NOT NULL DEFAULT 'default', + state text NOT NULL DEFAULT 'available', + scheduled_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + tags blob NOT NULL DEFAULT (jsonb('[]')), + unique_key blob, + unique_states integer, + CONSTRAINT finalized_or_finalized_at_null CHECK ( + (finalized_at IS NULL AND state NOT IN ('cancelled', 'completed', 'discarded')) OR + (finalized_at IS NOT NULL AND state IN ('cancelled', 'completed', 'discarded')) + ), + CONSTRAINT priority_in_range CHECK (priority >= 1 AND priority <= 4), + CONSTRAINT queue_length CHECK (length(queue) > 0 AND length(queue) < 128), + CONSTRAINT kind_length CHECK (length(kind) > 0 AND length(kind) < 128), + CONSTRAINT state_valid CHECK (state IN ('available', 'cancelled', 'completed', 'discarded', 'pending', 'retryable', 'running', 'scheduled')) +); + +INSERT INTO /* TEMPLATE: schema */river_job ( + id, + args, + attempt, + attempted_at, + attempted_by, + created_at, + errors, + finalized_at, + kind, + max_attempts, + metadata, + priority, + queue, + state, + scheduled_at, + tags, + unique_key, + unique_states +) +SELECT + id, + args, + attempt, + attempted_at, + attempted_by, + created_at, + errors, + finalized_at, + kind, + max_attempts, + metadata, + priority, + queue, + state, + scheduled_at, + tags, + unique_key, + unique_states +FROM /* TEMPLATE: schema */river_job_old; + +DROP TABLE /* TEMPLATE: schema */river_job_old; + +CREATE INDEX /* TEMPLATE: schema */river_job_kind ON river_job (kind); +CREATE INDEX /* TEMPLATE: schema */river_job_state_and_finalized_at_index ON river_job (state, finalized_at) WHERE finalized_at IS NOT NULL; +CREATE INDEX /* TEMPLATE: schema */river_job_prioritized_fetching_index ON river_job (state, queue, priority, scheduled_at, id); +CREATE UNIQUE INDEX /* TEMPLATE: schema */river_job_unique_idx ON river_job (unique_key) + WHERE unique_key IS NOT NULL + AND unique_states IS NOT NULL + AND CASE state + WHEN 'available' THEN unique_states & (1 << 0) + WHEN 'cancelled' THEN unique_states & (1 << 1) + WHEN 'completed' THEN unique_states & (1 << 2) + WHEN 'discarded' THEN unique_states & (1 << 3) + WHEN 'pending' THEN unique_states & (1 << 4) + WHEN 'retryable' THEN unique_states & (1 << 5) + WHEN 'running' THEN unique_states & (1 << 6) + WHEN 'scheduled' THEN unique_states & (1 << 7) + ELSE 0 + END >= 1; diff --git a/js/migrate/package.json b/js/migrate/package.json new file mode 100644 index 000000000..deb56f684 --- /dev/null +++ b/js/migrate/package.json @@ -0,0 +1,69 @@ +{ + "name": "@riverqueue/migrate", + "version": "0.50.0-alpha.1", + "description": "Canonical River database migrations for JavaScript and TypeScript.", + "type": "module", + "sideEffects": false, + "engines": { + "node": ">=26" + }, + "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", + "migrations", + "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", + "test": "vitest run --passWithNoTests" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/riverqueue/river.git", + "directory": "js/migrate" + }, + "contributors": [ + "Brandur Leach", + "Blake Gentry" + ], + "license": "LGPL-3.0-or-later", + "publishConfig": { + "access": "public", + "provenance": true + }, + "peerDependencies": { + "@types/node": ">=26", + "riverqueue": "workspace:0.50.0-alpha.1" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + }, + "devDependencies": { + "@riverqueue/driver-pg": "workspace:0.50.0-alpha.1", + "@types/node": "^26.1.1", + "pg": "^8.22.0", + "riverqueue": "workspace:0.50.0-alpha.1" + }, + "keywords": [ + "river", + "job-queue", + "migrations", + "postgresql", + "sqlite" + ] +} diff --git a/js/migrate/src/bundle.ts b/js/migrate/src/bundle.ts new file mode 100644 index 000000000..3e79ed0ad --- /dev/null +++ b/js/migrate/src/bundle.ts @@ -0,0 +1,118 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; + +import { MigrationError } from "riverqueue"; + +/** Database backend that a migration bundle targets. */ +export type MigrationBackend = "postgres" | "sqlite"; + +/** One River migration version with SQL for both directions. */ +export interface Migration { + readonly downSql: string; + /** Short name derived from the migration file name, such as `bulk_unique`. */ + readonly name: string; + readonly upSql: string; + /** Positive version number. A line starts at 1 and increases by 1. */ + readonly version: number; +} + +interface ManifestEntry { + file: string; + sha256: string; +} + +interface MigrationManifest { + backends: Record; + format: number; +} + +const MIGRATION_FILE_RE = + /^(?\d{3})_(?.+)\.(?up|down)\.sql$/; + +/** + * Load River's bundled main migration line for a backend, ordered by version. + * + * The SQL files ship with this package and are verified against recorded + * checksums on every load. PostgreSQL SQL contains a schema placeholder that + * a migrator fills in, so run migrations through {@link createMigrator} + * instead of executing this SQL directly. + * + * @throws {@link MigrationError} if the bundled files are missing or altered. + */ +export function loadMigrations( + backend: MigrationBackend +): readonly Migration[] { + if (!["postgres", "sqlite"].includes(backend)) { + throw new MigrationError( + `unsupported migration backend: ${JSON.stringify(backend)}`, + { backend, operation: "load" } + ); + } + const fail = (message: string, cause?: unknown): never => { + throw new MigrationError(message, { + backend, + operation: "load", + ...(cause === undefined ? {} : { cause }), + }); + }; + const root = new URL("../migrations/", import.meta.url); + const read = (path: string) => { + try { + return readFileSync(new URL(path, root)); + } catch (error: unknown) { + return fail(`failed to read bundled migration file ${path}`, error); + } + }; + + const manifest = JSON.parse( + read("manifest.json").toString("utf8") + ) as MigrationManifest; + if (manifest.format !== 1) { + fail(`unsupported River migration manifest format: ${manifest.format}`); + } + + const partial = new Map< + number, + { downSql?: string; name: string; upSql?: string } + >(); + for (const { file, sha256 } of manifest.backends[backend]) { + const groups = MIGRATION_FILE_RE.exec(file)?.groups; + const direction = groups?.direction; + const name = groups?.name; + const versionText = groups?.version; + if ( + (direction !== "down" && direction !== "up") || + name === undefined || + versionText === undefined + ) { + return fail(`invalid bundled migration file name: ${file}`); + } + + const version = Number.parseInt(versionText, 10); + const entry = partial.get(version) ?? { name }; + if (entry.name !== name) { + fail(`migration ${version} has mismatched up and down names`); + } + const contents = read(`${backend}/main/${file}`); + const actualHash = createHash("sha256").update(contents).digest("hex"); + if (actualHash !== sha256) { + fail(`bundled migration checksum mismatch for ${backend}/${file}`); + } + entry[`${direction}Sql`] = contents.toString("utf8"); + partial.set(version, entry); + } + + return [...partial.entries()] + .sort(([left], [right]) => left - right) + .map(([version, migration]) => { + if (migration.downSql === undefined || migration.upSql === undefined) { + return fail(`migration ${version} is missing an up or down direction`); + } + return Object.freeze({ + downSql: migration.downSql, + name: migration.name, + upSql: migration.upSql, + version, + }); + }); +} diff --git a/js/migrate/src/index.test.ts b/js/migrate/src/index.test.ts new file mode 100644 index 000000000..3680b9209 --- /dev/null +++ b/js/migrate/src/index.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { loadMigrations } from "./index.js"; + +describe("loadMigrations", () => { + it.each(["postgres", "sqlite"] as const)( + "loads the complete %s main line", + (backend) => { + const migrations = loadMigrations(backend); + + expect(migrations.map(({ version }) => version)).toEqual([ + 1, 2, 3, 4, 5, 6, 7, 8, + ]); + for (const migration of migrations) { + expect(migration.name).not.toBe(""); + expect(migration.upSql.trim()).not.toBe(""); + expect(migration.downSql.trim()).not.toBe(""); + } + } + ); +}); diff --git a/js/migrate/src/index.ts b/js/migrate/src/index.ts new file mode 100644 index 000000000..ed3b39d8b --- /dev/null +++ b/js/migrate/src/index.ts @@ -0,0 +1,32 @@ +/** + * River's database migrations for PostgreSQL and SQLite, and a runner that + * applies them explicitly during deployment. + * + * @packageDocumentation + */ +export { loadMigrations } from "./bundle.js"; +export type { Migration, MigrationBackend } from "./bundle.js"; + +export { MigrationError } from "riverqueue"; +export { createMigrator, MIGRATION_LINE_MAIN } from "./migrator.js"; +export type { + MigrateOptions, + MigrateResult, + MigrateVersion, + MigrationDirection, + MigrationTarget, + Migrator, + MigratorOptions, + MigratorSource, + PgClientMigrationTarget, + PgPoolMigrationTarget, + SqliteMigrationTarget, + ValidateOptions, + ValidateResult, +} from "./migrator.js"; +export type { + PgMigrationClient, + PgMigrationPool, + PgMigrationPoolClient, + PgMigrationQueryResult, +} from "./postgres.js"; diff --git a/js/migrate/src/migrator.integration.test.ts b/js/migrate/src/migrator.integration.test.ts new file mode 100644 index 000000000..2745c1023 --- /dev/null +++ b/js/migrate/src/migrator.integration.test.ts @@ -0,0 +1,376 @@ +import { randomUUID } from "node:crypto"; + +import { PgDriver } from "@riverqueue/driver-pg"; +import pg from "pg"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { createMigrator, type Migration } from "./index.js"; + +const TEST_DATABASE_URL = + process.env.TEST_DATABASE_URL ?? + "postgres://localhost:5432/river_test?sslmode=disable"; + +const ALL_VERSIONS = [1, 2, 3, 4, 5, 6, 7, 8]; + +function versions(result: { + versions: readonly { version: number }[]; +}): number[] { + return result.versions.map(({ version }) => version); +} + +function quote(identifier: string): string { + return `"${identifier.replaceAll('"', '""')}"`; +} + +describe("PostgreSQL migrator", () => { + let pool: pg.Pool; + let schema: string; + let schemas: string[]; + + async function createSchema(name: string): Promise { + schemas.push(name); + await pool.query(`CREATE SCHEMA ${quote(name)}`); + return name; + } + + beforeEach(async () => { + pool = new pg.Pool({ connectionString: TEST_DATABASE_URL }); + schemas = []; + schema = await createSchema( + `river_migrate_${randomUUID().replaceAll("-", "")}` + ); + }); + + afterEach(async () => { + for (const name of schemas) { + await pool.query(`DROP SCHEMA IF EXISTS ${quote(name)} CASCADE`); + } + await pool.end(); + }); + + it("migrates up, down by steps and targets, and back to 0", async () => { + // Mixed case, spaces, and a quote exercise identifier quoting. + const schema = await createSchema( + `River "Migrate" ${randomUUID().slice(0, 8)}` + ); + const migrator = createMigrator({ pool, schema }); + + const dryRun = await migrator.migrateUp({ dryRun: true }); + expect(versions(dryRun)).toEqual(ALL_VERSIONS); + await expect(migrator.existingVersions()).resolves.toEqual([]); + + expect(versions(await migrator.migrateUp({ maxSteps: 3 }))).toEqual([ + 1, 2, 3, + ]); + expect(versions(await migrator.migrateUp())).toEqual([4, 5, 6, 7, 8]); + await expect(migrator.validate()).resolves.toEqual({ + messages: [], + ok: true, + }); + const tables = await pool.query<{ table_name: string }>( + "SELECT table_name FROM information_schema.tables " + + "WHERE table_schema = $1 AND table_name = 'river_job'", + [schema] + ); + expect(tables.rows).toEqual([{ table_name: "river_job" }]); + + expect(versions(await migrator.migrateDown())).toEqual([8]); + expect(versions(await migrator.migrateDown({ targetVersion: 4 }))).toEqual([ + 7, 6, 5, + ]); + await expect(migrator.existingVersions()).resolves.toEqual([1, 2, 3, 4]); + expect(versions(await migrator.migrateDown({ targetVersion: 0 }))).toEqual([ + 4, 3, 2, 1, + ]); + await expect(migrator.existingVersions()).resolves.toEqual([]); + const remaining = await pool.query( + "SELECT 1 FROM information_schema.tables WHERE table_schema = $1", + [schema] + ); + expect(remaining.rows).toEqual([]); + }); + + it("preserves existing job and queue rows across a down-up migration", async () => { + const migrator = createMigrator({ pool, schema }); + await migrator.migrateUp(); + const inserted = await pool.query<{ id: string }>( + `INSERT INTO ${quote(schema)}.river_job ` + + "(args, kind, max_attempts, metadata, queue) " + + "VALUES ($1, $2, $3, $4, $5) RETURNING id::text AS id", + [ + { account: 42 }, + "migration_existing_job", + 10, + { source: "old" }, + "saved", + ] + ); + await pool.query( + `INSERT INTO ${quote(schema)}.river_queue (name, metadata) VALUES ($1, $2)`, + ["saved", { source: "old" }] + ); + + expect(versions(await migrator.migrateDown())).toEqual([8]); + expect(versions(await migrator.migrateUp())).toEqual([8]); + await expect(migrator.validate()).resolves.toEqual({ + messages: [], + ok: true, + }); + + const jobs = await pool.query<{ + args: { account: number }; + id: string; + metadata: { source: string }; + queue: string; + }>( + `SELECT id::text AS id, args, metadata, queue FROM ${quote(schema)}.river_job` + ); + const queues = await pool.query<{ + metadata: { source: string }; + name: string; + }>(`SELECT name, metadata FROM ${quote(schema)}.river_queue`); + expect(jobs.rows).toEqual([ + { + args: { account: 42 }, + id: inserted.rows[0]?.id, + metadata: { source: "old" }, + queue: "saved", + }, + ]); + expect(queues.rows).toEqual([ + { metadata: { source: "old" }, name: "saved" }, + ]); + }); + + it("migrates the database and schema of a PgDriver", async () => { + const migrator = createMigrator(new PgDriver(pool, { schema })); + + expect(versions(await migrator.migrateUp())).toEqual(ALL_VERSIONS); + await expect( + createMigrator({ pool, schema }).existingVersions() + ).resolves.toEqual(ALL_VERSIONS); + }); + + it("migrates the client and schema of a PgDriver of one client", async () => { + const client = new pg.Client({ connectionString: TEST_DATABASE_URL }); + await client.connect(); + try { + const migrator = createMigrator(new PgDriver(client, { schema })); + + expect(versions(await migrator.migrateUp())).toEqual(ALL_VERSIONS); + await expect( + createMigrator({ pool, schema }).existingVersions() + ).resolves.toEqual(ALL_VERSIONS); + } finally { + await client.end(); + } + }); + + it("migrates through a dedicated client", async () => { + const client = new pg.Client({ connectionString: TEST_DATABASE_URL }); + await client.connect(); + try { + const migrator = createMigrator({ client, schema }); + + expect(versions(await migrator.migrateUp())).toEqual(ALL_VERSIONS); + expect( + versions(await migrator.migrateDown({ targetVersion: 0 })) + ).toEqual([...ALL_VERSIONS].reverse()); + } finally { + await client.end(); + } + }); + + it("ignores applied versions newer than this package", async () => { + const migrator = createMigrator({ pool, schema }); + await migrator.migrateUp(); + await pool.query( + `INSERT INTO ${quote(schema)}.river_migration (line, version) VALUES ('main', 9)` + ); + + await expect(migrator.existingVersions()).resolves.toEqual([ + ...ALL_VERSIONS, + 9, + ]); + expect(versions(await migrator.migrateUp())).toEqual([]); + await expect(migrator.validate()).resolves.toMatchObject({ ok: true }); + expect(versions(await migrator.migrateDown())).toEqual([8]); + }); + + it("migrates an additional line and rolls back a failed version", async () => { + const extraMigrations: readonly Migration[] = [ + { + downSql: "DROP TABLE /* TEMPLATE: schema */extra_widget;", + name: "create_widget", + upSql: "CREATE TABLE /* TEMPLATE: schema */extra_widget (id bigint);", + version: 1, + }, + { + downSql: "SELECT 1;", + name: "broken", + upSql: + "ALTER TABLE /* TEMPLATE: schema */extra_widget ADD COLUMN name text; " + + "SELECT 1 / 0;", + version: 2, + }, + ]; + const main = createMigrator({ pool, schema }); + const extra = createMigrator( + { pool, schema }, + { line: "extra", migrations: extraMigrations } + ); + + await expect(extra.migrateUp()).rejects.toThrow( + 'cannot migrate line "extra" until the main line is migrated' + ); + await main.migrateUp(); + const failure = await extra.migrateUp().catch((error: unknown) => error); + + expect(failure).toMatchObject({ + backend: "postgres", + message: 'failed to apply up migration 2 (broken) on line "extra"', + operation: "apply", + }); + await expect(extra.existingVersions()).resolves.toEqual([1]); + const columns = await pool.query( + "SELECT column_name FROM information_schema.columns " + + "WHERE table_schema = $1 AND table_name = 'extra_widget'", + [schema] + ); + expect(columns.rows).toEqual([{ column_name: "id" }]); + await expect(main.migrateDown({ targetVersion: 4 })).rejects.toThrow( + "failed to apply down migration 5" + ); + expect(versions(await extra.migrateDown({ targetVersion: 0 }))).toEqual([ + 1, + ]); + }); + + it("records an additional line's version 1 until it is reverted", async () => { + const extraMigrations: readonly Migration[] = [ + { + downSql: "DROP TABLE /* TEMPLATE: schema */extra_widget;", + name: "create_widget", + upSql: "CREATE TABLE /* TEMPLATE: schema */extra_widget (id bigint);", + 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, + }, + ]; + const main = createMigrator({ pool, schema }); + await main.migrateUp(); + const extra = createMigrator( + { pool, schema }, + { line: "extra", migrations: extraMigrations } + ); + const recorded = async (): Promise => + ( + await pool.query<{ version: number | string }>( + `SELECT version FROM ${quote(schema)}.river_migration ` + + "WHERE line = 'extra' ORDER BY version" + ) + ).rows.map(({ version }) => Number(version)); + const hasWidgetTable = async (): Promise => + ( + await pool.query( + "SELECT 1 FROM information_schema.tables " + + "WHERE table_schema = $1 AND table_name = 'extra_widget'", + [schema] + ) + ).rows.length > 0; + + await extra.migrateUp(); + // Targeting version 1 stops before reverting it, so its row stays. + expect(versions(await extra.migrateDown({ targetVersion: 1 }))).toEqual([ + 2, + ]); + await expect(recorded()).resolves.toEqual([1]); + await expect(hasWidgetTable()).resolves.toBe(true); + + // Stepping down from version 1 applies its down migration and removes + // its row, unlike main version 1, whose down migration drops the table. + expect(versions(await extra.migrateDown())).toEqual([1]); + await expect(recorded()).resolves.toEqual([]); + await expect(hasWidgetTable()).resolves.toBe(false); + + await extra.migrateUp(); + expect(versions(await extra.migrateDown({ targetVersion: 0 }))).toEqual([ + 2, 1, + ]); + await expect(recorded()).resolves.toEqual([]); + await expect(hasWidgetTable()).resolves.toBe(false); + await expect(main.existingVersions()).resolves.toEqual(ALL_VERSIONS); + }); + + it("serializes concurrent migrators", async () => { + const first = createMigrator({ pool, schema }); + const second = createMigrator({ pool, schema }); + + const up = await Promise.all([first.migrateUp(), second.migrateUp()]); + expect(up.flatMap(versions).sort((a, b) => a - b)).toEqual(ALL_VERSIONS); + + const down = await Promise.all([ + first.migrateDown({ targetVersion: 0 }), + second.migrateDown({ targetVersion: 0 }), + ]); + expect(down.flatMap(versions).sort((a, b) => b - a)).toEqual( + [...ALL_VERSIONS].reverse() + ); + await expect(first.existingVersions()).resolves.toEqual([]); + }); + + it("locks by resolved schema, not by how the schema is spelled", async () => { + const searchPathPool = new pg.Pool({ + connectionString: TEST_DATABASE_URL, + options: `-c search_path=${schema}`, + }); + const holder = await pool.connect(); + try { + // Hold the lock that a migrator with an explicit schema would take. + await holder.query("BEGIN"); + await holder.query( + "SELECT pg_advisory_xact_lock(hashtext(current_database()::text), " + + "hashtext('river_migration:' || $1::text))", + [schema] + ); + + // This migrator names no schema, so it resolves it from search_path. + const migrating = createMigrator({ pool: searchPathPool }).migrateUp({ + maxSteps: 1, + }); + await waitForAdvisoryLockWaiter(pool); + await expect( + createMigrator({ pool, schema }).existingVersions() + ).resolves.toEqual([]); + + await holder.query("COMMIT"); + expect(versions(await migrating)).toEqual([1]); + await expect( + createMigrator({ pool, schema }).existingVersions() + ).resolves.toEqual([1]); + } finally { + await holder.query("ROLLBACK").catch(() => undefined); + holder.release(); + await searchPathPool.end(); + } + }); +}); + +async function waitForAdvisoryLockWaiter(pool: pg.Pool): Promise { + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + const result = await pool.query( + "SELECT 1 FROM pg_locks WHERE locktype = 'advisory' AND NOT granted " + + "AND database = (SELECT oid FROM pg_database WHERE datname = current_database())" + ); + if (result.rows.length > 0) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error("migrator never waited for the advisory lock"); +} diff --git a/js/migrate/src/migrator.test.ts b/js/migrate/src/migrator.test.ts new file mode 100644 index 000000000..b6da00082 --- /dev/null +++ b/js/migrate/src/migrator.test.ts @@ -0,0 +1,542 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; + +import { MigrationError, type ClientDriver } from "riverqueue"; +import { + registerDriver, + type DriverMigrationTarget, +} from "riverqueue/unstable-driver"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + createMigrator, + loadMigrations, + type Migration, + type PgMigrationClient, + type PgMigrationPool, +} from "./index.js"; + +const EXTRA_MIGRATIONS: readonly Migration[] = [ + { + downSql: "DROP TABLE extra_widget;", + name: "create_widget", + upSql: "CREATE TABLE extra_widget (id INTEGER PRIMARY KEY);", + version: 1, + }, + { + downSql: "ALTER TABLE extra_widget DROP COLUMN name;", + name: "add_widget_name", + upSql: "ALTER TABLE extra_widget ADD COLUMN name TEXT;", + version: 2, + }, +]; + +function versions(result: { + versions: readonly { version: number }[]; +}): number[] { + return result.versions.map(({ version }) => version); +} + +/** A registered insert-only driver that migrates `migration`, if given. */ +function registeredDriver( + migration: DriverMigrationTarget | undefined +): ClientDriver { + const operations = { + jobInsert: () => Promise.reject(new Error("not used")), + jobInsertMany: () => Promise.reject(new Error("not used")), + }; + const driver: ClientDriver = Object.freeze( + Object.create(null) as object + ); + registerDriver(driver, { + backend: "fake", + capability: "insert", + operations, + ...(migration === undefined ? {} : { migration }), + }); + return driver; +} + +describe("createMigrator", () => { + it("migrates a SQLite database", async () => { + const database = new DatabaseSync(":memory:"); + try { + const migrator = createMigrator({ database }); + + expect(migrator.backend).toBe("sqlite"); + expect(migrator.line).toBe("main"); + expect(migrator.migrations).toEqual(loadMigrations("sqlite")); + expect(versions(await migrator.migrateUp())).toHaveLength(8); + await expect( + createMigrator({ database }).existingVersions() + ).resolves.toHaveLength(8); + } finally { + database.close(); + } + }); + + it("migrates the connection a River driver registered", async () => { + const database = new DatabaseSync(":memory:"); + try { + const driver = registeredDriver({ database }); + const migrator = createMigrator(driver); + + expect(migrator.backend).toBe("sqlite"); + expect(versions(await migrator.migrateUp())).toHaveLength(8); + await expect( + createMigrator({ database }).existingVersions() + ).resolves.toHaveLength(8); + } finally { + database.close(); + } + }); + + it("rejects a River driver that can't migrate", () => { + const driver = registeredDriver(undefined); + + expect(() => createMigrator(driver)).toThrow( + "createMigrator requires a River driver that supports migrations" + ); + }); + + it.each([ + [null, "createMigrator requires a River driver"], + [{}, "createMigrator requires a River driver"], + [{ query: () => undefined }, "createMigrator requires a River driver"], + ])("rejects an invalid source %#", (source, message) => { + expect(() => createMigrator(source as never)).toThrow(MigrationError); + expect(() => createMigrator(source as never)).toThrow(message); + }); + + it("rejects invalid PostgreSQL schemas", () => { + const pool = {} as PgMigrationPool; + + expect(() => createMigrator({ pool, schema: "" })).toThrow("non-empty"); + expect(() => createMigrator({ pool, schema: "bad\0schema" })).toThrow( + "NUL" + ); + expect(() => createMigrator({ pool, schema: "x".repeat(64) })).toThrow( + "63 bytes" + ); + }); + + it("rejects invalid lines and migration sets", () => { + const database = new DatabaseSync(":memory:"); + try { + expect(() => createMigrator({ database }, { line: "" })).toThrow( + "migration line must be a string of 1 to 127 characters" + ); + expect(() => createMigrator({ database }, { line: "extra" })).toThrow( + 'migration line "extra" is not bundled' + ); + expect(() => + createMigrator( + { database }, + { line: "extra", migrations: EXTRA_MIGRATIONS.slice(1) } + ) + ).toThrow("expected 1, received 2"); + expect(() => + createMigrator( + { database }, + { + line: "extra", + migrations: [{ ...EXTRA_MIGRATIONS[0]!, upSql: " " }], + } + ) + ).toThrow("migration 1 must have a non-empty upSql"); + } finally { + database.close(); + } + }); +}); + +describe("PostgreSQL migrator", () => { + it("renders a quoted custom schema for a dry run without connecting", async () => { + const queries: { text: string; values?: readonly unknown[] }[] = []; + const pool: PgMigrationPool = { + async connect() { + throw new Error("a dry run must not check out a connection"); + }, + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -- implements the generic pool query + async query(text: string, values?: readonly unknown[]) { + queries.push({ text, ...(values === undefined ? {} : { values }) }); + return { + rows: [{ has_line_column: false, table_exists: false }] as TRow[], + }; + }, + }; + const schema = 'tenant"one; DROP TABLE users; --'; + const migrator = createMigrator({ pool, schema }); + + const result = await migrator.migrateUp({ + dryRun: true, + targetVersion: 1, + }); + + expect(versions(result)).toEqual([1]); + expect(result.versions[0]?.sql).toContain( + 'CREATE TABLE "tenant""one; DROP TABLE users; --".river_migration' + ); + expect(result.versions[0]?.sql).not.toContain("/* TEMPLATE: schema */"); + expect(queries).toHaveLength(1); + expect(queries[0]?.values).toEqual([ + '"tenant""one; DROP TABLE users; --"."river_migration"', + ]); + }); + + for (const [server, product, locks] of [ + ["PostgreSQL", "PostgreSQL 17.4 on aarch64-apple-darwin", true], + [ + "YugabyteDB", + "PostgreSQL 15.12-YB-2025.2.1.0-b1 on x86_64-pc-linux-gnu", + false, + ], + ] as const) { + it(`${locks ? "locks" : "doesn't lock"} each step on ${server}, like Go on YugabyteDB`, async () => { + const queries: string[] = []; + const client: PgMigrationClient = { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -- implements the generic client query + async query(text: string) { + queries.push(text); + const rows = text.startsWith("SELECT version()") + ? [{ product }] + : text.startsWith("SELECT to_regclass") + ? [{ has_line_column: false, table_exists: false }] + : []; + return { rows: rows as TRow[] }; + }, + }; + + await createMigrator({ client, schema: undefined }).migrateUp({ + targetVersion: 2, + }); + + expect( + queries.filter((text) => text.includes("pg_advisory_xact_lock")) + ).toHaveLength(locks ? 2 : 0); + expect( + queries.filter((text) => text.startsWith("SELECT version()")) + ).toHaveLength(1); + }); + } + + it("wraps read failures in MigrationError", async () => { + const cause = new Error("connection refused"); + const pool: PgMigrationPool = { + async connect() { + throw cause; + }, + async query() { + throw cause; + }, + }; + const migrator = createMigrator({ pool }); + + await expect(migrator.existingVersions()).rejects.toMatchObject({ + backend: "postgres", + cause, + operation: "read_versions", + }); + await expect(migrator.migrateUp()).rejects.toMatchObject({ + backend: "postgres", + cause, + operation: "connect", + }); + }); +}); + +describe("SQLite migrator", () => { + let database: DatabaseSync | undefined; + + afterEach(() => { + database?.close(); + database = undefined; + }); + + function openMigrator() { + database = new DatabaseSync(":memory:"); + return { database, migrator: createMigrator({ database }) }; + } + + it("migrates up, validates, and reverts one version by default", async () => { + const { migrator } = openMigrator(); + + await expect(migrator.existingVersions()).resolves.toEqual([]); + await expect(migrator.validate()).resolves.toEqual({ + messages: ["unapplied migrations: 1, 2, 3, 4, 5, 6, 7, 8"], + ok: false, + }); + + const up = await migrator.migrateUp(); + expect(up.direction).toBe("up"); + expect(versions(up)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]); + await expect(migrator.validate()).resolves.toEqual({ + messages: [], + ok: true, + }); + await expect(migrator.migrateUp()).resolves.toEqual({ + direction: "up", + versions: [], + }); + + const down = await migrator.migrateDown(); + expect(versions(down)).toEqual([8]); + await expect(migrator.existingVersions()).resolves.toEqual([ + 1, 2, 3, 4, 5, 6, 7, + ]); + await expect(migrator.validate({ targetVersion: 7 })).resolves.toEqual({ + messages: [], + ok: true, + }); + }); + + it("supports targets, step limits, dry runs, and reverting to 0", async () => { + const { migrator } = openMigrator(); + + const dryRun = await migrator.migrateUp({ dryRun: true, targetVersion: 2 }); + expect( + dryRun.versions.map(({ duration, version }) => ({ + duration: duration.toString(), + version, + })) + ).toEqual([ + { duration: "PT0S", version: 1 }, + { duration: "PT0S", version: 2 }, + ]); + await expect(migrator.existingVersions()).resolves.toEqual([]); + expect(dryRun.versions[0]?.sql).toMatch(/^CREATE TABLE river_migration/m); + expect(dryRun.versions[0]?.sql).not.toContain("TEMPLATE"); + + expect(versions(await migrator.migrateUp({ maxSteps: 2 }))).toEqual([1, 2]); + expect(versions(await migrator.migrateUp({ targetVersion: 5 }))).toEqual([ + 3, 4, 5, + ]); + expect(versions(await migrator.migrateUp({ maxSteps: 0 }))).toEqual([]); + expect( + versions(await migrator.migrateDown({ dryRun: true, targetVersion: 0 })) + ).toEqual([5, 4, 3, 2, 1]); + await expect(migrator.existingVersions()).resolves.toEqual([1, 2, 3, 4, 5]); + expect(versions(await migrator.migrateDown({ targetVersion: 3 }))).toEqual([ + 5, 4, + ]); + expect(versions(await migrator.migrateDown({ targetVersion: 0 }))).toEqual([ + 3, 2, 1, + ]); + await expect(migrator.existingVersions()).resolves.toEqual([]); + await expect(migrator.migrateDown({ targetVersion: 0 })).resolves.toEqual({ + direction: "down", + versions: [], + }); + }); + + it("rejects invalid options before touching the database", async () => { + const { migrator } = openMigrator(); + + await expect(migrator.migrateUp({ targetVersion: 0 })).rejects.toThrow( + "targetVersion 0 is only valid when migrating down" + ); + await expect(migrator.migrateDown({ targetVersion: 3 })).rejects.toThrow( + "cannot migrate down to version 3 because it is not applied" + ); + await expect(migrator.migrateUp({ maxSteps: -1 })).rejects.toMatchObject({ + operation: "plan", + }); + await expect(migrator.validate({ targetVersion: 99 })).rejects.toThrow( + "version 99 is not a migration version" + ); + }); + + it("ignores applied versions newer than this package", async () => { + const { database, migrator } = openMigrator(); + await migrator.migrateUp(); + database + .prepare("INSERT INTO river_migration (line, version) VALUES (?, ?)") + .run("main", 9); + + await expect(migrator.existingVersions()).resolves.toEqual([ + 1, 2, 3, 4, 5, 6, 7, 8, 9, + ]); + await expect(migrator.migrateUp()).resolves.toEqual({ + direction: "up", + versions: [], + }); + await expect(migrator.validate()).resolves.toEqual({ + messages: [], + ok: true, + }); + expect(versions(await migrator.migrateDown())).toEqual([8]); + await expect(migrator.existingVersions()).resolves.toEqual([ + 1, 2, 3, 4, 5, 6, 7, 9, + ]); + }); + + it("never reuses a deleted job's ID after version 8", async () => { + const { database, migrator } = openMigrator(); + await migrator.migrateUp({ targetVersion: 7 }); + const insertJob = (): bigint => { + const statement = database.prepare( + "INSERT INTO river_job (kind, max_attempts) VALUES ('id_reuse', 25) RETURNING id" + ); + statement.setReadBigInts(true); + return statement.get()!.id as bigint; + }; + const beforeUpgrade = insertJob(); + + expect(versions(await migrator.migrateUp())).toEqual([8]); + expect( + database.prepare("SELECT count(*) AS count FROM river_job").get() + ).toEqual({ count: 1 }); + database.prepare("DELETE FROM river_job").run(); + + expect(insertJob()).toBeGreaterThan(beforeUpgrade); + }); + + it.each( + [ + "CREATE INDEX river_job_workflow_scheduling ON river_job (state)", + "CREATE TABLE river_job_sequence (id integer PRIMARY KEY, key text)", + "CREATE TABLE river_workflow (id text PRIMARY KEY)", + ].flatMap((sql) => [["up", sql] as const, ["down", sql] as const]) + )("refuses version 8 %s once %s ran, like Go", async (direction, sql) => { + const { database, migrator } = openMigrator(); + const version = direction === "up" ? 7 : 8; + await migrator.migrateUp({ targetVersion: version }); + database.exec(sql); + database.exec("ALTER TABLE river_job ADD COLUMN extension_column text"); + + const migrate = + direction === "up" + ? migrator.migrateUp({ maxSteps: 1 }) + : migrator.migrateDown({ maxSteps: 1 }); + await expect(migrate).rejects.toMatchObject({ + cause: expect.objectContaining({ + message: expect.stringContaining( + "River SQLite migration 008 cannot run" + ), + }), + }); + await expect(migrator.existingVersions()).resolves.toHaveLength(version); + expect( + database + .prepare( + "SELECT count(*) AS count FROM pragma_table_info('river_job') WHERE name = 'extension_column'" + ) + .get() + ).toEqual({ count: 1 }); + }); + + it("migrates an additional line once the main line is migrated", async () => { + const { database, migrator: main } = openMigrator(); + const extra = createMigrator( + { database }, + { line: "extra", migrations: EXTRA_MIGRATIONS } + ); + + await expect(extra.migrateUp()).rejects.toThrow( + 'cannot migrate line "extra" until the main line is migrated' + ); + await main.migrateUp({ targetVersion: 4 }); + await expect(extra.existingVersions()).rejects.toMatchObject({ + operation: "read_versions", + }); + await main.migrateUp(); + + expect(versions(await extra.migrateUp())).toEqual([1, 2]); + await expect(extra.existingVersions()).resolves.toEqual([1, 2]); + await expect(main.existingVersions()).resolves.toEqual([ + 1, 2, 3, 4, 5, 6, 7, 8, + ]); + await expect(main.migrateDown({ targetVersion: 4 })).rejects.toThrow( + "main migration 5 cannot be reverted while other migration lines" + ); + await expect(main.existingVersions()).resolves.toEqual([1, 2, 3, 4, 5]); + + expect(versions(await extra.migrateDown({ targetVersion: 0 }))).toEqual([ + 2, 1, + ]); + await expect(extra.existingVersions()).resolves.toEqual([]); + expect(versions(await main.migrateDown({ targetVersion: 0 }))).toEqual([ + 5, 4, 3, 2, 1, + ]); + }); + + it("records an additional line's version 1 until it is reverted", async () => { + const { database, migrator: main } = openMigrator(); + await main.migrateUp(); + const extra = createMigrator( + { database }, + { line: "extra", migrations: EXTRA_MIGRATIONS } + ); + const recorded = (): unknown[] => + database + .prepare( + "SELECT version FROM river_migration WHERE line = ? ORDER BY version" + ) + .all("extra") + .map((row) => row.version); + const hasWidgetTable = (): boolean => + database + .prepare( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'extra_widget'" + ) + .get() !== undefined; + + await extra.migrateUp(); + // Targeting version 1 stops before reverting it, so its row stays. + expect(versions(await extra.migrateDown({ targetVersion: 1 }))).toEqual([ + 2, + ]); + expect(recorded()).toEqual([1]); + expect(hasWidgetTable()).toBe(true); + + // Stepping down from version 1 applies its down migration and removes + // its row, unlike main version 1, whose down migration drops the table. + expect(versions(await extra.migrateDown())).toEqual([1]); + expect(recorded()).toEqual([]); + expect(hasWidgetTable()).toBe(false); + + await extra.migrateUp(); + expect(versions(await extra.migrateDown({ targetVersion: 0 }))).toEqual([ + 2, 1, + ]); + expect(recorded()).toEqual([]); + expect(hasWidgetTable()).toBe(false); + await expect(main.existingVersions()).resolves.toEqual([ + 1, 2, 3, 4, 5, 6, 7, 8, + ]); + }); + + it("serializes concurrent migrators and applies each version once", async () => { + const directory = mkdtempSync(join(tmpdir(), "river-js-migrate-")); + const path = join(directory, "river.sqlite3"); + const firstDatabase = new DatabaseSync(path); + const secondDatabase = new DatabaseSync(path); + try { + firstDatabase.exec("PRAGMA busy_timeout = 5000"); + secondDatabase.exec("PRAGMA busy_timeout = 5000"); + const first = createMigrator({ database: firstDatabase }); + const second = createMigrator({ database: secondDatabase }); + + const up = await Promise.all([first.migrateUp(), second.migrateUp()]); + expect(up.flatMap(versions).sort((a, b) => a - b)).toEqual([ + 1, 2, 3, 4, 5, 6, 7, 8, + ]); + + const down = await Promise.all([ + first.migrateDown({ targetVersion: 0 }), + second.migrateDown({ targetVersion: 0 }), + ]); + expect(down.flatMap(versions).sort((a, b) => b - a)).toEqual([ + 8, 7, 6, 5, 4, 3, 2, 1, + ]); + await expect(first.existingVersions()).resolves.toEqual([]); + await expect(second.existingVersions()).resolves.toEqual([]); + } finally { + firstDatabase.close(); + secondDatabase.close(); + rmSync(directory, { force: true, recursive: true }); + } + }); +}); diff --git a/js/migrate/src/migrator.ts b/js/migrate/src/migrator.ts new file mode 100644 index 000000000..a12c27641 --- /dev/null +++ b/js/migrate/src/migrator.ts @@ -0,0 +1,469 @@ +import type { DatabaseSync } from "node:sqlite"; + +import { MigrationError, type ClientDriver } from "riverqueue"; +import { driverMigrationTarget } from "riverqueue/unstable-driver"; + +import { loadMigrations } from "./bundle.js"; +import type { Migration, MigrationBackend } from "./bundle.js"; +import { + MIGRATION_LINE_MAIN, + planMigrations, + requireKnownVersion, + validatePlanOptions, + type MigrationDirection, +} from "./plan.js"; +import { + PgMigrationStorage, + type PgMigrationClient, + type PgMigrationPool, +} from "./postgres.js"; +import { + SqliteMigrationStorage, + type SqliteMigrationRunner, +} from "./sqlite.js"; +import type { MigrationStorage } from "./storage.js"; + +export { MIGRATION_LINE_MAIN }; +export type { MigrationDirection }; + +/** Options for {@link Migrator.migrateDown} and {@link Migrator.migrateUp}. */ +export interface MigrateOptions { + /** + * Report the versions and SQL that would run without changing the + * database. Defaults to `false`. + */ + readonly dryRun?: boolean | undefined; + /** + * Run at most this many versions. `0` runs none, which is useful with + * `dryRun` to check the plan. + * + * Up migrations are unlimited by default. Down migrations revert one + * version by default, or every version above `targetVersion` when it is + * set. + */ + readonly maxSteps?: number | undefined; + /** + * Version the schema should end at. + * + * Migrating up applies missing versions up to and including this one; if + * it is already applied, nothing runs. Migrating down reverts versions + * above it, so it must be applied, or `0` to revert every version. + */ + readonly targetVersion?: number | undefined; +} + +export interface MigrateResult { + readonly direction: MigrationDirection; + /** + * Versions applied (up) or reverted (down) in the order they ran. For a + * dry run, the versions that would run. + */ + readonly versions: readonly MigrateVersion[]; +} + +/** One version applied or reverted by a migrate call. */ +export interface MigrateVersion { + /** Time taken to run the version's SQL, or zero for a dry run. */ + readonly duration: Temporal.Duration; + /** Short name of the migration, such as `bulk_unique`. */ + readonly name: string; + /** SQL that ran, or would run, with the schema filled in. */ + readonly sql: string; + readonly version: number; +} + +/** + * A migration runner for one database, schema, and migration line. + * + * Create one with {@link createMigrator}. Each version runs in its own + * transaction together with its `river_migration` bookkeeping. Concurrent + * migrators for the same schema serialize: PostgreSQL uses a + * transaction-scoped advisory lock and SQLite uses an immediate write + * transaction, and a version finished by another migrator is skipped rather + * than run twice. + */ +export interface Migrator { + /** Database backend being migrated. */ + readonly backend: MigrationBackend; + /** Migration line being migrated, such as {@link MIGRATION_LINE_MAIN}. */ + readonly line: string; + /** Every migration version known for the line, ordered by version. */ + readonly migrations: readonly Migration[]; + + /** + * Read the versions of the line that are applied in the database, in + * ascending order. The result can include versions newer than + * {@link Migrator.migrations} if a newer River release migrated the + * database. + */ + existingVersions(): Promise; + /** + * Revert applied versions, newest first. Reverts one version unless + * `maxSteps` or `targetVersion` says otherwise; `targetVersion: 0` removes + * every version, dropping River's tables and their data. + */ + migrateDown(options?: MigrateOptions): Promise; + /** Apply missing versions, oldest first. Applies every version by default. */ + migrateUp(options?: MigrateOptions): Promise; + /** + * Check that every known version, or every version up to `targetVersion`, + * is applied. Applied versions newer than this package are ignored. + */ + validate(options?: ValidateOptions): Promise; +} + +/** Options for {@link createMigrator}. */ +export interface MigratorOptions { + /** + * Migration line to operate on. Defaults to {@link MIGRATION_LINE_MAIN}. + * + * Lines other than main require `migrations` and a database whose main + * line is at version 5 or later. + */ + readonly line?: string | undefined; + /** + * Migrations for `line`, which packages that ship their own migration + * line provide. Versions must start at 1 and increase by 1. Defaults to + * River's bundled main line for the backend. + */ + readonly migrations?: readonly Migration[] | undefined; +} + +/** + * Something to build a migrator from: a River driver such as `PgDriver` or + * `SqliteDriver`, or a connection given directly. + */ +export type MigratorSource = ClientDriver | MigrationTarget; + +/** A database connection to migrate, given without a River driver. */ +export type MigrationTarget = + PgClientMigrationTarget | PgPoolMigrationTarget | SqliteMigrationTarget; + +/** Migrate PostgreSQL through one dedicated connection. */ +export interface PgClientMigrationTarget { + /** + * A connected node-postgres `Client` or `PoolClient` that is not inside a + * transaction. The caller keeps ownership and closes it. + */ + readonly client: PgMigrationClient; + /** + * Schema containing River's tables. Defaults to the connection's + * `search_path`. Must match the schema given to `PgDriver`. + */ + readonly schema?: string | undefined; +} + +/** Migrate PostgreSQL through a connection pool. */ +export interface PgPoolMigrationTarget { + /** + * A node-postgres `Pool`. The migrator checks out one connection per call + * and never ends the pool. + */ + readonly pool: PgMigrationPool; + /** + * Schema containing River's tables. Defaults to the connection's + * `search_path`. Must match the schema given to `PgDriver`. + */ + readonly schema?: string | undefined; +} + +/** Migrate a `node:sqlite` database. */ +export interface SqliteMigrationTarget { + /** An open database. The caller keeps ownership and closes it. */ + readonly database: DatabaseSync; +} + +/** Options for {@link Migrator.validate}. */ +export interface ValidateOptions { + /** Only require versions up to and including this one. */ + readonly targetVersion?: number | undefined; +} + +/** The result of {@link Migrator.validate}. */ +export interface ValidateResult { + /** Why validation failed. Empty when `ok` is `true`. */ + readonly messages: readonly string[]; + /** Whether every required version is applied. */ + readonly ok: boolean; +} + +const INVALID_SOURCE_MESSAGE = + "createMigrator requires a River driver that supports migrations, { pool }, { client }, or { database }"; +const LINE_MAX_LENGTH = 127; + +/** + * Create a migrator for a River driver or a database connection. + * + * Passing the driver used by the client migrates the same database and + * schema, so the schema is configured in one place: + * + * ```ts + * const driver = new PgDriver(pool, { schema: "river" }); + * await createMigrator(driver).migrateUp(); + * ``` + * + * Deploy scripts can pass a connection instead, such as `{ pool, schema }`, + * `{ client, schema }`, or `{ database }` for SQLite. + * + * Migrations never run implicitly; run them as a deployment step, before + * starting workers. + * + * @throws {@link MigrationError} if the source or options are invalid. + */ +export function createMigrator( + source: MigratorSource, + options: MigratorOptions = {} +): Migrator { + const target = resolveTarget(source); + const storage: MigrationStorage = + "database" in target + ? new SqliteMigrationStorage(target.database, target.run) + : new PgMigrationStorage( + "pool" in target ? { pool: target.pool } : { client: target.client }, + target.schema + ); + const line = options.line ?? MIGRATION_LINE_MAIN; + validateLine(storage.backend, line); + const migrations = + options.migrations === undefined + ? line === MIGRATION_LINE_MAIN + ? loadMigrations(storage.backend) + : configurationError( + storage.backend, + `migration line ${JSON.stringify(line)} is not bundled with ` + + "@riverqueue/migrate; pass its migrations" + ) + : copyMigrations(storage.backend, options.migrations); + return new StorageMigrator(storage, line, migrations); +} + +class StorageMigrator implements Migrator { + readonly backend: MigrationBackend; + readonly line: string; + readonly migrations: readonly Migration[]; + readonly #storage: MigrationStorage; + + constructor( + storage: MigrationStorage, + line: string, + migrations: readonly Migration[] + ) { + this.backend = storage.backend; + this.line = line; + this.migrations = migrations; + this.#storage = storage; + } + + async existingVersions(): Promise { + return this.#readVersions(); + } + + async migrateDown(options: MigrateOptions = {}): Promise { + return this.#migrate("down", options); + } + + async migrateUp(options: MigrateOptions = {}): Promise { + return this.#migrate("up", options); + } + + async validate(options: ValidateOptions = {}): Promise { + const { targetVersion } = options; + if (targetVersion !== undefined) { + requireKnownVersion(this.backend, this.migrations, targetVersion); + } + const applied = new Set(await this.#readVersions()); + const missing = this.migrations + .map(({ version }) => version) + .filter( + (version) => + (targetVersion === undefined || version <= targetVersion) && + !applied.has(version) + ); + return missing.length === 0 + ? { messages: [], ok: true } + : { + messages: [`unapplied migrations: ${missing.join(", ")}`], + ok: false, + }; + } + + async #migrate( + direction: MigrationDirection, + options: MigrateOptions + ): Promise { + const plan = { + maxSteps: options.maxSteps, + targetVersion: options.targetVersion, + }; + validatePlanOptions(this.backend, this.migrations, direction, plan); + const dryRun = options.dryRun ?? false; + if (typeof dryRun !== "boolean") { + configurationError(this.backend, "dryRun must be a boolean"); + } + + if (dryRun) { + const selected = planMigrations( + this.backend, + this.migrations, + direction, + plan, + await this.#readVersions() + ); + return { + direction, + versions: selected.map((migration) => ({ + duration: new Temporal.Duration(), + name: migration.name, + sql: this.#sql(direction, migration), + version: migration.version, + })), + }; + } + + return this.#storage.withSession(async (session) => { + const selected = planMigrations( + this.backend, + this.migrations, + direction, + plan, + await this.#wrapRead(session.readVersions(this.line)) + ); + const versions: MigrateVersion[] = []; + for (const migration of selected) { + const sql = this.#sql(direction, migration); + const startedAt = performance.now(); + const applied = await session.apply({ + direction, + line: this.line, + migration, + sql, + }); + if (!applied) continue; + versions.push({ + duration: measuredDuration(performance.now() - startedAt), + name: migration.name, + sql, + version: migration.version, + }); + } + return { direction, versions }; + }); + } + + async #readVersions(): Promise { + return this.#wrapRead(this.#storage.readVersions(this.line)); + } + + #sql(direction: MigrationDirection, migration: Migration): string { + return this.#storage.renderSql( + direction === "up" ? migration.upSql : migration.downSql + ); + } + + async #wrapRead( + read: Promise + ): Promise { + try { + return await read; + } catch (error: unknown) { + if (error instanceof MigrationError) throw error; + throw new MigrationError("failed to read applied River migrations", { + backend: this.backend, + operation: "read_versions", + cause: error, + }); + } + } +} + +function configurationError(backend: string, message: string): never { + throw new MigrationError(message, { backend, operation: "configure" }); +} + +function copyMigrations( + backend: MigrationBackend, + migrations: readonly Migration[] +): readonly Migration[] { + const value: unknown = migrations; + if (!Array.isArray(value) || migrations.length === 0) { + configurationError(backend, "migrations must be a non-empty array"); + } + return Object.freeze( + migrations.map((migration, index) => { + const expected = index + 1; + if (migration.version !== expected) { + configurationError( + backend, + `migration versions must start at 1 and increase by 1; ` + + `expected ${expected}, received ${String(migration.version)}` + ); + } + for (const field of ["downSql", "name", "upSql"] as const) { + const value: unknown = migration[field]; + if (typeof value !== "string" || value.trim().length === 0) { + configurationError( + backend, + `migration ${expected} must have a non-empty ${field}` + ); + } + } + return Object.freeze({ + downSql: migration.downSql, + name: migration.name, + upSql: migration.upSql, + version: migration.version, + }); + }) + ); +} + +function resolveTarget( + source: MigratorSource +): + | PgClientMigrationTarget + | PgPoolMigrationTarget + | (SqliteMigrationTarget & { readonly run?: SqliteMigrationRunner }) { + // A registered driver says which connection it migrates, and for SQLite + // how to run on it under the driver's lock; its own properties are never + // read. + const registered = driverMigrationTarget(source); + if (registered !== undefined) { + if (!("database" in registered)) return registered as MigrationTarget; + return { + database: registered.database as DatabaseSync, + ...(registered.run === undefined + ? {} + : { run: registered.run as SqliteMigrationRunner }), + }; + } + if (typeof source === "object" && (source as unknown) !== null) { + if ("database" in source) return { database: source.database }; + if ("pool" in source) { + return { pool: source.pool, schema: source.schema }; + } + if ("client" in source) { + return { client: source.client, schema: source.schema }; + } + } + return configurationError("unknown", INVALID_SOURCE_MESSAGE); +} + +function validateLine(backend: MigrationBackend, line: string): void { + if ( + typeof line !== "string" || + line.length === 0 || + line.length > LINE_MAX_LENGTH + ) { + configurationError( + backend, + `migration line must be a string of 1 to ${LINE_MAX_LENGTH} characters` + ); + } +} + +/** A measured time in fractional milliseconds as a balanced duration. */ +function measuredDuration(milliseconds: number): Temporal.Duration { + return Temporal.Duration.from({ + nanoseconds: Math.max(0, Math.round(milliseconds * 1_000_000)), + }).round({ largestUnit: "hours" }); +} diff --git a/js/migrate/src/plan.test.ts b/js/migrate/src/plan.test.ts new file mode 100644 index 000000000..22a639e3e --- /dev/null +++ b/js/migrate/src/plan.test.ts @@ -0,0 +1,157 @@ +import { MigrationError } from "riverqueue"; +import { describe, expect, it } from "vitest"; + +import type { Migration } from "./bundle.js"; +import { + planMigrations, + validatePlanOptions, + versionRecord, + type MigrationDirection, + type MigrationPlanOptions, +} from "./plan.js"; + +const MIGRATIONS: readonly Migration[] = [1, 2, 3, 4, 5].map((version) => ({ + downSql: `-- down ${version}`, + name: `migration_${version}`, + upSql: `-- up ${version}`, + version, +})); + +function plan( + direction: MigrationDirection, + applied: readonly number[], + options: Partial = {} +): readonly number[] { + const normalized = { + maxSteps: options.maxSteps, + targetVersion: options.targetVersion, + }; + validatePlanOptions("sqlite", MIGRATIONS, direction, normalized); + return planMigrations( + "sqlite", + MIGRATIONS, + direction, + normalized, + applied + ).map(({ version }) => version); +} + +describe("planMigrations", () => { + it("applies every missing version up by default", () => { + expect(plan("up", [])).toEqual([1, 2, 3, 4, 5]); + expect(plan("up", [1, 2])).toEqual([3, 4, 5]); + expect(plan("up", [1, 2, 3, 4, 5])).toEqual([]); + }); + + it("migrates up to and including a target version", () => { + expect(plan("up", [1], { targetVersion: 3 })).toEqual([2, 3]); + expect(plan("up", [1, 2, 3], { targetVersion: 2 })).toEqual([]); + }); + + it("does nothing up to a target that is already applied", () => { + expect(plan("up", [1, 2], { targetVersion: 2 })).toEqual([]); + expect(plan("up", [1, 2], { maxSteps: 1, targetVersion: 1 })).toEqual([]); + // Even a missing version below the target stays missing. + expect(plan("up", [1, 3], { targetVersion: 3 })).toEqual([]); + expect(plan("up", [1, 3], { targetVersion: 4 })).toEqual([2, 4]); + }); + + it("limits steps in either direction", () => { + expect(plan("up", [], { maxSteps: 2 })).toEqual([1, 2]); + expect(plan("up", [], { maxSteps: 0 })).toEqual([]); + expect(plan("down", [1, 2, 3, 4], { maxSteps: 3 })).toEqual([4, 3, 2]); + expect( + plan("down", [1, 2, 3, 4], { maxSteps: 1, targetVersion: 0 }) + ).toEqual([4]); + }); + + it("reverts one version down by default", () => { + expect(plan("down", [1, 2, 3])).toEqual([3]); + expect(plan("down", [])).toEqual([]); + }); + + it("reverts versions above a target, or all of them for target 0", () => { + expect(plan("down", [1, 2, 3, 4, 5], { targetVersion: 2 })).toEqual([ + 5, 4, 3, + ]); + expect(plan("down", [1, 2, 3], { targetVersion: 3 })).toEqual([]); + expect(plan("down", [1, 2, 3], { targetVersion: 0 })).toEqual([3, 2, 1]); + expect(plan("down", [], { targetVersion: 0 })).toEqual([]); + }); + + it("rejects a down target that is not applied", () => { + expect(() => plan("down", [1, 2], { targetVersion: 4 })).toThrow( + "cannot migrate down to version 4 because it is not applied" + ); + }); + + it("ignores applied versions that this package does not know", () => { + expect(plan("up", [1, 2, 3, 4, 5, 6])).toEqual([]); + expect(plan("up", [1, 2, 6])).toEqual([3, 4, 5]); + expect(plan("down", [1, 2, 3, 4, 5, 6])).toEqual([5]); + expect(plan("down", [1, 2, 3, 4, 5, 6], { targetVersion: 0 })).toEqual([ + 5, 4, 3, 2, 1, + ]); + }); +}); + +describe("validatePlanOptions", () => { + it.each([ + ["up", { maxSteps: -1 }, "maxSteps must be a non-negative integer"], + ["up", { maxSteps: 1.5 }, "maxSteps must be a non-negative integer"], + ["down", { targetVersion: -1 }, "targetVersion must be a non-negative"], + ["up", { targetVersion: 0 }, "only valid when migrating down"], + ["up", { targetVersion: 6 }, "available versions: 1, 2, 3, 4, 5"], + ["down", { targetVersion: 9 }, "version 9 is not a migration version"], + ] as const)("rejects %s %j", (direction, options, message) => { + const run = () => + validatePlanOptions("postgres", MIGRATIONS, direction, { + maxSteps: undefined, + targetVersion: undefined, + ...options, + }); + + expect(run).toThrow(MigrationError); + expect(run).toThrow(message); + }); +}); + +describe("versionRecord", () => { + it("omits the line column for main versions that predate it", () => { + expect(versionRecord("up", "main", 4)).toEqual({ + kind: "insert_without_line", + version: 4, + }); + expect(versionRecord("up", "main", 5)).toEqual({ + kind: "insert", + line: "main", + version: 5, + }); + expect(versionRecord("down", "main", 5)).toEqual({ + kind: "delete_without_line", + version: 5, + }); + expect(versionRecord("down", "main", 6)).toEqual({ + kind: "delete", + line: "main", + version: 6, + }); + }); + + it("skips bookkeeping after main version 1 drops the table", () => { + expect(versionRecord("down", "main", 1)).toEqual({ kind: "none" }); + }); + + it("always uses the line column for other lines", () => { + expect(versionRecord("up", "extra", 1)).toEqual({ + kind: "insert", + line: "extra", + version: 1, + }); + expect(versionRecord("down", "extra", 1)).toEqual({ + kind: "delete", + line: "extra", + version: 1, + }); + }); +}); diff --git a/js/migrate/src/plan.ts b/js/migrate/src/plan.ts new file mode 100644 index 000000000..619b89bc8 --- /dev/null +++ b/js/migrate/src/plan.ts @@ -0,0 +1,193 @@ +import { MigrationError } from "riverqueue"; + +import type { Migration, MigrationBackend } from "./bundle.js"; + +export type MigrationDirection = "down" | "up"; + +/** Validated options for one migration run. */ +export interface MigrationPlanOptions { + readonly maxSteps: number | undefined; + readonly targetVersion: number | undefined; +} + +/** + * Validate `maxSteps` and `targetVersion` before touching the database so + * that mistakes surface even when there is nothing to migrate. + */ +export function validatePlanOptions( + backend: MigrationBackend, + migrations: readonly Migration[], + direction: MigrationDirection, + options: MigrationPlanOptions +): void { + const { maxSteps, targetVersion } = options; + if ( + maxSteps !== undefined && + (!Number.isSafeInteger(maxSteps) || maxSteps < 0) + ) { + throw planError( + backend, + `maxSteps must be a non-negative integer; received ${String(maxSteps)}` + ); + } + if (targetVersion === undefined) return; + if (!Number.isSafeInteger(targetVersion) || targetVersion < 0) { + throw planError( + backend, + `targetVersion must be a non-negative integer; received ${String(targetVersion)}` + ); + } + if (targetVersion === 0) { + if (direction === "up") { + throw planError( + backend, + "targetVersion 0 is only valid when migrating down, where it removes every version" + ); + } + return; + } + requireKnownVersion(backend, migrations, targetVersion); +} + +/** Throw unless `version` is one of the line's migrations. */ +export function requireKnownVersion( + backend: MigrationBackend, + migrations: readonly Migration[], + version: number +): void { + if (!migrations.some((migration) => migration.version === version)) { + const available = migrations.map((migration) => migration.version); + throw planError( + backend, + `version ${version} is not a migration version on this line ` + + `(available versions: ${available.join(", ")})` + ); + } +} + +/** + * Choose the migrations to run in order, following River's Go migrator. + * + * Applied versions that this package does not know about, such as versions + * added by a newer River release, are ignored: up migrations apply only + * missing known versions and down migrations revert only known versions. + */ +export function planMigrations( + backend: MigrationBackend, + migrations: readonly Migration[], + direction: MigrationDirection, + options: MigrationPlanOptions, + appliedVersions: readonly number[] +): readonly Migration[] { + const applied = new Set(appliedVersions); + const { maxSteps, targetVersion } = options; + let selected: readonly Migration[]; + let defaultMaxSteps: number | undefined; + + if (direction === "up") { + // Like Go, an up migration whose target is already applied does + // nothing, even with other versions missing. + selected = + targetVersion !== undefined && applied.has(targetVersion) + ? [] + : migrations.filter( + ({ version }) => + !applied.has(version) && + (targetVersion === undefined || version <= targetVersion) + ); + } else { + if ( + targetVersion !== undefined && + targetVersion !== 0 && + !applied.has(targetVersion) + ) { + throw planError( + backend, + `cannot migrate down to version ${targetVersion} because it is not applied` + ); + } + selected = migrations + .filter( + ({ version }) => + applied.has(version) && + (targetVersion === undefined || version > targetVersion) + ) + .reverse(); + // Down migrations remove one version at a time unless a target says how + // far to go, because reverting drops tables and their data. + defaultMaxSteps = targetVersion === undefined ? 1 : undefined; + } + + const limit = maxSteps ?? defaultMaxSteps; + return limit === undefined ? selected : selected.slice(0, limit); +} + +/** River's main migration line, bundled with this package. */ +export const MIGRATION_LINE_MAIN = "main"; + +// Version that adds `river_migration.line`. Bookkeeping for main-line versions +// below it must not reference the column. +const LINE_COLUMN_VERSION = 5; + +/** How to update `river_migration` after one migration step. */ +export type VersionRecord = + | { readonly kind: "delete"; readonly line: string; readonly version: number } + | { readonly kind: "delete_without_line"; readonly version: number } + | { readonly kind: "insert"; readonly line: string; readonly version: number } + | { readonly kind: "insert_without_line"; readonly version: number } + | { readonly kind: "none" }; + +/** + * Describe the `river_migration` change that accompanies one step. + * + * Main-line versions before 5 predate the `line` column, and reverting main + * version 1 drops the table itself, so neither may use the column. + */ +export function versionRecord( + direction: MigrationDirection, + line: string, + version: number +): VersionRecord { + const isMain = line === MIGRATION_LINE_MAIN; + if (direction === "down") { + if (isMain && version === 1) return { kind: "none" }; + return isMain && version <= LINE_COLUMN_VERSION + ? { kind: "delete_without_line", version } + : { kind: "delete", line, version }; + } + return isMain && version < LINE_COLUMN_VERSION + ? { kind: "insert_without_line", version } + : { kind: "insert", line, version }; +} + +/** + * Throw if a non-main line is used before the main line created a + * `river_migration` table with a `line` column. + */ +export function requireLineStorage( + backend: MigrationBackend, + line: string, + storage: { readonly hasLineColumn: boolean; readonly tableExists: boolean } +): void { + if (line === MIGRATION_LINE_MAIN) return; + if (!storage.tableExists || !storage.hasLineColumn) { + throw new MigrationError( + `cannot migrate line ${JSON.stringify(line)} until the main line is ` + + "migrated to version 5 or later; migrate the main line and try again", + { backend, operation: "read_versions" } + ); + } +} + +function planError(backend: MigrationBackend, message: string): MigrationError { + return new MigrationError(message, { backend, operation: "plan" }); +} + +/** Whether `version` is already applied (up) or already reverted (down). */ +export function isInTargetState( + direction: MigrationDirection, + applied: readonly number[], + version: number +): boolean { + return applied.includes(version) === (direction === "up"); +} diff --git a/js/migrate/src/postgres.ts b/js/migrate/src/postgres.ts new file mode 100644 index 000000000..b12597275 --- /dev/null +++ b/js/migrate/src/postgres.ts @@ -0,0 +1,298 @@ +import { quoteIdentifier } from "riverqueue/unstable-driver"; +import { Buffer } from "node:buffer"; + +import { MigrationError } from "riverqueue"; + +import { + isInTargetState, + requireLineStorage, + versionRecord, + type MigrationDirection, +} from "./plan.js"; +import type { + MigrationSession, + MigrationStep, + MigrationStorage, +} from "./storage.js"; + +/** Result shape that River reads from PostgreSQL queries. */ +export interface PgMigrationQueryResult { + rows: TRow[]; +} + +/** + * A PostgreSQL connection that can run River's migrations, such as a + * node-postgres `Client` or a `PoolClient` checked out of a pool. + * + * The connection must not be inside a transaction: every migration version + * runs in its own transaction. + */ +export interface PgMigrationClient { + /** Run one SQL command, optionally with positional parameters. */ + query>( + text: string, + values?: readonly unknown[] + ): Promise>; +} + +/** A connection checked out of a {@link PgMigrationPool}. */ +export interface PgMigrationPoolClient extends PgMigrationClient { + /** Return the connection, destroying it when `error` is given. */ + release(error?: Error): void; +} + +/** A PostgreSQL connection pool such as a node-postgres `Pool`. */ +export interface PgMigrationPool { + /** Check out a dedicated connection for migrating. */ + connect(): Promise; + /** Run one SQL command on any pooled connection. */ + query>( + text: string, + values?: readonly unknown[] + ): Promise>; +} + +const POSTGRES_IDENTIFIER_MAX_BYTES = 63; +const TEMPLATE_SCHEMA = "/* TEMPLATE: schema */"; + +// Transaction-scoped so that it is released on commit or rollback and works +// through transaction-pooling proxies. The key uses the schema PostgreSQL +// resolves rather than how the caller spelled it, so an omitted schema and +// its explicit name serialize against each other. +const LOCK_SQL = + "SELECT pg_advisory_xact_lock(hashtext(current_database()::text), " + + "hashtext('river_migration:' || coalesce($1::text, current_schema()::text, '')))"; + +const PRODUCT_SQL = "SELECT version()::text AS product"; + +const STORAGE_SQL = + "SELECT to_regclass($1) IS NOT NULL AS table_exists, " + + "EXISTS (SELECT 1 FROM pg_catalog.pg_attribute " + + "WHERE attrelid = to_regclass($1) AND attname = 'line' " + + "AND NOT attisdropped) AS has_line_column"; + +export class PgMigrationStorage implements MigrationStorage { + readonly backend = "postgres" as const; + readonly #connection: + { readonly client: PgMigrationClient } | { readonly pool: PgMigrationPool }; + readonly #relation: string; + readonly #schema: string | undefined; + readonly #schemaPrefix: string; + + constructor( + connection: + | { readonly client: PgMigrationClient } + | { readonly pool: PgMigrationPool }, + schema: string | undefined + ) { + if (schema !== undefined) validateSchema(schema); + this.#connection = connection; + this.#schema = schema; + this.#schemaPrefix = + schema === undefined ? "" : `${quoteIdentifier(schema)}.`; + this.#relation = `${this.#schemaPrefix}${quoteIdentifier("river_migration")}`; + } + + async readVersions(line: string): Promise { + const connection = this.#connection; + return this.#readVersions( + "pool" in connection ? connection.pool : connection.client, + line + ); + } + + renderSql(sql: string): string { + return sql.replaceAll(TEMPLATE_SCHEMA, this.#schemaPrefix); + } + + async withSession( + run: (session: MigrationSession) => Promise + ): Promise { + const connection = this.#connection; + if (!("pool" in connection)) { + return run(this.#session(connection.client, () => undefined)); + } + + let client: PgMigrationPoolClient; + try { + client = await connection.pool.connect(); + } catch (error: unknown) { + throw new MigrationError("failed to connect to PostgreSQL", { + backend: "postgres", + operation: "connect", + cause: error, + }); + } + let broken: Error | undefined; + try { + return await run( + this.#session(client, (error) => { + broken ??= error; + }) + ); + } finally { + client.release(broken); + } + } + + async #readVersions( + queryable: PgMigrationClient, + line: string + ): Promise { + const storage = await queryable.query<{ + has_line_column: boolean; + table_exists: boolean; + }>(STORAGE_SQL, [this.#relation]); + const row = storage.rows[0]; + const tableExists = row?.table_exists === true; + const hasLineColumn = row?.has_line_column === true; + requireLineStorage("postgres", line, { hasLineColumn, tableExists }); + if (!tableExists) return []; + + const result = hasLineColumn + ? await queryable.query<{ version: number | string }>( + `SELECT version FROM ${this.#relation} WHERE line = $1 ORDER BY version`, + [line] + ) + : await queryable.query<{ version: number | string }>( + `SELECT version FROM ${this.#relation} ORDER BY version` + ); + return result.rows.map(({ version }) => toVersion(version)); + } + + #session( + client: PgMigrationClient, + markBroken: (error: Error) => void + ): MigrationSession { + let locks: Promise | undefined; + // YugabyteDB has advisory locks only behind a preview flag, and River + // for Go's migrator takes none, so migrations there run unlocked like + // Go's. + const takesLock = (): Promise => + (locks ??= client + .query<{ product: string }>(PRODUCT_SQL) + .then(({ rows }) => !isYugabyte(rows[0]?.product ?? ""))); + return { + apply: async (step) => this.#apply(client, step, markBroken, takesLock), + readVersions: async (line) => this.#readVersions(client, line), + }; + } + + async #apply( + client: PgMigrationClient, + step: MigrationStep, + markBroken: (error: Error) => void, + takesLock: () => Promise + ): Promise { + const { direction, line, migration, sql } = step; + let inTransaction = false; + try { + await client.query("BEGIN"); + inTransaction = true; + if (await takesLock()) { + await client.query(LOCK_SQL, [this.#schema ?? null]); + } + const applied = await this.#readVersions(client, line); + if (isInTargetState(direction, applied, migration.version)) { + // Another migrator finished this step while this one waited. + await client.query("COMMIT"); + return false; + } + await client.query(sql); + await this.#recordVersion(client, direction, line, migration.version); + await client.query("COMMIT"); + return true; + } catch (error: unknown) { + if (inTransaction) { + try { + await client.query("ROLLBACK"); + } catch (rollbackError: unknown) { + markBroken( + rollbackError instanceof Error + ? rollbackError + : new Error(String(rollbackError)) + ); + } + } + throw new MigrationError( + `failed to apply ${direction} migration ${migration.version} ` + + `(${migration.name}) on line ${JSON.stringify(line)}`, + { backend: "postgres", operation: "apply", cause: error } + ); + } + } + + async #recordVersion( + client: PgMigrationClient, + direction: MigrationDirection, + line: string, + version: number + ): Promise { + const record = versionRecord(direction, line, version); + const table = this.#relation; + switch (record.kind) { + case "delete": + await client.query( + `DELETE FROM ${table} WHERE line = $1 AND version = $2`, + [record.line, record.version] + ); + return; + case "delete_without_line": + await client.query(`DELETE FROM ${table} WHERE version = $1`, [ + record.version, + ]); + return; + case "insert": + await client.query( + `INSERT INTO ${table} (line, version) VALUES ($1, $2)`, + [record.line, record.version] + ); + return; + case "insert_without_line": + await client.query(`INSERT INTO ${table} (version) VALUES ($1)`, [ + record.version, + ]); + return; + case "none": + return; + } + } +} + +/** Throw unless `value` can name a PostgreSQL schema. */ +function validateSchema(value: string): void { + const fail = (message: string): never => { + throw new MigrationError(message, { + backend: "postgres", + operation: "configure", + }); + }; + if (typeof value !== "string" || value.length === 0) { + fail("PostgreSQL schema must be a non-empty string"); + } + if (value.includes("\0")) { + fail("PostgreSQL schema must not contain a NUL byte"); + } + if (Buffer.byteLength(value, "utf8") > POSTGRES_IDENTIFIER_MAX_BYTES) { + fail( + `PostgreSQL schema must not exceed ${POSTGRES_IDENTIFIER_MAX_BYTES} bytes` + ); + } +} + +function toVersion(value: number | string): number { + const version = Number(value); + if (!Number.isSafeInteger(version)) { + throw new MigrationError(`invalid River migration version ${value}`, { + backend: "postgres", + operation: "read_versions", + }); + } + return version; +} + +/** Whether `product`, the server's `version()`, names YugabyteDB. */ +function isYugabyte(product: string): boolean { + const lower = product.toLowerCase(); + return lower.includes("-yb") || lower.includes("yugabyte"); +} diff --git a/js/migrate/src/sqlite.ts b/js/migrate/src/sqlite.ts new file mode 100644 index 000000000..b54253359 --- /dev/null +++ b/js/migrate/src/sqlite.ts @@ -0,0 +1,204 @@ +import type { DatabaseSync } from "node:sqlite"; + +import { MigrationError } from "riverqueue"; + +import { + isInTargetState, + MIGRATION_LINE_MAIN, + requireLineStorage, + versionRecord, + type MigrationDirection, +} from "./plan.js"; +import type { + MigrationSession, + MigrationStep, + MigrationStorage, +} from "./storage.js"; + +// Reverting main version 5 rebuilds `river_migration` without its `line` +// column, which would lose other lines' history. PostgreSQL's SQL refuses +// this itself; SQLite cannot raise from plain SQL, so the migrator checks. +const LINE_COLUMN_VERSION = 5; +const TEMPLATE_SCHEMA = "/* TEMPLATE: schema */"; + +/** + * Runs a synchronous migration attempt on a SQLite connection. A driver's + * runner holds the driver's lock and, while another connection holds the + * write lock, retries the whole attempt after an asynchronous backoff; the + * attempt leaves no transaction open when it throws. + */ +export type SqliteMigrationRunner = ( + attempt: (database: DatabaseSync) => T +) => Promise; + +export class SqliteMigrationStorage implements MigrationStorage { + readonly backend = "sqlite" as const; + readonly #run: SqliteMigrationRunner; + + /** + * Without `run`, as for `{ database }`, statements run directly on the + * handle, which waits for a busy database for its own `timeout`. + */ + constructor(database: DatabaseSync, run?: SqliteMigrationRunner) { + this.#run = + run ?? + ((attempt: (database: DatabaseSync) => T) => + Promise.resolve(attempt(database))); + } + + async readVersions(line: string): Promise { + return this.#readVersionsOnce(line); + } + + renderSql(sql: string): string { + // SQLite has no schemas, so the placeholder shared with PostgreSQL's SQL + // renders as nothing. + return sql.replaceAll(TEMPLATE_SCHEMA, ""); + } + + async withSession( + run: (session: MigrationSession) => Promise + ): Promise { + return run({ + apply: async (step) => this.#apply(step), + readVersions: async (line) => this.#readVersionsOnce(line), + }); + } + + async #apply(step: MigrationStep): Promise { + const { direction, line, migration } = step; + try { + return await this.#run((database) => this.#applyOnce(database, step)); + } catch (error: unknown) { + if (error instanceof MigrationError) throw error; + throw new MigrationError( + `failed to apply ${direction} migration ${migration.version} ` + + `(${migration.name}) on line ${JSON.stringify(line)}`, + { backend: "sqlite", operation: "apply", cause: error } + ); + } + } + + /** Apply one step in its own transaction, rolling back when it throws. */ + #applyOnce(database: DatabaseSync, step: MigrationStep): boolean { + const { direction, line, migration, sql } = step; + // IMMEDIATE takes SQLite's write lock up front, which serializes + // concurrent migrators on the same file. + database.exec("BEGIN IMMEDIATE"); + try { + const applied = readVersions(database, line); + if (isInTargetState(direction, applied, migration.version)) { + database.exec("COMMIT"); + return false; + } + if ( + direction === "down" && + line === MIGRATION_LINE_MAIN && + migration.version === LINE_COLUMN_VERSION && + hasOtherLines(database) + ) { + throw new MigrationError( + "main migration 5 cannot be reverted while other migration lines " + + "are applied because it would lose their history", + { backend: "sqlite", operation: "apply" } + ); + } + database.exec(sql); + recordVersion(database, direction, line, migration.version); + database.exec("COMMIT"); + return true; + } catch (error: unknown) { + try { + database.exec("ROLLBACK"); + } catch { + // Keep the migration failure as the reported cause. + } + throw error; + } + } + + #readVersionsOnce(line: string): Promise { + return this.#run((database) => readVersions(database, line)); + } +} + +function exists(database: DatabaseSync, sql: string): boolean { + const statement = database.prepare(sql); + statement.setReadBigInts(true); + return statement.get()?.value === 1n; +} + +function hasOtherLines(database: DatabaseSync): boolean { + const statement = database.prepare( + "SELECT EXISTS (SELECT 1 FROM river_migration WHERE line <> ?) AS value" + ); + statement.setReadBigInts(true); + return statement.get(MIGRATION_LINE_MAIN)?.value === 1n; +} + +function readVersions(database: DatabaseSync, line: string): readonly number[] { + const tableExists = exists( + database, + "SELECT EXISTS (SELECT 1 FROM sqlite_schema " + + "WHERE type = 'table' AND name = 'river_migration') AS value" + ); + const hasLineColumn = + tableExists && + exists( + database, + "SELECT EXISTS (SELECT 1 FROM pragma_table_info('river_migration') " + + "WHERE name = 'line') AS value" + ); + requireLineStorage("sqlite", line, { hasLineColumn, tableExists }); + if (!tableExists) return []; + + const statement = database.prepare( + hasLineColumn + ? "SELECT version FROM river_migration WHERE line = ? ORDER BY version" + : "SELECT version FROM river_migration ORDER BY version" + ); + statement.setReadBigInts(true); + const rows = hasLineColumn ? statement.all(line) : statement.all(); + return rows.map(({ version }) => { + if (typeof version !== "bigint") { + throw new MigrationError( + `invalid River migration version ${String(version)}`, + { backend: "sqlite", operation: "read_versions" } + ); + } + return Number(version); + }); +} + +function recordVersion( + database: DatabaseSync, + direction: MigrationDirection, + line: string, + version: number +): void { + const record = versionRecord(direction, line, version); + switch (record.kind) { + case "delete": + database + .prepare("DELETE FROM river_migration WHERE line = ? AND version = ?") + .run(record.line, record.version); + return; + case "delete_without_line": + database + .prepare("DELETE FROM river_migration WHERE version = ?") + .run(record.version); + return; + case "insert": + database + .prepare("INSERT INTO river_migration (line, version) VALUES (?, ?)") + .run(record.line, record.version); + return; + case "insert_without_line": + database + .prepare("INSERT INTO river_migration (version) VALUES (?)") + .run(record.version); + return; + case "none": + return; + } +} diff --git a/js/migrate/src/storage.ts b/js/migrate/src/storage.ts new file mode 100644 index 000000000..c14304ff2 --- /dev/null +++ b/js/migrate/src/storage.ts @@ -0,0 +1,33 @@ +import type { Migration, MigrationBackend } from "./bundle.js"; +import type { MigrationDirection } from "./plan.js"; + +/** One migration version to apply in one direction. */ +export interface MigrationStep { + readonly direction: MigrationDirection; + readonly line: string; + readonly migration: Migration; + /** SQL with any schema placeholder already rendered. */ + readonly sql: string; +} + +/** Database operations used while applying migrations. */ +export interface MigrationSession { + /** + * Apply one step and its `river_migration` bookkeeping atomically while + * holding the backend's migration lock. Returns `false` without changing + * anything when another migrator already applied the step. + */ + apply(step: MigrationStep): Promise; + readVersions(line: string): Promise; +} + +/** Backend-specific access to River's migration table. */ +export interface MigrationStorage { + readonly backend: MigrationBackend; + /** Read the applied versions of `line` without holding a connection. */ + readVersions(line: string): Promise; + /** Fill in placeholders such as the PostgreSQL schema. */ + renderSql(sql: string): string; + /** Run `run` with a session bound to one connection. */ + withSession(run: (session: MigrationSession) => Promise): Promise; +} diff --git a/js/migrate/tsconfig.json b/js/migrate/tsconfig.json new file mode 100644 index 000000000..f1ce31207 --- /dev/null +++ b/js/migrate/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "exclude": ["src/**/*.integration.test.ts", "src/**/*.test.ts"], + "include": ["src"] +} diff --git a/js/package.json b/js/package.json index 2fbb194c2..bef07fe1d 100644 --- a/js/package.json +++ b/js/package.json @@ -26,18 +26,20 @@ ], "scripts": { "build": "node node_modules/typescript/bin/tsc", - "build:all": "pnpm run build && pnpm --filter='./driver/*' run build", + "build:all": "pnpm run build && pnpm --filter=@riverqueue/migrate run build && pnpm --filter='./driver/*' run build", "clean": "rm -rf dist", - "clean:all": "pnpm run clean && pnpm --filter='./driver/*' run clean", - "fmt": "prettier --write 'src/**/*.ts' 'driver/**/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts' 'examples/*/{README.md,package.json,tsconfig.json}' 'examples/tsconfig.json' '{README.md,package.json}'", - "fmt:check": "prettier --check 'src/**/*.ts' 'driver/**/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts' 'examples/*/{README.md,package.json,tsconfig.json}' 'examples/tsconfig.json' '{README.md,package.json}'", - "lint": "eslint 'src/**/*.ts' 'driver/**/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts'", - "lint:fix": "eslint --fix 'src/**/*.ts' 'driver/**/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts'", + "clean:all": "pnpm run clean && pnpm --filter=@riverqueue/migrate run clean && pnpm --filter='./driver/*' run clean", + "fmt": "prettier --write 'src/**/*.ts' 'driver/**/src/**/*.ts' 'migrate/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts' 'examples/*/{README.md,package.json,tsconfig.json}' 'examples/tsconfig.json' '{README.md,package.json}'", + "fmt:check": "prettier --check 'src/**/*.ts' 'driver/**/src/**/*.ts' 'migrate/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts' 'examples/*/{README.md,package.json,tsconfig.json}' 'examples/tsconfig.json' '{README.md,package.json}'", + "generate:migrations": "node scripts/sync-migrations.mjs", + "lint": "eslint 'src/**/*.ts' 'driver/**/src/**/*.ts' 'migrate/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts'", + "lint:fix": "eslint --fix 'src/**/*.ts' 'driver/**/src/**/*.ts' 'migrate/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts'", "prepublishOnly": "pnpm run clean && pnpm run build", "test": "vitest run --passWithNoTests", "test:coverage": "vitest run --coverage", "test:integration": "vitest run --passWithNoTests --config vitest.integration.config.ts", - "typecheck:tests": "node node_modules/typescript/bin/tsc -p tsconfig.tests.json" + "typecheck:tests": "node node_modules/typescript/bin/tsc -p tsconfig.tests.json", + "verify:migrations": "node scripts/sync-migrations.mjs --check" }, "repository": { "type": "git", diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml index dff60e088..f12148d81 100644 --- a/js/pnpm-lock.yaml +++ b/js/pnpm-lock.yaml @@ -152,6 +152,21 @@ importers: specifier: ^6.0.3 version: 6.0.3 + migrate: + devDependencies: + '@riverqueue/driver-pg': + specifier: workspace:0.50.0-alpha.1 + version: link:../driver/pg + '@types/node': + specifier: ^26.1.1 + version: 26.1.1 + pg: + specifier: ^8.22.0 + version: 8.22.0 + riverqueue: + specifier: workspace:0.50.0-alpha.1 + version: link:.. + packages: '@babel/helper-string-parser@7.29.7': diff --git a/js/pnpm-workspace.yaml b/js/pnpm-workspace.yaml index 378da482f..cedd4d5e8 100644 --- a/js/pnpm-workspace.yaml +++ b/js/pnpm-workspace.yaml @@ -1,6 +1,7 @@ packages: - "driver/*" - "examples/*" + - "migrate" ignoredBuiltDependencies: - "@prisma/client" diff --git a/js/scripts/sync-migrations.mjs b/js/scripts/sync-migrations.mjs new file mode 100644 index 000000000..7ebf845f8 --- /dev/null +++ b/js/scripts/sync-migrations.mjs @@ -0,0 +1,91 @@ +// Mirrors River's canonical PostgreSQL and SQLite migrations, which live in +// the Go drivers of the River repository this workspace is part of, into +// `migrate/migrations` with a manifest of SHA-256 digests. `--check` fails +// instead of writing when the mirror or its manifest differs from River's +// sources. +import { createHash } from "node:crypto"; +import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import process from "node:process"; +import { fileURLToPath, URL } from "node:url"; + +const repositoryRoot = resolve(fileURLToPath(new URL("..", import.meta.url))); +const riverRoot = resolve(repositoryRoot, ".."); +const targetRoot = resolve(repositoryRoot, "migrate/migrations"); +const check = process.argv.includes("--check"); +const sources = { + postgres: "riverdriver/riverpgxv5/migration/main", + sqlite: "riverdriver/riversqlite/migration/main", +}; + +const manifest = { + backends: {}, + format: 1, + sources, +}; + +for (const [backend, sourceRelative] of Object.entries(sources)) { + const sourceDirectory = resolve(riverRoot, sourceRelative); + const targetDirectory = resolve(targetRoot, backend, "main"); + const sourceFiles = (await readdir(sourceDirectory)) + .filter((file) => /^\d{3}_.+\.(?:up|down)\.sql$/.test(file)) + .sort(); + + if (sourceFiles.length === 0) { + throw new Error(`no canonical migrations found in ${sourceDirectory}`); + } + + const entries = []; + for (const file of sourceFiles) { + const contents = await readFile(resolve(sourceDirectory, file)); + entries.push({ + file, + sha256: createHash("sha256").update(contents).digest("hex"), + }); + + const target = resolve(targetDirectory, file); + if (check) { + let existing; + try { + existing = await readFile(target); + } catch (error) { + throw new Error(`generated migration is missing: ${target}`, { + cause: error, + }); + } + if (!existing.equals(contents)) { + throw new Error( + `generated migration differs from canonical source: ${target}` + ); + } + } else { + await mkdir(targetDirectory, { recursive: true }); + await writeFile(target, contents); + } + } + + for (const existing of await readdir(targetDirectory)) { + if (existing.endsWith(".sql") && !sourceFiles.includes(existing)) { + if (check) { + throw new Error( + `generated migration has no canonical source: ${resolve(targetDirectory, existing)}` + ); + } + await rm(resolve(targetDirectory, existing)); + } + } + + manifest.backends[backend] = entries; +} + +const encodedManifest = `${JSON.stringify(manifest, null, 2)}\n`; +const manifestPath = resolve(targetRoot, "manifest.json"); +if (check) { + const existingManifest = await readFile(manifestPath, "utf8"); + if (existingManifest !== encodedManifest) { + throw new Error(`generated migration manifest is stale: ${manifestPath}`); + } +} else { + await mkdir(targetRoot, { recursive: true }); + await writeFile(manifestPath, encodedManifest); +} diff --git a/js/tsconfig.tests.json b/js/tsconfig.tests.json index c21ab26ba..331b38eef 100644 --- a/js/tsconfig.tests.json +++ b/js/tsconfig.tests.json @@ -8,11 +8,15 @@ ], "@riverqueue/driver-prisma": [ "./driver/prisma/src/index.ts" + ], + "@riverqueue/migrate": [ + "./migrate/src/index.ts" ] } }, "include": [ "src", - "driver/*/src" + "driver/*/src", + "migrate/src" ] } From 38156a21176a3533d7a5bd5682391d3ee4244806 Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sun, 4 Oct 2026 17:21:56 -0500 Subject: [PATCH 28/43] test the PostgreSQL driver against a real database Unit tests cover `PgDriver`'s typing and surface and the parsing of every PostgreSQL value River reads itself, such as `bigint` IDs, microsecond timestamps, and arrays. Integration tests run each driver operation against PostgreSQL with River's migrations applied, in the default and a custom schema, including stale rescue snapshots, client stops, and clients without leader election. Further suites cover runtime resilience (row locks that outlast a statement timeout, transactional completion, `LISTEN` failures, a caller-owned pool's size), the pilot seam on real transactions, including caller transactions without savepoints and the untransformed arguments an insert interceptor sees, a multi-client stress test, and PostgreSQL-compatible servers without `xmax` or `LISTEN`/`NOTIFY`, simulated the way River Go's tests simulate YugabyteDB. --- js/driver/pg/src/driver.integration.test.ts | 3172 +++++++++++++++++ js/driver/pg/src/driver.test.ts | 1790 ++++++++++ js/driver/pg/src/pilot.integration.test.ts | 1857 ++++++++++ js/driver/pg/src/runtime.integration.test.ts | 415 +++ js/driver/pg/src/stress.integration.test.ts | 428 +++ js/driver/pg/src/yugabyte.integration.test.ts | 296 ++ 6 files changed, 7958 insertions(+) create mode 100644 js/driver/pg/src/driver.integration.test.ts create mode 100644 js/driver/pg/src/driver.test.ts create mode 100644 js/driver/pg/src/pilot.integration.test.ts create mode 100644 js/driver/pg/src/runtime.integration.test.ts create mode 100644 js/driver/pg/src/stress.integration.test.ts create mode 100644 js/driver/pg/src/yugabyte.integration.test.ts 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/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/runtime.integration.test.ts b/js/driver/pg/src/runtime.integration.test.ts new file mode 100644 index 000000000..3477486b8 --- /dev/null +++ b/js/driver/pg/src/runtime.integration.test.ts @@ -0,0 +1,415 @@ +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, type Logger, Workers } from "riverqueue"; +import { PgDriver } from "./driver.js"; + +const TEST_DATABASE_URL = + process.env.TEST_DATABASE_URL ?? + "postgres://localhost:5432/river_test?sslmode=disable"; +const filePrefix = `js_pgrt_${Math.random().toString(36).slice(2, 10)}`; +const migrationDirectory = fileURLToPath( + new URL("../../../migrate/migrations/postgres/main/", import.meta.url) +); + +interface LogEntry { + readonly attributes: Readonly> | undefined; + readonly level: string; + readonly message: string; +} + +function recordingLogger(entries: LogEntry[]): Logger { + const log = + (level: string) => + (attributes: Readonly>, message: string) => { + entries.push({ attributes, level, message }); + }; + return { + debug: log("debug"), + error: log("error"), + info: log("info"), + warn: log("warn"), + }; +} + +describe("PostgreSQL runtime resilience", () => { + let admin: pg.Pool; + + beforeAll(async () => { + admin = new pg.Pool({ connectionString: TEST_DATABASE_URL }); + await admin.query("SELECT 1"); + }); + + afterAll(async () => { + await admin.end(); + }); + + afterEach(async () => { + await admin.query("DELETE FROM river_job WHERE kind LIKE $1", [ + `${filePrefix}%`, + ]); + await admin.query("DELETE FROM river_queue WHERE name LIKE $1", [ + `${filePrefix}%`, + ]); + }); + + it("fails over within a second of a leader's resignation, like Go", async () => { + // A schema of its own, so no other test's client contends for leadership. + const schema = `${filePrefix}_failover`; + await admin.query(`CREATE SCHEMA "${schema}"`); + try { + for (const file of (await readdir(migrationDirectory)) + .filter((name) => name.endsWith(".up.sql")) + .sort()) { + const migration = await readFile( + join(migrationDirectory, file), + "utf8" + ); + await admin.query( + migration.replaceAll("/* TEMPLATE: schema */", `"${schema}".`) + ); + } + const job = defineJob({ kind: `${filePrefix}_failover` }); + const client = (clientId: string) => + new Client(new PgDriver(admin, { schema }), { + clientId, + queues: { [`${filePrefix}_failover`]: { maxWorkers: 1 } }, + workers: new Workers().add(job, () => undefined), + }); + const first = await client("first").start(); + const second = await client("second").start(); + try { + await waitFor(() => first.diagnostics.maintenance?.isLeader === true); + expect(second.diagnostics.maintenance?.isLeader).toBe(false); + + // With the default five second election interval, only the + // resignation notification explains a prompt failover. + const stopped = Date.now(); + await first.stop(); + await waitFor( + () => second.diagnostics.maintenance?.isLeader === true, + 1_000 + ); + expect(Date.now() - stopped).toBeLessThan(1_000); + } finally { + await first.stop(); + await second.stop(); + } + } finally { + await admin.query(`DROP SCHEMA "${schema}" CASCADE`); + } + }); + + it("rejects a non-ISO DateStyle with a configuration error", async () => { + const url = new URL(TEST_DATABASE_URL); + url.searchParams.set("options", "-c DateStyle=SQL,DMY"); + const pool = new pg.Pool({ connectionString: url.toString(), max: 1 }); + try { + const client = new Client(new PgDriver(pool)); + const job = defineJob({ kind: `${filePrefix}_date_style` }); + + await expect(client.insert(job, {})).rejects.toMatchObject({ + message: expect.stringContaining( + 'River needs PostgreSQL\'s DateStyle to be ISO, not "SQL, DMY"' + ), + name: "ConfigurationError", + }); + } finally { + await pool.end(); + } + }); + + it("fails to start without claiming when LISTEN fails, like Go", async () => { + const queue = `${filePrefix}_listen_fails`; + const job = defineJob({ kind: `${filePrefix}_listen_fails` }); + // A pooler or server that rejects LISTEN, as some proxies do. + const pool = new pg.Pool({ connectionString: TEST_DATABASE_URL }); + pool.on("connect", (connection) => { + const query = connection.query.bind(connection) as ( + ...args: unknown[] + ) => unknown; + Object.assign(connection, { + query: (...args: unknown[]) => + typeof args[0] === "string" && args[0].startsWith("LISTEN ") + ? Promise.reject( + Object.assign(new Error("LISTEN is not supported"), { + code: "0A000", + }) + ) + : query(...args), + }); + }); + try { + const client = new Client(new PgDriver(pool), { + leaderElectionDisabled: true, + queues: { + [queue]: { + fetchCooldown: { milliseconds: 1 }, + maxWorkers: 1, + pollInterval: { milliseconds: 5 }, + }, + }, + workers: new Workers().add(job, () => undefined), + }); + const inserted = await client.insert(job, {}, { queue }); + + await expect(client.start()).rejects.toThrow("LISTEN is not supported"); + expect(await jobState(admin, inserted.job.id)).toBe("available"); + } finally { + await pool.end(); + } + }); + + it("keeps a committed transactional completion after a handler error", async () => { + const queue = `${filePrefix}_tx_commit`; + const job = defineJob({ kind: `${filePrefix}_tx_commit` }); + const events: string[] = []; + let errorHandlerCalls = 0; + const client = new Client(new PgDriver(admin), { + completionBatchSize: 1, + errorHandler: () => { + errorHandlerCalls++; + }, + hooks: { + onEvent: ({ kind }) => { + events.push(kind); + }, + }, + leaderElectionDisabled: true, + queues: { + [queue]: { + fetchCooldown: { milliseconds: 1 }, + maxWorkers: 1, + pollInterval: { milliseconds: 5 }, + }, + }, + workers: new Workers().add(job, async ({ completeTx }) => { + const tx = await admin.connect(); + try { + await tx.query("BEGIN"); + await completeTx(tx, { output: { committed: true } }); + await tx.query("COMMIT"); + } finally { + tx.release(); + } + throw new Error("after commit"); + }), + }); + const inserted = await client.insert(job, {}, { queue }); + const run = await client.start(); + try { + await waitFor( + async () => (await jobState(admin, inserted.job.id)) === "completed" + ); + await waitFor(() => events.includes("job_completed")); + expect((await client.jobs.get(inserted.job.id))?.metadata.output).toEqual( + { + committed: true, + } + ); + expect(errorHandlerCalls).toBe(1); + expect(events.filter((kind) => kind === "job_completed")).toHaveLength(1); + expect(events).not.toContain("job_failed"); + expect(events).not.toContain("job_race"); + } finally { + await run.stop(); + } + }); + + it("falls back to normal completion after transactional rollback", async () => { + const queue = `${filePrefix}_tx_rollback`; + const job = defineJob({ kind: `${filePrefix}_tx_rollback` }); + const events: string[] = []; + const client = new Client(new PgDriver(admin), { + completionBatchSize: 1, + hooks: { + onEvent: ({ kind }) => { + events.push(kind); + }, + }, + leaderElectionDisabled: true, + queues: { + [queue]: { + fetchCooldown: { milliseconds: 1 }, + maxWorkers: 1, + pollInterval: { milliseconds: 5 }, + }, + }, + workers: new Workers().add(job, async ({ completeTx }) => { + const tx = await admin.connect(); + try { + await tx.query("BEGIN"); + await completeTx(tx, { output: { rolledBack: true } }); + await tx.query("ROLLBACK"); + } finally { + tx.release(); + } + }), + }); + const inserted = await client.insert(job, {}, { queue }); + const run = await client.start(); + try { + await waitFor( + async () => (await jobState(admin, inserted.job.id)) === "completed" + ); + await waitFor(() => events.includes("job_completed")); + expect( + (await client.jobs.get(inserted.job.id))?.metadata + ).not.toHaveProperty("output"); + expect(events.filter((kind) => kind === "job_completed")).toHaveLength(1); + expect(events).not.toContain("job_race"); + } finally { + await run.stop(); + } + }); + + it("bounds concurrent work to the caller-owned PostgreSQL pool", async () => { + const applicationName = `${filePrefix}_pool`; + const pool = new pg.Pool({ + application_name: applicationName, + connectionString: TEST_DATABASE_URL, + max: 4, + }); + const queue = `${filePrefix}_pool`; + const job = defineJob({ kind: `${filePrefix}_pool` }); + const client = new Client(new PgDriver(pool), { + completionBatchSize: 1, + leaderElectionDisabled: true, + queues: { + [queue]: { + fetchCooldown: { milliseconds: 1 }, + maxWorkers: 4, + pollInterval: { milliseconds: 5 }, + }, + }, + workers: new Workers().add(job, async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }), + }); + const run = await client.start(); + try { + await Promise.all( + Array.from({ length: 20 }, () => client.insert(job, {}, { queue })) + ); + let maximumConnections = 0; + await waitFor(async () => { + const connections = await admin.query<{ count: string }>( + "SELECT count(*)::text AS count FROM pg_stat_activity " + + "WHERE application_name = $1", + [applicationName] + ); + const count = Number(connections.rows[0]?.count); + maximumConnections = Math.max(maximumConnections, count); + expect(count).toBeLessThanOrEqual(4); + const completed = await admin.query<{ count: string }>( + "SELECT count(*)::text AS count FROM river_job WHERE kind = $1 AND state = 'completed'", + [job.kind] + ); + return Number(completed.rows[0]?.count) === 20; + }, 5_000); + expect(maximumConnections).toBeGreaterThan(0); + } finally { + await run.stop(); + await pool.end(); + } + }); + + it("keeps working when a row lock outlasts the statement timeout", async () => { + // A DBA guardrail such as `ALTER ROLE ... SET statement_timeout` plus a + // row lock held by another session (an admin transaction, a UI, or + // another engine's update) makes the first completion attempt fail with + // SQLSTATE 57014. The runtime must retry instead of failing. + const pool = new pg.Pool({ + connectionString: TEST_DATABASE_URL, + options: "-c statement_timeout=200", + }); + const queue = `${filePrefix}_lock`; + const job = defineJob({ kind: `${filePrefix}_lock` }); + const started: bigint[] = []; + const logs: LogEntry[] = []; + const client = new Client(new PgDriver(pool), { + completionFlushInterval: { milliseconds: 1 }, + logger: recordingLogger(logs), + leaderElectionDisabled: true, + queues: { + [queue]: { + fetchCooldown: { milliseconds: 10 }, + maxWorkers: 2, + pollInterval: { milliseconds: 50 }, + }, + }, + workers: new Workers().add(job, async ({ job: row }) => { + started.push(row.id); + await new Promise((resolve) => setTimeout(resolve, 100)); + }), + }); + const run = await client.start(); + try { + const first = await client.insert(job, {}, { queue }); + await waitFor(() => started.includes(first.job.id)); + + const locker = await admin.connect(); + try { + await locker.query("BEGIN"); + await locker.query( + "SELECT id FROM river_job WHERE id = $1 FOR UPDATE", + [first.job.id.toString(10)] + ); + // Hold the lock past the handler and the statement timeout. + await waitFor(() => + logs.some(({ attributes }) => + String(attributes?.error).includes("57014") + ) + ); + } finally { + await locker.query("ROLLBACK"); + locker.release(); + } + + await waitFor( + async () => (await jobState(admin, first.job.id)) === "completed", + 5_000 + ); + expect(run.state).toBe("running"); + + const second = await client.insert(job, {}, { queue }); + await waitFor( + async () => (await jobState(admin, second.job.id)) === "completed", + 5_000 + ); + expect(logs).toContainEqual( + expect.objectContaining({ + attributes: expect.objectContaining({ attempt: 1, retryable: true }), + level: "warn", + message: "River completion persistence attempt failed", + }) + ); + } finally { + await run.stop({ timeout: { milliseconds: 5_000 } }); + await pool.end(); + } + expect(run.state).toBe("stopped"); + }); +}); + +async function jobState(pool: pg.Pool, id: bigint): Promise { + const result = await pool.query<{ state: string }>( + "SELECT state::text AS state FROM river_job WHERE id = $1", + [id.toString(10)] + ); + return result.rows[0]?.state ?? "missing"; +} + +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/stress.integration.test.ts b/js/driver/pg/src/stress.integration.test.ts new file mode 100644 index 000000000..2b3f2a917 --- /dev/null +++ b/js/driver/pg/src/stress.integration.test.ts @@ -0,0 +1,428 @@ +import { setTimeout as delay } from "node:timers/promises"; + +import pg from "pg"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { Client, defineJob, Workers } from "riverqueue"; +import type { JobState, RiverEvent, RunHandle } from "riverqueue"; + +import { PgDriver } from "./driver.js"; + +// Bounded adversarial loops over several clients sharing one database. +// Scale them for a soak run, for example: +// +// RIVER_STRESS_ITERATIONS=200 RIVER_STRESS_SEED=7 pnpm run test:integration \ +// driver/pg/src/stress.integration.test.ts +const TEST_DATABASE_URL = + process.env.TEST_DATABASE_URL ?? + "postgres://localhost:5432/river_test?sslmode=disable"; +const ITERATIONS = positiveInteger("RIVER_STRESS_ITERATIONS", 10); +const SEED = positiveInteger("RIVER_STRESS_SEED", 1); +// Generous per-test budget that grows with the iteration count. +const TEST_TIMEOUT_MS = 20_000 + ITERATIONS * 3_000; +const CLIENTS = 3; +const JOBS_PER_ITERATION = 120; +const TERMINAL_STATES: readonly JobState[] = [ + "cancelled", + "completed", + "discarded", +]; +const filePrefix = `js_stress_${Math.random().toString(36).slice(2, 10)}`; + +function positiveInteger(name: string, fallback: number): number { + const value = process.env[name]; + if (value === undefined || value === "") return fallback; + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 1) { + throw new Error(`${name} must be a positive integer`); + } + return parsed; +} + +/** Small deterministic PRNG (mulberry32) so a failing seed replays. */ +function random(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = (state + 0x6d2b79f5) >>> 0; + let value = state; + value = Math.imul(value ^ (value >>> 15), value | 1); + value ^= value + Math.imul(value ^ (value >>> 7), value | 61); + return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296; + }; +} + +interface Fleet { + readonly clients: Client[]; + readonly events: RiverEvent[]; + /** Gracefully stop one client and start a fresh one in its place. */ + readonly restart: (index: number) => Promise; + readonly stop: () => Promise; +} + +/** Start competing clients that each record every terminal job event. */ +async function startFleet( + queue: string, + workers: Workers +): Promise { + const clients: Client[] = []; + const consumers: Promise[] = []; + const events: RiverEvent[] = []; + const pools: pg.Pool[] = []; + const runs: RunHandle[] = []; + const subscriptions: { close(): void }[] = []; + let generation = 0; + + const startMember = async (index: number, pool: pg.Pool) => { + const client = new Client(new PgDriver(pool), { + clientId: `${filePrefix}_${index}_${generation++}`, + completionFlushInterval: { milliseconds: 1 }, + leaderElectionDisabled: true, + queues: { + [queue]: { + fetchCooldown: { milliseconds: 1 }, + maxWorkers: 8, + pollInterval: { milliseconds: 20 }, + }, + }, + workers, + }); + const subscription = client.subscribe({ + capacity: 100_000, + kinds: ["job_cancelled", "job_completed", "job_failed"], + }); + subscriptions.push(subscription); + consumers.push( + (async () => { + for await (const event of subscription) events.push(event); + })() + ); + clients[index] = client; + runs[index] = await client.start(); + }; + + for (let index = 0; index < CLIENTS; index++) { + const pool = new pg.Pool({ connectionString: TEST_DATABASE_URL, max: 6 }); + pools.push(pool); + await startMember(index, pool); + } + return { + clients, + events, + restart: async (index) => { + const pool = pools[index]; + if (pool === undefined) throw new Error(`no client ${index}`); + await runs[index]?.stop({ mode: "graceful", timeout: { seconds: 5 } }); + await startMember(index, pool); + }, + stop: async () => { + await Promise.all( + runs.map((run) => run.stop({ timeout: { seconds: 5 } })) + ); + for (const subscription of subscriptions) subscription.close(); + await Promise.all(consumers); + await Promise.all(pools.map((pool) => pool.end())); + }, + }; +} + +describe("PostgreSQL multi-client stress", () => { + let admin: pg.Pool; + + beforeAll(async () => { + admin = new pg.Pool({ connectionString: TEST_DATABASE_URL }); + await admin.query("SELECT 1"); + }); + + afterAll(async () => { + await admin.query("DELETE FROM river_job WHERE kind LIKE $1", [ + `${filePrefix}%`, + ]); + await admin.query("DELETE FROM river_queue WHERE name LIKE $1", [ + `${filePrefix}%`, + ]); + await admin.end(); + }); + + it( + "works every job exactly once while clients compete, insert, and restart", + async () => { + const queue = `${filePrefix}_complete`; + const job = defineJob({ kind: `${filePrefix}_complete` }); + const invocations = new Map(); + const workers = new Workers().add(job, async ({ job }) => { + invocations.set(job.id, (invocations.get(job.id) ?? 0) + 1); + await delay(job.id % 3n === 0n ? 1 : 0); + }); + const fleet = await startFleet(queue, workers); + const next = random(SEED); + try { + for (let iteration = 0; iteration < ITERATIONS; iteration++) { + const context = `iteration ${iteration}, seed ${SEED}`; + fleet.events.length = 0; + invocations.clear(); + + // Insert from every client concurrently, in uneven batches, while + // all of them are fetching. + const inserts: Promise[] = []; + for (let remaining = JOBS_PER_ITERATION; remaining > 0;) { + const size = Math.min(remaining, 1 + Math.floor(next() * 30)); + remaining -= size; + const client = fleet.clients[Math.floor(next() * CLIENTS)]; + if (client === undefined) throw new Error("missing client"); + inserts.push( + client + .insertMany( + Array.from({ length: size }, () => ({ + args: {}, + job, + options: { queue }, + })) + ) + .then((results) => results.map((result) => result.job.id)) + ); + } + // Meanwhile, gracefully replace one client while it is working. + const restart = delay(Math.floor(next() * 20)).then(() => + fleet.restart(Math.floor(next() * CLIENTS)) + ); + const ids = (await Promise.all(inserts)).flat(); + await restart; + expect(new Set(ids).size, context).toBe(JOBS_PER_ITERATION); + + await waitFor( + async () => + (await nonTerminalCount(admin, job.kind)) === 0 && + fleet.events.length >= JOBS_PER_ITERATION, + context + ); + // Let any late duplicate event arrive before asserting. + await delay(50); + + const rows = await jobRows(admin, job.kind); + expect(rows.size, context).toBe(JOBS_PER_ITERATION); + for (const id of ids) { + const row = rows.get(id); + expect(row, `${context}: job ${id}`).toMatchObject({ + attempt: 1, + attemptedBy: 1, + state: "completed", + }); + expect(invocations.get(id), `${context}: job ${id}`).toBe(1); + } + const terminal = terminalEventsById(fleet.events); + for (const id of ids) { + expect(terminal.get(id), `${context}: job ${id} events`).toEqual([ + "job_completed", + ]); + } + expect(terminal.size, context).toBe(JOBS_PER_ITERATION); + + await admin.query("DELETE FROM river_job WHERE kind = $1", [ + job.kind, + ]); + } + } finally { + await fleet.stop(); + } + }, + TEST_TIMEOUT_MS + ); + + it( + "settles insert, work, and cancellation races consistently", + async () => { + const queue = `${filePrefix}_cancel`; + const job = defineJob({ kind: `${filePrefix}_cancel` }); + const invocations = new Map(); + const workDurations = new Map(); + const workers = new Workers().add( + job, + async ({ job, signal }) => { + invocations.set(job.id, (invocations.get(job.id) ?? 0) + 1); + await delay(workDurations.get(job.id) ?? 0, undefined, { signal }); + } + ); + const fleet = await startFleet(queue, workers); + const next = random(SEED + 1); + try { + for (let iteration = 0; iteration < ITERATIONS; iteration++) { + const context = `iteration ${iteration}, seed ${SEED}`; + fleet.events.length = 0; + invocations.clear(); + workDurations.clear(); + + const cancelled = new Map(); + const cancellations: Promise[] = []; + const inserts: Promise[] = []; + for (let remaining = JOBS_PER_ITERATION; remaining > 0;) { + const size = Math.min(remaining, 1 + Math.floor(next() * 20)); + remaining -= size; + const client = fleet.clients[Math.floor(next() * CLIENTS)]; + const canceller = fleet.clients[Math.floor(next() * CLIENTS)]; + if (client === undefined || canceller === undefined) { + throw new Error("missing client"); + } + // Decide each job's work time and cancellation delay up front so + // the seed alone determines the schedule. + const plan = Array.from({ length: size }, () => ({ + cancelAfterMs: next() < 0.5 ? Math.floor(next() * 40) : null, + workMs: Math.floor(next() * 30), + })); + inserts.push( + client + .insertMany( + plan.map(() => ({ args: {}, job, options: { queue } })) + ) + .then((results) => { + results.forEach((result, index) => { + const { cancelAfterMs, workMs } = plan[index] ?? {}; + workDurations.set(result.job.id, workMs ?? 0); + if (cancelAfterMs === null || cancelAfterMs === undefined) { + return; + } + cancellations.push( + delay(cancelAfterMs).then(async () => { + const row = await canceller.jobs.cancel(result.job.id); + cancelled.set(result.job.id, row?.state ?? null); + }) + ); + }); + }) + ); + } + await Promise.all(inserts); + await Promise.all(cancellations); + + await waitFor( + async () => (await nonTerminalCount(admin, job.kind)) === 0, + context + ); + const rows = await jobRows(admin, job.kind); + expect(rows.size, context).toBe(JOBS_PER_ITERATION); + const worked = [...rows.keys()].filter((id) => invocations.has(id)); + await waitFor( + () => terminalEventsById(fleet.events).size >= worked.length, + context + ); + // Let any late duplicate event arrive before asserting. + await delay(50); + + const terminal = terminalEventsById(fleet.events); + for (const [id, row] of rows) { + const where = `${context}: job ${id}`; + expect(["cancelled", "completed"], where).toContain(row.state); + expect(row.finalized, where).toBe(true); + const attempts = invocations.get(id) ?? 0; + // A terminal outcome is never retried or worked twice. + expect(attempts, where).toBeLessThanOrEqual(1); + expect(row.attempt, where).toBe(attempts); + if (!cancelled.has(id)) { + expect(row.state, where).toBe("completed"); + } + if (attempts === 0) { + // Cancelled before any client claimed it. + expect(row.state, where).toBe("cancelled"); + expect(terminal.get(id), where).toBeUndefined(); + } else { + // Exactly one terminal observation, agreeing with the row. + expect(terminal.get(id), where).toEqual([ + row.state === "completed" ? "job_completed" : "job_cancelled", + ]); + } + // A cancel that found the job already completed leaves it so. + if (cancelled.get(id) === "completed") { + expect(row.state, where).toBe("completed"); + } + } + expect( + fleet.events.filter((event) => event.kind === "job_failed"), + context + ).toEqual([]); + + await admin.query("DELETE FROM river_job WHERE kind = $1", [ + job.kind, + ]); + } + } finally { + await fleet.stop(); + } + }, + TEST_TIMEOUT_MS + ); +}); + +interface JobRowSummary { + readonly attempt: number; + readonly attemptedBy: number; + readonly finalized: boolean; + readonly state: JobState; +} + +async function jobRows( + pool: pg.Pool, + kind: string +): Promise> { + const result = await pool.query<{ + attempt: number; + attempted_by: number; + finalized: boolean; + id: string; + state: JobState; + }>( + `SELECT id::text AS id, state::text AS state, attempt, + coalesce(array_length(attempted_by, 1), 0) AS attempted_by, + finalized_at IS NOT NULL AS finalized + FROM river_job WHERE kind = $1`, + [kind] + ); + return new Map( + result.rows.map((row) => [ + BigInt(row.id), + { + attempt: row.attempt, + attemptedBy: row.attempted_by, + finalized: row.finalized, + state: row.state, + }, + ]) + ); +} + +async function nonTerminalCount(pool: pg.Pool, kind: string): Promise { + const result = await pool.query<{ count: string }>( + "SELECT count(*)::text AS count FROM river_job WHERE kind = $1 AND NOT (state::text = ANY($2))", + [kind, TERMINAL_STATES] + ); + return Number(result.rows[0]?.count ?? "0"); +} + +/** Terminal job event kinds observed for each job, across every client. */ +function terminalEventsById( + events: readonly RiverEvent[] +): Map { + const byId = new Map(); + for (const event of events) { + if ( + event.kind !== "job_cancelled" && + event.kind !== "job_completed" && + event.kind !== "job_failed" + ) { + continue; + } + byId.set(event.job.id, [...(byId.get(event.job.id) ?? []), event.kind]); + } + return byId; +} + +async function waitFor( + predicate: () => boolean | Promise, + context: string, + timeoutMs = 15_000 +): Promise { + const deadline = Date.now() + timeoutMs; + while (!(await predicate())) { + if (Date.now() > deadline) { + throw new Error(`condition was not reached (${context})`); + } + await delay(10); + } +} diff --git a/js/driver/pg/src/yugabyte.integration.test.ts b/js/driver/pg/src/yugabyte.integration.test.ts new file mode 100644 index 000000000..ad1edc9c2 --- /dev/null +++ b/js/driver/pg/src/yugabyte.integration.test.ts @@ -0,0 +1,296 @@ +/** + * PostgreSQL-compatible servers without `xmax` or `LISTEN`/`NOTIFY`, like + * YugabyteDB, simulated on PostgreSQL the way River for Go's tests do. + * + * A test schema shadows `version()` and `current_setting(text, boolean)` + * ahead of `pg_catalog` on the connections' `search_path`, so River detects + * a Yugabyte version and notification setting. When notifications are off it + * also shadows `pg_notify` with a function that raises, so any notification + * fails the statement that sends it. River's tables live in that schema as + * the connections' current schema. This exercises detection and River's + * fallbacks, not Yugabyte's storage or transaction semantics. + */ +import { readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import pg from "pg"; +import { Client, defineJob, type Logger, Workers } from "riverqueue"; +import { PgDriver, testPgDriver } from "./driver.js"; + +const TEST_DATABASE_URL = + process.env.TEST_DATABASE_URL ?? + "postgres://localhost:5432/river_test?sslmode=disable"; +const migrationDirectory = fileURLToPath( + new URL("../../../migrate/migrations/postgres/main/", import.meta.url) +); + +/** Which server a test schema simulates. */ +type Server = + /** PostgreSQL 17, before `RETURNING OLD`. */ + | "postgres17" + /** YugabyteDB before 2025.2.3, without `yb_enable_listen_notify`. */ + | "yugabyte_unavailable" + /** YugabyteDB with `yb_enable_listen_notify` off. */ + | "yugabyte_disabled" + /** YugabyteDB with `yb_enable_listen_notify` on. */ + | "yugabyte_enabled"; + +const SERVERS: readonly Server[] = [ + "postgres17", + "yugabyte_unavailable", + "yugabyte_disabled", + "yugabyte_enabled", +]; + +function listenNotify(server: Server): boolean { + return server === "postgres17" || server === "yugabyte_enabled"; +} + +describe("PostgreSQL servers like YugabyteDB, simulated", () => { + const cleanups: (() => Promise)[] = []; + + afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); + }); + + /** + * Create a schema simulating `server` with River migrated into it, and a + * pool whose connections search it ahead of `pg_catalog`. + */ + async function simulate(server: Server): Promise<{ + admin: pg.Pool; + pool: pg.Pool; + schema: string; + }> { + const schema = `js_yb_${Math.random().toString(36).slice(2, 10)}`; + const quoted = `"${schema}"`; + const admin = new pg.Pool({ connectionString: TEST_DATABASE_URL, max: 2 }); + cleanups.push(async () => { + await admin.query(`DROP SCHEMA IF EXISTS ${quoted} CASCADE`); + await admin.end(); + }); + await admin.query(`CREATE SCHEMA ${quoted}`); + if (server === "postgres17") { + await admin.query(` + CREATE FUNCTION ${quoted}.current_setting(setting_name text) + RETURNS text LANGUAGE sql AS $$ + SELECT CASE WHEN setting_name = 'server_version_num' THEN '170004' + ELSE pg_catalog.current_setting(setting_name) END + $$ + `); + } else { + const [version, setting] = + server === "yugabyte_unavailable" + ? ["2025.2.1.0", "NULL::text"] + : server === "yugabyte_disabled" + ? ["2025.2.3.0", "'off'::text"] + : ["2025.2.3.0", "'on'::text"]; + await admin.query(` + CREATE FUNCTION ${quoted}.version() RETURNS text LANGUAGE sql AS $$ + SELECT 'PostgreSQL 15.12-YB-${version}-b1'::text + $$; + CREATE FUNCTION ${quoted}.current_setting( + setting_name text, missing_ok boolean + ) RETURNS text LANGUAGE sql AS $$ + SELECT CASE WHEN setting_name = 'yb_enable_listen_notify' + THEN ${setting} + ELSE pg_catalog.current_setting(setting_name, missing_ok) END + $$ + `); + } + if (!listenNotify(server)) { + await admin.query(` + CREATE FUNCTION ${quoted}.pg_notify(text, text) RETURNS void + LANGUAGE plpgsql AS $$ + BEGIN RAISE EXCEPTION 'LISTEN/NOTIFY is unavailable'; END + $$ + `); + } + for (const file of (await readdir(migrationDirectory)) + .filter((name) => name.endsWith(".up.sql")) + .sort()) { + const migration = await readFile(join(migrationDirectory, file), "utf8"); + await admin.query( + migration.replaceAll("/* TEMPLATE: schema */", `${quoted}.`) + ); + } + + const url = new URL(TEST_DATABASE_URL); + url.searchParams.set("options", `-c search_path=${schema},pg_catalog`); + const pool = new pg.Pool({ connectionString: url.toString(), max: 4 }); + cleanups.push(() => pool.end()); + return { admin, pool, schema }; + } + + it.each(SERVERS)( + "detects whether %s delivers notifications and inserts unique jobs", + async (server) => { + const { admin, pool, schema } = await simulate(server); + const driver = testPgDriver(pool); + await expect(driver.runtimeDeliversNotifications()).resolves.toBe( + listenNotify(server) + ); + + const client = new Client(new PgDriver(pool)); + const job = defineJob<{ value: number }>()({ kind: "yugabyte_unique" }); + const first = await client.insert( + job, + { value: 1 }, + { unique: { byArgs: true } } + ); + const second = await client.insert( + job, + { value: 1 }, + { unique: { byArgs: true } } + ); + const other = await client.insert(job, { value: 2 }); + + expect(first.status).toBe("inserted"); + expect(second.status).toBe("duplicate"); + expect(second.job.id).toBe(first.job.id); + expect(other.status).toBe("inserted"); + // Without `xmax`, a row carries a nonce like SQLite's. + const nonces = await admin.query<{ has_nonce: boolean }>( + `SELECT metadata ? 'river:unique_nonce' AS has_nonce + FROM "${schema}".river_job ORDER BY id` + ); + expect(nonces.rows.map(({ has_nonce }) => has_nonce)).toEqual( + server === "postgres17" ? [false, false] : [true, true] + ); + } + ); + + it("sends no notification when the server has no LISTEN/NOTIFY", async () => { + const { pool } = await simulate("yugabyte_unavailable"); + const driver = testPgDriver(pool); + const client = new Client(new PgDriver(pool)); + const job = defineJob({ kind: "yugabyte_quiet" }); + + // Each of these notifies on PostgreSQL; the shadowed pg_notify raises. + const { job: inserted } = await client.insert(job, {}); + await expect(client.jobs.cancel(inserted.id)).resolves.toMatchObject({ + state: "cancelled", + }); + await driver.runtimeQueueUpsert("quiet", Temporal.Now.instant()); + await client.queues.pause("quiet"); + await client.queues.resume("*"); + await client.queues.update("quiet", { metadata: { owner: "workers" } }); + await client.requestLeadershipResignation(); + const leader = await driver.maintenanceLeaderAcquire( + "yugabyte_leader", + Temporal.Now.instant(), + 30_000, + null + ); + expect(leader).not.toBeNull(); + await expect(driver.maintenanceLeaderResign(leader!)).resolves.toBe(true); + }); + + it("polls for a running job's cancellation without LISTEN/NOTIFY", async () => { + const { pool } = await simulate("yugabyte_unavailable"); + const job = defineJob({ kind: "yugabyte_cancel" }); + const logs: string[] = []; + let started!: () => void; + const running = new Promise((resolve) => { + started = resolve; + }); + const client = new Client(new PgDriver(pool), { + leaderElectionDisabled: true, + logger: recordingLogger(logs), + queues: { + default: { + fetchCooldown: { milliseconds: 10 }, + maxWorkers: 1, + pollInterval: { milliseconds: 50 }, + }, + }, + workers: new Workers().add(job, ({ signal }) => { + started(); + return new Promise((_, reject) => { + signal.addEventListener("abort", () => reject(signal.reason), { + once: true, + }); + }); + }), + }); + const run = await client.start(); + try { + const { job: inserted } = await client.insert(job, {}); + await running; + // Cancel through another client, so only polling can tell this one. + const cancelledAt = Date.now(); + await new Client(new PgDriver(pool)).jobs.cancel(inserted.id); + await waitFor( + async () => (await client.jobs.get(inserted.id))?.state === "cancelled", + 6_000 + ); + expect(Date.now() - cancelledAt).toBeLessThan(6_000); + expect(logs).toContain( + "River's database does not support LISTEN/NOTIFY; polling instead" + ); + } finally { + await run.stop(); + } + // Schema setup, migrations, and up to six seconds of polling outlast + // vitest's default five second timeout on a loaded database. + }, 20_000); + + it("polls for a running job's cancellation when poll-only", async () => { + const { pool } = await simulate("postgres17"); + const job = defineJob({ kind: "poll_only_cancel" }); + let started!: () => void; + const running = new Promise((resolve) => { + started = resolve; + }); + const client = new Client(new PgDriver(pool), { + leaderElectionDisabled: true, + pollOnly: true, + queues: { + default: { + fetchCooldown: { milliseconds: 10 }, + maxWorkers: 1, + pollInterval: { milliseconds: 50 }, + }, + }, + workers: new Workers().add(job, ({ signal }) => { + started(); + return new Promise((_, reject) => { + signal.addEventListener("abort", () => reject(signal.reason), { + once: true, + }); + }); + }), + }); + const run = await client.start(); + try { + const { job: inserted } = await client.insert(job, {}); + await running; + await new Client(new PgDriver(pool)).jobs.cancel(inserted.id); + await waitFor( + async () => (await client.jobs.get(inserted.id))?.state === "cancelled", + 6_000 + ); + } finally { + await run.stop(); + } + }, 20_000); +}); + +function recordingLogger(messages: string[]): Logger { + const log = (_attributes: unknown, message: string) => { + messages.push(message); + }; + return { debug: log, error: log, info: log, warn: log }; +} + +async function waitFor( + predicate: () => boolean | Promise, + timeoutMs: number +): 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, 20)); + } +} From f890da4be6b3869ea42083e12dff45f64d44dcb0 Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sun, 4 Oct 2026 17:22:24 -0500 Subject: [PATCH 29/43] test the Prisma driver with mocked and real clients Unit tests cover the insert-only typing, exact IDs and timestamp precision, the complete insert contract, unique conflicts, YugabyteDB nonces without notifications, and inserts without a caller transaction, which run in a Prisma interactive transaction with the driver's transaction options. Integration tests insert on a real PostgreSQL database through a node-postgres stand-in for Prisma's raw query API, including unique skips that keep the existing job's kind and notifications on commit, and through a generated Prisma client with `@prisma/adapter-pg` to check exact integers and Prisma's interactive transactions. --- js/.gitignore | 2 + .../prisma/src/driver.integration.test.ts | 258 ++++++++++++ js/driver/prisma/src/driver.test.ts | 396 ++++++++++++++++++ .../prisma/src/prisma.integration.test.ts | 213 ++++++++++ 4 files changed, 869 insertions(+) create mode 100644 js/driver/prisma/src/driver.integration.test.ts create mode 100644 js/driver/prisma/src/driver.test.ts create mode 100644 js/driver/prisma/src/prisma.integration.test.ts diff --git a/js/.gitignore b/js/.gitignore index 340900562..4a5cac04c 100644 --- a/js/.gitignore +++ b/js/.gitignore @@ -1,6 +1,8 @@ node_modules/ coverage/ dist/ +temp/ +*/temp/ examples/prisma/src/generated/prisma/ *.tsbuildinfo .DS_Store diff --git a/js/driver/prisma/src/driver.integration.test.ts b/js/driver/prisma/src/driver.integration.test.ts new file mode 100644 index 000000000..05bc0e699 --- /dev/null +++ b/js/driver/prisma/src/driver.integration.test.ts @@ -0,0 +1,258 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { Pool } from "pg"; +import type { PoolClient } from "pg"; +import { + Client, + defineJob, + exactJsonNumber, + type InsertClient, + isExactJsonNumber, + type JsonValue, +} from "riverqueue"; +import { PrismaDriver } from "./driver.js"; +import type { PrismaClientLike } from "./driver.js"; + +const TEST_DATABASE_URL = + process.env.TEST_DATABASE_URL ?? + "postgres://localhost:5432/river_test?sslmode=disable"; +const filePrefix = `prisma_${Math.random().toString(36).slice(2, 8)}`; + +class PgPrismaAdapter implements PrismaClientLike { + constructor(private readonly pool: Pool | PoolClient) {} + + async $queryRawUnsafe( + sql: string, + ...values: unknown[] + ): Promise { + const result = await this.pool.query(sql, values); + return result.rows as T; + } +} + +/** A root client whose `$transaction` stands in for Prisma's. */ +class PgPrismaRootAdapter extends PgPrismaAdapter { + constructor(private readonly rootPool: Pool) { + super(rootPool); + } + + async $transaction( + callback: (tx: PrismaClientLike) => Promise + ): Promise { + const client = await this.rootPool.connect(); + try { + await client.query("BEGIN"); + try { + const result = await callback(new PgPrismaAdapter(client)); + await client.query("COMMIT"); + return result; + } catch (error: unknown) { + await client.query("ROLLBACK"); + throw error; + } + } finally { + client.release(); + } + } +} + +function job(suffix: string) { + return defineJob<{ key?: string; n?: number }>()({ + kind: `${filePrefix}_${suffix}`, + }); +} + +describe("PrismaDriver integration", () => { + let pool: Pool; + let client: InsertClient; + + beforeAll(() => { + pool = new Pool({ connectionString: TEST_DATABASE_URL }); + client = new Client(new PrismaDriver(new PgPrismaRootAdapter(pool))); + }); + + afterAll(async () => { + await pool.end(); + }); + + afterEach(async () => { + await pool.query("DELETE FROM river_job WHERE kind LIKE $1", [ + `${filePrefix}%`, + ]); + }); + + it("inserts and decodes an exact job row", async () => { + const definition = job("basic"); + + const result = await client.insert(definition, { key: "value" }); + + expect(result.job.id).toBeGreaterThan(0n); + expect(result.job.kind).toBe(definition.kind); + expect(result.job.args).toEqual({ key: "value" }); + expect(result.job.createdAt).toBeInstanceOf(Temporal.Instant); + expect(result.job.scheduledAt).toBeInstanceOf(Temporal.Instant); + expect(result.status).toBe("inserted"); + }); + + it("inserts with all options", async () => { + const definition = job("opts"); + const future = Temporal.Now.instant() + .add({ hours: 1 }) + .round({ roundingMode: "trunc", smallestUnit: "microsecond" }); + + const result = await client.insert( + definition, + { n: 42 }, + { + maxAttempts: 5, + metadata: { source: "integration" }, + priority: 3, + queue: "high_priority", + scheduledAt: future, + tags: ["tag_one", "tag_two"], + } + ); + + expect(result.job.maxAttempts).toBe(5); + expect(result.job.metadata).toEqual({ source: "integration" }); + expect(result.job.priority).toBe(3); + expect(result.job.queue).toBe("high_priority"); + expect(result.job.scheduledAt.toString()).toBe(future.toString()); + expect(result.job.state).toBe("scheduled"); + }); + + it("preserves order for heterogeneous batches", async () => { + const a = job("batch_a"); + const b = job("batch_b"); + + const results = await client.insertMany([ + { args: { n: 1 }, job: a }, + { args: { n: 2 }, job: b }, + ]); + + expect(results.map((result) => result.job.kind)).toEqual([a.kind, b.kind]); + expect(new Set(results.map((result) => result.job.id)).size).toBe(2); + }); + + it("returns the existing row for a unique conflict", async () => { + const definition = job("unique"); + const unique = { byArgs: true as const, byQueue: true as const }; + + const first = await client.insert(definition, { key: "same" }, { unique }); + const second = await client.insert(definition, { key: "same" }, { unique }); + + expect(first.status).toBe("inserted"); + expect(second.status).toBe("duplicate"); + expect(second.job.id).toBe(first.job.id); + }); + + it("keeps the existing job's kind on a unique skip of another kind", async () => { + const a = job("unique_kind_a"); + const b = job("unique_kind_b"); + const options = { unique: { byArgs: true, excludeKind: true } } as const; + + const first = await client.insert(a, { key: "same" }, options); + const single = await client.insert(b, { key: "same" }, options); + const [batched] = await client.insertMany([ + { args: { key: "same" }, job: b, 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(a.kind); + } + const stored = await pool.query<{ kind: string }>( + "SELECT kind FROM river_job WHERE kind = ANY($1)", + [[a.kind, b.kind]] + ); + expect(stored.rows).toEqual([{ kind: a.kind }]); + }); + + it("uses the exact caller-owned transaction", async () => { + const definition = job("tx"); + const poolClient = await pool.connect(); + try { + await poolClient.query("BEGIN"); + const tx = new PgPrismaAdapter(poolClient); + await client.insert(definition, {}, { tx }); + await poolClient.query("ROLLBACK"); + } finally { + poolClient.release(); + } + + const result = await pool.query( + "SELECT count(*)::int AS count FROM river_job WHERE kind = $1", + [definition.kind] + ); + expect(result.rows[0]?.count).toBe(0); + }); + + it("keeps integers beyond JavaScript's safe range exact", async () => { + const definition = defineJob<{ id: JsonValue }>()({ + kind: `${filePrefix}_exact`, + }); + + const result = await client.insert( + definition, + { id: exactJsonNumber("9007199254740993") }, + { metadata: { tenant: exactJsonNumber("9007199254740995") } } + ); + + expect(isExactJsonNumber(result.job.args.id)).toBe(true); + expect(JSON.stringify(result.job.args)).toBe('{"id":9007199254740993}'); + expect(JSON.stringify(result.job.metadata)).toBe( + '{"tenant":9007199254740995}' + ); + }); + + it("notifies producers of available jobs when the transaction commits", async () => { + const available = job("notify_available"); + const scheduled = job("notify_scheduled"); + const queue = `${filePrefix}_notify`; + const listener = await pool.connect(); + const payloads: string[] = []; + listener.on("notification", (message) => { + if (message.payload !== undefined) payloads.push(message.payload); + }); + const poolClient = await pool.connect(); + try { + const schema = await listener.query<{ schema: string }>( + "SELECT current_schema()::text AS schema" + ); + await listener.query(`LISTEN "${schema.rows[0]!.schema}.river_insert"`); + await poolClient.query("BEGIN"); + const tx = new PgPrismaAdapter(poolClient); + await client.insertMany( + [ + { + args: {}, + job: scheduled, + options: { + queue: `${queue}_later`, + scheduledAt: Temporal.Now.instant().add({ hours: 1 }), + }, + }, + { args: {}, job: available, options: { queue } }, + ], + { tx } + ); + // NOTIFY is transactional: nothing is delivered before commit. + await listener.query("SELECT 1"); + expect(payloads).toEqual([]); + await poolClient.query("COMMIT"); + + const deadline = Date.now() + 2_000; + while (payloads.length === 0 && Date.now() < deadline) { + await listener.query("SELECT 1"); + } + expect(payloads.map((payload) => JSON.parse(payload))).toEqual([ + { queue }, + ]); + } finally { + poolClient.release(); + await listener.query("UNLISTEN *"); + listener.release(); + } + }); +}); diff --git a/js/driver/prisma/src/driver.test.ts b/js/driver/prisma/src/driver.test.ts new file mode 100644 index 000000000..3039a9265 --- /dev/null +++ b/js/driver/prisma/src/driver.test.ts @@ -0,0 +1,396 @@ +import { beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest"; +import { + Client, + defineJob, + type InsertClient, + isExactJsonNumber, +} from "riverqueue"; +import type { JobInsertParams } from "riverqueue/unstable-driver"; +import { PrismaDriver, testPrismaDriver } from "./driver.js"; +import type { PrismaClientLike, PrismaInserter } from "./driver.js"; + +const CREATED_AT = "2024-06-01T00:00:00.123456Z"; + +function fakePrismaRow(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: "9007199254740993", + kind: "sort", + max_attempts: 25, + metadata: "{}", + priority: 1, + queue: "default", + scheduled_at: CREATED_AT, + state: "available", + tags: ["tag1", "tag2"], + unique_key: null, + unique_skipped_as_duplicate: false, + unique_states: null, + ...overrides, + }; +} + +function fakeInsertParams( + overrides: Partial = {} +): JobInsertParams { + const args = { strings: ["a", "b"] }; + return { + args, + encodedArgs: JSON.stringify(args), + kind: "sort", + maxAttempts: 25, + metadata: {}, + priority: 1, + queue: "default", + scheduledAt: Temporal.Instant.from("2024-06-01T00:00:00.123456Z"), + state: "available", + tags: [], + uniqueKey: null, + uniqueStates: null, + ...overrides, + }; +} + +/** + * A Prisma client that records each statement in `statements`, apart from + * the driver's one-time server detection, which it answers as `product`. + */ +function mockPrismaClient(product = "PostgreSQL 17.4") { + const mock = { + rowsToReturn: [] as Record[], + statements: vi.fn((query: string, ...values: unknown[]) => { + void query; + void values; + }), + $queryRawUnsafe: async (query: string, ...values: unknown[]) => { + if (query.includes("yb_listen_notify_enabled")) { + return [ + { + product, + version_num: 170_004, + yb_listen_notify_enabled: false, + }, + ]; + } + mock.statements(query, ...values); + return mock.rowsToReturn; + }, + }; + return mock as typeof mock & PrismaClientLike; +} + +describe("PrismaDriver", () => { + let prisma: ReturnType; + let driver: PrismaInserter; + + beforeEach(() => { + prisma = mockPrismaClient(); + driver = testPrismaDriver(prisma); + }); + + it("is typed as an insert-only client", () => { + const client = new Client(driver); + + expectTypeOf(client).toEqualTypeOf>(); + // @ts-expect-error -- Prisma cannot run workers. + expect(() => void client.start).not.toThrow(); + // @ts-expect-error -- Prisma cannot query jobs. + expect(() => void client.jobs).not.toThrow(); + }); + + it("keeps exact IDs and PostgreSQL timestamp precision", async () => { + prisma.rowsToReturn = [fakePrismaRow()]; + + const result = await driver.jobInsert(fakeInsertParams()); + + expect(result.job.id).toBe(9_007_199_254_740_993n); + expect(result.job.createdAt.toString()).toBe("2024-06-01T00:00:00.123456Z"); + expect(result.status).toBe("inserted"); + }); + + it("normalizes nullable collection columns", async () => { + prisma.rowsToReturn = [fakePrismaRow()]; + + const result = await driver.jobInsert(fakeInsertParams()); + + expect(result.job.attemptedBy).toEqual([]); + expect(result.job.errors).toEqual([]); + expect(result.job.uniqueStates).toBeNull(); + }); + + it("decodes optional fields and semantic unique states", async () => { + prisma.rowsToReturn = [ + fakePrismaRow({ + attempted_at: "2024-06-01T01:00:00.000001Z", + attempted_by: ["worker-1"], + errors: [ + JSON.stringify({ + at: "2024-06-01T01:00:00.000002Z", + attempt: 1, + error: "something broke", + trace: "trace", + }), + ], + finalized_at: "2024-06-01T02:00:00.000003Z", + unique_key: "cafe", + unique_states: "11110101", + }), + ]; + + const result = await driver.jobInsert(fakeInsertParams()); + + expect(result.job.attemptedAt?.toString()).toBe( + "2024-06-01T01:00:00.000001Z" + ); + expect(result.job.errors[0]?.at.toString()).toBe( + "2024-06-01T01:00:00.000002Z" + ); + expect(result.job.finalizedAt?.toString()).toBe( + "2024-06-01T02:00:00.000003Z" + ); + expect(result.job.uniqueKey).toEqual(Uint8Array.from([0xca, 0xfe])); + expect(result.job.uniqueStates).toEqual([ + "available", + "completed", + "pending", + "retryable", + "running", + "scheduled", + ]); + }); + + it("encodes the complete insert contract", async () => { + prisma.rowsToReturn = [fakePrismaRow()]; + + await driver.jobInsertMany([ + fakeInsertParams({ + metadata: { source: "test" }, + tags: ["urgent"], + uniqueKey: Uint8Array.from([1, 2, 3]), + uniqueStates: ["available", "running"], + }), + ]); + + const call = prisma.statements.mock.calls[0]; + const sql = call?.[0] as string; + const values = call?.slice(1) as unknown[]; + expect(sql).toContain('INSERT INTO "river_job"'); + expect(sql).toContain("ORDER BY prepared_job_data.input_order"); + expect(sql).toContain("FROM unnest("); + expect(values).toHaveLength(13); + expect(values[12]).toEqual([null]); + expect(values[3]).toEqual(['{"source":"test"}']); + expect(values[8]).toEqual(['["urgent"]']); + expect(values[9]).toEqual(["010203"]); + expect(values[10]).toEqual(["01000001"]); + }); + + it("decodes JSON beyond JavaScript's safe integers exactly", async () => { + prisma.rowsToReturn = [ + fakePrismaRow({ + args: '{"user_id": 9007199254740993}', + errors: [ + '{"at": "2024-06-01T01:00:00Z", "attempt": 1, "error": "x", "trace": "", "code": 9007199254740995}', + ], + metadata: '{"tenant": 9007199254740994}', + }), + ]; + + const { job } = await driver.jobInsert(fakeInsertParams()); + + // A lossy decode would round these, and rejecting them would throw + // after the insert already committed. + expect(isExactJsonNumber(job.args.user_id)).toBe(true); + expect(JSON.stringify(job.args)).toBe('{"user_id":9007199254740993}'); + expect(JSON.stringify(job.metadata)).toBe('{"tenant":9007199254740994}'); + expect(job.errors[0]?.error).toBe("x"); + }); + + it("notifies producers only when the client asks it to", async () => { + prisma.rowsToReturn = [fakePrismaRow()]; + + await driver.jobInsert(fakeInsertParams()); + await driver.notifyInsert(["default", "other"]); + await driver.notifyInsert([]); + + expect(prisma.statements).toHaveBeenCalledTimes(2); + const insertSql = prisma.statements.mock.calls[0]?.[0] as string; + expect(insertSql).not.toContain("pg_notify("); + const [notifySql, ...values] = prisma.statements.mock.calls[1]!; + expect(notifySql).toContain("pg_notify("); + expect(notifySql).toContain("'river_insert'"); + expect(values).toEqual([null, ["default", "other"]]); + }); + + it("marks unique insertions with nonces and sends no notifications on YugabyteDB", async () => { + prisma = mockPrismaClient("PostgreSQL 15.12-YB-2025.2.1.0-b1"); + driver = testPrismaDriver(prisma); + prisma.rowsToReturn = [ + fakePrismaRow(), + fakePrismaRow({ metadata: '{"river:unique_nonce": "0000000000000000"}' }), + ]; + + const results = await driver.jobInsertMany([ + fakeInsertParams(), + fakeInsertParams(), + ]); + await driver.notifyInsert(["default"]); + + expect(prisma.statements).toHaveBeenCalledOnce(); + const [sql, , , , metadata] = prisma.statements.mock.calls[0]!; + expect(sql).toContain("RETURNING *, false AS conflicted"); + const nonces = (metadata as string[]).map( + (text) => + (JSON.parse(text) as Record)["river:unique_nonce"] + ); + expect(nonces).toEqual([ + expect.stringMatching(/^[0-9a-f]{16}$/), + expect.stringMatching(/^[0-9a-f]{16}$/), + ]); + // Neither returned row carries its insertion's nonce: both existed. + expect(results.map(({ status }) => status)).toEqual([ + "duplicate", + "duplicate", + ]); + }); + + it("uses a constructor-owned portable schema", async () => { + prisma.rowsToReturn = [fakePrismaRow()]; + driver = testPrismaDriver(prisma, { schema: "custom_schema" }); + + await driver.jobInsert(fakeInsertParams()); + + const sql = prisma.statements.mock.calls[0]?.[0] as string; + expect(sql).toContain('"custom_schema"."river_job"'); + expect(sql).toContain('::"custom_schema"."river_job_state"'); + await driver.notifyInsert(["default"]); + expect(prisma.statements.mock.calls[1]?.[1]).toBe("custom_schema"); + }); + + it("uses the exact caller-owned transaction", async () => { + const tx = mockPrismaClient(); + tx.rowsToReturn = [fakePrismaRow()]; + + await driver.jobInsert(fakeInsertParams(), { tx }); + + expect(tx.statements).toHaveBeenCalledOnce(); + expect(prisma.statements).not.toHaveBeenCalled(); + }); + + it("inserts without a transaction in a Prisma interactive transaction", async () => { + const tx = mockPrismaClient(); + tx.rowsToReturn = [fakePrismaRow()]; + const failure = new Error("after next"); + const transactions: string[] = []; + const root = Object.assign(mockPrismaClient(), { + $transaction: async ( + callback: (transaction: PrismaClientLike) => Promise + ): Promise => { + try { + const result = await callback(tx); + transactions.push("commit"); + return result; + } catch (error: unknown) { + transactions.push("rollback"); + throw error; + } + }, + }); + const client = new Client(new PrismaDriver(root), { + insertMiddleware: [ + async (_context, next) => { + await next(); + throw failure; + }, + ], + }); + + await expect(client.insert(defineJob({ kind: "sort" }), {})).rejects.toBe( + failure + ); + + expect(transactions).toEqual(["rollback"]); + // The insertion and its insert notification, both rolled back. + expect(tx.statements).toHaveBeenCalledTimes(2); + expect(root.statements).not.toHaveBeenCalled(); + }); + + it("passes its transaction options to Prisma's interactive transaction", async () => { + const tx = mockPrismaClient(); + tx.rowsToReturn = [fakePrismaRow()]; + const passed: unknown[] = []; + const root = Object.assign(mockPrismaClient(), { + $transaction: ( + callback: (transaction: PrismaClientLike) => Promise, + options?: unknown + ): Promise => { + passed.push(options); + return callback(tx); + }, + }); + + await new Client(new PrismaDriver(root)).insert( + defineJob({ kind: "sort" }), + {} + ); + await new Client( + new PrismaDriver(root, { + transactionOptions: { + maxWait: { seconds: 3 }, + timeout: { seconds: 9 }, + }, + }) + ).insert(defineJob({ kind: "sort" }), {}); + + expect(passed).toEqual([undefined, { maxWait: 3_000, timeout: 9_000 }]); + }); + + it("requires $transaction to insert without a transaction", async () => { + const client = new Client(driver); + + await expect( + client.insert(defineJob({ kind: "sort" }), {}) + ).rejects.toMatchObject({ + code: "configuration", + message: expect.stringContaining("pass { tx }"), + }); + expect(prisma.statements).not.toHaveBeenCalled(); + }); + + it("returns no rows without querying for an empty batch", async () => { + await expect(driver.jobInsertMany([])).resolves.toEqual([]); + expect(prisma.statements).not.toHaveBeenCalled(); + }); + + it("rejects incomplete batch results", async () => { + prisma.rowsToReturn = [fakePrismaRow()]; + + await expect( + driver.jobInsertMany([fakeInsertParams(), fakeInsertParams()]) + ).rejects.toThrow("1 rows for 2"); + }); + + it("rejects invalid schema names before issuing SQL", () => { + expect(() => new PrismaDriver(prisma, { schema: "" })).toThrow( + "must start" + ); + expect(() => new PrismaDriver(prisma, { schema: "bad\0schema" })).toThrow( + "must start" + ); + expect(() => new PrismaDriver(prisma, { schema: 'odd"schema' })).toThrow( + "must start" + ); + expect(() => new PrismaDriver(prisma, { schema: "a".repeat(47) })).toThrow( + "46 bytes" + ); + expect( + () => new PrismaDriver(prisma, { schema: "a".repeat(46) }) + ).not.toThrow(); + }); +}); diff --git a/js/driver/prisma/src/prisma.integration.test.ts b/js/driver/prisma/src/prisma.integration.test.ts new file mode 100644 index 000000000..5edb33db9 --- /dev/null +++ b/js/driver/prisma/src/prisma.integration.test.ts @@ -0,0 +1,213 @@ +// Runs the driver against a real generated Prisma client and Prisma's +// PostgreSQL adapter, rather than the `pg`-backed stand-in the other +// integration tests use, so Prisma's own parameter and result handling is +// covered. The client is generated into a temporary directory at startup. +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +import { PrismaPg } from "@prisma/adapter-pg"; +import pg from "pg"; +import { + Client, + defineJob, + exactJsonNumber, + type InsertClient, + isExactJsonNumber, + jsonNumberToBigInt, +} from "riverqueue"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { testPrismaDriver } from "./driver.js"; +import type { PrismaClientLike, PrismaInserter } from "./driver.js"; + +const TEST_DATABASE_URL = + process.env.TEST_DATABASE_URL ?? + "postgres://localhost:5432/river_test?sslmode=disable"; +const packageDirectory = fileURLToPath(new URL("..", import.meta.url)); +const filePrefix = `real_prisma_${Math.random().toString(36).slice(2, 8)}`; + +interface GeneratedPrismaClient extends PrismaClientLike { + $disconnect(): Promise; + $transaction( + callback: (transaction: PrismaClientLike) => Promise + ): Promise; +} + +const accountJob = defineJob()({ + kind: `${filePrefix}_account`, +}); + +describe("PrismaDriver with a real Prisma client", () => { + let client: InsertClient; + let driver: PrismaInserter; + let generatedDirectory: string; + let pool: pg.Pool; + let prisma: GeneratedPrismaClient; + + beforeAll(async () => { + // Generate inside the package, in its ignored temp directory, so the + // client resolves Prisma's runtime from the package's dependencies. + const tempDirectory = join(packageDirectory, "temp"); + await mkdir(tempDirectory, { recursive: true }); + generatedDirectory = await mkdtemp(join(tempDirectory, "prisma-")); + const schema = join(generatedDirectory, "schema.prisma"); + await writeFile( + schema, + `generator client { + provider = "prisma-client" + output = "./client" + moduleFormat = "esm" + generatedFileExtension = "ts" + importFileExtension = "ts" +} + +datasource db { + provider = "postgresql" +} +` + ); + await promisify(execFile)( + join(packageDirectory, "node_modules", ".bin", "prisma"), + ["generate", "--schema", schema], + { cwd: packageDirectory } + ); + const generated = (await import( + join(generatedDirectory, "client", "client.ts") + )) as { + PrismaClient: new (options: { + adapter: PrismaPg; + }) => GeneratedPrismaClient; + }; + prisma = new generated.PrismaClient({ + adapter: new PrismaPg({ connectionString: TEST_DATABASE_URL }), + }); + driver = testPrismaDriver(prisma); + client = new Client(driver); + pool = new pg.Pool({ connectionString: TEST_DATABASE_URL }); + }, 60_000); + + afterAll(async () => { + await pool.query("DELETE FROM river_job WHERE kind LIKE $1", [ + `${filePrefix}%`, + ]); + await pool.end(); + await prisma.$disconnect(); + await rm(generatedDirectory, { force: true, recursive: true }); + }); + + it("keeps integers beyond JavaScript's safe range exact", async () => { + const { job } = await client.insert(accountJob, { + accountId: exactJsonNumber("9223372036854775807"), + }); + + const accountId = job.args.accountId; + expect(isExactJsonNumber(accountId) && jsonNumberToBigInt(accountId)).toBe( + 9_223_372_036_854_775_807n + ); + const stored = await pool.query<{ args: string }>( + "SELECT args::text FROM river_job WHERE id = $1", + [job.id.toString(10)] + ); + expect(stored.rows[0]?.args).toBe('{"accountId": 9223372036854775807}'); + }); + + it("commits and rolls back with Prisma's interactive transactions", async () => { + const committed = await prisma.$transaction( + async (tx) => + (await client.insert(accountJob, { accountId: "committed" }, { tx })) + .job + ); + const rollback = new Error("roll back"); + let rolledBackId: bigint | undefined; + await expect( + prisma.$transaction(async (tx) => { + rolledBackId = ( + await client.insert(accountJob, { accountId: "rolled back" }, { tx }) + ).job.id; + throw rollback; + }) + ).rejects.toBe(rollback); + + const ids = await pool.query<{ id: string }>( + "SELECT id::text FROM river_job WHERE id = ANY($1::bigint[])", + [[committed.id.toString(10), rolledBackId?.toString(10) ?? "0"]] + ); + expect(ids.rows.map(({ id }) => id)).toEqual([committed.id.toString(10)]); + }); + + it("rolls a non-transactional insert back when middleware throws after next()", async () => { + const failure = new Error("fails after the write"); + const failing = new Client(driver, { + insertMiddleware: [ + async (_context, next) => { + await next(); + throw failure; + }, + ], + }); + + await expect( + failing.insert(accountJob, { accountId: "middleware rollback" }) + ).rejects.toBe(failure); + + const rows = await pool.query( + "SELECT 1 FROM river_job WHERE kind = $1 AND args->>'accountId' = $2", + [accountJob.kind, "middleware rollback"] + ); + expect(rows.rowCount).toBe(0); + }); + + it("notifies workers of an available job once its transaction commits", async () => { + const queue = `${filePrefix}_queue`; + const listener = new pg.Client({ connectionString: TEST_DATABASE_URL }); + await listener.connect(); + try { + const notified = new Promise((resolve) => { + listener.on("notification", ({ payload }) => { + if (payload?.includes(queue) === true) resolve(payload); + }); + }); + const schema = await listener.query<{ schema: string }>( + "SELECT current_schema() AS schema" + ); + await listener.query( + `LISTEN "${schema.rows[0]?.schema ?? "public"}.river_insert"` + ); + + await prisma.$transaction(async (tx) => { + await client.insert(accountJob, { accountId: "notify" }, { queue, tx }); + }); + + await expect(notified).resolves.toContain(queue); + } finally { + await listener.end(); + } + }); + + it("keeps a reinserted job's creation time", async () => { + const createdAt = Temporal.Instant.from("2026-01-02T03:04:05.123456Z"); + + const [result] = await driver.jobInsertMany([ + { + args: { accountId: "reinserted" }, + createdAt, + encodedArgs: '{"accountId":"reinserted"}', + kind: accountJob.kind, + maxAttempts: 25, + metadata: {}, + priority: 1, + queue: "default", + scheduledAt: Temporal.Now.instant(), + state: "available", + tags: [], + uniqueKey: null, + uniqueStates: null, + }, + ]); + + expect(result?.job.createdAt.toString()).toBe(createdAt.toString()); + }); +}); From a390cf40972abc483f412fd90a199f0205e322f6 Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sun, 4 Oct 2026 17:23:16 -0500 Subject: [PATCH 30/43] add a SQLite driver on node:sqlite `@riverqueue/driver-sqlite` runs River's complete runtime on Node's built-in `node:sqlite`, with River Go's SQLite schema and queries. Like River for Go, River works on a private connection to the application's database file, in WAL mode with a zero busy timeout, so application statements never join River's transactions. While another connection holds the write lock, River retries with an asynchronous backoff so the event loop keeps running. Pass an application handle with a transaction open as `{ tx }` to run River's statements directly in it, without a savepoint, like River for Go; validation runs before any write. `transaction()` begins one with `BEGIN IMMEDIATE`, retrying asynchronously while the database is busy. An insertion without `{ tx }` runs in a transaction River owns, begun lazily at its first statement. Insert middleware and hooks run inside it, so when that transaction is still open at the event loop's next turn River rolls it back and fails the insertion with a `TransactionScopeError`, rather than holding the write lock across I/O. `SqliteDriver.memory()` and `driver.connect()` share an in-memory database. Tests cover the driver's operations, operation scopes, the FIFO handle lock, notification batching, and keyset pagination properties. --- js/driver/sqlite/package.json | 68 + js/driver/sqlite/src/codecs.ts | 486 ++++ js/driver/sqlite/src/coordination.test.ts | 695 +++++ js/driver/sqlite/src/coordination.ts | 91 + js/driver/sqlite/src/driver.test.ts | 1819 ++++++++++++ js/driver/sqlite/src/driver.ts | 2569 +++++++++++++++++ js/driver/sqlite/src/errors.ts | 94 + js/driver/sqlite/src/index.ts | 12 + js/driver/sqlite/src/notification.test.ts | 261 ++ js/driver/sqlite/src/operations.ts | 1234 ++++++++ .../sqlite/src/pagination.property.test.ts | 213 ++ js/driver/sqlite/src/scope.test.ts | 506 ++++ js/driver/sqlite/src/scope.ts | 290 ++ js/driver/sqlite/src/strict.ts | 69 + js/driver/sqlite/src/types.ts | 166 ++ js/driver/sqlite/tsconfig.json | 10 + js/driver/sqlite/tsconfig.test.json | 8 + js/pnpm-lock.yaml | 12 + js/tsconfig.tests.json | 3 + 19 files changed, 8606 insertions(+) create mode 100644 js/driver/sqlite/package.json create mode 100644 js/driver/sqlite/src/codecs.ts create mode 100644 js/driver/sqlite/src/coordination.test.ts create mode 100644 js/driver/sqlite/src/coordination.ts create mode 100644 js/driver/sqlite/src/driver.test.ts create mode 100644 js/driver/sqlite/src/driver.ts create mode 100644 js/driver/sqlite/src/errors.ts create mode 100644 js/driver/sqlite/src/index.ts create mode 100644 js/driver/sqlite/src/notification.test.ts create mode 100644 js/driver/sqlite/src/operations.ts create mode 100644 js/driver/sqlite/src/pagination.property.test.ts create mode 100644 js/driver/sqlite/src/scope.test.ts create mode 100644 js/driver/sqlite/src/scope.ts create mode 100644 js/driver/sqlite/src/strict.ts create mode 100644 js/driver/sqlite/src/types.ts create mode 100644 js/driver/sqlite/tsconfig.json create mode 100644 js/driver/sqlite/tsconfig.test.json diff --git a/js/driver/sqlite/package.json b/js/driver/sqlite/package.json new file mode 100644 index 000000000..2e3e8df57 --- /dev/null +++ b/js/driver/sqlite/package.json @@ -0,0 +1,68 @@ +{ + "name": "@riverqueue/driver-sqlite", + "version": "0.50.0-alpha.1", + "description": "First-party node:sqlite backend for River.", + "type": "module", + "sideEffects": false, + "engines": { + "node": ">=26" + }, + "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 && node ../../scripts/copy-license.mjs", + "clean": "rm -rf dist", + "prepack": "pnpm run clean && pnpm run build", + "test": "pnpm --dir ../../migrate run build && vitest run src", + "typecheck": "node ../../node_modules/typescript/bin/tsc -p tsconfig.test.json --noEmit" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/riverqueue/river.git", + "directory": "js/driver/sqlite" + }, + "contributors": [ + "Brandur Leach", + "Blake Gentry" + ], + "license": "LGPL-3.0-or-later", + "publishConfig": { + "access": "public", + "provenance": true + }, + "peerDependencies": { + "@types/node": ">=26", + "riverqueue": "workspace:0.50.0-alpha.1" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + }, + "devDependencies": { + "@riverqueue/migrate": "workspace:0.50.0-alpha.1", + "@types/node": "^26.1.1", + "riverqueue": "workspace:0.50.0-alpha.1" + }, + "keywords": [ + "river", + "job-queue", + "sqlite", + "node:sqlite", + "driver" + ] +} diff --git a/js/driver/sqlite/src/codecs.ts b/js/driver/sqlite/src/codecs.ts new file mode 100644 index 000000000..64a460fd4 --- /dev/null +++ b/js/driver/sqlite/src/codecs.ts @@ -0,0 +1,486 @@ +import { + isExactJsonNumber, + parseJson, + RiverError, + stringifyJson, +} from "riverqueue"; + +import { + decodeAttemptErrors, + recordQueueMetadataText, +} from "riverqueue/unstable-driver"; + +import { invalidInputError, invalidRowError } from "./errors.js"; +import { SQLITE_JOB_STATE } from "./types.js"; +import type { + SqliteAttemptError, + SqliteJobRow, + SqliteJobState, + SqliteJsonObject, + SqliteJsonValue, + SqliteQueueRow, +} from "./types.js"; + +const INT64_MAX = 9_223_372_036_854_775_807n; +const INT64_MIN = -9_223_372_036_854_775_808n; +const JOB_STATES = Object.values(SQLITE_JOB_STATE); +const MAX_SAFE_INTEGER = BigInt(Number.MAX_SAFE_INTEGER); + +/** + * SQL that is true when a JSON column holds text that isn't valid JSON, which + * another tool can write and which makes SQLite's JSON functions fail with + * "malformed JSON". River writes these columns as JSONB blobs, so only text + * values are checked, as Go's driver does. + */ +export function invalidJsonTextSql(column: string): string { + return `(typeof(${column}) = 'text' AND NOT json_valid(${column}))`; +} + +/** Read a JSON column as text, returning text that isn't valid JSON as is. */ +function tolerantJsonColumn(column: string): string { + return `CASE WHEN ${invalidJsonTextSql(column)} THEN ${column} ELSE json(${column}) END`; +} + +/** + * Every `river_job` column. JSON columns holding text that isn't valid JSON + * are returned as is, so the row can be reported as undecodable instead of + * failing the whole query, as in Go's driver. + */ +export const JOB_COLUMNS = ` + id, + attempt, + attempted_at, + ${tolerantJsonColumn("attempted_by")} AS attempted_by, + created_at, + ${tolerantJsonColumn("args")} AS encoded_args, + ${tolerantJsonColumn("errors")} AS errors, + finalized_at, + kind, + max_attempts, + ${tolerantJsonColumn("metadata")} AS metadata, + priority, + queue, + scheduled_at, + state, + ${tolerantJsonColumn("tags")} AS tags, + unique_key, + unique_states +`; + +export const QUEUE_COLUMNS = ` + created_at, + json(metadata) AS metadata, + name, + paused_at, + updated_at +`; + +/** Encode an instant in River's exact-millisecond SQLite representation. */ +export function sqliteTimestamp(value: Temporal.Instant): string { + try { + const iso = value + .round({ roundingMode: "halfExpand", smallestUnit: "millisecond" }) + .toString({ fractionalSecondDigits: 3 }); + return iso.replace("T", " ").replace(/Z$/, ""); + } catch (cause: unknown) { + throw invalidInput("timestamp", "expected a Temporal.Instant", cause); + } +} + +export function sqliteTimestampOrNull( + value: Temporal.Instant | null | undefined +): string | null { + return value === null || value === undefined ? null : sqliteTimestamp(value); +} + +/** Parse a River SQLite timestamp without passing through lossy Date. */ +export function parseSqliteTimestamp( + value: unknown, + field: string +): Temporal.Instant { + if (typeof value !== "string") { + throw invalidRow(field, "timestamp is not text"); + } + const normalized = /(?:Z|[+-]\d\d:\d\d)$/.test(value) + ? value.replace(" ", "T") + : `${value.replace(" ", "T")}Z`; + try { + return Temporal.Instant.from(normalized); + } catch (cause: unknown) { + throw invalidRow(field, "timestamp is invalid", cause); + } +} + +/** Encode JSON River writes. */ +export function encodeJson(value: unknown, field: string): string { + try { + return stringifyJson(value); + } catch (cause: unknown) { + if (cause instanceof RiverError) throw cause; + throw invalidInput(field, "value is not River JSON", cause); + } +} + +/** + * Check a caller's already encoded JSON for storage. Like Go's driver, River + * stores the text as given rather than re-encoding it. + */ +export function encodeEncodedJson(text: string, field: string): string { + if (typeof text !== "string") { + throw invalidInput(field, "encoded JSON must be a string"); + } + try { + parseJson(text); + } catch (cause: unknown) { + throw invalidInput(field, "value is not encoded JSON", cause); + } + return text; +} + +function decodeJsonObject(value: unknown, field: string): SqliteJsonObject { + const decoded = decodeJson(value, field); + if ( + decoded === null || + Array.isArray(decoded) || + typeof decoded !== "object" || + isExactJsonNumber(decoded) + ) { + throw invalidRow(field, "JSON value is not an object"); + } + return decoded; +} + +/** + * Decode one `river_job` row exactly as leniently as Go's `riversqlite` does. + * + * SQLite columns are wider than PostgreSQL's, so another engine may persist + * values River's PostgreSQL schema would reject: `attempt` and `max_attempts` + * are unbounded integers, and JSON `null` is accepted for `tags`, + * `attempted_by`, and `errors` (Go decodes it as an empty list). Negative counts + * clamp to zero like Go, and counts beyond `Number.MAX_SAFE_INTEGER` saturate, + * which preserves their "effectively unlimited" meaning without losing + * precision silently in arithmetic. + */ +export function decodeJobRow(raw: Record): SqliteJobRow { + const { error, job } = decodeJobRowPartial(raw); + if (error !== undefined) throw error; + return job; +} + +/** + * Decode a `river_job` row like {@link decodeJobRow}, except that an `args`, + * `attempted_by`, `errors`, `metadata`, `tags`, or `unique_states` value that + * can't be decoded is left empty and the decode error is returned alongside, + * as Go's driver does, so one bad row can't fail a claim, a completion batch, + * or the rescuer. Other fields still throw. + */ +export function decodeJobRowPartial(raw: Record): { + readonly error?: Error; + readonly job: SqliteJobRow; +} { + const failures: Error[] = []; + const partial = (empty: T, decoder: () => T): T => { + try { + return decoder(); + } catch (cause: unknown) { + failures.push( + cause instanceof Error ? cause : invalidRow("job", String(cause)) + ); + return empty; + } + }; + let job: SqliteJobRow; + try { + job = { + args: partial({}, () => decodeJsonObject(raw.encoded_args, "args")), + attempt: count(raw.attempt, "attempt"), + attemptedAt: nullableTimestamp(raw.attempted_at, "attempted_at"), + attemptedBy: partial([], () => + nullableStringArray(raw.attempted_by, "attempted_by") + ), + createdAt: parseSqliteTimestamp(raw.created_at, "created_at"), + errors: partial([], () => attemptErrors(raw.errors)), + finalizedAt: nullableTimestamp(raw.finalized_at, "finalized_at"), + id: int64(raw.id, "id"), + kind: requiredString(raw.kind, "kind"), + maxAttempts: count(raw.max_attempts, "max_attempts"), + metadata: partial({}, () => decodeJsonObject(raw.metadata, "metadata")), + priority: smallInteger(raw.priority, "priority", 1, 4), + queue: requiredString(raw.queue, "queue"), + scheduledAt: parseSqliteTimestamp(raw.scheduled_at, "scheduled_at"), + state: jobState(raw.state, "state"), + tags: partial([], () => nullableStringArray(raw.tags, "tags")), + uniqueKey: nullableBytes(raw.unique_key, "unique_key"), + uniqueStates: partial(null, () => decodeUniqueStates(raw.unique_states)), + }; + } catch (cause: unknown) { + if (cause instanceof RiverError) throw cause; + throw invalidRow("job", "could not decode row", cause); + } + if (failures.length === 0) return { job }; + const [first] = failures; + return { + error: + failures.length === 1 && first !== undefined + ? first + : new AggregateError( + failures, + failures.map((failure) => failure.message).join("; ") + ), + job, + }; +} + +export function decodeQueueRow(raw: Record): SqliteQueueRow { + try { + const row: SqliteQueueRow = { + createdAt: parseSqliteTimestamp(raw.created_at, "created_at"), + metadata: decodeJsonObject(raw.metadata, "metadata"), + name: requiredString(raw.name, "name"), + pausedAt: nullableTimestamp(raw.paused_at, "paused_at"), + updatedAt: parseSqliteTimestamp(raw.updated_at, "updated_at"), + }; + // `json(metadata)`, the stored text with its number literals as written. + recordQueueMetadataText(row, requiredString(raw.metadata, "metadata")); + return row; + } catch (cause: unknown) { + if (cause instanceof RiverError) throw cause; + throw invalidRow("queue", "could not decode row", cause); + } +} + +/** + * Encode unique states as Go does: an empty set is stored as `NULL`, which + * never participates in the partial unique index. + */ +export function encodeUniqueStates( + states: readonly SqliteJobState[] | null | undefined +): bigint | null { + if (states === null || states === undefined) return null; + let bits = 0n; + for (const state of states) { + const index = JOB_STATES.indexOf(state); + if (index < 0) throw invalidInput("uniqueStates", `unknown state ${state}`); + bits |= 1n << BigInt(index); + } + return bits === 0n ? null : bits; +} + +export function validateInt64(value: bigint, field: string): bigint { + if (value < INT64_MIN || value > INT64_MAX) { + throw invalidInput( + field, + "integer is outside SQLite's signed 64-bit range" + ); + } + return value; +} + +export function validateSmallInteger( + value: number, + field: string, + minimum: number, + maximum: number +): number { + if (!Number.isSafeInteger(value) || value < minimum || value > maximum) { + throw invalidInput( + field, + `expected an integer in the range ${minimum}..=${maximum}` + ); + } + return value; +} + +/** + * Check a name that only looks up existing rows. Like River for Go, any + * string is accepted, and a name no row can have is simply not found. + */ +export function validateLookupName(value: string, field: string): string { + if (typeof value !== "string") throw invalidInput(field, "must be a string"); + return value; +} + +export function validateName(value: string, field: string): string { + validateUnicode(value, field, invalidInput); + if (value.length === 0 || value.length >= 128) { + throw invalidInput(field, "must contain between 1 and 127 characters"); + } + return value; +} + +function attemptErrors(value: unknown): readonly SqliteAttemptError[] { + if (value === null) return []; + if (typeof value !== "string") + throw invalidRow("errors", "JSON projection is not text"); + // Each element decodes leniently, as River for Go's drivers decode it. + try { + return decodeAttemptErrors(value); + } catch (cause: unknown) { + throw invalidRow( + "errors", + cause instanceof TypeError ? cause.message : "contains invalid JSON", + cause + ); + } +} + +/** Clamp a persisted count to Go's `max(n, 0)` and JavaScript's safe range. */ +function count(value: unknown, field: string): number { + return saturate(int64(value, field)); +} + +function decodeJson(value: unknown, field: string): SqliteJsonValue { + if (typeof value !== "string") + throw invalidRow(field, "JSON projection is not text"); + try { + return parseJson(value); + } catch (cause: unknown) { + throw invalidRow( + field, + cause instanceof RiverError ? cause.message : "contains invalid JSON", + cause + ); + } +} + +function decodeUniqueStates(value: unknown): readonly SqliteJobState[] | null { + if (value === null) return null; + const bits = int64(value, "unique_states"); + if (bits < 0n || bits > 255n) { + throw invalidRow("unique_states", "bit mask is outside 0..=255"); + } + return JOB_STATES.filter((_, index) => (bits & (1n << BigInt(index))) !== 0n); +} + +function int64(value: unknown, field: string): bigint { + if (typeof value !== "bigint") { + throw invalidRow(field, "integer was not decoded as bigint"); + } + if (value < INT64_MIN || value > INT64_MAX) { + throw invalidRow(field, "integer is outside signed 64-bit range"); + } + return value; +} + +function jobState(value: unknown, field: string): SqliteJobState { + if ( + typeof value !== "string" || + !JOB_STATES.includes(value as SqliteJobState) + ) { + throw invalidRow(field, `unknown River state ${JSON.stringify(value)}`); + } + return value as SqliteJobState; +} + +export function nullableBytes( + value: unknown, + field: string +): Uint8Array | null { + if (value === null) return null; + // Go scans a TEXT value into []byte as its UTF-8 bytes. + if (typeof value === "string") return new TextEncoder().encode(value); + if (!ArrayBuffer.isView(value) || value instanceof DataView) { + throw invalidRow(field, "BLOB is not a byte array"); + } + return Uint8Array.from( + new Uint8Array(value.buffer, value.byteOffset, value.byteLength) + ); +} + +function nullableTimestamp( + value: unknown, + field: string +): Temporal.Instant | null { + return value === null ? null : parseSqliteTimestamp(value, field); +} + +function requiredString( + value: unknown, + field: string, + emptyAllowed = false +): string { + if (typeof value !== "string" || (!emptyAllowed && value.length === 0)) { + throw invalidRow(field, "value is not valid text"); + } + validateUnicode(value, field, invalidRow); + return value; +} + +function smallInteger( + value: unknown, + field: string, + minimum: number, + maximum: number +): number { + const integer = int64(value, field); + if (integer < BigInt(minimum) || integer > BigInt(maximum)) { + throw invalidRow(field, `integer is outside ${minimum}..=${maximum}`); + } + return Number(integer); +} + +function nullableStringArray(value: unknown, field: string): readonly string[] { + // Go decodes both SQL `NULL` and JSON `null` as an empty slice, and a `null` + // element as an empty string. + const decoded = value === null ? null : decodeJson(value, field); + if (decoded === null) return []; + if (!Array.isArray(decoded)) { + throw invalidRow(field, "JSON is not an array of strings"); + } + return decoded.map((item, index) => { + if (item === null) return ""; + if (typeof item !== "string") { + throw invalidRow(`${field}[${index}]`, "JSON value is not a string"); + } + return item; + }); +} + +function saturate(value: bigint): number { + if (value < 0n) return 0; + if (value > MAX_SAFE_INTEGER) return Number.MAX_SAFE_INTEGER; + return Number(value); +} + +function validateUnicode( + value: string, + field: string, + error: (field: string, message: string, cause?: unknown) => RiverError +): void { + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (!(next >= 0xdc00 && next <= 0xdfff)) { + throw error(field, "string contains an unpaired surrogate"); + } + index++; + } else if (code >= 0xdc00 && code <= 0xdfff) { + throw error(field, "string contains an unpaired surrogate"); + } + } +} + +function invalidInput( + field: string, + message: string, + cause?: unknown +): RiverError { + return invalidInputError( + "encode", + `invalid SQLite River input ${field}: ${message}`, + cause + ); +} + +function invalidRow( + field: string, + message: string, + cause?: unknown +): RiverError { + return invalidRowError( + "decode", + `invalid SQLite River row ${field}: ${message}`, + cause + ); +} diff --git a/js/driver/sqlite/src/coordination.test.ts b/js/driver/sqlite/src/coordination.test.ts new file mode 100644 index 000000000..072b0ff4d --- /dev/null +++ b/js/driver/sqlite/src/coordination.test.ts @@ -0,0 +1,695 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; + +import { DatabaseOperationError } from "riverqueue"; +import { describe, expect, onTestFinished, test } from "vitest"; + +import { FifoLock, retryBusy } from "./coordination.js"; +import { + SQLITE_DRIVER_TEST_HOOKS, + type SqliteRuntime, + testSqliteDriver, + testSqliteMemory, +} from "./driver.js"; +import { transaction } from "./scope.js"; +import type { SqliteDriverOptions, SqliteJobRow } from "./types.js"; + +/** River's own tests fail any lock window that crosses the event loop. */ +const STRICT = { + [SQLITE_DRIVER_TEST_HOOKS]: { strictLockWindow: true }, +} as SqliteDriverOptions; + +describe("FifoLock", () => { + test("grants the lock in request order and releases idempotently", async () => { + const lock = new FifoLock(); + const order: string[] = []; + const releaseFirst = await lock.acquire(); + const second = lock.acquire().then((release) => { + order.push("second"); + return release; + }); + const third = lock.acquire().then((release) => { + order.push("third"); + release(); + }); + + await Promise.resolve(); + expect(order).toEqual([]); + releaseFirst(); + releaseFirst(); + const releaseSecond = await second; + expect(order).toEqual(["second"]); + releaseSecond(); + await third; + expect(order).toEqual(["second", "third"]); + (await lock.acquire())(); + }); +}); + +describe("retryBusy", () => { + test("backs off asynchronously and gives up at the deadline", async () => { + let now = 0; + const sleeps: number[] = []; + const busy = Object.assign(new Error("database is locked"), { + code: "ERR_SQLITE_ERROR", + errcode: 5, + }); + let attempts = 0; + const policy = { + now: () => now, + sleep: (milliseconds: number) => { + sleeps.push(milliseconds); + now += milliseconds; + return Promise.resolve(); + }, + timeoutMs: 100, + }; + + await expect( + retryBusy(policy, () => { + attempts++; + throw busy; + }) + ).rejects.toBe(busy); + expect(sleeps).toEqual([2, 4, 8, 16, 32, 38]); + expect(attempts).toBe(7); + + now = 0; + sleeps.length = 0; + let remainingFailures = 2; + await expect( + retryBusy(policy, () => { + if (remainingFailures-- > 0) throw busy; + return "done"; + }) + ).resolves.toBe("done"); + expect(sleeps).toEqual([2, 4]); + + const other = new Error("constraint failed"); + await expect( + retryBusy(policy, () => { + throw other; + }) + ).rejects.toBe(other); + }); +}); + +describe("SqliteDriver connection coordination", () => { + test("keeps the event loop running while another connection holds the write lock", async () => { + const { driver, path } = await fileSetup(); + await driver.jobInsert({ args: {}, kind: "busy_claim" }); + const other = lockingConnection(path); + + let ticks = 0; + const interval = setInterval(() => { + ticks++; + }, 1); + onTestFinished(() => clearInterval(interval)); + let settled = false; + const claim = driver.jobClaim(claimParams()); + void claim.finally(() => { + settled = true; + }); + + await waitUntil(() => ticks >= 20); + expect(settled).toBe(false); + other.exec("COMMIT"); + + expect((await claim).jobs).toHaveLength(1); + }); + + test("migrates through the driver while another connection holds the write lock", async () => { + const directory = mkdtempSync(join(tmpdir(), "river-sqlite-")); + const path = join(directory, "river.db"); + const database = new DatabaseSync(path); + const driver = testSqliteDriver(database, STRICT); + onTestFinished(() => { + driver.close(); + database.close(); + rmSync(directory, { force: true, recursive: true }); + }); + const other = lockingConnection(path); + + const migrated = createMigrator(driver).migrateUp(); + await new Promise((resolve) => setTimeout(resolve, 100)); + other.exec("COMMIT"); + + await expect(migrated).resolves.toMatchObject({ + versions: expect.arrayContaining([ + expect.objectContaining({ version: 1 }), + ]), + }); + await expect( + driver.jobInsert({ args: {}, kind: "migrated" }) + ).resolves.toMatchObject({ status: "inserted" }); + }); + + test("switches to WAL on its first operation when construction finds the database busy", async () => { + const directory = mkdtempSync(join(tmpdir(), "river-sqlite-")); + const path = join(directory, "river.db"); + const database = new DatabaseSync(path); + onTestFinished(() => { + database.close(); + rmSync(directory, { force: true, recursive: true }); + }); + await migrate(database); + // A fresh connection reads the mode SQLite recorded in the file. + const journalMode = () => { + const fresh = new DatabaseSync(path); + try { + return fresh.prepare("PRAGMA journal_mode").get()?.journal_mode; + } finally { + fresh.close(); + } + }; + expect(journalMode()).toBe("delete"); + const other = lockingConnection(path); + + const busy = busyWaits(); + const driver = testSqliteDriver(database, { + [SQLITE_DRIVER_TEST_HOOKS]: { sleep: busy.sleep, strictLockWindow: true }, + } as SqliteDriverOptions); + onTestFinished(() => driver.close()); + expect(journalMode()).toBe("delete"); + let settled = false; + const insert = driver.jobInsert({ args: {}, kind: "wal_pending" }); + void insert.finally(() => { + settled = true; + }); + // The insertion is retrying the busy WAL switch, not finished. + await waitUntil(() => busy.count >= 2); + expect(settled).toBe(false); + other.exec("COMMIT"); + + await expect(insert).resolves.toMatchObject({ status: "inserted" }); + expect(journalMode()).toBe("wal"); + }); + + test("fails with a retryable error once the busy bound passes", async () => { + const { driver, path } = await fileSetup({ + busyTimeout: { milliseconds: 30 }, + }); + const other = lockingConnection(path); + + const failure = driver.jobInsert({ args: {}, kind: "busy_bound" }); + + await expect(failure).rejects.toBeInstanceOf(DatabaseOperationError); + await expect(failure).rejects.toMatchObject({ + backend: "sqlite", + code: "database", + message: expect.stringContaining("database is locked"), + operation: "insert", + retryable: true, + }); + other.exec("ROLLBACK"); + await expect( + driver.jobInsert({ args: {}, kind: "busy_bound" }) + ).resolves.toMatchObject({ status: "inserted" }); + }); + + test("retries a busy application transaction begin before running the callback", async () => { + const { database, driver, path } = await fileSetup(); + const other = lockingConnection(path); + let entered = false; + + const pending = transaction(database, async (tx) => { + entered = true; + return (await driver.jobInsert({ args: {}, kind: "busy_tx" }, { tx })) + .job; + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(entered).toBe(false); + other.exec("COMMIT"); + + const job = await pending; + expect((await driver.jobGet(job.id))?.kind).toBe("busy_tx"); + }); + + test("hides an application transaction's jobs from workers until it commits", async () => { + const busy = busyWaits(); + const { database, driver } = await fileSetup({ + [SQLITE_DRIVER_TEST_HOOKS]: { sleep: busy.sleep }, + } as SqliteDriverOptions); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + let inserted: SqliteJobRow | undefined; + + const pending = transaction(database, async (tx) => { + inserted = ( + await driver.jobInsert( + { args: {}, kind: "hidden_until_commit" }, + { tx } + ) + ).job; + await gate; + }); + await waitUntil(() => inserted !== undefined); + const id = (inserted as SqliteJobRow).id; + + // Reads see the committed state; a claim waits for the write lock. + await expect(driver.jobGet(id)).resolves.toBeNull(); + let claimed: readonly SqliteJobRow[] | undefined; + const claim = driver.jobClaim(claimParams()).then((result) => { + claimed = result.jobs; + }); + // The claim is retrying against the application's write lock. + await waitUntil(() => busy.count >= 2); + expect(claimed).toBeUndefined(); + + release(); + await pending; + await claim; + expect(claimed?.map((job) => job.id)).toEqual([id]); + }); + + test("keeps working with an application handle that was closed and reopened", async () => { + const { database, driver } = await fileSetup(); + const insert = () => + transaction(database, (tx) => + driver.jobInsert({ args: {}, kind: "reopened" }, { tx }) + ); + await insert(); + + // Closing a handle finalizes the statements River prepared on it. + database.close(); + database.open(); + + await expect(insert()).resolves.toMatchObject({ status: "inserted" }); + }); + + test("rolls back an application transaction's jobs with its rows", async () => { + const { database, driver } = await fileSetup(); + database.exec("CREATE TABLE application_row (id text PRIMARY KEY)"); + const rollback = new Error("roll back"); + + await expect( + transaction(database, async (tx) => { + tx.prepare("INSERT INTO application_row (id) VALUES (?)").run("row"); + await driver.jobInsert({ args: {}, id: 401n, kind: "rolled" }, { tx }); + await new Promise((resolve) => setTimeout(resolve, 5)); + throw rollback; + }) + ).rejects.toBe(rollback); + + expect(await driver.jobGet(401n)).toBeNull(); + expect( + database.prepare("SELECT count(*) AS count FROM application_row").get() + ).toEqual({ count: 0 }); + }); + + test("delivers notifications written in an application transaction after it commits", async () => { + const { database, driver } = await fileSetup(); + const controller = new AbortController(); + onTestFinished(() => controller.abort()); + let ready!: () => void; + const subscribed = new Promise((resolve) => { + ready = resolve; + }); + const iterator = driver + .runtimeNotificationSubscribe(["insert"], controller.signal, ready) + [Symbol.asyncIterator](); + const next = iterator.next(); + await subscribed; + + await transaction(database, async (tx) => { + await driver.jobInsert({ args: {}, kind: "notify_during_tx" }, { tx }); + await driver.notifyInsert(["default"], { tx }); + // Spans at least one poll interval of the subscription. + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + await expect(next).resolves.toEqual({ + done: false, + value: { payload: '{"queue": "default"}', topic: "insert" }, + }); + }); + + test("keeps River's background work waiting asynchronously during a long application transaction", async () => { + const { database, driver } = await fileSetup(); + await driver.jobInsert({ args: {}, kind: "background_waits" }); + let maxLagMs = 0; + let last = performance.now(); + const interval = setInterval(() => { + const now = performance.now(); + maxLagMs = Math.max(maxLagMs, now - last); + last = now; + }, 1); + onTestFinished(() => clearInterval(interval)); + + let started!: () => void; + const open = new Promise((resolve) => { + started = resolve; + }); + const pending = transaction(database, async (tx) => { + await driver.jobInsert({ args: {}, kind: "application_tx" }, { tx }); + started(); + await new Promise((resolve) => setTimeout(resolve, 300)); + }); + await open; + const claim = driver.jobClaim(claimParams()); + await pending; + expect((await claim).jobs).toHaveLength(2); + + // River's connection never lets SQLite wait for a lock synchronously; it + // retries between turns of the event loop. Blocking instead would have + // frozen timers for the whole 300 ms transaction (or deadlocked it), so + // a bound well below that is generous even on a loaded machine. + expect( + await driver.execute("busy_timeout", {}, (db) => + db.prepare("PRAGMA busy_timeout").get() + ) + ).toEqual({ timeout: 0 }); + expect(maxLagMs).toBeLessThan(250); + }); + + test("stops a River call waiting behind the transaction it runs inside", async () => { + const { driver } = await fileSetup(); + + await driver.operationScope(undefined, async (tx) => { + // The insertion takes the connection lock synchronously; the read, + // issued from the same scope before the lock was granted, would wait + // for the scope, which waits for the read. + const insert = driver.jobInsert({ args: {}, kind: "scope" }, { tx }); + const read = driver.jobGet(1n); + await expect(read).rejects.toMatchObject({ + code: "transaction_scope", + reason: "reentrant", + }); + await insert; + }); + }); + + test("lets River calls run inside its transaction before the first statement", async () => { + const { database, driver } = await fileSetup(); + const other = await fileSetup(); + + await driver.operationScope(undefined, async (tx) => { + // Nothing is locked yet, so these run as they would on PostgreSQL. + await driver.jobInsert({ args: {}, kind: "before_first" }); + await expect(driver.jobGet(1n)).resolves.not.toBeNull(); + await transaction(database, () => undefined); + await driver.jobInsert({ args: {}, kind: "scope" }, { tx }); + // Once River holds the lock, only another database is reachable. + await transaction(other.database, () => undefined); + await expect( + transaction(database, () => undefined) + ).rejects.toMatchObject({ reason: "reentrant" }); + }); + + await transaction(database, () => + transaction(other.database, () => undefined) + ); + }); + + test("names another driver on the same database in a re-entry error", async () => { + const { database, driver } = await fileSetup(); + using second = testSqliteDriver(database, STRICT); + + await driver.operationScope(undefined, async (tx) => { + await driver.jobInsert({ args: {}, kind: "scope" }, { tx }); + await expect(second.jobGet(1n)).rejects.toMatchObject({ + message: expect.stringContaining("another SqliteDriver"), + reason: "reentrant", + }); + }); + }); + + test("points at { tx } when an application transaction in this process holds the lock", async () => { + const { database, driver } = await fileSetup({ + busyTimeout: { milliseconds: 30 }, + }); + + database.exec("BEGIN IMMEDIATE"); + await expect( + driver.jobInsert({ args: {}, kind: "raw_begin" }) + ).rejects.toMatchObject({ + code: "database", + message: expect.stringContaining("pass the handle as { tx }"), + }); + database.exec("ROLLBACK"); + }); + + test("fails an open River transaction when the driver closes", async () => { + const { database, driver } = await fileSetup(); + + await expect( + driver.operationScope(undefined, async (tx) => { + await driver.jobInsert({ args: {}, kind: "closed" }, { tx }); + driver.close(); + await driver.jobInsert({ args: {}, kind: "after_close" }, { tx }); + }) + ).rejects.toMatchObject({ code: "lifecycle" }); + await expect( + driver.operationScope(undefined, async (tx) => { + driver.close(); + await driver.jobInsert({ args: {}, kind: "closed" }, { tx }); + }) + ).rejects.toMatchObject({ code: "lifecycle" }); + expect( + database.prepare("SELECT count(*) AS count FROM river_job").get() + ).toEqual({ count: 0 }); + }); + + test("fails River calls that would wait for a transaction they run inside", async () => { + const { database, driver } = await fileSetup(); + + await driver.operationScope(undefined, async (tx) => { + await driver.jobInsert({ args: {}, kind: "scope" }, { tx }); + const reentrant = { + code: "transaction_scope", + reason: "reentrant", + retryable: false, + }; + await expect(driver.jobGet(1n)).rejects.toMatchObject(reentrant); + await expect( + driver.jobInsert({ args: {}, kind: "reentrant" }) + ).rejects.toMatchObject(reentrant); + await expect( + driver.operationScope(undefined, () => Promise.resolve()) + ).rejects.toMatchObject(reentrant); + await expect( + transaction(database, () => undefined) + ).rejects.toMatchObject(reentrant); + }); + + await transaction(database, async () => { + // Reads don't wait for the application's write lock; writes would. + await expect(driver.jobGet(1n)).resolves.not.toBeNull(); + await expect( + driver.jobInsert({ args: {}, kind: "reentrant" }) + ).rejects.toMatchObject({ + code: "transaction_scope", + reason: "reentrant", + }); + await expect( + transaction(database, () => undefined) + ).rejects.toMatchObject({ code: "transaction_scope", reason: "nested" }); + }); + + database.exec("BEGIN"); + await expect(transaction(database, () => undefined)).rejects.toMatchObject({ + code: "transaction_scope", + reason: "nested", + }); + database.exec("ROLLBACK"); + }); + + test("ignores the async context of a transaction that already ended", async () => { + const { database, driver } = await fileSetup(); + let later!: Promise; + + await driver.operationScope(undefined, async (tx) => { + await driver.jobInsert({ args: {}, id: 1n, kind: "ended" }, { tx }); + later = new Promise((resolve) => setTimeout(resolve, 1)).then(() => + Promise.all([ + driver.operationScope(undefined, () => + Promise.resolve("scope after commit") + ), + transaction(database, () => "transaction after commit"), + driver.jobGet(1n).then((job) => job?.kind), + ]) + ); + }); + + await expect(later).resolves.toEqual([ + "scope after commit", + "transaction after commit", + "ended", + ]); + }); + + test("never ends an application transaction it did not begin", async () => { + const { database, driver } = await fileSetup(); + database.exec("CREATE TABLE application_row (id text PRIMARY KEY)"); + database.exec("BEGIN IMMEDIATE"); + database + .prepare("INSERT INTO application_row (id) VALUES (?)") + .run("application"); + + await driver.jobInsert( + { args: {}, kind: "inside_application_tx" }, + { tx: database } + ); + + expect(database.isTransaction).toBe(true); + database.exec("ROLLBACK"); + expect( + database.prepare("SELECT count(*) AS count FROM application_row").get() + ).toEqual({ count: 0 }); + expect( + database.prepare("SELECT count(*) AS count FROM river_job").get() + ).toEqual({ count: 0 }); + }); + + test("leaves a failed operation's writes for the caller transaction to roll back", async () => { + const { database, driver } = await fileSetup(); + database.exec( + `CREATE TRIGGER fail_insert BEFORE INSERT ON river_job + WHEN NEW.kind = 'failing' + BEGIN SELECT RAISE(ABORT, 'insert failed'); END` + ); + + // Like River for Go, River opens no savepoint in the caller's + // transaction: the batch's first row stays in it next to the caller's + // earlier work until the caller rolls back. + const rollback = new Error("roll back"); + await expect( + transaction(database, async (tx) => { + await driver.jobInsert({ args: {}, id: 1n, kind: "before" }, { tx }); + await expect( + driver.jobInsertMany( + [ + { args: {}, id: 2n, kind: "partial" }, + { args: {}, id: 3n, kind: "failing" }, + ], + { tx } + ) + ).rejects.toMatchObject({ + code: "database", + message: expect.stringContaining("insert failed"), + }); + expect((await driver.jobGet(1n, { tx }))?.kind).toBe("before"); + expect((await driver.jobGet(2n, { tx }))?.kind).toBe("partial"); + throw rollback; + }) + ).rejects.toBe(rollback); + + expect(await driver.jobGet(1n)).toBeNull(); + expect(await driver.jobGet(2n)).toBeNull(); + }); + + test("shares an in-memory database between River and connect() handles", async () => { + const driver = testSqliteMemory(STRICT); + onTestFinished(() => driver.close()); + await migrate(driver.database); + const application = driver.connect(); + onTestFinished(() => application.close()); + application.exec("CREATE TABLE application_row (id text PRIMARY KEY)"); + + await transaction(application, async (tx) => { + tx.prepare("INSERT INTO application_row (id) VALUES (?)").run("row"); + await driver.jobInsert({ args: {}, id: 7n, kind: "memory" }, { tx }); + }); + + expect((await driver.jobGet(7n))?.kind).toBe("memory"); + expect( + driver.database + .prepare("SELECT count(*) AS count FROM application_row") + .get() + ).toEqual({ count: 1 }); + expect(application.prepare("PRAGMA busy_timeout").get()).toEqual({ + timeout: 0, + }); + }); +}); + +function claimParams() { + return { + attemptedBy: "coordination-worker", + kinds: [], + queues: [{ limit: 10, name: "default" }], + }; +} + +async function fileSetup(options: SqliteDriverOptions = {}): Promise<{ + database: DatabaseSync; + driver: SqliteRuntime; + path: string; +}> { + const directory = mkdtempSync(join(tmpdir(), "river-sqlite-")); + const path = join(directory, "river.db"); + const database = new DatabaseSync(path); + await migrate(database); + const hooks = (options as { [SQLITE_DRIVER_TEST_HOOKS]?: object })[ + SQLITE_DRIVER_TEST_HOOKS + ]; + const driver = testSqliteDriver(database, { + ...options, + [SQLITE_DRIVER_TEST_HOOKS]: { ...hooks, strictLockWindow: true }, + } as SqliteDriverOptions); + onTestFinished(() => { + driver.close(); + database.close(); + rmSync(directory, { force: true, recursive: true }); + }); + return { database, driver, path }; +} + +/** + * A busy-retry sleep for the driver's test hooks that counts the retries, + * so a test can wait until an operation is actually waiting on a lock. + */ +function busyWaits(): { + readonly count: number; + sleep(milliseconds: number): Promise; +} { + let count = 0; + return { + get count() { + return count; + }, + sleep: (milliseconds) => { + count++; + return new Promise((resolve) => setTimeout(resolve, milliseconds)); + }, + }; +} + +/** Open a second connection that holds SQLite's write lock. */ +function lockingConnection(path: string): DatabaseSync { + const other = new DatabaseSync(path); + onTestFinished(() => { + if (other.isTransaction) other.exec("ROLLBACK"); + other.close(); + }); + other.exec("BEGIN IMMEDIATE"); + return other; +} + +function createMigrator(target: object): { + migrateUp(): Promise; +} { + return migrationModule.createMigrator(target); +} + +async function migrate(database: DatabaseSync): Promise { + await createMigrator({ database }).migrateUp(); +} + +const migrationModule = (await import( + new URL("../../../migrate/dist/index.js", import.meta.url).href +)) as { + createMigrator(target: object): { migrateUp(): Promise }; +}; + +async function waitUntil(condition: () => boolean): Promise { + for (let index = 0; index < 2_000; index++) { + if (condition()) return; + await new Promise((resolve) => setTimeout(resolve, 1)); + } + throw new Error("condition was not reached"); +} diff --git a/js/driver/sqlite/src/coordination.ts b/js/driver/sqlite/src/coordination.ts new file mode 100644 index 000000000..874bd0b05 --- /dev/null +++ b/js/driver/sqlite/src/coordination.ts @@ -0,0 +1,91 @@ +import { isRetryableSqliteError } from "./errors.js"; + +/** + * First-in, first-out asynchronous mutex. + * + * `node:sqlite` exposes one connection per `DatabaseSync`, so every River + * operation on a handle must wait for any transaction that is open on it. + * Waiters are resumed in arrival order so a steady stream of short operations + * cannot starve a queued transaction. + */ +export class FifoLock { + #held = false; + readonly #waiters: (() => void)[] = []; + + /** + * Wait for the lock and return a function that releases it exactly once. + * When `signal` aborts while waiting, stop waiting and reject with its + * reason. + */ + async acquire(signal?: AbortSignal): Promise<() => void> { + signal?.throwIfAborted(); + if (this.#held) { + await new Promise((resolve, reject) => { + const waiter = (): void => { + signal?.removeEventListener("abort", abort); + resolve(); + }; + const abort = (): void => { + const index = this.#waiters.indexOf(waiter); + if (index !== -1) this.#waiters.splice(index, 1); + reject(signal?.reason); + }; + signal?.addEventListener("abort", abort, { once: true }); + this.#waiters.push(waiter); + }); + } else { + this.#held = true; + } + let released = false; + return () => { + if (released) return; + released = true; + const next = this.#waiters.shift(); + if (next === undefined) this.#held = false; + else next(); + }; + } +} + +/** Bounds for retrying an operation while another connection holds a lock. */ +export interface BusyRetryPolicy { + /** Monotonic clock in milliseconds. */ + readonly now: () => number; + /** Resolve after roughly the given number of milliseconds. */ + readonly sleep: (milliseconds: number) => Promise; + /** Total time to keep retrying before surfacing the busy error. */ + readonly timeoutMs: number; +} + +const FIRST_BACKOFF_MS = 2; +const MAX_BACKOFF_MS = 50; + +/** + * Run a synchronous SQLite attempt, retrying `SQLITE_BUSY`/`SQLITE_LOCKED` + * with exponential backoff until the policy's deadline. + * + * Each attempt runs synchronously, and the event loop runs between attempts. + * With a zero `busy_timeout`, as River's connection has, an attempt fails at + * once instead of blocking the event loop while it waits. The attempt must leave no transaction + * open when it throws, so it is safe to run again. The last busy error is + * rethrown unchanged once the deadline passes; callers classify it as + * retryable. + */ +export async function retryBusy( + policy: BusyRetryPolicy, + attempt: () => T +): Promise { + const deadline = policy.now() + policy.timeoutMs; + let backoff = FIRST_BACKOFF_MS; + for (;;) { + try { + return attempt(); + } catch (cause: unknown) { + if (!isRetryableSqliteError(cause)) throw cause; + const remaining = deadline - policy.now(); + if (remaining <= 0) throw cause; + await policy.sleep(Math.min(backoff, remaining)); + backoff = Math.min(backoff * 2, MAX_BACKOFF_MS); + } + } +} diff --git a/js/driver/sqlite/src/driver.test.ts b/js/driver/sqlite/src/driver.test.ts new file mode 100644 index 000000000..101ae1f8e --- /dev/null +++ b/js/driver/sqlite/src/driver.test.ts @@ -0,0 +1,1819 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; + +import { describe, expect, onTestFinished, test } from "vitest"; +import { + Client, + DatabaseOperationError, + defineJob, + exactJsonNumber, + isExactJsonNumber, + ValidationError, +} from "riverqueue"; +import type { JobListParams } from "riverqueue/unstable-driver"; + +import { + SQLITE_DRIVER_TEST_HOOKS, + SqliteDriver, + type SqliteRuntime, + testSqliteDriver, + testSqliteMemory, +} from "./driver.js"; +import { transaction } from "./scope.js"; +import type { + SqliteDriverOptions, + SqliteJobRow, + SqliteRiverScope, +} from "./types.js"; + +/** River's own tests fail any lock window that crosses the event loop. */ +const STRICT = { + [SQLITE_DRIVER_TEST_HOOKS]: { strictLockWindow: true }, +} as SqliteDriverOptions; + +describe("SqliteDriver surface", () => { + test("exposes only its construction and connection lifecycle", () => { + using driver = SqliteDriver.memory(); + + expect(Reflect.ownKeys(driver)).toEqual([]); + expect(new Set(Reflect.ownKeys(SqliteDriver.prototype))).toEqual( + new Set(["close", "connect", "constructor", Symbol.dispose]) + ); + const handle = driver.connect(); + try { + expect(new Client(driver)).toBeInstanceOf(Client); + } finally { + handle.close(); + } + }); +}); + +describe("SqliteDriver", () => { + test("preserves exact IDs and rounds SQLite timestamps to milliseconds", async () => { + const { database, driver } = await setup(); + const exactID = 9_007_199_254_740_999n; + const instant = Temporal.Instant.from("2026-08-30T17:20:10.123600000Z"); + + const result = await driver.jobInsert({ + args: { nested: { accepted: true } }, + createdAt: instant, + id: exactID, + kind: "exact_values", + scheduledAt: instant, + }); + + expect(result.status).toBe("inserted"); + expect(result.job.id).toBe(exactID); + expect(typeof result.job.id).toBe("bigint"); + expect(result.job.createdAt.toString()).toBe("2026-08-30T17:20:10.124Z"); + expect((await driver.jobGet(exactID))?.id).toBe(exactID); + const scheduled = await driver.jobInsert({ + args: {}, + createdAt: Temporal.Instant.from("2026-08-30T17:20:10Z"), + kind: "scheduled_values", + scheduledAt: Temporal.Instant.from("2026-08-30T18:20:10Z"), + }); + expect(scheduled.job.state).toBe("scheduled"); + // Insertion alone notifies nobody; the client notifies producers after. + expect(notificationRows(database)).toEqual([]); + }); + + test("rounds half milliseconds up like Go's time.Round, before 1970 too", async () => { + const { driver } = await setup(); + const cases = [ + ["2026-08-30T17:20:10.1235Z", "2026-08-30T17:20:10.124Z"], + ["1969-12-31T23:59:59.1235Z", "1969-12-31T23:59:59.124Z"], + ["1969-12-31T23:59:59.9995Z", "1970-01-01T00:00:00Z"], + ] as const; + for (const [input, stored] of cases) { + const instant = Temporal.Instant.from(input); + const { job } = await driver.jobInsert({ + args: {}, + createdAt: instant, + kind: "rounding", + scheduledAt: instant, + }); + expect(job.createdAt.toString()).toBe(stored); + } + }); + + test("rejects a client batch repeating an active unique key, like Go", async () => { + const { database, driver } = await setup(); + const job = defineJob<{ value: string }>()({ kind: "batch_unique" }); + const item = { + args: { value: "same" }, + job, + options: { unique: { byArgs: true } }, + } as const; + + await expect( + new Client(driver).insertMany([ + item, + { args: { value: "other" }, job }, + item, + ]) + ).rejects.toThrow( + new ValidationError("unique key appears more than once in batch") + ); + expect( + database.prepare("SELECT count(*) AS count FROM river_job").get() + ).toEqual({ count: 0 }); + }); + + test("round-trips exact JSON numbers from JavaScript and other engines", async () => { + const { database, driver } = await setup(); + const inserted = await driver.jobInsert({ + args: { integer: exactJsonNumber("9223372036854775807") }, + kind: "exact_json_javascript", + }); + expect(isExactJsonNumber(inserted.job.args.integer)).toBe(true); + expect(JSON.stringify(inserted.job.args)).toBe( + '{"integer":9223372036854775807}' + ); + + const insertExact = database.prepare( + `INSERT INTO river_job (args, kind, max_attempts, metadata) + VALUES (jsonb(?), ?, 25, jsonb(?)) RETURNING id` + ); + insertExact.setReadBigInts(true); + const raw = insertExact.get( + '{"decimal":0.1234567890123456789,"integer":9223372036854775807}', + "exact_json_external", + '{"underflow":1e-400}' + ); + expect(typeof raw?.id).toBe("bigint"); + const job = (await driver.jobGet(raw!.id as bigint))!; + + expect(isExactJsonNumber(job.args.decimal)).toBe(true); + expect(isExactJsonNumber(job.args.integer)).toBe(true); + expect(isExactJsonNumber(job.metadata.underflow)).toBe(true); + expect(JSON.stringify(job.args)).toBe( + '{"decimal":0.1234567890123456789,"integer":9223372036854775807}' + ); + }); + + test("returns a duplicate for a unique key another job holds", async () => { + const { database, driver } = await setup(); + const uniqueKey = new Uint8Array(32).fill(7); + const first = await driver.jobInsert({ + args: { sequence: 1 }, + kind: "unique_job", + uniqueKey, + uniqueStates: ["available"], + }); + const second = await driver.jobInsert({ + args: { sequence: 2 }, + kind: "unique_job", + uniqueKey, + uniqueStates: ["available"], + }); + + expect(first.status).toBe("inserted"); + expect(second.status).toBe("duplicate"); + expect(second.job.id).toBe(first.job.id); + expect(second.job.args).toEqual({ sequence: 1 }); + expect(first.job.metadata["river:unique_nonce"]).toMatch(/^[0-9a-f]{16}$/); + expect(second.job.metadata["river:unique_nonce"]).toBe( + first.job.metadata["river:unique_nonce"] + ); + expect( + (await driver.jobGet(first.job.id))?.metadata["river:unique_nonce"] + ).toBe(first.job.metadata["river:unique_nonce"]); + expect( + resultRow( + database, + `SELECT json_extract(metadata, '$."river:unique_nonce"') AS nonce + FROM river_job` + )?.nonce + ).toBe(first.job.metadata["river:unique_nonce"]); + }); + + test("keeps the existing job's kind on a unique skip of another kind", async () => { + const { database, driver } = await setup(); + const jobA = defineJob<{ value: string }>()({ kind: "unique_kind_a" }); + const jobB = defineJob<{ value: string }>()({ kind: "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); + } + expect(database.prepare("SELECT kind FROM river_job").all()).toEqual([ + { kind: jobA.kind }, + ]); + }); + + test("writes insert notifications only for the queues it's given", async () => { + const { database, driver } = await setup(); + const insertPayloads = () => + notificationRows(database) + .filter(({ topic }) => topic === "river_insert") + .map(({ payload }) => payload); + + await driver.jobInsertMany([ + { args: {}, kind: "notification_volume", queue: "alpha" }, + { args: {}, kind: "notification_volume", queue: "beta" }, + ]); + await driver.notifyInsert([]); + expect(insertPayloads()).toEqual([]); + + await driver.notifyInsert(["alpha", "beta"]); + expect(insertPayloads()).toEqual([ + '{"queue": "alpha"}', + '{"queue": "beta"}', + ]); + + // Notifications written in a transaction roll back with it. + const rollback = new Error("roll back"); + await expect( + driver.operationScope(undefined, async (tx) => { + await driver.notifyInsert(["gamma"], { tx }); + throw rollback; + }) + ).rejects.toBe(rollback); + expect(insertPayloads()).toHaveLength(2); + }); + + test("notifies only the scheduled queues the client's limiter allows", async () => { + const { database, driver } = await setup(); + const now = Temporal.Now.instant(); + const leader = (await driver.maintenanceLeaderAcquire( + "leader", + now, + 60_000, + null + ))!; + await driver.jobInsertMany( + ["alpha", "beta", "alpha", "gamma"].map((queue) => ({ + args: {}, + kind: "scheduled_notification", + queue, + scheduledAt: now, + state: "scheduled" as const, + })) + ); + const offered: (readonly string[])[] = []; + + expect( + await driver.maintenanceSchedule(leader, { + allowInsertNotifications: (queues) => { + offered.push([...queues].sort()); + return ["alpha"]; + }, + limit: 10, + notificationHorizon: now, + now, + scheduledAtHorizon: now, + }) + ).toBe(4); + + expect(offered).toEqual([["alpha", "alpha", "beta", "gamma"]]); + expect( + notificationRows(database) + .filter(({ topic }) => topic === "river_insert") + .map(({ payload }) => payload) + ).toEqual(['{"queue": "alpha"}']); + }); + + test("cancels, retries, and protects running jobs from deletion", async () => { + const { database, driver } = await setup(); + const now = Temporal.Instant.from("2026-08-30T17:30:00.111Z"); + const inserted = (await driver.jobInsert({ args: {}, kind: "control_job" })) + .job; + + const cancelled = await driver.jobCancelDetailed(inserted.id, { now }); + expect(cancelled.status).toBe("cancelled"); + if (cancelled.status === "cancelled") { + expect(cancelled.job.state).toBe("cancelled"); + expect(cancelled.job.finalizedAt?.toString()).toBe( + "2026-08-30T17:30:00.111Z" + ); + expect(cancelled.job.metadata.cancel_attempted_at).toBe(now.toString()); + } + expect((await driver.jobCancelDetailed(inserted.id, { now })).status).toBe( + "unchanged" + ); + + const retried = await driver.jobRetryDetailed(inserted.id, { now }); + expect(retried.status).toBe("retried"); + if (retried.status === "retried") + expect(retried.job.state).toBe("available"); + expect((await driver.jobRetryDetailed(inserted.id, { now })).status).toBe( + "unchanged" + ); + + database + .prepare( + "UPDATE river_job SET state = 'running', attempt = 1 WHERE id = ?" + ) + .run(inserted.id); + expect(await driver.jobDelete(inserted.id)).toMatchObject({ + job: { id: inserted.id }, + status: "running", + }); + + const control = notificationRows(database); + // Like River for Go, a retry publishes no insert notification. + expect(control.map(({ topic }) => topic)).toEqual(["river_control"]); + expect(control[0]?.payload).toBe( + `{"action":"cancel","job_id":${inserted.id},"queue":"default"}` + ); + }); + + test("notifies a running job's cancellation in the cancelling transaction, like Go", async () => { + const { database, driver } = await setup(); + const inserted = (await driver.jobInsert({ args: {}, kind: "control_job" })) + .job; + database + .prepare( + "UPDATE river_job SET state = 'running', attempt = 1 WHERE id = ?" + ) + .run(inserted.id); + const cancels = () => + notificationRows(database).filter( + ({ topic }) => topic === "river_control" + ); + + // An application transaction that rolls back leaves no notification. + await expect( + transaction(database, async (tx) => { + await driver.jobCancel(inserted.id, { tx }); + expect( + tx + .prepare( + "SELECT count(*) AS n FROM river_notification WHERE topic = 'river_control'" + ) + .get()?.n + ).toBe(1); + throw new Error("roll back"); + }) + ).rejects.toThrow("roll back"); + expect(cancels()).toEqual([]); + + await transaction(database, async (tx) => { + await driver.jobCancel(inserted.id, { tx }); + }); + expect(cancels()).toEqual([ + { + payload: `{"action":"cancel","job_id":${inserted.id},"queue":"default"}`, + topic: "river_control", + }, + ]); + }); + + test("manages queues and writes control changes to the durable outbox", async () => { + const { database, driver } = await setup(); + const first = Temporal.Instant.from("2026-08-30T18:00:00.001Z"); + await driver.queueUpsert("beta", { metadata: { order: 2 }, now: first }); + await driver.queueUpsert("alpha", { metadata: { order: 1 }, now: first }); + + expect( + (await driver.queueList({ limit: 10_000, nameAfter: null })).map( + ({ name }) => name + ) + ).toEqual(["alpha", "beta"]); + expect(await driver.queueGet("missing")).toBeNull(); + await driver.queuePause("*"); + expect( + (await driver.queueList({ limit: 10_000, nameAfter: null })).every( + ({ pausedAt }) => pausedAt !== null + ) + ).toBe(true); + expect(await driver.queuePause("alpha")).toBeNull(); + + const resumed = await driver.queueResume("alpha"); + expect(resumed?.pausedAt).toBeNull(); + expect(await driver.queueResume("alpha")).toBeNull(); + const updated = await driver.queueUpdate("beta", { + metadata: { a: 1, nested: { enabled: true } }, + }); + expect(updated?.metadata).toEqual({ a: 1, nested: { enabled: true } }); + const touched = await driver.queueUpdate("beta", {}); + expect(touched?.metadata).toEqual({ a: 1, nested: { enabled: true } }); + expect(await driver.queueUpdate("missing", {})).toBeNull(); + + expect(notificationRows(database).map(({ payload }) => payload)).toEqual([ + '{"action":"pause","queue":"*"}', + '{"action":"resume","queue":"alpha"}', + '{"action":"metadata_changed","metadata":{"a":1,"nested":{"enabled":true}},"queue":"beta"}', + ]); + }); + + test("finds queue names outside the grammar absent instead of rejecting them", async () => { + const { driver } = await setup(); + + for (const name of ["Not A Queue!", "", "x".repeat(200)]) { + await expect(driver.queueGet(name)).resolves.toBeNull(); + await expect(driver.queuePause(name)).resolves.toBeNull(); + await expect(driver.queueResume(name)).resolves.toBeNull(); + await expect( + driver.queueUpdate(name, { metadata: {} }) + ).resolves.toBeNull(); + } + }); + + test("commits application SQL and River writes in an application transaction", async () => { + const { database, driver } = await setup(); + database.exec("CREATE TABLE application_row (id text PRIMARY KEY)"); + + const id = await transaction(database, async (tx) => { + tx.prepare("INSERT INTO application_row (id) VALUES (?)").run( + "application-1" + ); + const inserted = await driver.jobInsert( + { args: { applicationId: "application-1" }, kind: "transactional" }, + { tx } + ); + await Promise.resolve(); + return inserted.job.id; + }); + + expect((await driver.jobGet(id))?.args).toEqual({ + applicationId: "application-1", + }); + expect(resultRow(database, "SELECT id FROM application_row")?.id).toBe( + "application-1" + ); + }); + + test("rolls back thrown and rejected application transactions", async () => { + const { database, driver } = await setup(); + database.exec("CREATE TABLE application_row (id text PRIMARY KEY)"); + const syncFailure = new Error("sync failure"); + await expect( + transaction(database, (tx) => { + tx.prepare("INSERT INTO application_row (id) VALUES (?)").run( + "rollback-sync" + ); + throw syncFailure; + }) + ).rejects.toBe(syncFailure); + expect( + resultRow(database, "SELECT count(*) AS count FROM application_row") + ?.count + ).toBe(0n); + + const asyncFailure = new Error("async failure"); + await expect( + transaction(database, async (tx) => { + await driver.jobInsert( + { args: {}, id: 102n, kind: "rollback_async" }, + { tx } + ); + await Promise.resolve(); + throw asyncFailure; + }) + ).rejects.toBe(asyncFailure); + expect(await driver.jobGet(102n)).toBeNull(); + expect(notificationRows(database)).toEqual([]); + }); + + test("validates an ordinary batch before writing any of it in a caller transaction", async () => { + const { database, driver } = await setup(); + + await transaction(database, async (tx) => { + await driver.jobInsert( + { args: {}, id: 201n, kind: "before_batch" }, + { tx } + ); + await expect( + driver.jobInsertMany( + [ + { args: {}, id: 202n, kind: "batch_first" }, + { args: {}, id: 203n, kind: "batch_invalid", priority: 99 }, + ], + { tx } + ) + ).rejects.toMatchObject({ code: "validation" }); + await driver.jobInsert( + { args: {}, id: 204n, kind: "after_batch" }, + { tx } + ); + }); + + expect((await driver.jobGet(201n))?.kind).toBe("before_batch"); + expect(await driver.jobGet(202n)).toBeNull(); + expect(await driver.jobGet(203n)).toBeNull(); + expect((await driver.jobGet(204n))?.kind).toBe("after_batch"); + }); + + test("accepts only open transactions on its own database as { tx }", async () => { + const { database, driver } = await setup(); + const other = testSqliteMemory(STRICT); + onTestFinished(() => other.close()); + const foreign = other.connect(); + onTestFinished(() => foreign.close()); + + await expect(driver.jobGet(1n, { tx: database })).rejects.toMatchObject({ + code: "transaction_scope", + reason: "no_transaction", + }); + await transaction(foreign, async (tx) => { + await expect(driver.jobGet(1n, { tx })).rejects.toMatchObject({ + code: "backend_mismatch", + }); + }); + await expect( + driver.jobGet(1n, { tx: {} as DatabaseSync }) + ).rejects.toMatchObject({ code: "backend_mismatch" }); + + let expired!: DatabaseSync | SqliteRiverScope; + await driver.operationScope(undefined, async (tx) => { + expired = tx; + if (!(tx instanceof DatabaseSync)) { + // River's own transaction is opaque, not a connection. + // @ts-expect-error -- SqliteRiverScope has no prepare. + expect(() => tx.prepare("SELECT 1")).toThrow(TypeError); + } + await expect(other.jobGet(1n, { tx })).rejects.toMatchObject({ + code: "backend_mismatch", + }); + await expect(driver.jobGet(1n, { tx })).resolves.toBeNull(); + }); + await expect(driver.jobGet(1n, { tx: expired })).rejects.toMatchObject({ + code: "backend_mismatch", + }); + + // River's private connection is never a valid { tx }. + const connection = await driver.execute("leak", {}, (db) => db); + connection.exec("BEGIN"); + await expect(driver.jobGet(1n, { tx: connection })).rejects.toMatchObject({ + code: "backend_mismatch", + message: expect.stringContaining("private"), + }); + connection.exec("ROLLBACK"); + + const connected = driver.connect(); + onTestFinished(() => connected.close()); + await transaction(connected, async (tx) => { + await expect(driver.jobGet(1n, { tx })).resolves.toBeNull(); + }); + }); + + test("queues River operations behind its own open transaction", async () => { + const { driver } = await setup(); + const order: string[] = []; + + const scope = driver.operationScope(undefined, async (tx) => { + await driver.jobInsert({ args: {}, id: 301n, kind: "first" }, { tx }); + order.push("first:inserted"); + for (let index = 0; index < 10; index++) await Promise.resolve(); + order.push("first:end"); + }); + const outside = driver.jobGet(301n).then((job) => { + order.push(`outside:${job?.kind ?? "missing"}`); + }); + await Promise.all([scope, outside]); + + expect(order).toEqual(["first:inserted", "first:end", "outside:first"]); + }); + + test("closes only the connections it opened", async () => { + const directory = mkdtempSync(join(tmpdir(), "river-sqlite-close-")); + onTestFinished(() => { + rmSync(directory, { force: true, recursive: true }); + }); + const database = new DatabaseSync(join(directory, "river.db")); + onTestFinished(() => database.close()); + await migrate(database); + const fileDriver = testSqliteDriver(database, STRICT); + await fileDriver.jobInsert({ args: {}, kind: "ownership" }); + fileDriver.close(); + fileDriver.close(); + expect(database.isOpen).toBe(true); + expect( + resultRow(database, "SELECT count(*) AS count FROM river_job")?.count + ).toBe(1n); + await expect(fileDriver.jobGet(1n)).rejects.toMatchObject({ + code: "lifecycle", + }); + expect(() => fileDriver.connect()).toThrow(/closed/); + + const memory = testSqliteMemory(STRICT); + const connected = memory.connect(); + memory.close(); + expect(memory.database.isOpen).toBe(false); + expect(connected.isOpen).toBe(true); + connected.close(); + }); + + test("requires a database file River can open its own connection to", () => { + const database = new DatabaseSync(":memory:"); + + expect(() => testSqliteDriver(database)).toThrow(/SqliteDriver.memory/); + database.close(); + expect(() => testSqliteDriver(database)).toThrow(/open/); + }); + + test("rejects lossy JSON integers before writing", async () => { + const { database, driver } = await setup(); + await expect( + driver.jobInsert({ + args: { unsafe: Number.MAX_SAFE_INTEGER + 1 }, + kind: "unsafe_json", + }) + ).rejects.toThrow(expect.objectContaining({ code: "validation" })); + expect( + resultRow(database, "SELECT count(*) AS count FROM river_job")?.count + ).toBe(0n); + }); + + test("surfaces unknown persisted states as structured row errors", async () => { + const { database, driver } = await setup(); + const inserted = (await driver.jobInsert({ args: {}, kind: "bad_state" })) + .job; + database.exec("PRAGMA ignore_check_constraints = ON"); + database + .prepare("UPDATE river_job SET state = 'future_state' WHERE id = ?") + .run(inserted.id); + + await expect(driver.jobGet(inserted.id)).rejects.toThrow( + DatabaseOperationError + ); + await expect(driver.jobGet(inserted.id)).rejects.toThrow( + expect.objectContaining({ + code: "database", + details: expect.objectContaining({ reason: "invalid_row" }), + }) + ); + }); + + test("acts on rows River can't fully read by ID and lists them like Go", async () => { + const { database, driver } = await setup(); + const uniqueKey = new Uint8Array(32).fill(11); + const insert = async (kind: string, extra: object = {}) => + (await driver.jobInsert({ args: {}, kind, ...extra })).job; + const cancelled = await insert("poison_cancel"); + const retried = await insert("poison_retry", { + finalizedAt: Temporal.Instant.from("2026-01-01T00:00:00Z"), + state: "discarded", + }); + const deleted = await insert("poison_delete"); + const duplicate = await insert("poison_unique", { + uniqueKey, + uniqueStates: ["available"], + }); + // Another engine stored array arguments, which Go keeps as raw bytes. + database.exec( + "UPDATE river_job SET args = jsonb('[1, 2]') WHERE kind LIKE 'poison_%'" + ); + + await expect(driver.jobGet(cancelled.id)).rejects.toThrow( + expect.objectContaining({ + details: expect.objectContaining({ reason: "invalid_row" }), + }) + ); + const listed = await driver.jobList( + jobListParams({ kinds: ["poison_cancel", "poison_delete"] }) + ); + expect(listed.map(({ args, id }) => [id, args])).toEqual([ + [cancelled.id, {}], + [deleted.id, {}], + ]); + expect((await driver.jobCancel(cancelled.id))?.state).toBe("cancelled"); + expect((await driver.jobRetry(retried.id))?.state).toBe("available"); + expect(await driver.jobDelete(deleted.id)).toMatchObject({ + job: { id: deleted.id }, + status: "deleted", + }); + const single = await driver.jobInsert({ + args: {}, + kind: "poison_unique", + uniqueKey, + uniqueStates: ["available"], + }); + expect([single.job.id, single.status]).toEqual([duplicate.id, "duplicate"]); + }); + + test("lists and updates jobs with stable keyset filters", async () => { + const { driver } = await setup(); + const firstAt = Temporal.Instant.from("2026-08-30T18:10:00.001Z"); + const secondAt = Temporal.Instant.from("2026-08-30T18:10:00.002Z"); + const first = ( + await driver.jobInsert({ + args: {}, + id: 9_007_199_254_740_993n, + kind: "list_job", + metadata: { tenant: "one" }, + scheduledAt: firstAt, + tags: ["all", "first"], + }) + ).job; + const second = ( + await driver.jobInsert({ + args: {}, + id: 9_007_199_254_740_995n, + kind: "list_job", + metadata: { tenant: "one" }, + priority: 2, + scheduledAt: secondAt, + tags: ["all", "second"], + }) + ).job; + await driver.jobInsert({ args: {}, kind: "other_job" }); + + const firstPage = await driver.jobList( + jobListParams({ + kinds: ["list_job"], + limit: 1, + sortField: "scheduledAt", + tagsAll: ["all"], + }) + ); + expect(firstPage.map(({ id }) => id)).toEqual([first.id]); + const secondPage = await driver.jobList( + jobListParams({ + after: { + id: first.id, + kind: first.kind, + queue: first.queue, + sortField: "scheduledAt", + time: first.scheduledAt, + }, + kinds: ["list_job"], + sortField: "scheduledAt", + tagsAny: ["second"], + }) + ); + expect(secondPage.map(({ id }) => id)).toEqual([second.id]); + // Like Go, a cursor without a time resumes after its ID alone, even + // when the list is ordered by a time. + const timelessPage = await driver.jobList( + jobListParams({ + after: { + id: second.id, + kind: second.kind, + queue: second.queue, + sortField: "scheduledAt", + time: null, + }, + kinds: ["list_job"], + sortDirection: "desc", + sortField: "scheduledAt", + }) + ); + expect(timelessPage.map(({ id }) => id)).toEqual([first.id]); + + const updated = await driver.jobUpdate(second.id, { + metadata: { changed: true }, + output: { delivered: true }, + }); + expect(updated?.metadata).toEqual({ + changed: true, + output: { delivered: true }, + "river:unique_nonce": expect.stringMatching(/^[0-9a-f]{16}$/), + tenant: "one", + }); + expect( + ( + await driver.jobList( + jobListParams({ metadata: { output: { delivered: true } } }) + ) + ).map(({ id }) => id) + ).toEqual([second.id]); + }); + + test("matches Go time ordering by using the first requested state", async () => { + const { database, driver } = await setup(); + const completed = (await driver.jobInsert({ args: {}, kind: "time_order" })) + .job; + const scheduled = (await driver.jobInsert({ args: {}, kind: "time_order" })) + .job; + const running = (await driver.jobInsert({ args: {}, kind: "time_order" })) + .job; + database + .prepare( + `UPDATE river_job + SET state = 'completed', finalized_at = ?, scheduled_at = ? + WHERE id = ?` + ) + .run( + "2026-01-01 00:00:01.000000000+00:00", + "2026-01-01 00:00:03.000000000+00:00", + completed.id + ); + database + .prepare( + `UPDATE river_job SET state = 'scheduled', scheduled_at = ? WHERE id = ?` + ) + .run("2026-01-01 00:00:02.000000000+00:00", scheduled.id); + database + .prepare( + `UPDATE river_job + SET state = 'running', attempted_at = ?, scheduled_at = ? + WHERE id = ?` + ) + .run( + "2026-01-01 00:00:03.000000000+00:00", + "2026-01-01 00:00:01.000000000+00:00", + running.id + ); + + expect( + ( + await driver.jobList( + jobListParams({ + kinds: ["time_order"], + sortField: "time", + states: ["available", "completed", "running", "scheduled"], + }) + ) + ).map(({ id }) => id) + ).toEqual([running.id, scheduled.id, completed.id]); + }); + + test("deletes bounded filtered non-running jobs in ID order", async () => { + const { driver } = await setup(); + const first = (await driver.jobInsert({ args: {}, kind: "delete_many" })) + .job; + const second = (await driver.jobInsert({ args: {}, kind: "delete_many" })) + .job; + const last = (await driver.jobInsert({ args: {}, kind: "delete_many" })) + .job; + const [running] = ( + await driver.jobClaim({ + attemptedBy: "delete-worker", + kinds: ["delete_many"], + queues: [{ limit: 1, name: "default" }], + }) + ).jobs; + expect(running).toBeDefined(); + + expect( + ( + await driver.jobDeleteMany({ + all: false, + ids: [], + kinds: ["delete_many"], + limit: 10, + priorities: [], + queues: [], + states: [], + }) + ).map(({ id }) => id) + ).toEqual( + [first, second, last] + .filter(({ id }) => id !== running!.id) + .map(({ id }) => id) + ); + expect((await driver.jobGet(running!.id))?.state).toBe("running"); + await expect( + driver.jobDeleteMany({ + all: false, + ids: [], + kinds: [], + limit: 10, + priorities: [], + queues: [], + states: [], + }) + ).rejects.toThrow("requires a filter"); + }); + + test("claims due jobs atomically in priority order and honors queue pause", async () => { + const { database, driver } = await setup(); + const now = Temporal.Now.instant().subtract({ seconds: 1 }); + const low = ( + await driver.jobInsert({ + args: {}, + attemptedBy: ["old-1", "old-2"], + kind: "claim_job", + priority: 2, + scheduledAt: now, + }) + ).job; + const high = ( + await driver.jobInsert({ + args: {}, + kind: "claim_job", + priority: 1, + scheduledAt: now, + }) + ).job; + await driver.jobInsert({ + args: {}, + kind: "claim_job", + scheduledAt: now.add({ hours: 1 }), + }); + + const claimed = ( + await driver.jobClaim({ + attemptedBy: "sqlite-worker", + kinds: ["claim_job"], + queues: [{ limit: 2, name: "default" }], + }) + ).jobs; + expect(claimed.map(({ id }) => id)).toEqual([high.id, low.id]); + expect( + claimed.every( + ({ attempt, state }) => attempt === 1 && state === "running" + ) + ).toBe(true); + expect((await driver.jobGet(low.id))?.attemptedBy).toEqual([ + "old-1", + "old-2", + "sqlite-worker", + ]); + + const attemptedBy = Array.from( + { length: 101 }, + (_, index) => `worker-${index.toString().padStart(3, "0")}` + ); + const longHistory = ( + await driver.jobInsert({ + args: {}, + attemptedBy, + kind: "claim_history", + scheduledAt: now, + }) + ).job; + await driver.jobClaim({ + attemptedBy: "worker-101", + kinds: ["claim_history"], + queues: [{ limit: 1, name: "default" }], + }); + expect((await driver.jobGet(longHistory.id))?.attemptedBy).toEqual([ + ...attemptedBy.slice(2), + "worker-101", + ]); + expect( + await driver.jobClaim({ + attemptedBy: "other", + kinds: ["claim_job"], + queues: [{ limit: 2, name: "default" }], + }) + ).toEqual({ jobs: [] }); + + await driver.queueUpsert("paused", { now }); + await driver.queuePause("paused"); + const paused = ( + await driver.jobInsert({ + args: {}, + kind: "paused_claim", + queue: "paused", + scheduledAt: now, + }) + ).job; + expect( + await driver.jobClaim({ + attemptedBy: "worker", + kinds: [], + queues: [{ limit: 1, name: "paused" }], + }) + ).toEqual({ jobs: [] }); + expect((await driver.jobGet(paused.id))?.state).toBe("available"); + expect( + resultRow( + database, + "SELECT count(*) AS count FROM river_job WHERE state = 'running'" + )?.count + ).toBe(3n); + }); + + test("pages sparse metadata lists without blocking the event loop", async () => { + const { database, driver } = await setup(); + database.exec(` + WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM n WHERE x < 2500) + INSERT INTO river_job (kind, queue, state, metadata, scheduled_at) + SELECT 'sparse_list', 'default', 'available', jsonb(json_object('n', x)), + '2026-01-01 00:00:00' + FROM n; + `); + const params = jobListParams({ limit: 5, metadata: { n: 2400 } }); + let yielded = false; + setImmediate(() => { + yielded = true; + }); + + const listed = await driver.jobList(params); + + expect(yielded).toBe(true); + expect(listed.map(({ metadata }) => metadata.n)).toEqual([2400]); + await transaction(database, async (tx) => { + const inTransaction = await driver.jobList(params, { tx }); + expect(inTransaction.map(({ metadata }) => metadata.n)).toEqual([2400]); + }); + }); + + test("guards completion batches by attempt and applies cancellation races", async () => { + const { database, driver } = await setup(); + const now = Temporal.Instant.from("2026-08-30T18:30:00Z"); + const jobs = [ + ( + await driver.jobInsert({ + args: {}, + kind: "complete_job", + scheduledAt: now, + }) + ).job, + ( + await driver.jobInsert({ + args: {}, + kind: "complete_job", + scheduledAt: now, + }) + ).job, + ]; + const claimed = ( + await driver.jobClaim({ + attemptedBy: "completion-worker", + kinds: ["complete_job"], + queues: [{ limit: 2, name: "default" }], + }) + ).jobs; + expect(claimed).toHaveLength(2); + expect((await driver.jobCancelDetailed(jobs[1]!.id, { now })).status).toBe( + "cancelled" + ); + + const wrongOwner = await driver.jobCompleteMany([ + { + attempt: 1, + attemptedBy: "different-worker", + error: null, + id: jobs[0]!.id, + kind: "complete", + finalizedAt: Temporal.Now.instant(), + output: null, + outputSet: false, + scheduledAt: null, + }, + ]); + expect(wrongOwner[0]).toMatchObject({ + job: { state: "running" }, + status: "stale", + }); + + const completed = await driver.jobCompleteMany([ + { + attempt: 1, + attemptedBy: "completion-worker", + error: null, + id: jobs[0]!.id, + kind: "complete", + finalizedAt: Temporal.Now.instant(), + output: { ok: true }, + outputSet: true, + scheduledAt: null, + }, + { + attempt: 1, + attemptedBy: "completion-worker", + error: null, + id: jobs[1]!.id, + kind: "retry", + finalizedAt: null, + output: null, + outputSet: false, + scheduledAt: now, + }, + ]); + expect(completed.map(({ status }) => status)).toEqual([ + "applied", + "applied", + ]); + expect((await driver.jobGet(jobs[0]!.id))?.state).toBe("completed"); + expect((await driver.jobGet(jobs[1]!.id))?.state).toBe("cancelled"); + + const interrupted = ( + await driver.jobInsert({ + args: {}, + kind: "interrupt_job", + scheduledAt: now, + }) + ).job; + await driver.jobClaim({ + attemptedBy: "interrupt-worker", + kinds: ["interrupt_job"], + queues: [{ limit: 1, name: "default" }], + }); + expect( + ( + await driver.jobCompleteMany([ + { + attempt: 1, + attemptedBy: "interrupt-worker", + error: null, + id: interrupted.id, + kind: "interrupt", + finalizedAt: null, + output: null, + outputSet: false, + scheduledAt: now, + }, + ]) + )[0] + ).toMatchObject({ + job: { attempt: 0, errors: [], state: "available" }, + status: "applied", + }); + + const stale = await driver.jobCompleteMany([ + { + attempt: 1, + attemptedBy: "completion-worker", + error: null, + id: jobs[0]!.id, + kind: "discard", + finalizedAt: Temporal.Now.instant(), + output: null, + outputSet: false, + scheduledAt: null, + }, + ]); + expect(stale[0]).toMatchObject({ + job: { id: jobs[0]!.id, state: "completed" }, + key: `${jobs[0]!.id}:1:completion-worker`, + status: "stale", + }); + + const metadataMerged = await driver.jobCompleteMany([ + { + attempt: 1, + attemptedBy: "completion-worker", + error: null, + id: jobs[0]!.id, + kind: "complete", + finalizedAt: Temporal.Now.instant(), + metadata: { checkpoint: "same-attempt" }, + output: { delivered: true }, + outputSet: true, + scheduledAt: null, + }, + ]); + expect(metadataMerged[0]).toMatchObject({ + job: { + metadata: { + checkpoint: "same-attempt", + output: { delivered: true }, + }, + state: "completed", + }, + status: "stale", + }); + + database + .prepare( + "UPDATE river_job SET attempt = 2, attempted_by = jsonb('[\"new-owner\"]'), finalized_at = NULL, state = 'running' WHERE id = ?" + ) + .run(jobs[0]!.id); + const newerAttempt = await driver.jobCompleteMany([ + { + attempt: 1, + attemptedBy: "completion-worker", + error: null, + id: jobs[0]!.id, + kind: "complete", + finalizedAt: Temporal.Now.instant(), + metadata: { must_not_merge: true }, + output: null, + outputSet: false, + scheduledAt: null, + }, + ]); + expect(newerAttempt[0]).toMatchObject({ + job: { attempt: 2, state: "running" }, + status: "stale", + }); + expect( + (await driver.jobGet(jobs[0]!.id))?.metadata.must_not_merge + ).toBeUndefined(); + + const running = ( + await driver.jobInsert({ + args: {}, + kind: "batch_rollback", + scheduledAt: now, + }) + ).job; + await driver.jobClaim({ + attemptedBy: "worker", + kinds: ["batch_rollback"], + queues: [{ limit: 1, name: "default" }], + }); + await expect( + driver.jobCompleteMany([ + { + attempt: 1, + attemptedBy: "worker", + error: null, + id: running.id, + kind: "complete", + finalizedAt: Temporal.Now.instant(), + output: null, + outputSet: false, + scheduledAt: null, + }, + { + attempt: 0, + attemptedBy: "worker", + error: null, + id: running.id, + kind: "complete", + finalizedAt: Temporal.Now.instant(), + output: null, + outputSet: false, + scheduledAt: null, + }, + ]) + ).rejects.toThrow(expect.objectContaining({ code: "validation" })); + expect((await driver.jobGet(running.id))?.state).toBe("running"); + }); + + test("persists captured completion times for every outcome kind", async () => { + const { driver } = await setup(); + const finish = Temporal.Instant.from("2026-08-30T18:31:00.123600000Z"); + const scheduledAt = Temporal.Instant.from("2026-08-30T19:00:00Z"); + const kinds = [ + "cancel", + "complete", + "discard", + "interrupt", + "retry", + "snooze", + ] as const; + const jobs: SqliteJobRow[] = []; + for (const kind of kinds) { + jobs.push( + (await driver.jobInsert({ args: {}, kind: `completion_${kind}` })).job + ); + } + await driver.jobClaim({ + attemptedBy: "completion-timing-worker", + kinds: kinds.map((kind) => `completion_${kind}`), + queues: [{ limit: kinds.length, name: "default" }], + }); + + 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: "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.124Z", + "2026-08-30T18:31:00.124Z", + "2026-08-30T18:31:00.124Z", + null, + null, + null, + ]); + }); + + test("polls the durable outbox and publishes cancellation IDs exactly", async () => { + const { driver } = await setup(); + const exactID = 9_007_199_254_740_999n; + await driver.jobInsert({ args: {}, id: exactID, kind: "cancel_stream" }); + const controller = new AbortController(); + const ready = Promise.withResolvers(); + const iterator = driver + .runtimeNotificationSubscribe(["control"], controller.signal, () => { + ready.resolve(undefined); + }) + [Symbol.asyncIterator](); + const next = iterator.next(); + await ready.promise; + await driver.jobCancel(exactID); + const notification = await next; + expect(notification.done).toBe(false); + expect(notification.value?.topic).toBe("control"); + expect(notification.value?.payload).toContain( + `"job_id":${exactID.toString(10)}` + ); + controller.abort(); + await expect(iterator.next()).resolves.toEqual({ + done: true, + value: undefined, + }); + }); + + test("publishes leadership resignation requests through the durable outbox", async () => { + const { database, driver } = await setup(); + + await driver.runtimeRequestLeadershipResignation(); + + expect(notificationRows(database)).toEqual([ + { + payload: '{"action":"request_resign","leader_id":""}', + topic: "river_leadership", + }, + ]); + + const rollback = new Error("roll back resignation"); + await expect( + transaction(database, async (tx) => { + await driver.runtimeRequestLeadershipResignation({ tx }); + throw rollback; + }) + ).rejects.toBe(rollback); + expect(notificationRows(database)).toHaveLength(1); + + await transaction(database, async (tx) => { + await driver.runtimeRequestLeadershipResignation({ tx }); + }); + expect(notificationRows(database)).toHaveLength(2); + }); + + test("fences leadership renewals and supports expiry failover", async () => { + const { driver } = await setup(); + const firstAt = Temporal.Instant.from("2026-08-30T18:40:00Z"); + const first = await driver.leaderElect({ + leaderId: "leader-one", + now: firstAt, + ttlMs: 1_000, + }); + expect(first?.leaderId).toBe("leader-one"); + expect( + await driver.leaderElect({ + leaderId: "leader-two", + now: firstAt.add({ milliseconds: 500 }), + ttlMs: 1_000, + }) + ).toBeNull(); + const renewed = await driver.leaderReelect(first!, { + now: firstAt.add({ milliseconds: 500 }), + ttlMs: 1_000, + }); + expect(renewed?.expiresAt.toString()).toBe("2026-08-30T18:40:01.5Z"); + + const second = await driver.leaderElect({ + leaderId: "leader-two", + now: firstAt.add({ milliseconds: 1_501 }), + ttlMs: 1_000, + }); + expect(second?.leaderId).toBe("leader-two"); + expect( + await driver.leaderReelect(first!, { + now: firstAt.add({ milliseconds: 1_600 }), + ttlMs: 1_000, + }) + ).toBeNull(); + expect(await driver.leaderResign(first!)).toBe(false); + expect((await driver.leaderGet())?.leaderId).toBe("leader-two"); + expect(await driver.leaderResign(second!)).toBe(true); + expect(await driver.leaderGet()).toBeNull(); + }); + + test("renews only the held term and never adopts a same-ID term", async () => { + const { database, driver } = await setup(); + const now = Temporal.Now.instant(); + const first = (await driver.maintenanceLeaderAcquire( + "leader", + now, + 60_000, + null + ))!; + + // A live term is renewed only by its holder, like Go's elector. + expect( + await driver.maintenanceLeaderAcquire("leader", now, 60_000, null) + ).toBeNull(); + expect( + (await driver.maintenanceLeaderAcquire("leader", now, 60_000, first)) + ?.electedAt + ).toEqual(first.electedAt); + + // Another process with the same client ID takes over with a newer term. + database + .prepare( + "UPDATE river_leader SET elected_at = strftime('%Y-%m-%d %H:%M:%f', elected_at, '+1 second')" + ) + .run(); + expect( + await driver.maintenanceLeaderAcquire("leader", now, 60_000, first) + ).toBeNull(); + expect((await driver.leaderGet())?.electedAt).toEqual( + first.electedAt.add({ seconds: 1 }) + ); + }); + + test("cleans finalized jobs except in excluded queues", async () => { + const { database, driver } = await setup(); + const now = Temporal.Now.instant(); + const leader = (await driver.maintenanceLeaderAcquire( + "cleaner", + now, + 60_000, + null + ))!; + const ids: Record = {}; + for (const queue of ["kept", "cleaned"]) { + const { job } = await driver.jobInsert({ + args: {}, + kind: "cleanup", + queue, + }); + ids[queue] = job.id; + database + .prepare( + "UPDATE river_job SET state = 'completed', finalized_at = '2000-01-01 00:00:00.000' WHERE id = ?" + ) + .run(job.id); + } + + expect( + await driver.maintenanceCleanJobs( + leader, + { + cancelledBefore: now, + completedBefore: now, + discardedBefore: now, + limit: 10, + queuesExcluded: ["kept"], + }, + null, + new AbortController().signal + ) + ).toBe(1); + expect(await driver.jobGet(ids.kept as bigint)).not.toBeNull(); + expect(await driver.jobGet(ids.cleaned as bigint)).toBeNull(); + }); + + // Like Go's `QueuesFilteredBeforeLimit` driver cases: retained jobs in + // `kept1`/`kept2` hold the lowest IDs, so a pass limiting candidates + // before filtering queues would select only them and stall. + for (const testCase of [ + { + // `kept1` appears in both lists; exclusion wins. + batches: [2, 2, 1, 0], + deletedQueues: ["deleted1", "deleted2"], + name: "both lists", + queuesExcluded: ["kept1", "kept2"], + queuesIncluded: ["deleted1", "deleted2", "kept1"], + }, + { + batches: [2, 2, 2, 2, 2, 1, 0], + deletedQueues: ["deleted1", "deleted2", "kept1", "kept2"], + name: "an empty excluded list", + queuesExcluded: [], + }, + { + batches: [0], + deletedQueues: [], + name: "an empty included list", + queuesIncluded: [], + }, + { + batches: [2, 2, 1, 0], + deletedQueues: ["deleted1", "deleted2"], + name: "excluded queues", + queuesExcluded: ["kept1", "kept2"], + }, + { + batches: [2, 2, 1, 0], + deletedQueues: ["deleted1", "deleted2"], + name: "included queues", + queuesIncluded: ["deleted1", "deleted2"], + }, + { + batches: [0], + deletedQueues: [], + name: "a missing included queue", + queuesIncluded: ["missing"], + }, + { + batches: [2, 2, 2, 2, 2, 1, 0], + deletedQueues: ["deleted1", "deleted2", "kept1", "kept2"], + name: "a null included list", + queuesIncluded: null, + }, + ] as const) { + test(`cleans with queue filters before the batch limit: ${testCase.name}`, async () => { + const { database, driver } = await setup(); + const states = ["cancelled", "completed", "discarded"]; + const queues = [ + "kept1", + "kept2", + "kept1", + "kept2", + "kept1", + "kept2", + "deleted1", + "deleted2", + "deleted1", + "deleted2", + "deleted1", + ]; + const allIds: bigint[] = []; + const eligibleIds: bigint[] = []; + for (const [index, queue] of queues.entries()) { + const { job } = await driver.jobInsert({ + args: {}, + kind: "cleanup", + queue, + }); + database + .prepare( + "UPDATE river_job SET state = ?, finalized_at = '2000-01-01 00:00:00.000' WHERE id = ?" + ) + .run(states[index % states.length]!, job.id); + allIds.push(job.id); + if ((testCase.deletedQueues as readonly string[]).includes(queue)) { + eligibleIds.push(job.id); + } + } + + const before = Temporal.Now.instant(); + let deletedTotal = 0; + for (const wantDeleted of testCase.batches) { + const deleted = await driver.jobCleanup({ + cancelledBefore: before, + completedBefore: before, + discardedBefore: before, + limit: 2, + ...("queuesExcluded" in testCase + ? { queuesExcluded: testCase.queuesExcluded } + : {}), + ...("queuesIncluded" in testCase + ? { queuesIncluded: testCase.queuesIncluded } + : {}), + }); + expect(deleted).toBe(wantDeleted); + deletedTotal += deleted; + + // Batches delete the oldest eligible jobs first. + const gone = eligibleIds.slice(0, deletedTotal); + const remaining = database + .prepare("SELECT id FROM river_job ORDER BY id") + .all() + .map((row) => BigInt(row.id as number | bigint)); + expect(remaining).toEqual(allIds.filter((id) => !gone.includes(id))); + } + expect(deletedTotal).toBe(eligibleIds.length); + }); + } + + test("fences maintenance mutations to the exact current term", async () => { + const { driver } = await setup(); + const now = Temporal.Now.instant(); + const first = (await driver.maintenanceLeaderAcquire( + "leader-one", + now, + 60_000, + null + ))!; + const job = ( + await driver.jobInsert({ + args: {}, + kind: "fenced_scheduler", + scheduledAt: now, + state: "scheduled", + }) + ).job; + expect(await driver.maintenanceLeaderResign(first)).toBe(true); + const second = (await driver.maintenanceLeaderAcquire( + "leader-two", + now.add({ milliseconds: 1 }), + 60_000, + null + ))!; + const params = { + allowInsertNotifications: allowEveryQueue, + limit: 10, + notificationHorizon: now, + now, + scheduledAtHorizon: now, + }; + + expect(await driver.maintenanceSchedule(first, params)).toBe(0); + expect((await driver.jobGet(job.id))?.state).toBe("scheduled"); + expect(await driver.maintenanceSchedule(second, params)).toBe(1); + expect((await driver.jobGet(job.id))?.state).toBe("available"); + }); + + test("promotes ahead without prematurely notifying workers", async () => { + const { database, driver } = await setup(); + const now = Temporal.Now.instant(); + const leader = (await driver.maintenanceLeaderAcquire( + "leader", + now, + 60_000, + null + ))!; + const scheduledAt = now.add({ milliseconds: 100 }); + const job = ( + await driver.jobInsert({ + args: {}, + kind: "scheduler_lookahead", + scheduledAt, + state: "scheduled", + }) + ).job; + + expect( + await driver.maintenanceSchedule(leader, { + allowInsertNotifications: allowEveryQueue, + limit: 10, + notificationHorizon: now.add({ milliseconds: 5 }), + now, + scheduledAtHorizon: now.add({ seconds: 5 }), + }) + ).toBe(1); + + expect((await driver.jobGet(job.id))?.state).toBe("available"); + expect( + notificationRows(database).filter(({ topic }) => topic === "river_insert") + ).toEqual([]); + }); + + test("schedules unique jobs and runs bounded rescue and cleaners", async () => { + const { database, driver } = await setup(); + const now = Temporal.Instant.from("2026-08-30T18:50:00Z"); + const uniqueKey = new Uint8Array(32).fill(9); + await driver.jobInsert({ + args: {}, + kind: "unique_existing", + uniqueKey, + uniqueStates: ["available"], + }); + const conflict = ( + await driver.jobInsert({ + args: {}, + kind: "unique_scheduled", + scheduledAt: now, + state: "scheduled", + uniqueKey, + uniqueStates: ["available"], + }) + ).job; + const ordinary = ( + await driver.jobInsert({ + args: {}, + kind: "ordinary_scheduled", + scheduledAt: now, + state: "scheduled", + }) + ).job; + const scheduled = await driver.jobSchedule({ now }); + expect( + scheduled.map(({ conflictDiscarded, job }) => [job.id, conflictDiscarded]) + ).toEqual([ + [conflict.id, true], + [ordinary.id, false], + ]); + expect( + (await driver.jobGet(conflict.id))?.metadata.unique_key_conflict + ).toBe("scheduler_discarded"); + + const running = ( + await driver.jobInsert({ + args: {}, + kind: "rescue", + queue: "rescue", + scheduledAt: now, + }) + ).job; + await driver.jobClaim({ + attemptedBy: "worker", + kinds: [], + queues: [{ limit: 1, name: "rescue" }], + }); + const attemptedBefore = Temporal.Now.instant().add({ milliseconds: 1 }); + const stuck = await driver.jobGetStuck({ attemptedBefore }); + expect(stuck.some(({ id }) => id === running.id)).toBe(true); + const rescued = await driver.jobRescueMany( + [ + { + error: { at: now, attempt: 1, error: "stuck", trace: "trace" }, + id: running.id, + scheduledAt: now, + state: "retryable", + }, + ], + attemptedBefore + ); + expect(rescued[0]?.metadata["river:rescue_count"]).toBe(1); + + const old = now.subtract({ hours: 1 }); + database + .prepare( + "UPDATE river_job SET state = 'completed', finalized_at = ? WHERE id = ?" + ) + .run("2026-08-30 17:50:00.000", ordinary.id); + expect( + await driver.jobCleanup({ + cancelledBefore: old.add({ hours: 2 }), + completedBefore: old.add({ hours: 2 }), + discardedBefore: old.add({ hours: 2 }), + }) + ).toBeGreaterThan(0); + }); +}); + +/** A manually advanced monotonic clock for the driver's timing decisions. */ +class ManualClock { + #now = 0; + + advance(milliseconds: number): void { + this.#now += milliseconds; + } + + now(): number { + return this.#now; + } +} + +async function setup(options: SqliteDriverOptions = {}): Promise<{ + clock: ManualClock; + database: DatabaseSync; + driver: SqliteRuntime; +}> { + const clock = new ManualClock(); + const driver = testSqliteMemory({ + ...options, + [SQLITE_DRIVER_TEST_HOOKS]: { + now: () => clock.now(), + strictLockWindow: true, + }, + } as SqliteDriverOptions); + onTestFinished(() => driver.close()); + const database = driver.database; + await migrate(database); + return { clock, database, driver }; +} + +async function migrate(database: DatabaseSync): Promise { + const moduleUrl = new URL("../../../migrate/dist/index.js", import.meta.url); + const migrationModule = (await import(moduleUrl.href)) as { + createMigrator(target: { database: DatabaseSync }): { + migrateUp(): Promise; + }; + }; + await migrationModule.createMigrator({ database }).migrateUp(); +} + +function jobListParams(overrides: Partial = {}): JobListParams { + return { + after: null, + ids: [], + kinds: [], + limit: 100, + metadata: null, + priorities: [], + queues: [], + sortDirection: "asc", + sortField: "id", + states: [], + tagsAll: [], + tagsAny: [], + ...overrides, + }; +} + +function notificationRows( + database: DatabaseSync +): { payload: string; topic: string }[] { + const statement = database.prepare( + "SELECT payload, topic FROM river_notification ORDER BY id" + ); + statement.setReadBigInts(true); + return statement.all() as { payload: string; topic: string }[]; +} + +function resultRow( + database: DatabaseSync, + sql: string +): Record | undefined { + const statement = database.prepare(sql); + statement.setReadBigInts(true); + return statement.get(); +} + +/** 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/sqlite/src/driver.ts b/js/driver/sqlite/src/driver.ts new file mode 100644 index 000000000..04dfd9af6 --- /dev/null +++ b/js/driver/sqlite/src/driver.ts @@ -0,0 +1,2569 @@ +import { randomBytes, randomUUID } from "node:crypto"; +import { DatabaseSync, type DatabaseSyncOptions } from "node:sqlite"; +import { setTimeout as sleep } from "node:timers/promises"; +import { + LifecycleError, + RiverError, + toJsonObject, + TransactionScopeError, +} from "riverqueue"; +import type { + DriverInsertResult, + DriverRecord, + InsertDriver, + InsertDriverOptions, + JobInsertParams, + JobClaimOptions, + JobClaimParams, + JobClaimResult, + JobCompletionCommand, + JobCompletionResult, + JobDeleteManyParams, + JobDeleteResult, + JobListParams, + JobUpdateParams, + QueueListParams, + QueueRow, + QueueUpdateParams, + RuntimeJobCleanupParams, + RuntimeJobRescue, + RuntimeLeader, + RuntimeMaintenanceBatch, + RuntimeNotification, + RuntimeScheduleParams, + RuntimeWaitOptions, + PilotDatabase, + RuntimeDriver, +} from "riverqueue/unstable-driver"; +import { + durationToMilliseconds, + LinkedAbortSignal, + queueMetadataUpdate, + registerDriver, +} from "riverqueue/unstable-driver"; + +import { + JOB_COLUMNS, + QUEUE_COLUMNS, + decodeJobRow, + decodeJobRowPartial, + decodeQueueRow, + encodeEncodedJson, + encodeJson, + encodeUniqueStates, + invalidJsonTextSql, + sqliteTimestamp, + sqliteTimestampOrNull, + validateInt64, + validateLookupName, + validateName, + validateSmallInteger, +} from "./codecs.js"; +import { FifoLock, retryBusy, type BusyRetryPolicy } from "./coordination.js"; +import { eventLoopTurns, retainTurnCounter } from "./strict.js"; +import { + backendMismatchError, + configurationError, + databaseError, + invalidInputError, + invalidRowError, + isRetryableSqliteError, + isSqliteError, + SQLITE_BACKEND, +} from "./errors.js"; +import { + claimJobs, + cleanupJobs, + completeJobs, + leaderAttemptElect, + leaderAttemptReelect, + leaderGet, + leaderResign, + listJobs, + listJobsMetadataPage, + loadClaimedJobs, + notificationCleanup, + notificationLastId, + notificationPoll, + queueDeleteExpired, + rescueJobs, + resultStatement, + scheduleJobs, + stuckJobs, + updateJob, +} from "./operations.js"; +import type { + SqliteCancelResult, + SqliteCleanupJobsParams, + SqliteDeleteResult, + SqliteDriverOptions, + SqliteInsertJobParams, + SqliteInsertResult, + SqliteJobRow, + SqliteJsonObject, + SqliteLeader, + SqliteNotification, + SqliteOperationOptions, + SqliteQueueRow, + SqliteRescueJobParams, + SqliteRetryResult, + SqliteRiverScope, + SqliteScheduleResult, +} from "./types.js"; +import { + databaseKey, + fileKey, + openFrames, + registerDatabase, + riverFrame, + runInFrame, + sameDatabase, + type TransactionFrame, +} from "./scope.js"; + +const BUSY_TIMEOUT_MS_DEFAULT = 5_000; +/** Notifications a subscription reads per poll, like Go's SQLite listener. */ +const NOTIFICATION_BATCH_SIZE = 256; +const NOTIFICATION_TOPIC_CONTROL = "river_control"; +const NOTIFICATION_TOPIC_INSERT = "river_insert"; +const UNIQUE_NONCE_KEY = "river:unique_nonce"; +const NON_RUNNING_JOB_STATES = Object.freeze([ + "available", + "cancelled", + "completed", + "discarded", + "pending", + "retryable", + "scheduled", +] as const); +const UNIQUE_STATE_MATCH_SQL = ` + CASE state + WHEN 'available' THEN unique_states & (1 << 0) + WHEN 'cancelled' THEN unique_states & (1 << 1) + WHEN 'completed' THEN unique_states & (1 << 2) + WHEN 'discarded' THEN unique_states & (1 << 3) + WHEN 'pending' THEN unique_states & (1 << 4) + WHEN 'retryable' THEN unique_states & (1 << 5) + WHEN 'running' THEN unique_states & (1 << 6) + WHEN 'scheduled' THEN unique_states & (1 << 7) + ELSE 0 + END >= 1`; +const INSERT_COLUMNS_SQL = ` + id, args, attempt, attempted_at, attempted_by, created_at, errors, + finalized_at, kind, max_attempts, metadata, priority, queue, + scheduled_at, state, tags, unique_key, unique_states`; +// Like River for Go, a row without a creation or scheduled time takes the +// database's current time, the same for both within one statement. +const INSERT_VALUES_SQL = ` + ?, jsonb(?), ?, ?, jsonb(?), coalesce(?, datetime('now', 'subsec')), + jsonb(?), ?, ?, ?, jsonb(?), ?, ?, coalesce(?, datetime('now', 'subsec')), + ?, jsonb(?), ?, ?`; + +/** + * Deterministic clock and sleep used by tests. Not part of the public API. + * @internal + */ +export const SQLITE_DRIVER_TEST_HOOKS = Symbol.for( + "riverqueue.sqlite.driver.test_hooks" +); + +/** + * Test-only driver options, passed under {@link SQLITE_DRIVER_TEST_HOOKS}. + * The key is a registered symbol so River's and its first-party + * extensions' test suites can reach it without a public export. + * @internal + */ +interface SqliteDriverTestHooks { + readonly now?: () => number; + readonly sleep?: (milliseconds: number) => Promise; + /** + * Fail every River transaction that stays open across a turn of the + * event loop, deterministically, unlike the default probe. It hooks every + * async resource in the process while the driver is open, so it is only + * for tests and conformance runs. + */ + readonly strictLockWindow?: boolean; +} + +/** The memdb URI `SqliteDriver.memory()` passes to its constructor. */ +const MEMORY_LOCATION = Symbol("riverqueue.sqlite.memory_location"); + +/** + * One transaction River owns on its private connection. It begins lazily, + * with `BEGIN IMMEDIATE` at its first River statement, so work an insert + * middleware does before calling `next()` holds no lock. The object itself + * is the opaque `tx` passed to an operation scope's callback. + */ +class OwnedScope { + /** Settles once `BEGIN IMMEDIATE` succeeded or failed. */ + beginning: Promise | null = null; + readonly driver: SqliteRuntime; + /** Why the probe rolled the transaction back, once it has. */ + failure: TransactionScopeError | null = null; + readonly frame: TransactionFrame; + /** Whether the transaction holds River's connection lock. */ + holdsLock = false; + /** + * Aborts River calls from inside this transaction's async context that + * wait for the connection lock, once the transaction takes it: they would + * wait for the transaction, which waits for them. + */ + readonly lockWaiters = new Set<() => void>(); + /** + * How many pilot transactions are running in this transaction. While any + * is, River's private connection passed as `{ tx }` from inside the + * transaction stands for it. + */ + pilotDepth = 0; + /** Fires at the event loop's next turn to check the transaction ended. */ + probe: NodeJS.Immediate | null = null; + /** Event loop turns counted when the transaction began, in strict mode. */ + turnsAtBegin = 0; + /** Releases the connection lock held while the transaction is open. */ + release: (() => void) | null = null; + state: + "beginning" | "committing" | "ended" | "idle" | "open" | "rolled_back" = + "idle"; + + constructor(driver: SqliteRuntime, key: string) { + this.driver = driver; + this.frame = riverFrame(driver, key, this, () => this.holdsLock); + } +} + +/** SQLite bindings for one normalized job insertion. */ +interface InsertValues { + readonly args: string; + readonly attempt: number; + readonly attemptedAt: string | null; + readonly attemptedBy: string | null; + /** Null to use the database's current time. */ + readonly createdAt: string | null; + readonly errors: string | null; + readonly finalizedAt: string | null; + readonly id: bigint | null; + readonly kind: string; + readonly maxAttempts: number; + readonly metadata: string; + readonly priority: number; + readonly queue: string; + /** Null to use the database's current time. */ + readonly scheduledAt: string | null; + readonly state: string; + readonly tags: string; + readonly uniqueKey: Uint8Array | null; + readonly uniqueNonce: string; + readonly uniqueStates: bigint | null; +} + +/** + * Complete SQLite storage backend on Node's built-in `node:sqlite`. + * + * Like River for Go, River runs on a connection of its own. The driver + * opens a private `DatabaseSync` on the application's database file (in WAL + * mode, with a zero busy timeout) and never hands it out, so application + * statements can't join River's transactions, and River's can't join the + * application's. When another connection or process holds SQLite's write + * lock, River retries with an asynchronous backoff so the event loop keeps + * running. + * + * Pass `{ tx }` to run a River operation in an application transaction: any + * `DatabaseSync` on the same database with a transaction open, such as one + * begun with {@link transaction}. River runs its statements directly in + * that transaction, opening no savepoint, and never ends it. When a River + * call fails, statements it already ran stay in the transaction, so roll + * the transaction back; to recover and continue it instead, wrap the call + * in a savepoint of your own. + * + * An insertion without `{ tx }` runs in a transaction River owns, begun at + * its first statement, which holds SQLite's write lock until it commits. + * Insert middleware and hooks run inside it, so on SQLite they must not + * await I/O after `next()`. When River's transaction is still open at the + * event loop's next turn, River rolls it back, releasing the lock, and fails + * the operation with a `TransactionScopeError`. That check finds most such + * mistakes, including all slow I/O and every insertion started from an I/O + * callback such as an HTTP handler, but not fast local I/O awaited in an + * insertion started from a timer or `setImmediate` callback. + * + * Statements block the event loop while they run. Use a dedicated Node + * process for a heavily loaded worker so database work can't stall an HTTP + * server's event loop. + */ +export class SqliteDriver implements Disposable { + /** + * Type-only marker: a full runtime driver whose transactions are + * application handles with a transaction open. + */ + declare readonly "~river"?: { + readonly capability: "runtime"; + readonly transaction: DatabaseSync; + }; + + readonly #runtime: SqliteRuntime; + + /** + * Create a driver for the database `database` is open on. River opens its + * own connection to the same file and never uses or closes `database`. + * + * An in-memory database can't be shared between connections this way: + * use {@link SqliteDriver.memory} instead. + * + * @throws {ConfigurationError} when `database` is in memory or closed. + */ + constructor(database: DatabaseSync, options: SqliteDriverOptions = {}) { + this.#runtime = new SqliteRuntime(database, options); + registerDriver(this, this.#runtime.driverRecord()); + } + + /** + * Create a driver for a new, empty in-memory database. + * + * A `:memory:` database belongs to one connection, so River can't open + * its own connection to an application's. This creates a uniquely named + * in-memory database (SQLite's `memdb` VFS) that River's connection and + * application handles share. Get handles with {@link connect}. The + * database lives until the driver and every handle from `connect()` are + * closed. + */ + static memory(options: SqliteDriverOptions = {}): SqliteDriver { + return memoryDatabase( + (database, memoryOptions) => new SqliteDriver(database, memoryOptions), + options + ); + } + + /** + * Close River's private connection. Stop every client using the driver + * first. Handles from {@link connect} and the one passed to the + * constructor stay open. Closing twice does nothing. + */ + close(): void { + this.#runtime.close(); + } + + /** + * Open another application handle on this driver's database. The caller + * owns and closes it. + * + * `options` are `node:sqlite`'s. The busy `timeout` defaults to zero, so a + * statement that meets another connection's write lock fails at once + * instead of blocking the event loop. Pass a nonzero `timeout` when other + * processes write the database (see the package README). + */ + connect(options: DatabaseSyncOptions = {}): DatabaseSync { + return this.#runtime.connect(options); + } + + /** Same as {@link close}, for `using` declarations. */ + [Symbol.dispose](): void { + this.close(); + } +} + +/** + * Create a driver on a new uniquely named in-memory database, whose first + * application handle the driver owns and closes. + */ +function memoryDatabase( + create: (database: DatabaseSync, options: SqliteDriverOptions) => Driver, + options: SqliteDriverOptions +): Driver { + const location = `file:/river-${randomUUID()}?vfs=memdb`; + const database = new DatabaseSync(location, { timeout: 0 }); + try { + return create(database, { + ...options, + [MEMORY_LOCATION]: location, + } as SqliteDriverOptions); + } catch (error: unknown) { + database.close(); + throw error; + } +} + +/** + * @internal A registered driver whose operations are callable, for this + * package's tests. + */ +export function testSqliteDriver( + database: DatabaseSync, + options: SqliteDriverOptions = {} +): SqliteRuntime { + const runtime = new SqliteRuntime(database, options); + registerDriver(runtime, runtime.driverRecord()); + return runtime; +} + +/** @internal Like {@link testSqliteDriver}, on a new in-memory database. */ +export function testSqliteMemory( + options: SqliteDriverOptions = {} +): SqliteRuntime { + return memoryDatabase(testSqliteDriver, options); +} + +/** + * @internal The operations behind a {@link SqliteDriver}, which River + * reaches through its private driver registry. + */ +export class SqliteRuntime implements RuntimeDriver< + DatabaseSync | SqliteRiverScope +> { + declare readonly "~river"?: { + readonly capability: "runtime"; + readonly transaction: DatabaseSync; + }; + + /** @internal */ + readonly backend = "sqlite"; + /** @internal */ + readonly capabilities = Object.freeze({ + insert: true, + leadership: true, + maintenance: true, + notifications: true, + runtime: true, + transactions: true, + }); + /** The application handle the driver was created with. */ + readonly #application: DatabaseSync; + readonly #busyPolicy: BusyRetryPolicy; + #closed = false; + /** River's private connection. */ + readonly #connection: DatabaseSync; + /** Application handles River opened or was given, for diagnostics. */ + readonly #handles = new Set>(); + /** Identifies the database, as `databaseKey` does for its handles. */ + readonly #key: string; + /** The path or URI River opens connections to. */ + readonly #location: string; + /** Stops strict mode's turn counter, when this driver started it. */ + readonly #releaseTurnCounter: (() => void) | null = null; + /** Serializes River's operations on its private connection. */ + readonly #lock = new FifoLock(); + /** The River transaction holding {@link #lock}, while one does. */ + #lockHolder: OwnedScope | null = null; + /** Whether `close()` also closes {@link #application}. */ + readonly #ownsApplication: boolean; + /** + * Whether the switch to WAL still has to happen, because the database was + * busy when the driver was constructed. + */ + #walPending = false; + + /** + * Create a driver for the database `database` is open on. River opens its + * own connection to the same file and never uses or closes `database`. + * + * An in-memory database can't be shared between connections this way: + * use {@link SqliteDriver.memory} instead. + * + * @throws {ConfigurationError} when `database` is in memory or closed. + */ + constructor(database: DatabaseSync, options: SqliteDriverOptions = {}) { + const internal = options as SqliteDriverOptions & { + readonly [MEMORY_LOCATION]?: string; + readonly [SQLITE_DRIVER_TEST_HOOKS]?: SqliteDriverTestHooks; + }; + if (!(database instanceof DatabaseSync) || !database.isOpen) { + throw configurationError( + "construct", + "SqliteDriver requires an open node:sqlite DatabaseSync" + ); + } + const memoryLocation = internal[MEMORY_LOCATION]; + const location = memoryLocation ?? database.location(); + if (location === null) { + throw configurationError( + "construct", + "SqliteDriver opens its own connection to the application's " + + "database, which an in-memory database can't share; use " + + "SqliteDriver.memory() and open application handles with " + + "driver.connect()" + ); + } + const busyTimeoutMs = validateSmallInteger( + options.busyTimeout === undefined + ? BUSY_TIMEOUT_MS_DEFAULT + : durationToMilliseconds("busyTimeout", options.busyTimeout, { + allowZero: true, + }), + "busyTimeout", + 0, + 2_147_483_647 + ); + const hooks = internal[SQLITE_DRIVER_TEST_HOOKS]; + if (hooks?.strictLockWindow === true) { + this.#releaseTurnCounter = retainTurnCounter(); + } + this.#busyPolicy = { + now: hooks?.now ?? (() => performance.now()), + sleep: hooks?.sleep ?? ((milliseconds) => sleep(milliseconds)), + timeoutMs: busyTimeoutMs, + }; + this.#application = database; + this.#location = location; + this.#ownsApplication = memoryLocation !== undefined; + this.#key = + memoryLocation === undefined + ? (fileKey(location) ?? `path:${location}`) + : `memdb:${location}`; + this.#addHandle(database); + const opened = this.#databaseOperation("configure", () => + openConnection(location, memoryLocation === undefined) + ); + this.#connection = opened.connection; + this.#walPending = opened.walPending; + } + + /** What River's private registry records about this driver. */ + driverRecord(): DriverRecord { + return { + backend: this.backend, + capability: "runtime", + database: this.#pilotDatabase() as PilotDatabase, + // River's own connection, which migrations share with its operations, + // so they run under River's lock and retry a busy database like them. + migration: { + database: this.#connection, + run: (attempt: (database: object) => T) => + this.#exclusive("migrate", () => + retryBusy(this.#busyPolicy, () => attempt(this.#connection)) + ), + }, + operations: this as InsertDriver, + }; + } + + /** + * The application handle the driver was created with, for this + * package's tests. + */ + get database(): DatabaseSync { + return this.#application; + } + + /** + * Close River's private connection, and for {@link SqliteDriver.memory} + * its {@link database} handle. Stop every client using the driver first. + * Handles from {@link connect} and the one passed to the constructor stay + * open. Closing twice does nothing. + */ + close(): void { + if (this.#closed) return; + this.#closed = true; + this.#releaseTurnCounter?.(); + // Closing rolls back a transaction River still has open. + this.#connection.close(); + if (this.#ownsApplication && this.#application.isOpen) { + this.#application.close(); + } + } + + /** + * Open another application handle on this driver's database. The caller + * owns and closes it. + * + * `options` are `node:sqlite`'s. The busy `timeout` defaults to zero, so a + * statement that meets another connection's write lock fails at once + * instead of blocking the event loop. Pass a nonzero `timeout` when other + * processes write the database (see the package README). + */ + connect(options: DatabaseSyncOptions = {}): DatabaseSync { + this.#assertOpen("connect"); + const database = new DatabaseSync(this.#location, { + timeout: 0, + ...options, + }); + this.#addHandle(database); + return database; + } + + /** Same as {@link close}, for `using` declarations. */ + [Symbol.dispose](): void { + this.close(); + } + + /** @internal */ + async jobCancel( + id: bigint, + options: InsertDriverOptions = {} + ): Promise { + const result = await this.jobCancelDetailed(id, options); + return result.status === "not_found" ? null : result.job; + } + + /** @internal */ + async jobCancelDetailed( + id: bigint, + options: SqliteOperationOptions & { now?: Temporal.Instant } = {} + ): Promise { + validateInt64(id, "id"); + const now = options.now ?? Temporal.Now.instant(); + return this.#write("cancel", options, (database) => { + const raw = resultStatement( + database, + ` + UPDATE river_job + SET + state = CASE WHEN state = 'running' THEN state ELSE 'cancelled' END, + finalized_at = CASE WHEN state = 'running' THEN finalized_at ELSE ? END, + metadata = jsonb_set(metadata, '$.cancel_attempted_at', ?) + WHERE id = ? + AND state NOT IN ('cancelled', 'completed', 'discarded') + AND finalized_at IS NULL + RETURNING ${JOB_COLUMNS} + ` + ).get(sqliteTimestamp(now), now.toString(), id); + const updated = raw === undefined ? null : decodeJobRowPartial(raw).job; + const job = updated ?? getJob(database, id, { partial: true }); + if (job === null) return { status: "not_found" }; + + if (updated !== null) { + // River for Go's `NotificationInsertJobCancel`, in the same + // transaction. + insertNotification( + database, + NOTIFICATION_TOPIC_CONTROL, + `{"action":"cancel","job_id":${updated.id},"queue":${JSON.stringify(updated.queue)}}` + ); + } + return { + job, + status: updated === null ? "unchanged" : "cancelled", + }; + }); + } + + /** @internal */ + async jobDelete( + id: bigint, + options: InsertDriverOptions = {} + ): Promise { + return this.jobDeleteDetailed(id, options); + } + + /** @internal */ + async jobDeleteMany( + params: JobDeleteManyParams, + options: InsertDriverOptions = {} + ): Promise { + if ( + !Number.isSafeInteger(params.limit) || + params.limit < 1 || + params.limit > 10_000 + ) { + throw new RangeError("bulk delete maximum must be from 1 to 10000"); + } + const hasFilter = + params.ids.length > 0 || + params.kinds.length > 0 || + params.priorities.length > 0 || + params.queues.length > 0 || + params.states.length > 0; + if (!params.all && !hasFilter) { + throw new RangeError("bulk delete requires a filter or all=true"); + } + if (params.all && hasFilter) { + throw new RangeError( + "bulk delete all=true cannot be combined with filters" + ); + } + + const requestedStates = params.all + ? NON_RUNNING_JOB_STATES + : params.states.length === 0 + ? NON_RUNNING_JOB_STATES + : params.states.filter((state) => state !== "running"); + if (requestedStates.length === 0) return []; + + return this.#write("job_delete_many", options, (database) => { + const jobs = listJobs(database, { + after: null, + ids: params.ids, + kinds: params.kinds, + limit: params.limit, + metadata: null, + priorities: params.priorities, + queues: params.queues, + sortDirection: "asc", + sortField: "id", + states: requestedStates, + tagsAll: [], + tagsAny: [], + }); + if (jobs.length === 0) return []; + + const statement = resultStatement( + database, + `DELETE FROM river_job + WHERE id = ? AND state != 'running' + RETURNING ${JOB_COLUMNS}` + ); + const deleted: SqliteJobRow[] = []; + for (const job of jobs) { + const raw = statement.get(job.id); + if (raw !== undefined) deleted.push(decodeJobRowPartial(raw).job); + } + return deleted; + }); + } + + /** @internal */ + async jobDeleteDetailed( + id: bigint, + options: SqliteOperationOptions = {} + ): Promise { + validateInt64(id, "id"); + return this.#write("delete", options, (database) => { + const raw = resultStatement( + database, + `DELETE FROM river_job + WHERE id = ? AND state != 'running' + RETURNING ${JOB_COLUMNS}` + ).get(id); + if (raw !== undefined) + return { job: decodeJobRowPartial(raw).job, status: "deleted" }; + const job = getJob(database, id, { partial: true }); + if (job === null) return { status: "not_found" }; + return job.state === "running" + ? { job, status: "running" } + : { status: "not_found" }; + }); + } + + /** @internal */ + async jobGet( + id: bigint, + options: SqliteOperationOptions = {} + ): Promise { + validateInt64(id, "id"); + return this.#read("get", options, (database) => getJob(database, id)); + } + + /** + * The IDs among `ids` of running jobs with a cancellation request, like + * River for Go's `JobGetCancelRequested`. A poll-only runtime checks its + * running attempts with it. + * @internal + */ + async jobGetCancelRequested( + ids: readonly bigint[], + options: RuntimeWaitOptions = {} + ): Promise { + options.signal?.throwIfAborted(); + if (ids.length === 0) return []; + for (const id of ids) validateInt64(id, "id"); + return this.#read("job_get_cancel_requested", {}, (database) => + resultStatement( + database, + // Metadata that isn't valid JSON has no cancellation request, so it + // can't fail the lookup for the other jobs, as in Go. + `SELECT id FROM river_job + WHERE id IN (SELECT value FROM json_each(?)) + AND (CASE WHEN ${invalidJsonTextSql("metadata")} THEN NULL + ELSE metadata -> 'cancel_attempted_at' END) IS NOT NULL + AND state = 'running' + ORDER BY id` + ) + .all(`[${ids.map((id) => id.toString(10)).join(",")}]`) + .map(({ id }) => id as bigint) + ); + } + + /** @internal */ + async jobList( + params: JobListParams, + options: InsertDriverOptions = {} + ): Promise { + if (params.metadata === null) { + return this.#read("job_list", options, (database) => + listJobs(database, { ...params, metadata: null }) + ); + } + + // Filter metadata one bounded page at a time, yielding to the event loop + // (and, outside a transaction, releasing the handle) between pages so a + // sparse match can't block either for a whole-table scan. + const limit = validateSmallInteger(params.limit, "limit", 0, 10_000); + const selected: SqliteJobRow[] = []; + let after = params.after; + while (selected.length < limit) { + const page = await this.#read("job_list", options, (database) => + listJobsMetadataPage(database, { + ...params, + after, + limit: limit - selected.length, + }) + ); + selected.push(...page.jobs); + if (page.next === null) break; + after = page.next; + await new Promise((resolve) => setImmediate(resolve)); + } + return selected; + } + + /** @internal */ + async jobUpdate( + id: bigint, + params: JobUpdateParams, + options: InsertDriverOptions = {} + ): Promise { + return this.#write("job_update", options, (database) => + updateJob(database, id, params) + ); + } + + /** @internal */ + async jobClaim( + params: JobClaimParams, + options: JobClaimOptions = {} + ): Promise { + return this.#write( + "job_claim", + options.tx === undefined ? {} : { tx: options.tx }, + (database) => claimJobs(database, params) + ); + } + + /** @internal */ + async jobCompleteMany( + items: readonly JobCompletionCommand[], + options: { + readonly signal?: AbortSignal; + readonly tx?: DatabaseSync | SqliteRiverScope; + } = {} + ): Promise { + if (items.length === 0) return []; + options.signal?.throwIfAborted(); + return this.#write("job_complete_many", options, (database) => + completeJobs(database, items) + ); + } + + /** @internal */ + async jobGetStuck( + params: { + afterId?: bigint; + attemptedBefore: Temporal.Instant; + limit?: number; + }, + options: SqliteOperationOptions = {} + ): Promise { + return this.#read("job_get_stuck", options, (database) => + stuckJobs(database, params) + ); + } + + /** + * Rescue jobs selected with `attemptedBefore`, skipping any that are no + * longer running with an attempt before that same horizon. + * @internal + */ + async jobRescueMany( + items: readonly SqliteRescueJobParams[], + attemptedBefore: Temporal.Instant, + options: SqliteOperationOptions = {} + ): Promise { + if (items.length === 0) return []; + return this.#write("job_rescue_many", options, (database) => + rescueJobs(database, items, attemptedBefore) + ); + } + + /** @internal */ + async jobCleanup( + params: SqliteCleanupJobsParams, + options: SqliteOperationOptions = {} + ): Promise { + return this.#write("job_cleanup", options, (database) => + cleanupJobs(database, params) + ); + } + + /** @internal */ + async jobSchedule( + params: { + limit?: number; + now?: Temporal.Instant; + scheduledAtHorizon?: Temporal.Instant; + } = {}, + options: SqliteOperationOptions = {} + ): Promise { + return this.#write("job_schedule", options, (database) => + scheduleJobs(database, params) + ); + } + + /** + * Insert one resolved job and its wakeup in the same SQLite transaction. + * + * 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; + /** @internal */ + jobInsert( + params: SqliteInsertJobParams, + options?: SqliteOperationOptions + ): Promise>; + async jobInsert( + params: SqliteInsertJobParams, + options: SqliteOperationOptions = {} + ): Promise> { + // Validate before writing, so invalid input writes nothing even in a + // caller's transaction. + const values = this.#normalizeInsert(params); + return this.#write("insert", options, (database) => + this.#insertRow(database, values) + ); + } + + /** + * Insert an ordered batch atomically on the caller-owned SQLite handle. + * + * 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; + /** @internal */ + jobInsertMany( + params: readonly SqliteInsertJobParams[], + options?: SqliteOperationOptions + ): Promise[]>; + async jobInsertMany( + params: readonly SqliteInsertJobParams[], + options: SqliteOperationOptions = {} + ): Promise { + if (params.length === 0) return []; + // Validate every row before writing any of them, so an invalid row + // writes nothing even in a caller's transaction. + const values = params.map((item) => this.#normalizeInsert(item)); + return this.#write("insert_many", options, (database) => + values.map((item) => this.#insertRow(database, item)) + ); + } + + /** + * Notify producers of new jobs in each of `queues`, delivered once the + * transaction commits. + * + * 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. + */ + async notifyInsert( + queues: readonly string[], + options: InsertDriverOptions = {} + ): Promise { + if (queues.length === 0) return; + await this.#write("notify_insert", options, (database) => { + writeInsertNotifications(database, queues); + }); + } + + /** @internal */ + async queueList( + params: QueueListParams, + options: InsertDriverOptions = {} + ): Promise { + const limit = validateSmallInteger(params.limit, "limit", 0, 1_000_000); + if (limit === 0) return []; + return this.#read("queue_list", options, (database) => + resultStatement( + database, + `SELECT ${QUEUE_COLUMNS} + FROM river_queue + WHERE (? IS NULL OR name > ?) + ORDER BY name ASC LIMIT ?` + ) + .all(params.nameAfter, params.nameAfter, limit) + .map(decodeQueueRow) + ); + } + + /** @internal */ + async queueGet( + name: string, + options: SqliteOperationOptions = {} + ): Promise { + validateLookupName(name, "name"); + return this.#read("queue_get", options, (database) => { + const raw = resultStatement( + database, + `SELECT ${QUEUE_COLUMNS} FROM river_queue WHERE name = ?` + ).get(name); + return raw === undefined ? null : decodeQueueRow(raw); + }); + } + + /** @internal */ + async queuePause( + name: string, + options: InsertDriverOptions = {} + ): Promise { + const queues = await this.#queueSetPaused(name, true, options); + return name === "*" ? null : (queues[0] ?? null); + } + + /** @internal */ + async queueResume( + name: string, + options: InsertDriverOptions = {} + ): Promise { + const queues = await this.#queueSetPaused(name, false, options); + return name === "*" ? null : (queues[0] ?? null); + } + + /** @internal */ + async queueUpsert( + name: string, + params: { + metadata?: SqliteJsonObject; + now?: Temporal.Instant; + pausedAt?: Temporal.Instant | null; + } = {}, + options: SqliteOperationOptions = {} + ): Promise { + validateName(name, "name"); + const now = params.now ?? Temporal.Now.instant(); + return this.#write("queue_upsert", options, (database) => { + const raw = resultStatement( + database, + ` + INSERT INTO river_queue (created_at, metadata, name, paused_at, updated_at) + VALUES (?, jsonb(?), ?, ?, ?) + ON CONFLICT (name) DO UPDATE SET updated_at = excluded.updated_at + RETURNING ${QUEUE_COLUMNS} + ` + ).get( + sqliteTimestamp(now), + encodeJson(params.metadata ?? {}, "metadata"), + name, + sqliteTimestampOrNull(params.pausedAt), + sqliteTimestamp(now) + ); + if (raw === undefined) { + throw databaseError( + "queue_upsert", + "SQLite queue upsert returned no row" + ); + } + return decodeQueueRow(raw); + }); + } + + /** @internal */ + async queueUpdate( + name: string, + params: QueueUpdateParams, + options: InsertDriverOptions = {} + ): Promise { + validateLookupName(name, "name"); + const now = Temporal.Now.instant(); + const update = queueMetadataUpdate(name, params); + return this.#write("queue_update", options, (database) => { + const raw = resultStatement( + database, + ` + UPDATE river_queue + SET metadata = CASE WHEN ? THEN jsonb(?) ELSE metadata END, + updated_at = ? + WHERE name = ? + RETURNING ${QUEUE_COLUMNS} + ` + ).get( + update === undefined ? 0 : 1, + update?.text ?? "{}", + sqliteTimestamp(now), + name + ); + if (raw === undefined) return null; + const queue = decodeQueueRow(raw); + if (update !== undefined) { + insertNotification( + database, + NOTIFICATION_TOPIC_CONTROL, + update.notification + ); + } + return queue; + }); + } + + /** @internal */ + async queueCleanup( + params: { limit?: number; updatedBefore: Temporal.Instant }, + options: SqliteOperationOptions = {} + ): Promise { + return this.#write("queue_cleanup", options, (database) => + queueDeleteExpired(database, params) + ); + } + + /** @internal */ + async leaderElect( + params: { leaderId: string; now?: Temporal.Instant; ttlMs: number }, + options: SqliteOperationOptions = {} + ): Promise { + return this.#write("leader_elect", options, (database) => + leaderAttemptElect(database, params) + ); + } + + /** @internal */ + async leaderReelect( + leader: SqliteLeader, + params: { now?: Temporal.Instant; ttlMs: number }, + options: SqliteOperationOptions = {} + ): Promise { + return this.#write("leader_reelect", options, (database) => + leaderAttemptReelect(database, leader, params) + ); + } + + /** @internal */ + async leaderGet( + options: SqliteOperationOptions = {} + ): Promise { + return this.#read("leader_get", options, leaderGet); + } + + /** @internal */ + async leaderResign( + leader: Pick, + options: SqliteOperationOptions = {} + ): Promise { + return this.#write("leader_resign", options, (database) => + leaderResign(database, leader) + ); + } + + /** @internal */ + async notificationPoll( + params: { + afterId?: bigint; + limit?: number; + topics?: readonly string[]; + } = {}, + options: SqliteOperationOptions = {} + ): Promise { + return this.#read("notification_poll", options, (database) => + notificationPoll(database, params) + ); + } + + /** @internal */ + async notificationLastId( + options: SqliteOperationOptions = {} + ): Promise { + return this.#read("notification_last_id", options, notificationLastId); + } + + /** @internal */ + async notificationCleanup( + params: { createdBefore: Temporal.Instant; limit?: number }, + options: SqliteOperationOptions = {} + ): Promise { + return this.#write("notification_cleanup", options, (database) => + notificationCleanup(database, params) + ); + } + + /** + * Poll the durable outbox for the given topics. Like Go's SQLite listener, a + * subscription starts after the outbox's current last ID, so it never + * replays rows written before it, including rows written while an earlier + * subscription was closed. Its cursor and unread rows end with it. + * + * @internal + */ + async *runtimeNotificationSubscribe( + topics: readonly RuntimeNotification["topic"][], + signal: AbortSignal, + ready: () => void + ): AsyncGenerator { + let afterId = await this.notificationLastId(); + const backendTopics = topics.map(runtimeTopicName); + ready(); + while (!signal.aborted) { + const notifications = await this.notificationPoll({ + afterId, + limit: NOTIFICATION_BATCH_SIZE, + topics: backendTopics, + }); + for (const notification of notifications) { + // Like Go's listener, closing discards rows read but not delivered. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the signal can abort while a row is yielded + if (signal.aborted) return; + afterId = notification.id; + yield { + payload: notification.payload, + topic: runtimeTopic(notification.topic), + }; + } + if (notifications.length === 0) await waitForPoll(100, signal); + } + } + + /** @internal */ + async runtimeQueueUpsert( + name: string, + now: Temporal.Instant + ): Promise { + return this.queueUpsert(name, { now }); + } + + /** @internal */ + async runtimeRequestLeadershipResignation( + options: SqliteOperationOptions = {} + ): Promise { + await this.#write( + "runtime_request_leadership_resignation", + options, + (database) => { + insertNotification( + database, + "river_leadership", + '{"action":"request_resign","leader_id":""}' + ); + } + ); + } + + /** @internal */ + async maintenanceLeaderAcquire( + leaderId: string, + now: Temporal.Instant, + ttlMs: number, + held: RuntimeLeader | null + ): Promise { + return this.#write("maintenance_leader_acquire", {}, (database) => + held === null + ? leaderAttemptElect(database, { leaderId, now, ttlMs }) + : leaderAttemptReelect(database, { ...held, leaderId }, { now, ttlMs }) + ); + } + + /** @internal */ + async maintenanceLeaderResign(leader: RuntimeLeader): Promise { + return this.#write("maintenance_leader_resign", {}, (database) => { + // Like River for Go's SQLite driver, other clients learn of the + // resignation at their next election attempt, without a notification. + return leaderResign(database, leader); + }); + } + + /** @internal */ + async maintenanceSchedule( + leader: RuntimeLeader, + params: RuntimeScheduleParams, + batch?: RuntimeMaintenanceBatch + ): Promise { + return ( + (await this.#withMaintenanceLeader( + leader, + "maintenance_schedule", + (database) => { + const results = scheduleJobs(database, { + limit: params.limit, + now: params.now, + scheduledAtHorizon: params.scheduledAtHorizon, + }); + // Like River for Go's scheduler, wake producers of jobs that are + // due, or nearly due, through the client's insert notification + // limiter. Stored times are rounded to the millisecond, so round + // the horizon the same way; otherwise a job scheduled "now" can + // round past it. + const horizon = params.notificationHorizon.round({ + roundingMode: "halfExpand", + smallestUnit: "millisecond", + }); + writeInsertNotifications( + database, + params.allowInsertNotifications( + results.flatMap(({ job }) => + Temporal.Instant.compare(job.scheduledAt, horizon) <= 0 + ? [job.queue] + : [] + ) + ) + ); + return results.length; + }, + batch + )) ?? 0 + ); + } + + /** @internal */ + async maintenanceGetStuck( + leader: RuntimeLeader, + attemptedBefore: Temporal.Instant, + afterId: bigint, + limit: number, + batch?: RuntimeMaintenanceBatch + ): Promise { + return ( + (await this.#withMaintenanceLeader( + leader, + "maintenance_get_stuck", + (database) => stuckJobs(database, { afterId, attemptedBefore, limit }), + batch + )) ?? [] + ); + } + + /** + * Rescue a page read by {@link maintenanceGetStuck} with the same + * `attemptedBefore`. Jobs that finished, were released, or were claimed + * again in between are left untouched. + * @internal + */ + async maintenanceRescue( + leader: RuntimeLeader, + attemptedBefore: Temporal.Instant, + jobs: readonly RuntimeJobRescue[], + options: InsertDriverOptions = {} + ): Promise { + return ( + (await this.#withMaintenanceLeader( + leader, + "maintenance_rescue", + (database) => rescueJobs(database, jobs, attemptedBefore).length, + undefined, + options + )) ?? 0 + ); + } + + /** @internal */ + async maintenanceCleanJobs( + leader: RuntimeLeader, + params: RuntimeJobCleanupParams, + timeoutMs: number | null, + signal: AbortSignal + ): Promise { + const count = + (await this.#withMaintenanceLeader( + leader, + "maintenance_clean_jobs", + (database) => cleanupJobs(database, params), + { signal, timeoutMs } + )) ?? 0; + return count; + } + + /** @internal */ + async maintenanceCleanQueues( + leader: RuntimeLeader, + updatedBefore: Temporal.Instant, + limit: number, + batch?: RuntimeMaintenanceBatch + ): Promise { + return ( + (await this.#withMaintenanceLeader( + leader, + "maintenance_clean_queues", + (database) => + queueDeleteExpired(database, { limit, updatedBefore }).length, + batch + )) ?? 0 + ); + } + + /** @internal */ + async maintenanceCleanNotifications( + leader: RuntimeLeader, + createdBefore: Temporal.Instant, + limit: number, + batch?: RuntimeMaintenanceBatch + ): Promise { + return ( + (await this.#withMaintenanceLeader( + leader, + "maintenance_clean_notifications", + (database) => notificationCleanup(database, { createdBefore, limit }), + batch + )) ?? 0 + ); + } + + /** @internal */ + async jobRetry( + id: bigint, + options: InsertDriverOptions = {} + ): Promise { + const result = await this.jobRetryDetailed(id, options); + return result.status === "not_found" ? null : result.job; + } + + /** @internal */ + async jobRetryDetailed( + id: bigint, + options: SqliteOperationOptions & { now?: Temporal.Instant } = {} + ): Promise { + validateInt64(id, "id"); + const now = options.now ?? Temporal.Now.instant(); + return this.#write("retry", options, (database) => { + const timestamp = sqliteTimestamp(now); + const raw = resultStatement( + database, + ` + UPDATE river_job + SET + state = 'available', + max_attempts = CASE + WHEN attempt = max_attempts THEN max_attempts + 1 + ELSE max_attempts + END, + finalized_at = NULL, + scheduled_at = ? + WHERE id = ? + AND state != 'running' + AND (state != 'available' OR scheduled_at > ?) + RETURNING ${JOB_COLUMNS} + ` + ).get(timestamp, id, timestamp); + const updated = raw === undefined ? null : decodeJobRowPartial(raw).job; + const job = updated ?? getJob(database, id, { partial: true }); + if (job === null) return { status: "not_found" }; + return { + job, + status: updated === null ? "unchanged" : "retried", + }; + }); + } + + /** + * Run synchronous SQL for a first-party extension that stores its own + * state beside River's, on the connection its operation belongs to. + * + * With `tx`, `callback` runs directly in that transaction (River's scope, + * which it begins if it hasn't yet, or an application transaction). + * Otherwise it runs in a short transaction of its own on + * River's connection, retried while another connection holds the lock, + * so it must be safe to run again. + * + * @internal + */ + execute( + operation: string, + options: SqliteOperationOptions, + callback: (database: DatabaseSync) => T + ): Promise { + return this.#write(operation, options, (database) => callback(database)); + } + + /** + * Run one River operation in a transaction, like River for Go's + * `dbutil.WithTxV`. + * + * With `tx`, the operation joins that application transaction. Otherwise + * River owns a transaction on its private connection. It begins with + * `BEGIN IMMEDIATE` at the operation's first River statement, commits + * when `callback` resolves, and rolls back when it rejects. `callback` + * receives an opaque value standing for the transaction: pass it back to + * this driver as `{ tx }`, or to {@link execute}. + * + * Once the transaction has begun, a River call without `{ tx }` from + * inside it would wait for it forever, so it fails at once with a + * `TransactionScopeError`. Before its first statement River holds no + * lock, and such calls run normally. + * + * @internal + */ + async operationScope( + tx: DatabaseSync | SqliteRiverScope | undefined, + callback: (tx: DatabaseSync | SqliteRiverScope) => Promise + ): Promise { + if (tx !== undefined) { + this.#resolveTransaction(tx, "operation_scope"); + return callback(tx); + } + this.#assertOpen("operation_scope"); + this.#assertNotReentrant("operation_scope", true); + const scope = new OwnedScope(this, this.#key); + let result: T; + try { + result = await runInFrame(scope.frame, () => + callback(scope as unknown as SqliteRiverScope) + ); + } catch (error: unknown) { + await this.#endScope(scope, false); + throw error; + } + await this.#endScope(scope, true); + return result; + } + + /** + * Wait for River's connection lock. A caller inside River's own + * transactions that haven't begun yet stops waiting, with a + * `TransactionScopeError`, as soon as one of them takes the lock: that + * transaction would wait for the caller, which would wait for it. + */ + async #acquireLock( + operation: string, + own: OwnedScope | null = null, + signal?: AbortSignal + ): Promise<() => void> { + const enclosing: OwnedScope[] = []; + for (const frame of openFrames()) { + if ( + frame.kind === "river" && + frame.driver === this && + frame.scope !== own + ) { + enclosing.push(frame.scope as OwnedScope); + } + } + if (enclosing.length === 0) return this.#lock.acquire(signal); + const controller = new AbortController(); + const abort = (): void => { + controller.abort(this.#reentrantError(operation, true)); + }; + for (const scope of enclosing) scope.lockWaiters.add(abort); + const link = + signal === undefined + ? undefined + : new LinkedAbortSignal([controller.signal, signal]); + try { + return await this.#lock.acquire(link?.signal ?? controller.signal); + } finally { + link?.[Symbol.dispose](); + for (const scope of enclosing) scope.lockWaiters.delete(abort); + } + } + + /** Track an application handle on this driver's database. */ + #addHandle(database: DatabaseSync): void { + registerDatabase(database, this.#key); + this.#handles.add(new WeakRef(database)); + } + + /** Whether an application handle River knows has a transaction open. */ + #applicationTransactionOpen(): boolean { + for (const reference of this.#handles) { + const database = reference.deref(); + if (database === undefined || !database.isOpen) { + this.#handles.delete(reference); + } else if (database.isTransaction) { + return true; + } + } + return false; + } + + #assertOpen(operation: string): void { + if (this.#closed) { + throw new LifecycleError( + `SQLite driver is closed, so ${operation} can't run`, + { details: { backend: SQLITE_BACKEND, operation } } + ); + } + } + + /** + * Fail a River call without `{ tx }` that would wait for a transaction + * the calling code is inside: River's own transaction on this database + * once it holds the lock (insert middleware after `next()`, `afterInsert` + * hooks), or for a write, an application transaction on the same + * database. + */ + #assertNotReentrant(operation: string, write: boolean): void { + const details = { backend: SQLITE_BACKEND, operation }; + for (const frame of openFrames()) { + if ( + frame.kind === "river" && + frame.holdsLock() && + (frame.driver === this || sameDatabase(frame.key, this.#key)) + ) { + throw this.#reentrantError(operation, frame.driver === this); + } + if ( + write && + frame.kind === "application" && + sameDatabase(frame.key, this.#key) + ) { + throw new TransactionScopeError( + "reentrant", + `SQLite River operation ${operation} was called without { tx } ` + + "inside transaction() on the same database, so it would wait " + + "for that transaction's write lock. Pass the transaction's " + + "handle as { tx }", + { details } + ); + } + } + } + + /** + * Begin a River-owned transaction at its first statement: wait for the + * private connection, then take SQLite's write lock with + * `BEGIN IMMEDIATE`, retrying asynchronously while another connection + * holds it. + */ + #beginScope(scope: OwnedScope, signal?: AbortSignal): Promise { + scope.beginning ??= (async () => { + scope.state = "beginning"; + const release = await this.#acquireLock( + "transaction_begin", + scope, + signal + ); + scope.holdsLock = true; + this.#lockHolder = scope; + for (const abort of [...scope.lockWaiters]) abort(); + try { + this.#assertOpen("transaction_begin"); + await this.#switchToWal("transaction_begin"); + await this.#retryBusy("transaction_begin", () => { + this.#connection.exec("BEGIN IMMEDIATE"); + }); + } catch (error: unknown) { + scope.holdsLock = false; + if (this.#lockHolder === scope) this.#lockHolder = null; + release(); + scope.state = "idle"; + throw error; + } + scope.release = release; + scope.state = "open"; + scope.turnsAtBegin = eventLoopTurns(); + // River's transaction must end within the event loop turn it began + // in: code awaiting only promises can't let other I/O, timers, or + // requests run. If the probe runs first, something awaited I/O. + scope.probe = setImmediate(() => { + this.#probeScope(scope); + }); + })(); + return scope.beginning; + } + + #databaseOperation(operation: string, callback: () => T): T { + try { + return callback(); + } catch (cause: unknown) { + throw this.#wrapError(operation, cause); + } + } + + /** + * End a River-owned transaction: commit it, or roll it back when the + * operation failed, and release the private connection. + */ + async #endScope(scope: OwnedScope, commit: boolean): Promise { + try { + if (scope.beginning !== null) { + // A failed begin was already reported through the statement. + await scope.beginning.catch(() => undefined); + } + this.#checkTurns(scope); + // The probe's failure explains whatever else the operation threw. + if (scope.failure !== null) throw scope.failure; + if (scope.state !== "open") return; + if (!commit) { + this.#rollbackQuietly(); + return; + } + // Closing the driver rolled the transaction back. + this.#assertOpen("transaction_commit"); + scope.state = "committing"; + try { + // SQLite keeps a transaction open when COMMIT is busy, so COMMIT + // alone can be retried. + await this.#retryBusy("transaction_commit", () => { + this.#connection.exec("COMMIT"); + }); + } catch (error: unknown) { + this.#rollbackQuietly(); + throw error; + } + } finally { + this.#releaseScope(scope); + scope.state = "ended"; + scope.frame.ended = true; + } + } + + /** Run a River operation on the private connection, one at a time. */ + async #exclusive(operation: string, run: () => Promise): Promise { + const release = await this.#acquireLock(operation); + try { + this.#assertOpen(operation); + await this.#switchToWal(operation); + return await run(); + } finally { + release(); + } + } + + /** + * Run statements in a River-owned transaction, beginning it if this is + * its first statement. Like River for Go, River opens no savepoint in its + * own transaction: a failed operation fails the transaction's owner, + * which rolls it back. + */ + async #inScope( + scope: OwnedScope, + operation: string, + callback: (database: DatabaseSync) => T + ): Promise { + this.#checkTurns(scope); + if (scope.failure !== null) throw scope.failure; + if (scope.state !== "open") await this.#beginScope(scope); + this.#assertOpen(operation); + if (scope.state !== "open") { + throw backendMismatchError( + operation, + "SQLite River transaction already ended" + ); + } + return this.#databaseOperation(operation, () => callback(this.#connection)); + } + + #insertRow( + database: DatabaseSync, + values: InsertValues + ): SqliteInsertResult { + const raw = resultStatement( + database, + ` + INSERT INTO river_job (${INSERT_COLUMNS_SQL}) + VALUES (${INSERT_VALUES_SQL}) + ON CONFLICT (unique_key) + WHERE unique_key IS NOT NULL + AND unique_states IS NOT NULL + AND ${UNIQUE_STATE_MATCH_SQL} + DO UPDATE SET kind = river_job.kind + RETURNING ${JOB_COLUMNS} + ` + ).get(...insertBindings(values)); + if (raw === undefined) { + throw databaseError("insert", "SQLite insert returned no River job"); + } + + // Go's returning SQLite insert tags every row with a nonce, including + // non-unique rows, and keeps it in stored and returned metadata. A + // different nonce means the upsert returned an existing unique job. + const job = decodeJobRowPartial(raw).job as SqliteJobRow; + const duplicate = job.metadata[UNIQUE_NONCE_KEY] !== values.uniqueNonce; + return { job, status: duplicate ? "duplicate" : "inserted" }; + } + + /** + * Whether an application handle is on this driver's database: one River + * knows (the handle it was created with, or one from `connect()`), or one + * opened on the same database file. + */ + #isSameDatabase(database: DatabaseSync): boolean { + return sameDatabase(databaseKey(database), this.#key); + } + + #normalizeInsert( + params: SqliteInsertJobParams + ): InsertValues { + const state = + params.state ?? + (params.scheduledAt !== undefined && + Temporal.Instant.compare( + params.scheduledAt, + params.createdAt ?? Temporal.Now.instant() + ) > 0 + ? "scheduled" + : "available"); + const uniqueKey = params.uniqueKey ?? null; + if (uniqueKey !== null && uniqueKey.byteLength !== 32) { + throw invalidInputError( + "insert", + "invalid SQLite River input uniqueKey: expected 32 bytes" + ); + } + // Like Go's returning insert, roll a nonce for each row. The client + // rejects a batch holding two rows with the same active unique key + // before writing it, like Go. + const uniqueNonce = randomBytes(8).toString("hex"); + const metadata = toJsonObject(params.metadata ?? {}); + metadata[UNIQUE_NONCE_KEY] = uniqueNonce; + + const errors = (params.errors ?? []).map((error) => ({ + at: error.at.toString(), + attempt: validateSmallInteger(error.attempt, "error.attempt", 0, 32_767), + error: error.error, + trace: error.trace, + })); + return { + // Like Go, store the caller's encoded arguments rather than `args`. + args: + params.encodedArgs === undefined + ? encodeJson(params.args, "args") + : encodeEncodedJson(params.encodedArgs, "encodedArgs"), + attempt: validateSmallInteger(params.attempt ?? 0, "attempt", 0, 32_767), + attemptedAt: sqliteTimestampOrNull(params.attemptedAt), + // Go writes NULL for absent client IDs and errors, not an empty array. + attemptedBy: + params.attemptedBy === undefined + ? null + : encodeJson(params.attemptedBy, "attemptedBy"), + createdAt: sqliteTimestampOrNull(params.createdAt), + errors: errors.length === 0 ? null : encodeJson(errors, "errors"), + finalizedAt: sqliteTimestampOrNull(params.finalizedAt), + id: params.id === undefined ? null : validateInt64(params.id, "id"), + kind: validateName(params.kind, "kind"), + maxAttempts: validateSmallInteger( + params.maxAttempts ?? 25, + "maxAttempts", + 1, + 32_767 + ), + metadata: encodeJson(metadata, "metadata"), + priority: validateSmallInteger(params.priority ?? 1, "priority", 1, 4), + queue: validateName(params.queue ?? "default", "queue"), + scheduledAt: sqliteTimestampOrNull(params.scheduledAt), + state: validateJobState(state), + tags: encodeJson(params.tags ?? [], "tags"), + uniqueKey, + uniqueNonce, + uniqueStates: encodeUniqueStates(params.uniqueStates), + }; + } + + /** + * Borrow River's private connection, outside any transaction, for a + * pilot's reads and single autocommit statements, holding River's lock on + * it meanwhile. A River call without `{ tx }` from inside fails at once + * instead of waiting for the lock forever. + */ + async #pilotConnection( + callback: (handle: DatabaseSync) => PromiseLike | Result, + options: { readonly signal?: AbortSignal } = {} + ): Promise { + const operation = "pilot_connection"; + options.signal?.throwIfAborted(); + this.#assertOpen(operation); + this.#assertNotReentrant(operation, false); + // The frame marks River's lock as held for re-entry detection only; it + // is never a transaction. + const scope = new OwnedScope(this, this.#key); + const release = await this.#acquireLock(operation, scope, options.signal); + scope.holdsLock = true; + try { + this.#assertOpen(operation); + await this.#switchToWal(operation); + const result = await runInFrame(scope.frame, () => + callback(this.#connection) + ); + if (this.#connection.isTransaction) { + throw new TransactionScopeError( + "nested", + "a pilot's connection callback left a transaction open; use " + + "the pilot database's transaction() instead", + { details: { backend: SQLITE_BACKEND, operation } } + ); + } + return result; + } catch (cause: unknown) { + this.#rollbackQuietly(); + throw this.#wrapError(operation, cause); + } finally { + scope.holdsLock = false; + scope.frame.ended = true; + release(); + } + } + + /** The database this driver gives a client's pilot. */ + #pilotDatabase(): PilotDatabase { + return { + backend: SQLITE_BACKEND, + connection: (callback, options) => + this.#pilotConnection(callback, options), + deleteFinalizedJobs: async (params, options) => + this.#write( + "delete_finalized_jobs", + options?.tx === undefined ? {} : requireTx(options), + (database) => cleanupJobs(database, params) + ), + loadClaimed: async (ids, options) => + this.#read("load_claimed", requireTx(options), (database) => + loadClaimedJobs(database, ids) + ), + notify: async (topic, payloads, options) => + this.#write("notify", requireTx(options), (database) => { + const name = runtimeTopicName(pilotTopic(topic)); + for (const payload of payloads) { + insertNotification(database, name, payload); + } + }), + schema: null, + transaction: (callback, options) => + this.#pilotTransaction(callback, options), + }; + } + + /** + * Run a pilot's callback directly in River's transaction `scope`, + * beginning it if this is its first statement. River's private connection + * passed as `{ tx }` from inside stands for `scope` meanwhile. Like River + * for Go, River opens no savepoint: when the callback fails, its writes + * stay in `scope` until the scope's owner rolls it back. + */ + async #pilotInScope( + scope: OwnedScope, + callback: (tx: DatabaseSync) => PromiseLike | Result, + signal: AbortSignal | undefined + ): Promise { + const operation = "pilot_transaction"; + this.#throwIfFailed(scope); + if (scope.state !== "open") await this.#beginScope(scope, signal); + this.#assertOpen(operation); + if (!this.#scopeOpen(scope)) { + throw backendMismatchError( + operation, + "SQLite River transaction already ended" + ); + } + scope.pilotDepth++; + try { + const result = await runInFrame(scope.frame, () => + callback(this.#connection) + ); + this.#throwIfFailed(scope); + signal?.throwIfAborted(); + return result; + } catch (cause: unknown) { + // The probe's failure explains whatever else the callback threw. + throw this.#scopeFailure(scope) ?? cause; + } finally { + scope.pilotDepth--; + } + } + + /** + * River's transaction that River's private connection stands for when a + * pilot passes it as `{ tx }`: the one holding the connection, while a + * pilot transaction is running in it and the caller runs inside it. + * Anywhere else the private connection is never a valid `{ tx }`. + */ + #pilotScope(): OwnedScope | null { + const holder = this.#lockHolder; + if (holder === null || holder.pilotDepth === 0) return null; + for (const frame of openFrames()) { + if (frame.kind === "river" && frame.scope === holder) return holder; + } + return null; + } + + /** + * Run a pilot's callback in a new transaction River owns on its private + * connection, or directly in `tx`, opening no savepoint. River's + * transaction begins before + * the callback runs, retrying a busy database first, and like any River + * transaction must end in the event loop turn it began in. + */ + async #pilotTransaction( + callback: (tx: DatabaseSync) => PromiseLike | Result, + options: { + readonly signal?: AbortSignal; + readonly tx?: DatabaseSync | SqliteRiverScope; + } = {} + ): Promise { + const operation = "pilot_transaction"; + const signal = options.signal; + signal?.throwIfAborted(); + if (options.tx !== undefined) { + const target = this.#resolveTransaction(options.tx, operation); + if (target instanceof OwnedScope) { + return this.#pilotInScope(target, callback, signal); + } + try { + const result = await callback(target); + signal?.throwIfAborted(); + return result; + } catch (cause: unknown) { + throw this.#wrapError(operation, cause); + } + } + this.#assertOpen(operation); + this.#assertNotReentrant(operation, true); + const scope = new OwnedScope(this, this.#key); + scope.pilotDepth = 1; + let result: Result; + try { + result = await runInFrame(scope.frame, async () => { + await this.#beginScope(scope, signal); + const value = await callback(this.#connection); + this.#throwIfFailed(scope); + signal?.throwIfAborted(); + return value; + }); + } catch (error: unknown) { + await this.#endScope(scope, false).catch(() => undefined); + throw this.#scopeFailure(scope) ?? error; + } + await this.#endScope(scope, true); + return result; + } + + async #queueSetPaused( + name: string, + paused: boolean, + options: SqliteOperationOptions & { now?: Temporal.Instant } + ): Promise { + validateLookupName(name, "name"); + const now = options.now ?? Temporal.Now.instant(); + return this.#write( + paused ? "queue_pause" : "queue_resume", + options, + (database) => { + const pausedSql = paused ? "coalesce(paused_at, ?)" : "NULL"; + const changedSql = paused + ? "paused_at IS NULL" + : "paused_at IS NOT NULL"; + const statement = resultStatement( + database, + ` + UPDATE river_queue + SET + paused_at = ${pausedSql}, + updated_at = CASE WHEN ${changedSql} THEN ? ELSE updated_at END + WHERE (? = '*' OR name = ?) AND ${changedSql} + RETURNING ${QUEUE_COLUMNS} + ` + ); + const timestamp = sqliteTimestamp(now); + const rows = paused + ? statement.all(timestamp, timestamp, name, name) + : statement.all(timestamp, name, name); + const queues = rows.map(decodeQueueRow); + if (queues.length > 0) { + insertNotification( + database, + NOTIFICATION_TOPIC_CONTROL, + `{"action":"${paused ? "pause" : "resume"}","queue":${JSON.stringify(name)}}` + ); + } + return queues; + } + ); + } + + /** + * Run a read. Inside a caller transaction it sees that transaction's + * writes; otherwise it waits for the handle and retries a busy database. + */ + async #read( + operation: string, + options: SqliteOperationOptions, + callback: (database: DatabaseSync) => T + ): Promise { + if (options.tx !== undefined) { + const target = this.#resolveTransaction(options.tx, operation); + if (target instanceof OwnedScope) { + return this.#inScope(target, operation, (database) => + callback(database) + ); + } + return this.#databaseOperation(operation, () => callback(target)); + } + this.#assertNotReentrant(operation, false); + return this.#exclusive(operation, () => + this.#retryBusy(operation, () => callback(this.#connection)) + ); + } + + /** + * Finish the switch to WAL that construction found the database too busy + * for, retrying asynchronously like any other River statement. Call with + * River's lock held. + */ + async #switchToWal(operation: string): Promise { + if (!this.#walPending) return; + await this.#retryBusy(operation, () => { + switchToWal(this.#connection); + }); + this.#walPending = false; + } + + async #retryBusy(operation: string, attempt: () => T): Promise { + try { + return await retryBusy(this.#busyPolicy, attempt); + } catch (cause: unknown) { + throw this.#wrapError(operation, cause); + } + } + + /** The error for a River call that would wait for its own transaction. */ + #reentrantError( + operation: string, + sameDriver: boolean + ): TransactionScopeError { + return new TransactionScopeError( + "reentrant", + `SQLite River operation ${operation} was called without { tx } from ` + + "inside River's own transaction " + + (sameDriver + ? "on this driver" + : "on another SqliteDriver for the same database") + + ", which insert middleware and hooks run in. River holds the " + + "database's write lock until the middleware or hook returns, so " + + "the call would wait for it forever. Move the call before next() " + + "or out of the hook, or react to committed jobs with " + + "client.subscribe", + { details: { backend: SQLITE_BACKEND, operation } } + ); + } + + /** + * Resolve `{ tx }` to River's own transaction on this driver or an + * application handle on this driver's database with a transaction open. + */ + #resolveTransaction( + tx: unknown, + operation: string + ): DatabaseSync | OwnedScope { + if (tx instanceof OwnedScope) { + if (tx.driver !== this) { + throw backendMismatchError( + operation, + "SQLite transaction belongs to another SqliteDriver" + ); + } + if (tx.state === "ended") { + throw backendMismatchError( + operation, + "SQLite River transaction already ended" + ); + } + return tx; + } + if (!(tx instanceof DatabaseSync)) { + throw backendMismatchError( + operation, + "SQLite { tx } must be a node:sqlite DatabaseSync with an open transaction" + ); + } + if (tx === this.#connection) { + const admitted = this.#pilotScope(); + if (admitted !== null) return admitted; + throw backendMismatchError( + operation, + "River's private SQLite connection can't be passed as { tx }" + ); + } + if (!tx.isOpen || !this.#isSameDatabase(tx)) { + throw backendMismatchError( + operation, + "SQLite { tx } is not open on this driver's database; open " + + "application handles on the same file or with driver.connect()" + ); + } + if (!tx.isTransaction) { + throw new TransactionScopeError( + "no_transaction", + "SQLite { tx } has no open transaction; begin one with " + + "transaction(db, …) or BEGIN IMMEDIATE and pass the handle while " + + "it is open", + { details: { backend: SQLITE_BACKEND, operation } } + ); + } + return tx; + } + + /** + * Roll back River's transaction when it is still open at the event loop's + * next turn, which releases SQLite's write lock for every other writer at + * once, and fail the operation. Committing may legitimately wait across + * turns while COMMIT is busy. + */ + #probeScope(scope: OwnedScope): void { + scope.probe = null; + if (scope.state !== "open") return; + this.#failScope(scope); + } + + /** Why River's transaction `scope` failed, once it has, checking turns. */ + #scopeFailure(scope: OwnedScope): TransactionScopeError | null { + this.#checkTurns(scope); + return scope.failure; + } + + /** Whether River's transaction `scope` is still open. */ + #scopeOpen(scope: OwnedScope): boolean { + return scope.state === "open"; + } + + /** Throw why River's transaction `scope` failed, once it has. */ + #throwIfFailed(scope: OwnedScope): void { + const failure = this.#scopeFailure(scope); + if (failure !== null) throw failure; + } + + /** + * In strict mode, fail River's open transaction as the probe would once + * any macrotask callback has run since it began, which catches I/O too + * fast for the probe to see. + */ + #checkTurns(scope: OwnedScope): void { + if ( + this.#releaseTurnCounter !== null && + scope.state === "open" && + eventLoopTurns() !== scope.turnsAtBegin + ) { + this.#failScope(scope); + } + } + + /** Roll back River's transaction and fail its operation. */ + #failScope(scope: OwnedScope): void { + this.#rollbackQuietly(); + scope.state = "rolled_back"; + scope.failure = new TransactionScopeError( + "event_loop_turn", + "River's SQLite transaction stayed open across a turn of the event " + + "loop: " + + (scope.pilotDepth > 0 + ? "a companion's transaction or operation interceptor awaited " + + "I/O while River held SQLite's write lock, which blocks every " + + "other writer. River rolled the operation back. Such a " + + "transaction must await only promises, not I/O or timers" + : "insert middleware or a hook awaited I/O after next() while " + + "River held SQLite's write lock, which blocks every other " + + "writer. River rolled the operation back. Move the I/O before " + + "calling next(), or react to the committed job with " + + "client.subscribe"), + { details: { backend: SQLITE_BACKEND, operation: "transaction" } } + ); + this.#releaseScope(scope); + } + + /** Release the private connection a River-owned transaction holds. */ + #releaseScope(scope: OwnedScope): void { + if (scope.probe !== null) { + clearImmediate(scope.probe); + scope.probe = null; + } + scope.holdsLock = false; + if (this.#lockHolder === scope) this.#lockHolder = null; + scope.release?.(); + scope.release = null; + } + + /** + * Wrap a failure: a `LifecycleError` once the driver is closed, and a + * busy database with a hint when an application handle in this process + * holds the lock, which a caller that forgot `{ tx }` can't wait out. + */ + #wrapError(operation: string, cause: unknown): unknown { + if (cause instanceof RiverError) return cause; + const details = { backend: SQLITE_BACKEND, operation }; + if (this.#closed) { + return new LifecycleError( + `SQLite driver was closed while ${operation} ran`, + { cause, details } + ); + } + if (isRetryableSqliteError(cause) && this.#applicationTransactionOpen()) { + return databaseError( + operation, + `SQLite River operation ${operation} failed: ` + + `${(cause as Error).message}. An application handle on this ` + + "database in this process has a transaction open, such as one " + + "begun with a raw BEGIN; if this call belongs to that " + + "transaction, pass the handle as { tx }", + { cause } + ); + } + return wrapSqliteError(operation, cause); + } + + #rollbackQuietly(): void { + if (!this.#connection.isOpen || !this.#connection.isTransaction) return; + try { + this.#connection.exec("ROLLBACK"); + } catch { + // Preserve the operation failure as the primary cause. + } + } + + /** + * Run `callback` in a write fenced by `leader`'s exact term. SQLite + * statements can't be interrupted, so a `batch` only stops work that hasn't + * started when its signal aborts. + */ + async #withMaintenanceLeader( + leader: RuntimeLeader, + operation: string, + callback: (database: DatabaseSync) => T, + batch?: RuntimeMaintenanceBatch, + options: SqliteOperationOptions = {} + ): Promise { + batch?.signal.throwIfAborted(); + return this.#write(operation, options, (database) => { + batch?.signal.throwIfAborted(); + const current = leaderGet(database); + if ( + current === null || + current.leaderId !== leader.leaderId || + !current.electedAt.equals(leader.electedAt) || + Temporal.Instant.compare(current.expiresAt, Temporal.Now.instant()) < 0 + ) { + return null; + } + return callback(database); + }); + } + + /** + * Run a write in a transaction. + * + * With `tx` the operation runs directly in that transaction, like River + * for Go, opening no savepoint: when it fails part way through, its + * statements stay in the transaction until the transaction's owner rolls + * it back. Otherwise it runs in its own `BEGIN IMMEDIATE` + * transaction on River's connection; when another connection holds the + * write lock the whole attempt rolls back and is retried after an + * asynchronous backoff, so no transaction stays open across an await. + */ + async #write( + operation: string, + options: SqliteOperationOptions, + callback: (database: DatabaseSync) => T + ): Promise { + if (options.tx !== undefined) { + const target = this.#resolveTransaction(options.tx, operation); + if (target instanceof OwnedScope) { + return this.#inScope(target, operation, callback); + } + return this.#databaseOperation(operation, () => callback(target)); + } + + this.#assertNotReentrant(operation, true); + return this.#exclusive(operation, () => + this.#retryBusy(operation, () => { + const database = this.#connection; + database.exec("BEGIN IMMEDIATE"); + try { + const result = callback(database); + database.exec("COMMIT"); + return result; + } catch (cause: unknown) { + this.#rollbackQuietly(); + throw cause; + } + }) + ); + } +} + +/** A pilot's notification topic, checked for untyped callers. */ +function pilotTopic(topic: string): "control" | "insert" { + if (topic === "control" || topic === "insert") return topic; + throw invalidInputError( + "notify", + `River notifications can be sent on "control" or "insert", not ${JSON.stringify(topic)}` + ); +} + +/** The `{ tx }` a pilot's statement must run in. */ +function requireTx( + options: { readonly tx?: unknown } | undefined +): SqliteOperationOptions { + const tx = options?.tx; + if (tx === undefined) { + throw new TransactionScopeError( + "no_transaction", + "this pilot database operation requires { tx }", + { details: { backend: SQLITE_BACKEND } } + ); + } + return { tx } as SqliteOperationOptions; +} + +/** + * Open River's private connection with a zero busy timeout: River retries a + * busy database asynchronously instead of letting SQLite block the event + * loop. A file database is switched to WAL, so application reads proceed + * while River writes; an in-memory database has no WAL. A constructor can't + * wait, so when another connection keeps the database busy, the switch is + * left pending for River's first operation, which retries it asynchronously. + */ +function openConnection( + location: string, + file: boolean +): { connection: DatabaseSync; walPending: boolean } { + const connection = new DatabaseSync(location, { timeout: 0 }); + try { + if (!file) return { connection, walPending: false }; + try { + switchToWal(connection); + } catch (error: unknown) { + if (!isRetryableSqliteError(error)) throw error; + return { connection, walPending: true }; + } + return { connection, walPending: false }; + } catch (error: unknown) { + connection.close(); + throw error; + } +} + +/** Switch a file database to WAL, which SQLite records in the file. */ +function switchToWal(connection: DatabaseSync): void { + const mode = connection.prepare("PRAGMA journal_mode").get(); + if (mode?.journal_mode !== "wal") { + connection.exec("PRAGMA journal_mode = WAL"); + } +} + +/** + * Read a job by ID. With `partial`, fields River can't decode are left empty + * instead of throwing, so operations by ID work on any row. + */ +function getJob( + database: DatabaseSync, + id: bigint, + options: { readonly partial?: boolean } = {} +): SqliteJobRow | null { + const raw = resultStatement( + database, + `SELECT ${JOB_COLUMNS} FROM river_job WHERE id = ? LIMIT 1` + ).get(id); + if (raw === undefined) return null; + return options.partial === true + ? decodeJobRowPartial(raw).job + : decodeJobRow(raw); +} + +function insertBindings( + values: InsertValues +): (bigint | number | string | Uint8Array | null)[] { + return [ + values.id, + values.args, + values.attempt, + values.attemptedAt, + values.attemptedBy, + values.createdAt, + values.errors, + values.finalizedAt, + values.kind, + values.maxAttempts, + values.metadata, + values.priority, + values.queue, + values.scheduledAt, + values.state, + values.tags, + values.uniqueKey, + values.uniqueStates, + ]; +} + +function insertNotification( + database: DatabaseSync, + topic: string, + payload: string +): void { + resultStatement( + database, + "INSERT INTO river_notification (payload, topic) VALUES (?, ?)" + ).run(payload, topic); +} + +/** Write River for Go's insert notification for each of `queues`. */ +function writeInsertNotifications( + database: DatabaseSync, + queues: readonly string[] +): void { + for (const queue of queues) { + // River for Go's `{"queue": %q}`, with a space after the colon. + insertNotification( + database, + NOTIFICATION_TOPIC_INSERT, + `{"queue": ${JSON.stringify(queue)}}` + ); + } +} + +function runtimeTopic(topic: string): RuntimeNotification["topic"] { + switch (topic) { + case "river_control": + return "control"; + case "river_insert": + return "insert"; + case "river_leadership": + return "leadership"; + default: + throw invalidRowError( + "runtime_notification_subscribe", + `unknown River notification topic ${JSON.stringify(topic)}` + ); + } +} + +function runtimeTopicName(topic: RuntimeNotification["topic"]): string { + switch (topic) { + case "control": + return "river_control"; + case "insert": + return "river_insert"; + case "leadership": + return "river_leadership"; + } +} + +function validateJobState(state: string): string { + if ( + state !== "available" && + state !== "cancelled" && + state !== "completed" && + state !== "discarded" && + state !== "pending" && + state !== "retryable" && + state !== "running" && + state !== "scheduled" + ) { + throw invalidInputError( + "insert", + `invalid SQLite River input state: unknown state ${JSON.stringify(state)}` + ); + } + return state; +} + +function waitForPoll(milliseconds: number, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve(); + return new Promise((resolve) => { + const timer = setTimeout(finish, milliseconds); + timer.unref(); + signal.addEventListener("abort", finish, { once: true }); + + function finish(): void { + clearTimeout(timer); + signal.removeEventListener("abort", finish); + resolve(); + } + }); +} + +/** Wrap a `node:sqlite` failure; River and other errors pass through. */ +function wrapSqliteError(operation: string, cause: unknown): unknown { + if (cause instanceof RiverError || !isSqliteError(cause)) return cause; + return databaseError( + operation, + `SQLite River operation ${operation} failed: ${cause.message}`, + { cause } + ); +} diff --git a/js/driver/sqlite/src/errors.ts b/js/driver/sqlite/src/errors.ts new file mode 100644 index 000000000..3856afc57 --- /dev/null +++ b/js/driver/sqlite/src/errors.ts @@ -0,0 +1,94 @@ +import { + BackendMismatchError, + ConfigurationError, + DatabaseOperationError, + ValidationError, +} from "riverqueue"; + +/** Backend name recorded on SQLite errors. */ +export const SQLITE_BACKEND = "sqlite"; + +/** SQLite result codes that mean another connection holds a lock. */ +const SQLITE_BUSY = 5; +const SQLITE_LOCKED = 6; + +/** A failed SQLite operation, retryable when the database is busy. */ +export function databaseError( + operation: string, + message: string, + options: { cause?: unknown; reason?: string; retryable?: boolean } = {} +): DatabaseOperationError { + const { cause, reason } = options; + return new DatabaseOperationError(message, { + backend: SQLITE_BACKEND, + ...(cause === undefined ? {} : { cause }), + ...(reason === undefined ? {} : { details: { reason } }), + operation, + retryable: options.retryable ?? isRetryableSqliteError(cause), + }); +} + +/** A persisted row River cannot decode. */ +export function invalidRowError( + operation: string, + message: string, + cause?: unknown +): DatabaseOperationError { + return databaseError(operation, message, { + ...(cause === undefined ? {} : { cause }), + reason: "invalid_row", + retryable: false, + }); +} + +/** Input rejected before it reached SQLite. */ +export function invalidInputError( + operation: string, + message: string, + cause?: unknown +): ValidationError { + return new ValidationError(message, { + ...(cause === undefined ? {} : { cause }), + details: { backend: SQLITE_BACKEND, operation }, + }); +} + +/** Invalid SQLite driver configuration or misuse of its transactions. */ +export function configurationError( + operation: string, + message: string +): ConfigurationError { + return new ConfigurationError(message, { + details: { backend: SQLITE_BACKEND, operation }, + }); +} + +/** A transaction token that belongs to another driver or transaction. */ +export function backendMismatchError( + operation: string, + message: string +): BackendMismatchError { + return new BackendMismatchError(SQLITE_BACKEND, message, { + details: { operation }, + }); +} + +/** Whether an error was raised by `node:sqlite` itself. */ +export function isSqliteError(value: unknown): value is Error & { + readonly code: string; + readonly errcode?: number; + readonly errstr?: string; +} { + return ( + value instanceof Error && + "code" in value && + value.code === "ERR_SQLITE_ERROR" + ); +} + +/** Whether a SQLite failure is a transient busy or locked database. */ +export function isRetryableSqliteError(value: unknown): boolean { + if (!isSqliteError(value)) return false; + const primary = (value.errcode ?? -1) & 0xff; + return primary === SQLITE_BUSY || primary === SQLITE_LOCKED; +} diff --git a/js/driver/sqlite/src/index.ts b/js/driver/sqlite/src/index.ts new file mode 100644 index 000000000..d227109ae --- /dev/null +++ b/js/driver/sqlite/src/index.ts @@ -0,0 +1,12 @@ +import type { DatabaseSync } from "node:sqlite"; + +export { SqliteDriver } from "./driver.js"; +export { transaction, type SqliteTransactionOptions } from "./scope.js"; +export type { SqliteDriverOptions } from "./types.js"; + +declare module "riverqueue" { + interface RiverTransactionRegistry { + /** Application handles on a SQLite driver's database with a transaction open. */ + "@riverqueue/driver-sqlite": DatabaseSync; + } +} diff --git a/js/driver/sqlite/src/notification.test.ts b/js/driver/sqlite/src/notification.test.ts new file mode 100644 index 000000000..4fdb8ce4c --- /dev/null +++ b/js/driver/sqlite/src/notification.test.ts @@ -0,0 +1,261 @@ +import type { DatabaseSync } from "node:sqlite"; + +import type { RuntimeNotification } from "riverqueue/unstable-driver"; +import { describe, expect, onTestFinished, test, vi } from "vitest"; + +import { + SQLITE_DRIVER_TEST_HOOKS, + type SqliteRuntime, + testSqliteMemory, +} from "./driver.js"; +import type { SqliteDriverOptions } from "./types.js"; + +/** River's own tests fail any lock window that crosses the event loop. */ +const STRICT = { + [SQLITE_DRIVER_TEST_HOOKS]: { strictLockWindow: true }, +} as SqliteDriverOptions; + +// Mirrors River's driver tests for SQLite's `river_notification` outbox: its +// listener and `NotificationDeleteBefore`. +describe("SqliteDriver notifications", () => { + describe("cleanup", () => { + test("deletes notifications before a horizon", async () => { + const { database, driver } = await setup(); + const createdBefore = insertAgedNotifications(database); + + await expect( + driver.notificationCleanup({ createdBefore, limit: 10 }) + ).resolves.toBe(2); + expect(payloadsByAge(database)).toEqual([ + "horizon_payload", + "new_payload", + ]); + }); + + test("deletes at most a limit of notifications, oldest first", async () => { + const { database, driver } = await setup(); + const params = { + createdBefore: insertAgedNotifications(database), + limit: 1, + }; + + await expect(driver.notificationCleanup(params)).resolves.toBe(1); + // Delete by age, even when the oldest notification was inserted later. + expect(payloadsByAge(database)[0]).toBe("old_payload"); + await expect(driver.notificationCleanup(params)).resolves.toBe(1); + await expect(driver.notificationCleanup(params)).resolves.toBe(0); + // Keeps the notification exactly at the horizon. + expect(payloadsByAge(database)).toEqual([ + "horizon_payload", + "new_payload", + ]); + }); + }); + + describe("subscriptions", () => { + test("delivers more notifications than one read batch, in order", async () => { + const { database, driver } = await setup(); + const poll = vi.spyOn(driver, "notificationPoll"); + const subscription = await subscribe(driver, ["insert"]); + const payloads = Array.from( + { length: 600 }, + (_, index) => `payload_${index.toString()}` + ); + + notify(database, "river_control", payloads); + notify(database, "river_insert", payloads); + + for (const payload of payloads) { + await expect(subscription.next()).resolves.toEqual({ + payload, + topic: "insert", + }); + } + await subscription.requireNone(); + expect(poll.mock.calls.map(([params]) => params?.limit)).toEqual( + poll.mock.calls.map(() => 256) + ); + // Three full batches of notifications, 256 at a time. + expect(poll.mock.calls.length).toBeGreaterThanOrEqual(3); + }); + + test("discards notifications read but not delivered when closed", async () => { + const { database, driver } = await setup(); + const first = await subscribe(driver, ["insert"]); + + notify(database, "river_insert", ["first", "buffered"]); + await expect(first.next()).resolves.toMatchObject({ payload: "first" }); + first.close(); + await expect(first.done()).resolves.toBe(true); + + const second = await subscribe(driver, ["insert"]); + notify(database, "river_insert", ["new"]); + await expect(second.next()).resolves.toMatchObject({ payload: "new" }); + await second.requireNone(); + }); + + test("does not replay notifications written before it subscribed", async () => { + const { database, driver } = await setup(); + + notify(database, "river_insert", ["old"]); + const subscription = await subscribe(driver, ["insert"]); + notify(database, "river_insert", ["new"]); + + await expect(subscription.next()).resolves.toEqual({ + payload: "new", + topic: "insert", + }); + await subscription.requireNone(); + }); + + test("resubscribes after cleanup deleted every notification", async () => { + const { database, driver } = await setup(); + const first = await subscribe(driver, ["insert"]); + + notify(database, "river_insert", ["first", "buffered"]); + await expect(first.next()).resolves.toMatchObject({ payload: "first" }); + first.close(); + database.exec("DELETE FROM river_notification"); + + const second = await subscribe(driver, ["insert"]); + notify(database, "river_insert", ["new"]); + await expect(second.next()).resolves.toMatchObject({ payload: "new" }); + await second.requireNone(); + }); + + test("skips notifications written between subscriptions", async () => { + const { database, driver } = await setup(); + const first = await subscribe(driver, ["insert"]); + first.close(); + + notify(database, "river_insert", ["gap"]); + const second = await subscribe(driver, ["insert"]); + notify(database, "river_insert", ["new"]); + + await expect(second.next()).resolves.toMatchObject({ payload: "new" }); + await second.requireNone(); + }); + }); +}); + +interface Subscription { + close(): void; + /** Whether the subscription ends without delivering anything else. */ + done(): Promise; + next(): Promise; + /** Wait a few polls and fail if anything else is delivered. */ + requireNone(): Promise; +} + +async function subscribe( + driver: SqliteRuntime, + topics: readonly RuntimeNotification["topic"][] +): Promise { + const controller = new AbortController(); + onTestFinished(() => { + controller.abort(); + }); + const ready = Promise.withResolvers(); + const iterator = driver + .runtimeNotificationSubscribe(topics, controller.signal, () => { + ready.resolve(undefined); + }) + [Symbol.asyncIterator](); + // The generator runs until its first yield, reading the outbox's last ID. + let pending: Promise> | undefined = + iterator.next(); + await ready.promise; + // Ask for each notification only when the test wants it, so nothing is + // read ahead of the subscription's own buffering. + const take = (): Promise> => { + const result = pending ?? iterator.next(); + pending = undefined; + return result; + }; + return { + close: () => { + controller.abort(); + }, + done: async () => (await take()).done === true, + next: async () => { + const result = await take(); + if (result.done === true) throw new Error("subscription ended"); + return result.value; + }, + requireNone: async () => { + pending = take(); + const timeout = new Promise<"none">((resolve) => + setTimeout(resolve, 300, "none") + ); + await expect(Promise.race([pending, timeout])).resolves.toBe("none"); + }, + }; +} + +/** + * Insert four notifications out of age order, one exactly at the returned + * horizon, in the fixed-width timestamp format River's SQLite driver writes. + */ +function insertAgedNotifications(database: DatabaseSync): Temporal.Instant { + // Include a trailing fractional zero to exercise the fixed-width format. + const now = Temporal.Now.instant() + .round({ roundingMode: "floor", smallestUnit: "second" }) + .add({ milliseconds: 120 }); + const timestamp = (instant: Temporal.Instant): string => + instant + .toString({ fractionalSecondDigits: 3 }) + .replace("T", " ") + .replace(/Z$/, ""); + const insert = database.prepare( + "INSERT INTO river_notification (created_at, payload, topic) VALUES (?, ?, 'topic')" + ); + insert.run(timestamp(now.subtract({ minutes: 61 })), "old_payload"); + insert.run(timestamp(now.subtract({ hours: 2 })), "oldest_payload"); + insert.run(timestamp(now.subtract({ hours: 1 })), "horizon_payload"); + insert.run(timestamp(now.subtract({ minutes: 30 })), "new_payload"); + return now.subtract({ hours: 1 }); +} + +function notify( + database: DatabaseSync, + topic: string, + payloads: readonly string[] +): void { + const insert = database.prepare( + "INSERT INTO river_notification (payload, topic) VALUES (?, ?)" + ); + database.exec("BEGIN"); + for (const payload of payloads) insert.run(payload, topic); + database.exec("COMMIT"); +} + +function payloadsByAge(database: DatabaseSync): string[] { + return database + .prepare("SELECT payload FROM river_notification ORDER BY created_at") + .all() + .map((row) => row.payload as string); +} + +async function setup(): Promise<{ + database: DatabaseSync; + driver: SqliteRuntime; +}> { + const driver = testSqliteMemory(STRICT); + const database = driver.connect(); + onTestFinished(() => { + database.close(); + driver.close(); + }); + await migrate(database); + return { database, driver }; +} + +async function migrate(database: DatabaseSync): Promise { + const moduleUrl = new URL("../../../migrate/dist/index.js", import.meta.url); + const migrationModule = (await import(moduleUrl.href)) as { + createMigrator(target: { database: DatabaseSync }): { + migrateUp(): Promise; + }; + }; + await migrationModule.createMigrator({ database }).migrateUp(); +} diff --git a/js/driver/sqlite/src/operations.ts b/js/driver/sqlite/src/operations.ts new file mode 100644 index 000000000..08744f713 --- /dev/null +++ b/js/driver/sqlite/src/operations.ts @@ -0,0 +1,1234 @@ +import type { DatabaseSync, StatementSync } from "node:sqlite"; +import type { + JobClaimParams, + JobClaimResult, + JobCompletionCommand, + JobCompletionResult, + JobListParams, + JobUpdateParams, +} from "riverqueue/unstable-driver"; +import type { JobListCursorValue } from "riverqueue/unstable-driver"; +import { + jobCompletionKey, + jobListCursorValue, + jobListKeyset, + jobListKeysetSql, +} from "riverqueue/unstable-driver"; +import type { JsonObject, JsonValue, RiverError } from "riverqueue"; +import { isExactJsonNumber } from "riverqueue"; + +import { + JOB_COLUMNS, + invalidJsonTextSql, + nullableBytes, + decodeJobRow, + decodeJobRowPartial, + encodeJson, + parseSqliteTimestamp, + sqliteTimestamp, + sqliteTimestampOrNull, + validateInt64, + validateName, + validateSmallInteger, +} from "./codecs.js"; +import { invalidInputError, invalidRowError } from "./errors.js"; +import type { + SqliteCleanupJobsParams, + SqliteJobRow, + SqliteJobState, + SqliteLeader, + SqliteNotification, + SqliteRescueJobParams, + SqliteScheduleResult, +} from "./types.js"; + +const JOB_STATES: readonly SqliteJobState[] = [ + "available", + "cancelled", + "completed", + "discarded", + "pending", + "retryable", + "running", + "scheduled", +]; + +/** `attempted_by` keeps at most this many client IDs, like Go. */ +const MAX_ATTEMPTED_BY = 100; + +/** + * Append the bound attempt error (bound three times) to `errors` like Go's + * statements: SQL `NULL` starts a new array, and any other value that isn't + * an array (another tool may have written one) is kept, wrapped in an array, + * as a string when it is text that isn't valid JSON. + */ +const APPEND_ERROR_SQL = `CASE + WHEN ${invalidJsonTextSql("errors")} + THEN jsonb(json_array(errors, json(?))) + WHEN coalesce(json_type(errors), 'array') <> 'array' + THEN jsonb(json_array(json(errors), json(?))) + ELSE jsonb(json_insert(json(coalesce(errors, jsonb('[]'))), '$[#]', json(?))) +END`; + +/** + * SQL that is true when the bound client ID made the latest attempt. When + * `attempted_by` is text that isn't valid JSON (left in place when the job was + * claimed, as in Go), the attempt number alone fences the completion. + */ +const ATTEMPTED_BY_FENCE_SQL = `(CASE WHEN ${invalidJsonTextSql("attempted_by")} + THEN 1 ELSE json_extract(attempted_by, '$[#-1]') = ? END)`; + +/** + * `metadata` for metadata filters: SQL `NULL`, which matches no filter, when + * it is text that isn't valid JSON. + */ +const PREFILTER_METADATA_SQL = `(CASE WHEN ${invalidJsonTextSql("metadata")} THEN NULL ELSE metadata END)`; + +/** SQL that is true when `metadata` can be read as JSON. */ +const VALID_METADATA_SQL = `NOT ${invalidJsonTextSql("metadata")}`; + +/** + * Append the bound client ID to `attempted_by`, keeping the newest + * `MAX_ATTEMPTED_BY - 1` (bound first) existing entries. Matches Go's + * statement, except that a non-array value (Go may write JSON `null`) starts a + * fresh array instead of producing `[null, id]`. Text that isn't valid JSON is + * left in place, as in Go. + */ +const ATTEMPTED_BY_APPEND_SQL = `CASE WHEN ${invalidJsonTextSql("attempted_by")} +THEN attempted_by +ELSE jsonb(json_insert( + coalesce(( + SELECT jsonb_group_array(value) + FROM ( + SELECT value FROM ( + SELECT key, value + FROM json_each( + CASE WHEN ${invalidJsonTextSql("attempted_by")} THEN jsonb('[]') + WHEN json_type(attempted_by) IS 'array' THEN attempted_by + ELSE jsonb('[]') END + ) + ORDER BY key DESC + LIMIT ? + ) ORDER BY key ASC + ) + ), jsonb('[]')), + '$[#]', ? +)) END`; + +/** Atomically claim due work in River priority order. */ +export function claimJobs( + database: DatabaseSync, + params: JobClaimParams +): JobClaimResult { + validateName(params.attemptedBy, "attemptedBy"); + for (const kind of params.kinds) validateName(kind, "kinds"); + const claimedAt = Temporal.Now.instant(); + const now = sqliteTimestamp(claimedAt); + const jobs: SqliteJobRow[] = []; + const decodeErrors = new Map(); + const queueNames = new Set(); + for (const queue of params.queues) { + validateName(queue.name, "queues.name"); + if (queueNames.has(queue.name)) { + throw invalidInput("queues", "must contain each queue once"); + } + queueNames.add(queue.name); + const limit = validateSmallInteger(queue.limit, "queues.limit", 0, 10_000); + if (limit === 0) continue; + const values: (number | string)[] = [ + now, + MAX_ATTEMPTED_BY - 1, + params.attemptedBy, + queue.name, + now, + ]; + let kindFilter = ""; + if (params.kinds.length > 0) { + kindFilter = ` AND kind IN (${placeholders(params.kinds.length)})`; + values.push(...params.kinds); + } + values.push(limit); + const rows = resultStatement( + database, + ` + UPDATE river_job + SET + attempt = attempt + 1, + attempted_at = ?, + attempted_by = ${ATTEMPTED_BY_APPEND_SQL}, + state = 'running' + WHERE id IN ( + SELECT river_job.id + FROM river_job + WHERE queue = ? + AND scheduled_at <= ? + AND state = 'available' + ${kindFilter} + AND NOT EXISTS ( + SELECT 1 FROM river_queue + WHERE river_queue.name = river_job.queue + AND river_queue.paused_at IS NOT NULL + ) + ORDER BY priority ASC, scheduled_at ASC, id ASC + LIMIT ? + ) + RETURNING ${JOB_COLUMNS} + ` + ).all(...values); + // The claim has moved every row to `running`, so a row that can't be + // decoded is returned with its error, in its place in claim order, for + // the runtime to fail rather than failing, and rolling back, the whole + // claim. + const decoded = rows.map(decodeJobRowPartial); + jobs.push(...decoded.map(({ job }) => job).sort(compareClaimedJobs)); + for (const { error, job } of decoded) { + if (error !== undefined) decodeErrors.set(job.id, error); + } + } + return decodeErrors.size === 0 ? { jobs } : { decodeErrors, jobs }; +} + +/** + * Read claimed jobs by ID, in the order of `ids`, decoding each as a claim + * does. Rejects when an ID repeats or has no row. + */ +export function loadClaimedJobs( + database: DatabaseSync, + ids: readonly bigint[] +): JobClaimResult { + const unique = new Set(); + for (const id of ids) { + validateInt64(id, "ids"); + if (id <= 0n) throw invalidInput("ids", "must be positive"); + if (unique.has(id)) throw invalidInput("ids", `repeat job ${id}`); + unique.add(id); + } + const jobs: SqliteJobRow[] = []; + const decodeErrors = new Map(); + if (ids.length === 0) return { jobs }; + const rows = resultStatement( + database, + `SELECT ${JOB_COLUMNS} FROM river_job + WHERE id IN (SELECT value FROM json_each(?))` + ).all(`[${ids.map((id) => id.toString(10)).join(",")}]`); + const byId = new Map>(); + for (const row of rows) { + const decoded = decodeJobRowPartial(row); + byId.set(decoded.job.id, decoded); + } + for (const id of ids) { + const decoded = byId.get(id); + if (decoded === undefined) { + throw invalidRowError("load_claimed", `claimed job ${id} has no row`); + } + jobs.push(decoded.job); + if (decoded.error !== undefined) decodeErrors.set(id, decoded.error); + } + return decodeErrors.size === 0 ? { jobs } : { decodeErrors, jobs }; +} + +/** Complete a batch while rejecting late results from older attempts. */ +export function completeJobs( + database: DatabaseSync, + items: readonly JobCompletionCommand[] +): readonly JobCompletionResult[] { + const results: JobCompletionResult[] = []; + for (const item of items) { + validateInt64(item.id, "id"); + // SQLite stores attempts as unbounded integers; accept whatever a claim + // could have produced. + validateSmallInteger(item.attempt, "attempt", 1, Number.MAX_SAFE_INTEGER); + validateName(item.attemptedBy, "attemptedBy"); + validateCompletionTiming(item); + const now = Temporal.Now.instant(); + const transition = completionTransition(item); + const error = + item.error === null + ? "{}" + : encodeJson( + { + at: item.error.at.toString(), + attempt: item.attempt, + error: item.error.error, + trace: item.error.trace, + }, + "error" + ); + const metadataUpdates = { + ...(item.metadata ?? {}), + ...(item.outputSet ? { output: item.output } : {}), + }; + const metadata = encodeJson(metadataUpdates, "metadata"); + const updatesMetadata = Object.keys(metadataUpdates).length > 0; + // Like Go, a present `cancel_attempted_at` key requests cancellation even + // when its value is JSON null (`->` yields the text 'null', not SQL NULL). + // Metadata that isn't valid JSON is treated as having no + // `cancel_attempted_at` and is left in place, as in Go. + const shouldCancel = `( + (? IN ('available', 'retryable', 'scheduled')) + AND (CASE WHEN ${VALID_METADATA_SQL} + THEN metadata -> '$.cancel_attempted_at' END) IS NOT NULL + )`; + const statement = resultStatement( + database, + ` + UPDATE river_job + SET + attempt = CASE + WHEN NOT ${shouldCancel} AND ? THEN ? + ELSE attempt + END, + errors = CASE WHEN ? THEN ${APPEND_ERROR_SQL} ELSE errors END, + finalized_at = CASE + WHEN ${shouldCancel} THEN ? + WHEN ? THEN ? + ELSE finalized_at + END, + metadata = CASE + WHEN ? AND ${VALID_METADATA_SQL} + THEN jsonb_patch(json(metadata), json(?)) + ELSE metadata + END, + scheduled_at = CASE + WHEN NOT ${shouldCancel} AND ? THEN ? + ELSE scheduled_at + END, + state = CASE WHEN ${shouldCancel} THEN 'cancelled' ELSE ? END + WHERE id = ? + AND state = 'running' + AND attempt = ? + AND ${ATTEMPTED_BY_FENCE_SQL} + RETURNING ${JOB_COLUMNS} + ` + ); + const finalizedAt = sqliteTimestampOrNull(transition.finalizedAt); + const scheduledAt = sqliteTimestampOrNull(transition.scheduledAt); + const raw = statement.get( + transition.state, + transition.nextAttempt === null ? 0 : 1, + transition.nextAttempt ?? 0, + item.error === null ? 0 : 1, + error, + error, + error, + transition.state, + sqliteTimestamp(now), + transition.finalizedAt === null ? 0 : 1, + finalizedAt, + updatesMetadata ? 1 : 0, + metadata, + transition.state, + transition.scheduledAt === null ? 0 : 1, + scheduledAt, + transition.state, + transition.state, + item.id, + item.attempt, + item.attemptedBy + ); + const staleRaw = + raw === undefined && updatesMetadata + ? resultStatement( + database, + `UPDATE river_job + SET metadata = CASE WHEN ${VALID_METADATA_SQL} + THEN jsonb_patch(json(metadata), json(?)) ELSE metadata END + WHERE id = ? + AND state != 'running' + AND attempt = ? + AND ${ATTEMPTED_BY_FENCE_SQL} + RETURNING ${JOB_COLUMNS}` + ).get(metadata, item.id, item.attempt, item.attemptedBy) + : undefined; + const key = jobCompletionKey(item); + results.push( + raw === undefined + ? { + job: + staleRaw === undefined + ? getJobPartial(database, item.id) + : decodeJobRowPartial(staleRaw).job, + key, + status: "stale", + } + : { + // A row that can't be fully decoded is still returned, with those + // fields empty, so it can't roll back the rest of the batch. + job: decodeJobRowPartial(raw).job, + key, + status: "applied", + } + ); + } + return results; +} + +/** + * List jobs with safe filters and stable keyset ordering. A metadata filter + * scans in pages with {@link listJobsMetadataPage} instead, so the event + * loop runs between them. + */ +export function listJobs( + database: DatabaseSync, + params: JobListParams & { readonly metadata: null } +): readonly SqliteJobRow[] { + const limit = validateSmallInteger(params.limit, "limit", 0, 10_000); + if (limit === 0) return []; + return listJobPage(database, params); +} + +/** Rows scanned per page of a metadata-filtered job list. */ +const METADATA_LIST_PAGE_SIZE = 1_000; + +/** + * Scan one bounded page of a metadata-filtered job list. + * + * SQLite has no JSON containment operator, so metadata filtering happens in + * JavaScript after a conservative SQL prefilter. Returns up to `params.limit` + * matches from at most one page of candidates, and the cursor to continue + * from, or `null` when the scan is complete. Callers yield to the event + * loop between pages so a sparse match never blocks it for a whole scan. + */ +export function listJobsMetadataPage( + database: DatabaseSync, + params: JobListParams +): { + readonly jobs: readonly SqliteJobRow[]; + readonly next: JobListCursorValue | null; +} { + const limit = validateSmallInteger(params.limit, "limit", 0, 10_000); + const metadata = params.metadata; + if (limit === 0 || metadata === null) { + return { + jobs: limit === 0 ? [] : listJobPage(database, params), + next: null, + }; + } + // The prefilter is selected as a flag rather than filtered on, so the + // statement reads at most one page of rows however sparse the matches are. + const prefilter = metadataPrefilter(metadata); + const query = jobListQuery( + { ...params, limit: METADATA_LIST_PAGE_SIZE, metadata: null }, + { + sql: `, CASE WHEN true${prefilter.sql} THEN 1 ELSE 0 END AS river_metadata_prefilter`, + values: prefilter.values, + } + ); + const rows = resultStatement(database, query.sql).all(...query.values); + const jobs: SqliteJobRow[] = []; + for (const raw of rows) { + if (raw.river_metadata_prefilter !== 1n) continue; + const row = decodeJobRowPartial(raw).job; + if (!jsonContains(row.metadata, metadata)) continue; + jobs.push(row); + if (jobs.length === limit) { + return { jobs, next: jobListCursorValue(row, params) }; + } + } + const last = rows.at(-1); + return { + jobs, + next: + rows.length < METADATA_LIST_PAGE_SIZE || last === undefined + ? null + : jobListCursorValue(decodeJobRowPartial(last).job, params), + }; +} + +function listJobPage( + database: DatabaseSync, + params: JobListParams +): readonly SqliteJobRow[] { + const query = jobListQuery(params); + // Like Go, list rows another engine wrote that River can't fully read, + // with the fields it can't decode left empty. + return resultStatement(database, query.sql) + .all(...query.values) + .map((raw) => decodeJobRowPartial(raw).job); +} + +/** + * Build a job list statement. `select` adds columns after the job's own, + * with their parameters bound first. + */ +function jobListQuery( + params: JobListParams, + select: { + readonly sql: string; + readonly values: readonly (bigint | string)[]; + } = { + sql: "", + values: [], + } +): { + readonly sql: string; + readonly values: readonly (bigint | number | string | Uint8Array | null)[]; +} { + const limit = params.limit; + const direction = params.sortDirection; + const orderBy = params.sortField; + const states = params.states; + if (!["asc", "desc"].includes(direction)) { + throw invalidInput("direction", "must be asc or desc"); + } + if (!["finalizedAt", "id", "scheduledAt", "time"].includes(orderBy)) { + throw invalidInput("orderBy", "unknown ordering field"); + } + if ( + orderBy === "finalizedAt" && + (states.length === 0 || + states.some( + (state) => + state !== "cancelled" && + state !== "completed" && + state !== "discarded" + )) + ) { + throw invalidInput( + "orderBy", + "finalized_at requires only terminal state filters" + ); + } + for (const state of states) validateState(state, "states"); + + if (params.after !== null) validateInt64(params.after.id, "after.id"); + const values: (bigint | number | string | Uint8Array | null)[] = [ + ...select.values, + ]; + const keyset = jobListKeysetSql(jobListKeyset(params), (value) => { + values.push(typeof value === "bigint" ? value : sqliteTimestamp(value)); + return "?"; + }); + let sql = `SELECT ${JOB_COLUMNS}${select.sql} FROM river_job WHERE true`; + if (keyset.after !== null) sql += ` AND ${keyset.after}`; + sql = addInFilter(sql, values, "id", params.ids, (value) => + validateInt64(value, "ids") + ); + sql = addInFilter(sql, values, "kind", params.kinds, (value) => + validateName(value, "kinds") + ); + sql = addInFilter(sql, values, "priority", params.priorities, (value) => + validateSmallInteger(value, "priorities", 1, 4) + ); + sql = addInFilter(sql, values, "queue", params.queues, (value) => + validateName(value, "queues") + ); + sql = addInFilter(sql, values, "state", states, (value) => value); + for (const tag of params.tagsAll) { + sql += " AND EXISTS (SELECT 1 FROM json_each(json(tags)) WHERE value = ?)"; + values.push(tag); + } + if (params.tagsAny.length > 0) { + sql += ` AND EXISTS (SELECT 1 FROM json_each(json(tags)) WHERE value IN (${placeholders( + params.tagsAny.length + )}))`; + values.push(...params.tagsAny); + } + sql += ` ORDER BY ${keyset.orderBy} LIMIT ?`; + values.push(limit); + return { sql, values }; +} + +function jsonContains(actual: JsonValue, expected: JsonValue): boolean { + if (expected === null || typeof expected !== "object") { + return actual === expected; + } + if (Array.isArray(expected)) { + if (!Array.isArray(actual)) return false; + return expected.every((expectedItem) => + actual.some((actualItem) => jsonContains(actualItem, expectedItem)) + ); + } + if (actual === null || typeof actual !== "object" || Array.isArray(actual)) { + return false; + } + return Object.entries(expected as JsonObject).every( + ([key, value]) => + Object.hasOwn(actual, key) && + jsonContains((actual as JsonObject)[key] as JsonValue, value) + ); +} + +/** + * Merge metadata and set output on a job with a JSON merge patch, like River + * for Go's SQLite `JobUpdate`. + */ +export function updateJob( + database: DatabaseSync, + id: bigint, + params: JobUpdateParams +): SqliteJobRow | null { + validateInt64(id, "id"); + const metadataPatch = Object.create(null) as JsonObject; + for (const [key, value] of Object.entries(params.metadata ?? {})) { + metadataPatch[key] = value; + } + if (Object.hasOwn(params, "output")) { + metadataPatch.output = params.output as JsonValue; + } + const hasMetadataPatch = Object.keys(metadataPatch).length > 0; + const raw = resultStatement( + database, + `UPDATE river_job SET + metadata = CASE + WHEN ? THEN jsonb_patch(json(metadata), json(?)) ELSE metadata END + WHERE id = ? + RETURNING ${JOB_COLUMNS}` + ).get(hasMetadataPatch ? 1 : 0, encodeJson(metadataPatch, "metadata"), id); + return raw === undefined ? null : decodeJobRow(raw); +} + +/** Fetch stuck running jobs in deterministic rescue order. */ +export function stuckJobs( + database: DatabaseSync, + params: { + afterId?: bigint; + attemptedBefore: Temporal.Instant; + limit?: number; + } +): readonly SqliteJobRow[] { + const afterId = validateInt64(params.afterId ?? 0n, "afterId"); + const limit = validateSmallInteger(params.limit ?? 100, "limit", 0, 10_000); + if (limit === 0) return []; + return ( + resultStatement( + database, + `SELECT ${JOB_COLUMNS} FROM river_job + WHERE state = 'running' AND id > ? AND attempted_at < ? + ORDER BY id ASC LIMIT ?` + ) + .all(afterId, sqliteTimestamp(params.attemptedBefore), limit) + // A row that can't be fully decoded is returned with those fields empty + // so it can't keep the rescuer from recovering every stuck job. + .map((raw) => decodeJobRowPartial(raw).job) + ); +} + +/** + * Rescue selected stuck jobs with the same semantics as Go and Rust. + * + * Only jobs that are still `running` with `attempted_at` strictly before the + * horizon the rescuer selected them with are updated. A job that completed, + * was released, or was claimed again after the rescuer read it is left + * untouched, including its errors, metadata, and timestamps. + */ +export function rescueJobs( + database: DatabaseSync, + items: readonly SqliteRescueJobParams[], + attemptedBefore: Temporal.Instant +): readonly SqliteJobRow[] { + const horizon = sqliteTimestamp(attemptedBefore); + const statement = resultStatement( + database, + ` + UPDATE river_job SET + errors = ${APPEND_ERROR_SQL}, + finalized_at = ?, + scheduled_at = ?, + metadata = CASE WHEN NOT ${VALID_METADATA_SQL} THEN metadata ELSE jsonb_set( + metadata, '$."river:rescue_count"', + coalesce( + CASE json_type(metadata, '$."river:rescue_count"') + WHEN 'integer' THEN json_extract(metadata, '$."river:rescue_count"') + WHEN 'real' THEN json_extract(metadata, '$."river:rescue_count"') + END, 0 + ) + 1 + ) END, + state = ? + WHERE id = ? + AND state = 'running' + AND attempted_at < ? + RETURNING ${JOB_COLUMNS} + ` + ); + const rows: SqliteJobRow[] = []; + for (const item of items) { + validateInt64(item.id, "id"); + const error = encodeJson( + { + at: item.error.at.toString(), + attempt: item.error.attempt, + error: item.error.error, + trace: item.error.trace, + }, + "error" + ); + const raw = statement.get( + error, + error, + error, + sqliteTimestampOrNull(item.finalizedAt), + sqliteTimestamp(item.scheduledAt), + item.state, + item.id, + horizon + ); + if (raw !== undefined) rows.push(decodeJobRowPartial(raw).job); + } + return rows; +} + +/** Delete a bounded set of terminal jobs past their retention horizons. */ +export function cleanupJobs( + database: DatabaseSync, + params: SqliteCleanupJobsParams +): number { + const limit = validateSmallInteger(params.limit ?? 1_000, "limit", 0, 10_000); + const queuesExcluded = (params.queuesExcluded ?? []).map((queue) => + validateName(queue, "queuesExcluded") + ); + const queuesIncluded = + params.queuesIncluded === undefined || params.queuesIncluded === null + ? null + : params.queuesIncluded.map((queue) => + validateName(queue, "queuesIncluded") + ); + // An empty inclusion list matches no queues, unlike an absent one. + if (limit === 0 || queuesIncluded?.length === 0) return 0; + const cancelledBefore = sqliteTimestampOrNull(params.cancelledBefore); + const completedBefore = sqliteTimestampOrNull(params.completedBefore); + const discardedBefore = sqliteTimestampOrNull(params.discardedBefore); + const values: (null | number | string)[] = [ + cancelledBefore, + cancelledBefore, + completedBefore, + completedBefore, + discardedBefore, + discardedBefore, + ]; + let sql = ` + DELETE FROM river_job WHERE id IN ( + SELECT id FROM river_job WHERE ( + (? IS NOT NULL AND state = 'cancelled' AND finalized_at < ?) + OR (? IS NOT NULL AND state = 'completed' AND finalized_at < ?) + OR (? IS NOT NULL AND state = 'discarded' AND finalized_at < ?) + )`; + // Queue filters apply inside the limited subquery, so retained jobs never + // use up a batch. + if (queuesExcluded.length > 0) { + sql += ` AND queue NOT IN (${placeholders(queuesExcluded.length)})`; + values.push(...queuesExcluded); + } + if (queuesIncluded !== null) { + sql += ` AND queue IN (${placeholders(queuesIncluded.length)})`; + values.push(...queuesIncluded); + } + for (const key of params.metadataExclusions ?? []) { + sql += " AND json_extract(metadata, ?) IS NULL"; + values.push(jsonPathForKey(key)); + } + sql += " ORDER BY id ASC LIMIT ?)"; + values.push(limit); + return Number(resultStatement(database, sql).run(...values).changes); +} + +/** Make due retryable/scheduled jobs available, discarding unique collisions. */ +export function scheduleJobs( + database: DatabaseSync, + params: { + limit?: number; + now?: Temporal.Instant; + scheduledAtHorizon?: Temporal.Instant; + } +): readonly SqliteScheduleResult[] { + const limit = validateSmallInteger(params.limit ?? 1_000, "limit", 0, 10_000); + if (limit === 0) return []; + const now = params.now ?? Temporal.Now.instant(); + const scheduledAtHorizon = params.scheduledAtHorizon ?? now; + const candidates = resultStatement( + database, + // Only the columns scheduling needs, so a job with a JSON column that + // isn't valid JSON can't fail the scheduler, as in Go. + `SELECT id, unique_key FROM river_job + WHERE state IN ('retryable', 'scheduled') AND scheduled_at <= ? + ORDER BY priority ASC, scheduled_at ASC, id ASC LIMIT ?` + ) + .all(sqliteTimestamp(scheduledAtHorizon), limit) + .map((raw) => ({ + id: validateInt64(raw.id as bigint, "id"), + uniqueKey: nullableBytes(raw.unique_key, "unique_key"), + })); + const results: SqliteScheduleResult[] = []; + for (const candidate of candidates) { + let collision = false; + if (candidate.uniqueKey !== null) { + collision = + resultStatement( + database, + `SELECT EXISTS ( + SELECT 1 FROM river_job + WHERE id != ? AND unique_key = ? AND unique_states IS NOT NULL + AND CASE state + WHEN 'available' THEN unique_states & (1 << 0) + WHEN 'cancelled' THEN unique_states & (1 << 1) + WHEN 'completed' THEN unique_states & (1 << 2) + WHEN 'discarded' THEN unique_states & (1 << 3) + WHEN 'pending' THEN unique_states & (1 << 4) + WHEN 'retryable' THEN unique_states & (1 << 5) + WHEN 'running' THEN unique_states & (1 << 6) + WHEN 'scheduled' THEN unique_states & (1 << 7) + ELSE 0 END >= 1 + ) AS present` + ).get(candidate.id, candidate.uniqueKey)?.present === 1n; + } + const raw = collision + ? resultStatement( + database, + `UPDATE river_job SET + metadata = CASE WHEN ${VALID_METADATA_SQL} + THEN jsonb_patch( + json(metadata), + json('{"unique_key_conflict":"scheduler_discarded"}') + ) + ELSE metadata END, + finalized_at = ?, state = 'discarded' + WHERE id = ? AND state IN ('retryable', 'scheduled') + RETURNING ${JOB_COLUMNS}` + ).get(sqliteTimestamp(now), candidate.id) + : resultStatement( + database, + `UPDATE river_job SET state = 'available' + WHERE id = ? AND state IN ('retryable', 'scheduled') + RETURNING ${JOB_COLUMNS}` + ).get(candidate.id); + if (raw !== undefined) { + results.push({ + conflictDiscarded: collision, + // A row that can't be fully decoded is still scheduled and returned + // with those fields empty. + job: decodeJobRowPartial(raw).job, + }); + } + } + return results; +} + +/** Attempt to acquire the portable singleton leader after expiring old terms. */ +export function leaderAttemptElect( + database: DatabaseSync, + params: { leaderId: string; now?: Temporal.Instant; ttlMs: number } +): SqliteLeader | null { + validateName(params.leaderId, "leaderId"); + const ttlMs = validateSmallInteger(params.ttlMs, "ttlMs", 1, 2_147_483_647); + const now = params.now ?? Temporal.Now.instant(); + resultStatement( + database, + "DELETE FROM river_leader WHERE expires_at < ?" + ).run(sqliteTimestamp(now)); + const raw = resultStatement( + database, + `INSERT INTO river_leader (leader_id, elected_at, expires_at) + VALUES (?, ?, ?) ON CONFLICT (name) DO NOTHING + RETURNING elected_at, expires_at, leader_id` + ).get( + params.leaderId, + sqliteTimestamp(now), + sqliteTimestamp(now.add({ milliseconds: ttlMs })) + ); + return raw === undefined ? null : decodeLeader(raw); +} + +/** Renew exactly the still-current, unexpired leadership term. */ +export function leaderAttemptReelect( + database: DatabaseSync, + leader: SqliteLeader, + params: { now?: Temporal.Instant; ttlMs: number } +): SqliteLeader | null { + validateName(leader.leaderId, "leaderId"); + const ttlMs = validateSmallInteger(params.ttlMs, "ttlMs", 1, 2_147_483_647); + const now = params.now ?? Temporal.Now.instant(); + const raw = resultStatement( + database, + // Compare terms as instants, like Go, so equal times written in another + // engine's text format (for example without milliseconds) still match. + `UPDATE river_leader SET expires_at = ? + WHERE unixepoch(elected_at, 'subsec') = unixepoch(?, 'subsec') + AND expires_at >= ? AND leader_id = ? + RETURNING elected_at, expires_at, leader_id` + ).get( + sqliteTimestamp(now.add({ milliseconds: ttlMs })), + sqliteTimestamp(leader.electedAt), + sqliteTimestamp(now), + leader.leaderId + ); + return raw === undefined ? null : decodeLeader(raw); +} + +/** Read the singleton elected leader, including an expired term for diagnostics. */ +export function leaderGet(database: DatabaseSync): SqliteLeader | null { + const raw = resultStatement( + database, + "SELECT elected_at, expires_at, leader_id FROM river_leader LIMIT 1" + ).get(); + return raw === undefined ? null : decodeLeader(raw); +} + +/** Resign only the exact fenced leadership term. */ +export function leaderResign( + database: DatabaseSync, + leader: Pick +): boolean { + const result = resultStatement( + database, + `DELETE FROM river_leader + WHERE unixepoch(elected_at, 'subsec') = unixepoch(?, 'subsec') + AND leader_id = ?` + ).run(sqliteTimestamp(leader.electedAt), leader.leaderId); + return Number(result.changes) > 0; +} + +/** + * Read durable notifications after an exact outbox ID, in ID order. Like Go's + * `NotificationGetAfter`, topics are bound as one JSON array. + */ +export function notificationPoll( + database: DatabaseSync, + params: { afterId?: bigint; limit?: number; topics?: readonly string[] } = {} +): readonly SqliteNotification[] { + const afterId = validateInt64(params.afterId ?? 0n, "afterId"); + const limit = validateSmallInteger(params.limit ?? 1_000, "limit", 0, 10_000); + if (limit === 0) return []; + const values: (bigint | number | string)[] = [afterId]; + let sql = `SELECT created_at, id, payload, topic + FROM river_notification WHERE id > ?`; + const topics = params.topics ?? []; + if (topics.length > 0) { + sql += " AND topic IN (SELECT value FROM json_each(?))"; + values.push( + JSON.stringify(topics.map((topic) => validateName(topic, "topics"))) + ); + } + sql += " ORDER BY id ASC LIMIT ?"; + values.push(limit); + return resultStatement(database, sql) + .all(...values) + .map(decodeNotification); +} + +/** Return the current durable outbox high-water ID. */ +export function notificationLastId(database: DatabaseSync): bigint { + const raw = resultStatement( + database, + "SELECT coalesce(max(id), 0) AS id FROM river_notification" + ).get(); + return raw?.id as bigint; +} + +/** + * Delete up to `limit` notifications created before a horizon, oldest first, + * like Go's `NotificationDeleteBefore`. + */ +export function notificationCleanup( + database: DatabaseSync, + params: { createdBefore: Temporal.Instant; limit?: number } +): number { + const limit = validateSmallInteger(params.limit ?? 1_000, "limit", 0, 10_000); + if (limit === 0) return 0; + return Number( + resultStatement( + database, + `DELETE FROM river_notification WHERE id IN ( + SELECT id FROM river_notification WHERE created_at < ? + ORDER BY created_at, id + LIMIT ? + )` + ).run(sqliteTimestamp(params.createdBefore), limit).changes + ); +} + +/** Delete inactive queues in bounded deterministic order. */ +export function queueDeleteExpired( + database: DatabaseSync, + params: { limit?: number; updatedBefore: Temporal.Instant } +): readonly string[] { + const limit = validateSmallInteger(params.limit ?? 1_000, "limit", 0, 10_000); + if (limit === 0) return []; + return resultStatement( + database, + `DELETE FROM river_queue WHERE name IN ( + SELECT name FROM river_queue WHERE updated_at < ? + ORDER BY name ASC LIMIT ? + ) RETURNING name` + ) + .all(sqliteTimestamp(params.updatedBefore), limit) + .map((row) => row.name as string); +} + +function addInFilter( + sql: string, + values: (bigint | number | string | Uint8Array | null)[], + column: string, + filter: readonly T[] | undefined, + validate: (value: T) => bigint | number | string +): string { + if (filter === undefined || filter.length === 0) return sql; + sql += ` AND ${column} IN (${placeholders(filter.length)})`; + for (const value of filter) values.push(validate(value)); + return sql; +} + +function compareClaimedJobs(left: SqliteJobRow, right: SqliteJobRow): number { + return ( + left.priority - right.priority || + Temporal.Instant.compare(left.scheduledAt, right.scheduledAt) || + (left.id < right.id ? -1 : left.id > right.id ? 1 : 0) + ); +} + +/** + * The row changes for one completion. A snooze or interruption refunds its + * attempt like Go's `Attempt - 1` parameters; an error never does, even when + * the near-future fast path persists it as `available`. + */ +function completionTransition(item: JobCompletionCommand): { + finalizedAt: Temporal.Instant | null; + nextAttempt: number | null; + scheduledAt: Temporal.Instant | null; + state: SqliteJobState; +} { + switch (item.kind) { + case "cancel": + return { + finalizedAt: item.finalizedAt, + nextAttempt: null, + scheduledAt: null, + state: "cancelled", + }; + case "complete": + return { + finalizedAt: item.finalizedAt, + nextAttempt: null, + scheduledAt: null, + state: "completed", + }; + case "discard": + return { + finalizedAt: item.finalizedAt, + nextAttempt: null, + scheduledAt: null, + state: "discarded", + }; + case "interrupt": + if (item.scheduledAt === null) { + throw invalidInput("scheduledAt", "is required for interrupt"); + } + return { + finalizedAt: null, + nextAttempt: Math.max(item.attempt - 1, 0), + scheduledAt: item.scheduledAt, + state: "available", + }; + case "retry": + if (item.scheduledAt === null) { + throw invalidInput("scheduledAt", "is required for retry"); + } + return { + finalizedAt: null, + nextAttempt: null, + scheduledAt: item.scheduledAt, + state: item.available === true ? "available" : "retryable", + }; + case "snooze": + if (item.scheduledAt === null) { + throw invalidInput("scheduledAt", "is required for snooze"); + } + return { + finalizedAt: null, + nextAttempt: Math.max(item.attempt - 1, 0), + scheduledAt: item.scheduledAt, + state: item.available === true ? "available" : "scheduled", + }; + } +} + +function validateCompletionTiming(item: JobCompletionCommand): void { + const terminal = + item.kind === "cancel" || + item.kind === "complete" || + item.kind === "discard"; + if (terminal !== (item.finalizedAt !== null)) { + throw invalidInput( + "finalizedAt", + terminal + ? `is required for ${item.kind}` + : `must be null for ${item.kind}` + ); + } + if ( + item.available === true && + item.kind !== "retry" && + item.kind !== "snooze" + ) { + throw invalidInput( + "available", + `the near-future fast path does not apply to ${item.kind}` + ); + } +} + +function decodeLeader(raw: Record): SqliteLeader { + if (typeof raw.leader_id !== "string") { + throw invalidRow("leader_id", "is not text"); + } + return { + electedAt: parseSqliteTimestamp(raw.elected_at, "elected_at"), + expiresAt: parseSqliteTimestamp(raw.expires_at, "expires_at"), + leaderId: raw.leader_id, + }; +} + +function decodeNotification(raw: Record): SqliteNotification { + if ( + typeof raw.id !== "bigint" || + typeof raw.payload !== "string" || + typeof raw.topic !== "string" + ) { + throw invalidRow("notification", "has an invalid column type"); + } + return { + createdAt: parseSqliteTimestamp(raw.created_at, "created_at"), + id: raw.id, + payload: raw.payload, + topic: raw.topic, + }; +} + +function invalidInput(field: string, message: string): RiverError { + return invalidInputError( + "runtime", + `invalid SQLite River input ${field}: ${message}` + ); +} + +/** Read one job, leaving fields that can't be decoded empty. */ +function getJobPartial( + database: DatabaseSync, + id: bigint +): SqliteJobRow | null { + const raw = resultStatement( + database, + `SELECT ${JOB_COLUMNS} FROM river_job WHERE id = ? LIMIT 1` + ).get(id); + return raw === undefined ? null : decodeJobRowPartial(raw).job; +} + +function invalidRow(field: string, message: string): RiverError { + return invalidRowError( + "runtime", + `invalid SQLite River row ${field}: ${message}` + ); +} + +function jsonPathForKey(key: string): string { + if (key.length === 0) throw invalidInput("metadataExclusions", "empty key"); + return `$.${JSON.stringify(key)}`; +} + +/** + * SQL that narrows a metadata filter to rows that can possibly match it. + * + * The exact comparison still happens in JavaScript (River JSON numbers are + * arbitrary precision), so this only has to be necessary, never sufficient: + * every top-level key must be present with a compatible JSON type, and string, + * boolean, and `null` values must match exactly. It keeps metadata-filtered + * scans from decoding and comparing every row in the table. + */ +function metadataPrefilter(expected: JsonObject): { + readonly sql: string; + readonly values: readonly (bigint | string)[]; +} { + let sql = ""; + const values: (bigint | string)[] = []; + for (const [key, value] of Object.entries(expected)) { + // SQLite JSON paths cannot escape a double quote inside a label. + if (key.includes('"') || key.includes("\\")) continue; + const path = `$."${key}"`; + if (typeof value === "string") { + sql += ` AND json_type(${PREFILTER_METADATA_SQL}, ?) = 'text' AND json_extract(${PREFILTER_METADATA_SQL}, ?) = ?`; + values.push(path, path, value); + } else if (typeof value === "boolean") { + sql += ` AND json_type(${PREFILTER_METADATA_SQL}, ?) = '${value ? "true" : "false"}'`; + values.push(path); + } else if (value === null) { + sql += ` AND json_type(${PREFILTER_METADATA_SQL}, ?) = 'null'`; + values.push(path); + } else if (Array.isArray(value)) { + sql += ` AND json_type(${PREFILTER_METADATA_SQL}, ?) = 'array'`; + values.push(path); + } else if (Number.isSafeInteger(value)) { + // Any JSON spelling of this integer (`5`, `5.0`, `5e0`) reads as a + // number equal to it, so comparing numerically never drops a match. + sql += ` AND json_type(${PREFILTER_METADATA_SQL}, ?) IN ('integer', 'real') AND json_extract(${PREFILTER_METADATA_SQL}, ?) = ?`; + values.push(path, path, BigInt(value as number)); + } else if (typeof value === "number" || isExactJsonNumber(value)) { + sql += ` AND json_type(${PREFILTER_METADATA_SQL}, ?) IN ('integer', 'real')`; + values.push(path); + } else { + sql += ` AND json_type(${PREFILTER_METADATA_SQL}, ?) = 'object'`; + values.push(path); + } + } + return { sql, values }; +} + +function placeholders(length: number): string { + return Array.from({ length }, () => "?").join(", "); +} + +const STATEMENT_CACHE_LIMIT = 256; +const statementCaches = new WeakMap>(); + +/** + * Return a prepared statement for `sql`, reusing one per database handle. + * + * Every statement reads integers as `bigint`. The cache is a small LRU keyed + * by SQL text; statements whose text varies with input length (for example + * `IN (?, ?)` lists) share the bound instead of growing without limit. + */ +export function resultStatement( + database: DatabaseSync, + sql: string +): StatementSync { + let cache = statementCaches.get(database); + if (cache === undefined) { + cache = new Map(); + statementCaches.set(database, cache); + } + const cached = cache.get(sql); + if (cached !== undefined) { + if (statementIsLive(cached)) { + // Refresh recency. + cache.delete(sql); + cache.set(sql, cached); + return cached; + } + // The handle was closed and opened again, which finalized every + // statement prepared on it. + cache.clear(); + } + const statement = database.prepare(sql); + statement.setReadBigInts(true); + cache.set(sql, statement); + if (cache.size > STATEMENT_CACHE_LIMIT) { + const oldest = cache.keys().next(); + if (oldest.done !== true) cache.delete(oldest.value); + } + return statement; +} + +/** Whether `statement` can still run: closing its database finalizes it. */ +function statementIsLive(statement: StatementSync): boolean { + try { + void statement.sourceSQL; + return true; + } catch { + return false; + } +} + +function validateState(state: SqliteJobState, field: string): SqliteJobState { + if (!JOB_STATES.includes(state)) { + throw invalidInput(field, `unknown state ${JSON.stringify(state)}`); + } + return state; +} diff --git a/js/driver/sqlite/src/pagination.property.test.ts b/js/driver/sqlite/src/pagination.property.test.ts new file mode 100644 index 000000000..9e773f515 --- /dev/null +++ b/js/driver/sqlite/src/pagination.property.test.ts @@ -0,0 +1,213 @@ +import type { DatabaseSync } from "node:sqlite"; + +import fc from "fast-check"; +import { describe, expect, it } from "vitest"; +import { Client, defineJob } from "riverqueue"; +import type { JobRow, JobState } from "riverqueue"; +import type { JobListOrderBy, SortDirection } from "riverqueue/unstable-driver"; + +import { + SQLITE_DRIVER_TEST_HOOKS, + type SqliteRuntime, + testSqliteMemory, +} from "./driver.js"; +import type { SqliteDriverOptions } from "./types.js"; + +/** River's own tests fail any lock window that crosses the event loop. */ +const STRICT = { + [SQLITE_DRIVER_TEST_HOOKS]: { strictLockWindow: true }, +} as SqliteDriverOptions; + +const pageJob = defineJob({ + kind: "pagination_property", + decode: (value) => value, +}); + +// A handful of scheduled times shared by many jobs, so that nearly every +// page boundary falls inside a run of equal sort values. +const SCHEDULE_OFFSETS_MS = [-7_200_000, -3_600_000, -1, 3_600_000, 7_200_000]; + +interface GeneratedJob { + readonly cancel: boolean; + readonly pending: boolean; + readonly priority: number; + readonly scheduleOffset: number; +} + +const jobsArbitrary = fc.array( + fc.record({ + cancel: fc.boolean(), + pending: fc.boolean(), + priority: fc.integer({ max: 4, min: 1 }), + scheduleOffset: fc.constantFrom(...SCHEDULE_OFFSETS_MS), + }), + { maxLength: 30, minLength: 1 } +); + +const FINALIZED = ["cancelled", "completed", "discarded"]; + +/** + * The time River sorts by, or `null` where that column is null. Like Go, + * `time` picks one column for the whole query from the first requested + * state (`available` when none are given). + */ +function sortTime( + job: JobRow, + orderBy: JobListOrderBy, + firstState: JobState +): bigint | null { + const column = + orderBy !== "time" + ? orderBy + : FINALIZED.includes(firstState) + ? "finalizedAt" + : firstState === "running" + ? "attemptedAt" + : "scheduledAt"; + return column === "id" ? 0n : (job[column]?.epochNanoseconds ?? null); +} + +/** + * River's keyset order: the sort time, then the ID, in one direction. Null + * times sort after every time, so last ascending and first descending. + */ +function compareJobs( + orderBy: JobListOrderBy, + direction: SortDirection, + firstState: JobState +): (left: JobRow, right: JobRow) => number { + const sign = direction === "asc" ? 1 : -1; + return (left, right) => { + const leftTime = sortTime(left, orderBy, firstState); + const rightTime = sortTime(right, orderBy, firstState); + if (leftTime !== rightTime) { + if (leftTime === null) return sign; + if (rightTime === null) return -sign; + return leftTime < rightTime ? -sign : sign; + } + return left.id < right.id ? -sign : left.id > right.id ? sign : 0; + }; +} + +async function setup(): Promise<{ + client: Client; + driver: SqliteRuntime; +}> { + const driver = testSqliteMemory(STRICT); + const database = driver.database; + const moduleUrl = new URL("../../../migrate/dist/index.js", import.meta.url); + const { createMigrator } = (await import(moduleUrl.href)) as { + createMigrator(target: { database: DatabaseSync }): { + migrateUp(): Promise; + }; + }; + await createMigrator({ database }).migrateUp(); + return { client: new Client(driver), driver }; +} + +describe("SQLite job list pagination properties", () => { + it("visits every job exactly once in keyset order across ties", async () => { + await fc.assert( + fc.asyncProperty( + jobsArbitrary, + fc.constantFrom( + "finalizedAt", + "id", + "scheduledAt", + "time" + ), + fc.constantFrom("asc", "desc"), + fc.integer({ max: 7, min: 1 }), + fc.constantFrom( + ["available", "pending", "scheduled"], + ["scheduled", "available"], + ["cancelled"], + // Mixed states sort every job by the first state's column, which + // is null for the unfinalized jobs of a finalized-first filter. + ["available", "cancelled", "pending", "scheduled"], + ["cancelled", "available", "pending", "scheduled"], + [] + ), + async (generated, orderBy, sortDirection, limit, timeStates) => { + const { client, driver } = await setup(); + try { + const base = Temporal.Now.instant().round("millisecond"); + const { length } = await client.insertMany( + generated.map((job) => ({ + args: {}, + job: pageJob, + options: { + pending: job.pending, + priority: job.priority, + scheduledAt: base.add({ milliseconds: job.scheduleOffset }), + }, + })) + ); + expect(length).toBe(generated.length); + // Cancel in a burst so that finalization times collide too. + const all = await client.jobs.list({ limit: 1_000 }); + await Promise.all( + all.jobs + .filter((_, index) => generated[index]?.cancel === true) + .map((job) => client.jobs.cancel(job.id)) + ); + + const states = + orderBy === "finalizedAt" + ? (["cancelled"] as const) + : orderBy === "time" + ? timeStates + : undefined; + const filter = { + orderBy, + sortDirection, + ...(states === undefined ? {} : { states }), + }; + const full = await client.jobs.list({ ...filter, limit: 1_000 }); + const expected = [...full.jobs].sort( + compareJobs(orderBy, sortDirection, states?.[0] ?? "available") + ); + expect(full.jobs.map((job) => job.id)).toEqual( + expected.map((job) => job.id) + ); + expect(full.jobs.length).toBe( + states === undefined || states.length === 0 + ? generated.length + : all.jobs.filter((_, index) => { + const job = generated[index]; + const state = job?.cancel + ? "cancelled" + : job?.pending + ? "pending" + : (job?.scheduleOffset ?? 0) > 0 + ? "scheduled" + : "available"; + return states.includes(state); + }).length + ); + + const paged: bigint[] = []; + let after: string | undefined; + for (let page = 0; page <= generated.length + 1; page++) { + const result = await client.jobs.list({ + ...filter, + limit, + ...(after === undefined ? {} : { after }), + }); + expect(result.jobs.length).toBeLessThanOrEqual(limit); + paged.push(...result.jobs.map((job) => job.id)); + if (result.nextCursor === null || result.jobs.length === 0) { + break; + } + after = result.nextCursor; + } + expect(paged).toEqual(full.jobs.map((job) => job.id)); + } finally { + driver.close(); + } + } + ), + { numRuns: 60 } + ); + }); +}); diff --git a/js/driver/sqlite/src/scope.test.ts b/js/driver/sqlite/src/scope.test.ts new file mode 100644 index 000000000..306ba55ab --- /dev/null +++ b/js/driver/sqlite/src/scope.test.ts @@ -0,0 +1,506 @@ +import { EventEmitter } from "node:events"; +import { mkdtempSync, rmSync } from "node:fs"; +import { readFile, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; + +import { Client, defineJob, type ClientOptions } from "riverqueue"; +import { describe, expect, onTestFinished, test } from "vitest"; + +import { + SQLITE_DRIVER_TEST_HOOKS, + type SqliteRuntime, + testSqliteDriver, + testSqliteMemory, +} from "./driver.js"; +import { transaction } from "./scope.js"; +import type { SqliteDriverOptions } from "./types.js"; + +/** River's own tests fail any lock window that crosses the event loop. */ +const STRICT = { + [SQLITE_DRIVER_TEST_HOOKS]: { strictLockWindow: true }, +} as SqliteDriverOptions; + +const otherJob = defineJob({ kind: "other" }); +const scopedJob = defineJob({ kind: "scoped" }); + +describe("SqliteDriver insertion transactions", () => { + test("rolls an insertion back when middleware or a hook throws after the write", async () => { + const failure = new Error("fails after the write"); + let failIn: "afterInsert" | "middleware" | null = null; + const { client, count } = await setup({ + hooks: { + afterInsert: () => { + if (failIn === "afterInsert") throw failure; + }, + }, + insertMiddleware: [ + async (_context, next) => { + const results = await next(); + if (failIn === "middleware") throw failure; + return results; + }, + ], + }); + + for (const where of ["middleware", "afterInsert"] as const) { + failIn = where; + await expect(client.insert(scopedJob, {})).rejects.toBe(failure); + await expect( + client.insertMany([{ args: {}, job: scopedJob }]) + ).rejects.toBe(failure); + } + expect(count("river_job")).toBe(0); + expect(count("river_notification")).toBe(0); + + failIn = null; + await client.insert(scopedJob, {}); + expect(count("river_job")).toBe(1); + }); + + test("takes the write lock only at the insertion's first statement", async () => { + let entered!: () => void; + const inMiddleware = new Promise((resolve) => { + entered = resolve; + }); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const { client, count, database } = await setup({ + insertMiddleware: [ + async (_context, next) => { + entered(); + // Stands in for I/O such as a remote key service call. + await gate; + return next(); + }, + ], + }); + database.exec("CREATE TABLE application_row (id text PRIMARY KEY)"); + + const insertion = client.insert(scopedJob, {}); + await inMiddleware; + // No lock is held yet, so another connection writes without waiting. + database.prepare("INSERT INTO application_row (id) VALUES (?)").run("a"); + release(); + await insertion; + + expect(count("application_row")).toBe(1); + expect(count("river_job")).toBe(1); + }); + + test("keeps application statements on other handles out of its transaction", async () => { + const outcomes: string[] = []; + const { client, count, database } = await setup((application) => ({ + insertMiddleware: [ + async (_context, next) => { + const results = await next(); + outcomes.push( + `read:${String( + application + .prepare("SELECT count(*) AS count FROM river_job") + .get()?.count + )}` + ); + try { + application + .prepare("INSERT INTO application_row (id) VALUES (?)") + .run("joined"); + } catch (error: unknown) { + outcomes.push( + `write:${String((error as { errcode?: number }).errcode)}` + ); + } + return results; + }, + ], + })); + database.exec("CREATE TABLE application_row (id text PRIMARY KEY)"); + + await client.insert(scopedJob, {}); + + // The application saw committed state only and couldn't write while + // River held the lock; River's insertion still committed on its own. + expect(outcomes).toEqual(["read:0", "write:5"]); + expect(count("application_row")).toBe(0); + expect(count("river_job")).toBe(1); + }); + + test("fails River calls from middleware and hooks that would wait for its transaction", async () => { + let call: (() => Promise) | undefined; + const { client, count, database } = await setup({ + hooks: { + afterInsert: async () => { + await call?.(); + }, + }, + }); + const reentrant = { code: "transaction_scope", reason: "reentrant" }; + + call = () => client.jobs.get(1n); + await expect(client.insert(scopedJob, {})).rejects.toMatchObject(reentrant); + call = () => client.insert(scopedJob, {}); + await expect(client.insert(scopedJob, {})).rejects.toMatchObject(reentrant); + call = () => transaction(database, () => undefined); + await expect(client.insert(scopedJob, {})).rejects.toMatchObject(reentrant); + expect(count("river_job")).toBe(0); + }); + + test("lets unrelated work that inherited its async context run before the write", async () => { + // A shared batcher created lazily inside insert middleware inherits the + // insertion's async context, so its later flushes run "inside" River's + // transaction while the middleware still awaits I/O before next(). + const bus = new EventEmitter(); + let batcher: NodeJS.Timeout | undefined; + onTestFinished(() => clearInterval(batcher)); + const { client, count } = await setup({ + insertMiddleware: [ + async (context, next) => { + if ( + batcher === undefined && + context.requests[0]?.args.wire === true + ) { + batcher = setInterval(() => bus.emit("flush"), 5); + await new Promise((resolve) => setTimeout(resolve, 50)); + } + return next(); + }, + ], + }); + const flushed: Promise[] = []; + bus.on("flush", () => { + flushed.push(client.insert(scopedJob, {})); + }); + + await client.insert(scopedJob, { wire: true }); + clearInterval(batcher); + const results = await Promise.allSettled(flushed); + + expect(results.length).toBeGreaterThan(2); + expect(results.filter(({ status }) => status === "rejected")).toEqual([]); + expect(count("river_job")).toBe(results.length + 1); + }); + + test("lets middleware and beforeInsert hooks call River before the write", async () => { + const directory = mkdtempSync(join(tmpdir(), "river-sqlite-other-")); + const otherDatabase = new DatabaseSync(join(directory, "other.db")); + onTestFinished(() => { + otherDatabase.close(); + rmSync(directory, { force: true, recursive: true }); + }); + const { client, count } = await setup((application) => ({ + hooks: { + beforeInsert: async (context) => { + if (context.requests[0]?.kind !== scopedJob.kind) return; + await client.jobs.list(); + await transaction(otherDatabase, () => undefined); + await transaction(application, () => undefined); + }, + }, + insertMiddleware: [ + async (context, next) => { + if (context.requests[0]?.kind === scopedJob.kind) { + await client.insert(otherJob, {}); + } + return next(); + }, + ], + })); + + await client.insert(scopedJob, {}); + + expect(count("river_job")).toBe(2); + }); + + test("inserts in an application transaction on another handle", async () => { + const { client, count, database, driver } = await setup(); + const application = driver.connect(); + onTestFinished(() => application.close()); + application.exec("CREATE TABLE application_row (id text PRIMARY KEY)"); + const rollback = new Error("roll back"); + + await expect( + transaction(application, async (tx) => { + tx.prepare("INSERT INTO application_row (id) VALUES (?)").run("a"); + await client.insert(scopedJob, {}, { tx }); + // Readers outside the transaction don't see the job yet. + expect( + database.prepare("SELECT count(*) AS count FROM river_job").get() + ).toEqual({ count: 0 }); + throw rollback; + }) + ).rejects.toBe(rollback); + expect(count("river_job")).toBe(0); + + await transaction(application, async (tx) => { + tx.prepare("INSERT INTO application_row (id) VALUES (?)").run("b"); + await client.insertMany([{ args: {}, job: scopedJob }], { tx }); + }); + expect(count("application_row")).toBe(1); + expect(count("river_job")).toBe(1); + }); + + test("fails an insertion that awaits I/O while River holds the write lock", async () => { + const io: Record Promise> = { + file: () => readFile(import.meta.filename), + timer: () => new Promise((resolve) => setTimeout(resolve, 5)), + }; + let wait: (() => Promise) | null = null; + let where: "afterInsert" | "middleware" = "middleware"; + const { client, count } = await setup({ + hooks: { + afterInsert: async () => { + if (where === "afterInsert") await wait?.(); + }, + }, + insertMiddleware: [ + async (_context, next) => { + const results = await next(); + if (where === "middleware") await wait?.(); + return results; + }, + ], + }); + + for (const [name, awaitIo] of Object.entries(io)) { + for (const hook of ["middleware", "afterInsert"] as const) { + wait = awaitIo; + where = hook; + await expect( + client.insert(scopedJob, {}), + `${name} in ${hook}` + ).rejects.toMatchObject({ + code: "transaction_scope", + message: expect.stringContaining("after next()"), + reason: "event_loop_turn", + retryable: false, + }); + } + } + expect(count("river_job")).toBe(0); + + // I/O before next() holds no lock and is fine. + wait = null; + await client.insert(scopedJob, {}); + expect(count("river_job")).toBe(1); + }); + + test("releases the write lock as soon as the event loop turns", async () => { + let afterNext!: () => void; + const reachedAfterNext = new Promise((resolve) => { + afterNext = resolve; + }); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const { client, count, database, driver } = await setup({ + insertMiddleware: [ + async (_context, next) => { + const results = await next(); + afterNext(); + await gate; + return results; + }, + ], + }); + database.exec("CREATE TABLE application_row (id text PRIMARY KEY)"); + + const insertion = client.insert(scopedJob, {}); + void insertion.catch(() => undefined); + await reachedAfterNext; + await new Promise((resolve) => setImmediate(resolve)); + + // The middleware still awaits, but the lock is already released for + // application writers and River's other work alike. + database.prepare("INSERT INTO application_row (id) VALUES (?)").run("a"); + await driver.jobInsert({ args: {}, kind: "during_window" }); + release(); + + await expect(insertion).rejects.toMatchObject({ + reason: "event_loop_turn", + }); + expect(count("application_row")).toBe(1); + expect( + database + .prepare("SELECT kind FROM river_job") + .all() + .map(({ kind }) => kind) + ).toEqual(["during_window"]); + }); + + test("fails fast local I/O after next() deterministically in strict mode", async () => { + // The probe misses I/O that completes before the event loop's check + // phase, which is common for an insertion started from a timer or + // setImmediate callback. River's own tests run in strict mode, which + // counts every macrotask callback instead. + const io: Record Promise> = { + digest: () => crypto.subtle.digest("SHA-256", new Uint8Array(16)), + stat: () => stat(import.meta.filename), + }; + let wait: () => Promise = () => Promise.resolve(); + const { client, count } = await setup({ + insertMiddleware: [ + async (_context, next) => { + const results = await next(); + await wait(); + return results; + }, + ], + }); + const origins: Record void) => void> = { + immediate: (run) => setImmediate(run), + timer: (run) => setTimeout(run, 0), + }; + + for (const [origin, schedule] of Object.entries(origins)) { + for (const [name, awaitIo] of Object.entries(io)) { + wait = awaitIo; + for (let index = 0; index < 25; index++) { + const outcome = await new Promise((resolve) => { + schedule(() => { + client + .insert(scopedJob, {}) + .then( + () => "committed", + (error: unknown) => (error as { reason?: string }).reason + ) + .then(resolve, resolve); + }); + }); + expect(outcome, `${name} from ${origin}`).toBe("event_loop_turn"); + } + } + } + expect(count("river_job")).toBe(0); + }); + + test("allows microtask-only waits after next() in strict mode", async () => { + const { client, count } = await setup({ + insertMiddleware: [ + async (_context, next) => { + const results = await next(); + for (let index = 0; index < 20; index++) await Promise.resolve(); + await new Promise((resolve) => { + queueMicrotask(resolve); + }); + await new Promise((resolve) => { + process.nextTick(resolve); + }); + return results; + }, + ], + }); + const origins: ((run: () => void) => void)[] = [ + (run) => setImmediate(run), + (run) => setTimeout(run, 0), + (run) => { + void stat(import.meta.filename).then(run); + }, + (run) => { + run(); + }, + ]; + + for (const schedule of origins) { + for (let index = 0; index < 10; index++) { + await new Promise((resolve, reject) => { + schedule(() => { + client.insert(scopedJob, {}).then(() => resolve(), reject); + }); + }); + } + } + expect(count("river_job")).toBe(40); + }); + + test("keeps a plain insertion within one turn of the event loop", async () => { + const { client, count, database } = await setup(); + database.exec("CREATE TABLE application_row (id integer PRIMARY KEY)"); + const busy: unknown[] = []; + let writes = 0; + let running = true; + const write = (): void => { + if (!running) return; + try { + database + .prepare("INSERT INTO application_row (id) VALUES (?)") + .run(++writes); + } catch (error: unknown) { + busy.push(error); + } + setImmediate(write); + }; + setImmediate(write); + + for (let index = 0; index < 200; index++) { + await new Promise((resolve) => setImmediate(resolve)); + await client.insert(scopedJob, {}); + } + await Promise.all( + Array.from({ length: 50 }, () => client.insert(scopedJob, {})) + ); + running = false; + + // The writer ran between insertions and never met River's lock. + expect(busy).toEqual([]); + expect(writes).toBeGreaterThan(100); + expect(count("river_job")).toBe(250); + }); + + test("keeps its transaction while COMMIT waits across turns of the event loop", async () => { + using driver = testSqliteMemory(STRICT); + await migrate(driver.database); + const reader = driver.connect(); + onTestFinished(() => reader.close()); + // An in-memory database's COMMIT waits for readers holding a snapshot. + reader.exec("BEGIN"); + reader.prepare("SELECT count(*) FROM river_job").get(); + setTimeout(() => reader.exec("COMMIT"), 30); + + await driver.operationScope(undefined, async (tx) => { + await driver.jobInsert({ args: {}, kind: "slow_commit" }, { tx }); + }); + + expect(driver.database.prepare("SELECT kind FROM river_job").all()).toEqual( + [{ kind: "slow_commit" }] + ); + }); +}); + +async function setup( + options: + | ClientOptions + | ((database: DatabaseSync, driver: SqliteRuntime) => ClientOptions) = {} +) { + const directory = mkdtempSync(join(tmpdir(), "river-sqlite-scope-")); + const database = new DatabaseSync(join(directory, "river.db")); + await migrate(database); + const driver = testSqliteDriver(database, STRICT); + onTestFinished(() => { + driver.close(); + database.close(); + rmSync(directory, { force: true, recursive: true }); + }); + const client = new Client( + driver, + typeof options === "function" ? options(database, driver) : options + ); + const count = (table: string): number => + Number( + database.prepare(`SELECT count(*) AS count FROM ${table}`).get()?.count + ); + return { client, count, database, driver }; +} + +async function migrate(database: DatabaseSync): Promise { + const moduleUrl = new URL("../../../migrate/dist/index.js", import.meta.url); + const migrationModule = (await import(moduleUrl.href)) as { + createMigrator(target: { database: DatabaseSync }): { + migrateUp(): Promise; + }; + }; + await migrationModule.createMigrator({ database }).migrateUp(); +} diff --git a/js/driver/sqlite/src/scope.ts b/js/driver/sqlite/src/scope.ts new file mode 100644 index 000000000..5347895b8 --- /dev/null +++ b/js/driver/sqlite/src/scope.ts @@ -0,0 +1,290 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import { statSync } from "node:fs"; +import { DatabaseSync } from "node:sqlite"; +import { setTimeout as sleep } from "node:timers/promises"; + +import { TransactionScopeError, ValidationError } from "riverqueue"; +import type { DurationInput } from "riverqueue"; +import { durationToMilliseconds } from "riverqueue/unstable-driver"; + +import { retryBusy, type BusyRetryPolicy } from "./coordination.js"; +import { databaseError, isSqliteError, SQLITE_BACKEND } from "./errors.js"; + +const BUSY_TIMEOUT_MS_DEFAULT = 5_000; + +/** + * A transaction open in an async context: River's own transaction around an + * operation, or an application transaction begun with {@link transaction}. + * + * Callbacks keep the async context they were created in, so one created + * inside a transaction may run after it ended. `ended` makes such a callback + * ignore the frame. + */ +export type TransactionFrame = + | { + readonly database: DatabaseSync; + ended: boolean; + /** The {@link databaseKey} of `database`. */ + readonly key: string | null; + readonly kind: "application"; + readonly parent: TransactionFrame | undefined; + } + | { + /** The `SqliteDriver` whose private connection holds the transaction. */ + readonly driver: object; + ended: boolean; + /** + * Whether River's transaction holds its connection. It begins lazily, + * so code it runs before its first statement (insert middleware before + * `next()`, `beforeInsert` hooks) may still call River and SQLite + * freely. + */ + readonly holdsLock: () => boolean; + /** The {@link databaseKey} of the driver's database. */ + readonly key: string; + readonly kind: "river"; + readonly parent: TransactionFrame | undefined; + /** The driver's own state for the transaction. */ + readonly scope: object; + }; + +/** Identities of handles whose database can't be told from a file. */ +const registeredKeys = new WeakMap(); +/** Cached file identities of other handles. */ +const fileKeys = new WeakMap(); + +/** + * A key identifying the database `database` is open on: its file's device + * and inode, or the key a driver registered for an in-memory database it + * shares. Null when it can't be told, such as for a private `:memory:` + * database. + */ +export function databaseKey(database: DatabaseSync): string | null { + const registered = registeredKeys.get(database); + if (registered !== undefined) return registered; + if (!database.isOpen) return null; + let key = fileKeys.get(database); + if (key === undefined) { + const location = database.location(); + key = location === null ? null : fileKey(location); + fileKeys.set(database, key); + } + return key; +} + +/** The identity of a database file, or null when it can't be read. */ +export function fileKey(location: string): string | null { + try { + const { dev, ino } = statSync(location, { bigint: true }); + return `file:${dev}:${ino}`; + } catch { + return null; + } +} + +/** Record the database a handle whose file can't identify it is open on. */ +export function registerDatabase(database: DatabaseSync, key: string): void { + registeredKeys.set(database, key); +} + +/** Whether two database keys are known to name the same database. */ +export function sameDatabase(a: string | null, b: string | null): boolean { + return a !== null && a === b; +} + +const frames = new AsyncLocalStorage(); + +/** The transactions still open in the current async context, innermost first. */ +export function openFrames(): TransactionFrame[] { + const open: TransactionFrame[] = []; + for (let frame = frames.getStore(); frame !== undefined;) { + if (!frame.ended) open.push(frame); + frame = frame.parent; + } + return open; +} + +/** The async-context frame for River's own transaction on `driver`. */ +export function riverFrame( + driver: object, + key: string, + scope: object, + holdsLock: () => boolean +): TransactionFrame { + return { + driver, + ended: false, + holdsLock, + key, + kind: "river", + parent: frames.getStore(), + scope, + }; +} + +/** Run `callback` with `frame` as the innermost open transaction. */ +export function runInFrame(frame: TransactionFrame, callback: () => T): T { + return frames.run(frame, callback); +} + +/** Options for {@link transaction}. */ +export interface SqliteTransactionOptions { + /** + * Total time to keep retrying `BEGIN IMMEDIATE`, and `COMMIT`, while + * another connection holds SQLite's write lock, such as `{ seconds: 5 }`. + * Retries wait asynchronously, so the event loop keeps running. Defaults + * to 5 seconds. + */ + busyTimeout?: DurationInput; +} + +/** + * Run `callback` in an immediate transaction on an application handle, + * committing when it resolves and rolling back when it throws or rejects. + * + * Pass `database` to River as `{ tx }` inside the callback to insert jobs + * that commit or roll back with the application's rows. The callback may + * await freely: River runs on its own connection and waits for the + * transaction to end. + * + * `BEGIN IMMEDIATE` takes SQLite's write lock up front. While another + * connection or process holds it, the helper retries with an asynchronous + * backoff rather than blocking the event loop, and fails with a retryable + * `DatabaseOperationError` after `busyTimeout`. + * + * Transactions on one database don't nest. SQLite has a single writer, so + * calling `transaction` on a handle that already has a transaction open, + * inside another `transaction` callback on the same database, or inside + * River's own transaction on it once River holds the write lock (insert + * middleware after `next()`, `afterInsert` hooks) could only fail after + * `busyTimeout`. It fails at once with a `TransactionScopeError` instead. + * Transactions on different databases may nest. + */ +export async function transaction( + database: DatabaseSync, + callback: (database: DatabaseSync) => T | PromiseLike, + options: SqliteTransactionOptions = {} +): Promise { + if (!(database instanceof DatabaseSync) || !database.isOpen) { + throw new ValidationError( + "transaction() requires an open node:sqlite DatabaseSync", + { details: { backend: SQLITE_BACKEND, operation: "transaction" } } + ); + } + assertCanBegin(database); + const policy = busyPolicy(options); + + await begin(database, policy); + const frame: TransactionFrame = { + database, + ended: false, + key: databaseKey(database), + kind: "application", + parent: frames.getStore(), + }; + try { + let result: T; + try { + result = await frames.run(frame, () => callback(database)); + } catch (error: unknown) { + rollbackQuietly(database); + throw error; + } + try { + // SQLite keeps the transaction open when COMMIT is busy, so COMMIT + // alone can be retried. + await retryBusy(policy, () => { + database.exec("COMMIT"); + }); + } catch (error: unknown) { + rollbackQuietly(database); + throw wrapError("transaction_commit", error); + } + return result; + } finally { + frame.ended = true; + } +} + +function assertCanBegin(database: DatabaseSync): void { + const details = { backend: SQLITE_BACKEND, operation: "transaction" }; + const key = databaseKey(database); + for (const frame of openFrames()) { + if (frame.kind === "river") { + if (!frame.holdsLock() || !sameDatabase(frame.key, key)) continue; + throw new TransactionScopeError( + "reentrant", + "transaction() was called from inside River's own transaction on " + + "the same database, which insert middleware and hooks run in. " + + "River holds SQLite's write lock until the middleware or hook " + + "returns, so the transaction could only fail after its " + + "busyTimeout. Begin it before inserting and pass { tx }, or run " + + "it after the insertion returns", + { details } + ); + } + if (frame.database !== database && !sameDatabase(frame.key, key)) { + continue; + } + throw new TransactionScopeError( + "nested", + "transaction() was called inside another transaction() on the same " + + "database. SQLite has one writer, so the inner transaction could " + + "only fail after its busyTimeout. Use the outer transaction's " + + "handle instead", + { details } + ); + } + if (database.isTransaction) { + throw new TransactionScopeError( + "nested", + "the handle passed to transaction() already has an open transaction; " + + "pass it to River as { tx } directly instead", + { details } + ); + } +} + +async function begin( + database: DatabaseSync, + policy: BusyRetryPolicy +): Promise { + try { + await retryBusy(policy, () => { + database.exec("BEGIN IMMEDIATE"); + }); + } catch (error: unknown) { + throw wrapError("transaction_begin", error); + } +} + +function busyPolicy(options: SqliteTransactionOptions): BusyRetryPolicy { + return { + now: () => performance.now(), + sleep: (milliseconds) => sleep(milliseconds), + timeoutMs: + options.busyTimeout === undefined + ? BUSY_TIMEOUT_MS_DEFAULT + : durationToMilliseconds("busyTimeout", options.busyTimeout, { + allowZero: true, + }), + }; +} + +function rollbackQuietly(database: DatabaseSync): void { + if (!database.isOpen || !database.isTransaction) return; + try { + database.exec("ROLLBACK"); + } catch { + // Preserve the callback's failure as the primary cause. + } +} + +function wrapError(operation: string, cause: unknown): unknown { + if (!isSqliteError(cause)) return cause; + return databaseError( + operation, + `SQLite ${operation} failed: ${cause.message}`, + { cause } + ); +} diff --git a/js/driver/sqlite/src/strict.ts b/js/driver/sqlite/src/strict.ts new file mode 100644 index 000000000..6efe7ecb1 --- /dev/null +++ b/js/driver/sqlite/src/strict.ts @@ -0,0 +1,69 @@ +import { + createHook, + executionAsyncResource, + type AsyncHook, +} from "node:async_hooks"; + +/** + * An internal, test-only strict check that River's own SQLite transaction + * stays within one turn of the event loop. + * + * The `setImmediate` probe misses fast I/O that completes before the check + * phase. This counts every macrotask callback instead: an `async_hooks` + * hook tags each non-promise, non-microtask, non-tick resource when it is + * created and counts `before` callbacks of tagged resources, so any + * callback of a timer, immediate, or I/O request between two reads of the + * counter means the event loop turned. Promise continuations are never + * counted, so a scope that awaits only promises sees no change. + * + * Hooking every async resource slows promise-heavy code several times over, + * and resources created before the hook is enabled are never counted, so it + * is enabled only for River's own tests and conformance runs, for as long as + * any strict driver is open. It is not part of the public API. + */ + +/** Async resource types that never run macrotask callbacks. */ +const UNCOUNTED_TYPES: ReadonlySet = new Set([ + "Microtask", + "PROMISE", + "TickObject", +]); + +let hook: AsyncHook | null = null; +let turns = 0; +let users = 0; +const counted = new WeakSet(); + +/** Start counting, and return a function that stops once, when unused. */ +export function retainTurnCounter(): () => void { + if (users === 0) { + hook ??= createHook({ + before() { + const resource: unknown = executionAsyncResource(); + if (typeof resource === "object" && resource !== null) { + if (counted.has(resource)) turns++; + } + }, + init(_asyncId, type, _triggerAsyncId, resource: unknown) { + if (UNCOUNTED_TYPES.has(type)) return; + if (typeof resource === "object" && resource !== null) { + counted.add(resource); + } + }, + }); + hook.enable(); + } + users++; + let released = false; + return () => { + if (released) return; + released = true; + users--; + if (users === 0) hook?.disable(); + }; +} + +/** Macrotask callbacks run since the counter started. */ +export function eventLoopTurns(): number { + return turns; +} diff --git a/js/driver/sqlite/src/types.ts b/js/driver/sqlite/src/types.ts new file mode 100644 index 000000000..e359a41e1 --- /dev/null +++ b/js/driver/sqlite/src/types.ts @@ -0,0 +1,166 @@ +import type { DatabaseSync } from "node:sqlite"; +import { JOB_STATE } from "riverqueue"; +import type { + AttemptError, + DurationInput, + JobRow, + JobState, + JsonObject, + JsonValue, +} from "riverqueue"; + +/** Persisted River job states in their protocol bit order. */ +export const SQLITE_JOB_STATE = JOB_STATE; + +export type SqliteJobState = JobState; + +/** Values accepted by River's JSON persistence boundary. */ +export type SqliteJsonValue = JsonValue; + +/** A JSON object accepted by River's SQLite backend. */ +export type SqliteJsonObject = JsonObject; + +/** One persisted work-attempt failure. */ +export type SqliteAttemptError = AttemptError; + +/** Exact properties of a River job read from SQLite. */ +export type SqliteJobRow = + JobRow; + +/** Exact properties of a River queue read from SQLite. */ +export interface SqliteQueueRow { + createdAt: Temporal.Instant; + metadata: SqliteJsonObject; + name: string; + pausedAt: Temporal.Instant | null; + updatedAt: Temporal.Instant; +} + +/** One durable notification-outbox record. */ +export interface SqliteNotification { + createdAt: Temporal.Instant; + id: bigint; + payload: string; + topic: string; +} + +/** The elected portable River leader and its fenced term. */ +export interface SqliteLeader { + electedAt: Temporal.Instant; + expiresAt: Temporal.Instant; + leaderId: string; +} + +/** One stuck-job rescue transition. */ +export interface SqliteRescueJobParams { + error: SqliteAttemptError; + finalizedAt?: Temporal.Instant | null; + id: bigint; + scheduledAt: Temporal.Instant; + state: "cancelled" | "discarded" | "retryable"; +} + +/** Retention horizons for a bounded cleaner pass. */ +export interface SqliteCleanupJobsParams { + cancelledBefore: Temporal.Instant | null; + completedBefore: Temporal.Instant | null; + discardedBefore: Temporal.Instant | null; + limit?: number; + metadataExclusions?: readonly string[]; + /** Queues whose jobs the pass keeps, even when they're also included. */ + queuesExcluded?: readonly string[]; + /** + * Queues the pass is limited to. Absent or `null` matches every queue, + * while an empty list matches none. + */ + queuesIncluded?: readonly string[] | null; +} + +/** Result of moving a due scheduled/retryable job toward availability. */ +export interface SqliteScheduleResult { + conflictDiscarded: boolean; + job: SqliteJobRow; +} + +/** Already-resolved semantic inputs for one River job insertion. */ +export interface SqliteInsertJobParams< + TArgs extends SqliteJsonObject = SqliteJsonObject, +> { + args: TArgs; + attempt?: number; + attemptedAt?: Temporal.Instant | null; + attemptedBy?: readonly string[]; + createdAt?: Temporal.Instant; + /** Stored instead of `args` when set, like Go's `EncodedArgs`. */ + encodedArgs?: string; + errors?: readonly SqliteAttemptError[]; + finalizedAt?: Temporal.Instant | null; + id?: bigint; + kind: string; + maxAttempts?: number; + metadata?: SqliteJsonObject; + priority?: number; + queue?: string; + scheduledAt?: Temporal.Instant; + state?: SqliteJobState; + tags?: readonly string[]; + uniqueKey?: Uint8Array | null; + uniqueStates?: readonly SqliteJobState[] | null; +} + +/** Result of one insert, including a unique-key conflict. */ +export interface SqliteInsertResult< + TArgs extends SqliteJsonObject = SqliteJsonObject, +> { + job: SqliteJobRow; + status: "duplicate" | "inserted"; +} + +export type SqliteCancelResult = + | { job: SqliteJobRow; status: "cancelled" | "unchanged" } + | { status: "not_found" }; + +export type SqliteDeleteResult = + | { job: SqliteJobRow; status: "deleted" } + | { job: SqliteJobRow; status: "running" } + | { status: "not_found" }; + +export type SqliteRetryResult = + | { job: SqliteJobRow; status: "retried" | "unchanged" } + | { status: "not_found" }; + +declare const SQLITE_RIVER_SCOPE: unique symbol; + +/** + * River's own transaction on its private SQLite connection, as River passes + * it between its own operations. It is opaque, never reaches application + * code, and isn't exported from the package. + */ +export interface SqliteRiverScope { + readonly [SQLITE_RIVER_SCOPE]: true; +} + +export interface SqliteOperationOptions { + /** + * Run in this transaction: an application handle on the driver's + * database with a transaction open, or River's own transaction. + */ + tx?: DatabaseSync | SqliteRiverScope; +} + +/** SQLite setup owned by the backend, not by the common River client. */ +export interface SqliteDriverOptions { + /** + * Total time a River operation keeps retrying while another connection or + * process holds SQLite's lock, such as `{ seconds: 5 }`. Defaults to 5 + * seconds, the busy timeout River's Go conformance adapter and Rust + * implementation configure. + * + * River's private connection has a zero `busy_timeout`, so SQLite never + * blocks the event loop waiting for a lock. Between attempts River waits + * asynchronously with exponential backoff, so timers and I/O keep + * running. When the bound is exceeded the operation fails with a + * `DatabaseOperationError` whose `retryable` is `true`. + */ + busyTimeout?: DurationInput; +} diff --git a/js/driver/sqlite/tsconfig.json b/js/driver/sqlite/tsconfig.json new file mode 100644 index 000000000..fc6b18f33 --- /dev/null +++ b/js/driver/sqlite/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "stripInternal": true + }, + "exclude": ["src/**/*.integration.test.ts", "src/**/*.test.ts"], + "include": ["src"] +} diff --git a/js/driver/sqlite/tsconfig.test.json b/js/driver/sqlite/tsconfig.test.json new file mode 100644 index 000000000..7039342f7 --- /dev/null +++ b/js/driver/sqlite/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "exclude": [], + "include": ["src"] +} diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml index f12148d81..4eb6984f0 100644 --- a/js/pnpm-lock.yaml +++ b/js/pnpm-lock.yaml @@ -99,6 +99,18 @@ importers: specifier: ^6.0.3 version: 6.0.3 + driver/sqlite: + devDependencies: + '@riverqueue/migrate': + specifier: workspace:0.50.0-alpha.1 + version: link:../../migrate + '@types/node': + specifier: ^26.1.1 + version: 26.1.1 + riverqueue: + specifier: workspace:0.50.0-alpha.1 + version: link:../.. + examples/node-postgres: dependencies: '@riverqueue/driver-pg': diff --git a/js/tsconfig.tests.json b/js/tsconfig.tests.json index 331b38eef..2b2cc9bda 100644 --- a/js/tsconfig.tests.json +++ b/js/tsconfig.tests.json @@ -9,6 +9,9 @@ "@riverqueue/driver-prisma": [ "./driver/prisma/src/index.ts" ], + "@riverqueue/driver-sqlite": [ + "./driver/sqlite/src/index.ts" + ], "@riverqueue/migrate": [ "./migrate/src/index.ts" ] From abf93180276cb242e3fd24145e33785626d247fc Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sun, 4 Oct 2026 17:23:48 -0500 Subject: [PATCH 31/43] test SQLite against Go's riversqlite and the pilot seam Check that the SQLite driver reads and writes rows exactly as River Go's `riversqlite` does: rows only Go's wider decoding accepts, null cancellation markers, update, cancellation, retry, and output JSON, near-future retries and snoozes stored as available, and rescue guards against stale snapshots. Run the whole runtime on SQLite, including while another connection holds the write lock, and drive the pilot seam through River's private connection and application transactions: interceptors that throw after `next()`, insert interceptors that see each row's arguments from before argument transforms, caller transactions without savepoints, nested operations, finalized job deletion filters, metadata notifications, and peer claims through a graceful stop. --- js/driver/sqlite/src/compatibility.test.ts | 1434 +++++++++++ js/driver/sqlite/src/pilot.test.ts | 2536 ++++++++++++++++++++ js/driver/sqlite/src/runtime.test.ts | 678 ++++++ 3 files changed, 4648 insertions(+) create mode 100644 js/driver/sqlite/src/compatibility.test.ts create mode 100644 js/driver/sqlite/src/pilot.test.ts create mode 100644 js/driver/sqlite/src/runtime.test.ts diff --git a/js/driver/sqlite/src/compatibility.test.ts b/js/driver/sqlite/src/compatibility.test.ts new file mode 100644 index 000000000..e73c52129 --- /dev/null +++ b/js/driver/sqlite/src/compatibility.test.ts @@ -0,0 +1,1434 @@ +import type { DatabaseSync } from "node:sqlite"; + +import { + Client, + DatabaseOperationError, + ValidationError, + defineJob, + exactJsonNumber, + parseJson, +} from "riverqueue"; +import type { JsonObject, JsonValue } from "riverqueue"; +import type { + JobCompletionCommand, + JobListParams, + RuntimeJobRescue, +} from "riverqueue/unstable-driver"; +import { describe, expect, onTestFinished, test } from "vitest"; + +import { + SQLITE_DRIVER_TEST_HOOKS, + type SqliteRuntime, + testSqliteMemory, +} from "./driver.js"; +import type { + SqliteDriverOptions, + SqliteJobRow, + SqliteJsonObject, + SqliteRescueJobParams, +} from "./types.js"; + +/** River's own tests fail any lock window that crosses the event loop. */ +const STRICT = { + [SQLITE_DRIVER_TEST_HOOKS]: { strictLockWindow: true }, +} as SqliteDriverOptions; + +// Mirrors riverdrivertest's precision test time, rounded to SQLite's +// millisecond precision. +const PRECISION_TEST_TIME = Temporal.Instant.from("2025-04-30T13:26:39.123Z"); + +describe("SqliteDriver compatibility with Go's riversqlite", () => { + test("decodes and works rows that only Go's wider decoding accepts", async () => { + const { database, driver } = await setup(); + database.exec(` + INSERT INTO river_job ( + id, args, attempt, attempted_by, created_at, errors, kind, + max_attempts, metadata, scheduled_at, tags, unique_key, unique_states + ) VALUES ( + 1, jsonb('{}'), 39999, jsonb('null'), '2026-01-01 00:00:00', + jsonb('[null, {"attempt": 2}, {"at": null, "error": "e", "trace": null}]'), + 'go_legal', 40000, jsonb('{}'), '2026-01-01 00:00:00', + jsonb('null'), 'text-key', 0 + ), ( + 2, jsonb('{}'), -5, jsonb('["a", null]'), CURRENT_TIMESTAMP, + jsonb('null'), 'go_legal', 9223372036854775807, jsonb('{}'), + '2026-01-01 00:00:01', jsonb('["tag", null]'), NULL, NULL + )`); + + const zero = Temporal.Instant.from("0001-01-01T00:00:00Z"); + const first = await driver.jobGet(1n); + expect(first).toMatchObject({ + attempt: 39_999, + attemptedBy: [], + errors: [ + { at: zero, attempt: 0, error: "", trace: "" }, + { at: zero, attempt: 2, error: "", trace: "" }, + { at: zero, attempt: 0, error: "e", trace: "" }, + ], + maxAttempts: 40_000, + tags: [], + uniqueKey: new TextEncoder().encode("text-key"), + uniqueStates: [], + }); + expect(await driver.jobGet(2n)).toMatchObject({ + attempt: 0, + attemptedBy: ["a", ""], + errors: [], + maxAttempts: Number.MAX_SAFE_INTEGER, + tags: ["tag", ""], + }); + + const claimed = ( + await driver.jobClaim({ + attemptedBy: "js-worker", + kinds: ["go_legal"], + queues: [{ limit: 10, name: "default" }], + }) + ).jobs; + expect(claimed.map(({ attempt, id }) => [id, attempt])).toEqual([ + [1n, 40_000], + // The claim stores -4; decoding clamps it to 0 like Go. + [2n, 0], + ]); + expect(claimed[0]?.attemptedBy).toEqual(["js-worker"]); + + const [completed] = await driver.jobCompleteMany([ + completion({ + attempt: 40_000, + attemptedBy: "js-worker", + error: { + at: PRECISION_TEST_TIME, + error: "failed", + trace: "", + }, + id: 1n, + kind: "retry", + scheduledAt: PRECISION_TEST_TIME, + }), + ]); + expect(completed).toMatchObject({ status: "applied" }); + expect(completed?.job?.errors.at(-1)).toMatchObject({ + attempt: 40_000, + error: "failed", + }); + }); + + test("tolerates JSON columns holding text that isn't valid JSON like Go", async () => { + const { database, driver } = await setup(); + const scheduledAt = Temporal.Now.instant().subtract({ seconds: 10 }); + const columns = ["args", "attempted_by", "errors", "metadata", "tags"]; + const ids = new Map(); + for (const [index, column] of columns.entries()) { + const { job } = await driver.jobInsert({ + args: {}, + kind: "malformed", + scheduledAt: scheduledAt.add({ milliseconds: index }), + }); + database + .prepare(`UPDATE river_job SET ${column} = '[not json' WHERE id = ?`) + .run(job.id); + ids.set(column, job.id); + } + const healthy = ( + await driver.jobInsert({ + args: {}, + kind: "malformed", + scheduledAt: scheduledAt.add({ seconds: 1 }), + }) + ).job; + + // The claim doesn't fail on "malformed JSON": every job is claimed, in + // claim order, and each malformed one is reported undecodable. + const claimed = await driver.jobClaim({ + attemptedBy: "js-worker", + kinds: [], + queues: [{ limit: 10, name: "default" }], + }); + expect(claimed.jobs.map(({ id }) => id)).toEqual([ + ...columns.map((column) => ids.get(column)), + healthy.id, + ]); + const undecodable = claimed.jobs.filter(({ id }) => + claimed.decodeErrors?.has(id) + ); + expect( + new Map( + [...(claimed.decodeErrors ?? [])].map(([id, error]) => [ + id, + error.message, + ]) + ) + ).toEqual( + new Map( + columns.map((column) => [ + ids.get(column), + expect.stringContaining(column) as unknown as string, + ]) + ) + ); + + // Failing their attempts leaves the invalid values in place, except + // that invalid errors text becomes a string in a new array. + const failed = await driver.jobCompleteMany( + undecodable.map((job) => + completion({ + attempt: job.attempt, + attemptedBy: "js-worker", + error: { + at: scheduledAt, + error: "job row couldn't be decoded", + trace: "", + }, + id: job.id, + kind: "retry", + scheduledAt: scheduledAt.subtract({ seconds: 1 }), + }) + ) + ); + expect(failed.map(({ status }) => status)).toEqual( + columns.map(() => "applied") + ); + for (const column of columns) { + const text = rawRow( + database, + `SELECT CASE WHEN typeof(${column}) = 'text' THEN ${column} + ELSE json(${column}) END AS value + FROM river_job WHERE id = ${ids.get(column)}` + )?.value; + if (column === "errors") { + expect(JSON.parse(text as string)).toEqual([ + "[not json", + expect.objectContaining({ error: "job row couldn't be decoded" }), + ]); + } else { + expect(text).toBe("[not json"); + } + } + const errorsJob = await driver.jobGet(ids.get("errors")!); + expect(errorsJob?.errors.map(({ error }) => error)).toEqual([ + "[not json", + "job row couldn't be decoded", + ]); + + // Scheduling the retries and rescuing a stuck one don't fail either. + const leader = (await driver.maintenanceLeaderAcquire( + "leader", + Temporal.Now.instant(), + 60_000, + null + ))!; + await expect( + driver.maintenanceSchedule(leader, { + allowInsertNotifications: allowEveryQueue, + limit: 100, + now: Temporal.Now.instant(), + notificationHorizon: Temporal.Now.instant(), + scheduledAtHorizon: Temporal.Now.instant(), + }) + ).resolves.toBe(columns.length); + database + .prepare( + "UPDATE river_job SET state = 'running', attempted_at = '2000-01-01 00:00:00.000' WHERE id = ?" + ) + .run(ids.get("metadata")!); + const horizon = Temporal.Instant.from("2001-01-01T00:00:00Z"); + const stuck = await driver.maintenanceGetStuck(leader, horizon, 0n, 10); + expect(stuck.map(({ id }) => id)).toEqual([ids.get("metadata")]); + await driver.maintenanceRescue(leader, horizon, [ + { + error: { at: horizon, attempt: 1, error: "stuck", trace: "" }, + finalizedAt: null, + id: ids.get("metadata")!, + scheduledAt: horizon, + state: "retryable", + }, + ]); + expect( + rawRow( + database, + `SELECT state, metadata FROM river_job WHERE id = ${ids.get("metadata")}` + ) + ).toEqual({ metadata: "[not json", state: "retryable" }); + }); + + test("returns undecodable claimed rows with their errors and completes them like Go", async () => { + const { database, driver } = await setup(); + const scheduledAt = Temporal.Now.instant().subtract({ seconds: 10 }); + const corrupt = ( + await driver.jobInsert({ args: {}, kind: "poison", scheduledAt }) + ).job; + const wrappedErrors = ( + await driver.jobInsert({ + args: {}, + kind: "poison", + scheduledAt: scheduledAt.add({ seconds: 1 }), + }) + ).job; + const healthy = ( + await driver.jobInsert({ + args: {}, + kind: "poison", + scheduledAt: scheduledAt.add({ seconds: 2 }), + }) + ).job; + database + .prepare("UPDATE river_job SET tags = jsonb('[1]') WHERE id = ?") + .run(corrupt.id); + database + .prepare( + `UPDATE river_job SET errors = jsonb('{"legacy":true}') WHERE id = ?` + ) + .run(wrappedErrors.id); + + const claimed = await driver.jobClaim({ + attemptedBy: "js-worker", + kinds: [], + queues: [{ limit: 10, name: "default" }], + }); + + // Every row is running and comes back in claim order; the undecodable + // ones have the bad field empty and their decode errors alongside. + expect(claimed.jobs.map(({ id }) => id)).toEqual([ + corrupt.id, + wrappedErrors.id, + healthy.id, + ]); + expect([...(claimed.decodeErrors?.keys() ?? [])]).toEqual([ + corrupt.id, + wrappedErrors.id, + ]); + const undecodable = claimed.jobs.slice(0, 2); + expect(undecodable[0]).toMatchObject({ + attempt: 1, + state: "running", + tags: [], + }); + expect(claimed.decodeErrors?.get(corrupt.id)?.message).toContain("tags[0]"); + expect(undecodable[1]?.errors).toEqual([]); + + // The runtime fails their attempts. The error is appended without + // decoding the bad column, which keeps its value; a non-array errors + // value is wrapped in an array first. Completion still returns the rows. + const failures = await driver.jobCompleteMany( + undecodable.map((job) => + completion({ + attempt: job.attempt, + attemptedBy: "js-worker", + error: { + at: scheduledAt, + error: "job row couldn't be decoded", + trace: "", + }, + id: job.id, + kind: "retry", + scheduledAt: scheduledAt.add({ hours: 1 }), + }) + ) + ); + expect(failures.map(({ status }) => status)).toEqual([ + "applied", + "applied", + ]); + expect(failures[0]?.job).toMatchObject({ state: "retryable", tags: [] }); + expect( + rawRow( + database, + `SELECT json(tags) AS tags, json_array_length(errors) AS errors + FROM river_job WHERE id = ${corrupt.id}` + ) + ).toEqual({ errors: 1n, tags: "[1]" }); + expect( + rawRow( + database, + `SELECT json(errors) AS errors FROM river_job WHERE id = ${wrappedErrors.id}` + )?.errors + ).toMatch( + /^\[\{"legacy":true\},\{"at":.*"error":"job row couldn't be decoded"/ + ); + + // The rescuer can read stuck rows that don't fully decode. + database + .prepare( + "UPDATE river_job SET state = 'running', attempted_at = '2000-01-01 00:00:00.000' WHERE id = ?" + ) + .run(corrupt.id); + const leader = (await driver.maintenanceLeaderAcquire( + "leader", + Temporal.Now.instant(), + 60_000, + null + ))!; + const stuck = await driver.maintenanceGetStuck( + leader, + Temporal.Now.instant(), + 0n, + 10 + ); + expect(stuck.map(({ id }) => id)).toContain(corrupt.id); + }); + + test("finds cancellation requests beside metadata that isn't valid JSON like Go", async () => { + const { database, driver } = await setup(); + const requested = await runningJob(driver, { + metadata: { cancel_attempted_at: PRECISION_TEST_TIME.toString() }, + }); + const corrupt = await runningJob(driver); + const plain = await runningJob(driver); + database + .prepare("UPDATE river_job SET metadata = '[not json' WHERE id = ?") + .run(corrupt.id); + + await expect( + driver.jobGetCancelRequested([plain.id, corrupt.id, requested.id]) + ).resolves.toEqual([requested.id]); + }); + + test("treats a present null cancel_attempted_at as a cancellation like Go", async () => { + const { driver } = await setup(); + const job = await runningJob(driver, { + metadata: { cancel_attempted_at: null }, + }); + + const [result] = await driver.jobCompleteMany([ + completion({ + attempt: job.attempt, + attemptedBy: "js-worker", + id: job.id, + kind: "retry", + scheduledAt: PRECISION_TEST_TIME, + }), + ]); + + expect(result?.job?.state).toBe("cancelled"); + }); + + test("fences leader terms by instant, not by timestamp text", async () => { + const { database, driver } = await setup(); + database.exec( + `INSERT INTO river_leader (elected_at, expires_at, leader_id) + VALUES ('2026-08-30 18:40:00', '2999-01-01 00:00:00.000', 'go-leader')` + ); + const leader = await driver.leaderGet(); + expect(leader?.electedAt.toString()).toBe("2026-08-30T18:40:00Z"); + + const renewed = await driver.leaderReelect(leader!, { + now: Temporal.Instant.from("2026-08-30T18:40:01Z"), + ttlMs: 1_000, + }); + expect(renewed?.leaderId).toBe("go-leader"); + expect(await driver.leaderResign(renewed!)).toBe(true); + expect(await driver.leaderGet()).toBeNull(); + }); + + test("stores an empty unique state set as NULL like Go", async () => { + const { database, driver } = await setup(); + const inserted = await driver.jobInsert({ + args: {}, + kind: "empty_unique_states", + uniqueKey: new Uint8Array(32).fill(1), + uniqueStates: [], + }); + + expect( + rawRow( + database, + `SELECT unique_states FROM river_job WHERE id = ${inserted.job.id}` + ) + ).toEqual({ unique_states: null }); + expect(inserted.job.uniqueStates).toBeNull(); + }); + + test("stores inserted JSON like Go's riversqlite", async () => { + // The values River for Go's `riversqlite` driver stores for the same + // inserts and error: Go's `Client.InsertManyFast` for the client insert, + // the returning `JobInsertFastMany` for the reinsertion, and the + // completer's `JobSetStateIfRunningMany` for the error. + const { database, driver } = await setup(); + const stored = (kind: string) => ({ + ...rawRow( + database, + `SELECT attempted_by IS NULL AS attempted_by_null, + errors IS NULL AS errors_null + FROM river_job WHERE kind = '${kind}'` + ), + ...storedJson(database, kind, [ + "args", + "attempted_by", + "errors", + "metadata", + "tags", + ]), + }); + + // River's own insert encodes arguments and metadata. + const escapeJob = defineJob<{ + html: string; + lines: string; + nested: Record; + }>()({ kind: "golden_escape" }); + const client = new Client(driver); + const args = { + html: '&', + lines: "one\u2028two\u2029three", + nested: { "k<": ">v&" }, + }; + const metadata = { note: " & y", sep: "a\u2028b", already: "<" }; + await client.insertMany([ + { + args, + job: escapeJob, + options: { metadata, tags: ["tag_a", "tag-b"] }, + }, + ]); + expect(stored("golden_escape")).toEqual({ + args, + attempted_by: null, + attempted_by_null: 1n, + errors: null, + errors_null: 1n, + metadata, + tags: ["tag_a", "tag-b"], + }); + // Like Go's returning insert, River's insertion adds a nonce. + expect(uniqueNonce(database, "golden_escape")).toMatch(/^[0-9a-f]{16}$/); + + // A reinsertion stores the caller's encoded arguments, not `args`, and + // keeps its creation time. + const createdAt = Temporal.Instant.from("2026-01-02T03:04:05.123Z"); + const [reinserted] = await driver.jobInsertMany([ + { + args: { a: [1, 2.5, 1], u: "\u2028", z: "&c" }, + createdAt, + encodedArgs: + '{"z": "&c", "a": [1, 2.50, 123456789012345678901234567890], "u": "\u2028"}', + kind: "golden_reinsert", + maxAttempts: 25, + metadata: { m: ">" }, + priority: 1, + queue: "default", + scheduledAt: Temporal.Instant.from("2026-01-02T04:00:00Z"), + state: "available", + tags: [], + uniqueKey: null, + uniqueStates: null, + }, + ]); + expect(stored("golden_reinsert")).toEqual({ + args: { + a: [1, 2.5, exactJsonNumber("123456789012345678901234567890")], + u: "\u2028", + z: "&c", + }, + attempted_by: null, + attempted_by_null: 1n, + errors: null, + errors_null: 1n, + metadata: { m: ">" }, + tags: [], + }); + // Like Go's returning insert, a row without a unique key still stores + // the nonce, as eight random bytes in lowercase hex. + expect(uniqueNonce(database, "golden_reinsert")).toMatch(/^[0-9a-f]{16}$/); + expect(reinserted?.job.metadata["river:unique_nonce"]).toBe( + uniqueNonce(database, "golden_reinsert") + ); + expect(reinserted?.job.createdAt).toEqual(createdAt); + expect(Object.keys(reinserted!.job.args)).toEqual(["z", "a", "u"]); + expect(reinserted?.job.args.a).toEqual([ + 1, + 2.5, + exactJsonNumber("123456789012345678901234567890"), + ]); + + // A failed attempt's error and client ID. + const failing = await driver.jobInsert({ + args: {}, + attempt: 1, + attemptedAt: createdAt, + attemptedBy: ["client<1>"], + createdAt, + kind: "golden_error", + scheduledAt: createdAt, + state: "running", + }); + await driver.jobCompleteMany([ + completion({ + attempt: 1, + attemptedBy: "client<1>", + error: { at: createdAt, error: " & \u2028", trace: "trace>" }, + id: failing.job.id, + kind: "retry", + scheduledAt: Temporal.Instant.from("2026-01-02T04:00:00Z"), + }), + ]); + expect(stored("golden_error")).toMatchObject({ + attempted_by: ["client<1>"], + errors: [ + { + at: "2026-01-02T03:04:05.123Z", + attempt: 1, + error: " & \u2028", + trace: "trace>", + }, + ], + }); + }); + + test("stores update, cancellation, retry, and output JSON like Go's riversqlite", async () => { + // The values River for Go's `riversqlite` driver stores for + // `Client.JobUpdate` with an output, `JobCancel`, `JobRetry` after a + // cancellation, and the completer's `JobSetStateIfRunningMany` + // completing with an output and discarding with an error. Every job + // starts with metadata `{"m":"x"}`. + const output = { text: "a&c\u2028d\u2029\u00e9", values: [2.5, "<&>"] }; + const now = Temporal.Instant.from("2026-01-02T05:06:07.123456789Z"); + const cancelled = { cancel_attempted_at: now.toString(), m: "x" }; + const { database, driver } = await setup(); + const insert = async (kind: string, state: "available" | "running") => + ( + await driver.jobInsert({ + args: {}, + attempt: 1, + attemptedAt: now, + attemptedBy: ["client<1>"], + kind, + metadata: { m: "x" }, + state, + }) + ).job.id; + const stored = (kind: string) => ({ + ...rawRow( + database, + `SELECT state, finalized_at, scheduled_at, errors IS NULL AS errors_null + FROM river_job WHERE kind = '${kind}'` + ), + ...storedJson(database, kind, ["errors", "metadata"]), + }); + + await driver.jobUpdate(await insert("golden_update", "available"), { + output, + }); + expect(stored("golden_update")).toMatchObject({ + metadata: { m: "x", output }, + }); + + await driver.jobCancelDetailed(await insert("golden_cancel", "available"), { + now, + }); + expect(stored("golden_cancel")).toEqual({ + errors: null, + errors_null: 1n, + finalized_at: "2026-01-02 05:06:07.123", + metadata: cancelled, + scheduled_at: expect.stringMatching( + /^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\.\d{3}$/ + ), + state: "cancelled", + }); + + const retried = await insert("golden_retry", "available"); + await driver.jobCancelDetailed(retried, { now }); + await driver.jobRetryDetailed(retried, { now: now.add({ hours: 1 }) }); + expect(stored("golden_retry")).toEqual({ + errors: null, + errors_null: 1n, + finalized_at: null, + metadata: cancelled, + scheduled_at: "2026-01-02 06:06:07.123", + state: "available", + }); + + const worked = await insert("golden_output", "running"); + await driver.jobCompleteMany([ + completion({ + attempt: 1, + attemptedBy: "client<1>", + finalizedAt: now, + id: worked, + kind: "complete", + output, + outputSet: true, + }), + ]); + expect(stored("golden_output")).toMatchObject({ + finalized_at: "2026-01-02 05:06:07.123", + metadata: { m: "x", output }, + state: "completed", + }); + + const discarded = await insert("golden_discard", "running"); + await driver.jobCompleteMany([ + completion({ + attempt: 1, + attemptedBy: "client<1>", + error: { at: now, error: " & \u2028", trace: "trace>" }, + finalizedAt: now, + id: discarded, + kind: "discard", + }), + ]); + expect(stored("golden_discard")).toMatchObject({ + errors: [ + { + at: now.toString(), + attempt: 1, + error: " & \u2028", + trace: "trace>", + }, + ], + finalized_at: "2026-01-02 05:06:07.123", + metadata: { m: "x" }, + state: "discarded", + }); + }); + + test("stores claim, snooze, rescue, scheduler, and queue JSON like Go's riversqlite", async () => { + // The values River for Go's `riversqlite` driver stores for + // `JobGetAvailable` for a claim, the completer's + // `JobSetStateIfRunningMany` for a snooze, a running job's cancellation + // with an error, and a completion whose job is no longer running + // (`JobSetMetadataIfNotRunning`), `JobRescueMany`, `JobSchedule` with a + // unique key conflict, and `QueueCreateOrSetUpdatedAt`/`QueueUpdate`. + // Jobs start with metadata `{"m":"x"}`. + const createdAt = Temporal.Instant.from("2026-01-02T03:04:05.123Z"); + const now = Temporal.Instant.from("2026-01-02T05:06:07.123456789Z"); + const { database, driver } = await setup(); + const insert = async ( + kind: string, + state: "available" | "running", + options: { metadata?: SqliteJsonObject; queue?: string } = {} + ) => + ( + await driver.jobInsert({ + args: {}, + attempt: 1, + attemptedAt: createdAt, + attemptedBy: ["client<1>"], + createdAt, + kind, + metadata: options.metadata ?? { m: "x" }, + queue: options.queue ?? "default", + scheduledAt: createdAt, + state, + }) + ).job.id; + const stored = (kind: string) => ({ + ...rawRow(database, `SELECT state FROM river_job WHERE kind = '${kind}'`), + ...storedJson(database, kind, ["attempted_by", "errors", "metadata"]), + }); + const queueMetadata = (): unknown => { + const row = rawRow( + database, + `SELECT typeof(metadata) AS type, json(metadata) AS metadata + FROM river_queue WHERE name = 'golden_queue'` + ); + expect(row?.type).toBe("blob"); + return parseJson(row?.metadata as string); + }; + + // A claim appends the client ID to `attempted_by`. + await insert("golden_claim", "available", { queue: "golden_claim" }); + await driver.jobClaim({ + attemptedBy: "worker<2>\u2028\u00e9", + kinds: [], + queues: [{ limit: 1, name: "golden_claim" }], + }); + expect(stored("golden_claim")).toMatchObject({ + attempted_by: ["client<1>", "worker<2>\u2028\u00e9"], + state: "running", + }); + + // A snooze records its count. + const snoozed = await insert("golden_snooze", "running"); + await driver.jobCompleteMany([ + completion({ + attempt: 1, + attemptedBy: "client<1>", + id: snoozed, + kind: "snooze", + metadata: { snoozes: 1 }, + scheduledAt: now.add({ hours: 1 }), + }), + ]); + expect(stored("golden_snooze")).toMatchObject({ + metadata: { m: "x", snoozes: 1 }, + state: "scheduled", + }); + + // A running job cancelled with an error. + const cancelled = await insert("golden_cancel_running", "running"); + await driver.jobCompleteMany([ + completion({ + attempt: 1, + attemptedBy: "client<1>", + error: { at: now, error: "cancelled \u2028", trace: "" }, + finalizedAt: now, + id: cancelled, + kind: "cancel", + }), + ]); + expect(stored("golden_cancel_running")).toMatchObject({ + errors: [ + { + at: now.toString(), + attempt: 1, + error: "cancelled \u2028", + trace: "", + }, + ], + metadata: { m: "x" }, + state: "cancelled", + }); + + // A completion that finds its job no longer running still merges its + // output. + const stale = await insert("golden_stale", "available"); + await driver.jobCompleteMany([ + completion({ + attempt: 1, + attemptedBy: "client<1>", + finalizedAt: now, + id: stale, + kind: "complete", + output: { n: 3, text: "\u2028" }, + outputSet: true, + }), + ]); + expect(stored("golden_stale")).toMatchObject({ + metadata: { m: "x", output: { n: 3, text: "\u2028" } }, + state: "available", + }); + + // Rescue adds a rescue count, or increments an earlier one. + const rescueError = { + at: now, + attempt: 1, + error: "Stuck job rescued by JobRescuer", + trace: "", + }; + const storedRescueError = { ...rescueError, at: now.toString() }; + const first = await insert("golden_rescue_first", "running"); + const again = await insert("golden_rescue_again", "running", { + metadata: { m: "x", "river:rescue_count": 2 }, + }); + await driver.jobRescueMany( + [ + { + error: rescueError, + finalizedAt: null, + id: first, + scheduledAt: now, + state: "retryable", + }, + { + error: rescueError, + finalizedAt: now, + id: again, + scheduledAt: now, + state: "discarded", + }, + ], + now + ); + expect(stored("golden_rescue_first")).toMatchObject({ + errors: [storedRescueError], + metadata: { m: "x", "river:rescue_count": 1 }, + state: "retryable", + }); + expect(stored("golden_rescue_again")).toMatchObject({ + errors: [storedRescueError], + metadata: { m: "x", "river:rescue_count": 3 }, + state: "discarded", + }); + + // The scheduler discards a job whose unique key another job holds. + for (const state of ["available", "scheduled"] as const) { + await driver.jobInsert({ + args: {}, + createdAt, + kind: `golden_schedule_${state}`, + metadata: { m: "x" }, + scheduledAt: createdAt, + state, + uniqueKey: new Uint8Array(32).fill(7), + uniqueStates: ["available"], + }); + } + await driver.jobSchedule({ now }); + expect(stored("golden_schedule_scheduled")).toMatchObject({ + metadata: { m: "x", unique_key_conflict: "scheduler_discarded" }, + state: "discarded", + }); + + // Queue metadata on creation and on update. + await driver.queueUpsert("golden_queue", { + metadata: { n: 1.5, note: " & \u2028" }, + now, + }); + expect(queueMetadata()).toEqual({ n: 1.5, note: " & \u2028" }); + await driver.queueUpdate("golden_queue", { + metadata: { list: [1, "a"], updated: "" }, + }); + expect(queueMetadata()).toEqual({ list: [1, "a"], updated: "" }); + }); + + test("merges completion metadata into stored metadata like Go", async () => { + const { database, driver } = await setup(); + database.exec(` + INSERT INTO river_job ( + args, attempt, attempted_at, attempted_by, kind, max_attempts, + metadata, state + ) VALUES ( + jsonb('{}'), 1, '2026-01-02 03:04:05.123', jsonb('["client<1>"]'), + 'golden_patch', 25, jsonb('{"b": 1, "1": 2}'), 'running' + )`); + const id = rawRow( + database, + "SELECT id FROM river_job WHERE kind = 'golden_patch'" + )?.id as bigint; + + await driver.jobCompleteMany([ + completion({ + attempt: 1, + attemptedBy: "client<1>", + finalizedAt: Temporal.Now.instant(), + id, + kind: "complete", + metadata: { "river:resumable_step": "s", "10": 1, "9": 2 }, + output: { b: 1, a: 2 }, + outputSet: true, + }), + ]); + + expect(storedJson(database, "golden_patch", ["metadata"])).toEqual({ + metadata: { + "1": 2, + "10": 1, + "9": 2, + b: 1, + output: { a: 2, b: 1 }, + "river:resumable_step": "s", + }, + }); + }); + + test("writes NULL for absent client IDs and errors like Go", async () => { + const { database, driver } = await setup(); + const absent = await driver.jobInsert({ args: {}, kind: "absent" }); + // Go's full insert writes an explicitly empty client ID list as `[]`. + const empty = await driver.jobInsert({ + args: {}, + attemptedBy: [], + errors: [], + kind: "empty", + }); + + const columns = (id: bigint) => + rawRow( + database, + `SELECT json(attempted_by) AS attempted_by, errors FROM river_job + WHERE id = ${id}` + ); + expect(columns(absent.job.id)).toEqual({ + attempted_by: null, + errors: null, + }); + expect(columns(empty.job.id)).toEqual({ attempted_by: "[]", errors: null }); + expect(absent.job).toMatchObject({ attemptedBy: [], errors: [] }); + }); + + test("persists near-future retries and snoozes as available like Go", async () => { + const { database, driver } = await setup(); + const snoozing = (await driver.jobInsert({ args: {}, kind: "fast_path" })) + .job; + const failing = (await driver.jobInsert({ args: {}, kind: "fast_path" })) + .job; + await driver.jobClaim({ + attemptedBy: "js-worker", + kinds: ["fast_path"], + queues: [{ limit: 2, name: "default" }], + }); + const notificationsBefore = rawRow( + database, + "SELECT count(*) AS count FROM river_notification" + )?.count; + const scheduledAt = Temporal.Now.instant() + .round({ roundingMode: "ceil", smallestUnit: "millisecond" }) + .add({ seconds: 1 }); + const common = { + attempt: 1, + attemptedBy: "js-worker", + available: true, + scheduledAt, + }; + + const results = await driver.jobCompleteMany([ + completion({ ...common, id: snoozing.id, kind: "snooze" }), + completion({ + ...common, + error: { at: PRECISION_TEST_TIME, error: "boom", trace: "" }, + id: failing.id, + kind: "retry", + }), + ]); + + // Like Go's `JobSetStateSnoozedAvailable`, a snooze refunds the attempt. + expect(results[0]).toMatchObject({ + job: { attempt: 0, errors: [], scheduledAt, state: "available" }, + status: "applied", + }); + // Like `JobSetStateErrorAvailable`, an error keeps it. + expect(results[1]).toMatchObject({ + job: { attempt: 1, errors: [{ error: "boom" }], state: "available" }, + status: "applied", + }); + // Neither is claimable, nor announced, before its scheduled time. + await expect( + driver.jobClaim({ + attemptedBy: "js-worker", + kinds: ["fast_path"], + queues: [{ limit: 2, name: "default" }], + }) + ).resolves.toEqual({ jobs: [] }); + expect( + rawRow(database, "SELECT count(*) AS count FROM river_notification") + ?.count + ).toBe(notificationsBefore); + + const completed = (await driver.jobInsert({ args: {}, kind: "fast_path" })) + .job; + await expect( + driver.jobCompleteMany([ + completion({ + attempt: 1, + attemptedBy: "js-worker", + available: true, + finalizedAt: PRECISION_TEST_TIME, + id: completed.id, + kind: "complete", + }), + ]) + ).rejects.toBeInstanceOf(ValidationError); + }); + + test("pages list cursors across tied timestamps", async () => { + const { driver } = await setup(); + const ids: bigint[] = []; + for (let index = 0; index < 5; index++) { + ids.push( + ( + await driver.jobInsert({ + args: {}, + kind: "tied_cursor", + scheduledAt: PRECISION_TEST_TIME, + }) + ).job.id + ); + } + + for (const sortField of ["scheduledAt", "time"] as const) { + const seen: bigint[] = []; + let after: JobListParams["after"] = null; + for (;;) { + const page = await driver.jobList( + listParams({ + after, + kinds: ["tied_cursor"], + limit: 2, + sortField, + states: ["available"], + }) + ); + seen.push(...page.map(({ id }) => id)); + const last = page.at(-1); + if (page.length < 2 || last === undefined) break; + after = { + id: last.id, + kind: last.kind, + queue: last.queue, + sortField, + time: last.scheduledAt, + }; + } + expect(seen).toEqual(ids); + } + }); + + test("filters metadata across many pages without missing sparse matches", async () => { + const { driver } = await setup(); + const matches: bigint[] = []; + const params = Array.from({ length: 2_500 }, (_, index) => ({ + args: {}, + kind: "sparse_metadata", + metadata: + index === 3 || index === 2_400 + ? { + 'quoted"key': true, + amount: exactJsonNumber("12345678901234567890"), + tenant: "wanted", + } + : { amount: 1, tenant: index % 2 === 0 ? "other" : 7 }, + })); + const inserted = await driver.jobInsertMany(params); + matches.push(inserted[3]!.job.id, inserted[2_400]!.job.id); + + const listed = await driver.jobList( + listParams({ + limit: 10, + metadata: { + 'quoted"key': true, + amount: exactJsonNumber("12345678901234567890"), + tenant: "wanted", + }, + }) + ); + expect(listed.map(({ id }) => id)).toEqual(matches); + + const firstOnly = await driver.jobList( + listParams({ limit: 1, metadata: { tenant: "wanted" } }) + ); + expect(firstOnly.map(({ id }) => id)).toEqual([matches[0]]); + const afterFirst = await driver.jobList( + listParams({ + after: { + id: matches[0]!, + kind: "sparse_metadata", + queue: "default", + sortField: "id", + time: null, + }, + limit: 5, + metadata: { tenant: "wanted" }, + }) + ); + expect(afterFirst.map(({ id }) => id)).toEqual([matches[1]]); + }); + + test("wraps only node:sqlite failures and keeps the SQLite message", async () => { + const { database, driver } = await setup(); + + await expect( + driver.jobInsert({ args: {}, kind: "bad_priority", priority: 9 }) + ).rejects.toBeInstanceOf(ValidationError); + await expect( + driver.jobDeleteMany({ + all: false, + ids: [], + kinds: [], + limit: 0, + priorities: [], + queues: [], + states: [], + }) + ).rejects.toBeInstanceOf(RangeError); + + database.exec("DROP TABLE river_queue"); + const failure = driver.queueGet("missing"); + await expect(failure).rejects.toBeInstanceOf(DatabaseOperationError); + await expect(failure).rejects.toMatchObject({ + backend: "sqlite", + cause: expect.objectContaining({ code: "ERR_SQLITE_ERROR" }), + message: expect.stringContaining("no such table: river_queue"), + operation: "queue_get", + retryable: false, + }); + }); +}); + +describe("SqliteDriver rescue guards against stale snapshots", () => { + const horizon = PRECISION_TEST_TIME.subtract({ hours: 1 }); + const rescueAt = PRECISION_TEST_TIME.add({ minutes: 1 }); + + for (const state of ["cancelled", "discarded", "retryable"] as const) { + test(`leaves a job completed after the fetch untouched (${state})`, async () => { + const { driver } = await setup(); + const job = await runningJob(driver, { + metadata: { "river:rescue_count": 5, something: "else" }, + }); + const stillRunning = await runningJob(driver, { + metadata: { "river:rescue_count": 5, something: "else" }, + }); + const stuck = await driver.jobGetStuck({ attemptedBefore: horizon }); + expect(stuck.map(({ id }) => id)).toEqual([job.id, stillRunning.id]); + + // The worker completes after the rescuer reads the job but before its + // rescue write. + const [completed] = await driver.jobCompleteMany([ + completion({ + attempt: job.attempt, + attemptedBy: "js-worker", + finalizedAt: PRECISION_TEST_TIME, + id: job.id, + kind: "complete", + output: { worker: "finished" }, + outputSet: true, + }), + ]); + expect(completed?.job?.state).toBe("completed"); + const completedJob = await driver.jobGet(job.id); + + const finalizedAt = state === "retryable" ? null : rescueAt; + await driver.jobRescueMany( + [job, stillRunning].map((row, index) => + rescue(row.id, state, index === 0 ? "stale rescue" : "stuck", { + finalizedAt, + }) + ), + horizon + ); + + const rescued = await driver.jobGet(stillRunning.id); + expect(rescued).toMatchObject({ + finalizedAt, + metadata: { "river:rescue_count": 6, something: "else" }, + scheduledAt: rescueAt, + state, + }); + expect(rescued?.errors.map(({ error }) => error)).toEqual(["stuck"]); + expect(await driver.jobGet(job.id)).toEqual(completedJob); + }); + } + + for (const release of ["failed", "interrupted"] as const) { + test(`leaves a job claimed again after the fetch untouched (${release})`, async () => { + const { driver } = await setup(); + const job = await runningJob(driver, { + metadata: { "river:rescue_count": 5 }, + }); + const stuck = await driver.jobGetStuck({ attemptedBefore: horizon }); + expect(stuck.map(({ id }) => id)).toEqual([job.id]); + + // The old worker releases the job and a new worker claims it before the + // rescuer writes its stale snapshot. The state alone still matches. + await driver.jobCompleteMany([ + completion({ + attempt: job.attempt, + attemptedBy: "js-worker", + error: + release === "failed" + ? { at: PRECISION_TEST_TIME, error: "failed", trace: "" } + : null, + id: job.id, + kind: release === "failed" ? "retry" : "interrupt", + scheduledAt: PRECISION_TEST_TIME, + }), + ]); + if (release === "failed") { + await driver.jobSchedule({ now: Temporal.Now.instant() }); + } + const [claimed] = ( + await driver.jobClaim({ + attemptedBy: "new-worker", + kinds: [], + queues: [{ limit: 1, name: job.queue }], + }) + ).jobs; + expect(claimed?.id).toBe(job.id); + expect( + Temporal.Instant.compare(claimed!.attemptedAt!, horizon) + ).toBeGreaterThan(0); + + await driver.jobRescueMany( + [rescue(job.id, "retryable", "stale rescue")], + horizon + ); + + expect(await driver.jobGet(job.id)).toEqual(claimed); + }); + } + + test("rescues only attempts strictly before the horizon", async () => { + const { driver } = await setup(); + const jobs: SqliteJobRow[] = []; + for (const offset of [-1, 0, 1]) { + jobs.push( + await runningJob(driver, { + attemptedAt: horizon.add({ milliseconds: offset }), + }) + ); + } + + const rescued = await driver.jobRescueMany( + jobs.map(({ id }) => rescue(id, "retryable", "stuck")), + horizon + ); + + expect(rescued.map(({ id }) => id)).toEqual([jobs[0]!.id]); + expect((await driver.jobGet(jobs[0]!.id))?.state).toBe("retryable"); + expect(await driver.jobGet(jobs[1]!.id)).toEqual(jobs[1]); + expect(await driver.jobGet(jobs[2]!.id)).toEqual(jobs[2]); + }); + + test("applies the guard through the leader-fenced maintenance rescue", async () => { + const { driver } = await setup(); + const leader = (await driver.maintenanceLeaderAcquire( + "leader", + Temporal.Now.instant(), + 60_000, + null + ))!; + const eligible = await runningJob(driver); + const fresh = await runningJob(driver, { + attemptedAt: horizon.add({ milliseconds: 1 }), + }); + + await expect( + driver.maintenanceRescue(leader, horizon, [ + rescue(eligible.id, "retryable", "stuck"), + rescue(fresh.id, "retryable", "stuck"), + ]) + ).resolves.toBe(1); + expect(await driver.jobGet(fresh.id)).toEqual(fresh); + }); + + function rescue( + id: bigint, + state: SqliteRescueJobParams["state"], + error: string, + options: { finalizedAt?: Temporal.Instant | null } = {} + ): RuntimeJobRescue { + return { + error: { at: PRECISION_TEST_TIME, attempt: 1, error, trace: "" }, + finalizedAt: + options.finalizedAt === undefined + ? state === "retryable" + ? null + : rescueAt + : options.finalizedAt, + id, + scheduledAt: rescueAt, + state, + }; + } +}); + +function completion( + overrides: Partial & + Pick +): JobCompletionCommand { + return { + error: null, + finalizedAt: null, + output: null, + outputSet: false, + scheduledAt: null, + ...overrides, + }; +} + +function listParams(overrides: Partial = {}): JobListParams { + return { + after: null, + ids: [], + kinds: [], + limit: 100, + metadata: null, + priorities: [], + queues: [], + sortDirection: "asc", + sortField: "id", + states: [], + tagsAll: [], + tagsAny: [], + ...overrides, + }; +} + +function rawRow( + database: DatabaseSync, + sql: string +): Record | undefined { + const statement = database.prepare(sql); + statement.setReadBigInts(true); + return statement.get(); +} + +/** Insert a job that a previous worker attempted two hours before the test time. */ +async function runningJob( + driver: SqliteRuntime, + options: { + attemptedAt?: Temporal.Instant; + metadata?: Record; + } = {} +): Promise { + const attemptedAt = + options.attemptedAt ?? PRECISION_TEST_TIME.subtract({ hours: 2 }); + return ( + await driver.jobInsert({ + args: {}, + attempt: 1, + attemptedAt, + attemptedBy: ["js-worker"], + kind: "rescue_guard", + metadata: (options.metadata ?? {}) as SqliteJobRow["metadata"], + scheduledAt: attemptedAt, + state: "running", + }) + ).job; +} + +/** + * Decode `columns` of the `river_job` row of a kind, requiring each one that + * isn't NULL to be stored as a JSONB blob, as Go's `riversqlite` stores it. + * Metadata is returned without its random unique nonce. + */ +function storedJson( + database: DatabaseSync, + kind: string, + columns: readonly string[] +): Record { + const row = rawRow( + database, + `SELECT ${columns + .map( + (column) => + `typeof(${column}) AS "${column}:type", json(${column}) AS "${column}"` + ) + .join(", ")} + FROM river_job WHERE kind = '${kind}'` + ); + if (row === undefined) throw new Error(`no job of kind ${kind}`); + return Object.fromEntries( + columns.map((column) => { + const text = row[column]; + if (text === null) return [column, null]; + expect(row[`${column}:type`], column).toBe("blob"); + const value = parseJson(text as string); + if (column === "metadata") { + delete (value as JsonObject)["river:unique_nonce"]; + } + return [column, value]; + }) + ); +} + +/** The unique nonce stored in the raw metadata of the job of a kind. */ +function uniqueNonce(database: DatabaseSync, kind: string): unknown { + return rawRow( + database, + `SELECT json_extract(metadata, '$."river:unique_nonce"') AS nonce + FROM river_job WHERE kind = '${kind}'` + )?.nonce; +} + +async function setup(): Promise<{ + database: DatabaseSync; + driver: SqliteRuntime; +}> { + const driver = testSqliteMemory(STRICT); + onTestFinished(() => driver.close()); + const database = driver.database; + const moduleUrl = new URL("../../../migrate/dist/index.js", import.meta.url); + const migrationModule = (await import(moduleUrl.href)) as { + createMigrator(target: { database: DatabaseSync }): { + migrateUp(): Promise; + }; + }; + await migrationModule.createMigrator({ database }).migrateUp(); + return { database, driver }; +} + +/** 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/sqlite/src/pilot.test.ts b/js/driver/sqlite/src/pilot.test.ts new file mode 100644 index 000000000..0497b89bc --- /dev/null +++ b/js/driver/sqlite/src/pilot.test.ts @@ -0,0 +1,2536 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; + +import { + BackendMismatchError, + Client, + ConfigurationError, + DatabaseOperationError, + defineJob, + ExtensionError, + JobCancelledError, + LifecycleError, + periodicJob, + ValidationError, + Workers, + type ClientOptions, + type JobRow, + type QueueConfig, +} from "riverqueue"; +import { + createJobArgsTransformPlugin, + createJobInsertMetadataTransformPlugin, + PilotClient, + type PreparedInsertParams, + type Pilot, + type PilotDatabase, + type PilotHost, +} from "riverqueue/unstable-driver"; +import { describe, expect, onTestFinished, test } from "vitest"; + +import { + SQLITE_DRIVER_TEST_HOOKS, + testSqliteDriver, + testSqliteMemory, +} from "./driver.js"; +import { transaction } from "./scope.js"; +import type { SqliteDriverOptions } from "./types.js"; + +type Transaction = DatabaseSync; + +/** River's own tests fail any lock window that crosses the event loop. */ +const STRICT = { + [SQLITE_DRIVER_TEST_HOOKS]: { strictLockWindow: true }, +} as SqliteDriverOptions; + +const job = defineJob({ kind: "pilot_job" }); + +/** A companion's queue configuration: River's, plus one key it owns. */ +interface CompanionQueueConfig extends QueueConfig { + readonly limit?: number; +} + +/** A companion client, as a first-party package would build one. */ +class CompanionClient extends PilotClient {} + +/** River's transactions reach a pilot as native handles. */ +function handle(tx: Transaction): DatabaseSync { + if (!(tx instanceof DatabaseSync)) { + throw new Error("expected a native SQLite handle"); + } + return tx; +} + +/** Record a companion effect in the scratch table, in `tx`. */ +function note(tx: Transaction, text: string, jobId: bigint | null = null) { + handle(tx) + .prepare("INSERT INTO pilot_companion (job_id, note) VALUES (?, ?)") + .run(jobId, text); +} + +async function setup( + createPilot: ( + database: PilotDatabase + ) => Pilot = () => ({}), + options: + | ClientOptions + | ((database: DatabaseSync) => ClientOptions) = {}, + driverOptions: SqliteDriverOptions = {} +) { + const directory = mkdtempSync(join(tmpdir(), "river-sqlite-pilot-")); + const database = new DatabaseSync(join(directory, "river.db"), { + timeout: 0, + }); + await migrate(database); + database.exec( + "CREATE TABLE pilot_companion (id INTEGER PRIMARY KEY, job_id INTEGER, note TEXT NOT NULL)" + ); + const driver = testSqliteDriver(database, { ...STRICT, ...driverOptions }); + let pilotDatabase: PilotDatabase | undefined; + let host: PilotHost | undefined; + const client = new CompanionClient( + driver, + typeof options === "function" ? options(database) : options, + (db) => { + pilotDatabase = db; + const pilot = createPilot(db); + const init = pilot.init; + return { + ...pilot, + init(pilotHost) { + host = pilotHost; + init?.call(this, pilotHost); + }, + }; + } + ); + onTestFinished(() => { + driver.close(); + database.close(); + rmSync(directory, { force: true, recursive: true }); + }); + const rows = (sql: string): Row[] => + database.prepare(sql).all() as Row[]; + const notes = (): string[] => + rows<{ note: string }>("SELECT note FROM pilot_companion ORDER BY id").map( + (row) => row.note + ); + const count = (table: string): number => + Number( + database.prepare(`SELECT count(*) AS count FROM ${table}`).get()?.count + ); + return { + client, + count, + database, + driver, + host: host as unknown as PilotHost, + notes, + pilotDatabase: pilotDatabase as unknown as PilotDatabase, + rows, + }; +} + +describe("SQLite pilot database", () => { + test("commits a pilot transaction on River's connection", async () => { + const { client, count, notes, pilotDatabase } = await setup(); + + const inserted = await pilotDatabase.transaction(async (tx) => { + note(tx, "companion"); + // River's connection stands for the transaction inside it. + return client.insert(job, {}, { tx }); + }); + + expect(inserted.status).toBe("inserted"); + expect(notes()).toEqual(["companion"]); + expect(count("river_job")).toBe(1); + }); + + test("rolls a pilot transaction back when its callback rejects", async () => { + const { client, count, notes, pilotDatabase } = await setup(); + const failure = new Error("companion failed"); + + await expect( + pilotDatabase.transaction(async (tx) => { + note(tx, "companion"); + await client.insert(job, {}, { tx }); + throw failure; + }) + ).rejects.toBe(failure); + + expect(notes()).toEqual([]); + expect(count("river_job")).toBe(0); + }); + + test("rolls back instead of committing once the signal aborted", async () => { + const { notes, pilotDatabase } = await setup(); + const controller = new AbortController(); + const reason = new Error("stopped"); + + await expect( + pilotDatabase.transaction( + (tx) => { + note(tx, "companion"); + controller.abort(reason); + }, + { signal: controller.signal } + ) + ).rejects.toBe(reason); + await expect( + pilotDatabase.transaction( + () => { + throw new Error("never runs"); + }, + { signal: controller.signal } + ) + ).rejects.toBe(reason); + + expect(notes()).toEqual([]); + }); + + test("never runs the callback when River can't begin", async () => { + const { database, pilotDatabase } = await setup( + () => ({}), + {}, + { + busyTimeout: { milliseconds: 20 }, + } + ); + let calls = 0; + database.exec("BEGIN IMMEDIATE"); + try { + await expect( + pilotDatabase.transaction(() => { + calls++; + }) + ).rejects.toBeInstanceOf(DatabaseOperationError); + } finally { + database.exec("ROLLBACK"); + } + + expect(calls).toBe(0); + }); + + test("runs directly in a supplied application transaction", async () => { + const { client, count, database, notes, pilotDatabase } = await setup(); + + const rollback = new Error("roll back"); + await expect( + transaction(database, async (tx) => { + note(tx, "application"); + await pilotDatabase.transaction( + async (inner) => { + expect(inner).toBe(tx); + note(inner, "kept"); + await client.insert(job, {}, { tx: inner }); + }, + { tx } + ); + await expect( + pilotDatabase.transaction( + async (inner) => { + note(inner, "failed"); + await client.insert(job, {}, { tx: inner }); + throw new Error("callback failed"); + }, + { tx } + ) + ).rejects.toThrow("callback failed"); + // Like River for Go, River opens no savepoint: the failed + // callback's writes stay in the caller's transaction until the + // caller rolls it back. + expect(tx.isTransaction).toBe(true); + expect(notes()).toEqual(["application", "kept", "failed"]); + expect(count("river_job")).toBe(2); + throw rollback; + }) + ).rejects.toBe(rollback); + + expect(notes()).toEqual([]); + expect(count("river_job")).toBe(0); + }); + + test("rejects River's connection as { tx } outside a pilot transaction", async () => { + const { client, pilotDatabase } = await setup(); + let riverConnection: Transaction | undefined; + await pilotDatabase.transaction((tx) => { + riverConnection = tx; + }); + + await expect( + client.insert(job, {}, { tx: riverConnection as Transaction }) + ).rejects.toBeInstanceOf(BackendMismatchError); + await expect( + pilotDatabase.transaction(() => undefined, { + tx: riverConnection as Transaction, + }) + ).rejects.toBeInstanceOf(BackendMismatchError); + + // Not even while a pilot transaction holds it, from code outside it. + const gate = Promise.withResolvers(); + const outside = gate.promise.then(() => + client.insert(job, {}, { tx: riverConnection as Transaction }) + ); + await pilotDatabase.transaction(async () => { + gate.resolve(undefined); + await expect(outside).rejects.toBeInstanceOf(BackendMismatchError); + }); + }); + + test("rejects River's connection as { tx } in River's own transaction", async () => { + let riverConnection: Transaction | undefined; + let lookup: unknown; + const { client, count, pilotDatabase } = await setup( + () => ({}), + () => ({ + insertMiddleware: [ + async (_context, next) => { + const results = await next(); + lookup = await client.jobs + .get(1n, { tx: riverConnection as Transaction }) + .catch((error: unknown) => error); + return results; + }, + ], + }) + ); + await pilotDatabase.transaction((tx) => { + riverConnection = tx; + }); + + await client.insert(job, {}); + + expect(lookup).toBeInstanceOf(BackendMismatchError); + expect(count("river_job")).toBe(1); + }); + + test("fails a pilot transaction that awaits I/O", async () => { + const { notes, pilotDatabase } = await setup(); + + await expect( + pilotDatabase.transaction(async (tx) => { + note(tx, "companion"); + await stat(tmpdir()); + }) + ).rejects.toMatchObject({ + message: expect.stringContaining( + "a companion's transaction or operation interceptor awaited I/O" + ) as unknown, + reason: "event_loop_turn", + }); + + expect(notes()).toEqual([]); + }); + + test("borrows River's connection outside any transaction", async () => { + const { client, notes, pilotDatabase } = await setup(); + + await pilotDatabase.connection((connection) => { + note(connection, "autocommit"); + }); + await expect( + pilotDatabase.connection(async () => { + await client.insert(job, {}); + }) + ).rejects.toMatchObject({ reason: "reentrant" }); + await expect( + pilotDatabase.connection((connection) => { + handle(connection).exec("BEGIN"); + note(connection, "left open"); + }) + ).rejects.toMatchObject({ reason: "nested" }); + + expect(notes()).toEqual(["autocommit"]); + await expect(client.insert(job, {})).resolves.toMatchObject({ + status: "inserted", + }); + }); + + test("claims and loads jobs in a pilot transaction", async () => { + const { client, driver, pilotDatabase, rows } = await setup(); + await client.insertMany([ + { args: {}, job }, + { args: {}, job }, + ]); + const claim = (tx: Transaction) => + driver.jobClaim( + { + attemptedBy: "pilot-client", + kinds: [], + queues: [{ limit: 2, name: "default" }], + }, + { tx } + ); + + await expect( + pilotDatabase.transaction(async (tx) => { + const claimed = await claim(tx); + expect(claimed.jobs).toHaveLength(2); + throw new Error("roll the claim back"); + }) + ).rejects.toThrow("roll the claim back"); + expect( + rows<{ state: string }>("SELECT state FROM river_job").map( + ({ state }) => state + ) + ).toEqual(["available", "available"]); + + const [claimed, loaded] = await pilotDatabase.transaction(async (tx) => { + const result = await claim(tx); + const ids = result.jobs.map(({ id }) => id).reverse(); + return [result, await pilotDatabase.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); + expect(loaded.decodeErrors?.size ?? 0).toBe(0); + + const id = claimed.jobs[0]?.id ?? 0n; + await pilotDatabase.transaction(async (tx) => { + await expect( + pilotDatabase.loadClaimed([id, id], { tx }) + ).rejects.toBeInstanceOf(ValidationError); + await expect( + pilotDatabase.loadClaimed([id + 100n], { tx }) + ).rejects.toThrow("has no row"); + }); + await expect( + pilotDatabase.loadClaimed([id], {} as { tx: Transaction }) + ).rejects.toMatchObject({ reason: "no_transaction" }); + }); + + test("writes notifications that commit and roll back with the transaction", async () => { + const { pilotDatabase, rows } = await setup(); + + await pilotDatabase.transaction((tx) => + pilotDatabase.notify("control", ['{"kept":true}'], { tx }) + ); + await expect( + pilotDatabase.transaction(async (tx) => { + await pilotDatabase.notify("insert", ['{"queue":"x"}'], { tx }); + throw new Error("rolled back"); + }) + ).rejects.toThrow("rolled back"); + await expect( + pilotDatabase.transaction((tx) => + pilotDatabase.notify("leadership" as "control", ["{}"], { tx }) + ) + ).rejects.toBeInstanceOf(ValidationError); + + expect( + rows<{ payload: string; topic: string }>( + "SELECT payload, topic FROM river_notification" + ) + ).toEqual([{ payload: '{"kept":true}', topic: "river_control" }]); + }); + + test("deletes finalized jobs by state cutoff, lowest IDs first", async () => { + const { client, count, database, pilotDatabase, rows } = await setup(); + const insertFinalized = async ( + state: string | null, + finalizedAt = "2000-01-01 00:00:00.000" + ): Promise => { + const { job: row } = await client.insert(job, {}); + if (state !== null) { + database + .prepare( + "UPDATE river_job SET state = ?, finalized_at = ? WHERE id = ?" + ) + .run(state, finalizedAt, row.id); + } + return row.id; + }; + await insertFinalized("cancelled"); + const cancelledLater = await insertFinalized( + "cancelled", + "2000-01-03 00:00:00.000" + ); + const completed = await insertFinalized("completed"); + await insertFinalized("discarded"); + const available = await insertFinalized(null); + const discardedLast = await insertFinalized("discarded"); + const cutoff = Temporal.Instant.from("2000-01-02T00:00:00Z"); + const params = { + cancelledBefore: cutoff, + completedBefore: null, + discardedBefore: cutoff, + limit: 2, + }; + const remaining = () => + rows<{ id: number }>("SELECT id FROM river_job ORDER BY id").map((row) => + BigInt(row.id) + ); + + expect(await pilotDatabase.deleteFinalizedJobs(params)).toBe(2); + expect(remaining()).toEqual([ + cancelledLater, + completed, + available, + discardedLast, + ]); + expect(await pilotDatabase.deleteFinalizedJobs(params)).toBe(1); + expect(await pilotDatabase.deleteFinalizedJobs(params)).toBe(0); + expect(remaining()).toEqual([cancelledLater, completed, available]); + // It needs no leadership term. + expect(count("river_leader")).toBe(0); + }); + + // Like River for Go's `QueuesFilteredBeforeLimit` driver cases: jobs in + // `kept1` and `kept2` hold the lowest IDs, so a batch limiting candidates + // before filtering queues would select only them and stall. + for (const testCase of [ + { + // `kept1` is in both lists; exclusion wins. + batches: [2, 2, 1, 0], + deletedQueues: ["deleted1", "deleted2"], + name: "both lists", + queuesExcluded: ["kept1", "kept2"], + queuesIncluded: ["deleted1", "deleted2", "kept1"], + }, + { + batches: [0], + deletedQueues: [], + name: "an empty included list", + queuesIncluded: [], + }, + { + batches: [2, 2, 1, 0], + deletedQueues: ["deleted1", "deleted2"], + name: "excluded queues", + queuesExcluded: ["kept1", "kept2"], + }, + { + batches: [2, 2, 1, 0], + deletedQueues: ["deleted1", "deleted2"], + name: "included queues", + queuesIncluded: ["deleted1", "deleted2"], + }, + { + batches: [2, 2, 2, 2, 2, 1, 0], + deletedQueues: ["deleted1", "deleted2", "kept1", "kept2"], + name: "a null included list", + queuesIncluded: null, + }, + ] as const) { + test(`filters finalized job deletion by queue before the limit: ${testCase.name}`, async () => { + const { client, database, pilotDatabase, rows } = await setup(); + const states = ["cancelled", "completed", "discarded"]; + const queues = [ + ...["kept1", "kept2", "kept1", "kept2", "kept1", "kept2"], + ...["deleted1", "deleted2", "deleted1", "deleted2", "deleted1"], + ]; + const allIds: bigint[] = []; + const eligibleIds: bigint[] = []; + for (const [index, queue] of queues.entries()) { + const { job: row } = await client.insert(job, {}, { queue }); + database + .prepare( + "UPDATE river_job SET state = ?, finalized_at = '2000-01-01 00:00:00.000' WHERE id = ?" + ) + .run(states[index % states.length]!, row.id); + allIds.push(row.id); + if ((testCase.deletedQueues as readonly string[]).includes(queue)) { + eligibleIds.push(row.id); + } + } + const before = Temporal.Now.instant(); + + let deletedTotal = 0; + for (const wantDeleted of testCase.batches) { + const deleted = await pilotDatabase.deleteFinalizedJobs({ + cancelledBefore: before, + completedBefore: before, + discardedBefore: before, + limit: 2, + ...("queuesExcluded" in testCase + ? { queuesExcluded: testCase.queuesExcluded } + : {}), + ...("queuesIncluded" in testCase + ? { queuesIncluded: testCase.queuesIncluded } + : {}), + }); + expect(deleted).toBe(wantDeleted); + deletedTotal += deleted; + const gone = eligibleIds.slice(0, deletedTotal); + expect( + rows<{ id: number }>("SELECT id FROM river_job ORDER BY id").map( + (row) => BigInt(row.id) + ) + ).toEqual(allIds.filter((id) => !gone.includes(id))); + } + expect(deletedTotal).toBe(eligibleIds.length); + }); + } + + test("deletes finalized jobs in a pilot transaction, rolling back with it", async () => { + const { client, count, database, pilotDatabase } = await setup(); + for (let index = 0; index < 3; index++) { + const { job: row } = await client.insert(job, {}); + database + .prepare( + "UPDATE river_job SET state = 'completed', finalized_at = '2000-01-01 00:00:00.000' WHERE id = ?" + ) + .run(row.id); + } + const params = { + cancelledBefore: null, + completedBefore: Temporal.Now.instant(), + discardedBefore: null, + limit: 2, + }; + + await expect( + pilotDatabase.transaction(async (tx) => { + expect(await pilotDatabase.deleteFinalizedJobs(params, { tx })).toBe(2); + throw new Error("rolled back"); + }) + ).rejects.toThrow("rolled back"); + expect(count("river_job")).toBe(3); + + expect( + await pilotDatabase.transaction((tx) => + pilotDatabase.deleteFinalizedJobs(params, { tx }) + ) + ).toBe(2); + expect(count("river_job")).toBe(1); + }); + + test("rescues in a pilot transaction, fenced by the leader", async () => { + const { client, driver, pilotDatabase, rows } = await setup(); + const inserted = await client.insert(job, {}); + await driver.jobClaim({ + attemptedBy: "stuck-client", + kinds: [], + queues: [{ limit: 1, name: "default" }], + }); + const now = Temporal.Now.instant(); + const leader = await driver.maintenanceLeaderAcquire( + "leader", + now, + 30_000, + null + ); + if (leader === null) throw new Error("expected leadership"); + const rescue = { + error: { at: now, attempt: 1, error: "stuck", trace: "" }, + finalizedAt: null, + id: inserted.job.id, + scheduledAt: now, + state: "retryable" as const, + }; + const before = now.add({ seconds: 1 }); + + await expect( + pilotDatabase.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(rows<{ state: string }>("SELECT state FROM river_job")).toEqual([ + { state: "running" }, + ]); + + await pilotDatabase.transaction((tx) => + driver.maintenanceRescue(leader, before, [rescue], { tx }) + ); + expect(rows<{ state: string }>("SELECT state FROM river_job")).toEqual([ + { state: "retryable" }, + ]); + }); +}); + +describe("SQLite pilot interception", () => { + const recordingPilot = ( + failAfterNext: () => boolean = () => false + ): Pilot => ({ + intercept: { + async cancel(context, next) { + const job = await next(); + note(context.tx, "cancel", job?.id ?? null); + if (failAfterNext()) throw new Error("cancel companion failed"); + return job; + }, + async insert(context, next) { + note(context.tx, `before ${context.operation}`); + const results = await next(); + for (const result of results) { + note(context.tx, `inserted ${result.status}`, result.job.id); + } + if (failAfterNext()) throw new Error("insert companion failed"); + return results; + }, + async retry(context, next) { + const job = await next(); + note(context.tx, "retry", job?.id ?? null); + if (failAfterNext()) throw new Error("retry companion failed"); + return job; + }, + }, + }); + + test("commits companion writes with the standard insertion", async () => { + const { client, count, notes } = await setup(() => recordingPilot()); + + await client.insert(job, {}); + await client.insertMany([ + { args: {}, job }, + { args: {}, job }, + ]); + + expect(notes()).toEqual([ + "before insert", + "inserted inserted", + "before insertMany", + "inserted inserted", + "inserted inserted", + ]); + expect(count("river_job")).toBe(3); + }); + + test("leaves a failed interception's writes in the caller transaction until it rolls back", async () => { + let fail = true; + const { client, count, database, notes } = await setup(() => + recordingPilot(() => fail) + ); + const rollback = new Error("roll back"); + + // Without { tx }, River's own transaction rolls the whole insertion + // back. + await expect(client.insert(job, {})).rejects.toThrow( + "insert companion failed" + ); + expect(notes()).toEqual([]); + expect(count("river_job")).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. + await expect( + transaction(database, async (tx) => { + 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(notes()).toEqual([ + "application", + "before insert", + "inserted inserted", + "before insertMany", + "inserted inserted", + "inserted inserted", + ]); + expect(count("river_job")).toBe(3); + throw rollback; + }) + ).rejects.toBe(rollback); + expect(notes()).toEqual([]); + expect(count("river_job")).toBe(0); + + fail = false; + const available = await client.insert(job, {}); + const scheduled = await client.insert( + job, + {}, + { scheduledAt: Temporal.Now.instant().add({ hours: 1 }) } + ); + const before = notes(); + fail = true; + await expect( + transaction(database, async (tx) => { + 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 client.jobs.get(available.job.id, { tx }))?.state).toBe( + "cancelled" + ); + expect((await client.jobs.get(scheduled.job.id, { tx }))?.state).toBe( + "available" + ); + expect(notes().slice(before.length)).toEqual(["cancel", "retry"]); + throw rollback; + }) + ).rejects.toBe(rollback); + expect((await client.jobs.get(available.job.id))?.state).toBe("available"); + expect((await client.jobs.get(scheduled.job.id))?.state).toBe("scheduled"); + expect(notes()).toEqual(before); + }); + + test("runs cancel and retry interceptors in the operation's transaction", async () => { + const { client, notes } = await setup(() => recordingPilot()); + const inserted = await client.insert(job, {}); + + await client.jobs.cancel(inserted.job.id); + await client.jobs.retry(inserted.job.id); + + expect(notes().slice(2)).toEqual(["cancel", "retry"]); + }); + + test("runs overlapping operations on one caller transaction in turn", async () => { + const { client, count, database, notes } = await setup(() => + recordingPilot() + ); + + await transaction(database, async (tx) => { + 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(count("river_job")).toBe(3); + expect(notes().filter((text) => text.startsWith("before"))).toEqual([ + "before insert", + "before insert", + "before insertMany", + ]); + }); + + test("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, database } = await 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; + + await transaction(database, async (tx) => { + 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) => setImmediate(resolve)); + proceed.resolve(undefined); + await expect(failing).rejects.toThrow("insert companion failed"); + await expect(cancel).resolves.toMatchObject({ state: "cancelled" }); + }); + + expect((await client.jobs.get(other.job.id))?.state).toBe("cancelled"); + }); + + test("runs operations nested in an interceptor on its transaction", async () => { + const nestedJob = defineJob({ kind: "pilot_nested" }); + const { client, count, database, host, notes } = await setup(() => ({ + intercept: { + async insert(context, next) { + const results = await next(); + if (context.params[0]?.kind === job.kind) { + const { args, ...stored } = context.params[0]; + void args; + const prepared = { ...stored, kind: nestedJob.kind }; + // Both nested insertions queue inside this one, not behind it. + await Promise.all([ + host.insertPrepared([prepared], { tx: context.tx }), + host.insertPrepared([prepared], { tx: context.tx }), + client.jobs.get(1n, { tx: context.tx }), + ]); + note(context.tx, "nested"); + } + return results; + }, + }, + })); + + await transaction(database, (tx) => client.insert(job, {}, { tx })); + await client.insert(job, {}); + + expect(count("river_job")).toBe(6); + expect(notes()).toEqual(["nested", "nested"]); + }); + + test("runs the rescuer's reads and updates through the pilot", async () => { + const calls: string[] = []; + const { client, database, driver, rows } = await setup( + () => ({ + intercept: { + async getStuck(context, next) { + const jobs = await next(); + calls.push(`getStuck ${jobs.length} in ${context.timeoutMs} ms`); + return jobs; + }, + async rescue(context, next) { + const rescued = await next(); + note(context.tx, "rescued"); + calls.push(`rescue ${rescued}`); + return rescued; + }, + }, + }), + { + jobTimeout: { seconds: 1 }, + maintenance: { + electionInterval: { milliseconds: 50 }, + rescueAfter: { seconds: 1 }, + rescuerInterval: { milliseconds: 20 }, + }, + pollOnly: true, + queues: { other: { maxWorkers: 1 } }, + workers: new Workers().add(job, () => undefined), + } + ); + await client.insert(job, {}); + await driver.jobClaim({ + attemptedBy: "gone", + kinds: [], + queues: [{ limit: 1, name: "default" }], + }); + database.exec( + "UPDATE river_job SET attempted_at = '2020-01-01 00:00:00.000'" + ); + + const run = await client.start(); + try { + await waitFor(() => calls.includes("rescue 1")); + } finally { + await run.stop(); + } + + // The rescuer's batch timeout, River's default of 30 s. + expect(calls).toContain("getStuck 1 in 30000 ms"); + expect(rows<{ state: string }>("SELECT state FROM river_job")).toEqual([ + { state: "retryable" }, + ]); + expect(rows<{ note: string }>("SELECT note FROM pilot_companion")).toEqual([ + { note: "rescued" }, + ]); + }); + + test("intercepts background and transactional completion alike", async () => { + const txJob = defineJob({ kind: "pilot_tx_complete" }); + const fabricateJob = defineJob({ kind: "pilot_fabricate" }); + let fabricate = false; + let secondCompletion: unknown; + let fabricated: unknown; + const { client, count, notes, rows } = await setup( + () => ({ + intercept: { + async complete(context, next) { + const results = await next(); + for (const result of results) { + if (result.status === "applied" && result.job !== null) { + note( + context.tx, + `completed ${result.job.kind} ${context.commands.length}`, + result.job.id + ); + } + } + return fabricate + ? results.map((result) => ({ ...result })) + : results; + }, + }, + }), + (database) => ({ + completionBatchSize: 1, + leaderElectionDisabled: true, + pollOnly: true, + queues: { + default: { + fetchCooldown: { milliseconds: 1 }, + maxWorkers: 1, + pollInterval: { milliseconds: 5 }, + }, + }, + workers: new Workers() + .add(job, () => undefined) + .add(txJob, async ({ completeTx }) => { + const application = new DatabaseSync(database.location() ?? "", { + timeout: 0, + }); + try { + await transaction(application, async (tx) => { + await completeTx(tx); + secondCompletion = await completeTx(tx).catch( + (error: unknown) => error + ); + }); + } finally { + application.close(); + } + }) + .add(fabricateJob, async ({ completeTx }) => { + const application = new DatabaseSync(database.location() ?? "", { + timeout: 0, + }); + try { + fabricate = true; + await transaction(application, async (tx) => { + fabricated = await completeTx(tx).catch( + (error: unknown) => error + ); + }); + } finally { + fabricate = false; + application.close(); + } + }), + }) + ); + + const run = await client.start(); + try { + await client.insert(job, {}); + await client.insert(txJob, {}); + await client.insert(fabricateJob, {}); + await waitFor( + () => + count("river_job") === + rows<{ n: number }>( + "SELECT count(*) AS n FROM river_job WHERE state = 'completed'" + )[0]?.n + ); + } finally { + await run.stop(); + } + + // The ordinary completion of the job completed in a transaction was + // stale, so it had no companion effect; the fabricated result was + // rejected, and the job completed through the background batch. + expect(secondCompletion).toBeInstanceOf(LifecycleError); + expect(fabricated).toBeInstanceOf(ExtensionError); + expect(notes()).toEqual([ + "completed pilot_job 1", + "completed pilot_tx_complete 1", + "completed pilot_fabricate 1", + ]); + }); + + test("shows the insert interceptor each row's arguments from before the argument transforms", async () => { + const seen: { original: readonly string[]; stored: readonly string[] }[] = + []; + const { client, database, host, rows } = await 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 }, + ]); + await transaction(database, async (tx) => { + await client.insert(job, { n: 4 }, { tx }); + }); + 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. + expect( + rows<{ args: string }>( + "SELECT json(args) AS args FROM river_job ORDER BY id" + ).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("SQLite caller transactions", () => { + const rollback = new Error("roll back"); + + /** Run `callback` in an application transaction, then roll it back. */ + const rolledBack = async ( + database: DatabaseSync, + callback: (tx: DatabaseSync) => Promise + ): Promise => { + await expect( + transaction(database, async (tx) => { + await callback(tx); + throw rollback; + }) + ).rejects.toBe(rollback); + }; + + test("leaves an insertion that fails after its write in the caller transaction", async () => { + let failMiddleware = false; + let failHook = false; + const { client, count, database } = await 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(count("river_job")).toBe(0); + + await rolledBack(database, 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(count("river_job")).toBe(3); + }); + expect(count("river_job")).toBe(0); + }); + + test("validates an insertion before writing any of it", async () => { + const { client, count, database, notes } = await setup(); + + await transaction(database, async (tx) => { + note(tx, "application"); + await expect( + client.insertMany( + [ + { args: {}, job }, + { args: {}, job, options: { priority: 99 } }, + ], + { tx } + ) + ).rejects.toBeInstanceOf(ValidationError); + expect(count("river_job")).toBe(0); + }); + expect(notes()).toEqual(["application"]); + }); + + test("keeps the caller transaction usable after a statement SQLite rejects", async () => { + const failing = defineJob({ kind: "pilot_failing" }); + const { client, count, database, notes } = await setup(); + database.exec( + `CREATE TRIGGER fail_insert BEFORE INSERT ON river_job + WHEN NEW.kind = 'pilot_failing' + BEGIN SELECT RAISE(ABORT, 'insert failed'); END` + ); + + // Unlike PostgreSQL, SQLite undoes only the failed statement, so the + // caller's transaction keeps River's earlier write and its own work, + // and the caller still rolls back on the error. + await rolledBack(database, async (tx) => { + note(tx, "application"); + await client.insert(job, {}, { tx }); + await expect(client.insert(failing, {}, { tx })).rejects.toBeInstanceOf( + DatabaseOperationError + ); + expect(notes()).toEqual(["application"]); + expect(count("river_job")).toBe(1); + }); + expect(notes()).toEqual([]); + expect(count("river_job")).toBe(0); + }); + + test("leaves a failed transactional completion in the caller transaction until it rolls back", async () => { + const txJob = defineJob({ kind: "pilot_tx_complete" }); + let fail = true; + let failure: unknown; + let inTransaction: string | undefined; + let notesInTransaction: unknown; + const { client, notes, rows } = await setup( + () => ({ + intercept: { + async complete(context, next) { + const results = await next(); + note(context.tx, `completed ${context.commands.length}`); + if (fail) throw new Error("complete companion failed"); + return results; + }, + }, + }), + (database) => ({ + completionBatchSize: 1, + leaderElectionDisabled: true, + pollOnly: true, + queues: { + default: { + fetchCooldown: { milliseconds: 1 }, + maxWorkers: 1, + pollInterval: { milliseconds: 5 }, + }, + }, + workers: new Workers().add(txJob, async ({ completeTx, job: row }) => { + const application = new DatabaseSync(database.location() ?? "", { + timeout: 0, + }); + try { + await expect( + transaction(application, async (tx) => { + failure = await completeTx(tx).catch((error: unknown) => error); + inTransaction = ( + tx + .prepare("SELECT state FROM river_job WHERE id = ?") + .get(row.id) as { state: string } | undefined + )?.state; + notesInTransaction = tx + .prepare("SELECT note FROM pilot_companion ORDER BY id") + .all() + .map((note) => note.note); + throw rollback; + }) + ).rejects.toBe(rollback); + } finally { + application.close(); + } + // 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, {}); + await waitFor( + () => + rows<{ state: string }>("SELECT state FROM river_job")[0]?.state === + "completed" + ); + } finally { + await run.stop(); + } + + expect(failure).toMatchObject({ + message: expect.stringContaining("complete companion failed"), + }); + expect(inTransaction).toBe("completed"); + expect(notesInTransaction).toEqual(["completed 1"]); + expect(notes()).toEqual(["completed 1"]); + }); +}); + +describe("SQLite prepared insertion", () => { + test("inserts stored rows like an ordinary insertion of them", async () => { + const calls: string[] = []; + const { host, rows } = await setup( + () => ({ + intercept: { + async insert(context, next) { + calls.push(`pilot ${context.operation}`); + return next(); + }, + }, + }), + { + hooks: { + afterInsert: () => { + calls.push("afterInsert"); + }, + beforeInsert: (context) => { + calls.push( + `beforeInsert ${context.operation} ${ + context.requests[0]?.definition === undefined + ? "without definition" + : "with definition" + }` + ); + }, + }, + insertMiddleware: [ + async (_context, next) => { + calls.push("middleware"); + return next(); + }, + ], + plugins: [ + createJobArgsTransformPlugin({ + name: "args", + onInsert: ({ args, definition, encodedArgs }) => { + calls.push( + `args transform ${encodedArgs} ${definition === undefined ? "without definition" : "with definition"}` + ); + // Keeping the stored arguments keeps their bytes. + return { args, encodedArgs }; + }, + onRead: ({ args }) => args, + }), + createJobInsertMetadataTransformPlugin({ + name: "metadata", + onInsert: ({ args, metadata }) => { + calls.push(`metadata transform ${JSON.stringify(args)}`); + return { metadata }; + }, + }), + ], + } + ); + const createdAt = Temporal.Instant.from("2020-01-02T03:04:05.678Z"); + const uniqueKey = new Uint8Array(32).fill(7); + const prepared: PreparedInsertParams = { + createdAt, + // Stored as given, in its own key order. + encodedArgs: '{"b":1,"a":2}', + kind: "stored_job", + 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]); + + // Everything an ordinary insertion runs, once each, on the stored + // arguments. + expect(calls).toEqual([ + 'metadata transform {"b":1,"a":2}', + 'args transform {"b":1,"a":2} without definition', + "middleware", + "beforeInsert insertMany without definition", + "pilot insertMany", + "afterInsert", + ]); + expect(result?.status).toBe("inserted"); + const [row] = rows>( + `SELECT json(args) AS args, created_at, json(metadata) AS metadata, + max_attempts, priority, unique_key + FROM river_job` + ); + expect(row?.args).toBe('{"b":1,"a":2}'); + expect(row?.created_at).toBe("2020-01-02 03:04:05.678"); + expect(row?.max_attempts).toBe(3); + expect(row?.priority).toBe(2); + expect(new Uint8Array(row?.unique_key as Uint8Array)).toEqual(uniqueKey); + const metadata = JSON.parse(row?.metadata as string) as Record< + string, + unknown + >; + delete metadata["river:unique_nonce"]; + expect(metadata).toEqual({ stored: true }); + expect( + rows<{ payload: string }>("SELECT payload FROM river_notification") + ).toEqual([{ payload: '{"queue": "default"}' }]); + + // The same unique key is now a duplicate. + const [duplicate] = await host.insertPrepared([prepared]); + expect(duplicate?.status).toBe("duplicate"); + }); + + test("stores what the insert transforms return for a stored row", async () => { + const { host, rows } = await setup(() => ({}), { + plugins: [ + createJobArgsTransformPlugin({ + name: "wrap", + // Wraps plain arguments, and keeps arguments it already wrapped. + onInsert: ({ args, encodedArgs }) => + "wrapped" in args + ? { args, encodedArgs } + : { + args: { wrapped: args }, + encodedArgs: `{"wrapped":${encodedArgs}}`, + }, + onRead: ({ args }) => args, + }), + createJobInsertMetadataTransformPlugin({ + name: "metadata", + onInsert: ({ metadata }) => ({ + metadata: { ...metadata, reinserted: true }, + }), + }), + ], + }); + const stored = ( + encodedArgs: string, + kind: string + ): PreparedInsertParams => ({ + encodedArgs, + kind, + maxAttempts: 25, + metadata: { stored: true }, + priority: 1, + queue: "default", + state: "available", + tags: [], + uniqueKey: null, + uniqueStates: null, + }); + + await host.insertPrepared([ + stored('{"wrapped":{"y":1,"x":1}}', "already_wrapped"), + stored('{"x":2}', "plain"), + ]); + + const inserted = rows<{ args: string; kind: string; metadata: string }>( + "SELECT json(args) AS args, kind, json(metadata) AS metadata FROM river_job ORDER BY id" + ); + expect(inserted.map(({ args, kind }) => ({ args, kind }))).toEqual([ + // As stored, keys in their stored order. + { args: '{"wrapped":{"y":1,"x":1}}', kind: "already_wrapped" }, + { args: '{"wrapped":{"x":2}}', kind: "plain" }, + ]); + for (const { metadata } of inserted) { + expect(JSON.parse(metadata)).toMatchObject({ + reinserted: true, + stored: true, + }); + } + }); + + test("keeps stored arguments that aren't an object, and pending states", async () => { + const argsTransformed: string[] = []; + const metadataSeen: string[] = []; + const hooked: string[] = []; + const { host, rows } = await setup(() => ({}), { + hooks: { + beforeInsert: (context) => { + for (const request of context.requests) { + hooked.push(`${request.kind} ${JSON.stringify(request.args)}`); + } + }, + }, + plugins: [ + createJobArgsTransformPlugin({ + name: "args", + onInsert: ({ args, encodedArgs, kind }) => { + argsTransformed.push(kind); + return { args, encodedArgs }; + }, + onRead: ({ args }) => args, + }), + createJobInsertMetadataTransformPlugin({ + name: "metadata", + onInsert: ({ args, kind, metadata, pending }) => { + metadataSeen.push(`${kind} ${JSON.stringify(args)} ${pending}`); + return kind === "made_pending" + ? { metadata, pending: true } + : { metadata }; + }, + }), + ], + }); + const stored = ( + kind: string, + encodedArgs: string, + state: PreparedInsertParams["state"] = "available" + ): PreparedInsertParams => ({ + encodedArgs, + kind, + maxAttempts: 25, + metadata: {}, + priority: 1, + queue: "default", + state, + tags: [], + uniqueKey: null, + uniqueStates: null, + }); + + await host.insertPrepared([ + // Another River client's job whose arguments are a JSON array. + stored("array_args", '[1,"a"]'), + stored("stored_pending", "{}", "pending"), + stored("made_pending", "{}"), + ]); + + expect( + rows<{ args: string; kind: string; state: string }>( + "SELECT json(args) AS args, kind, state FROM river_job ORDER BY id" + ) + ).toEqual([ + { args: '[1,"a"]', kind: "array_args", state: "available" }, + { args: "{}", kind: "stored_pending", state: "pending" }, + { args: "{}", kind: "made_pending", state: "pending" }, + ]); + // Argument transforms take only objects; the rest see empty arguments. + expect(argsTransformed).toEqual(["stored_pending", "made_pending"]); + expect(metadataSeen).toEqual([ + "array_args {} false", + "stored_pending {} true", + "made_pending {} false", + ]); + expect(hooked).toEqual([ + "array_args {}", + "stored_pending {}", + "made_pending {}", + ]); + }); + + test("validates prepared rows and joins a supplied transaction", async () => { + const { count, database, host } = await setup(); + const prepared: PreparedInsertParams = { + encodedArgs: "{}", + kind: "stored_job", + maxAttempts: 25, + metadata: {}, + priority: 1, + queue: "default", + scheduledAt: Temporal.Now.instant(), + state: "available", + tags: [], + uniqueKey: null, + uniqueStates: null, + }; + + for (const [invalid, message] of [ + [{ ...prepared, priority: 0 }, "priority must be an integer from 1 to 4"], + [ + { ...prepared, encodedArgs: "{not json" }, + "encodedArgs must be JSON text", + ], + // Arguments come from `encodedArgs` alone. + [{ ...prepared, args: {} }, "args must be omitted"], + ] as const) { + await expect( + host.insertPrepared([invalid as PreparedInsertParams]) + ).rejects.toThrow(`prepared job 0 ${message}`); + } + await expect(host.insertPrepared([])).resolves.toEqual([]); + await expect( + transaction(database, async (tx) => { + await host.insertPrepared([prepared], { tx }); + throw new Error("roll back"); + }) + ).rejects.toThrow("roll back"); + expect(count("river_job")).toBe(0); + }); +}); + +describe("SQLite producer configuration", () => { + test("hands the session its queue metadata as stored", async () => { + const texts: string[] = []; + const { client, database } = await setup( + () => ({ + startProducer: (context) => { + texts.push(`start ${context.metadataText}`); + return Promise.resolve({ + configurationChanged: (configuration) => { + texts.push(`changed ${configuration.metadataText}`); + }, + }); + }, + }), + { + leaderElectionDisabled: true, + pollOnly: true, + queueControlPollInterval: { milliseconds: 10 }, + queues: { default: { maxWorkers: 1 } }, + workers: new Workers().add(job, () => undefined), + } + ); + // River stamps its own writes with the client's clock, so a change a + // few milliseconds later by SQLite's clock could read as older. + const store = (metadata: string, updatedAt = "+0 seconds") => + database + .prepare( + `INSERT INTO river_queue (name, metadata, created_at, updated_at) + VALUES ('default', jsonb(?), datetime('now', 'subsec'), + datetime('now', ?, 'subsec')) + ON CONFLICT (name) DO UPDATE SET metadata = excluded.metadata, + updated_at = excluded.updated_at` + ) + .run(metadata, updatedAt); + // Number literals River's parsed metadata folds into plain numbers. + store('{"retries":1.0,"scale":1e2}'); + + const run = await client.start(); + await waitFor(() => texts.length === 1); + // The same values, written differently, are offered too. + store('{"retries":1,"scale":100}', "+1 seconds"); + await waitFor(() => texts.length === 2); + await run.stop(); + + expect(texts).toEqual([ + 'start {"retries":1.0,"scale":1e2}', + 'changed {"retries":1,"scale":100}', + ]); + }); +}); + +describe("SQLite producer metadata notifications", () => { + test("offers a queue's metadata change as its notification arrives", async () => { + const texts: string[] = []; + const { client, database } = await setup( + () => ({ + startProducer: () => + Promise.resolve({ + configurationChanged: (configuration) => { + texts.push(configuration.metadataText); + }, + }), + }), + { + leaderElectionDisabled: true, + // Only the notification can deliver the change in time. + queueControlPollInterval: { hours: 1 }, + queues: { default: { maxWorkers: 1 } }, + workers: new Workers().add(job, () => undefined), + } + ); + const run = await client.start(); + // Another client changes the queue's metadata. + const other = testSqliteDriver(database, STRICT); + onTestFinished(() => other.close()); + await new Client(other).queues.update("default", { + metadata: { retries: 2 }, + }); + + await waitFor(() => texts.length === 1); + await run.stop(); + + expect(texts).toEqual(['{"retries":2}']); + }); +}); + +describe("SQLite producer sessions", () => { + test("claims through River's claim or its own statement in a pilot transaction", async () => { + const finished: bigint[] = []; + const calls: string[] = []; + const worked: bigint[] = []; + const { client, count, rows } = await setup( + (database) => ({ + startProducer: (context) => { + calls.push(`start ${context.queue.name}`); + return Promise.resolve({ + claim: async (claimContext, next) => { + const own = claimContext.queue === "own"; + const result = await database.transaction(async (tx) => { + if (!own) return next({ tx }); + // Select and claim like a companion would, then read the + // claimed rows back with River's decoder. + const ids = handle(tx) + .prepare( + `UPDATE river_job + SET state = 'running', attempt = attempt + 1, + attempted_at = datetime('now', 'subsec'), + attempted_by = jsonb(json_array(?)) + WHERE id IN ( + SELECT id FROM river_job + WHERE queue = 'own' AND state = 'available' + ORDER BY id LIMIT ? + ) + RETURNING id` + ) + .all(claimContext.attemptedBy, claimContext.limit) + .map((row) => BigInt(row.id as number)); + note(tx, `claimed ${ids.length}`); + return database.loadClaimed(ids, { tx }); + }); + return result; + }, + jobFinished: (row) => { + finished.push(row.id); + }, + shutdown: () => { + calls.push(`shutdown ${context.queue.name}`); + return Promise.resolve(); + }, + }); + }, + }), + { + leaderElectionDisabled: true, + pollOnly: true, + queues: { + default: { + fetchCooldown: { milliseconds: 1 }, + maxWorkers: 2, + pollInterval: { milliseconds: 5 }, + }, + own: { + fetchCooldown: { milliseconds: 1 }, + maxWorkers: 2, + pollInterval: { milliseconds: 5 }, + }, + }, + workers: new Workers().add(job, ({ job: row }) => { + worked.push(row.id); + }), + } + ); + const inserted = await client.insertMany([ + { args: {}, job }, + { args: {}, job }, + { args: {}, job, options: { queue: "own" } }, + { args: {}, job, options: { queue: "own" } }, + ]); + + const run = await client.start(); + await waitFor(() => finished.length === 4); + await run.stop(); + + const ids = inserted.map(({ job: row }) => row.id).sort(); + expect([...worked].sort()).toEqual(ids); + expect([...finished].sort()).toEqual(ids); + expect(calls.sort()).toEqual([ + "shutdown default", + "shutdown own", + "start default", + "start own", + ]); + expect(count("river_job")).toBe(4); + expect( + rows<{ n: number }>( + "SELECT count(*) AS n FROM pilot_companion WHERE note LIKE 'claimed %'" + )[0]?.n + ).toBeGreaterThan(0); + }); +}); + +describe("SQLite claim-time cancellation", () => { + test("cancels a job whose cancellation arrives while its claim is in flight", async () => { + 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, rows } = await 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; + }, + }), + }), + { + pollOnly: true, + queues: { + default: { + fetchCooldown: { milliseconds: 1 }, + maxWorkers: 1, + pollInterval: { milliseconds: 5 }, + }, + }, + workers: new Workers().add(job, ({ signal }) => { + startedCancelled = signal.aborted; + signal.throwIfAborted(); + }), + } + ); + const inserted = await client.insert(job, {}); + + const run = await client.start(); + await claimed.promise; + await client.jobs.cancel(inserted.job.id); + release.resolve(undefined); + await waitFor( + () => + rows<{ state: string }>("SELECT state FROM river_job")[0]?.state === + "cancelled" + ); + await run.stop(); + + expect(startedCancelled).toBe(true); + }); +}); + +describe("SQLite peer attempts", () => { + test("claims peers with a companion statement and completes each once", async () => { + let attempts: PilotHost["attempts"] | undefined; + let database: PilotDatabase | undefined; + const rejected: string[] = []; + const worked: bigint[] = []; + /** Claim every available job of the `peers` queue in `tx`. */ + const claimPeers = (tx: Transaction, attemptedBy: string) => { + const ids = handle(tx) + .prepare( + `UPDATE river_job + SET state = 'running', attempt = attempt + 1, + attempted_at = datetime('now', 'subsec'), + attempted_by = jsonb(json_array(?)) + WHERE id IN ( + SELECT id FROM river_job + WHERE queue = 'peers' AND state = 'available' + ORDER BY id + ) + RETURNING id` + ) + .all(attemptedBy) + .map((row) => BigInt(row.id as number)); + note(tx, `claimed ${ids.length}`); + return ids.toSorted((left, right) => (left < right ? -1 : 1)); + }; + const { client, notes, rows } = await setup( + (pilotDatabase) => { + database = pilotDatabase; + return { + init(host) { + attempts = host.attempts; + }, + }; + }, + { + pollOnly: true, + queues: { + default: { + fetchCooldown: { milliseconds: 1 }, + maxWorkers: 1, + pollInterval: { milliseconds: 5 }, + }, + }, + workers: new Workers().add(job, async (context) => { + if (attempts === undefined || database === undefined) { + throw new Error("the pilot wasn't initialized"); + } + const peerDatabase = database; + worked.push(context.job.id); + // A claim returning the attempt's own job rolls back entirely. + await attempts + .claim(context, async ({ tx }) => { + const ids = claimPeers(tx, context.execution.attemptedBy); + return peerDatabase.loadClaimed([...ids, context.job.id], { + tx, + }); + }) + .catch((error: unknown) => { + expect(error).toBeInstanceOf(ExtensionError); + rejected.push((error as Error).message); + }); + const peers = await attempts.claim(context, async ({ tx }) => + peerDatabase.loadClaimed( + claimPeers(tx, context.execution.attemptedBy), + { tx } + ) + ); + // The first peer completes; the attempt leaves the second without + // an outcome. + await attempts.complete(context, [ + { job: peers[0] as JobRow, result: { status: "succeeded" } }, + ]); + }), + } + ); + const inserted = await client.insertMany([ + { args: {}, job, options: { queue: "peers" } }, + { args: {}, job, options: { queue: "peers" } }, + ]); + const [first, second] = inserted.map(({ job: row }) => row.id); + + const run = await client.start(); + const coordinator = await client.insert(job, {}); + await waitFor( + () => + rows<{ state: string }>( + `SELECT state FROM river_job WHERE id = ${coordinator.job.id}` + )[0]?.state === "completed" + ); + await run.stop(); + + expect(worked).toEqual([coordinator.job.id]); + expect(rejected).toEqual([ + `a peer claim returned job ${coordinator.job.id}, the claiming attempt's own job`, + ]); + // Only the committed claim's note remains. + expect(notes()).toEqual(["claimed 2"]); + const state = (id: bigint | undefined) => + rows<{ attempt: number; errors: string | null; state: string }>( + `SELECT attempt, json(errors) AS errors, state FROM river_job WHERE id = ${id}` + )[0]; + expect(state(first)).toMatchObject({ attempt: 1, state: "completed" }); + // Failed, and due again at once, so available. + expect(state(second)).toMatchObject({ attempt: 1, state: "available" }); + expect(state(second)?.errors).toContain( + "ended without an outcome for this job" + ); + }); + + /** + * Claim every available job of the `peers` queue for `attemptedBy`, in + * `tx`, with the pilot database's `loadClaimed`. + */ + const claimAllPeers = ( + database: PilotDatabase, + tx: Transaction, + attemptedBy: string + ) => { + const ids = handle(tx) + .prepare( + `UPDATE river_job + SET state = 'running', attempt = attempt + 1, + attempted_at = datetime('now', 'subsec'), + attempted_by = jsonb(json_array(?)) + WHERE queue = 'peers' AND state = 'available' + RETURNING id` + ) + .all(attemptedBy) + .map((row) => BigInt(row.id as number)); + return database.loadClaimed(ids, { tx }); + }; + + test("keeps a coordinator's peer claims open through a graceful stop", async () => { + let attempts: PilotHost["attempts"] | undefined; + let database: PilotDatabase | undefined; + const running = Promise.withResolvers(); + const gate = Promise.withResolvers(); + const { client, rows } = await setup( + (pilotDatabase) => { + database = pilotDatabase; + return { + init(host) { + attempts = host.attempts; + }, + }; + }, + { + pollOnly: true, + queues: { + default: { + fetchCooldown: { milliseconds: 1 }, + maxWorkers: 1, + pollInterval: { milliseconds: 5 }, + }, + }, + workers: new Workers().add(job, async (context) => { + if (attempts === undefined || database === undefined) { + throw new Error("the pilot wasn't initialized"); + } + const peerDatabase = database; + running.resolve(undefined); + await gate.promise; + const peers = await attempts.claim(context, async ({ tx }) => + claimAllPeers(peerDatabase, tx, context.execution.attemptedBy) + ); + await attempts.complete( + context, + peers.map((peer) => ({ + job: peer, + result: { status: "succeeded" }, + })) + ); + }), + } + ); + await client.insertMany([ + { args: {}, job, options: { queue: "peers" } }, + { args: {}, job, options: { queue: "peers" } }, + ]); + + const run = await client.start(); + await client.insert(job, {}); + 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( + rows<{ attempt: number; queue: string; state: string }>( + "SELECT attempt, queue, state FROM river_job ORDER BY id" + ) + ).toEqual([ + { attempt: 1, queue: "peers", state: "completed" }, + { attempt: 1, queue: "peers", state: "completed" }, + { attempt: 1, queue: "default", state: "completed" }, + ]); + }); + + test("refuses peer claims once a stop or cancellation cancels the coordinator", async () => { + for (const cancellation of ["cancelling stop", "job cancellation"]) { + let attempts: PilotHost["attempts"] | undefined; + let database: PilotDatabase | undefined; + const running = Promise.withResolvers(); + const refused = Promise.withResolvers(); + const { client, rows } = await setup( + (pilotDatabase) => { + database = pilotDatabase; + return { + init(host) { + attempts = host.attempts; + }, + }; + }, + { + pollOnly: true, + queues: { + default: { + fetchCooldown: { milliseconds: 1 }, + maxWorkers: 1, + pollInterval: { milliseconds: 5 }, + }, + }, + workers: new Workers().add(job, async (context) => { + if (attempts === undefined || database === undefined) { + throw new Error("the pilot wasn't initialized"); + } + const peerDatabase = database; + running.resolve(undefined); + await new Promise((resolve) => { + context.signal.addEventListener("abort", resolve, { once: true }); + }); + refused.resolve( + await attempts + .claim(context, async ({ tx }) => + claimAllPeers(peerDatabase, tx, context.execution.attemptedBy) + ) + .then( + () => undefined, + (error: unknown) => error + ) + ); + }), + } + ); + await client.insert(job, {}, { queue: "peers" }); + + const run = await client.start(); + const coordinator = await client.insert(job, {}); + 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 + ); + await run.stop(); + } + + // The refused claim left the peer untouched. + expect( + rows<{ attempt: number; state: string }>( + "SELECT attempt, state FROM river_job WHERE queue = 'peers'" + ) + ).toEqual([{ attempt: 0, state: "available" }]); + } + }); +}); + +describe("SQLite pilot services", () => { + test("runs services, term services, and the pilot's periodic job store", async () => { + const calls: string[] = []; + const upserted: string[] = []; + const { client, count, notes } = await setup( + () => ({ + maintenanceServices: () => [ + { + name: "leader_only", + run: ({ signal, term }) => { + calls.push(`term ${term.leaderId}`); + return new Promise((resolve) => { + signal.addEventListener("abort", () => { + calls.push("term ended"); + resolve(); + }); + }); + }, + }, + ], + periodicJobs: { + getAll: () => { + calls.push("periodic getAll"); + return Promise.resolve([]); + }, + keepAliveAndReap: () => Promise.resolve(), + upsertMany: (tx, jobs) => { + // A native handle in the transaction inserting the jobs. + note(tx, "periodic upsert"); + upserted.push(...jobs.map(({ id }) => id)); + return Promise.resolve(); + }, + }, + services: () => [ + { + name: "always", + run: ({ signal }) => { + calls.push("service"); + return new Promise((resolve) => { + signal.addEventListener("abort", () => { + calls.push("service ended"); + resolve(); + }); + }); + }, + }, + ], + }), + { + clientId: "services-leader", + maintenance: { electionInterval: { milliseconds: 20 } }, + periodicJobs: [ + periodicJob({ + args: {}, + every: { hours: 1 }, + id: "hourly", + job, + runOnStart: true, + }), + ], + pollOnly: true, + queues: { default: { maxWorkers: 1 } }, + workers: new Workers().add(job, () => undefined), + } + ); + + const run = await client.start(); + await waitFor( + () => + calls.includes("term services-leader") && + calls.includes("periodic getAll") && + upserted.includes("hourly") + ); + await run.stop(); + + expect(notes()).toContain("periodic upsert"); + expect(count("river_job")).toBeGreaterThan(0); + expect(calls[0]).toBe("service"); + expect(calls).toContain("term ended"); + expect(calls).toContain("service ended"); + }); + + test("commits a durable periodic batch with its next-run times, or neither", async () => { + let failUpsert = true; + const { client, count, database } = await setup( + () => ({ + periodicJobs: { + getAll: () => Promise.resolve([]), + keepAliveAndReap: () => Promise.resolve(), + upsertMany: async (tx, jobs) => { + await Promise.resolve(); + for (const periodic of jobs) { + handle(tx) + .prepare( + "INSERT OR REPLACE INTO periodic_state (id, next_run_at) VALUES (?, ?)" + ) + .run(periodic.id, periodic.nextRunAt.toString()); + } + if (failUpsert) { + failUpsert = false; + throw new Error("store failed after its write"); + } + }, + }, + }), + { + periodicJobs: [ + periodicJob({ + args: {}, + every: { seconds: 1 }, + id: "durable", + job, + runOnStart: true, + }), + ], + queues: { default: { maxWorkers: 1 } }, + workers: new Workers().add(job, () => undefined), + } + ); + database.exec( + "CREATE TABLE periodic_state (id text PRIMARY KEY, next_run_at text)" + ); + + const run = await client.start(); + onTestFinished(() => run.stop()); + await waitFor(() => !failUpsert); + // Well before the next occurrence, the failed batch has left neither its + // job nor its next run behind. + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(count("river_job")).toBe(0); + expect(count("periodic_state")).toBe(0); + + await waitFor(() => count("river_job") > 0); + expect(count("river_job")).toBe(1); + expect(count("periodic_state")).toBe(1); + await run.stop(); + }); +}); + +describe("PilotClient", () => { + test("is a Client whose pilot sees the final client after construction", async () => { + let initClient: unknown; + let initInsert: Promise | undefined; + const { client, host } = await setup(() => ({ + init(pilotHost) { + initClient = pilotHost.client; + initInsert = pilotHost.client + .insert(job, {}) + .catch((error: unknown) => error); + }, + })); + + expect(client).toBeInstanceOf(Client); + expect(initClient).toBe(client); + expect(host.client).toBe(client); + expect(host.clientId).toMatch(/^riverqueue-js-/); + expect(host.workerKinds).toEqual([]); + expect(host.producerReportInterval.total("seconds")).toBe(30); + await expect(initInsert).resolves.toBeInstanceOf(LifecycleError); + await expect(client.insert(job, {})).resolves.toMatchObject({ + status: "inserted", + }); + }); + + test("types the host's client and queue options as the companion's", () => { + const driver = testSqliteMemory(STRICT); + onTestFinished(() => { + driver.close(); + }); + class TypedClient extends PilotClient { + readonly companion = "companion"; + } + let captured: PilotHost | undefined; + const pilot = (): Pilot => ({ + init(host) { + captured = host; + }, + queueOptions: { keys: ["limit"], parse: (_queue, config) => config }, + }); + + const client = new TypedClient( + driver, + { queues: { limited: { limit: 1, maxWorkers: 1 } } }, + pilot + ); + expect( + () => + new TypedClient( + driver, + // @ts-expect-error A queue key neither River nor the pilot owns. + { queues: { limited: { maxWorkers: 1, other: 1 } } }, + pilot + ) + ).toThrow(ValidationError); + + expect(captured?.client).toBe(client); + expect(captured?.client.companion).toBe("companion"); + }); + + test("can't construct PilotClient itself", () => { + const driver = testSqliteMemory(STRICT); + onTestFinished(() => { + driver.close(); + }); + const Abstract = PilotClient as unknown as new ( + ...args: unknown[] + ) => unknown; + + expect(() => new Abstract(driver, {}, () => ({}))).toThrow(TypeError); + }); + + test("ignores an extra constructor argument on a plain Client", () => { + const driver = testSqliteMemory(STRICT); + onTestFinished(() => { + driver.close(); + }); + let created = false; + const client = new ( + Client as unknown as new (...args: unknown[]) => Client + )(driver, {}, () => { + created = true; + return {}; + }); + + expect(client).toBeInstanceOf(Client); + expect(created).toBe(false); + }); + + test("rejects pilots River can't attach", async () => { + const driver = testSqliteMemory(STRICT); + onTestFinished(() => { + driver.close(); + }); + const construct = ( + createPilot: (database: PilotDatabase) => unknown, + options: ClientOptions = {} + ) => + new CompanionClient( + driver, + options, + createPilot as () => Pilot + ); + const shared: Pilot = {}; + construct(() => shared); + + for (const createPilot of [ + () => shared, + () => Promise.resolve({}), + () => null, + () => ({ init: () => Promise.resolve() }), + () => ({ intercept: { claim: () => undefined } }), + () => ({ intercept: { insert: true } }), + () => ({ queueOptions: { keys: ["maxWorkers"], parse: () => 1 } }), + () => ({ queueOptions: { keys: ["a", "a"], parse: () => 1 } }), + () => ({ queueOptions: { keys: [""], parse: () => 1 } }), + () => ({ queueOptions: { keys: ["limitMs"], parse: () => 1 } }), + ]) { + expect(() => construct(createPilot)).toThrow(ConfigurationError); + } + expect( + () => + new CompanionClient( + { + jobInsert: () => undefined, + jobInsertMany: () => undefined, + } as never, + {}, + () => ({}) + ) + ).toThrow(ConfigurationError); + }); + + test("rejects queue keys nothing owns", async () => { + const driver = testSqliteMemory(STRICT); + onTestFinished(() => { + driver.close(); + }); + + expect( + () => + new Client(driver, { + queues: { default: { limit: 1, maxWorkers: 1 } as QueueConfig }, + }) + ).toThrow(ValidationError); + expect( + () => + new CompanionClient( + driver, + { queues: { default: { other: 1, maxWorkers: 1 } as QueueConfig } }, + () => ({ queueOptions: { keys: ["limit"], parse: () => 1 } }) + ) + ).toThrow('queue "default" has no option "other"'); + }); + + test("rejects queue keys River doesn't know when adding or updating a queue", async () => { + const { driver } = await setup(); + // An ordinary client, without a pilot. + const client = new Client(driver, { + leaderElectionDisabled: true, + pollOnly: true, + queues: { default: { maxWorkers: 1 } }, + workers: new Workers().add(job, () => undefined), + }); + const run = await client.start(); + try { + await expect( + run.addQueue("added", { maxWorkers: 1, other: 1 } as QueueConfig) + ).rejects.toThrow('queue "added" has no option "other"'); + await expect( + run.updateQueue("default", { maxWorkers: 2, other: 1 } as QueueConfig) + ).rejects.toThrow('queue "default" has no option "other"'); + expect(Object.keys(run.diagnostics.queues)).toEqual(["default"]); + expect(run.diagnostics.queues.default?.maxWorkers).toBe(1); + } finally { + await run.stop(); + } + }); + + test("parses pilot-owned queue keys before River changes anything", async () => { + const parsed: [string, Readonly>][] = []; + const { client } = await setup( + () => ({ + queueOptions: { + keys: ["limit"], + parse(queue: string, config: Readonly>) { + parsed.push([queue, config]); + if (config.limit === -1) throw new ValidationError("bad limit"); + if (config.limit === -2) return Promise.resolve(1); + return { limit: config.limit ?? null }; + }, + }, + }), + { + leaderElectionDisabled: true, + pollOnly: true, + queues: { + default: { limit: 2, maxWorkers: 2 } as QueueConfig, + other: { maxWorkers: 1 }, + }, + workers: new Workers().add(job, () => undefined), + } + ); + expect(parsed).toEqual([ + ["default", { limit: 2 }], + ["other", {}], + ]); + + const run = await client.start(); + try { + await run.addQueue("added", { limit: 3, maxWorkers: 1 }); + await expect( + run.addQueue("rejected", { limit: -1, maxWorkers: 1 }) + ).rejects.toThrow("bad limit"); + await expect( + run.addQueue("async", { limit: -2, maxWorkers: 1 }) + ).rejects.toBeInstanceOf(ConfigurationError); + await expect( + run.addQueue("unknown", { + maxWorkers: 1, + other: 1, + } as CompanionQueueConfig) + ).rejects.toBeInstanceOf(ValidationError); + await expect( + run.updateQueue("default", { limit: -1, maxWorkers: 7 }) + ).rejects.toThrow("bad limit"); + + expect(Object.keys(run.diagnostics.queues).sort()).toEqual([ + "added", + "default", + "other", + ]); + expect(run.diagnostics.queues.default?.maxWorkers).toBe(2); + await run.updateQueue("default", { limit: 4, maxWorkers: 7 }); + expect(run.diagnostics.queues.default?.maxWorkers).toBe(7); + } finally { + await run.stop(); + } + expect(parsed.slice(2)).toEqual([ + ["added", { limit: 3 }], + ["rejected", { limit: -1 }], + ["async", { limit: -2 }], + ["default", { limit: -1 }], + ["default", { limit: 4 }], + ]); + }); + + test("wakes producers for jobs another owner committed", async () => { + const worked: bigint[] = []; + const emptyClaim = Promise.withResolvers(); + const { client, host } = await setup(() => ({}), { + hooks: { + onMetric: (metric) => { + if (metric.name === "job_get_available_count" && metric.count === 0) { + emptyClaim.resolve(undefined); + } + }, + }, + leaderElectionDisabled: true, + pollOnly: true, + queues: { + default: { maxWorkers: 1, pollInterval: { minutes: 1 } }, + }, + workers: new Workers().add(job, ({ job: row }) => { + worked.push(row.id); + }), + }); + host.notifyCommitted([]); + const run = await client.start(); + try { + // Once the producer found the queue empty it waits a minute to poll. + await emptyClaim.promise; + const inserted = await client.insert(job, {}); + host.notifyCommitted([inserted]); + await waitFor(() => worked.includes(inserted.job.id)); + } finally { + await run.stop(); + } + }); +}); + +async function migrate(database: DatabaseSync): Promise { + const moduleUrl = new URL("../../../migrate/dist/index.js", import.meta.url); + const migrationModule = (await import(moduleUrl.href)) as { + createMigrator(target: { database: DatabaseSync }): { + migrateUp(): Promise; + }; + }; + await migrationModule.createMigrator({ database }).migrateUp(); +} + +async function waitFor(condition: () => boolean): Promise { + const deadline = performance.now() + 3_000; + while (!condition()) { + if (performance.now() > deadline) { + throw new Error("condition was not reached"); + } + await new Promise((resolve) => setTimeout(resolve, 1)); + } +} diff --git a/js/driver/sqlite/src/runtime.test.ts b/js/driver/sqlite/src/runtime.test.ts new file mode 100644 index 000000000..2e3e14ba1 --- /dev/null +++ b/js/driver/sqlite/src/runtime.test.ts @@ -0,0 +1,678 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; + +import { + Client, + Workers, + complete, + defineJob, + type JsonObject, +} from "riverqueue"; +import { describe, expect, onTestFinished, test } from "vitest"; +import { z } from "zod"; + +import { + SQLITE_DRIVER_TEST_HOOKS, + type SqliteRuntime, + testSqliteDriver, + testSqliteMemory, +} from "./driver.js"; +import { transaction } from "./scope.js"; +import type { SqliteDriverOptions } from "./types.js"; + +/** River's own tests fail any lock window that crosses the event loop. */ +const STRICT = { + [SQLITE_DRIVER_TEST_HOOKS]: { strictLockWindow: true }, +} as SqliteDriverOptions; + +describe("SqliteDriver runtime", () => { + test("runs the testing guide's whole-runtime example", async () => { + // Mirrors "The whole runtime" in docs/testing.md, which the snippet check + // only typechecks; keep the two in sync. + const chargeCard = defineJob<{ amountCents: number }>()({ + kind: "charge_card", + }); + const workers = new Workers().add(chargeCard, () => undefined); + using driver = testSqliteMemory(); + await migrate(driver.database); + 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(); + }); + + test("completes an exact attempt in a caller transaction", async () => { + const { database, driver } = await setup(); + const definition = defineJob({ kind: "sqlite_runtime_complete_tx" }); + const observed: string[] = []; + const workers = new Workers().add(definition, async (context) => { + await transaction(database, async (tx) => { + await context.completeTx(tx, { output: { committed: true } }); + }); + }); + const client = new Client(driver, { + clientId: "sqlite-runtime-tx-test", + completionBatchSize: 1, + hooks: { + onEvent: ({ kind }) => { + observed.push(kind); + }, + }, + queues: { + default: { + fetchCooldown: { milliseconds: 1 }, + maxWorkers: 1, + pollInterval: { milliseconds: 5 }, + }, + }, + workers, + }); + const events = client.subscribe({ kinds: ["job_completed"] }); + const inserted = await client.insert(definition, {}); + const run = await client.start(); + + const completed = await waitForJob(client, inserted.job.id, "completed"); + expect(completed.metadata.output).toEqual({ committed: true }); + expect((await events.next()).value).toMatchObject({ + job: { id: inserted.job.id, state: "completed" }, + kind: "job_completed", + }); + await waitUntil(() => observed.includes("job_completed")); + + await run.stop(); + events.close(); + expect(observed.filter((kind) => kind === "job_completed")).toHaveLength(1); + expect(observed).not.toContain("job_race"); + }); + + test("a committed transactional completion overrides a later handler error", async () => { + const { database, driver } = await setup(); + const definition = defineJob({ kind: "sqlite_runtime_complete_tx_error" }); + const observed: string[] = []; + const workStatuses: string[] = []; + let errorHandlerCalls = 0; + const workers = new Workers().add(definition, async (context) => { + await transaction(database, async (tx) => { + await context.completeTx(tx, { output: { committed: true } }); + }); + throw new Error("after commit"); + }); + const client = new Client(driver, { + clientId: "sqlite-runtime-tx-error-test", + completionBatchSize: 1, + errorHandler: () => { + errorHandlerCalls++; + }, + hooks: { + onEvent: ({ kind }) => { + observed.push(kind); + }, + afterWork: (_context, result) => { + workStatuses.push(result.status); + }, + }, + queues: { + default: { + fetchCooldown: { milliseconds: 1 }, + maxWorkers: 1, + pollInterval: { milliseconds: 5 }, + }, + }, + workers, + }); + const inserted = await client.insert(definition, {}); + const run = await client.start(); + + const completed = await waitForJob(client, inserted.job.id, "completed"); + expect(completed.metadata.output).toEqual({ committed: true }); + await waitUntil(() => observed.includes("job_completed")); + + await run.stop(); + expect(errorHandlerCalls).toBe(1); + expect(workStatuses).toEqual(["failed"]); + expect(observed.filter((kind) => kind === "job_completed")).toHaveLength(1); + expect(observed).not.toContain("job_failed"); + expect(observed).not.toContain("job_race"); + }); + + test("a rolled-back transactional completion falls back to normal completion", async () => { + const { database, driver } = await setup(); + const definition = defineJob({ + kind: "sqlite_runtime_complete_tx_rollback", + }); + const observed: string[] = []; + const rollback = new Error("roll back"); + const workers = new Workers().add(definition, async (context) => { + try { + await transaction(database, async (tx) => { + await context.completeTx(tx, { output: { rolledBack: true } }); + throw rollback; + }); + } catch (error: unknown) { + if (error !== rollback) throw error; + } + }); + const client = new Client(driver, { + clientId: "sqlite-runtime-tx-rollback-test", + completionBatchSize: 1, + hooks: { + onEvent: ({ kind }) => { + observed.push(kind); + }, + }, + queues: { + default: { + fetchCooldown: { milliseconds: 1 }, + maxWorkers: 1, + pollInterval: { milliseconds: 5 }, + }, + }, + workers, + }); + const inserted = await client.insert(definition, {}); + const run = await client.start(); + + const completed = await waitForJob(client, inserted.job.id, "completed"); + expect(completed.metadata).not.toHaveProperty("output"); + await waitUntil(() => observed.includes("job_completed")); + + await run.stop(); + expect(observed.filter((kind) => kind === "job_completed")).toHaveLength(1); + expect(observed).not.toContain("job_race"); + }); + + test("works and completes an exact job through the common runtime", async () => { + const { driver } = await setup(); + const definition = defineJob({ + kind: "sqlite_runtime_complete", + schema: z.object({ value: z.number() }), + }); + const workers = new Workers().add(definition, ({ job }) => + complete({ output: { doubled: job.args.value * 2 } }) + ); + const client = new Client(driver, { + clientId: "sqlite-runtime-test", + completionFlushInterval: { milliseconds: 1 }, + queues: { + default: { + fetchCooldown: { milliseconds: 1 }, + maxWorkers: 1, + pollInterval: { milliseconds: 5 }, + }, + }, + workers, + }); + const run = await client.start(); + const inserted = await client.insert(definition, { value: 21 }); + + const completed = await waitForJob(client, inserted.job.id, "completed"); + expect(completed.attempt).toBe(1); + expect(completed.attemptedBy).toEqual(["sqlite-runtime-test"]); + expect(completed.metadata.output).toEqual({ doubled: 42 }); + + await run.stop(); + await expect(run.completed).resolves.toBeUndefined(); + expect(run.state).toBe("stopped"); + }); + + test.each([ + ["with notifications", false], + ["poll only", true], + ])("works jobs without leader election, %s", async (_name, pollOnly) => { + const { driver } = await setup(); + const definition = defineJob({ kind: "sqlite_runtime_no_leader" }); + const client = new Client(driver, { + clientId: "sqlite-runtime-no-leader", + completionFlushInterval: { milliseconds: 1 }, + leaderElectionDisabled: true, + pollOnly, + queues: { + default: { + fetchCooldown: { milliseconds: 1 }, + maxWorkers: 1, + pollInterval: { milliseconds: 5 }, + }, + }, + workers: new Workers().add(definition, () => undefined), + }); + const run = await client.start(); + const inserted = await client.insert(definition, {}); + + const completed = await waitForJob(client, inserted.job.id, "completed"); + expect(completed.attemptedBy).toEqual(["sqlite-runtime-no-leader"]); + await expect(driver.leaderGet()).resolves.toBeNull(); + expect(run.diagnostics.maintenance).toBeNull(); + + await run.stop(); + expect(run.state).toBe("stopped"); + await expect(driver.leaderGet()).resolves.toBeNull(); + }); + + test("runs extensions, resumable steps, and subscriptions on SQLite", async () => { + const { driver } = await setup(); + const definition = defineJob({ + kind: "sqlite_runtime_extensions", + schema: z.object({ value: z.number() }), + }); + const order: string[] = []; + const client = new Client(driver, { + clientId: "sqlite-runtime-extensions-test", + completionBatchSize: 1, + hooks: { + afterWork: () => { + order.push("after"); + }, + beforeWork: () => { + order.push("before"); + }, + }, + middleware: [ + async (_context, next) => { + order.push("middleware-before"); + const result = await next(); + order.push("middleware-after"); + return result; + }, + ], + queues: { + default: { + fetchCooldown: { milliseconds: 1 }, + maxWorkers: 1, + pollInterval: { milliseconds: 5 }, + }, + }, + workers: new Workers().add(definition, async ({ job, resumable }) => { + await resumable.step("first", () => { + order.push("first"); + }); + await resumable.step("second", () => { + order.push("second"); + }); + return complete({ output: { doubled: job.args.value * 2 } }); + }), + }); + const events = client.subscribe({ + kinds: ["job_started", "job_completed"], + }); + const inserted = await client.insert(definition, { value: 21 }); + const run = await client.start(); + try { + const completed = await waitForJob(client, inserted.job.id, "completed"); + expect(completed.metadata.output).toEqual({ doubled: 42 }); + expect((await events.next()).value.kind).toBe("job_started"); + expect((await events.next()).value.kind).toBe("job_completed"); + expect(order).toEqual([ + "middleware-before", + "before", + "first", + "second", + "after", + "middleware-after", + ]); + } finally { + await run.stop(); + events.close(); + } + }); + + test("persists resumable checkpoints on failed SQLite attempts", async () => { + const { driver } = await setup(); + const definition = defineJob({ kind: "sqlite_runtime_resumable_retry" }); + const order: string[] = []; + const client = new Client(driver, { + clientId: "sqlite-runtime-resumable-test", + completionBatchSize: 1, + leaderElectionDisabled: true, + queues: { + default: { + fetchCooldown: { milliseconds: 1 }, + maxWorkers: 1, + pollInterval: { milliseconds: 5 }, + }, + }, + retryPolicy: (_job, now) => now.add({ hours: 1 }), + workers: new Workers().add(definition, async ({ resumable }) => { + await resumable.step("first", () => { + order.push("first"); + }); + await resumable.step("second", () => { + order.push("second"); + throw new Error("retry after checkpoint"); + }); + }), + }); + const inserted = await client.insert(definition, {}); + const run = await client.start(); + try { + const retryable = await waitForJob(client, inserted.job.id, "retryable"); + expect(retryable.metadata["river:resumable_step"]).toBe("first"); + expect(order).toEqual(["first", "second"]); + } finally { + await run.stop(); + } + }); + + test("fails jobs with invalid JSON columns without stalling the queue, like Go", async () => { + const { database, driver } = await setup(); + const job = defineJob({ kind: "sqlite_runtime_invalid_json" }); + const worked: bigint[] = []; + const client = new Client(driver, { + leaderElectionDisabled: true, + queues: { + default: { + fetchCooldown: { milliseconds: 1 }, + maxWorkers: 2, + pollInterval: { milliseconds: 5 }, + }, + }, + // Far enough out that a failed attempt leaves its job retryable, not + // available again at once with its errors value repaired. + retryPolicy: () => Temporal.Instant.from("2100-01-01T00:00:00Z"), + workers: new Workers().add(job, ({ job: { id } }) => { + worked.push(id); + }), + }); + // Ordinary jobs flank the invalid ones in claim order. + const first = await client.insert(job, {}); + const columns = ["args", "attempted_by", "errors", "metadata", "tags"]; + const invalid = new Map(); + for (const column of columns) { + const { job: inserted } = await client.insert(job, {}); + database + .prepare(`UPDATE river_job SET ${column} = '[not json' WHERE id = ?`) + .run(inserted.id); + invalid.set(column, inserted.id); + } + const last = await client.insert(job, {}); + + const run = await client.start(); + try { + await waitForJob(client, first.job.id, "completed"); + await waitForJob(client, last.job.id, "completed"); + await waitUntilAsync(() => + Promise.resolve( + [...invalid.values()].every( + (id) => + database + .prepare("SELECT state FROM river_job WHERE id = ?") + .get(id)?.state === "retryable" + ) + ) + ); + } finally { + await run.stop(); + } + + // Only the ordinary jobs were worked. Each invalid value is left in + // place, except that invalid errors text becomes a string in a new + // array with the attempt's decode error appended. + expect(worked.sort()).toEqual([first.job.id, last.job.id]); + for (const [column, id] of invalid) { + const { value } = database + .prepare( + `SELECT CASE WHEN typeof(${column}) = 'text' THEN ${column} + ELSE json(${column}) END AS value + FROM river_job WHERE id = ?` + ) + .get(id) as { value: string }; + if (column === "errors") { + expect(JSON.parse(value)).toEqual([ + "[not json", + expect.objectContaining({ + attempt: 1, + error: expect.stringContaining("job row couldn't be decoded"), + }), + ]); + } else { + expect(value).toBe("[not json"); + } + } + }); + + test("keeps a live runtime healthy while another connection holds the write lock", async () => { + const directory = mkdtempSync(join(tmpdir(), "river-sqlite-runtime-")); + const path = join(directory, "river.db"); + const database = new DatabaseSync(path); + const other = new DatabaseSync(path); + onTestFinished(() => { + if (other.isTransaction) other.exec("ROLLBACK"); + other.close(); + database.close(); + rmSync(directory, { force: true, recursive: true }); + }); + await migrate(database); + using driver = testSqliteDriver(database, STRICT); + const definition = defineJob({ kind: "sqlite_runtime_busy" }); + const client = new Client(driver, { + clientId: "sqlite-runtime-busy-test", + completionBatchSize: 1, + queues: { + default: { + fetchCooldown: { milliseconds: 1 }, + maxWorkers: 1, + pollInterval: { milliseconds: 1 }, + }, + }, + workers: new Workers().add(definition, () => undefined), + }); + const run = await client.start(); + + // Another process (for example a Go client) holds SQLite's write lock. + other.exec("BEGIN IMMEDIATE"); + let ticks = 0; + const interval = setInterval(() => { + ticks++; + }, 1); + onTestFinished(() => clearInterval(interval)); + const inserted = client.insert(definition, {}); + await waitUntil(() => ticks >= 50); + expect(run.state).toBe("running"); + + other.exec("COMMIT"); + const { job } = await inserted; + await waitForJob(client, job.id, "completed"); + await run.stop(); + expect(run.state).toBe("stopped"); + }); + + test("keeps a live runtime healthy across a long caller transaction", async () => { + const { database, driver } = await setup(); + const definition = defineJob({ kind: "sqlite_runtime_long_tx" }); + const client = new Client(driver, { + clientId: "sqlite-runtime-long-tx-test", + completionBatchSize: 1, + queues: { + default: { + fetchCooldown: { milliseconds: 1 }, + maxWorkers: 1, + pollInterval: { milliseconds: 1 }, + }, + }, + workers: new Workers().add(definition, () => undefined), + }); + const run = await client.start(); + let release!: () => void; + let transactionStarted!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + const started = new Promise((resolve) => { + transactionStarted = resolve; + }); + let insertedId = 0n; + + const pending = transaction(database, async (tx) => { + insertedId = (await client.insert(definition, {}, { tx })).job.id; + transactionStarted(); + await held; + }); + await started; + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(run.state).toBe("running"); + expect(run.diagnostics.activeAttempts).toBe(0); + + release(); + await pending; + await waitForJob(client, insertedId, "completed"); + await run.stop(); + + expect(run.state).toBe("stopped"); + }); + + test("a poll-only client polls for its running jobs' cancellations, like Go", async () => { + const { driver } = await setup(); + const job = defineJob({ kind: "sqlite_runtime_poll_only_cancel" }); + const client = new Client(driver, { + leaderElectionDisabled: true, + pollOnly: true, + queues: { + default: { + fetchCooldown: { milliseconds: 1 }, + maxWorkers: 1, + pollInterval: { milliseconds: 5 }, + }, + }, + workers: new Workers().add( + job, + ({ signal }) => + new Promise((_, reject) => { + signal.addEventListener("abort", () => reject(signal.reason), { + once: true, + }); + }) + ), + }); + const inserted = await client.insert(job, {}); + const run = await client.start(); + try { + await waitUntilAsync( + async () => + (await client.jobs.get(inserted.job.id))?.state === "running" + ); + // Another client's cancellation reaches this one only by polling. + await new Client(driver).jobs.cancel(inserted.job.id); + await waitUntilAsync( + async () => + (await client.jobs.get(inserted.job.id))?.state === "cancelled" + ); + } finally { + await run.stop(); + } + }); + + test("claims only kinds with workers when fetchOnlyKnownKinds is set", async () => { + const { driver } = await setup(); + const known = defineJob({ kind: "sqlite_runtime_known_kind" }); + const other = defineJob({ kind: "sqlite_runtime_other_kind" }); + const queues = { + default: { + fetchCooldown: { milliseconds: 1 }, + maxWorkers: 2, + pollInterval: { milliseconds: 5 }, + }, + }; + const client = new Client(driver, { + fetchOnlyKnownKinds: true, + leaderElectionDisabled: true, + queues, + workers: new Workers().add(known, () => undefined), + }); + // The other kind comes first in claim order. + const otherJob = await client.insert(other, {}); + const knownJob = await client.insert(known, {}); + + const run = await client.start(); + await waitForJob(client, knownJob.job.id, "completed"); + await run.stop(); + + // Left available without using an attempt, for a client that knows it. + expect(await client.jobs.get(otherJob.job.id)).toMatchObject({ + attempt: 0, + errors: [], + state: "available", + }); + const otherClient = new Client(driver, { + leaderElectionDisabled: true, + queues, + workers: new Workers().add(other, () => undefined), + }); + const otherRun = await otherClient.start(); + await waitForJob(otherClient, otherJob.job.id, "completed"); + await otherRun.stop(); + }); +}); + +async function setup(): Promise<{ + database: DatabaseSync; + driver: SqliteRuntime; +}> { + const driver = testSqliteMemory(STRICT); + const database = driver.connect(); + onTestFinished(() => { + database.close(); + driver.close(); + }); + await migrate(database); + return { database, driver }; +} + +async function migrate(database: DatabaseSync): Promise { + const moduleUrl = new URL("../../../migrate/dist/index.js", import.meta.url); + const migrationModule = (await import(moduleUrl.href)) as { + createMigrator(target: { database: DatabaseSync }): { + migrateUp(): Promise; + }; + }; + await migrationModule.createMigrator({ database }).migrateUp(); +} + +async function waitForJob( + client: Client, + id: bigint, + state: string +): Promise< + Awaited> & { metadata: JsonObject } +> { + for (let index = 0; index < 1_000; index++) { + const job = await client.jobs.get(id); + if (job?.state === state) return job; + await new Promise((resolve) => setTimeout(resolve, 1)); + } + throw new Error(`job ${id} did not reach ${state}`); +} + +/** Wait up to six seconds, covering a few two-second cancellation polls. */ +async function waitUntilAsync( + condition: () => Promise +): Promise { + const deadline = Date.now() + 6_000; + while (!(await condition())) { + if (Date.now() > deadline) throw new Error("condition was not reached"); + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + +async function waitUntil(condition: () => boolean): Promise { + for (let index = 0; index < 1_000; index++) { + if (condition()) return; + await new Promise((resolve) => setTimeout(resolve, 1)); + } + throw new Error("condition was not reached"); +} From 5e4bb754b699c06870161faa704686c3535a75f2 Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sun, 4 Oct 2026 17:24:27 -0500 Subject: [PATCH 32/43] add @riverqueue/worker-threads for CPU-bound handlers Run handlers in a bounded pool of Node worker threads so CPU-heavy work can't block the event loop River claims, completes, and stops on. `WorkerThreads` is a work executor: register a handler by module URL and export name with `workers.addExecutor(definition, executor.handler(definition, { module, exportName }))`, and a `WorkerThreadModule` type rejects a missing or mistyped export at compile time. Args are decoded and validated in the main thread; River JSON crosses the boundary as text, preserving exact numbers, and other decoded args must survive structured clone unchanged. Outcomes, output, metadata, and logs cross back as River JSON with River's size bounds, and a thrown error fails the attempt with a bounded `WorkerThreadHandlerError`. When an attempt is cancelled, times out, or its client stops, the handler's signal aborts; a handler that hasn't settled after the client's `jobStuckThreshold` has its thread terminated, failing with a `JobAbortedError` when a stop caused the abort. A thread that crashes or exits is discarded, and a replacement starts when a task needs it. --- js/package.json | 12 +- js/pnpm-lock.yaml | 15 + js/pnpm-workspace.yaml | 1 + js/tsconfig.tests.json | 6 +- js/worker-threads/package.json | 70 ++ js/worker-threads/src/args.ts | 128 +++ js/worker-threads/src/client.test.ts | 330 +++++++ js/worker-threads/src/index.test.ts | 960 +++++++++++++++++++++ js/worker-threads/src/index.ts | 428 +++++++++ js/worker-threads/src/pool.ts | 591 +++++++++++++ js/worker-threads/src/protocol.ts | 153 ++++ js/worker-threads/src/testdata/format.ts | 11 + js/worker-threads/src/testdata/handlers.ts | 184 ++++ js/worker-threads/src/testdata/jobs.ts | 58 ++ js/worker-threads/src/thread.ts | 300 +++++++ js/worker-threads/tsconfig.json | 16 + 16 files changed, 3256 insertions(+), 7 deletions(-) create mode 100644 js/worker-threads/package.json create mode 100644 js/worker-threads/src/args.ts create mode 100644 js/worker-threads/src/client.test.ts create mode 100644 js/worker-threads/src/index.test.ts create mode 100644 js/worker-threads/src/index.ts create mode 100644 js/worker-threads/src/pool.ts create mode 100644 js/worker-threads/src/protocol.ts create mode 100644 js/worker-threads/src/testdata/format.ts create mode 100644 js/worker-threads/src/testdata/handlers.ts create mode 100644 js/worker-threads/src/testdata/jobs.ts create mode 100644 js/worker-threads/src/thread.ts create mode 100644 js/worker-threads/tsconfig.json diff --git a/js/package.json b/js/package.json index bef07fe1d..eab90288c 100644 --- a/js/package.json +++ b/js/package.json @@ -26,14 +26,14 @@ ], "scripts": { "build": "node node_modules/typescript/bin/tsc", - "build:all": "pnpm run build && pnpm --filter=@riverqueue/migrate run build && pnpm --filter='./driver/*' run build", + "build:all": "pnpm run build && pnpm --filter=@riverqueue/migrate run build && pnpm --filter='./driver/*' run build && pnpm --filter=@riverqueue/worker-threads run build", "clean": "rm -rf dist", - "clean:all": "pnpm run clean && pnpm --filter=@riverqueue/migrate run clean && pnpm --filter='./driver/*' run clean", - "fmt": "prettier --write 'src/**/*.ts' 'driver/**/src/**/*.ts' 'migrate/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts' 'examples/*/{README.md,package.json,tsconfig.json}' 'examples/tsconfig.json' '{README.md,package.json}'", - "fmt:check": "prettier --check 'src/**/*.ts' 'driver/**/src/**/*.ts' 'migrate/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts' 'examples/*/{README.md,package.json,tsconfig.json}' 'examples/tsconfig.json' '{README.md,package.json}'", + "clean:all": "pnpm run clean && pnpm --filter=@riverqueue/migrate run clean && pnpm --filter='./driver/*' run clean && pnpm --filter=@riverqueue/worker-threads run clean", + "fmt": "prettier --write 'src/**/*.ts' 'driver/**/src/**/*.ts' 'migrate/src/**/*.ts' 'worker-threads/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts' 'examples/*/{README.md,package.json,tsconfig.json}' 'examples/tsconfig.json' '{README.md,package.json}'", + "fmt:check": "prettier --check 'src/**/*.ts' 'driver/**/src/**/*.ts' 'migrate/src/**/*.ts' 'worker-threads/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts' 'examples/*/{README.md,package.json,tsconfig.json}' 'examples/tsconfig.json' '{README.md,package.json}'", "generate:migrations": "node scripts/sync-migrations.mjs", - "lint": "eslint 'src/**/*.ts' 'driver/**/src/**/*.ts' 'migrate/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts'", - "lint:fix": "eslint --fix 'src/**/*.ts' 'driver/**/src/**/*.ts' 'migrate/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts'", + "lint": "eslint 'src/**/*.ts' 'driver/**/src/**/*.ts' 'migrate/src/**/*.ts' 'worker-threads/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts'", + "lint:fix": "eslint --fix 'src/**/*.ts' 'driver/**/src/**/*.ts' 'migrate/src/**/*.ts' 'worker-threads/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts'", "prepublishOnly": "pnpm run clean && pnpm run build", "test": "vitest run --passWithNoTests", "test:coverage": "vitest run --coverage", diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml index 4eb6984f0..515c1af1c 100644 --- a/js/pnpm-lock.yaml +++ b/js/pnpm-lock.yaml @@ -179,6 +179,21 @@ importers: specifier: workspace:0.50.0-alpha.1 version: link:.. + worker-threads: + devDependencies: + '@riverqueue/driver-sqlite': + specifier: workspace:0.50.0-alpha.1 + version: link:../driver/sqlite + '@riverqueue/migrate': + specifier: workspace:0.50.0-alpha.1 + version: link:../migrate + '@types/node': + specifier: ^26.1.1 + version: 26.1.1 + riverqueue: + specifier: workspace:0.50.0-alpha.1 + version: link:.. + packages: '@babel/helper-string-parser@7.29.7': diff --git a/js/pnpm-workspace.yaml b/js/pnpm-workspace.yaml index cedd4d5e8..eed7e5f86 100644 --- a/js/pnpm-workspace.yaml +++ b/js/pnpm-workspace.yaml @@ -2,6 +2,7 @@ packages: - "driver/*" - "examples/*" - "migrate" + - "worker-threads" ignoredBuiltDependencies: - "@prisma/client" diff --git a/js/tsconfig.tests.json b/js/tsconfig.tests.json index 2b2cc9bda..71071d1ac 100644 --- a/js/tsconfig.tests.json +++ b/js/tsconfig.tests.json @@ -14,12 +14,16 @@ ], "@riverqueue/migrate": [ "./migrate/src/index.ts" + ], + "@riverqueue/worker-threads": [ + "./worker-threads/src/index.ts" ] } }, "include": [ "src", "driver/*/src", - "migrate/src" + "migrate/src", + "worker-threads/src" ] } diff --git a/js/worker-threads/package.json b/js/worker-threads/package.json new file mode 100644 index 000000000..428eb116a --- /dev/null +++ b/js/worker-threads/package.json @@ -0,0 +1,70 @@ +{ + "name": "@riverqueue/worker-threads", + "version": "0.50.0-alpha.1", + "description": "Optional bounded worker-thread executor for River TypeScript.", + "type": "module", + "sideEffects": [ + "./dist/thread.js" + ], + "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", + "!src/testdata", + "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", + "test": "vitest run --passWithNoTests" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/riverqueue/river.git", + "directory": "js/worker-threads" + }, + "contributors": [ + "Brandur Leach", + "Blake Gentry" + ], + "license": "LGPL-3.0-or-later", + "publishConfig": { + "access": "public", + "provenance": true + }, + "peerDependencies": { + "@types/node": ">=26", + "riverqueue": "workspace:0.50.0-alpha.1" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + }, + "devDependencies": { + "@riverqueue/driver-sqlite": "workspace:0.50.0-alpha.1", + "@riverqueue/migrate": "workspace:0.50.0-alpha.1", + "@types/node": "^26.1.1", + "riverqueue": "workspace:0.50.0-alpha.1" + }, + "keywords": [ + "river", + "job-queue", + "worker-threads", + "workers" + ] +} diff --git a/js/worker-threads/src/args.ts b/js/worker-threads/src/args.ts new file mode 100644 index 000000000..0e166341d --- /dev/null +++ b/js/worker-threads/src/args.ts @@ -0,0 +1,128 @@ +import { + ConfigurationError, + isExactJsonNumber, + stringifyJson, +} from "riverqueue"; + +import type { EncodedArgs } from "./protocol.js"; + +/** + * Encode a job's decoded args for a worker thread. + * + * Args in River's JSON domain cross as text, which preserves exact JSON + * numbers that structured clone rejects. Other args must survive structured + * clone unchanged: primitives including `bigint`, plain objects and arrays, + * `Date`, `Map`, `Set`, and `Uint8Array`. Anything that would arrive as a + * different type, such as a class instance whose prototype would be dropped, + * is rejected so a handler's argument types stay truthful. + */ +export function encodeArgs(kind: string, args: unknown): EncodedArgs { + let text: string | undefined; + try { + text = stringifyJson(args); + } catch { + // Not River JSON; fall back to a checked structured clone below. + } + if (text !== undefined) return { encoding: "json", text }; + + const problem = cloneProblem(args, "$", new Set()); + if (problem !== undefined) { + throw new ConfigurationError( + `decoded args for job kind ${JSON.stringify(kind)} cannot cross the ` + + `worker-thread boundary: ${problem.path} ${problem.reason}`, + { details: { kind, path: problem.path } } + ); + } + return { encoding: "clone", value: args }; +} + +interface CloneProblem { + readonly path: string; + readonly reason: string; +} + +function cloneProblem( + value: unknown, + path: string, + seen: Set +): CloneProblem | undefined { + switch (typeof value) { + case "bigint": + case "boolean": + case "number": + case "string": + case "undefined": + return undefined; + case "function": + return { path, reason: "is a function" }; + case "symbol": + return { path, reason: "is a symbol" }; + } + if (value === null || typeof value !== "object" || seen.has(value)) { + return undefined; + } + seen.add(value); + + if (isExactJsonNumber(value)) { + return { + path, + reason: + "is an exact JSON number, which only crosses in args that are " + + "entirely River JSON", + }; + } + const prototype: unknown = Object.getPrototypeOf(value); + if (prototype === Map.prototype) { + let index = 0; + for (const [key, entry] of value as Map) { + const problem = + cloneProblem(key, `${path}.`, seen) ?? + cloneProblem(entry, `${path}.`, seen); + if (problem !== undefined) return problem; + index++; + } + return undefined; + } + if (prototype === Set.prototype) { + let index = 0; + for (const entry of value as Set) { + const problem = cloneProblem(entry, `${path}.`, seen); + if (problem !== undefined) return problem; + index++; + } + return undefined; + } + if (prototype === Date.prototype || prototype === Uint8Array.prototype) { + return undefined; + } + if ( + Array.isArray(value) + ? prototype !== Array.prototype + : prototype !== Object.prototype && prototype !== null + ) { + const name = (value as { constructor?: { name?: unknown } }).constructor + ?.name; + return { + path, + reason: `is a ${typeof name === "string" && name !== "" ? name : "non-plain"} instance`, + }; + } + + for (const key of Reflect.ownKeys(value)) { + if (Array.isArray(value) && key === "length") continue; + if (typeof key === "symbol") { + return { path, reason: "has a symbol key" }; + } + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined || !descriptor.enumerable) continue; + const childPath = Array.isArray(value) + ? `${path}[${key}]` + : `${path}.${key}`; + if (!("value" in descriptor)) { + return { path: childPath, reason: "is an accessor property" }; + } + const problem = cloneProblem(descriptor.value, childPath, seen); + if (problem !== undefined) return problem; + } + return undefined; +} diff --git a/js/worker-threads/src/client.test.ts b/js/worker-threads/src/client.test.ts new file mode 100644 index 000000000..8fc91ec09 --- /dev/null +++ b/js/worker-threads/src/client.test.ts @@ -0,0 +1,330 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { SqliteDriver } from "@riverqueue/driver-sqlite"; +import { createMigrator } from "@riverqueue/migrate"; +import { + Client, + exactJsonNumber, + isExactJsonNumber, + LifecycleError, + Workers, +} from "riverqueue"; +import type { JobRow, JsonObject, WorkContext } from "riverqueue"; + +import { WorkerThreads } from "./index.js"; +import type { WorkerThreadModule } from "./index.js"; +import type * as testHandlers from "./testdata/handlers.js"; +import { testJob } from "./testdata/jobs.js"; + +const handlers: WorkerThreadModule = new URL( + "./testdata/handlers.js", + import.meta.url +); + +// A thread failure must never surface as a host-process failure. +const hostFailures: unknown[] = []; +const recordHostFailure = (reason: unknown) => hostFailures.push(reason); +beforeAll(() => { + process.on("uncaughtException", recordHostFailure); + process.on("unhandledRejection", recordHostFailure); +}); +afterAll(() => { + process.off("uncaughtException", recordHostFailure); + process.off("unhandledRejection", recordHostFailure); + expect(hostFailures).toEqual([]); +}); + +describe("WorkerThreads with River clients", () => { + interface TestClient { + readonly client: Client; + readonly driver: SqliteDriver; + } + + const setup = async ( + executor: WorkerThreads, + exportName: "complete" | "cooperate" | "echoExact" | "spin" = "complete", + jobStuckThreshold?: Temporal.DurationLike + ): Promise => { + const driver = SqliteDriver.memory({ + // The SQLite driver's private strict lock-window check. + [Symbol.for("riverqueue.sqlite.driver.test_hooks")]: { + strictLockWindow: true, + }, + }); + await createMigrator(driver).migrateUp(); + const workers = new Workers().addExecutor( + testJob, + executor.handler(testJob, { exportName, module: handlers }) + ); + const client = new Client(driver, { + ...(jobStuckThreshold === undefined ? {} : { jobStuckThreshold }), + queues: { + default: { + fetchCooldown: { milliseconds: 1 }, + maxWorkers: 2, + pollInterval: { milliseconds: 10 }, + }, + }, + workers, + }); + return { client, driver }; + }; + + it("closes an executor at the end of an await using scope", async () => { + let closed: WorkerThreads | undefined; + { + await using executor = new WorkerThreads({ maxThreads: 1 }); + closed = executor; + const target = executor.handler(testJob, { + exportName: "complete", + module: handlers, + }); + await expect( + executor.start(workContext({ value: "scoped" }), target.handler).result + ).resolves.toEqual({ output: { value: "scoped" }, type: "complete" }); + expect(executor.diagnostics()).toMatchObject({ totalThreads: 1 }); + } + + expect(closed.diagnostics()).toMatchObject({ totalThreads: 0 }); + const target = closed.handler(testJob, { + exportName: "complete", + module: handlers, + }); + await expect( + closed.start(workContext({ value: "late" }), target.handler).result + ).rejects.toThrow(LifecycleError); + }); + + it("cancels a cooperative handler and reuses its thread", async () => { + await using executor = new WorkerThreads({ maxThreads: 1 }); + const { client, driver } = await setup(executor, "cooperate"); + const run = await client.start(); + + const inserted = await client.insert(testJob, {}); + await waitFor(() => executor.diagnostics().activeThreads === 1); + await client.jobs.cancel(inserted.job.id); + + await expect( + waitForFinalized(client, inserted.job.id) + ).resolves.toMatchObject({ state: "cancelled" }); + expect(executor.diagnostics()).toMatchObject({ + crashedThreads: 0, + idleThreads: 1, + totalThreads: 1, + }); + await run.stop(); + driver.close(); + }); + + it("persists an ignored cancellation only after terminating its thread", async () => { + await using executor = new WorkerThreads({ maxThreads: 1 }); + const { client, driver } = await setup(executor, "spin", { + milliseconds: 10, + }); + const run = await client.start(); + + const inserted = await client.insert(testJob, {}); + await waitFor(() => executor.diagnostics().activeThreads === 1); + await client.jobs.cancel(inserted.job.id); + + await expect( + waitForFinalized(client, inserted.job.id) + ).resolves.toMatchObject({ state: "cancelled" }); + // The thread had exited before River persisted the cancellation. + expect(executor.diagnostics()).toMatchObject({ + crashedThreads: 0, + totalThreads: 0, + }); + await run.stop(); + driver.close(); + }); + + it("fails an attempt whose thread ignored a stop's abort, after the stuck threshold", async () => { + await using executor = new WorkerThreads({ maxThreads: 1 }); + const { client, driver } = await setup(executor, "spin", { + milliseconds: 200, + }); + const run = await client.start(); + + const spinning = await client.insert(testJob, {}); + await waitFor(() => executor.diagnostics().activeThreads === 1); + // The client works two jobs, so this one waits for the busy thread. + const waiting = await client.insert(testJob, {}); + await waitFor(() => executor.diagnostics().pendingTasks === 1); + const stopping = Date.now(); + await run.stop({ mode: "cancel" }); + + // The stop waited for the whole threshold before terminating it. + expect(Date.now() - stopping).toBeGreaterThanOrEqual(200); + const spun = await client.jobs.get(spinning.job.id); + expect(spun).toMatchObject({ + attempt: 1, + errors: [ + { attempt: 1, error: "job aborted after ignoring cancellation" }, + ], + }); + expect(["available", "retryable"]).toContain(spun?.state); + // The waiting attempt never ran, so it's interrupted without using an + // attempt. + await expect(client.jobs.get(waiting.job.id)).resolves.toMatchObject({ + attempt: 0, + errors: [], + state: "available", + }); + driver.close(); + }); + + it("interrupts a handler that stops because of a stop's abort without using its attempt", async () => { + await using executor = new WorkerThreads({ maxThreads: 1 }); + const { client, driver } = await setup(executor, "cooperate"); + const run = await client.start(); + + const inserted = await client.insert(testJob, {}); + await waitFor(() => executor.diagnostics().activeThreads === 1); + await run.stop({ mode: "cancel" }); + + await expect(client.jobs.get(inserted.job.id)).resolves.toMatchObject({ + attempt: 0, + errors: [], + state: "available", + }); + expect(executor.diagnostics()).toMatchObject({ totalThreads: 1 }); + driver.close(); + }); + + it("keeps a shared executor open when one of its clients stops", async () => { + await using executor = new WorkerThreads({ maxThreads: 1 }); + const first = await setup(executor); + const second = await setup(executor); + const firstRun = await first.client.start(); + const secondRun = await second.client.start(); + + await expect(work(first.client, { value: "first" })).resolves.toMatchObject( + { metadata: { output: { value: "first" } }, state: "completed" } + ); + await firstRun.stop(); + + await expect( + work(second.client, { value: "second" }) + ).resolves.toMatchObject({ + metadata: { output: { value: "second" } }, + state: "completed", + }); + await secondRun.stop(); + expect(executor.diagnostics()).toMatchObject({ + idleThreads: 1, + totalThreads: 1, + }); + first.driver.close(); + second.driver.close(); + }); + + it("serves a client started after another client stopped", async () => { + await using executor = new WorkerThreads({ maxThreads: 1 }); + const first = await setup(executor); + const firstRun = await first.client.start(); + await expect( + work(first.client, { value: "before" }) + ).resolves.toMatchObject({ state: "completed" }); + await firstRun.stop(); + + const second = await setup(executor); + const secondRun = await second.client.start(); + await expect( + work(second.client, { value: "after" }) + ).resolves.toMatchObject({ + metadata: { output: { value: "after" } }, + state: "completed", + }); + await secondRun.stop(); + first.driver.close(); + second.driver.close(); + }); + + it("works a persisted exact int64 through a single thread", async () => { + await using executor = new WorkerThreads({ maxThreads: 1 }); + const { client, driver } = await setup(executor, "echoExact"); + const run = await client.start(); + + const worked = await work(client, { + id: exactJsonNumber("9007199254740993"), + }); + expect(worked.state).toBe("completed"); + const output = worked.metadata["output"] as JsonObject; + expect( + isExactJsonNumber(output["id"]) ? output["id"].rawJSON : output["id"] + ).toBe("9007199254740993"); + + await expect( + work(client, { id: exactJsonNumber("9007199254740995") }) + ).resolves.toMatchObject({ state: "completed" }); + await run.stop(); + driver.close(); + }); +}); + +/** Insert one job and poll until River finalizes it. */ +async function work(client: Client, args: JsonObject): Promise { + const inserted = await client.insert(testJob, args); + return waitForFinalized(client, inserted.job.id); +} + +/** Poll between event-loop turns until a condition holds. */ +async function waitFor(predicate: () => boolean): Promise { + const deadline = Date.now() + 5_000; + while (!predicate()) { + if (Date.now() > deadline) throw new Error("condition was not reached"); + await new Promise((resolve) => setTimeout(resolve, 1)); + } +} + +async function waitForFinalized(client: Client, id: bigint): Promise { + const deadline = Date.now() + 10_000; + for (;;) { + const job = await client.jobs.get(id); + if (job !== null && job.finalizedAt !== null) return job; + if (Date.now() > deadline) throw new Error("job was not finalized"); + await new Promise((resolve) => setTimeout(resolve, 5)); + } +} + +function workContext(args: JsonObject): WorkContext { + const now = Temporal.Now.instant(); + return { + client: {} as WorkContext["client"], + completeTx: () => + Promise.reject(new Error("test context has no transaction")), + execution: { attemptedBy: "worker-thread-test", startedAt: now }, + job: { + args, + attempt: 1, + attemptedAt: now, + attemptedBy: ["worker-thread-test"], + createdAt: now, + errors: [], + finalizedAt: null, + id: 1n, + kind: testJob.kind, + maxAttempts: 3, + metadata: {}, + priority: 1, + queue: "default", + rawArgs: args, + scheduledAt: now, + state: "running", + tags: [], + uniqueKey: null, + uniqueStates: null, + }, + logger: { + debug: () => undefined, + error: () => undefined, + info: () => undefined, + warn: () => undefined, + }, + recordOutput: () => undefined, + resumable: {} as WorkContext["resumable"], + setMetadata: () => undefined, + signal: new AbortController().signal, + }; +} diff --git a/js/worker-threads/src/index.test.ts b/js/worker-threads/src/index.test.ts new file mode 100644 index 000000000..8a3026aa0 --- /dev/null +++ b/js/worker-threads/src/index.test.ts @@ -0,0 +1,960 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { + ConfigurationError, + exactJsonNumber, + isExactJsonNumber, + LifecycleError, +} from "riverqueue"; +import type { JsonObject, JsonValue, WorkContext } from "riverqueue"; + +import { WorkerThreadHandlerError, WorkerThreads } from "./index.js"; +import type { WorkerThreadModule } from "./index.js"; +import type * as testHandlers from "./testdata/handlers.js"; +import { richJob, testJob } from "./testdata/jobs.js"; + +// Only `handlers.ts` exists; threads resolve the `.js` name to the source. +const handlers: WorkerThreadModule = new URL( + "./testdata/handlers.js", + import.meta.url +); + +// A thread failure must never surface as a host-process failure. +const hostFailures: unknown[] = []; +const recordHostFailure = (reason: unknown) => hostFailures.push(reason); +beforeAll(() => { + process.on("uncaughtException", recordHostFailure); + process.on("unhandledRejection", recordHostFailure); +}); +afterAll(() => { + process.off("uncaughtException", recordHostFailure); + process.off("unhandledRejection", recordHostFailure); + expect(hostFailures).toEqual([]); +}); + +describe("WorkerThreads", () => { + it("bounds native thread concurrency", async () => { + const executor = new WorkerThreads({ maxThreads: 1 }); + const started = signals(); + const target = executor.handler(testJob, { + exportName: "sleep", + module: handlers, + }); + const first = executor.start( + context({ milliseconds: 30 }, started), + target.handler + ); + const second = executor.start( + context({ milliseconds: 30 }, started), + target.handler + ); + + await started.next(); + expect(executor.diagnostics()).toMatchObject({ + activeThreads: 1, + pendingTasks: 1, + totalThreads: 1, + }); + + await Promise.all([first.result, second.result]); + expect(executor.diagnostics()).toMatchObject({ + activeThreads: 0, + idleThreads: 1, + pendingTasks: 0, + totalThreads: 1, + }); + await executor.close(); + expect(executor.diagnostics()).toMatchObject({ totalThreads: 0 }); + }); + + it("reports an attempt as started only once a thread takes it", async () => { + const executor = new WorkerThreads({ maxThreads: 1 }); + const started = signals(); + const target = executor.handler(testJob, { + exportName: "sleep", + module: handlers, + }); + const first = executor.start( + context({ milliseconds: 20 }, started), + target.handler + ); + const second = executor.start(context({ milliseconds: 0 }), target.handler); + let secondStarted = false; + void second.started?.then(() => { + secondStarted = true; + }); + + await first.started; + await started.next(); + expect(secondStarted).toBe(false); + + await first.result; + await second.started; + await second.result; + await executor.close(); + }); + + it("cooperatively aborts and reuses a healthy thread", async () => { + const executor = new WorkerThreads({ maxThreads: 1 }); + const started = signals(); + const target = executor.handler(testJob, { + exportName: "cooperate", + module: handlers, + }); + const handle = executor.start(context({}, started), target.handler); + await started.next(); + + const reason = new Error("cancelled"); + reason.name = "JobCancelledError"; + // The handler stopped on its own, so River classifies its rejection. + await expect( + handle.abort(reason, { gracePeriod: gracePeriod(100) }) + ).resolves.toEqual({ + terminated: false, + }); + await expect(handle.result).rejects.toMatchObject({ + message: "cancelled", + name: "JobCancelledError", + }); + expect(executor.diagnostics()).toMatchObject({ + idleThreads: 1, + totalThreads: 1, + }); + + const completeTarget = executor.handler(testJob, { + exportName: "complete", + module: handlers, + }); + await expect( + executor.start(context({ value: "reused" }), completeTarget.handler) + .result + ).resolves.toEqual({ output: { value: "reused" }, type: "complete" }); + expect(executor.diagnostics()).toMatchObject({ totalThreads: 1 }); + await executor.close(); + }); + + it("forcibly terminates a synchronous loop without blocking the main event loop", async () => { + const executor = new WorkerThreads({ maxThreads: 1 }); + const started = signals(); + const target = executor.handler(testJob, { + exportName: "spin", + module: handlers, + }); + const handle = executor.start(context({}, started), target.handler); + await started.next(); + + let mainLoopAdvanced = false; + const mainLoopTimer = setTimeout(() => { + mainLoopAdvanced = true; + }, 0); + await expect( + handle.abort(new Error("stop"), { gracePeriod: gracePeriod(1) }) + ).resolves.toEqual({ + terminated: true, + }); + await expect(handle.result).rejects.toThrow("stop"); + await new Promise((resolve) => setTimeout(resolve, 0)); + clearTimeout(mainLoopTimer); + expect(mainLoopAdvanced).toBe(true); + expect(executor.diagnostics()).toMatchObject({ totalThreads: 0 }); + await executor.close(); + }); + + it("shuts down active work and leaves no owned thread handles", async () => { + const executor = new WorkerThreads({ maxThreads: 2 }); + const started = signals(); + const target = executor.handler(testJob, { + exportName: "sleep", + module: handlers, + }); + const handle = executor.start( + context({ milliseconds: 60_000 }, started), + target.handler + ); + await started.next(); + + await executor.close(); + await expect(handle.result).rejects.toThrow(LifecycleError); + expect(executor.diagnostics()).toEqual({ + activeThreads: 0, + crashedThreads: 0, + idleThreads: 0, + pendingTasks: 0, + totalThreads: 0, + }); + }); + + it("rejects queued and later tasks when closed", async () => { + const executor = new WorkerThreads({ maxThreads: 1 }); + const started = signals(); + const sleeping = executor.handler(testJob, { + exportName: "sleep", + module: handlers, + }); + const running = executor.start( + context({ milliseconds: 60_000 }, started), + sleeping.handler + ); + const queued = executor.start( + context({ milliseconds: 0 }), + sleeping.handler + ); + await started.next(); + expect(executor.diagnostics()).toMatchObject({ pendingTasks: 1 }); + + await executor.close(); + await expect(running.result).rejects.toThrow( + "closed while the attempt was running" + ); + await expect(queued.result).rejects.toThrow( + "worker thread executor is closed" + ); + await expect( + executor.start(context({ milliseconds: 0 }), sleeping.handler).result + ).rejects.toThrow(LifecycleError); + expect(executor.diagnostics()).toMatchObject({ + pendingTasks: 0, + totalThreads: 0, + }); + }); + + it("removes an aborted task from the queue without disturbing the running one", async () => { + const executor = new WorkerThreads({ maxThreads: 1 }); + const started = signals(); + const sleeping = executor.handler(testJob, { + exportName: "sleep", + module: handlers, + }); + const running = executor.start( + context({ milliseconds: 20 }, started), + sleeping.handler + ); + const queued = executor.start( + context({ milliseconds: 0 }), + sleeping.handler + ); + await started.next(); + + await expect( + queued.abort(new Error("not needed"), { gracePeriod: gracePeriod(0) }) + ).resolves.toEqual({ + terminated: true, + }); + await expect(queued.result).rejects.toThrow("not needed"); + expect(executor.diagnostics()).toMatchObject({ pendingTasks: 0 }); + await expect(running.result).resolves.toBeUndefined(); + await executor.close(); + }); + + it("fences task messages while reusing a thread after handler failure", async () => { + const executor = new WorkerThreads({ maxThreads: 1 }); + const failed = executor.handler(testJob, { + exportName: "fail", + module: handlers, + }); + await expect( + executor.start(context({}), failed.handler).result + ).rejects.toThrow("handler failed"); + + const completed = executor.handler(testJob, { + exportName: "complete", + module: handlers, + }); + await expect( + executor.start(context({ value: "next" }), completed.handler).result + ).resolves.toEqual({ output: { value: "next" }, type: "complete" }); + expect(executor.diagnostics()).toMatchObject({ + idleThreads: 1, + totalThreads: 1, + }); + await executor.close(); + }); + + it("forwards recorded output before a failed isolated result", async () => { + const executor = new WorkerThreads({ maxThreads: 1 }); + const outputs: unknown[] = []; + const target = executor.handler(testJob, { + exportName: "outputThenFail", + module: handlers, + }); + await expect( + executor.start(context({}, signals(), outputs), target.handler).result + ).rejects.toThrow("failed after output"); + expect(outputs).toEqual([{ beforeFailure: true }]); + await executor.close(); + }); + + it("returns a snooze's duration from a thread", async () => { + const executor = new WorkerThreads({ maxThreads: 1 }); + const target = executor.handler(testJob, { + exportName: "snooze", + module: handlers, + }); + const outcome = await executor.start(context({}), target.handler).result; + expect(outcome).toEqual({ + duration: Temporal.Duration.from({ seconds: 30 }), + type: "snooze", + }); + expect( + (outcome as { duration: Temporal.Duration }).duration + ).toBeInstanceOf(Temporal.Duration); + await executor.close(); + }); + + it("forwards attempt metadata before an isolated result", async () => { + const executor = new WorkerThreads({ maxThreads: 1 }); + const metadata: Array<[string, unknown]> = []; + const target = executor.handler(testJob, { + exportName: "metadataThenComplete", + module: handlers, + }); + + await expect( + executor.start( + context({}, signals(), [], (key, value) => metadata.push([key, value])), + target.handler + ).result + ).resolves.toEqual({ type: "complete" }); + expect(metadata).toEqual([["thread", { forwarded: true }]]); + await executor.close(); + }); + + describe("args", () => { + it("passes decoded args alongside the persisted input", async () => { + const executor = new WorkerThreads({ maxThreads: 1 }); + const persisted = context({ value: "persisted" }); + const target = executor.handler(testJob, { + exportName: "echoArgs", + module: handlers, + }); + + await expect( + executor.start( + { + ...persisted, + job: { ...persisted.job, args: { value: "decoded" } }, + }, + target.handler + ).result + ).resolves.toEqual({ + output: { + args: { value: "decoded" }, + rawArgs: { value: "persisted" }, + }, + type: "complete", + }); + await executor.close(); + }); + + it("carries structured-clone args that are not River JSON", async () => { + const executor = new WorkerThreads({ maxThreads: 1 }); + const base = context({ at: "2026-09-01T00:00:00.000Z", big: "1" }); + const target = executor.handler(richJob, { + exportName: "describeRich", + module: handlers, + }); + + await expect( + executor.start( + { + ...base, + job: { + ...base.job, + args: { + at: new Date("2026-09-01T00:00:00.000Z"), + big: 2n ** 70n, + bytes: new Uint8Array([1, 2, 3]), + lookup: new Map([["one", 1]]), + tags: new Set(["a", "b"]), + }, + kind: richJob.kind, + }, + }, + target.handler + ).result + ).resolves.toEqual({ + output: { + at: "date:2026-09-01T00:00:00.000Z", + big: (2n ** 70n).toString(), + bytes: "bytes:3", + lookup: "map:1", + tags: "set:2", + }, + type: "complete", + }); + await executor.close(); + }); + + it("rejects decoded args that would not arrive unchanged", async () => { + class Money { + constructor(readonly cents: bigint) {} + } + const executor = new WorkerThreads({ maxThreads: 1 }); + const base = context({}); + const target = executor.handler(testJob, { + exportName: "complete", + module: handlers, + }); + + for (const [args, path] of [ + [{ price: new Money(1n) }, "$.price is a Money instance"], + [{ nested: [() => undefined] }, "$.nested[0] is a function"], + [ + { big: 1n, id: exactJsonNumber("9007199254740993") }, + "$.id is an exact JSON number", + ], + ] as const) { + expect(() => + executor.start( + { ...base, job: { ...base.job, args } }, + target.handler + ) + ).toThrow(path); + } + expect(executor.diagnostics()).toMatchObject({ totalThreads: 0 }); + await executor.close(); + }); + }); + + it("preserves exact JSON numbers without leaking the only thread", async () => { + const executor = new WorkerThreads({ maxThreads: 1 }); + const exact = exactJsonNumber("9007199254740993"); + const logged: unknown[] = []; + const metadata: Array<[string, unknown]> = []; + const outputs: unknown[] = []; + const base = context({ id: exact }, signals(), outputs, (key, value) => + metadata.push([key, value]) + ); + const target = executor.handler(testJob, { + exportName: "echoExact", + module: handlers, + }); + + const outcome = await executor.start( + { + ...base, + logger: { + ...base.logger, + info: (attributes: unknown) => { + logged.push(attributes); + }, + }, + }, + target.handler + ).result; + + const exactId = (value: unknown): string | undefined => { + const id = (value as { id?: JsonValue } | undefined)?.id; + return isExactJsonNumber(id) ? id.rawJSON : undefined; + }; + expect(exactId((outcome as { output?: unknown }).output)).toBe( + "9007199254740993" + ); + expect(logged.map(exactId)).toEqual(["9007199254740993"]); + expect(outputs.map(exactId)).toEqual(["9007199254740993"]); + expect( + metadata.map(([key, value]) => [key, exactId({ id: value })]) + ).toEqual([["id", "9007199254740993"]]); + + const complete = executor.handler(testJob, { + exportName: "complete", + module: handlers, + }); + await expect( + executor.start(context({ value: "next" }), complete.handler).result + ).resolves.toEqual({ output: { value: "next" }, type: "complete" }); + expect(executor.diagnostics()).toMatchObject({ + activeThreads: 0, + idleThreads: 1, + totalThreads: 1, + }); + await executor.close(); + }); + + it("reports non-JSON outcomes without hanging or poisoning the thread", async () => { + const executor = new WorkerThreads({ maxThreads: 1 }); + const uncloneable = executor.handler(testJob, { + exportName: "uncloneableOutcome", + module: handlers, + }); + + await expect( + executor.start(context({}), uncloneable.handler).result + ).rejects.toThrow(/function/i); + + const complete = executor.handler(testJob, { + exportName: "complete", + module: handlers, + }); + await expect( + executor.start(context({ value: "reused" }), complete.handler).result + ).resolves.toEqual({ output: { value: "reused" }, type: "complete" }); + await executor.close(); + }); + + it("destroys a running thread when forwarding a log fails", async () => { + const executor = new WorkerThreads({ maxThreads: 1 }); + const started = signals(); + const poisoned = context({ milliseconds: 60_000 }, started); + const sleeping = executor.handler(testJob, { + exportName: "sleep", + module: handlers, + }); + const handle = executor.start( + { + ...poisoned, + logger: { + ...poisoned.logger, + info: () => { + started.send(); + throw new Error("log forwarding failed"); + }, + }, + }, + sleeping.handler + ); + + await started.next(); + await expect(handle.result).rejects.toThrow("log forwarding failed"); + + const complete = executor.handler(testJob, { + exportName: "complete", + module: handlers, + }); + await expect( + executor.start(context({ value: "replacement" }), complete.handler).result + ).resolves.toEqual({ output: { value: "replacement" }, type: "complete" }); + expect(executor.diagnostics()).toMatchObject({ + idleThreads: 1, + totalThreads: 1, + }); + await executor.close(); + }); + + it("rejects handlers from another executor or for another job kind", async () => { + const executor = new WorkerThreads({ maxThreads: 1 }); + const other = new WorkerThreads({ maxThreads: 1 }); + const foreign = other.handler(testJob, { + exportName: "complete", + module: handlers, + }); + const rich = executor.handler(richJob, { + exportName: "describeRich", + module: handlers, + }); + + expect(() => executor.start(context({}), foreign.handler)).toThrow( + "not created by this executor" + ); + expect(() => executor.start(context({}), rich.handler)).toThrow( + 'handler for job kind "test_rich" cannot work job kind "test"' + ); + // Like the Workers registry, a handler also works its kind aliases. + const renamedJob = { + ...testJob, + kind: "test_renamed", + kindAliases: ["test"], + } as unknown as typeof testJob; + const renamed = executor.handler(renamedJob, { + exportName: "complete", + module: handlers, + }); + await expect( + executor.start(context({ value: "alias" }), renamed.handler).result + ).resolves.toEqual({ output: { value: "alias" }, type: "complete" }); + expect(() => + executor.handler( + testJob, + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- the compiler rejects this invalid export name without it + { + exportName: "", + module: handlers, + } as never + ) + ).toThrow(ConfigurationError); + expect(() => + executor.handler(testJob, { + exportName: "complete", + module: handlers.href, + } as never) + ).toThrow(ConfigurationError); + await Promise.all([executor.close(), other.close()]); + }); + + it("type-checks export names against the module and definition", () => { + const executor = new WorkerThreads({ maxThreads: 1 }); + + executor.handler(testJob, { exportName: "complete", module: handlers }); + executor.handler(richJob, { exportName: "describeRich", module: handlers }); + // A plain URL has no module type, so any export name compiles. + executor.handler(testJob, { + exportName: "anything", + module: new URL(handlers.href), + }); + // @ts-expect-error The module has no such export. + executor.handler(testJob, { exportName: "completee", module: handlers }); + // @ts-expect-error `nthSquare` is not a worker-thread handler. + executor.handler(testJob, { exportName: "nthSquare", module: handlers }); + // @ts-expect-error `describeRich` handles a different definition's args. + executor.handler(testJob, { exportName: "describeRich", module: handlers }); + }); + + it("reports a missing handler module or export", async () => { + const executor = new WorkerThreads({ maxThreads: 1 }); + const missingModule = executor.handler(testJob, { + exportName: "complete", + module: new URL("./testdata/missing.js", import.meta.url), + }); + const missingExport = executor.handler(testJob, { + exportName: "missing", + module: new URL(handlers.href), + }); + + await expect( + executor.start(context({}), missingModule.handler).result + ).rejects.toThrow(/missing\.js/); + await expect( + executor.start(context({}), missingExport.handler).result + ).rejects.toThrow('ESM export "missing" is not a function'); + await executor.close(); + }); + + it("bounds error records crossing the thread boundary", async () => { + const executor = new WorkerThreads({ maxThreads: 1 }); + const target = executor.handler(testJob, { + exportName: "failWithHugeError", + module: handlers, + }); + + const error: unknown = await executor + .start(context({}), target.handler) + .result.then(undefined, (thrown: unknown) => thrown); + expect(error).toBeInstanceOf(WorkerThreadHandlerError); + expect((error as Error).message).toHaveLength(32_768); + expect((error as Error).stack?.length).toBeLessThanOrEqual(32_768); + await executor.close(); + }); + + it("reports an error whose getters throw without crashing its thread", async () => { + const executor = new WorkerThreads({ maxThreads: 1 }); + const failing = executor.handler(testJob, { + exportName: "failWithThrowingGetters", + module: handlers, + }); + const healthy = executor.handler(testJob, { + exportName: "complete", + module: handlers, + }); + + const error: unknown = await executor + .start(context({}), failing.handler) + .result.then(undefined, (thrown: unknown) => thrown); + expect(error).toBeInstanceOf(WorkerThreadHandlerError); + expect(error).toMatchObject({ + message: "unreadable error message", + name: "Error", + }); + // The same thread keeps working. + await expect( + executor.start(context({ value: 1 }), healthy.handler).result + ).resolves.toBeDefined(); + expect(executor.diagnostics()).toMatchObject({ + crashedThreads: 0, + totalThreads: 1, + }); + await executor.close(); + }); + + it("bounds logs, output, and metadata sent from a thread", async () => { + const executor = new WorkerThreads({ maxThreads: 1 }); + const logged: Array<[string, unknown]> = []; + const logging = executor.handler(testJob, { + exportName: "logHuge", + module: handlers, + }); + const logContext = context({}); + await executor.start( + { + ...logContext, + logger: { + ...logContext.logger, + info: (first: unknown, second?: unknown) => { + logged.push( + typeof first === "string" + ? [first, undefined] + : [String(second), first] + ); + }, + }, + }, + logging.handler + ).result; + expect(logged[0]?.[0]).toHaveLength(32_768); + expect(logged[1]?.[0]).toBe( + "with attributes [log attributes omitted: 100011 characters]" + ); + + const outputs: unknown[] = []; + const metadata: unknown[] = []; + const oversized = executor.handler(testJob, { + exportName: "oversizedOutput", + module: handlers, + }); + const error: unknown = await executor + .start( + context({}, signals(), outputs, (key) => metadata.push(key)), + oversized.handler + ) + .result.then(undefined, (thrown: unknown) => thrown); + expect(metadata).toEqual([]); + expect(outputs).toEqual([ + { metadataRejected: "job metadata value must not exceed 32 MiB" }, + ]); + expect(error).toBeInstanceOf(WorkerThreadHandlerError); + expect(error).toMatchObject({ + message: "job output must not exceed 32 MiB", + name: "ValidationError", + }); + await executor.close(); + }); + + it("validates executor options", () => { + expect(() => new WorkerThreads({ maxThreads: 0 })).toThrow( + ConfigurationError + ); + expect( + () => + new WorkerThreads({ + maxThreads: 1, + resourceLimits: { maxOldGenerationSizeMb: 0 }, + }) + ).toThrow(ConfigurationError); + expect( + () => + new WorkerThreads({ + maxThreads: 1, + resourceLimits: { maxHeap: 1 } as never, + }) + ).toThrow(ConfigurationError); + }); + + describe("thread failures", () => { + it.each([ + ["an uncaught exception", "throwWhenIdle"], + ["an unhandled rejection", "rejectWhenIdle"], + ["process.exit()", "exitWhenIdle"], + ] as const)( + "evicts an idle thread that fails with %s and replaces it lazily", + async (_failure, exportName) => { + const executor = new WorkerThreads({ maxThreads: 1 }); + const failing = executor.handler(testJob, { + exportName, + module: handlers, + }); + await expect( + executor.start(context({}), failing.handler).result + ).resolves.toEqual({ type: "complete" }); + + await waitFor(() => executor.diagnostics().crashedThreads === 1); + expect(executor.diagnostics()).toMatchObject({ + idleThreads: 0, + totalThreads: 0, + }); + + const complete = executor.handler(testJob, { + exportName: "complete", + module: handlers, + }); + await expect( + executor.start(context({ value: "replacement" }), complete.handler) + .result + ).resolves.toEqual({ + output: { value: "replacement" }, + type: "complete", + }); + expect(executor.diagnostics()).toMatchObject({ + crashedThreads: 1, + idleThreads: 1, + totalThreads: 1, + }); + await executor.close(); + } + ); + + it("retries a task once when its reused thread dies before starting it", async () => { + const executor = new WorkerThreads({ maxThreads: 1 }); + const exiting = executor.handler(testJob, { + exportName: "blockThenExit", + module: handlers, + }); + const complete = executor.handler(testJob, { + exportName: "complete", + module: handlers, + }); + + const next = await executor + .start(context({}), exiting.handler) + .result.then( + () => + executor.start(context({ value: "retried" }), complete.handler) + .result + ); + + expect(next).toEqual({ output: { value: "retried" }, type: "complete" }); + expect(executor.diagnostics()).toMatchObject({ + crashedThreads: 1, + totalThreads: 1, + }); + await executor.close(); + }); + + it("fails an attempt that exceeds its thread's heap limit", async () => { + const executor = new WorkerThreads({ + maxThreads: 1, + resourceLimits: { + maxOldGenerationSizeMb: 16, + maxYoungGenerationSizeMb: 4, + }, + }); + const allocating = executor.handler(testJob, { + exportName: "allocateForever", + module: handlers, + }); + + await expect( + executor.start(context({}), allocating.handler).result + ).rejects.toThrow(/memory limit/); + + const complete = executor.handler(testJob, { + exportName: "complete", + module: handlers, + }); + await expect( + executor.start(context({ value: "after" }), complete.handler).result + ).resolves.toEqual({ output: { value: "after" }, type: "complete" }); + expect(executor.diagnostics()).toMatchObject({ + crashedThreads: 1, + totalThreads: 1, + }); + await executor.close(); + }); + + it("fails the running attempt when its thread crashes", async () => { + const executor = new WorkerThreads({ maxThreads: 1 }); + const crashing = executor.handler(testJob, { + exportName: "crashWhileRunning", + module: handlers, + }); + + const result = executor.start(context({}), crashing.handler).result; + await expect(result).rejects.toThrow(WorkerThreadHandlerError); + await expect(result).rejects.toThrow("crashed in flight"); + + const complete = executor.handler(testJob, { + exportName: "complete", + module: handlers, + }); + await expect( + executor.start(context({ value: "after" }), complete.handler).result + ).resolves.toEqual({ output: { value: "after" }, type: "complete" }); + expect(executor.diagnostics()).toMatchObject({ + crashedThreads: 1, + totalThreads: 1, + }); + await executor.close(); + }); + }); + + it("revives execution timestamps with the worker realm's native Temporal", async () => { + const executor = new WorkerThreads({ maxThreads: 1 }); + const target = executor.handler(testJob, { + exportName: "confirmNativeTemporal", + module: handlers, + }); + + await expect( + executor.start(context({}), target.handler).result + ).resolves.toEqual({ + output: { startedAt: "2026-08-30T12:00:00.123456789Z" }, + type: "complete", + }); + await executor.close(); + }); +}); + +function context( + args: JsonObject, + started = signals(), + outputs: unknown[] = [], + metadata: (key: string, value: unknown) => void = () => undefined +): WorkContext { + const now = Temporal.Instant.from("2026-08-30T12:00:00.123456789Z"); + return { + client: {} as WorkContext["client"], + completeTx: () => + Promise.reject(new Error("test context has no transaction")), + execution: { attemptedBy: "worker-thread-test", startedAt: now }, + job: { + args, + attempt: 1, + attemptedAt: now, + attemptedBy: ["worker-thread-test"], + createdAt: now, + errors: [], + finalizedAt: null, + id: 42n, + kind: "test", + maxAttempts: 3, + metadata: {}, + priority: 1, + queue: "default", + rawArgs: args, + scheduledAt: now, + state: "running", + tags: [], + uniqueKey: null, + uniqueStates: null, + }, + logger: { + debug: () => undefined, + error: () => undefined, + info: (_attributes: unknown, message?: string) => { + if (message === "started") started.send(); + }, + warn: () => undefined, + }, + recordOutput: (value) => outputs.push(value), + resumable: {} as WorkContext["resumable"], + setMetadata: metadata, + signal: new AbortController().signal, + }; +} + +function gracePeriod(milliseconds: number): Temporal.Duration { + return Temporal.Duration.from({ milliseconds }); +} + +/** Poll between event-loop turns until a condition driven by thread events holds. */ +async function waitFor(predicate: () => boolean): Promise { + const deadline = Date.now() + 5_000; + while (!predicate()) { + if (Date.now() > deadline) throw new Error("condition was not reached"); + await new Promise((resolve) => setTimeout(resolve, 1)); + } +} + +function signals(): { next(): Promise; send(): void } { + const queue: Array<() => void> = []; + let buffered = 0; + return { + next: () => + buffered > 0 + ? (buffered--, Promise.resolve()) + : new Promise((resolve) => queue.push(resolve)), + send: () => { + const resolve = queue.shift(); + if (resolve === undefined) buffered++; + else resolve(); + }, + }; +} diff --git a/js/worker-threads/src/index.ts b/js/worker-threads/src/index.ts new file mode 100644 index 000000000..9e95b073c --- /dev/null +++ b/js/worker-threads/src/index.ts @@ -0,0 +1,428 @@ +/** + * Optional bounded worker-thread executor for CPU-heavy River handlers. + * + * @packageDocumentation + */ +import type { ResourceLimits } from "node:worker_threads"; + +import { ConfigurationError, jobToJsonValue, stringifyJson } from "riverqueue"; +import type { + JobDefinition, + JsonValue, + WorkContext, + WorkExecutor, + WorkExecutorHandle, + WorkExecutorTarget, + WorkOutcome, +} from "riverqueue"; + +import { encodeArgs } from "./args.js"; +import { WorkerThreadPool } from "./pool.js"; + +export { WorkerThreadHandlerError } from "./pool.js"; + +/** + * Type-level link from a module URL to the exports of that module. + * + * Never present at runtime; it only carries `Module` through inference. + */ +declare const workerThreadModuleExports: unique symbol; + +/** + * Snapshot of a {@link WorkerThreads} executor's threads and queue. + * + * River includes it under `executors.worker_threads` in client diagnostics. + */ +export type WorkerThreadsDiagnostics = { + /** Threads currently running an attempt. */ + readonly activeThreads: number; + /** + * Threads lost since construction to an uncaught error, an unhandled + * rejection, `process.exit()`, or a resource limit rather than to an abort + * or `close()`. A rising count usually means handlers leave failing + * background work behind after they return. + */ + readonly crashedThreads: number; + /** Live threads waiting for an attempt. */ + readonly idleThreads: number; + /** Attempts waiting for a thread. */ + readonly pendingTasks: number; + /** Live native threads, including any being terminated. */ + readonly totalThreads: number; +}; + +/** + * Names of `Module`'s exports that can handle jobs of `Definition`. + * + * Resolves to `string` when the module's type is unknown, as it is for a + * plain `URL`. + */ +export type WorkerThreadExportName< + Module, + Definition extends JobDefinition = JobDefinition, +> = unknown extends Module + ? string + : { + [ + Name in keyof Module & string + ]: Module[Name] extends WorkerThreadWorkHandler + ? Name + : never; + }[keyof Module & string]; + +/** + * Where a worker thread finds a job's handler. + * + * Give `module` the {@link WorkerThreadModule} type of the handler module to + * have TypeScript check that `exportName` names an export typed as + * {@link WorkerThreadWorkHandler} for the registered definition. + */ +export interface WorkerThreadHandlerTarget< + Definition extends JobDefinition = JobDefinition, + Module = unknown, +> { + /** Name of the module export that handles the job. */ + readonly exportName: WorkerThreadExportName; + /** Absolute URL of the ESM module that exports the handler. */ + readonly module: WorkerThreadModule; +} + +/** + * An ES module URL annotated with the type of the module it points to. + * + * Any `URL` is assignable, so annotate the URL to give + * {@link WorkerThreads.handler} the module's exports without importing the + * module's code into the main thread: + * + * ```ts + * import type * as primeHandlers from "./prime-handlers.js"; + * + * const primeModule: WorkerThreadModule = new URL( + * "./prime-handlers.js", + * import.meta.url + * ); + * ``` + */ +export type WorkerThreadModule = URL & { + readonly [workerThreadModuleExports]?: Module; +}; + +/** + * Options for a {@link WorkerThreads} executor. + */ +export interface WorkerThreadsOptions { + /** + * Maximum number of live native threads. + * + * Attempts beyond this limit wait for a thread without spending their job + * timeout. + */ + readonly maxThreads: number; + /** + * V8 heap and stack limits applied to every thread. + * + * A thread that exceeds its heap limit is terminated by Node; the attempt + * it was running fails and the thread is replaced for later attempts. + */ + readonly resourceLimits?: ResourceLimits; +} + +/** + * The context passed to a handler running in a worker thread. + * + * It carries the members of River's `WorkContext` that can cross a thread + * boundary. `job.args` holds the args decoded and validated by the job + * definition in the main thread, and `job.rawArgs` the persisted JSON. + * `client`, `completeTx`, and `resumable` are unavailable because they depend + * on the main thread's connections and state. + * + * `logger`, `recordOutput`, and `setMetadata` accept River JSON values and + * are forwarded to the main thread asynchronously. + */ +export type WorkerThreadWorkContext< + Definition extends JobDefinition = JobDefinition, +> = Pick< + WorkContext, + "execution" | "job" | "logger" | "recordOutput" | "setMetadata" | "signal" +>; + +/** + * A job handler exported from a module that runs in a worker thread. + * + * Type the export with the job's definition so its args are typed, and so + * {@link WorkerThreads.handler} accepts the export for that definition: + * + * ```ts + * import type { findPrime } from "./jobs.js"; + * + * export const findPrimeHandler: WorkerThreadWorkHandler = ({ + * job, + * }) => complete({ output: { prime: nthPrime(job.args.ordinal) } }); + * ``` + * + * A type-only import of the definition keeps the handler module from loading + * anything it does not need. Like an in-process handler, it succeeds by + * returning nothing or a River outcome and fails by throwing. Outcomes cross + * back to the main thread as River JSON. + */ +/* eslint-disable @typescript-eslint/no-invalid-void-type -- ordinary and async no-return handlers are valid */ +export type WorkerThreadWorkHandler< + Definition extends JobDefinition = JobDefinition, +> = ( + context: WorkerThreadWorkContext +) => PromiseLike | WorkOutcome | void; +/* eslint-enable @typescript-eslint/no-invalid-void-type */ + +interface RegisteredHandler { + readonly exportName: string; + readonly kind: string; + readonly kinds: readonly string[]; + readonly moduleUrl: string; +} + +/** + * A bounded pool of native threads that runs CPU-heavy job handlers without + * blocking River's event loop. + * + * Handlers are ESM exports referenced by module URL and export name, because + * closures cannot cross a thread boundary. Each attempt runs alone in a + * reusable thread. Aborting an attempt aborts its handler's signal, waits + * the client's `jobStuckThreshold`, then terminates the thread, and River + * persists the outcome only after the thread has settled or exited. A thread + * that crashes fails only the attempt it was running and is replaced lazily. + * + * The application owns the executor: several clients may share one, and + * stopping a client never closes it. Close it with {@link WorkerThreads.close} + * or `await using` once every client using it has stopped. Idle threads do + * not keep the process alive. + * + * Worker threads isolate availability, not security. Only run trusted + * handler modules. + */ +export class WorkerThreads implements AsyncDisposable, WorkExecutor { + /** Executor name under which River reports this executor's diagnostics. */ + readonly name = "worker_threads"; + readonly #handlers = new WeakMap(); + readonly #pool: WorkerThreadPool; + + /** + * Create an executor. Threads start lazily as attempts need them. + * + * @throws {ConfigurationError} when an option is out of range. + */ + constructor(options: WorkerThreadsOptions) { + requireInteger("maxThreads", options.maxThreads, 1); + const resourceLimits = requireResourceLimits(options.resourceLimits); + this.#pool = new WorkerThreadPool({ + maxThreads: options.maxThreads, + ...(resourceLimits === undefined ? {} : { resourceLimits }), + }); + } + + /** + * Permanently close the executor and wait for its threads to exit. + * + * Queued attempts and attempts still running fail with a `LifecycleError`, + * and later attempts fail immediately. Stop every client using the executor + * first so running attempts finish or abort normally. Closing is idempotent. + */ + close(): Promise { + return this.#pool.close(); + } + + /** Report the executor's current threads and queue. */ + diagnostics(): WorkerThreadsDiagnostics { + return { ...this.#pool.diagnostics }; + } + + /** + * Describe a thread handler for `Workers.addExecutor`. + * + * ```ts + * import type * as primeHandlers from "./prime-handlers.js"; + * + * const primeModule: WorkerThreadModule = new URL( + * "./prime-handlers.js", + * import.meta.url + * ); + * workers.addExecutor( + * findPrime, + * executor.handler(findPrime, { + * exportName: "findPrimeHandler", + * module: primeModule, + * }) + * ); + * ``` + * + * With a typed `module`, TypeScript rejects an `exportName` that is missing + * or not a {@link WorkerThreadWorkHandler} for `definition`. With a plain + * `URL`, any name compiles and a missing export fails the attempt. Register + * the returned target for the same definition; an attempt of another kind + * fails with a `ConfigurationError`. + * + * @throws {ConfigurationError} when the definition, module, or export name + * is invalid. + */ + handler( + definition: Definition, + target: WorkerThreadHandlerTarget + ): WorkExecutorTarget { + if ( + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- validates untyped JavaScript input + definition === null || + typeof definition !== "object" || + typeof definition.kind !== "string" + ) { + throw new ConfigurationError( + "worker thread handler needs a job definition" + ); + } + if (!(target.module instanceof URL)) { + throw new ConfigurationError( + "worker thread handler module must be an absolute URL" + ); + } + if (typeof target.exportName !== "string" || target.exportName === "") { + throw new ConfigurationError( + "worker thread handler exportName must be a non-empty string" + ); + } + const handler = Object.freeze({}); + this.#handlers.set(handler, { + exportName: target.exportName, + kind: definition.kind, + // Like the Workers registry, the handler also works its kind aliases. + kinds: [definition.kind, ...(definition.kindAliases ?? [])], + moduleUrl: target.module.href, + }); + return Object.freeze({ executor: this, handler }); + } + + /** + * Queue one attempt for a thread. + * + * River's runtime calls this for jobs registered with a target from + * {@link WorkerThreads.handler}; applications do not call it directly. + * + * @throws {ConfigurationError} when the handler was not created by this + * executor, belongs to another job kind, or the decoded args cannot cross + * the thread boundary unchanged. + */ + start(context: WorkContext, handler: unknown): WorkExecutorHandle { + const registered = + handler !== null && typeof handler === "object" + ? this.#handlers.get(handler) + : undefined; + if (registered === undefined) { + throw new ConfigurationError( + "worker thread handler was not created by this executor" + ); + } + if (!registered.kinds.includes(context.job.kind)) { + throw new ConfigurationError( + `worker thread handler for job kind ${JSON.stringify(registered.kind)} ` + + `cannot work job kind ${JSON.stringify(context.job.kind)}` + ); + } + const task = this.#pool.execute({ + args: encodeArgs(context.job.kind, context.job.args), + execution: { + attemptedBy: context.execution.attemptedBy, + startedAt: context.execution.startedAt.toString(), + }, + exportName: registered.exportName, + job: stringifyJson( + jobToJsonValue({ ...context.job, args: context.job.rawArgs }) + ), + logger: (level, message, attributes) => { + context.logger[level](attributes ?? {}, message); + }, + moduleUrl: registered.moduleUrl, + recordOutput: (value) => context.recordOutput(value), + setMetadata: (key, value) => context.setMetadata(key, value), + }); + const result = task.result.then(decodeOutcome); + // Like the task's own result, a rejection nobody awaited isn't + // unhandled. + void result.catch(() => undefined); + return { + // Report termination only when River stopped the handler itself. A + // handler that settled on its own reports its result, and River + // classifies it the same way as an in-process handler's. + abort: async (reason, { gracePeriod }) => ({ + terminated: await task.abort(reason, gracePeriod.total("milliseconds")), + }), + result, + started: task.started, + }; + } + + /** Close the executor at the end of an `await using` scope. */ + [Symbol.asyncDispose](): Promise { + return this.close(); + } +} + +function requireInteger(name: string, value: number, minimum: number): void { + if (!Number.isSafeInteger(value) || value < minimum) { + throw new ConfigurationError( + `${name} must be a safe integer of at least ${minimum}` + ); + } +} + +const RESOURCE_LIMIT_KEYS = [ + "codeRangeSizeMb", + "maxOldGenerationSizeMb", + "maxYoungGenerationSizeMb", + "stackSizeMb", +] as const; + +/** Copy validated limits so a bad value fails construction, not a spawn. */ +function requireResourceLimits( + limits: ResourceLimits | undefined +): ResourceLimits | undefined { + if (limits === undefined) return undefined; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- validates untyped JavaScript input + if (limits === null || typeof limits !== "object") { + throw new ConfigurationError("resourceLimits must be an object"); + } + const copy: { -readonly [Key in keyof ResourceLimits]: number } = {}; + for (const key of Object.keys(limits)) { + if (!(RESOURCE_LIMIT_KEYS as readonly string[]).includes(key)) { + throw new ConfigurationError( + `unknown worker thread resource limit ${JSON.stringify(key)}` + ); + } + } + for (const key of RESOURCE_LIMIT_KEYS) { + const value = limits[key]; + if (value === undefined) continue; + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + throw new ConfigurationError( + `resourceLimits.${key} must be a positive finite number` + ); + } + copy[key] = value; + } + return Object.freeze(copy); +} + +/** + * A thread's outcome from River JSON, reading a snooze's duration back from + * the ISO 8601 text the thread sent. + */ +function decodeOutcome( + outcome: JsonValue | undefined +): WorkOutcome | undefined { + if (typeof outcome === "object" && outcome !== null) { + const { duration, type } = outcome as { + readonly duration?: unknown; + readonly type?: unknown; + }; + if (type === "snooze" && typeof duration === "string") { + return { duration: Temporal.Duration.from(duration), type: "snooze" }; + } + } + return outcome as WorkOutcome | undefined; +} diff --git a/js/worker-threads/src/pool.ts b/js/worker-threads/src/pool.ts new file mode 100644 index 000000000..af3028c9c --- /dev/null +++ b/js/worker-threads/src/pool.ts @@ -0,0 +1,591 @@ +import { Worker } from "node:worker_threads"; +import type { ResourceLimits } from "node:worker_threads"; + +import { LifecycleError, parseJson, parseJsonObject } from "riverqueue"; +import type { JsonObject, JsonValue } from "riverqueue"; + +import { + serializeError, + type LogLevel, + type RunMessage, + type ThreadMessage, +} from "./protocol.js"; + +export interface WorkerThreadPoolOptions { + readonly maxThreads: number; + readonly resourceLimits?: ResourceLimits; +} + +export interface WorkerThreadPoolDiagnostics { + readonly activeThreads: number; + readonly crashedThreads: number; + readonly idleThreads: number; + readonly pendingTasks: number; + readonly totalThreads: number; +} + +export interface WorkerThreadTaskRequest { + readonly args: RunMessage["args"]; + readonly execution: RunMessage["execution"]; + readonly exportName: string; + /** `JobRowJson` text whose `args` are the persisted input. */ + readonly job: string; + readonly logger: WorkerThreadTaskLogger; + readonly moduleUrl: string; + readonly recordOutput: (value: JsonValue) => void; + readonly setMetadata: (key: string, value: JsonValue) => void; +} + +interface WorkerThreadTaskLogger { + (level: LogLevel, message: string, attributes: JsonObject | undefined): void; +} + +export interface WorkerThreadTaskHandle { + /** Settles with the handler's parsed outcome, or rejects with its failure. */ + readonly result: Promise; + /** + * Resolves once the task is handed to a thread, or rejects if the task + * settles while still queued. + */ + readonly started: Promise; + + /** + * Stop the task and resolve only once it no longer runs. + * + * A queued task is removed. A running task's handler sees its signal abort; + * if it has not settled after the grace period, its thread is terminated. + * Resolves `true` when River stopped the task itself, by removing it from + * the queue or terminating its thread, and `false` when the handler had + * already settled or settled on its own within the grace period. + */ + abort(reason: unknown, gracePeriodMs: number): Promise; +} + +/** + * An error reported by an isolated handler or by the thread running it. + * + * An error thrown by the handler keeps its original `name`, `message`, and + * `stack`, bounded to the limits River applies to persisted attempt errors. + * An attempt whose thread crashes or exits also fails with this error. + */ +export class WorkerThreadHandlerError extends Error { + /** + * @param message - The original error's message. + * @param options - The original error's `name` and `stack`, if known. + */ + constructor( + message: string, + options: { readonly name?: string; readonly stack?: string } = {} + ) { + super(message); + this.name = options.name || "WorkerThreadHandlerError"; + if (options.stack !== undefined && options.stack.length > 0) { + this.stack = options.stack; + } + } +} + +/** + * A bounded set of reusable River-owned native threads. + * + * Each thread runs at most one task at a time, and tasks queue in FIFO order + * while every thread is busy. A thread that crashes or exits, whether idle or + * running, is discarded; a replacement starts only when a queued task needs + * one. + */ +export class WorkerThreadPool { + readonly maxThreads: number; + + readonly #idle: ThreadSlot[] = []; + readonly #resourceLimits: ResourceLimits | undefined; + readonly #queue: PoolTask[] = []; + readonly #slots = new Set(); + #closing: Promise | undefined; + #crashedThreads = 0; + #nextTaskId = 1; + #nextThreadNumber = 1; + + constructor(options: WorkerThreadPoolOptions) { + this.maxThreads = options.maxThreads; + this.#resourceLimits = options.resourceLimits; + } + + get diagnostics(): WorkerThreadPoolDiagnostics { + let activeThreads = 0; + for (const slot of this.#slots) { + if (slot.busy) activeThreads++; + } + return { + activeThreads, + crashedThreads: this.#crashedThreads, + idleThreads: this.#idle.length, + pendingTasks: this.#queue.length, + totalThreads: this.#slots.size, + }; + } + + /** Reject queued and running tasks, then wait for every thread to exit. */ + close(): Promise { + this.#closing ??= this.#close(); + return this.#closing; + } + + execute(request: WorkerThreadTaskRequest): WorkerThreadTaskHandle { + const task = new PoolTask(this, request, this.#nextTaskId++); + if (this.#closing !== undefined) { + task.fail(closedError()); + return task; + } + this.#queue.push(task); + this.#dispatch(); + return task; + } + + /** @internal Remove a task that has not been handed to a thread. */ + dequeue(task: PoolTask): void { + const index = this.#queue.indexOf(task); + if (index !== -1) this.#queue.splice(index, 1); + } + + /** @internal Retry a task whose reused thread died before starting it. */ + requeue(task: PoolTask): void { + if (this.#closing !== undefined) { + task.fail(closedError()); + return; + } + this.#queue.unshift(task); + this.#dispatch(); + } + + async #close(): Promise { + const error = closedError(); + for (const task of this.#queue.splice(0)) task.fail(error); + this.#idle.length = 0; + await Promise.all( + [...this.#slots].map((slot) => + slot.terminate( + new LifecycleError( + "worker thread executor closed while the attempt was running" + ) + ) + ) + ); + } + + #dispatch(): void { + while (this.#closing === undefined && this.#queue.length > 0) { + let slot: ThreadSlot | undefined; + try { + slot = this.#takeIdle() ?? this.#spawn(); + } catch (error: unknown) { + // Dispatch also runs from thread events, so a failed spawn must fail + // the task it was for rather than escape as an uncaught exception. + this.#queue.shift()?.fail(error); + continue; + } + if (slot === undefined) return; + const task = this.#queue.shift(); + if (task === undefined) { + this.#idle.push(slot); + return; + } + slot.run(task); + } + } + + #onExit(slot: ThreadSlot, crashed: boolean): void { + this.#slots.delete(slot); + const index = this.#idle.indexOf(slot); + if (index !== -1) this.#idle.splice(index, 1); + if (crashed) this.#crashedThreads++; + this.#dispatch(); + } + + #onIdle(slot: ThreadSlot): void { + if (this.#closing !== undefined) return; + this.#idle.push(slot); + this.#dispatch(); + } + + #spawn(): ThreadSlot | undefined { + if (this.#slots.size >= this.maxThreads) return undefined; + const slot = new ThreadSlot( + { + name: `riverqueue-worker-thread-${this.#nextThreadNumber++}`, + ...(this.#resourceLimits === undefined + ? {} + : { resourceLimits: this.#resourceLimits }), + }, + { + onExit: (exited, crashed) => this.#onExit(exited, crashed), + onIdle: (idle) => this.#onIdle(idle), + } + ); + this.#slots.add(slot); + return slot; + } + + /** Take the most recently used idle thread that is still alive. */ + #takeIdle(): ThreadSlot | undefined { + for (;;) { + const slot = this.#idle.pop(); + if (slot === undefined || slot.reusable) return slot; + } + } +} + +interface ThreadSlotOwner { + onExit(slot: ThreadSlot, crashed: boolean): void; + onIdle(slot: ThreadSlot): void; +} + +/** + * One native thread and the task it is currently running. + * + * Its `error`, `exit`, and `message` listeners stay attached for the thread's + * whole life. A failure while a task runs settles that task; a failure while + * idle only evicts the thread. Without a permanent `error` listener, a stray + * timer throwing after its handler returned would crash the host process. + */ +class ThreadSlot { + readonly #exited: Promise; + readonly #owner: ThreadSlotOwner; + readonly #worker: Worker; + #crash: { readonly error: unknown } | undefined; + #hasExited = false; + #resolveExited: () => void = () => undefined; + #task: PoolTask | undefined; + #taskAcknowledged = false; + #termination: { readonly reason: unknown } | undefined; + #used = false; + + constructor( + options: { + readonly name: string; + readonly resourceLimits?: ResourceLimits; + }, + owner: ThreadSlotOwner + ) { + this.#owner = owner; + this.#exited = new Promise((resolve) => { + this.#resolveExited = resolve; + }); + this.#worker = new Worker(THREAD_ENTRY_URL, options); + this.#worker.on("error", (error: unknown) => this.#onError(error)); + this.#worker.on("exit", (code: number) => this.#onExit(code)); + this.#worker.on("message", (message: ThreadMessage) => + this.#onMessage(message) + ); + } + + /** Whether a task currently occupies this thread. */ + get busy(): boolean { + return this.#task !== undefined; + } + + /** Whether the thread is idle, alive, and not being terminated. */ + get reusable(): boolean { + return ( + this.#task === undefined && + this.#crash === undefined && + this.#termination === undefined && + !this.#hasExited && + this.#worker.threadId !== -1 + ); + } + + /** Forward a cooperative abort to `task` if it still runs here. */ + abort(task: PoolTask, reason: unknown): void { + if (this.#task !== task) return; + try { + this.#worker.postMessage({ + reason: serializeError(reason), + taskId: task.id, + type: "abort", + }); + } catch { + // A thread that cannot receive the abort is terminated after the grace. + } + } + + run(task: PoolTask): void { + this.#task = task; + this.#taskAcknowledged = false; + this.#worker.ref(); + try { + this.#worker.postMessage(task.runMessage()); + } catch (error: unknown) { + // Nothing reached the thread, so it remains healthy and reusable. + this.#finishTask(); + task.fail(error); + return; + } + task.begin(this); + } + + /** + * Terminate the thread and resolve once it has exited. + * + * A task still running here fails with `reason` once the thread is gone. + */ + terminate(reason: unknown): Promise { + if (!this.#hasExited) { + this.#termination ??= { reason }; + void this.#worker.terminate(); + } + return this.#exited; + } + + /** Fail the current task now and discard its still-running thread. */ + #abandon(error: unknown): void { + const task = this.#task; + this.#task = undefined; + task?.fail(error); + void this.terminate(error); + } + + #finishTask(): void { + this.#task = undefined; + this.#used = true; + this.#worker.unref(); + this.#owner.onIdle(this); + } + + #onError(error: unknown): void { + // An `error` is always followed by `exit`, and Node delivers every + // message the thread posted before emitting `exit`. Settling the task + // there keeps a result posted just before a crash from being lost. + this.#crash ??= { error }; + } + + #onExit(code: number): void { + this.#hasExited = true; + const task = this.#task; + this.#task = undefined; + if (task !== undefined) this.#settleOrphan(task, code); + this.#owner.onExit( + this, + this.#crash !== undefined || this.#termination === undefined + ); + this.#resolveExited(); + } + + #onMessage(message: ThreadMessage): void { + const task = this.#task; + if (task === undefined || message.taskId !== task.id) return; + switch (message.type) { + case "error": + this.#finishTask(); + task.fail( + new WorkerThreadHandlerError(message.error.message, message.error) + ); + return; + case "log": + this.#forward(() => + task.request.logger( + message.level, + message.message, + message.attributes === undefined + ? undefined + : parseJsonObject(message.attributes) + ) + ); + return; + case "metadata": + this.#forward(() => + task.request.setMetadata(message.key, parseJson(message.value)) + ); + return; + case "output": + this.#forward(() => + task.request.recordOutput(parseJson(message.output)) + ); + return; + case "result": { + let outcome: JsonValue | undefined; + try { + outcome = + message.outcome === undefined + ? undefined + : parseJson(message.outcome); + } catch (error: unknown) { + this.#finishTask(); + task.fail(error); + return; + } + this.#finishTask(); + task.succeed(outcome); + return; + } + case "started": + this.#taskAcknowledged = true; + return; + } + } + + /** Settle a task whose thread exited underneath it. */ + #settleOrphan(task: PoolTask, code: number): void { + if (this.#termination !== undefined) { + task.fail(this.#termination.reason); + } else if (!this.#taskAcknowledged && this.#used && task.requeueable) { + // A thread reused from the idle set died before it could begin this + // task, most likely from a previous handler's stray background work. + task.requeue(); + } else if (this.#crash !== undefined) { + task.fail(crashError(this.#crash.error)); + } else { + task.fail( + new WorkerThreadHandlerError( + `worker thread exited unexpectedly with code ${code}` + ) + ); + } + } + + /** + * Run a parent-side callback for a forwarded message. + * + * If it throws, the handler is still running in the thread, so the attempt + * fails with that error and the thread is discarded rather than reused. + */ + #forward(callback: () => void): void { + try { + callback(); + } catch (error: unknown) { + this.#abandon(error); + } + } +} + +class PoolTask implements WorkerThreadTaskHandle { + readonly id: number; + readonly request: WorkerThreadTaskRequest; + readonly result: Promise; + readonly started: Promise; + readonly #pool: WorkerThreadPool; + #rejectResult: (reason: unknown) => void = () => undefined; + #rejectStarted: (reason: unknown) => void = () => undefined; + #requeued = false; + #resolveResult: (value: JsonValue | undefined) => void = () => undefined; + #resolveStarted: () => void = () => undefined; + #slot: ThreadSlot | undefined; + #state: "queued" | "running" | "settled" = "queued"; + + constructor( + pool: WorkerThreadPool, + request: WorkerThreadTaskRequest, + id: number + ) { + this.#pool = pool; + this.request = request; + this.id = id; + this.result = new Promise((resolve, reject) => { + this.#resolveResult = resolve; + this.#rejectResult = reject; + }); + this.started = new Promise((resolve, reject) => { + this.#resolveStarted = resolve; + this.#rejectStarted = reject; + }); + void this.result.catch(() => undefined); + void this.started.catch(() => undefined); + } + + /** Whether this running task may be retried once on another thread. */ + get requeueable(): boolean { + return !this.#requeued && this.#state === "running"; + } + + async abort(reason: unknown, gracePeriodMs: number): Promise { + if (this.#state === "settled") return false; + if (this.#state === "queued") { + this.#pool.dequeue(this); + this.fail(reason); + return true; + } + const slot = this.#slot; + if (slot === undefined) return false; + slot.abort(this, reason); + const settled = this.result.then( + () => undefined, + () => undefined + ); + if (await settlesWithin(settled, gracePeriodMs)) return false; + await slot.terminate(reason); + await settled; + return true; + } + + /** Record that the run message reached `slot`. */ + begin(slot: ThreadSlot): void { + this.#slot = slot; + this.#state = "running"; + this.#resolveStarted(); + } + + fail(error: unknown): void { + if (this.#state === "settled") return; + this.#state = "settled"; + this.#rejectStarted(error); + this.#rejectResult(error); + } + + /** Return this task to the front of the queue, at most once. */ + requeue(): void { + this.#requeued = true; + this.#slot = undefined; + this.#state = "queued"; + this.#pool.requeue(this); + } + + runMessage(): RunMessage { + return { + args: this.request.args, + execution: this.request.execution, + exportName: this.request.exportName, + job: this.request.job, + moduleUrl: this.request.moduleUrl, + taskId: this.id, + type: "run", + }; + } + + succeed(outcome: JsonValue | undefined): void { + if (this.#state === "settled") return; + this.#state = "settled"; + this.#resolveResult(outcome); + } +} + +function closedError(): LifecycleError { + return new LifecycleError("worker thread executor is closed"); +} + +function crashError(error: unknown): WorkerThreadHandlerError { + const serialized = serializeError(error); + return new WorkerThreadHandlerError(serialized.message, serialized); +} + +/** Resolve whether `promise` settles before an unreferenced timeout. */ +async function settlesWithin( + promise: Promise, + milliseconds: number +): Promise { + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve(false), milliseconds); + timer.unref(); + }); + try { + return await Promise.race([promise.then(() => true), timeout]); + } finally { + clearTimeout(timer); + } +} + +// Development and tests load the TypeScript entry point through Node's +// built-in type stripping; published builds load the compiled JavaScript. +const THREAD_ENTRY_URL = new URL( + import.meta.url.endsWith(".ts") ? "./thread.ts" : "./thread.js", + import.meta.url +); diff --git a/js/worker-threads/src/protocol.ts b/js/worker-threads/src/protocol.ts new file mode 100644 index 000000000..92d7f2ff0 --- /dev/null +++ b/js/worker-threads/src/protocol.ts @@ -0,0 +1,153 @@ +/** + * Messages exchanged between the pool and a River-owned worker thread. + * + * Every JSON payload crosses the boundary as text produced by `stringifyJson` + * and read back with `parseJson`. Structured clone cannot represent the exact + * JSON numbers River uses for values such as Go-produced int64 IDs, and text + * keeps each message a small set of strings and numbers that always clones. + * Decoded args that are not River JSON cross by structured clone only after + * `encodeArgs` verifies they arrive unchanged. + * + * The thread entry point imports this module for types only, so it stays + * loadable by Node's built-in type stripping during development. + */ + +/** Bounded description of an error or abort reason. */ +export interface SerializedError { + readonly message: string; + readonly name: string; + readonly stack: string; +} + +/** Ask the thread to abort the task's signal cooperatively. */ +interface AbortMessage { + readonly reason: SerializedError; + readonly taskId: number; + readonly type: "abort"; +} + +/** + * A job's decoded args: River JSON as exact text, or a value checked to + * survive structured clone unchanged. + */ +export type EncodedArgs = + | { readonly encoding: "clone"; readonly value: unknown } + | { readonly encoding: "json"; readonly text: string }; + +/** Run one attempt in an idle thread. */ +export interface RunMessage { + readonly args: EncodedArgs; + readonly execution: { + readonly attemptedBy: string; + readonly startedAt: string; + }; + readonly exportName: string; + /** `JobRowJson` text whose `args` are the persisted input. */ + readonly job: string; + readonly moduleUrl: string; + readonly taskId: number; + readonly type: "run"; +} + +/** Messages posted from the pool to a thread. */ +export type HostMessage = AbortMessage | RunMessage; + +/** Levels accepted by the forwarded job logger. */ +export type LogLevel = "debug" | "error" | "info" | "warn"; + +/** Messages posted from a thread to the pool. */ +export type ThreadMessage = + | { + readonly error: SerializedError; + readonly taskId: number; + readonly type: "error"; + } + | { + /** JSON object text, when attributes were supplied. */ + readonly attributes?: string; + readonly level: LogLevel; + readonly message: string; + readonly taskId: number; + readonly type: "log"; + } + | { + readonly key: string; + readonly taskId: number; + readonly type: "metadata"; + /** JSON value text. */ + readonly value: string; + } + | { + /** JSON value text. */ + readonly output: string; + readonly taskId: number; + readonly type: "output"; + } + | { + /** JSON text of the returned outcome, absent for `undefined`. */ + readonly outcome?: string; + readonly taskId: number; + readonly type: "result"; + } + | { + /** The thread accepted the run message and is starting the task. */ + readonly taskId: number; + readonly type: "started"; + }; + +/** Longest error name retained across the boundary. */ +const MAX_ERROR_NAME_LENGTH = 256; + +/** Longest error message or stack retained, matching persisted attempt errors. */ +const MAX_ERROR_TEXT_LENGTH = 32_768; + +/** Describe any thrown value within the boundary's size limits. */ +export function serializeError(error: unknown): SerializedError { + let isError = false; + try { + isError = error instanceof Error; + } catch { + // A proxy can throw from `instanceof`; describe it as a non-Error value. + } + if (isError) { + const value = error as Error; + const message = safeProperty(value, "message"); + const stack = safeProperty(value, "stack"); + return { + message: truncate( + message === undefined ? "unreadable error message" : safeString(message) + ), + name: truncate( + safeString(safeProperty(value, "name") ?? "") || "Error", + MAX_ERROR_NAME_LENGTH + ), + stack: truncate(stack === undefined ? "" : safeString(stack)), + }; + } + return { message: truncate(safeString(error)), name: "Error", stack: "" }; +} + +/** Read a property that a hostile or broken getter may throw from. */ +function safeProperty( + value: object, + key: "message" | "name" | "stack" +): unknown { + try { + return (value as Record)[key]; + } catch { + return undefined; + } +} + +function safeString(value: unknown): string { + if (typeof value === "string") return value; + try { + return String(value); + } catch { + return "non-Error value"; + } +} + +function truncate(value: string, limit = MAX_ERROR_TEXT_LENGTH): string { + return value.length <= limit ? value : value.slice(0, limit); +} diff --git a/js/worker-threads/src/testdata/format.ts b/js/worker-threads/src/testdata/format.ts new file mode 100644 index 000000000..8e694ff13 --- /dev/null +++ b/js/worker-threads/src/testdata/format.ts @@ -0,0 +1,11 @@ +/** + * A sibling module imported by `handlers.ts` through its compiled `.js` name, + * proving that a thread resolves TypeScript sources imported that way. + */ +export function describeValue(value: unknown): string { + if (value instanceof Date) return `date:${value.toISOString()}`; + if (value instanceof Map) return `map:${value.size}`; + if (value instanceof Set) return `set:${value.size}`; + if (value instanceof Uint8Array) return `bytes:${value.length}`; + return typeof value; +} diff --git a/js/worker-threads/src/testdata/handlers.ts b/js/worker-threads/src/testdata/handlers.ts new file mode 100644 index 000000000..360191491 --- /dev/null +++ b/js/worker-threads/src/testdata/handlers.ts @@ -0,0 +1,184 @@ +/** + * Thread-side handlers for the worker-thread tests. + * + * Tests reference this module as `handlers.js`, which does not exist beside + * the TypeScript source, so every test also exercises the thread's fallback + * from a missing `.js` module to its `.ts` source. + */ +import { + complete as completeOutcome, + snooze as snoozeOutcome, +} from "riverqueue"; + +import type { WorkerThreadWorkHandler } from "../index.js"; +import { describeValue } from "./format.js"; +import type { richJob, testJob } from "./jobs.js"; + +type TestHandler = WorkerThreadWorkHandler; + +export const allocateForever: TestHandler = ({ logger }) => { + logger.info("started"); + const retained: number[][] = []; + for (;;) retained.push(new Array(100_000).fill(retained.length)); +}; + +export const blockThenExit: TestHandler = () => { + // Keep the thread busy after returning so the next run message is queued + // behind this callback, then exit before the thread can acknowledge it. + setImmediate(() => { + const end = Date.now() + 100; + while (Date.now() < end) { + // Deliberately block this isolated thread. + } + process.exit(9); + }); + return completeOutcome(); +}; + +export const complete: TestHandler = ({ job }) => + completeOutcome({ output: { value: job.args.value ?? null } }); + +export const confirmNativeTemporal: TestHandler = ({ execution }) => { + if (!(execution.startedAt instanceof Temporal.Instant)) { + throw new TypeError("startedAt is not a native Temporal.Instant"); + } + return completeOutcome({ + output: { startedAt: execution.startedAt.toString() }, + }); +}; + +export const cooperate: TestHandler = ({ logger, signal }) => { + logger.info("started"); + return new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(signal.reason as Error), { + once: true, + }); + }); +}; + +export const crashWhileRunning: TestHandler = ({ logger }) => { + logger.info("started"); + setImmediate(() => { + throw new Error("crashed in flight"); + }); + return new Promise(() => undefined); +}; + +export const describeRich: WorkerThreadWorkHandler = ({ + job, +}) => + completeOutcome({ + output: { + at: describeValue(job.args.at), + big: job.args.big.toString(), + bytes: describeValue(job.args.bytes), + lookup: describeValue(job.args.lookup), + tags: describeValue(job.args.tags), + }, + }); + +export const echoArgs: TestHandler = ({ job }) => + completeOutcome({ output: { args: { ...job.args }, rawArgs: job.rawArgs } }); + +export const echoExact: TestHandler = ({ + job, + logger, + recordOutput, + setMetadata, +}) => { + const id = job.args.id ?? null; + logger.info({ id }, "exact"); + recordOutput({ id }); + setMetadata("id", id); + return completeOutcome({ output: { id } }); +}; + +export const exitWhenIdle: TestHandler = () => { + setImmediate(() => process.exit(7)); + return completeOutcome(); +}; + +export const fail: TestHandler = () => { + throw new Error("handler failed"); +}; + +export const failWithHugeError: TestHandler = () => { + throw new Error("x".repeat(100_000)); +}; + +export const failWithThrowingGetters: TestHandler = () => { + const error = new Error("hidden"); + for (const key of ["message", "name", "stack"]) { + Object.defineProperty(error, key, { + get() { + throw new Error(`${key} getter failed`); + }, + }); + } + throw error; +}; + +export const logHuge: TestHandler = ({ logger }) => { + logger.info("x".repeat(100_000)); + logger.info({ blob: "y".repeat(100_000) }, "with attributes"); + return completeOutcome(); +}; + +export const metadataThenComplete: TestHandler = ({ setMetadata }) => { + setMetadata("thread", { forwarded: true }); + return completeOutcome(); +}; + +/** Not a job handler; registering it must not type-check. */ +export function nthSquare(ordinal: number): number { + return ordinal * ordinal; +} + +export const oversizedOutput: TestHandler = ({ recordOutput, setMetadata }) => { + const huge = "z".repeat(32 * 1024 * 1024 + 1); + try { + setMetadata("huge", huge); + } catch (error: unknown) { + recordOutput({ metadataRejected: (error as Error).message }); + } + recordOutput(huge); + return completeOutcome(); +}; + +export const outputThenFail: TestHandler = ({ recordOutput }) => { + recordOutput({ beforeFailure: true }); + throw new Error("failed after output"); +}; + +export const rejectWhenIdle: TestHandler = () => { + setImmediate(() => { + void Promise.reject(new Error("stray rejection")); + }); + return completeOutcome(); +}; + +export const sleep: TestHandler = ({ job, logger }) => { + logger.info("started"); + return new Promise((resolve) => + setTimeout(() => resolve(undefined), job.args.milliseconds ?? 0) + ); +}; + +export const snooze: TestHandler = () => snoozeOutcome({ seconds: 30 }); + +export const spin: TestHandler = ({ logger }) => { + logger.info("started"); + for (;;) { + // Deliberately block this isolated thread to exercise forced termination. + } +}; + +export const throwWhenIdle: TestHandler = () => { + setImmediate(() => { + throw new Error("stray timer"); + }); + return completeOutcome(); +}; + +export const uncloneableOutcome: TestHandler = () => + ({ output: { value: () => undefined }, type: "complete" }) as never; diff --git a/js/worker-threads/src/testdata/jobs.ts b/js/worker-threads/src/testdata/jobs.ts new file mode 100644 index 000000000..dde51ebe3 --- /dev/null +++ b/js/worker-threads/src/testdata/jobs.ts @@ -0,0 +1,58 @@ +/** + * Job definitions shared by the worker-thread tests and their thread-side + * handlers. Handlers import this module for types only. + */ +import { defineJob, isExactJsonNumber } from "riverqueue"; +import type { ExactJsonNumber, JsonObject } from "riverqueue"; + +/** Args accepted by {@link testJob}. */ +export interface TestArgs { + readonly id?: ExactJsonNumber | number; + readonly milliseconds?: number; + readonly value?: string; +} + +/** Args decoded by {@link richJob}, which structured clone must carry. */ +export interface RichArgs { + readonly at: Date; + readonly big: bigint; + readonly bytes: Uint8Array; + readonly lookup: Map; + readonly tags: Set; +} + +/** The ordinary job used by most worker-thread tests. */ +export const testJob = defineJob({ + kind: "test", + decode(value: JsonObject): TestArgs { + const { id, milliseconds, value: text } = value; + if (id !== undefined && typeof id !== "number" && !isExactJsonNumber(id)) { + throw new TypeError("id must be a number"); + } + if (milliseconds !== undefined && typeof milliseconds !== "number") { + throw new TypeError("milliseconds must be a number"); + } + if (text !== undefined && typeof text !== "string") { + throw new TypeError("value must be a string"); + } + return value; + }, +}); + +/** A job whose decoded args are not River JSON. */ +export const richJob = defineJob<{ at: string; big: string }>()({ + kind: "test_rich", + decode(value: JsonObject): RichArgs { + const { at, big } = value; + if (typeof at !== "string" || typeof big !== "string") { + throw new TypeError("at and big must be strings"); + } + return { + at: new Date(at), + big: BigInt(big), + bytes: new Uint8Array([1, 2, 3]), + lookup: new Map([["one", 1]]), + tags: new Set(["a", "b"]), + }; + }, +}); diff --git a/js/worker-threads/src/thread.ts b/js/worker-threads/src/thread.ts new file mode 100644 index 000000000..c730d6adf --- /dev/null +++ b/js/worker-threads/src/thread.ts @@ -0,0 +1,300 @@ +/** + * Entry point for River-owned worker threads. + * + * This module runs only inside threads started by the pool. It imports + * `./protocol.js` for types only and depends at runtime only on Node and + * `riverqueue`, so Node's built-in type stripping can load the TypeScript + * source directly in development and tests. + */ +import { Buffer } from "node:buffer"; +import { registerHooks } from "node:module"; +import { parentPort } from "node:worker_threads"; + +import { + jobFromJsonValue, + parseJson, + stringifyJson, + ValidationError, +} from "riverqueue"; +import type { JsonValue } from "riverqueue"; + +import type { + HostMessage, + LogLevel, + RunMessage, + SerializedError, + ThreadMessage, +} from "./protocol.js"; + +// Keep in sync with the limits in `./protocol.ts`, which this module can only +// import as types. +const MAX_ERROR_NAME_LENGTH = 256; +const MAX_ERROR_TEXT_LENGTH = 32_768; +const MAX_LOG_TEXT_LENGTH = 32_768; +const MAX_VALUE_BYTES = 32 * 1024 * 1024; + +const LOG_LEVELS: readonly LogLevel[] = ["debug", "error", "info", "warn"]; + +const port = parentPort; +if (port === null) { + throw new Error("River's worker-thread entry point must run in a thread"); +} + +if (process.features.typescript) { + // TypeScript sources import siblings by their compiled `.js` names. When a + // `.js` module does not exist but a `.ts` source beside it does, load the + // source, so a handler URL and its imports work unchanged whether the + // application runs from source under Node's type stripping or from its + // build. Only a failed resolution takes this path; a build is never + // affected. + registerHooks({ + resolve(specifier, context, nextResolve) { + try { + return nextResolve(specifier, context); + } catch (error: unknown) { + const source = typeScriptSourceSpecifier(specifier, error); + if (source === undefined) throw error; + try { + return nextResolve(source, context); + } catch { + throw error; + } + } + }, + }); +} + +let current: + { readonly controller: AbortController; readonly taskId: number } | undefined; + +port.on("message", (message: HostMessage) => { + if (message.type === "abort") { + if (current?.taskId === message.taskId) { + current.controller.abort(reviveError(message.reason)); + } + return; + } + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- validates messages from the host thread + if (message.type !== "run") return; + if (current !== undefined) { + // The pool only dispatches to idle threads; refuse rather than hang. + post({ + error: serializeError(new Error("worker thread is already busy")), + taskId: message.taskId, + type: "error", + }); + return; + } + + const controller = new AbortController(); + current = { controller, taskId: message.taskId }; + post({ taskId: message.taskId, type: "started" }); + void run(message, controller.signal); +}); + +async function run(message: RunMessage, signal: AbortSignal): Promise { + const { taskId } = message; + let settled: ThreadMessage; + try { + const context = workContext(message, signal); + const module = (await import(message.moduleUrl)) as Record; + const handler = module[message.exportName]; + if (typeof handler !== "function") { + throw new TypeError( + `ESM export ${JSON.stringify(message.exportName)} is not a function` + ); + } + const outcome: unknown = await ( + handler as (workContext: typeof context) => unknown + )(context); + settled = + outcome === undefined + ? { taskId, type: "result" } + : { + outcome: stringifyJson(encodeOutcome(outcome)), + taskId, + type: "result", + }; + } catch (error: unknown) { + settled = { error: serializeError(error), taskId, type: "error" }; + } + // Become idle before reporting so the pool can dispatch the next task. + current = undefined; + post(settled); +} + +function workContext(message: RunMessage, signal: AbortSignal) { + const { taskId } = message; + const row = jobFromJsonValue(parseJson(message.job)); + const args = + message.args.encoding === "json" + ? parseJson(message.args.text) + : message.args.value; + const logger = Object.fromEntries( + LOG_LEVELS.map((level) => [ + level, + (first: unknown, second?: unknown) => { + const [text, attributes] = + typeof first === "string" ? [first, undefined] : [second, first]; + if ( + attributes !== undefined && + (attributes === null || + typeof attributes !== "object" || + Array.isArray(attributes)) + ) { + throw new TypeError("log attributes must be a JSON object"); + } + // Bound what one log call sends to the host. Oversized attributes + // are dropped and noted rather than failing the handler. + let message = truncate(safeString(text), MAX_LOG_TEXT_LENGTH); + let encoded = + attributes === undefined ? undefined : stringifyJson(attributes); + if (encoded !== undefined && encoded.length > MAX_LOG_TEXT_LENGTH) { + message = `${message} [log attributes omitted: ${encoded.length.toString(10)} characters]`; + encoded = undefined; + } + post({ + ...(encoded === undefined ? {} : { attributes: encoded }), + level, + message, + taskId, + type: "log", + }); + }, + ]) + ); + return Object.freeze({ + execution: Object.freeze({ + attemptedBy: message.execution.attemptedBy, + startedAt: Temporal.Instant.from(message.execution.startedAt), + }), + job: Object.freeze({ ...row, args, rawArgs: row.args }), + logger: Object.freeze(logger), + recordOutput: (value: JsonValue) => { + post({ + output: boundedJson(value, "job output"), + taskId, + type: "output", + }); + }, + setMetadata: (key: string, value: JsonValue) => { + if (typeof key !== "string") { + throw new TypeError("metadata key must be a string"); + } + post({ + key, + taskId, + type: "metadata", + value: boundedJson(value, "job metadata value"), + }); + }, + signal, + }); +} + +function typeScriptSourceSpecifier( + specifier: string, + error: unknown +): string | undefined { + if ( + (error as { code?: unknown } | null)?.code !== "ERR_MODULE_NOT_FOUND" || + !/^(?:\.{1,2}\/|file:)/.test(specifier) + ) { + return undefined; + } + const match = /\.([cm]?)js$/.exec(specifier); + return match === null + ? undefined + : `${specifier.slice(0, match.index)}.${match[1] ?? ""}ts`; +} + +function post(message: ThreadMessage): void { + port?.postMessage(message); +} + +function reviveError(value: SerializedError): Error { + const error = new Error(value.message); + error.name = value.name; + if (value.stack.length > 0) error.stack = value.stack; + return error; +} + +/** + * Encode a value the thread sends to the host, rejecting one larger than + * the host accepts for output (32 MiB) before it crosses the boundary. + */ +function boundedJson(value: JsonValue, description: string): string { + const encoded = stringifyJson(value); + if (Buffer.byteLength(encoded, "utf8") > MAX_VALUE_BYTES) { + throw new ValidationError(`${description} must not exceed 32 MiB`); + } + return encoded; +} + +/** Read a property that a hostile or broken getter may throw from. */ +function safeProperty( + value: object, + key: "message" | "name" | "stack" +): unknown { + try { + return (value as Record)[key]; + } catch { + return undefined; + } +} + +function serializeError(error: unknown): SerializedError { + let isError = false; + try { + isError = error instanceof Error; + } catch { + // A proxy can throw from `instanceof`; describe it as a non-Error value. + } + if (isError) { + const value = error as Error; + const message = safeProperty(value, "message"); + const stack = safeProperty(value, "stack"); + return { + message: truncate( + message === undefined ? "unreadable error message" : safeString(message) + ), + name: truncate( + safeString(safeProperty(value, "name") ?? "") || "Error", + MAX_ERROR_NAME_LENGTH + ), + stack: truncate(stack === undefined ? "" : safeString(stack)), + }; + } + return { message: truncate(safeString(error)), name: "Error", stack: "" }; +} + +function safeString(value: unknown): string { + if (typeof value === "string") return value; + try { + return String(value); + } catch { + return "non-Error value"; + } +} + +function truncate(value: string, limit = MAX_ERROR_TEXT_LENGTH): string { + return value.length <= limit ? value : value.slice(0, limit); +} + +/** + * A handler's outcome as River JSON: a snooze's `Temporal.Duration` crosses + * as its ISO 8601 text, which the executor reads back. + */ +function encodeOutcome(outcome: unknown): unknown { + if ( + typeof outcome === "object" && + outcome !== null && + (outcome as { readonly type?: unknown }).type === "snooze" + ) { + const { duration } = outcome as { readonly duration?: unknown }; + if (duration instanceof Temporal.Duration) { + return { ...outcome, duration: duration.toString() }; + } + } + return outcome; +} diff --git a/js/worker-threads/tsconfig.json b/js/worker-threads/tsconfig.json new file mode 100644 index 000000000..6cdc3cb6e --- /dev/null +++ b/js/worker-threads/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "paths": { + "riverqueue": ["../dist/index.d.ts"] + } + }, + "exclude": [ + "src/**/*.integration.test.ts", + "src/**/*.test.ts", + "src/testdata" + ], + "include": ["src"] +} From f19861ed44651e2d65a26bbf24ae9ef028204ff3 Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sun, 4 Oct 2026 17:25:01 -0500 Subject: [PATCH 33/43] add @riverqueue/test helpers for producers and workers `createTestClient` returns an insert-only client with deterministic IDs and a log of every insertion, so producer code typed against `InsertClient` can be tested without a database. `requireInserted`, `requireNotInserted`, and `requireManyInserted` assert on that log like Go's `rivertest`, and their `InDatabase` variants check jobs a real client inserted through `client.jobs`. `testJob` builds a realistic running job whose args are validated by the definition exactly as the runtime validates them, and `workOnce` runs one handler, or the one a `Workers` registry has for the job's kind, without a database or background runtime. The helpers use the production API's exact `bigint`, `Temporal.Instant`, and JSON values. --- js/package.json | 12 +- js/pnpm-lock.yaml | 6 + js/pnpm-workspace.yaml | 1 + js/test/package.json | 57 +++ js/test/src/index.test.ts | 310 +++++++++++++++++ js/test/src/index.ts | 708 ++++++++++++++++++++++++++++++++++++++ js/test/tsconfig.json | 9 + js/tsconfig.tests.json | 4 + 8 files changed, 1101 insertions(+), 6 deletions(-) create mode 100644 js/test/package.json create mode 100644 js/test/src/index.test.ts create mode 100644 js/test/src/index.ts create mode 100644 js/test/tsconfig.json diff --git a/js/package.json b/js/package.json index eab90288c..06577fad0 100644 --- a/js/package.json +++ b/js/package.json @@ -26,14 +26,14 @@ ], "scripts": { "build": "node node_modules/typescript/bin/tsc", - "build:all": "pnpm run build && pnpm --filter=@riverqueue/migrate run build && pnpm --filter='./driver/*' run build && pnpm --filter=@riverqueue/worker-threads run build", + "build:all": "pnpm run build && pnpm --filter=@riverqueue/migrate run build && pnpm --filter='./driver/*' run build && pnpm --filter=@riverqueue/worker-threads run build && pnpm --filter=@riverqueue/test run build", "clean": "rm -rf dist", - "clean:all": "pnpm run clean && pnpm --filter=@riverqueue/migrate run clean && pnpm --filter='./driver/*' run clean && pnpm --filter=@riverqueue/worker-threads run clean", - "fmt": "prettier --write 'src/**/*.ts' 'driver/**/src/**/*.ts' 'migrate/src/**/*.ts' 'worker-threads/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts' 'examples/*/{README.md,package.json,tsconfig.json}' 'examples/tsconfig.json' '{README.md,package.json}'", - "fmt:check": "prettier --check 'src/**/*.ts' 'driver/**/src/**/*.ts' 'migrate/src/**/*.ts' 'worker-threads/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts' 'examples/*/{README.md,package.json,tsconfig.json}' 'examples/tsconfig.json' '{README.md,package.json}'", + "clean:all": "pnpm run clean && pnpm --filter=@riverqueue/migrate run clean && pnpm --filter='./driver/*' run clean && pnpm --filter=@riverqueue/worker-threads run clean && pnpm --filter=@riverqueue/test run clean", + "fmt": "prettier --write 'src/**/*.ts' 'driver/**/src/**/*.ts' 'migrate/src/**/*.ts' 'worker-threads/src/**/*.ts' 'test/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts' 'examples/*/{README.md,package.json,tsconfig.json}' 'examples/tsconfig.json' '{README.md,package.json}'", + "fmt:check": "prettier --check 'src/**/*.ts' 'driver/**/src/**/*.ts' 'migrate/src/**/*.ts' 'worker-threads/src/**/*.ts' 'test/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts' 'examples/*/{README.md,package.json,tsconfig.json}' 'examples/tsconfig.json' '{README.md,package.json}'", "generate:migrations": "node scripts/sync-migrations.mjs", - "lint": "eslint 'src/**/*.ts' 'driver/**/src/**/*.ts' 'migrate/src/**/*.ts' 'worker-threads/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts'", - "lint:fix": "eslint --fix 'src/**/*.ts' 'driver/**/src/**/*.ts' 'migrate/src/**/*.ts' 'worker-threads/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts'", + "lint": "eslint 'src/**/*.ts' 'driver/**/src/**/*.ts' 'migrate/src/**/*.ts' 'worker-threads/src/**/*.ts' 'test/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts'", + "lint:fix": "eslint --fix 'src/**/*.ts' 'driver/**/src/**/*.ts' 'migrate/src/**/*.ts' 'worker-threads/src/**/*.ts' 'test/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts'", "prepublishOnly": "pnpm run clean && pnpm run build", "test": "vitest run --passWithNoTests", "test:coverage": "vitest run --coverage", diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml index 515c1af1c..dfe6702e8 100644 --- a/js/pnpm-lock.yaml +++ b/js/pnpm-lock.yaml @@ -179,6 +179,12 @@ importers: specifier: workspace:0.50.0-alpha.1 version: link:.. + test: + devDependencies: + riverqueue: + specifier: workspace:0.50.0-alpha.1 + version: link:.. + worker-threads: devDependencies: '@riverqueue/driver-sqlite': diff --git a/js/pnpm-workspace.yaml b/js/pnpm-workspace.yaml index eed7e5f86..c2ce9208b 100644 --- a/js/pnpm-workspace.yaml +++ b/js/pnpm-workspace.yaml @@ -2,6 +2,7 @@ packages: - "driver/*" - "examples/*" - "migrate" + - "test" - "worker-threads" ignoredBuiltDependencies: diff --git a/js/test/package.json b/js/test/package.json new file mode 100644 index 000000000..b6165084e --- /dev/null +++ b/js/test/package.json @@ -0,0 +1,57 @@ +{ + "name": "@riverqueue/test", + "version": "0.50.0-alpha.1", + "description": "Typed test helpers for River jobs, workers, and clients.", + "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", + "test": "vitest run src" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/riverqueue/river.git", + "directory": "js/test" + }, + "contributors": [ + "Brandur Leach", + "Blake Gentry" + ], + "license": "LGPL-3.0-or-later", + "publishConfig": { + "access": "public", + "provenance": true + }, + "peerDependencies": { + "riverqueue": "workspace:0.50.0-alpha.1" + }, + "devDependencies": { + "riverqueue": "workspace:0.50.0-alpha.1" + }, + "keywords": [ + "river", + "job-queue", + "testing" + ] +} diff --git a/js/test/src/index.test.ts b/js/test/src/index.test.ts new file mode 100644 index 000000000..9cad3273d --- /dev/null +++ b/js/test/src/index.test.ts @@ -0,0 +1,310 @@ +import { AssertionError } from "node:assert"; + +import { + defineJob, + PayloadValidationError, + snooze, + Workers, + type InsertClient, +} from "riverqueue"; +import { describe, expect, expectTypeOf, test } from "vitest"; +import { z } from "zod"; + +import { + createTestClient, + requireInserted, + requireInsertedInDatabase, + requireManyInserted, + requireNotInserted, + requireNotInsertedInDatabase, + testJob, + workOnce, +} from "./index.js"; +import type { JobListingClient } from "./index.js"; + +const emailJob = defineJob({ + defaults: { maxAttempts: 7, priority: 3, queue: "testing" }, + kind: "test_email", + schema: z.object({ + message: z.string().transform((message) => message.trim()), + }), +}); + +describe("testJob", () => { + test("validates input like the runtime and applies definition defaults", async () => { + const now = Temporal.Instant.from("2026-08-30T20:00:00.123456789Z"); + const job = await testJob( + emailJob, + { message: " hello " }, + { + attempt: 4, + id: 9_007_199_254_740_993n, + metadata: { source: "test" }, + now, + state: "retryable", + } + ); + + expectTypeOf(job.args).toEqualTypeOf<{ message: string }>(); + expect(job).toMatchObject({ + args: { message: "hello" }, + attempt: 4, + attemptedAt: now, + attemptedBy: ["riverqueue-test"], + createdAt: now, + id: 9_007_199_254_740_993n, + kind: "test_email", + maxAttempts: 7, + metadata: { source: "test" }, + priority: 3, + queue: "testing", + rawArgs: { message: " hello " }, + state: "retryable", + }); + await expect( + testJob(emailJob, { message: 1 } as unknown as { message: string }) + ).rejects.toSatisfy( + (error: unknown) => + error instanceof PayloadValidationError && error.phase === "work" + ); + }); +}); + +describe("workOnce", () => { + test("persists metadata and suppressed resumable errors like the runtime", async () => { + const job = await testJob(emailJob, { message: "resume" }); + let firstRuns = 0; + const first = await workOnce(job, async ({ resumable, setMetadata }) => { + setMetadata("application", true); + await resumable.step("first", () => { + firstRuns++; + }); + await resumable + .step("second", () => { + throw new Error("retry"); + }) + .catch(() => undefined); + }); + expect(first.status).toBe("failed"); + expect(first.metadata).toEqual({ + application: true, + "river:resumable_step": "first", + }); + const second = await workOnce( + { ...job, attempt: 2, metadata: first.metadata }, + async ({ resumable }) => { + await resumable.step("first", () => { + firstRuns++; + }); + await resumable.step("second", () => undefined); + } + ); + expect(second.status).toBe("succeeded"); + expect(firstRuns).toBe(1); + }); + + test("captures output, logs, outcomes, and errors", async () => { + const job = await testJob(emailJob, { message: "work once" }); + const worked = await workOnce(job, ({ job, logger, recordOutput }) => { + logger.info({ id: job.id.toString(10) }, "working"); + recordOutput({ delivered: true }); + return snooze({ seconds: 30 }); + }); + + expect(worked).toMatchObject({ + logs: [ + { + attributes: { id: "1" }, + level: "info", + message: "working", + }, + ], + outcome: { + duration: Temporal.Duration.from({ seconds: 30 }), + type: "snooze", + }, + output: { delivered: true }, + status: "succeeded", + }); + + const error = new Error("worker failed"); + const failed = await workOnce(job, () => { + throw error; + }); + expect(failed).toMatchObject({ error, status: "failed" }); + }); + + test("runs the handler registered in a Workers bundle with its timeout", async () => { + const workers = new Workers().add( + emailJob, + async ({ job, signal }) => { + await new Promise((resolve) => setTimeout(resolve, 50)); + signal.throwIfAborted(); + return job.args.message.length > 0 ? undefined : undefined; + }, + { timeout: { milliseconds: 1 } } + ); + const job = await testJob(emailJob, { message: "timeout" }); + + const worked = await workOnce(job, workers); + + expect(worked.status).toBe("failed"); + await expect( + workOnce({ ...job, kind: "unknown" }, workers) + ).rejects.toBeInstanceOf(AssertionError); + }); + + test("records insertions from ctx.client and makes transactions explicit", async () => { + const job = await testJob(emailJob, { message: "steps" }); + const followUp = defineJob({ kind: "follow_up" }); + const worked = await workOnce( + job, + async ({ client, completeTx, resumable }) => { + await resumable.step("first", () => undefined); + await client.insert(followUp, { from: job.id.toString(10) }); + await expect(completeTx({})).rejects.toThrow( + 'does not support "transactional completion"' + ); + } + ); + + expect(worked.status).toBe("succeeded"); + }); +}); + +describe("createTestClient", () => { + test("records exact deterministic insertions and transactions", async () => { + interface ApplicationTransaction { + readonly name: string; + } + const now = Temporal.Instant.from("2026-08-30T21:00:00.123456789Z"); + const testClient = createTestClient({ + now: () => now, + startingId: 100n, + }); + expectTypeOf(testClient.client).toEqualTypeOf< + InsertClient + >(); + const tx = { name: "application transaction" }; + const result = await testClient.client.insert( + emailJob, + { message: "inserted" }, + { tx } + ); + + expect(result.job).toMatchObject({ createdAt: now, id: 100n }); + expect(testClient.insertions).toHaveLength(1); + expect(testClient.insertions[0]).toMatchObject({ + job: { args: { message: "inserted" }, kind: "test_email" }, + transaction: tx, + }); + }); + + test("asserts on insertions like rivertest", async () => { + const other = defineJob({ kind: "other_job" }); + const { client, insertions } = createTestClient(); + await client.insert(emailJob, { message: "one" }, { priority: 1 }); + await client.insertMany([ + { args: { n: 1 }, job: other }, + { args: { message: "two" }, job: emailJob }, + ]); + + const inserted = requireInserted(insertions, emailJob, { + args: { message: "one" }, + priority: 1, + }); + expectTypeOf(inserted.args).toEqualTypeOf<{ message: string }>(); + expect(inserted.queue).toBe("testing"); + expect(() => requireInserted(insertions, emailJob)).toThrow(AssertionError); + requireNotInserted(insertions, emailJob, { args: { message: "three" } }); + expect(() => requireNotInserted(insertions, other)).toThrow(AssertionError); + expect( + requireManyInserted(insertions, [ + { job: emailJob, queue: "testing" }, + { args: { n: 1 }, job: other }, + { job: emailJob }, + ]) + ).toHaveLength(3); + expect(() => + requireManyInserted(insertions, [{ job: emailJob }, { job: other }]) + ).toThrow(AssertionError); + }); + + test("runs insert options, hooks, middleware, and plugins", async () => { + const seen: string[] = []; + const { client, insertions } = createTestClient({ + defaultInsertOptions: { tags: ["default"] }, + hooks: { + beforeInsert: ({ operation }) => { + seen.push(`hook:${operation}`); + }, + }, + insertMiddleware: [ + async ({ requests }, next) => { + seen.push(`middleware:${requests.length}`); + return next(); + }, + ], + plugins: [{ hooks: {}, name: "noop" }], + }); + + await client.insert(emailJob, { message: "one" }); + + expect(seen).toEqual(["middleware:1", "hook:insert"]); + expect(requireInserted(insertions, emailJob).tags).toEqual(["default"]); + }); +}); + +describe("requireInsertedInDatabase", () => { + test("pages through persisted jobs, optionally inside a transaction", async () => { + const tx = { transaction: true }; + const listed: unknown[] = []; + const rows = await Promise.all( + Array.from({ length: 1_500 }, (_, index) => + testJob( + emailJob, + { message: index === 1_200 ? "wanted" : "other" }, + { id: BigInt(index + 1), state: "available" } + ) + ) + ); + const client = { + jobs: { + list: (options: { after?: string; limit?: number; tx?: unknown }) => { + listed.push(options); + const start = options.after === undefined ? 0 : Number(options.after); + const end = start + (options.limit ?? 100); + return Promise.resolve({ + jobs: rows.slice(start, end), + nextCursor: end < rows.length ? String(end) : null, + }); + }, + }, + } as unknown as JobListingClient; + + const found = await requireInsertedInDatabase( + client, + emailJob, + { args: { message: "wanted" } }, + { tx } + ); + + expect(found.id).toBe(1_201n); + expectTypeOf(found.args).toEqualTypeOf<{ message: string }>(); + expect(listed).toEqual([ + { kinds: ["test_email"], limit: 1_000, tx }, + { after: "1000", kinds: ["test_email"], limit: 1_000, tx }, + ]); + await requireNotInsertedInDatabase(client, emailJob, { + args: { message: "missing" }, + }); + await expect(requireInsertedInDatabase(client, emailJob)).rejects.toThrow( + AssertionError + ); + await expect( + requireNotInsertedInDatabase(client, emailJob, { + args: { message: "wanted" }, + }) + ).rejects.toThrow(AssertionError); + }); +}); diff --git a/js/test/src/index.ts b/js/test/src/index.ts new file mode 100644 index 000000000..af2912b10 --- /dev/null +++ b/js/test/src/index.ts @@ -0,0 +1,708 @@ +/** + * Typed helpers for testing River job producers and workers. + * + * - {@link createTestClient} records insertions instead of writing them, and + * {@link requireInserted} / {@link requireNotInserted} / + * {@link requireManyInserted} assert on that log like Go's `rivertest`. + * {@link requireInsertedInDatabase} / {@link requireNotInsertedInDatabase} + * assert on the jobs a real client persisted, optionally inside a + * transaction. + * - {@link testJob} builds a realistic job by validating producer input + * through its definition, exactly as the runtime does before working. + * - {@link workOnce} runs one handler (directly or from a `Workers` + * registry) and returns its outcome, output, metadata, and logs. + * + * For full runtime semantics (middleware, retries, scheduling, persistence), + * run a real client against an in-memory `node:sqlite` database; see the + * testing guide. + * + * @packageDocumentation + */ +import { AssertionError } from "node:assert"; + +import { + Client, + JOB_STATE, + MAX_ATTEMPTS_DEFAULT, + PRIORITY_DEFAULT, + QUEUE_DEFAULT, + toJsonObject, + toJsonValue, + UnsupportedCapabilityError, +} from "riverqueue"; +import type { + ClientOptions, + InsertClient, + Job, + JobDefinition, + JobDefinitionArgs, + JobDefinitionInput, + JobListResult, + JobOperations, + JobRow, + JobState, + JsonObject, + JsonValue, + LogAttributes, + Resumable, + WorkContext, + WorkHandler, + WorkLogFunction, + WorkLogger, + Workers, + WorkOutcome, +} from "riverqueue"; +import { + createResumable, + decodeJobArgs, + finishResumable, + jsonValuesEqual, + registerDriver, + workerRegistration, +} from "riverqueue/unstable-driver"; +import type { + DriverInsertResult, + InsertDriver, + InsertDriverOptions, + JobInsertParams, +} from "riverqueue/unstable-driver"; + +/** One job recorded by a test client, with the transaction it was given. */ +export interface TestInsertion { + /** The row River would have persisted, with a deterministic ID. */ + readonly job: JobRow; + /** The caller-owned transaction passed as `{ tx }`, if any. */ + readonly transaction: Transaction | undefined; +} + +/** + * Options for {@link createTestClient}. Insert options, hooks, insert + * middleware, and plugins configure the client as they would a real one, so + * a test sees the jobs they produce. + */ +export interface TestClientOptions extends Pick< + ClientOptions, + "defaultInsertOptions" | "hooks" | "insertMiddleware" | "plugins" +> { + /** Clock used for `createdAt`. Defaults to `Temporal.Now.instant`. */ + readonly now?: () => Temporal.Instant; + /** First ID assigned to an inserted job. Defaults to `1n`. */ + readonly startingId?: bigint; +} + +/** An insert-only test client and its insertion log. */ +export interface TestClient { + /** + * An insert-only client. Pass it wherever application code takes an + * `InsertClient`. + */ + readonly client: InsertClient; + /** Insertions in call order. The array grows as the client inserts. */ + readonly insertions: readonly TestInsertion[]; +} + +/** + * Create a deterministic, database-free client that records insertions. + * + * `Transaction` is the transaction type application code passes as `{ tx }` + * (for example `PoolClient`); it is recorded but never used. + * + * @example + * ```ts + * const { client, insertions } = createTestClient(); + * await signUp(client, "person@example.com"); + * requireInserted(insertions, sendWelcomeEmail, { + * args: { to: "person@example.com" }, + * }); + * ``` + */ +export function createTestClient( + options: TestClientOptions = {} +): TestClient { + const { + defaultInsertOptions, + hooks, + insertMiddleware, + now, + plugins, + startingId, + } = options; + const driver = new TestInsertDriver({ + ...(now === undefined ? {} : { now }), + ...(startingId === undefined ? {} : { startingId }), + }); + return { + client: new Client(driver, { + ...(defaultInsertOptions === undefined ? {} : { defaultInsertOptions }), + ...(hooks === undefined ? {} : { hooks }), + ...(insertMiddleware === undefined ? {} : { insertMiddleware }), + ...(plugins === undefined ? {} : { plugins }), + }), + insertions: driver.insertions, + }; +} + +/** Fields an inserted job must match; omitted fields are not compared. */ +export interface InsertedJobMatch { + /** Top-level args that must be equal (compared as JSON). */ + readonly args?: Partial>; + /** Attempts allowed before the job is discarded. */ + readonly maxAttempts?: number; + /** Metadata that must be equal (compared as JSON). */ + readonly metadata?: JsonObject; + /** Priority the job was inserted with. */ + readonly priority?: number; + /** Queue the job was inserted into. */ + readonly queue?: string; + /** Time the job was scheduled for, compared exactly. */ + readonly scheduledAt?: Temporal.Instant; + /** State the job was inserted in, such as `scheduled`. */ + readonly state?: JobState; + /** Tags that must be equal, in order. */ + readonly tags?: readonly string[]; +} + +/** A test client or its insertion log. */ +export type InsertionLog = + Pick | readonly TestInsertion[]; + +/** + * Assert that exactly one recorded insertion matches `definition` (and + * `match`, when given) and return it with its producer args typed. Throws an + * `AssertionError` describing the recorded jobs otherwise. + */ +export function requireInserted( + log: InsertionLog, + definition: Definition, + match: InsertedJobMatch = {} +): JobRow> { + const matches = findInserted(log, definition, match); + if (matches.length !== 1) { + throw new AssertionError({ + message: `expected exactly one inserted ${JSON.stringify(definition.kind)} job matching ${JSON.stringify(match)}, found ${matches.length}; inserted: ${describeLog(log)}`, + }); + } + return matches[0] as JobRow>; +} + +/** + * Assert that no recorded insertion matches `definition` (and `match`, when + * given). + */ +export function requireNotInserted( + log: InsertionLog, + definition: Definition, + match: InsertedJobMatch = {} +): void { + const matches = findInserted(log, definition, match); + if (matches.length > 0) { + throw new AssertionError({ + message: `expected no inserted ${JSON.stringify(definition.kind)} job matching ${JSON.stringify(match)}, found ${matches.length}`, + }); + } +} + +/** A client whose persisted jobs can be listed, such as a runtime `Client`. */ +export interface JobListingClient { + readonly jobs: Pick, "list">; +} + +/** + * Assert that exactly one job in the database matches `definition` (and + * `match`, when given) and return it with its producer args typed, like + * Go's `rivertest.RequireInsertedTx`. Pass `tx` to look inside a transaction + * that hasn't committed yet. Throws an `AssertionError` otherwise. + */ +export async function requireInsertedInDatabase< + Definition extends JobDefinition, + Transaction = unknown, +>( + client: JobListingClient, + definition: Definition, + match: InsertedJobMatch = {}, + options: { readonly tx?: Transaction } = {} +): Promise>> { + const matches = await findPersisted(client, definition, match, options); + if (matches.length !== 1) { + throw new AssertionError({ + message: `expected exactly one ${JSON.stringify(definition.kind)} job in the database matching ${JSON.stringify(match)}, found ${matches.length}`, + }); + } + return matches[0] as JobRow>; +} + +/** + * Assert that no job in the database matches `definition` (and `match`, + * when given), like Go's `rivertest.RequireNotInsertedTx`. + */ +export async function requireNotInsertedInDatabase< + Definition extends JobDefinition, + Transaction = unknown, +>( + client: JobListingClient, + definition: Definition, + match: InsertedJobMatch = {}, + options: { readonly tx?: Transaction } = {} +): Promise { + const matches = await findPersisted(client, definition, match, options); + if (matches.length > 0) { + throw new AssertionError({ + message: `expected no ${JSON.stringify(definition.kind)} job in the database matching ${JSON.stringify(match)}, found ${matches.length}`, + }); + } +} + +/** One expected insertion for {@link requireManyInserted}. */ +export interface ExpectedInsertion< + Definition extends JobDefinition = JobDefinition, +> extends InsertedJobMatch { + /** Definition the inserted job must belong to. */ + readonly job: Definition; +} + +/** + * Assert that the recorded insertions are exactly `expected`, in order, and + * return them. + */ +export function requireManyInserted( + log: InsertionLog, + expected: readonly ExpectedInsertion[] +): readonly JobRow[] { + const jobs = insertionsOf(log).map(({ job }) => job); + const ok = + jobs.length === expected.length && + expected.every((item, index) => { + const job = jobs[index]; + return ( + job !== undefined && job.kind === item.job.kind && jobMatches(job, item) + ); + }); + if (!ok) { + throw new AssertionError({ + message: `expected inserted jobs ${JSON.stringify(expected.map(({ job }) => job.kind))}, found ${describeLog(log)}`, + }); + } + return jobs; +} + +/** Row fields that {@link testJob} lets a test override. */ +export interface TestJobOptions { + /** Attempt number of the running attempt. Defaults to 1. */ + readonly attempt?: number; + /** When the attempt started. Defaults to `now`. */ + readonly attemptedAt?: Temporal.Instant | null; + /** Clients that attempted the job, oldest first; `riverqueue-test` by default. */ + readonly attemptedBy?: readonly string[]; + /** When the job was inserted. Defaults to `now`. */ + readonly createdAt?: Temporal.Instant; + /** Errors recorded by earlier attempts, oldest first. */ + readonly errors?: JobRow["errors"]; + /** When the job reached a final state; null while it can still run. */ + readonly finalizedAt?: Temporal.Instant | null; + /** Job ID. Defaults to 1. */ + readonly id?: bigint; + /** Attempts allowed. Defaults to the definition's default, else River's. */ + readonly maxAttempts?: number; + /** Job metadata. Defaults to `{}`. */ + readonly metadata?: JsonObject; + /** Clock for unspecified timestamps. Defaults to `Temporal.Now.instant()`. */ + readonly now?: Temporal.Instant; + /** Priority from 1 (first) to 4. Defaults like `maxAttempts`. */ + readonly priority?: number; + /** Queue of the job. Defaults like `maxAttempts`. */ + readonly queue?: string; + /** When the job became available. Defaults to the definition's, else `now`. */ + readonly scheduledAt?: Temporal.Instant; + /** Job state. Defaults to `running`, as a handler sees it. */ + readonly state?: JobState; + readonly tags?: readonly string[]; + /** Unique key bytes of a unique job, or null. */ + readonly uniqueKey?: Uint8Array | null; + /** States in which the unique key is enforced, or null. */ + readonly uniqueStates?: readonly JobState[] | null; +} + +/** + * Build a realistic running job for a worker test. + * + * `input` is what a producer would insert. It is converted to River JSON and + * validated with the definition's schema or decoder, exactly as the runtime + * does before working, so `job.args` is the worker's typed args and + * `job.rawArgs` the persisted JSON. Row fields default from the definition's + * insertion defaults. + */ +export async function testJob( + definition: Definition, + input: JobDefinitionInput, + options: TestJobOptions = {} +): Promise> { + const now = options.now ?? Temporal.Now.instant(); + const defaults = definition.defaults; + const rawArgs = toJsonObject(input); + const args: JobDefinitionArgs = await decodeJobArgs( + definition, + rawArgs + ); + return { + args, + attempt: options.attempt ?? 1, + attemptedAt: options.attemptedAt === undefined ? now : options.attemptedAt, + attemptedBy: Object.freeze([ + ...(options.attemptedBy ?? ["riverqueue-test"]), + ]), + createdAt: options.createdAt ?? now, + errors: Object.freeze([...(options.errors ?? [])]), + finalizedAt: options.finalizedAt ?? null, + id: options.id ?? 1n, + kind: definition.kind, + maxAttempts: + options.maxAttempts ?? defaults.maxAttempts ?? MAX_ATTEMPTS_DEFAULT, + metadata: toJsonObject(options.metadata ?? defaults.metadata ?? {}), + priority: options.priority ?? defaults.priority ?? PRIORITY_DEFAULT, + queue: options.queue ?? defaults.queue ?? QUEUE_DEFAULT, + rawArgs, + scheduledAt: options.scheduledAt ?? defaults.scheduledAt ?? now, + state: options.state ?? JOB_STATE.running, + tags: Object.freeze([...(options.tags ?? defaults.tags ?? [])]), + uniqueKey: options.uniqueKey ?? null, + uniqueStates: options.uniqueStates ?? null, + }; +} + +/** One message logged through the work context's logger. */ +export interface TestLogEntry { + readonly attributes: LogAttributes | undefined; + readonly level: "debug" | "error" | "info" | "warn"; + readonly message: string; +} + +/** Options for {@link workOnce}. */ +export interface WorkOnceOptions { + /** Worker identity recorded in `ctx.execution`. */ + readonly attemptedBy?: string; + /** + * Client exposed as `ctx.client`. Defaults to a {@link createTestClient} + * client that records insertions; its job and queue operations throw + * `UnsupportedCapabilityError`. + */ + readonly client?: Client; + /** Implementation of `ctx.completeTx`. Defaults to one that rejects. */ + readonly completeTx?: ( + tx: Transaction, + options?: { readonly output?: JsonValue } + ) => Promise; + /** Start time recorded in `ctx.execution`. */ + readonly now?: Temporal.Instant; + /** Resumable state. Defaults to one read from the job's metadata. */ + readonly resumable?: Resumable; + /** Abort signal exposed as `ctx.signal`. */ + readonly signal?: AbortSignal; +} + +interface WorkOnceResultBase { + readonly context: WorkContext; + /** Messages the handler logged. */ + readonly logs: readonly TestLogEntry[]; + /** Metadata updates to merge with this attempt, including resumable progress. */ + readonly metadata: JsonObject; + /** Output recorded with `recordOutput` or `complete({ output })`. */ + readonly output: JsonValue | undefined; +} + +/** Result of {@link workOnce}, narrowed by `status`. */ +export type WorkOnceResult< + Definition extends JobDefinition, + Transaction = unknown, +> = + | (WorkOnceResultBase & { + readonly outcome: WorkOutcome | undefined; + readonly status: "succeeded"; + }) + | (WorkOnceResultBase & { + readonly error: unknown; + readonly status: "failed"; + }); + +/** + * Run one handler against a job without a database or background runtime. + * + * `handler` is either a handler function or a `Workers` registry, in which + * case the in-process handler registered for `job.kind` runs with its + * configured timeout applied to `ctx.signal`. Middleware, hooks, retries, + * and persistence are not simulated. + */ +export async function workOnce< + Definition extends JobDefinition, + Transaction = unknown, +>( + job: Job, + handler: WorkHandler | Workers, + options: WorkOnceOptions = {} +): Promise> { + const { handler: work, timeoutSignal } = resolveHandler(job, handler); + const logs: TestLogEntry[] = []; + const metadata: JsonObject = {}; + let output: JsonValue | undefined; + const client = + options.client ?? + (createTestClient().client as Client); + const signals = [options.signal, timeoutSignal].filter( + (signal): signal is AbortSignal => signal !== undefined + ); + const context: WorkContext = { + client, + completeTx: + options.completeTx ?? + (() => + Promise.reject( + new UnsupportedCapabilityError( + "@riverqueue/test workOnce", + "transactional completion" + ) + )), + execution: { + attemptedBy: options.attemptedBy ?? "riverqueue-test", + startedAt: options.now ?? Temporal.Now.instant(), + }, + job, + logger: testLogger(logs), + recordOutput: (value) => { + output = toJsonValue(value); + metadata.output = output; + }, + resumable: + options.resumable ?? + createResumable(client, { ...job, args: job.rawArgs }), + setMetadata: (key, value) => { + metadata[key] = toJsonValue(value); + }, + signal: + signals.length === 0 + ? new AbortController().signal + : AbortSignal.any(signals), + }; + try { + context.signal.throwIfAborted(); + const outcome = (await work(context)) as WorkOutcome | undefined; + if (outcome?.type === "complete" && outcome.output !== undefined) { + context.recordOutput(outcome.output); + } + const finished = finishResumable(context.resumable, false); + Object.assign(metadata, finished.metadata); + if (finished.error !== null) throw finished.error; + return { + context, + logs, + metadata: toJsonObject(metadata), + outcome, + output, + status: "succeeded", + }; + } catch (error: unknown) { + Object.assign(metadata, finishResumable(context.resumable, true).metadata); + return { + context, + error, + logs, + metadata: toJsonObject(metadata), + output, + status: "failed", + }; + } +} + +function resolveHandler( + job: Job, + handler: WorkHandler | Workers +): { + readonly handler: WorkHandler; + readonly timeoutSignal: AbortSignal | undefined; +} { + if (typeof handler === "function") { + return { handler, timeoutSignal: undefined }; + } + const registration = workerRegistration(handler, job.kind); + if (registration === undefined) { + throw new AssertionError({ + message: `no worker registered for job kind ${JSON.stringify(job.kind)}`, + }); + } + if (registration.type !== "in_process") { + throw new UnsupportedCapabilityError( + "@riverqueue/test workOnce", + "executor-owned workers" + ); + } + const timeout = registration.options.timeout; + return { + handler: registration.handler as unknown as WorkHandler< + Definition, + Transaction + >, + timeoutSignal: + timeout === undefined || timeout === null + ? undefined + : AbortSignal.timeout(Math.max(1, timeout.total("milliseconds"))), + }; +} + +class TestInsertDriver implements InsertDriver { + declare readonly "~river"?: { + readonly capability: "insert"; + readonly transaction: Transaction; + }; + readonly insertions: TestInsertion[] = []; + readonly #now: () => Temporal.Instant; + #nextId: bigint; + + constructor(options: Pick) { + this.#nextId = options.startingId ?? 1n; + this.#now = options.now ?? (() => Temporal.Now.instant()); + registerDriver(this, { + backend: "test", + capability: "insert", + operations: this, + }); + } + + jobInsert( + params: JobInsertParams, + options: InsertDriverOptions = {} + ): DriverInsertResult { + const [result] = this.#insert([params], options); + if (result === undefined) throw new Error("test insertion returned no job"); + return result; + } + + jobInsertMany( + params: readonly JobInsertParams[], + options: InsertDriverOptions = {} + ): readonly DriverInsertResult[] { + return this.#insert(params, options); + } + + #insert( + params: readonly JobInsertParams[], + options: InsertDriverOptions + ): readonly DriverInsertResult[] { + return params.map((item) => { + const job: JobRow = Object.freeze({ + args: item.args, + attempt: 0, + attemptedAt: null, + attemptedBy: [], + createdAt: this.#now(), + errors: [], + finalizedAt: null, + id: this.#nextId++, + kind: item.kind, + maxAttempts: item.maxAttempts, + metadata: item.metadata, + priority: item.priority, + queue: item.queue, + scheduledAt: item.scheduledAt ?? Temporal.Now.instant(), + state: item.state, + tags: item.tags, + uniqueKey: item.uniqueKey, + uniqueStates: item.uniqueStates, + }); + this.insertions.push(Object.freeze({ job, transaction: options.tx })); + return { job, status: "inserted" }; + }); + } +} + +function insertionsOf(log: InsertionLog): readonly TestInsertion[] { + return "insertions" in log ? log.insertions : log; +} + +function findInserted( + log: InsertionLog, + definition: Definition, + match: InsertedJobMatch +): readonly JobRow[] { + return insertionsOf(log) + .map(({ job }) => job) + .filter((job) => job.kind === definition.kind && jobMatches(job, match)); +} + +/** Every persisted job of `definition`'s kind matching `match`. */ +async function findPersisted( + client: JobListingClient, + definition: JobDefinition, + match: InsertedJobMatch, + options: { readonly tx?: Transaction } +): Promise { + const matches: JobRow[] = []; + let after: string | null = null; + do { + const page: JobListResult = await client.jobs.list({ + ...(after === null ? {} : { after }), + kinds: [definition.kind], + limit: 1_000, + ...(options.tx === undefined ? {} : { tx: options.tx }), + }); + matches.push(...page.jobs.filter((job) => jobMatches(job, match))); + after = page.nextCursor; + } while (after !== null); + return matches; +} + +function jobMatches( + job: JobRow, + match: InsertedJobMatch +): boolean { + if (match.args !== undefined) { + for (const [key, value] of Object.entries(toJsonObject(match.args))) { + const actual = job.args[key]; + if (actual === undefined || !jsonValuesEqual(actual, value)) return false; + } + } + if (match.metadata !== undefined) { + const metadata = toJsonObject(match.metadata); + for (const [key, value] of Object.entries(metadata)) { + const actual = job.metadata[key]; + if (actual === undefined || !jsonValuesEqual(actual, value)) return false; + } + } + return ( + (match.maxAttempts === undefined || + job.maxAttempts === match.maxAttempts) && + (match.priority === undefined || job.priority === match.priority) && + (match.queue === undefined || job.queue === match.queue) && + (match.scheduledAt === undefined || + job.scheduledAt.equals(match.scheduledAt)) && + (match.state === undefined || job.state === match.state) && + (match.tags === undefined || + (job.tags.length === match.tags.length && + job.tags.every((tag, index) => tag === match.tags?.[index]))) + ); +} + +function describeLog(log: InsertionLog): string { + return JSON.stringify( + insertionsOf(log).map(({ job }) => ({ + args: job.args, + kind: job.kind, + queue: job.queue, + })) + ); +} + +function testLogger(entries: TestLogEntry[]): WorkLogger { + const append = + (level: TestLogEntry["level"]): WorkLogFunction => + (first: LogAttributes | string, message?: string): void => { + entries.push( + typeof first === "string" + ? { attributes: undefined, level, message: first } + : { attributes: first, level, message: message ?? "" } + ); + }; + return { + debug: append("debug"), + error: append("error"), + info: append("info"), + warn: append("warn"), + }; +} diff --git a/js/test/tsconfig.json b/js/test/tsconfig.json new file mode 100644 index 000000000..015e9bcd6 --- /dev/null +++ b/js/test/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/tsconfig.tests.json b/js/tsconfig.tests.json index 71071d1ac..7a5a42b04 100644 --- a/js/tsconfig.tests.json +++ b/js/tsconfig.tests.json @@ -15,6 +15,9 @@ "@riverqueue/migrate": [ "./migrate/src/index.ts" ], + "@riverqueue/test": [ + "./test/src/index.ts" + ], "@riverqueue/worker-threads": [ "./worker-threads/src/index.ts" ] @@ -24,6 +27,7 @@ "src", "driver/*/src", "migrate/src", + "test/src", "worker-threads/src" ] } From a73e22a7a53d113741f67f5c89c497747d41b3c9 Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sun, 4 Oct 2026 17:25:57 -0500 Subject: [PATCH 34/43] add the riverqueue command line in @riverqueue/cli Mirror River's Go CLI for migrations: `migrate-up`, `migrate-down`, `migrate-list`, `validate`, and `migrate-get` on PostgreSQL (`postgres://` URLs or `PG*` variables) and SQLite (`sqlite://PATH`), with `--schema`, `--target-version`, `--max-steps`, `--dry-run`, `--show-sql`, and Go's 10 second default `--statement-timeout`. `run()` runs the same commands in-process, and an embedding package can add its own migration lines, selected with `--line`, as Go's CLI allows. `riverqueue bench` inserts and works no-op jobs on a disposable PostgreSQL database and reports throughput with the same default workload as River's Go and Rust benchmarks, plus peak running jobs, pending completions, pool connections, event-loop delay, and memory. It requires an explicit `--database-url` and confirmation, since it empties River's tables. --- js/cli/package.json | 81 ++++ js/cli/src/bench.ts | 580 +++++++++++++++++++++++ js/cli/src/benchmark-metrics.test.ts | 130 +++++ js/cli/src/benchmark-metrics.ts | 132 ++++++ js/cli/src/bin.ts | 7 + js/cli/src/command.ts | 56 +++ js/cli/src/database.ts | 150 ++++++ js/cli/src/index.ts | 10 + js/cli/src/migrate-commands.ts | 467 ++++++++++++++++++ js/cli/src/options.ts | 278 +++++++++++ js/cli/src/run.integration.test.ts | 184 ++++++++ js/cli/src/run.test.ts | 681 +++++++++++++++++++++++++++ js/cli/src/run.ts | 265 +++++++++++ js/cli/tsconfig.json | 9 + js/package.json | 12 +- js/pnpm-lock.yaml | 25 + js/pnpm-workspace.yaml | 1 + js/tsconfig.tests.json | 1 + 18 files changed, 3063 insertions(+), 6 deletions(-) create mode 100644 js/cli/package.json create mode 100644 js/cli/src/bench.ts create mode 100644 js/cli/src/benchmark-metrics.test.ts create mode 100644 js/cli/src/benchmark-metrics.ts create mode 100644 js/cli/src/bin.ts create mode 100644 js/cli/src/command.ts create mode 100644 js/cli/src/database.ts create mode 100644 js/cli/src/index.ts create mode 100644 js/cli/src/migrate-commands.ts create mode 100644 js/cli/src/options.ts create mode 100644 js/cli/src/run.integration.test.ts create mode 100644 js/cli/src/run.test.ts create mode 100644 js/cli/src/run.ts create mode 100644 js/cli/tsconfig.json diff --git a/js/cli/package.json b/js/cli/package.json new file mode 100644 index 000000000..4aac3f73a --- /dev/null +++ b/js/cli/package.json @@ -0,0 +1,81 @@ +{ + "name": "@riverqueue/cli", + "version": "0.50.0-alpha.1", + "description": "Command-line migrations and benchmarks 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" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@types/pg": { + "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/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..e1adfce68 --- /dev/null +++ b/js/cli/src/index.ts @@ -0,0 +1,10 @@ +/** + * The `riverqueue` command line for River migrations and benchmarks. + * + * 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..50ca78c2b --- /dev/null +++ b/js/cli/src/run.ts @@ -0,0 +1,265 @@ +import { readFileSync } from "node:fs"; + +import { + MIGRATION_LINE_MAIN, + type Migration, + type MigrationBackend, +} from "@riverqueue/migrate"; + +import { benchCommand } from "./bench.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, + 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/package.json b/js/package.json index 06577fad0..c4b4f8611 100644 --- a/js/package.json +++ b/js/package.json @@ -26,14 +26,14 @@ ], "scripts": { "build": "node node_modules/typescript/bin/tsc", - "build:all": "pnpm run build && pnpm --filter=@riverqueue/migrate run build && pnpm --filter='./driver/*' run build && pnpm --filter=@riverqueue/worker-threads run build && pnpm --filter=@riverqueue/test run build", + "build:all": "pnpm run build && pnpm --filter=@riverqueue/migrate run build && pnpm --filter='./driver/*' run build && pnpm --filter=@riverqueue/worker-threads run build && pnpm --filter=@riverqueue/test run build && pnpm --filter=@riverqueue/cli run build", "clean": "rm -rf dist", - "clean:all": "pnpm run clean && pnpm --filter=@riverqueue/migrate run clean && pnpm --filter='./driver/*' run clean && pnpm --filter=@riverqueue/worker-threads run clean && pnpm --filter=@riverqueue/test run clean", - "fmt": "prettier --write 'src/**/*.ts' 'driver/**/src/**/*.ts' 'migrate/src/**/*.ts' 'worker-threads/src/**/*.ts' 'test/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts' 'examples/*/{README.md,package.json,tsconfig.json}' 'examples/tsconfig.json' '{README.md,package.json}'", - "fmt:check": "prettier --check 'src/**/*.ts' 'driver/**/src/**/*.ts' 'migrate/src/**/*.ts' 'worker-threads/src/**/*.ts' 'test/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts' 'examples/*/{README.md,package.json,tsconfig.json}' 'examples/tsconfig.json' '{README.md,package.json}'", + "clean:all": "pnpm run clean && pnpm --filter=@riverqueue/migrate run clean && pnpm --filter='./driver/*' run clean && pnpm --filter=@riverqueue/worker-threads run clean && pnpm --filter=@riverqueue/test run clean && pnpm --filter=@riverqueue/cli run clean", + "fmt": "prettier --write 'src/**/*.ts' 'driver/**/src/**/*.ts' 'migrate/src/**/*.ts' 'worker-threads/src/**/*.ts' 'test/src/**/*.ts' 'cli/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts' 'examples/*/{README.md,package.json,tsconfig.json}' 'examples/tsconfig.json' '{README.md,package.json}'", + "fmt:check": "prettier --check 'src/**/*.ts' 'driver/**/src/**/*.ts' 'migrate/src/**/*.ts' 'worker-threads/src/**/*.ts' 'test/src/**/*.ts' 'cli/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts' 'examples/*/{README.md,package.json,tsconfig.json}' 'examples/tsconfig.json' '{README.md,package.json}'", "generate:migrations": "node scripts/sync-migrations.mjs", - "lint": "eslint 'src/**/*.ts' 'driver/**/src/**/*.ts' 'migrate/src/**/*.ts' 'worker-threads/src/**/*.ts' 'test/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts'", - "lint:fix": "eslint --fix 'src/**/*.ts' 'driver/**/src/**/*.ts' 'migrate/src/**/*.ts' 'worker-threads/src/**/*.ts' 'test/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts'", + "lint": "eslint 'src/**/*.ts' 'driver/**/src/**/*.ts' 'migrate/src/**/*.ts' 'worker-threads/src/**/*.ts' 'test/src/**/*.ts' 'cli/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts'", + "lint:fix": "eslint --fix 'src/**/*.ts' 'driver/**/src/**/*.ts' 'migrate/src/**/*.ts' 'worker-threads/src/**/*.ts' 'test/src/**/*.ts' 'cli/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts'", "prepublishOnly": "pnpm run clean && pnpm run build", "test": "vitest run --passWithNoTests", "test:coverage": "vitest run --coverage", diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml index dfe6702e8..d15dcb079 100644 --- a/js/pnpm-lock.yaml +++ b/js/pnpm-lock.yaml @@ -62,6 +62,31 @@ importers: specifier: ^4.6.5 version: 4.6.5 + cli: + dependencies: + '@riverqueue/driver-pg': + specifier: workspace:0.50.0-alpha.1 + version: link:../driver/pg + '@riverqueue/migrate': + specifier: workspace:0.50.0-alpha.1 + version: link:../migrate + pg: + specifier: ^8.22.0 + version: 8.22.0 + riverqueue: + specifier: workspace:0.50.0-alpha.1 + version: link:.. + devDependencies: + '@types/node': + specifier: ^26.1.1 + version: 26.1.1 + '@types/pg': + specifier: ^8.20.0 + version: 8.20.0 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + driver/pg: dependencies: postgres-array: diff --git a/js/pnpm-workspace.yaml b/js/pnpm-workspace.yaml index c2ce9208b..efd8d2d6e 100644 --- a/js/pnpm-workspace.yaml +++ b/js/pnpm-workspace.yaml @@ -1,4 +1,5 @@ packages: + - "cli" - "driver/*" - "examples/*" - "migrate" diff --git a/js/tsconfig.tests.json b/js/tsconfig.tests.json index 7a5a42b04..7c29bc586 100644 --- a/js/tsconfig.tests.json +++ b/js/tsconfig.tests.json @@ -25,6 +25,7 @@ }, "include": [ "src", + "cli/src", "driver/*/src", "migrate/src", "test/src", From b953b53a5693921d41b0f4e20cd2db95aea679b7 Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sun, 4 Oct 2026 17:27:00 -0500 Subject: [PATCH 35/43] add a riverqueue 0.1 upgrade codemod `riverqueue codemod-0.1` rewrites code written for the insert-only 0.1 client to the current API with the project's own `typescript` package: argument classes whose persisted args are their constructor parameter properties become `defineJob` definitions, `JobArgsObject` and `InsertManyParams` become definitions and plain batch items, `uniqueOpts` becomes `unique` with duration values, and renamed option types are imported under their old names. It edits only the expressions it rewrites, is idempotent, and marks what it can't rewrite with `TODO(riverqueue-0.1)` comments. `--check` exits 1 if anything would change. The migration guide describes every change and what the codemod leaves to finish by hand. A fixture pins the published `riverqueue@0.1.0` tarball by its registry hashes along with its documentation, a 0.1 consumer, the codemod's exact output, and the hand-migrated result. `migration:legacy` verifies the archive, reruns the codemod, requires every remaining compiler error to sit under a `TODO` marker, and compiles the result with TypeScript 6 and the next TypeScript release, guarding packed paths against machine-local names. --- js/cli/package.json | 8 +- js/cli/src/codemod-command.test.ts | 225 ++ js/cli/src/codemod-command.ts | 281 +++ js/cli/src/codemod-edits.ts | 112 + js/cli/src/codemod.test.ts | 806 +++++++ js/cli/src/codemod.ts | 2114 +++++++++++++++++ js/cli/src/index.ts | 3 +- js/cli/src/run.ts | 2 + js/docs/migrating-from-0.1.md | 222 ++ js/fixtures/migration-0.1/README.md | 27 + js/fixtures/migration-0.1/after.ts | 48 + js/fixtures/migration-0.1/before.ts.txt | 34 + js/fixtures/migration-0.1/codemod.ts.txt | 30 + .../migration-0.1/original/docs/README.md | 180 ++ .../original/docs/development.md | 89 + .../original/examples/node-postgres-README.md | 35 + .../original/examples/prisma-README.md | 42 + .../migration-0.1/original/manifest.json | 19 + .../migration-0.1/riverqueue-0.1.0.tgz | Bin 0 -> 17326 bytes js/package.json | 2 + js/pnpm-lock.yaml | 81 + js/scripts/check-legacy-fixture.mjs | 237 ++ js/scripts/package-guard.mjs | 58 + 23 files changed, 4652 insertions(+), 3 deletions(-) create mode 100644 js/cli/src/codemod-command.test.ts create mode 100644 js/cli/src/codemod-command.ts create mode 100644 js/cli/src/codemod-edits.ts create mode 100644 js/cli/src/codemod.test.ts create mode 100644 js/cli/src/codemod.ts create mode 100644 js/docs/migrating-from-0.1.md create mode 100644 js/fixtures/migration-0.1/README.md create mode 100644 js/fixtures/migration-0.1/after.ts create mode 100644 js/fixtures/migration-0.1/before.ts.txt create mode 100644 js/fixtures/migration-0.1/codemod.ts.txt create mode 100644 js/fixtures/migration-0.1/original/docs/README.md create mode 100644 js/fixtures/migration-0.1/original/docs/development.md create mode 100644 js/fixtures/migration-0.1/original/examples/node-postgres-README.md create mode 100644 js/fixtures/migration-0.1/original/examples/prisma-README.md create mode 100644 js/fixtures/migration-0.1/original/manifest.json create mode 100644 js/fixtures/migration-0.1/riverqueue-0.1.0.tgz create mode 100644 js/scripts/check-legacy-fixture.mjs create mode 100644 js/scripts/package-guard.mjs diff --git a/js/cli/package.json b/js/cli/package.json index 4aac3f73a..3bef4d28e 100644 --- a/js/cli/package.json +++ b/js/cli/package.json @@ -1,7 +1,7 @@ { "name": "@riverqueue/cli", "version": "0.50.0-alpha.1", - "description": "Command-line migrations and benchmarks for River TypeScript.", + "description": "Command-line migrations, benchmarks, and upgrade codemods for River TypeScript.", "type": "module", "sideEffects": [ "./dist/bin.js" @@ -56,7 +56,8 @@ }, "peerDependencies": { "@types/node": ">=26", - "@types/pg": ">=8" + "@types/pg": ">=8", + "typescript": "^5.0.0 || ^6.0.0" }, "peerDependenciesMeta": { "@types/node": { @@ -64,6 +65,9 @@ }, "@types/pg": { "optional": true + }, + "typescript": { + "optional": true } }, "devDependencies": { 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/index.ts b/js/cli/src/index.ts index e1adfce68..255d878cf 100644 --- a/js/cli/src/index.ts +++ b/js/cli/src/index.ts @@ -1,5 +1,6 @@ /** - * The `riverqueue` command line for River migrations and benchmarks. + * 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. diff --git a/js/cli/src/run.ts b/js/cli/src/run.ts index 50ca78c2b..be18c34cb 100644 --- a/js/cli/src/run.ts +++ b/js/cli/src/run.ts @@ -7,6 +7,7 @@ import { } 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, @@ -74,6 +75,7 @@ const versionCommand: Command = { const COMMANDS: ReadonlyMap = new Map( [ benchCommand, + codemodCommand, migrateDownCommand, migrateGetCommand, migrateListCommand, 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/fixtures/migration-0.1/README.md b/js/fixtures/migration-0.1/README.md new file mode 100644 index 000000000..7b3950175 --- /dev/null +++ b/js/fixtures/migration-0.1/README.md @@ -0,0 +1,27 @@ +# `riverqueue@0.1.0` migration fixture + +This fixture preserves the original insertion-only release at tag `v0.1.0` and +commit `7d1ad4d56ccd0eaaa06c5f0ebddbf24fdee82796`. `before.ts.txt` +intentionally uses the 0.1 names and types; `after.ts` is the mechanical +migration and is compiled against the packed current package by the package +consumer check. + +`codemod.ts.txt` is the exact output of `riverqueue codemod-0.1 --write` on +`before.ts.txt`. It keeps a `.txt` extension because it intentionally still +fails to compile at the one site the codemod marks for review (a `number` +annotation on the now-`bigint` job ID). The legacy fixture check reruns the +codemod, requires this output, requires every compiler error in it to sit +under a `TODO(riverqueue-0.1)` comment, and compiles it cleanly with both +TypeScript compilers after applying that one manual fix. + +The original package used argument classes, lossy numeric IDs, `Date`, +`InsertManyParams`, and boolean unique-skip results. The current API uses job +definitions, `bigint`, `Temporal.Instant`, plain batch items, and discriminated +results. See `docs/migrating-from-0.1.md` for the complete rationale. + +`riverqueue-0.1.0.tgz` is the exact npm registry artifact, pinned by SHA-1, +SHA-256, and npm SHA-512 integrity in `original/manifest.json`. The release did +not package a README and npm has no README metadata for it, so `original/` +also retains the documentation and example README files from the exact Git tag. +CI verifies their hashes, inspects the archive, and compiles the before/after +consumers without needing registry or Git history access. diff --git a/js/fixtures/migration-0.1/after.ts b/js/fixtures/migration-0.1/after.ts new file mode 100644 index 000000000..cb2c4244c --- /dev/null +++ b/js/fixtures/migration-0.1/after.ts @@ -0,0 +1,48 @@ +import { Client, defineJob } from "riverqueue"; + +declare const client: Client; + +const sort = defineJob({ + defaults: { queue: "sorting" }, + kind: "sort", + decode(value) { + const strings = value.strings; + if ( + !Array.isArray(strings) || + !strings.every((item): item is string => typeof item === "string") + ) { + throw new TypeError("strings must be an array of strings"); + } + return { strings }; + }, +}); + +async function insertMigratedJobs() { + const result = await client.insert( + sort, + { strings: ["b", "a"] }, + { + scheduledAt: Temporal.Now.instant().add({ minutes: 1 }), + unique: { + byArgs: true, + byPeriod: Temporal.Duration.from({ minutes: 1 }), + }, + } + ); + + const id: bigint = result.job.id; + if (result.status === "duplicate") console.log("duplicate", id.toString()); + + await client.insertMany([ + // `JobArgsObject` had no insertion defaults, so this job used the default + // queue rather than the `sort` definition's. + { + job: sort, + args: { strings: ["c"] }, + options: { priority: 2, queue: "default" }, + }, + { job: sort, args: { strings: ["d"] } }, + ] as const); +} + +void insertMigratedJobs; diff --git a/js/fixtures/migration-0.1/before.ts.txt b/js/fixtures/migration-0.1/before.ts.txt new file mode 100644 index 000000000..e2f49c540 --- /dev/null +++ b/js/fixtures/migration-0.1/before.ts.txt @@ -0,0 +1,34 @@ +import { + Client, + InsertManyParams, + JobArgsObject, + type JobArgs, +} from "riverqueue"; + +declare const client: Client; + +class SortArgs implements JobArgs { + kind = "sort"; + insertOpts = { queue: "sorting" }; + + constructor(readonly strings: string[]) {} +} + +async function insertLegacyJobs() { + const result = await client.insert(new SortArgs(["b", "a"]), { + scheduledAt: new Date(Date.now() + 60_000), + uniqueOpts: { byArgs: true, byPeriod: 60 }, + }); + + const id: number = result.job.id; + if (result.uniqueSkippedAsDuplicated) console.log("duplicate", id); + + await client.insertMany([ + new InsertManyParams(new JobArgsObject("sort", { strings: ["c"] }), { + priority: 2, + }), + new SortArgs(["d"]), + ]); +} + +void insertLegacyJobs; diff --git a/js/fixtures/migration-0.1/codemod.ts.txt b/js/fixtures/migration-0.1/codemod.ts.txt new file mode 100644 index 000000000..909707cc1 --- /dev/null +++ b/js/fixtures/migration-0.1/codemod.ts.txt @@ -0,0 +1,30 @@ +import { Client, defineJob } from "riverqueue"; + +const sortJob = defineJob({ kind: "sort" }); + +declare const client: Client; + +const sort = defineJob<{ strings: string[] }>()({ + kind: "sort", + defaults: { queue: "sorting" }, +}); + +async function insertLegacyJobs() { + const result = await client.insert(sort, { strings: ["b", "a"] }, { + scheduledAt: new Date(Date.now() + 60_000), + unique: { byArgs: true, byPeriod: { seconds: 60 } }, + }); + + // TODO(riverqueue-0.1): `JobRow.id` is now a `bigint`; review number annotations, arithmetic, and JSON serialization + const id: number = result.job.id; + if (result.status === "duplicate") console.log("duplicate", id); + + await client.insertMany([ + { job: sortJob, args: { strings: ["c"] }, options: { + priority: 2, + } }, + { job: sort, args: { strings: ["d"] } }, + ]); +} + +void insertLegacyJobs; diff --git a/js/fixtures/migration-0.1/original/docs/README.md b/js/fixtures/migration-0.1/original/docs/README.md new file mode 100644 index 000000000..c4c0cf043 --- /dev/null +++ b/js/fixtures/migration-0.1/original/docs/README.md @@ -0,0 +1,180 @@ +# River TypeScript Client + +TypeScript client for [River](https://github.com/riverqueue/river), a fast and reliable background job framework for PostgreSQL. + +This is an **insert-only** client — it can enqueue jobs for processing, but jobs are executed by a River server written in Go. Both the client and server share the same PostgreSQL database. + +## Packages + +The project is structured as a monorepo with a core package and driver packages: + +| Package | Description | +|---------|-------------| +| [`riverqueue`](.) | Core client, types, and job insertion logic. | +| [`@riverqueue/driver-pg`](./driver/pg) | Driver for [node-postgres (`pg`)](https://node-postgres.com/). | +| [`@riverqueue/driver-prisma`](./driver/prisma) | Driver for [Prisma](https://www.prisma.io/). | + +Drivers are separate packages so that ORM/database libraries not in use don't become transitive dependencies. + +## Installation + +Install the core package along with the driver for your database library: + +```sh +# Using node-postgres (pg) +pnpm add riverqueue @riverqueue/driver-pg pg + +# Using Prisma +pnpm add riverqueue @riverqueue/driver-prisma +``` + +## Usage + +### Defining Job Args + +Job args must implement the `JobArgs` interface with a `kind` string that identifies the job type. Use `toJSON()` to control which fields are serialized as the job's args in the database: + +```typescript +import type { JobArgs } from "riverqueue"; + +class SortArgs implements JobArgs { + kind = "sort"; + + constructor(public strings: string[]) {} + + toJSON() { + return { strings: this.strings }; + } +} +``` + +For quick one-off jobs, use `JobArgsObject`: + +```typescript +import { JobArgsObject } from "riverqueue"; + +const args = new JobArgsObject("sort", { strings: ["whale", "tiger", "bear"] }); +``` + +### Inserting Jobs + +#### With node-postgres + +```typescript +import { Pool } from "pg"; +import { Client } from "riverqueue"; +import { PgDriver } from "@riverqueue/driver-pg"; + +const pool = new Pool({ connectionString: "postgres://localhost/mydb" }); +const client = new Client(new PgDriver(pool)); + +// Insert a single job +const result = await client.insert(new SortArgs(["whale", "tiger", "bear"])); +console.log(result.job.id); // inserted job ID + +// Insert with options +const result2 = await client.insert( + new SortArgs(["whale", "tiger", "bear"]), + { + queue: "high_priority", + priority: 2, + maxAttempts: 5, + } +); + +// Insert many jobs at once +const results = await client.insertMany([ + new SortArgs(["whale", "tiger"]), + new SortArgs(["bear", "fox"]), +]); +``` + +#### With Prisma + +```typescript +import { PrismaClient } from "@prisma/client"; +import { Client } from "riverqueue"; +import { PrismaDriver } from "@riverqueue/driver-prisma"; + +const prisma = new PrismaClient(); +const client = new Client(new PrismaDriver(prisma)); + +const result = await client.insert(new SortArgs(["whale", "tiger", "bear"])); +``` + +### Scheduled Jobs + +Schedule jobs to run at a future time: + +```typescript +await client.insert(new SortArgs(["whale", "tiger"]), { + scheduledAt: new Date(Date.now() + 60 * 60 * 1000), // 1 hour from now +}); +``` + +### Unique Jobs + +Unique jobs prevent duplicate insertions based on configurable criteria: + +```typescript +await client.insert(new SortArgs(["whale", "tiger"]), { + uniqueOpts: { + byArgs: true, // unique per args + byQueue: true, // unique per queue + byPeriod: 900, // unique within 15-minute windows + }, +}); +``` + +### Batch Inserts + +Use `insertMany` for efficient batch insertions: + +```typescript +import { InsertManyParams } from "riverqueue"; + +const results = await client.insertMany([ + // Raw job args use default options + new SortArgs(["whale", "tiger"]), + + // InsertManyParams pairs args with per-job options + new InsertManyParams(new SortArgs(["bear", "fox"]), { + queue: "high_priority", + maxAttempts: 10, + }), +]); +``` + +### Transactions + +#### With node-postgres + +```typescript +const poolClient = await pool.connect(); +try { + await poolClient.query("BEGIN"); + + await client.insert(new SortArgs(["whale"]), { tx: poolClient }); + await client.insert(new SortArgs(["tiger"]), { tx: poolClient }); + + await poolClient.query("COMMIT"); +} catch (e) { + await poolClient.query("ROLLBACK"); + throw e; +} finally { + poolClient.release(); +} +``` + +#### With Prisma + +```typescript +await prisma.$transaction(async (tx) => { + await client.insert(new SortArgs(["whale"]), { tx }); + await client.insert(new SortArgs(["tiger"]), { tx }); +}); +``` + +## Development + +See [developing River TypeScript](https://github.com/riverqueue/riverqueue-js/blob/master/docs/development.md). diff --git a/js/fixtures/migration-0.1/original/docs/development.md b/js/fixtures/migration-0.1/original/docs/development.md new file mode 100644 index 000000000..74b85ee02 --- /dev/null +++ b/js/fixtures/migration-0.1/original/docs/development.md @@ -0,0 +1,89 @@ +# River TypeScript development + +## Setup + + pnpm install + +## Commands + +```sh +pnpm run build # Build the core package +pnpm run build:all # Build all packages (core + drivers) +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:integration # Run integration tests (requires database) +``` + +## Integration tests + +Integration tests run against a real PostgreSQL database with River's schema. Create the test database and apply migrations: + + createdb river_test + river migrate-up --database-url "postgres://localhost/river_test" --line main + +The `river` CLI can be installed with Go: + + go install github.com/riverqueue/river/cmd/river@latest + +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 + +## Releasing a new version + +The publishable packages are the root `riverqueue` package and the driver +packages under `driver/*`. The packages under `examples/*` are private examples +and should stay at `0.0.0`. + +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 version numbers in the publishable `package.json` files: + + ```shell + pnpm version $VERSION --no-git-tag-version + pnpm --filter './driver/*' exec npm version $VERSION --no-git-tag-version + ``` + +3. Update `CHANGELOG.md` by moving the release notes from `Unreleased` into a + heading for the new version. + +4. Optional: Verify the release locally. Notably, changes must be committed for + this to work. + + ```shell + pnpm publish --dry-run + pnpm --filter './driver/*' publish --dry-run --access public + ``` + +5. Prepare a PR with the version and changelog changes. Have it reviewed and + merged. + +6. Upon merge, pull down the changes, tag, and push: + + ```shell + git checkout master && git pull --rebase + git tag v$VERSION -m "release v$VERSION" + git push origin v$VERSION + ``` + +7. Publish packages to npm. Publish the root package first because the driver + packages depend on it: + + ```shell + pnpm publish + pnpm --filter './driver/*' publish --access public + ``` + +8. Cut a new GitHub release by visiting [new release](https://github.com/riverqueue/riverqueue-js/releases/new), + selecting the new tag, and copying in the version's `CHANGELOG.md` content + as the release body. diff --git a/js/fixtures/migration-0.1/original/examples/node-postgres-README.md b/js/fixtures/migration-0.1/original/examples/node-postgres-README.md new file mode 100644 index 000000000..f4b1a1c03 --- /dev/null +++ b/js/fixtures/migration-0.1/original/examples/node-postgres-README.md @@ -0,0 +1,35 @@ +# River TypeScript Example: node-postgres + +A minimal example demonstrating how to use the [River](https://github.com/riverqueue/riverqueue-js) TypeScript client with `node-postgres` (`pg`) to insert background jobs into PostgreSQL. + +The example defines two job types (`SortArgs` and `SendEmailArgs`) and shows single job insertion, insertion with scheduling options, and batch insertion. + +## Prerequisites + +- Node.js >= 18 +- pnpm +- PostgreSQL with [River's schema](https://riverqueue.com/docs) migrated + +## Setup + +From the repository root, install dependencies (this is a workspace project): + +```sh +pnpm install +``` + +Build the River packages and the example: + +```sh +pnpm run build:all +cd examples/node-postgres +pnpm run build +``` + +## Running + +```sh +DATABASE_URL=postgres://localhost:5432/river_dev pnpm start +``` + +If `DATABASE_URL` is not set, it defaults to `postgres://localhost:5432/river_dev`. diff --git a/js/fixtures/migration-0.1/original/examples/prisma-README.md b/js/fixtures/migration-0.1/original/examples/prisma-README.md new file mode 100644 index 000000000..44f1976f7 --- /dev/null +++ b/js/fixtures/migration-0.1/original/examples/prisma-README.md @@ -0,0 +1,42 @@ +# River TypeScript Example: Prisma + +A minimal example demonstrating how to use the [River](https://github.com/riverqueue/riverqueue-js) TypeScript client with [Prisma](https://www.prisma.io/) to insert background jobs into PostgreSQL. + +The example defines two job types (`SortArgs` and `SendEmailArgs`) and shows single job insertion, insertion with scheduling options, and batch insertion. + +## Prerequisites + +- Node.js >= 18 +- pnpm +- PostgreSQL with [River's schema](https://riverqueue.com/docs) migrated + +## Setup + +From the repository root, install dependencies (this is a workspace project): + +```sh +pnpm install +``` + +Generate the Prisma client: + +```sh +cd examples/prisma +npx prisma generate +``` + +Build the River packages and the example: + +```sh +pnpm run build:all +cd examples/prisma +pnpm run build +``` + +## Running + +```sh +DATABASE_URL=postgres://localhost:5432/river_dev pnpm start +``` + +If `DATABASE_URL` is not set, it defaults to `postgres://localhost:5432/river_dev`. diff --git a/js/fixtures/migration-0.1/original/manifest.json b/js/fixtures/migration-0.1/original/manifest.json new file mode 100644 index 000000000..30be77c74 --- /dev/null +++ b/js/fixtures/migration-0.1/original/manifest.json @@ -0,0 +1,19 @@ +{ + "version": "0.1.0", + "npm": { + "file": "riverqueue-0.1.0.tgz", + "integrity": "sha512-axBxy75OVxypCCbnlzzDYMepUzCCxHcDu9Y9WPvZzCUH8C+S+BKHwykX89qs5HmKKlCf/Nxzyn2xl7P9Y23Btg==", + "sha1": "c58bf28453ee306388e9a1ee1ee2d7db5fefa5ac", + "sha256": "1632662dcb43840753eed022acd7d5f38856f35e52eed4e71dc66c777f7a9229" + }, + "source": { + "commit": "7d1ad4d56ccd0eaaa06c5f0ebddbf24fdee82796", + "documents": { + "docs/README.md": "ec3664ce3a145c5eaa00f2faf57ea9ac87dc55b99019a17a8bcef2f0d422678b", + "docs/development.md": "8e38b4632da98d2cc973c29331bb826ff039d1d50181e4252c04310cdde1ce41", + "examples/node-postgres-README.md": "3c445e0ad927424d06e520402d5a6b94add4ac7ed9e88166e1360cd6c4ca02ae", + "examples/prisma-README.md": "3b3d5c38906d2e363ed4e6c5b3d2e65d86796e66d891faa74fcc72896554fd80" + }, + "tag": "v0.1.0" + } +} diff --git a/js/fixtures/migration-0.1/riverqueue-0.1.0.tgz b/js/fixtures/migration-0.1/riverqueue-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..6fe0d300149f8088141e80fc7219f693e67f47cb GIT binary patch literal 17326 zcmV)yK$5>7iwFP!000006YYI#cN;g7=zjLEz;JUXltWQ(*_K9=X+Eql@k6qtnelkF z7Dz&5OU-V&yQzmAopXQ0{e}BW?y15XjV38sR+3ruIdQ}WP$(3t3RQ&ydQ5xgbikJS zVU{oTqL9UT{Ve;BdwrIdmp9kf3H*O~dD;E{>e||~HL|?6vAMjmvbnjuPL@~J)|a1= z|5(0XH2lvb%W3)_%U|7>JGei{6OP77nv*Z2mol2OpJ+BDH{>);Mx+uaeYVw0ug7^( z*?yvG{`|J_@!&8xY<~>?9&}y>jaTiO@v`~$_1>%YVY_9%+-tvSb>8fnuimu}-@SJm zzdL&K#;JGE{HfhKdIj)a2mkyS93Hk`?;Rd|Y_(qoN3RZRWba+)?Yqw5d;MnrsD0Gd zKjnDpOJ_;9ewLYonZ)6z32TJ;h-T+6(`0mz)0|~S8(w3v%d7C_y(G?Zav1D>eAgyB zWa+>E>+-{YT`nH~@L!kTEy-fh|Hq>@o&BRWAI$;TAs?O)LcCOT3N$610!dBhwWg?B zAgJoR)|7p6ERlZK-aqQRYq#!>bs2K=;IQ=c=@asl?9njI$XU`QG#zB*GR%i$%+f`8 z6UG@!^Dv1?GKT+U^dKKh38;$d9i)@gag_;e=XhN|G zSz00~z0_lb0rc4^okV#i+a51$KOs+-p0F!q)?P%jtbh{vf)oWH+xQnB&D(J@Z`fVt0OKu1~gTo8EOPL`-)Rrvc~jU3kG#k`HMv`am|w>f|Vn!gEIQAtSqqsK%$ShWL>r zW&o>4W0J(twP=p5$^ZP{|A&-jSFATd-U%}@q;WrDebT)qRKv_zdcji3IY$}mlQ0Gv zo=xH&Hbpk0q@O~4lc$tU5#y061S@`;unDVd7wYZ}T3D`X|4mn4;(aWR2iQ=y<%H&g zCp*Yryhr3t39vd&l4xOJdkV+9+AM3mWv!yKmlPyQ}oMkySl5N~ZyxTOnM5?#bG)b!`VSGWOurKID zVme}5j9-u-9V?s%u(m122CF-K} z5?9XIb=(s@2p#OQ3>Td-SLr)^>?3;4_-MrHqnnZ+eeWYdT!j`3 zWaqCQ4MEcZfX->$W65d3Eb!?EF?XfBRp#sJa6~%rQ+?ox*{CjDMv{+xV z4XnkSZ^${jmJ|lcBgyMzFUj&j$`1Bl5!y|_I-n+~(Im>lMNopBHwW!^hooMwlece3 z^X;3LuR6^`(t1md_FBPV8%UU6CL~QRGnjzWDD34ViA`wGZ$?MVK&eKugj_O$Iugx{ zjKzJvri3ZH>-8pS%HkdazR8Eo1lXrJ?NV4r<31VD^qgdqZpJ=MV0BYO$Vf6tGZtO2 ztnTp07t#k}vHqSA+LUFJD1S}IV^BmlQgQS(NgLNki2X0@x~f>Lpf~#6cL*t)t}Kv= z9TaY-H!OrXi;wt^5;m`>k-R;vfOb&Ti~Oy1ASgXGrVpY?-2ipGT_zo~HCvuem{e$f zftad{i+F``pItc^eVv{)#f+m?!V=?xvf#K+WR1h51K0^`?lm1(i_Bv(1bB+aZVj7F z?HaI>R>kT^l2cN|NV3AXuCOYf{UN*hg!(wKlLSO-hcYFW>d12lcAl_9&TEEE}m z17cTUmUDDnV$=sLukwyO%>h-__)j~~yw6UF^SL&KVm!%))i1UtB4Jp z^Dv&UV(CrcO<_2h8cxlg%zDJ)K|Y+J{5{AcM{jw76^39}LIC3hpNXhMV!9HvxZcuB z`IR{X3Y=+TZWXQ08p*G0s3mrp->pV#9vAfUFfOowYqD3+G{NO%NH3(v1%ZWY0_`ha z$_S2)dr6=51Mb4(IQT1c?*MQ?bm3*kbnFpPcky>y0d@M5i1mZqiUriof?t1ipqr&G zL}mBd)GvkHI|TSQUSF0_I%26o1K^6Re!v2d`RQQI4ev}%phFiFGw+QLD;^pM9a zDugsG$QT^3z~UPZO!eN5h+5E$Y1tyq3<6`eXwIq&P8$f4+O>c51@*{QdBjkO03BpP z?9f}KIXk3-tZEDos(3~xUw=4u&{G<2i^P*C@)hzhuUlSVbzZp;^{N~1i+Jfm+vztN z{rS&)#6z0##kh%K1scYAX4EUV`NFbL1FTuYIW|2IWsn(tWHOwr-Iv8hvM5u&KL|MVxLyxzS=z2``(Rbh{%VbY&kWnaikc?}%elp5>` z;|jlkt~bZ$YGPwNOG~74O7aB4GSvkLO$vLVM(jupxeTL-^k@{3Feh}9CnK7N@cX)E zLK}n^uo8k-5<=j6#Nr%1Xdd_iTOmy%l8AU`NTcvGHQZNgc?ja`LO>XpIlmr*-zlop zIr?O0XNOcyMX0pma<7;-WLJ60 zMhs%w7&WFbn7JwAK}`&P8&30Sl8$K3qgK%XR+Qn$@{uuk0lbmKj0pF7M8jAFz@xC6 z()5~qWLLD8N7omQsl*IgDgM`C*5g{269#=esXLE5WZDZ=BKA!$NKNwd|OtnKGwvhVhM< zwYp)PI7$@8c^XDJOL?(f3K#?sy{d;9{@;U-&r>iV0w>=TZGFR+0#-;)$;_deSV5aG zHpX36*+u8WIlG3nN4LZ`epjXFkl1hRU!J(IhZwr!X|BrB67F^YZTPNXpXDqag)ul+ zJ(5iF@kE5aL4PKod#tS{L#YsL4JT<{IxEE)dD}f>y}S-MRLxN=)B#Y{JN3E?N$CRh zr;WQPEuaB+3L62sn}s=rhcBsq@rja4mNt?}+?Q5Ko=#%$Xb;1Yk|M)|tofE}N3@DV z8Dq7b@4jFcT>C)>M~BU-Mq%Cop#d;k+n`;o)eZji_uzz6$c~X{QLSV{y1KDhS*UZh zSv7lFsQ1GGZ2J_vwoF=EmeG7dALs04R*`=&<>65=HMBO{)txi@=8cs!{kliSuD~f&4&Le8t?MTevwPKUi#j za#*PP6NI9n?pCprFrEM#cnbxcpzvQO7MUy;4;(m?N`|(~=>W8)TSE$MP6slc^jEUF zu`$nfXa)VnY?w@wfvN;{hw-*Zo5`A+M zEM)_BMZWvOq1}}4tQNc{7>AFhmIe^w>8EbWK25@u^?}6xj34i&G>2Iid#71df|-v3 zQV|I%$Q@XP3qmK~fA>Ycx%vJUM9B=F6Vg$Zmj07u$t3MD5g9ys_i9HaIKtM$Z=HJg zyLiT){7+!)`%D6zng6-E?&N>2uWhb8=6^oKqY{z&EbFCV7Z%>UM%dMu_3|3&veP7G zZQ9dWp%)?WhnYj!6nR*M)HIz$sRZv9iKIs2r`X)*w)1`Ffr3krPr72GfP2&_>m_MF zlNn?pHVu32jH1RIhBr}UitoOItMml95moYE*`mZ9dLi95n~LA745e+{<6 z@Wu_~F`82r{tER4y&%Y_6e$i=AS`!;w0qFsA}czy242(Fj7We{o9`ZMk>$cW9&5Kl z&#I_Vh+1=xfw%Dlf zGHX&W3-_S~n*v24v<2>TrI>>&>K3F#yeB(0G~pk~=&*19l2_ld!Q5!hm#V#5;hMTq zBUK~a0>V{hjw1Z#`kMdNYe2Q?WND}zN8n4X{4AH%lMTw7N!b+t*I06W;b4jKn9IV9 zP)zK@BrcgZU8xE6j>N^XD@Q!^mbpuwuBho(Y+ z#R&I?c>~TjP77z7R3zruiuJ?tv9}vT(5H1_*EV$;Z}k0_ir}rv77_<&bMyUFD2D0~ zP<%`GR1i{wi1zLxKea7t7tzqN1*Wz_Z6aEEOq8i@(O<3SN&C};M%53;Q!86ZDGl~! z5=GTH8gRwrZ(}A`fV8A60|3gHfz+%y#N>36Pf{j?7S|j2ryeJl7*^O^{;0zr@|i=T z_}Pb|t7spes1?ffva(hz6bnja7!HP&VzITP@NpF@@fG6(L!o!n{Wwr+}m4nOp(1K8{un`TT3Y@lpisS+jNmB3x7EQMf!izLJgICI(X|w@OF_`vfX29qf=|-#t}Z7JWER?==X)=CHH`@2{>(&7y`gH zCqRA>*4!bLA&a7|+=mqQi{a~5+n2u&Hu*xf{Oj-^3k3CtokXk-rVH&@ig3LPi;omT0xcCL)} z6PA%U$%!Ppv#C#b5}-9}-=5+RO5$&21kEHZ$oyi&E?9IsDsx{D==qN@AGV<>kHMEH zMbJYadmpivkGvO0ClO0SV#F`g$X#IW6CVz<`2Q|5Tq@p3xlc=>Hvri2?xXK8L9 zrF2ei?fe(?HZu-I;psq}49~eNerymAiOnIBR#Gn*6#)izedl_3AtOa?Sa5fGO*x|Hg5CP=p;bh3L%FoMjq{$D&=x?EZUz!Nqg(!lZ zXvWke^2LxJ`cBet0C94XqY<3_YRM1h}$VK|Ih( zU0j6cs^k%%Mv@P`auX+VScVsNe% zC-52vd>iwFXPe=;*rwo&d#E~BlGQv!_S_xf3ltHpGk8&9o6oxF1(-rytAh9|ygl8O z#75`oW=ON`jE%xx5+(6B?P&$lhw|)peU$4?K^wo`9McR!;aq0hC@TZo)3{3v!A4%j6i>s!BxTbV1APK@M4P2s%Tq2FDp13O^{r^~y+Wcs z%?tHXCr^kKoVZuo^d?!(H#_De4Yz|l^qKBo&L)t+#7iIeKm0c>r&A;?J|=eF?y>Se z<$WIydjIG0^76(;;r`E+^~e0rhj_$AYY-atN|$1ZN5T5Hd@T9LYdh{;{AbGl=H{j= z|EufEkMjQzkCgx0CUL8Fvu*Sk1Hya9FnWq z1&4`zJ^gxNuO^nldB2L-k$;%`MZB)@!Onj`>8gAFV|{t^QT`v|nYI4gEb(Xr{JTAt z{Byl_?|saW|JAj1_x}IY&1V~r^8XOe(h{tWDwZnmK7btue)EC4w35Y>QC(fkjG+e4 z+7Br%^n!*F?M6(mkZyft7C`+T++i9;tUsl`v>32{0%?!=wED`JfcpI~>(R77t-i1` zp!S%>{V*Ps*Hs1x)J|ERUQZvZuuZTwzcRSIuD&@74v@&y+zMWmPFmvP?RYZkvJ_6D zjp$W4nvCS(A%3d`o{XrQw`gN0KC6f9kkt(vxVnaH3Mki9B0FTohQco%n*zeJt#Fb= z_)>oaAWbg9K7*4Um-HIKVEi1EzB8Zat`f{Mg&TTUk6$AHX%hC%^@Yv=OzsHc%}V_2 z9wu*1`9y$~rbCY!o9o?BgsD4)KUDCwRRj}_rLE+{Ho zK8Q)jR&1_tG9HuD!c1)wm?2z*0dS{-fcuq7AmF(qk^g4ju7oB0W+pYLSqGkEn;A#F z`C9g9u*`U@^}pbY+)p1f*Z(!w|KC{NTzg#qAL5z&t3JO6Ki=bky9;QebNF%Z?Lp_T z^Y+bwI-TI%VBI3mYHq0-=J6KUESAdg9;;(YA3Os;-XiOTQbB=Zb%yDKhZM(KWVKkz zX>zR2B7g7@=NQi!-|$tqnZy_P4Fr8pKCYU=%F7cdc@A<;gsh82%v`6{dFdG!02!x9 zFK9$|h`4mexNd~s?2(+3;-w_KLO0CiHPaZVZolr374nzAn61n;M=UTGG}2e|tQvRoJePO#+}KoBM1*UC25^l)QbPLK^pzo^ zD*TXya;|-ncL*31=H#gy#9Fq z%Y!^q?|+$c=ga*Dy#MG{h)&=sp^KBzgUKFg(x3Dge10TNf!J~t0ks~+{qQ2}PiUmm z+1!*5$Px{6G8Wo4BUN#m>+?lO?h{LTjYRAevq;l$Fw7UW%=UyV@2Dt4jjZg507Z?g z?x@H{jjZj6ph(SbyuPDCD>bsQqvA3(vbm#zIyLfaM@58GdyyVeW?Og-wgs7CBpino z)5)A4ptkWGfGf*)1gOkH8{o?F9RX@f(gFx~4&Du*G(Jsm0PT+8w7qHoM0j@vC`?=p zjzha6IBgXxfC8QZC{v3-5_`$08^+8y+f^!acVJtu7AYWhD_*v&shJn-mwbb>SZsWY zQx+O{uGn~*1e~hM{-#K@60p2x)LGdPu*nbu4<^xik% z!%n%v%U!wP8y6<{ErjW$HXzFNzz@;T(?#mbQHf=vFJ;89)08SBKX`Q1d|1vJw||L| z7mAnjkHIm>LBLD5o*WZ5AGu^_{C4qK3Pc4tN=YJ~1veBV;O~$R^*X*J%ua87vT~W50jtjkA~-v$es&a)gd``$WV-pcQ%^>WmA>(%_Thxed#;=C$4sFBr$;^c5323Hm4)5m3Min7Ob>Z{kOdD#FKtOw%( z8buxkC_NY84`w|^1?@-1|HS|6XIT>8cONtM|JK%u`Tr}A`Tq~|fMTrR`Lr$TOZA`@ zDidX8sUl;K(50o5UkJ$mfkavB8De;aUHKOE{X8dbJDT_k1gSxbL zK0tlVzs;S}5rdC&V>|tY-#YINzB2{gj1sV%_&fMq5xlG0Y3sT{Rw(V)b5YK>U!VqG z6e{(KKEgSxHP+$LN*@Ah^8$}*R!+kRf$?<)@{n?47p{F<(lciKxpBBbu|66SO-S7}ON_-U8R9Fv}(^)94P9 z5gXG1yCt0eOd0Kum}Jt67K4BRi+dq6@cSFfqA<>v&Ty9hdu6%4TwjqS_#00DQk?X$ z^Z#DmsBa3k*QMhDmOZbpF6%N5Rf1e|3l{nD#>H{U@;qdzthiEtE-QM$TJ_iJ&+BWl z1aU#J!?KO~bKOdpEgE<|uW!7lugW?iqz?t_^_BXDCjU9RzJv?VHM=00Wy7EYcq_$O97AJjx|1;K14en)Q^4Wf7LAap#_@QBA7jV2@DW9P zv$)7VhqUv3T^wOsdiuStCqeZhMp12~BeIyD5)?C>gB^*sBYd@mt9T{7PrK(=~A);o7V;k(;`lJn0w;r4B}PQxpsJy7B5 zc2Fdvah5}hEiS@YSGYv%O>)RGrCqV^7Ve{`RIV`7+5t^av;D3Fz)8m?`#K zFSftK$M^jYSncl9PM($jwZ5@ZIRCNoxc~bg&lhdvt<}(AsccmYKB&|x+_QR@Bza}4 zqJHDGYV|q_ygY)PRgP;FepAHOhvQmBeBeH-Y*m7w9n{_i!Q0v|LGVkh9R%&#>mYay zf1Nk^pEL+)tr-N(?I38bwB71QL2v|r^?2>`Mi87gRQ;uvTOWT~9lhs&At2Fi&|GQN zE}KDciBPTtD*p(Af7IRw!Taq_&|GWO2JIjiwBg6FUE2+UT>~&e>ICh7)kf_g7-6lr zUAt}s!F9tx1?x3~<`{Zvx3=Z88yxIH-#^tpHGl$xMi2}dqT~G_*vFQ&P7rilXq}*W zVb)P2Gbn}?2aTYeHe48XCH-@|8Fb8og4$q^1)SuQ@JD1 z5R=yI8)#rFY>$vR8yWqzf%O~|=R_kC1BmZmj*|TTv`oHpc{_A0$Y3u*lJ+A&i;4V2@8=LF?FQ1!ApT4S%8bL6E zHMAKw>>ptx2txdEHg^dZXw-uZsUF)wbJ*Y*?1DNi7zj%DA--JI7`Vfd^+{PF?H4Wn z8vj8(+5>|{6*sI_yFtJ}pI>qqQ@Z)Tbk|qvL_VK)WMYZ!IZ zLTgMNw-%)7@p3x|sF)m_60E)42!drzuBhBJ(F26P0QjM8e~cU2>flI)yFn1bdfi_7 ziI=?=%*d^cusBxDHrj65*Or@J>$N?Db>2JlP)z62(XyMG282N)Xy&2_YoZ<91f%A% z5d@be^aC|SA50-wn#j$dnZ8tb{;d8o2ae>?lkC8?CVr*K|D=jS?}O$WFDyO0eXvv& zU;LzOcGI}~Nsd_$hnsm+8FvGmj$zXka^1OUb?NDHW)DjxUKcIbCdE2RDH{CXBe$50 zX5NxaVG%=v(cPOkG@msTCNB*|rx7vz7^-Adl5(wO(NdYHCNV8zHEC)?bOaL6j)hXh zkvQbkHo8L|G+u%2O$C87uN1q<+GDY*_$NyDqKTy6A^N__dyihHX6IQw6o}8se$_Na zC`JCK+BtZ}V4ydncI{I;2tMJDv|YPw2f-!&xN6rvwSwSN3rkvtTF^74|JrpN0I@7M zGA%Lq+;pdr1-wI_~A7`+TIU3 zQm+VZ*77O4J=Y2(ZW#vME&{VK*l612Ny{p4ueAACHJz}h;$nAUE=QVw?KvMdTs7^$ zwW;BVfDu*EnoPmI5+lV*1$SFBnC?+EeIa_srU=@>J!xR%$B`PnUL$YfUkJicC<*W4 zKm2*BAu_3ggvS6RJ4A_dI&AegQ3P%_M*$>*e$*Im?7E7NNZ7;APn?Vpd zW^OwNJy;D2UFi<+9X8Q+r&OT4aod)}qyZy-(ZE{p z=RaLrNg>WjBUrw9==93X)`rtBzFm?29ze(&QdXL83=sIvL0rERo(ynm+YLi&z2G$0 z?=aBx9s|Z&Vl}C3lMsj%NGsXX5DNB!6uri&sv5vEhl4HtGBh5 zVZ}MYev}U#J(6_)T(ItaE~@O{g}IzOZw0|~#7l;5^rnNht{jVU9~Wpn0J|gf>I=m- zk2lH@whdmO^fQwuG{16g!)O%Di@D@1w}W6ANx#yrP1->)!5D?IWqaW!`)fJ(*a<>!JBmc(RB+Zco9-@YE90dajA zwYL~txVB-L`eO{}BQy*PRwGvOjAPR`Zh1#*t67%}?==4PdUFAz>{QsRuVyLKKU- zW_zVxi#`wpV$HSJA)aO)$Ka4^JU0CXUEEQ^&Sd;s`rJ-b)S07VOnl88uPCu1Fz`kGXUF2N9@>q5Wn47)WpKp_5IA;`UJV;w z&vnuaf{E$5zSqHGC;0DM51ix-)m!fGO>E?pL>h9X^W?&}UyRrCEhD z?tfk>-2bw%w)uGf^MgFo_WuK-1-ab$z*z|Ff~O z{&@f6gFG|k|CUKWv!vh21k%=@{zot3PM(ljFfl*a+zQZ5%-!EYa?$REYBV*uNN-}^ zDP^b~HRK*`fP1v62DdR}*xj-iAeC2#%`pR~z0ok%f4R}$wW2<6h0oBwn3exg@MhHB zxi(jIuuHiVZuX@MOz-+w&Q7ZSY`2VJxl_zja&fN`5;Nu>g|m+*(H!4mD87wg$K!Au z6+LdJo;~h@rzi4`+AThL7Wpt?RYooWy;p9$1K;@q`coob!8)3$OD#{x^6!RAf-!rZ zMdf{INBoU_ihXVHTW$X3hojNAjr>C2_DFCH4M#ihZuC3brWPqa&glY0FWjD<+2M@V zGe)C{X73aT=5uyTe$hK7f%4q~6*<8%b%I?;dw&_5E}3=cpbub2$k~`r7Mx*67D0)y)LQ;})s(C^AJzgP+${1Ujd|DgX37f`0;{2wmF(4OO za@@;@Sww7l-DS2{8$qzzu<5&0NVRn6%d>XN=qvYCrOo%A=PFye9Ar`JR zjD!j&Oz1>V;PAIt{xKKkXV;wtc>m&mQgCPAEBCu}epR5*(I@St<{x#s=FnO?c46tP z6_XI=*~sEFORrF;#W{4gE-L_SI9r@7Lw0&isuE~i@p2Xq9}%r}qr{$crg$2J7jr>S zOj5oi`OL&C|FU)uzaF% z4yH3Zr7$TPpAL(K?H~v(6XVVbrv0K>8?=I8&_aFHx(7X1$T*igbgL-i$`n-Q0_l?_ zyu0H)o*TGleA_E3Km5JU51$U-legsdbL+MuC2+bh&*w4xo>pIOXrsS7ml~XVNu8I; z_tDvRUfz>2bjJWnacfokv9@t*{G-P!lOiu#`sA1#Bj4?r&P3-;yp5{_>!H1TH~;=M zUXVLFr=EuAYb$=j*(yH;d_nbnl?Gl;fDF$3mJ(q6%?aR1+J=tg(Ous-ML@wC=Zjh6 zyj6VR>&A&{;>>V&(G~lP+Gnxe~3d`jal8(`i{R{-}dolPv6g4p1ytB zY7X5z&z`MdjWWCU>w;_6vNUb~L-T}gr;UW71u&k)yz}WU=S;!f=1*LGp#gp+ol5pL z=Ae^v%t9Y{^Q7!SiJKB(Ht}Q_itF$^>47|>*Uo)Z1(DfAXD{

-V`dIR>KX!Dl3SgG~x3XEd z|9N%&@&4xrdA#y(e)MV%1;DG{;?$R3Pp2N&f-tsT3H2tLvpQN$Hd=16Ug{m2pxAh& z?d{JwhF0?7i5X|g?)NI|uRNg!mZ=`8{>wK-Q*3icHqc{N`5$2SsT;1qyB%0XNvxx)BO8Z+P^dBVcp-AVr}cM1^h8Iq9gwq zOY_BKoM#Vs|HsN&;rm}3>#NI;^8XM|iTvAqFsB~SjcywWoEHE4+$;-I>2FZgl6DXz z7z!Jkcc&IYDDWu-aWub!=8JO)k5xBnhzw*_@?{UeGi%ln)_2C02=fuepvXI%ZTh5>#-)qBpDV)$S zKV#O88ur%%oVrdLGoQ%wRuG&c6{D6*aqEj~7yrKBZ~EG&RR2HN{V#C;(`w=Vm(9of zUmoO{ssHD;0_W5IkAnF7dMx=rOS<njESZ|&Lgqy6_Fk5~RBAIv5BQ2Dm@ z-qeU1eCLFzi+c2Vt6f;*6CHTF`pv5;H|39`rI79Z-t1`2yyu{#%iP?o z*0tqU@G>5#1e*BL4V+UwWqaN54OMwksWchRG`M#ou@zpt1R>*oKV7KcFZ*+cU^GF|1e-{#XYH9; zMZgi_gWny@%GDF>BvxCgMQ4vYI)BusVE^Cy?$12=pBt|IzrOLf|NkIQsr^5v-#^>> zZwFGP1inOnWj*?gU31SUU}cb?w~Z?=i8RcIL+)^)l%(ww-qJ zLyzWnvfzZ3w!5_zj1APsKY13cTxAaUi3G!{?cgbA`E3$ygz)=M$Lvvcm0_g|J_`E_PGE5Adgr7d-!0k#lUHIkGp`A#^&yFE7%HxRaB8{EeXijjCU<+gkkPL9lU7$yBNIF1YB!YHbBEyu2P&|e&nb3Ur>i7UBrUE>}FgWlHESP|VjklJS=>xwqz-Rahd zxOX#H*ZH`H`I%cigrZb!Ek|2(zW1>VP9U=a%D0a2?&TRZMI zPzv|Hx<(oPWnW=qnuCu(JwV+k@ti>O|HYVge76mE?3L42QP zbZ&3)Y9G7_zkT08JHA`DZ=tmKU7jeXjoae}`jOv%!2))3%3+%J1_WitIq|Bx~KJqn(9KH;cG#Jkp*FJv7gEd2B#y{yPqR;y8Qe z^jb2D%Syk{BX30iovbDP`EcUO`_5A>HD9tjr@8k3phu^!UF24xzsL>|;j! zXJdV}kpH*&82^2kCmfBFG$;A>n2|4}g;#lz8*-W^BT}jB8{sP3Pwc84Jn;8+oM%RL z=is2*=I5jvq=T#mf4oaBjfNuQaQn&9)2HMe%O+7yl2bxS7RG~!k+Y;rIPfru>*VRu z6Ly7=!#HQ@DeWl##MS!*(m!XC|8 ze~WaJBw{q)esTi@c|x9&JsPGN;!o2-MlQpANX9H(vA@ zU!T;U5Cqr0qN8!dw!}|(=p_wvB2<8GQvDz+5E8RXL1rk~qbVI_Rd{)jq&duN^+Tmw zIbNudFJwfof;?xVah`3F4RW(kv*8#`C^jKWOC+V2dWcuN|W8~9>r6B{3iz1wod>Ceg zW`vS)%1*0Uk(qi$PSsr=W%iw|5XTH{&6V(A3vf31NY27RFAcVXL= zU#=_c6%JVVGD*+tN^OL3FPiij$#NR^Y1%L9dI^uleOPe#TN;s*{OSY&rjs}Zg#(Ni z#)OhQrEx})8*LF2)9Prr#MN!&&w^<1^KFk^o)*WOdxF+={>me<_;|FzAH$Nc{Xc?toVcgZEjXK=+ViO@Vj11@JN7@ERU8iU2a za+YQ!1N%Ksgr$+y$su@H+N}*UGGdgwYygVa1E(fpBNpeB+rD7Yh8f8w-80rhfW0A& z2dw_Y>n#7AbWPhu7*pIN=?7?rN!Jky&cnEGzp)|~@nqCB9DyVa^K1Jp`dBsu@D%!! zi1mYfi?nDi?3fHlxuuB8U*=R}^x=5hhzR|{uD8fh80XIev~_`7QpqAfwk zwz9IkvI1wRD{Hdu#Ut#hehdx|+pqTy4?ecqFN333hc&YIuJiU?=kUFLvwze+YU`gK zo8G3Pu!7rZnxr*%({{RZpvcKhOJS`fS^od}s_Xx+Z*Dxs{~qKi`2Vx~ese?H$;xQbm~_GVO)kPd>ywL+%H#u3!zW;T z%iVndRi&BQ;752ginxD~oN~twob&}Sn1x~A4^!65qiZmB1>_U51hC8`2f62y({r@N zd#n%p+9UzL6gppw*aeGJ-(}93vE`*r{@3&>98E?fPjVU&Dgp+i%6IB8pM>SG!M#Ab z>@-Ok>4#a5rck8pa3j?4qa9U%F7`ycfvk|~FdPh7mM_4F*GV->F5y?*+GTe&zUoE> zEF>}% z_M;nRanmw%YL3|0#hooDNFp*kv{7N@=BUuuGDnsFOqF&5WeUB<{1m1lCV375QUE zQr=#pM%BxRo_Vaf`$ z!!_qe9U(O(wSC1iAV8d8IncqKqId{^j5qlujQc;@xpaOr@|%40=nwLE@BhE&KBn7$ zD_{Y-{{PDACw2yqKNh9XeNDdXePDX z>}JYK-y#)Zpv}`v`0A{C`;=V-T+Rdu8G8 z;P2$rH*w_GVxgKIZ=sw7U!fwkuTT)wSELB%E#&0!6)7_CY;v(8igg(l@sTj$mGRE4 zR=Kz0rLr&9LUzdNhTVy@xaR@sn(btVtk}@dMwkbbWwnzXQW36=vt5gZpLE=~8P{<( zU2*43cyNT06IgOjypSc^hkeFVOy?CI0CHK#_aWrIoXm%HJIjX2Bhnn&Lx!#d0YL-4`~U-~i(lksTUWl3f8Lbt>dP^BH|59CNo?Tz|@^2rEeo+=7bMY}Lhn7j*dZ2vZ| z8R-@@pjPUJ=*0Hw5TH$-Jf%J6oJ}B2ju}@>hMvKLO{gm&#s#x9hC6sNIH4qbPo+7_ zgaVFF8EFD7>71S=-5O7^fMXu~bb>lwkuh+XdfM#_`$d2C(9)uK(nCo_pYad?N({`L zg2eNjODE9;E+?42&OBQ75)&PL_2izS)pq(bq?c%B2X7WJNT~kzc@%wZZ5# zokEHYI=l>zpkdpTbXpk)KBN~+0Oab~OvzA?8`oNC7NGxQ$YO7SyC_yUBL4+jK!i` zRTekNCcPfZveOAHfRNE3jcR?8PPHqgJGktn)&*J?$tl;{abFk&Vt#}vfkX)P5Q~aT zPOmfqH5V##P{eKJki$$5#uvdYvZD@Ij#y4XmI=+Wq!*%0;`sx`@)Dk?IEM_W@USWJ zQ$oc~FxW3W+|=ZU#?)NHk*_Xu#mAHQR$vpz+fxysJRx1yOGZpbkBp-(#Q?C&?tou! zlBST#gJCF3XUO)xB)hMV9{YmFJakIV88`F&YB%Yg8RtI1d4`qhG#pHvJgNDT{`fwr ziu-{MwjzH#1Xh;XrvqzddY>;E_KiLP`S|=!pIP63xQ{+&o&VU_D185SZRIik_aM)$ z_J6^my}*1_eGU)Rz22ChF?kLQM7ma%vM+RTDv!jLJ4n<0UQBN zN^qPX5t*A(g&%U!T(CDOyejk?eMRNlJagUu{FQyoJpc8~jsI`1K7Rk@VV+s%zrN9x Z;E&Ja^Y}bIkI!%X{D0*z*dPFe0RSasZCn5V literal 0 HcmV?d00001 diff --git a/js/package.json b/js/package.json index c4b4f8611..59a53cbc9 100644 --- a/js/package.json +++ b/js/package.json @@ -34,6 +34,7 @@ "generate:migrations": "node scripts/sync-migrations.mjs", "lint": "eslint 'src/**/*.ts' 'driver/**/src/**/*.ts' 'migrate/src/**/*.ts' 'worker-threads/src/**/*.ts' 'test/src/**/*.ts' 'cli/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts'", "lint:fix": "eslint --fix 'src/**/*.ts' 'driver/**/src/**/*.ts' 'migrate/src/**/*.ts' 'worker-threads/src/**/*.ts' 'test/src/**/*.ts' 'cli/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts'", + "migration:legacy": "node scripts/check-legacy-fixture.mjs", "prepublishOnly": "pnpm run clean && pnpm run build", "test": "vitest run --passWithNoTests", "test:coverage": "vitest run --coverage", @@ -76,6 +77,7 @@ "prettier": "^3.9.6", "typescript": "^6.0.3", "typescript-eslint": "^8.65.0", + "typescript-next": "npm:typescript@7.1.0-dev.20260830.1", "valibot": "^1.5.0", "vite": "^8.0.16", "vitest": "^4.1.11", diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml index d15dcb079..bfda1cec8 100644 --- a/js/pnpm-lock.yaml +++ b/js/pnpm-lock.yaml @@ -49,6 +49,9 @@ importers: typescript-eslint: specifier: ^8.65.0 version: 8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) + typescript-next: + specifier: npm:typescript@7.1.0-dev.20260830.1 + version: typescript@7.1.0-dev.20260830.1 valibot: specifier: ^1.5.0 version: 1.5.0(typescript@6.0.3) @@ -710,6 +713,48 @@ packages: resolution: {integrity: sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript/typescript-darwin-arm64@7.1.0-dev.20260830.1': + resolution: {integrity: sha512-qhUW/7KAg/zaCTmtKg+gyP3r68Buld8PIHLgK45+rxCQsQzKq0AL5RIe8nRmgH8GrMp3rX5uYFEz8hJM2r4Tew==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.1.0-dev.20260830.1': + resolution: {integrity: sha512-NBc8LrddWv2E+d1zKrP5cXMuU0hpIdB7MIZCpxgcfmhsUuo+jsLy9ktuws4PdX+XyZmbW9PEDRES12WAi/DdEg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-linux-arm64@7.1.0-dev.20260830.1': + resolution: {integrity: sha512-/xXknEuRRjWJNnlVC0dbbteVzHlH2ZAlbucSAP/UFohkaCtfS87k6SpG0/VTfwkv7cQEPAIRP6qG9xkVurZPGQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.1.0-dev.20260830.1': + resolution: {integrity: sha512-GBrFXo6XTn64ZaPM3f2ix9hnwsPcPwPwYTMS/BCmCICRPDjSgAtpV8ZqZrMHoNp7S2BzQjW/UzkqLafl41hEIw==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-x64@7.1.0-dev.20260830.1': + resolution: {integrity: sha512-9LAECeM1QR6fkx83bNd0mB9cCaltfASxI/fZ/rqgrSWcCPq7j2JbdXrIeafP553JWkVidzfzB/tl3tlr2sm6Gw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-win32-arm64@7.1.0-dev.20260830.1': + resolution: {integrity: sha512-XxXMpOpmhu1dxaY4Y5OJdcKBHDjJaweEOor3hQp7AaKZLueYDDWHT8uzhE+6xye4xxkVzCQjbG/3Hpxdxk8JaA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.1.0-dev.20260830.1': + resolution: {integrity: sha512-iU0vE/nik1/WNZ9GwVCYGVw1lWrjdxmkEq2LJbqGdYm0g/noYW92Q8DW/1my0DqX/ggQKLHLfxoR6alWvCENGQ==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + '@visx/curve@4.0.1-alpha.0': resolution: {integrity: sha512-jRu61Uz274pV1zyioXmboyrLutYbnKsgjj4njSGCnhdXj5GkZvZbg+ThDb6oOzoAnJOBRLz4rzPlWvNJOzuVMg==} @@ -1623,6 +1668,11 @@ packages: engines: {node: '>=14.17'} hasBin: true + typescript@7.1.0-dev.20260830.1: + resolution: {integrity: sha512-eJVnD3qImawDkJhjlH5y+X1jY+ybdFzYnWcBnOZGS4tveuR5sA/+4wKngInLlIASm/Q/trSEM14fMh3d6hyufQ==} + engines: {node: '>=16.20.0'} + hasBin: true + undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} @@ -2245,6 +2295,27 @@ snapshots: '@typescript-eslint/types': 8.65.0 eslint-visitor-keys: 5.0.1 + '@typescript/typescript-darwin-arm64@7.1.0-dev.20260830.1': + optional: true + + '@typescript/typescript-darwin-x64@7.1.0-dev.20260830.1': + optional: true + + '@typescript/typescript-linux-arm64@7.1.0-dev.20260830.1': + optional: true + + '@typescript/typescript-linux-arm@7.1.0-dev.20260830.1': + optional: true + + '@typescript/typescript-linux-x64@7.1.0-dev.20260830.1': + optional: true + + '@typescript/typescript-win32-arm64@7.1.0-dev.20260830.1': + optional: true + + '@typescript/typescript-win32-x64@7.1.0-dev.20260830.1': + optional: true + '@visx/curve@4.0.1-alpha.0': dependencies: '@visx/vendor': 4.0.0-alpha.0 @@ -3135,6 +3206,16 @@ snapshots: typescript@6.0.3: {} + typescript@7.1.0-dev.20260830.1: + optionalDependencies: + '@typescript/typescript-darwin-arm64': 7.1.0-dev.20260830.1 + '@typescript/typescript-darwin-x64': 7.1.0-dev.20260830.1 + '@typescript/typescript-linux-arm': 7.1.0-dev.20260830.1 + '@typescript/typescript-linux-arm64': 7.1.0-dev.20260830.1 + '@typescript/typescript-linux-x64': 7.1.0-dev.20260830.1 + '@typescript/typescript-win32-arm64': 7.1.0-dev.20260830.1 + '@typescript/typescript-win32-x64': 7.1.0-dev.20260830.1 + undici-types@8.3.0: {} uri-js@4.4.1: diff --git a/js/scripts/check-legacy-fixture.mjs b/js/scripts/check-legacy-fixture.mjs new file mode 100644 index 000000000..1131d1d6e --- /dev/null +++ b/js/scripts/check-legacy-fixture.mjs @@ -0,0 +1,237 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + copyFile, + mkdir, + mkdtemp, + readFile, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +import { + assertNoDeniedContent, + assertPortableArchivePath, + deniedSubstrings, +} from "./package-guard.mjs"; + +const execFileAsync = promisify(execFile); +const compilers = ["typescript", "typescript-next"]; +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const fixtureRoot = resolve(repositoryRoot, "fixtures/migration-0.1"); +const originalRoot = join(fixtureRoot, "original"); +const manifest = JSON.parse( + await readFile(join(originalRoot, "manifest.json"), "utf8") +); +const archive = join(fixtureRoot, manifest.npm.file); +const archiveBytes = await readFile(archive); + +assert.equal(hash(archiveBytes, "sha1", "hex"), manifest.npm.sha1); +assert.equal(hash(archiveBytes, "sha256", "hex"), manifest.npm.sha256); +assert.equal( + `sha512-${hash(archiveBytes, "sha512", "base64")}`, + manifest.npm.integrity +); +for (const [relativePath, expected] of Object.entries( + manifest.source.documents +)) { + const contents = await readFile(join(originalRoot, relativePath)); + assert.equal(hash(contents, "sha256", "hex"), expected, relativePath); +} + +const { stdout: archiveListing } = await execFileAsync("tar", [ + "-tzf", + archive, +]); +const archiveFiles = archiveListing.trim().split("\n"); +const denied = deniedSubstrings(repositoryRoot); +for (const file of archiveFiles) { + assert.ok(file.startsWith("package/"), `${file} is inside package/`); + assertPortableArchivePath(`legacy archive ${file}`, file); + assertNoDeniedContent(`legacy archive ${file}`, file, denied); +} +const { stdout: packageJsonText } = await execFileAsync("tar", [ + "-xOf", + archive, + "package/package.json", +]); +const packageJson = JSON.parse(packageJsonText); +assert.equal(packageJson.name, "riverqueue"); +assert.equal(packageJson.version, manifest.version); +assert.equal(packageJson.license, "LGPL-3.0-or-later"); + +const temporaryDirectory = await mkdtemp(join(tmpdir(), "riverqueue-0.1-")); +try { + const packageDirectory = join( + temporaryDirectory, + "node_modules", + "riverqueue" + ); + await mkdir(packageDirectory, { recursive: true }); + await execFileAsync("tar", [ + "-xzf", + archive, + "--strip-components=1", + "-C", + packageDirectory, + ]); + await copyFile( + join(fixtureRoot, "before.ts.txt"), + join(temporaryDirectory, "consumer.ts") + ); + await writeFile( + join(temporaryDirectory, "tsconfig.json"), + `${JSON.stringify( + { + compilerOptions: { + exactOptionalPropertyTypes: true, + module: "NodeNext", + moduleResolution: "NodeNext", + noEmit: true, + skipLibCheck: true, + strict: true, + target: "ES2024", + types: [], + }, + files: ["consumer.ts"], + }, + null, + 2 + )}\n` + ); + for (const compiler of compilers) { + await execFileAsync(process.execPath, [ + resolve(repositoryRoot, "node_modules", compiler, "bin", "tsc"), + "--project", + join(temporaryDirectory, "tsconfig.json"), + ]); + } + + await checkCodemod(join(temporaryDirectory, "codemod")); +} finally { + await rm(temporaryDirectory, { force: true, recursive: true }); +} + +process.stdout.write( + `validated riverqueue@${manifest.version} archive, docs, TS6/TS-next consumer, and codemod output\n` +); + +/** + * Run the built `riverqueue codemod-0.1` over the 0.1 consumer, require the + * recorded output, and compile it against the current workspace packages. + * The only compiler errors allowed are at sites the codemod marked for + * review; after the documented manual fix for each, it must compile cleanly. + */ +async function checkCodemod(directory) { + const { run } = await import( + resolve(repositoryRoot, "cli", "dist", "index.js") + ); + await mkdir(join(directory, "node_modules"), { recursive: true }); + await symlink( + repositoryRoot, + join(directory, "node_modules", "riverqueue"), + "dir" + ); + const consumer = join(directory, "consumer.ts"); + await copyFile(join(fixtureRoot, "before.ts.txt"), consumer); + let stdout = ""; + let stderr = ""; + const exitCode = await run(["codemod-0.1", "--write", consumer], { + stderr: { write: (chunk) => (stderr += chunk) }, + stdout: { write: (chunk) => (stdout += chunk) }, + }); + assert.equal(exitCode, 0, stderr); + const output = await readFile(consumer, "utf8"); + assert.equal( + output, + await readFile(join(fixtureRoot, "codemod.ts.txt"), "utf8"), + "codemod output matches fixtures/migration-0.1/codemod.ts.txt" + ); + assert.match(stdout, /1 site to review/); + + await writeFile( + join(directory, "tsconfig.json"), + `${JSON.stringify( + { + compilerOptions: { + exactOptionalPropertyTypes: true, + lib: ["ES2024", "ESNext.Temporal"], + module: "NodeNext", + moduleResolution: "NodeNext", + noEmit: true, + skipLibCheck: true, + strict: true, + target: "ES2024", + typeRoots: [resolve(repositoryRoot, "node_modules", "@types")], + types: ["node"], + }, + files: ["consumer.ts"], + }, + null, + 2 + )}\n` + ); + const outputLines = output.split("\n"); + for (const compiler of compilers) { + const diagnostics = await compile(directory, compiler); + assert.ok(diagnostics.length > 0, `${compiler} flags the marked sites`); + for (const { line, text } of diagnostics) { + assert.match( + outputLines[line - 2] ?? "", + /\/\/ TODO\(riverqueue-0\.1\):/, + `${compiler} error at an unmarked line: ${text}` + ); + } + } + + // The manual fix the JobRow.id TODO asks for. + await writeFile( + consumer, + output.replace( + "const id: number = result.job.id;", + "const id: bigint = result.job.id;" + ) + ); + for (const compiler of compilers) { + assert.deepEqual(await compile(directory, compiler), [], compiler); + } +} + +/** Compile the project in `directory` and return its diagnostics. */ +async function compile(directory, compiler) { + try { + await execFileAsync( + process.execPath, + [ + resolve(repositoryRoot, "node_modules", compiler, "bin", "tsc"), + "--project", + join(directory, "tsconfig.json"), + "--pretty", + "false", + ], + { cwd: directory } + ); + return []; + } catch (error) { + const lines = String(error.stdout ?? "") + .split("\n") + .filter((line) => line.trim() !== ""); + if (lines.length === 0) throw error; + return lines.map((text) => { + const match = /(?:^|[\\/])consumer\.ts\((\d+),\d+\): error /.exec(text); + assert.ok(match, `unexpected compiler output: ${text}`); + return { line: Number(match[1]), text }; + }); + } +} + +function hash(contents, algorithm, encoding) { + return createHash(algorithm).update(contents).digest(encoding); +} diff --git a/js/scripts/package-guard.mjs b/js/scripts/package-guard.mjs new file mode 100644 index 000000000..a0d81dac8 --- /dev/null +++ b/js/scripts/package-guard.mjs @@ -0,0 +1,58 @@ +// Guards shared by the archive checks so published tarballs and pinned +// fixtures cannot carry machine-local paths or maintainer-denied names. + +import assert from "node:assert/strict"; +import { homedir } from "node:os"; +import { posix } from "node:path"; +import process from "node:process"; + +/** + * Build the list of case-insensitive substrings that must never appear in a + * packed path or packed text file. + * + * The list always includes the local checkout and home directory so builds + * cannot leak absolute machine paths. Maintainers can extend it without + * committing the extra names by setting `RIVER_PACKAGE_DENYLIST` to a comma- + * or newline-separated list. + */ +export function deniedSubstrings(repositoryRoot, env = process.env) { + const configured = (env.RIVER_PACKAGE_DENYLIST ?? "") + .split(/[,\n]/u) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + // Match local directories as path prefixes so a short home directory such + // as `/root` cannot collide with ordinary words. + const localDirectories = [repositoryRoot, homedir()] + .filter((directory) => directory.length > 1) + .map((directory) => `${directory.replace(/[\\/]+$/u, "")}/`); + return [...new Set([...localDirectories, ...configured])].map((entry) => + entry.toLowerCase() + ); +} + +/** Assert that text contains none of the denied substrings. */ +export function assertNoDeniedContent(label, text, denied) { + const lowered = text.toLowerCase(); + for (const entry of denied) { + assert.ok( + !lowered.includes(entry), + `${label} contains a denied local or private path` + ); + } +} + +/** + * Assert that an archive-relative path is portable: relative, POSIX + * separators, and never escaping the archive root. + */ +export function assertPortableArchivePath(label, path) { + assert.ok( + !path.startsWith("/") && !path.includes("\\") && !/^[a-z]:/iu.test(path), + `${label} is not a portable relative path: ${JSON.stringify(path)}` + ); + const normalized = posix.normalize(path); + assert.ok( + normalized !== ".." && !normalized.startsWith("../"), + `${label} escapes the archive root: ${JSON.stringify(path)}` + ); +} From 8652dc62b9d6b7fe85fcdbb521463c82685e311a Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sun, 4 Oct 2026 17:30:01 -0500 Subject: [PATCH 36/43] add runnable worker examples Add examples alongside the existing node-postgres and Prisma inserters: - `pg-worker` migrates PostgreSQL, works a job that snoozes once and then inserts a follow-up through the worker's client, and stops gracefully on completion or `SIGTERM`. - `sqlite-worker` migrates an in-memory SQLite database and works a typed job in-process. - `graceful-shutdown` stops a client while a handler is still running and shows the completion persisted before `stop` resolves. - `hooks-metrics` feeds hooks, middleware, and events into a small metrics collector. - `worker-thread-cpu` runs a CPU-heavy handler on `@riverqueue/worker-threads`. - `mixed-language` inserts a versioned payload under a stable kind that a Go, Rust, or JavaScript worker can decode alike. --- js/examples/graceful-shutdown/README.md | 14 ++ js/examples/graceful-shutdown/package.json | 23 +++ js/examples/graceful-shutdown/src/index.ts | 39 +++++ js/examples/graceful-shutdown/tsconfig.json | 8 ++ js/examples/hooks-metrics/README.md | 14 ++ js/examples/hooks-metrics/package.json | 23 +++ js/examples/hooks-metrics/src/index.ts | 74 ++++++++++ js/examples/hooks-metrics/tsconfig.json | 8 ++ js/examples/mixed-language/README.md | 18 +++ js/examples/mixed-language/package.json | 24 ++++ js/examples/mixed-language/src/index.ts | 52 +++++++ js/examples/mixed-language/tsconfig.json | 8 ++ js/examples/pg-worker/README.md | 20 +++ js/examples/pg-worker/package.json | 25 ++++ js/examples/pg-worker/src/index.ts | 70 +++++++++ js/examples/pg-worker/src/jobs.ts | 20 +++ js/examples/pg-worker/tsconfig.json | 8 ++ js/examples/sqlite-worker/README.md | 18 +++ js/examples/sqlite-worker/package.json | 23 +++ js/examples/sqlite-worker/src/index.ts | 36 +++++ js/examples/sqlite-worker/tsconfig.json | 8 ++ js/examples/worker-thread-cpu/README.md | 30 ++++ js/examples/worker-thread-cpu/package.json | 25 ++++ js/examples/worker-thread-cpu/src/handler.ts | 30 ++++ js/examples/worker-thread-cpu/src/index.ts | 48 +++++++ js/examples/worker-thread-cpu/src/jobs.ts | 11 ++ js/examples/worker-thread-cpu/tsconfig.json | 9 ++ js/pnpm-lock.yaml | 144 +++++++++++++++++++ 28 files changed, 830 insertions(+) create mode 100644 js/examples/graceful-shutdown/README.md create mode 100644 js/examples/graceful-shutdown/package.json create mode 100644 js/examples/graceful-shutdown/src/index.ts create mode 100644 js/examples/graceful-shutdown/tsconfig.json create mode 100644 js/examples/hooks-metrics/README.md create mode 100644 js/examples/hooks-metrics/package.json create mode 100644 js/examples/hooks-metrics/src/index.ts create mode 100644 js/examples/hooks-metrics/tsconfig.json create mode 100644 js/examples/mixed-language/README.md create mode 100644 js/examples/mixed-language/package.json create mode 100644 js/examples/mixed-language/src/index.ts create mode 100644 js/examples/mixed-language/tsconfig.json create mode 100644 js/examples/pg-worker/README.md create mode 100644 js/examples/pg-worker/package.json create mode 100644 js/examples/pg-worker/src/index.ts create mode 100644 js/examples/pg-worker/src/jobs.ts create mode 100644 js/examples/pg-worker/tsconfig.json create mode 100644 js/examples/sqlite-worker/README.md create mode 100644 js/examples/sqlite-worker/package.json create mode 100644 js/examples/sqlite-worker/src/index.ts create mode 100644 js/examples/sqlite-worker/tsconfig.json create mode 100644 js/examples/worker-thread-cpu/README.md create mode 100644 js/examples/worker-thread-cpu/package.json create mode 100644 js/examples/worker-thread-cpu/src/handler.ts create mode 100644 js/examples/worker-thread-cpu/src/index.ts create mode 100644 js/examples/worker-thread-cpu/src/jobs.ts create mode 100644 js/examples/worker-thread-cpu/tsconfig.json diff --git a/js/examples/graceful-shutdown/README.md b/js/examples/graceful-shutdown/README.md new file mode 100644 index 000000000..47b61b8cb --- /dev/null +++ b/js/examples/graceful-shutdown/README.md @@ -0,0 +1,14 @@ +# Graceful shutdown example + +This self-contained SQLite example starts one active handler and requests a +graceful stop while it is still running. River stops fetching, allows the +handler to finish, persists completion, and then resolves `RunHandle.stop`. + +```sh +pnpm --filter riverqueue-example-graceful-shutdown run build +pnpm --filter riverqueue-example-graceful-shutdown run start +``` + +Production services should trigger the same stop call from their process +manager's shutdown signal and keep `run.completed` observed for fatal service +errors. diff --git a/js/examples/graceful-shutdown/package.json b/js/examples/graceful-shutdown/package.json new file mode 100644 index 000000000..3767ce26c --- /dev/null +++ b/js/examples/graceful-shutdown/package.json @@ -0,0 +1,23 @@ +{ + "name": "riverqueue-example-graceful-shutdown", + "version": "0.0.0", + "private": true, + "type": "module", + "engines": { + "node": ">=26" + }, + "scripts": { + "build": "tsc", + "start": "node dist/index.js" + }, + "dependencies": { + "@riverqueue/driver-sqlite": "workspace:*", + "@riverqueue/migrate": "workspace:*", + "riverqueue": "workspace:*", + "zod": "^4.6.5" + }, + "devDependencies": { + "@types/node": "^26.1.1", + "typescript": "^6.0.3" + } +} diff --git a/js/examples/graceful-shutdown/src/index.ts b/js/examples/graceful-shutdown/src/index.ts new file mode 100644 index 000000000..94e258d8a --- /dev/null +++ b/js/examples/graceful-shutdown/src/index.ts @@ -0,0 +1,39 @@ +import { setTimeout } from "node:timers/promises"; + +import { SqliteDriver } from "@riverqueue/driver-sqlite"; +import { createMigrator } from "@riverqueue/migrate"; +import { Client, Workers, defineJob } from "riverqueue"; +import { z } from "zod"; + +const finishReport = defineJob({ + kind: "example.finish_report", + schema: z.object({ reportId: z.string().min(1) }), +}); + +let started!: () => void; +const handlerStarted = new Promise((resolve) => { + started = resolve; +}); +const workers = new Workers(); +workers.add(finishReport, async ({ job, signal }) => { + started(); + await setTimeout(50, undefined, { signal }); + console.log(`finished report ${job.args.reportId}`); +}); + +const driver = SqliteDriver.memory(); +await createMigrator(driver).migrateUp(); +const client = new Client(driver, { + queues: { default: { maxWorkers: 1 } }, + workers, +}); +const inserted = await client.insert(finishReport, { reportId: "report_1" }); +await using run = await client.start(); +await handlerStarted; + +await run.stop({ mode: "graceful", timeout: { seconds: 5 } }); +const completed = await client.jobs.get(inserted.job.id); +if (completed?.state !== "completed") { + throw new Error("graceful shutdown did not persist active work"); +} +driver.close(); diff --git a/js/examples/graceful-shutdown/tsconfig.json b/js/examples/graceful-shutdown/tsconfig.json new file mode 100644 index 000000000..e46006227 --- /dev/null +++ b/js/examples/graceful-shutdown/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/js/examples/hooks-metrics/README.md b/js/examples/hooks-metrics/README.md new file mode 100644 index 000000000..d357ae5e6 --- /dev/null +++ b/js/examples/hooks-metrics/README.md @@ -0,0 +1,14 @@ +# Hooks and metrics example + +This SQLite example connects River's ordered hooks, Koa-style middleware, and +after-commit events to a tiny in-memory metrics collector. A production service +would replace the collector with its existing metrics or tracing SDK; River +does not require a particular observability dependency. + +```sh +pnpm --filter riverqueue-example-hooks-metrics run build +pnpm --filter riverqueue-example-hooks-metrics run start +``` + +Hooks observe attempt-local work, while `job_completed` observes the committed +database transition. Do not count both as independent completed jobs. diff --git a/js/examples/hooks-metrics/package.json b/js/examples/hooks-metrics/package.json new file mode 100644 index 000000000..3b0602ab0 --- /dev/null +++ b/js/examples/hooks-metrics/package.json @@ -0,0 +1,23 @@ +{ + "name": "riverqueue-example-hooks-metrics", + "version": "0.0.0", + "private": true, + "type": "module", + "engines": { + "node": ">=26" + }, + "scripts": { + "build": "tsc", + "start": "node dist/index.js" + }, + "dependencies": { + "@riverqueue/driver-sqlite": "workspace:*", + "@riverqueue/migrate": "workspace:*", + "riverqueue": "workspace:*", + "zod": "^4.6.5" + }, + "devDependencies": { + "@types/node": "^26.1.1", + "typescript": "^6.0.3" + } +} diff --git a/js/examples/hooks-metrics/src/index.ts b/js/examples/hooks-metrics/src/index.ts new file mode 100644 index 000000000..bb0582061 --- /dev/null +++ b/js/examples/hooks-metrics/src/index.ts @@ -0,0 +1,74 @@ +import { SqliteDriver } from "@riverqueue/driver-sqlite"; +import { createMigrator } from "@riverqueue/migrate"; +import { Client, Workers, defineJob } from "riverqueue"; +import { z } from "zod"; + +const collectMetric = defineJob({ + kind: "example.collect_metric", + schema: z.object({ name: z.string().min(1) }), +}); +const workers = new Workers(); +workers.add(collectMetric, ({ job }) => { + console.log(`collected ${job.args.name}`); +}); + +const counters = new Map(); +const increment = (name: string) => + counters.set(name, (counters.get(name) ?? 0) + 1); +const driver = SqliteDriver.memory(); +await createMigrator(driver).migrateUp(); +const client = new Client(driver, { + hooks: { + afterInsert(_context, results) { + counters.set("jobs_inserted", results.length); + }, + afterWork(_context, result) { + increment(`attempt_${result.status}`); + }, + beforeWork({ job }) { + increment(`started_${job.kind}`); + }, + onEvent(event) { + increment(`event_${event.kind}`); + }, + }, + middleware: [ + async (context, next) => { + const startedAt = Temporal.Now.instant(); + try { + return await next(); + } finally { + const elapsed = Temporal.Now.instant().since(startedAt); + console.log( + `${context.job.kind} attempt took ${elapsed.total("milliseconds")} ms` + ); + } + }, + ], + queues: { default: { maxWorkers: 1 } }, + workers, +}); + +await using events = client.subscribe({ + kinds: ["job_completed"], + signal: AbortSignal.timeout(10_000), +}); +const inserted = await client.insert(collectMetric, { name: "queue_depth" }); +await using run = await client.start(); +for await (const event of events) { + if (event.kind === "job_completed" && event.job.id === inserted.job.id) break; +} +await run.stop({ mode: "graceful", timeout: { seconds: 5 } }); + +for (const required of [ + "attempt_succeeded", + "event_job_completed", + "jobs_inserted", + "started_example.collect_metric", +]) { + if ((counters.get(required) ?? 0) < 1) { + throw new Error(`missing metric ${required}`); + } +} +console.log(Object.fromEntries(counters)); +driver.close(); diff --git a/js/examples/hooks-metrics/tsconfig.json b/js/examples/hooks-metrics/tsconfig.json new file mode 100644 index 000000000..e46006227 --- /dev/null +++ b/js/examples/hooks-metrics/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/js/examples/mixed-language/README.md b/js/examples/mixed-language/README.md new file mode 100644 index 000000000..14975b301 --- /dev/null +++ b/js/examples/mixed-language/README.md @@ -0,0 +1,18 @@ +# Mixed-language producer example + +This PostgreSQL example inserts a versioned payload under the stable job kind +`mixed_language.generate_report`, then reads the durable row back without any +JavaScript-private metadata. A Go, Rust, or JavaScript worker can register that +same kind and decode the same JSON contract. + +```sh +DATABASE_URL=postgres://localhost/river_dev \ + pnpm --filter riverqueue-example-mixed-language run build +DATABASE_URL=postgres://localhost/river_dev \ + pnpm --filter riverqueue-example-mixed-language run start +``` + +Language-level type names do not need to match. Persisted job kinds, payload +field names, JSON meanings, and migration compatibility do. Version payload +changes explicitly, and keep every language's schema accepting both the old +and new shapes during rolling deployments. diff --git a/js/examples/mixed-language/package.json b/js/examples/mixed-language/package.json new file mode 100644 index 000000000..c24c1826e --- /dev/null +++ b/js/examples/mixed-language/package.json @@ -0,0 +1,24 @@ +{ + "name": "riverqueue-example-mixed-language", + "version": "0.0.0", + "private": true, + "type": "module", + "engines": { + "node": ">=26" + }, + "scripts": { + "build": "tsc", + "start": "node dist/index.js" + }, + "dependencies": { + "@riverqueue/driver-pg": "workspace:*", + "pg": "^8.22.0", + "riverqueue": "workspace:*", + "zod": "^4.6.5" + }, + "devDependencies": { + "@types/node": "^26.1.1", + "@types/pg": "^8.20.0", + "typescript": "^6.0.3" + } +} diff --git a/js/examples/mixed-language/src/index.ts b/js/examples/mixed-language/src/index.ts new file mode 100644 index 000000000..45c3a2b23 --- /dev/null +++ b/js/examples/mixed-language/src/index.ts @@ -0,0 +1,52 @@ +import { randomUUID } from "node:crypto"; + +import { PgDriver } from "@riverqueue/driver-pg"; +import { Pool } from "pg"; +import { Client, defineJob } from "riverqueue"; +import { z } from "zod"; + +const generateReport = defineJob({ + kind: "mixed_language.generate_report", + schema: z.object({ + reportId: z.string().min(1), + requestedBy: z.string().min(1), + schemaVersion: z.number().int().positive(), + }), +}); + +const pool = new Pool({ + connectionString: + process.env.DATABASE_URL ?? "postgres://localhost:5432/river_dev", +}); +const client = new Client(new PgDriver(pool)); +const reportId = randomUUID(); +const inserted = await client.insert( + generateReport, + { + reportId, + requestedBy: "typescript-api", + schemaVersion: 1, + }, + { scheduledAt: Temporal.Now.instant() } +); + +const persisted = await pool.query<{ + args: { + reportId: string; + requestedBy: string; + schemaVersion: number; + }; + kind: string; +}>("SELECT args, kind FROM river_job WHERE id = $1", [inserted.job.id]); +const row = persisted.rows[0]; +if ( + row?.kind !== generateReport.kind || + row.args.reportId !== reportId || + row.args.schemaVersion !== 1 +) { + throw new Error("persisted mixed-language contract did not round-trip"); +} +console.log( + `inserted ${row.kind} job ${inserted.job.id} for a Go, Rust, or JS worker` +); +await pool.end(); diff --git a/js/examples/mixed-language/tsconfig.json b/js/examples/mixed-language/tsconfig.json new file mode 100644 index 000000000..e46006227 --- /dev/null +++ b/js/examples/mixed-language/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/js/examples/pg-worker/README.md b/js/examples/pg-worker/README.md new file mode 100644 index 000000000..40bf45f07 --- /dev/null +++ b/js/examples/pg-worker/README.md @@ -0,0 +1,20 @@ +# PostgreSQL worker example + +This example migrates a PostgreSQL database, inserts a job, and works it in +the same process: the handler snoozes once as if a payment provider were busy, +then succeeds and inserts a follow-up job through the worker's own client. It +shuts down gracefully when the follow-up completes or on `SIGTERM`. + +It uses `node-postgres`, Zod for validation, and structured logging through +the job's `logger` (which writes warnings and errors to `console` unless the +client is given a logger such as pino). + +From the repository root, with a disposable PostgreSQL database: + +```sh +pnpm install +pnpm run build:all +pnpm --filter riverqueue-example-pg-worker run build +DATABASE_URL=postgres://localhost:5432/river_example \ + pnpm --filter riverqueue-example-pg-worker run start +``` diff --git a/js/examples/pg-worker/package.json b/js/examples/pg-worker/package.json new file mode 100644 index 000000000..ff2fca531 --- /dev/null +++ b/js/examples/pg-worker/package.json @@ -0,0 +1,25 @@ +{ + "name": "riverqueue-example-pg-worker", + "version": "0.0.0", + "private": true, + "type": "module", + "engines": { + "node": ">=26" + }, + "scripts": { + "build": "tsc", + "start": "node dist/index.js" + }, + "dependencies": { + "@riverqueue/driver-pg": "workspace:*", + "@riverqueue/migrate": "workspace:*", + "pg": "^8.22.0", + "riverqueue": "workspace:*", + "zod": "^4.6.5" + }, + "devDependencies": { + "@types/node": "^26.1.1", + "@types/pg": "^8.20.0", + "typescript": "^6.0.3" + } +} diff --git a/js/examples/pg-worker/src/index.ts b/js/examples/pg-worker/src/index.ts new file mode 100644 index 000000000..b53663b92 --- /dev/null +++ b/js/examples/pg-worker/src/index.ts @@ -0,0 +1,70 @@ +import { PgDriver } from "@riverqueue/driver-pg"; +import { createMigrator } from "@riverqueue/migrate"; +import { Pool } from "pg"; +import { Client, Workers, snooze } from "riverqueue"; + +import { chargeInvoice, sendReceipt } from "./jobs.js"; + +const pool = new Pool({ + connectionString: + process.env.DATABASE_URL ?? "postgres://localhost:5432/river_dev", + max: 20, +}); +const driver = new PgDriver(pool); + +// A real deployment runs migrations as a separate step before workers start. +await createMigrator(driver).migrateUp(); + +let providerBusy = true; +const workers = new Workers() + .add(chargeInvoice, async ({ client, job, logger, signal }) => { + signal.throwIfAborted(); + // Simulate a provider that is briefly unavailable: snoozing retries later + // without using one of the job's attempts. + if (providerBusy) { + providerBusy = false; + logger.info("payment provider busy; snoozing"); + return snooze({ seconds: 0 }); + } + logger.info({ amountCents: job.args.amountCents }, "charged invoice"); + // Insert follow-up work with the worker's own client. + await client.insert(sendReceipt, { invoiceId: job.args.invoiceId }); + return undefined; + }) + .add(sendReceipt, ({ job, logger }) => { + logger.info({ invoiceId: job.args.invoiceId }, "sent receipt"); + }); + +const client = new Client(driver, { + queues: { + billing: { maxWorkers: 5 }, + default: { maxWorkers: 10 }, + }, + workers, +}); + +await using events = client.subscribe({ + kinds: ["job_completed"], + signal: AbortSignal.timeout(30_000), +}); +const run = await client.start(); +process.once("SIGTERM", () => { + void run.stop({ timeout: { seconds: 30 } }); +}); + +const inserted = await client.insert(chargeInvoice, { + amountCents: 1_999, + invoiceId: `inv_${Date.now()}`, +}); +console.log(`inserted job ${inserted.job.id}`); + +// Wait for the receipt, which is inserted only after the charge succeeds. +for await (const event of events) { + if (event.kind === "job_completed" && event.job.kind === sendReceipt.kind) { + console.log(`completed job ${event.job.id} (${event.job.kind})`); + break; + } +} + +await run.stop({ timeout: { seconds: 5 } }); +await pool.end(); diff --git a/js/examples/pg-worker/src/jobs.ts b/js/examples/pg-worker/src/jobs.ts new file mode 100644 index 000000000..24296bd07 --- /dev/null +++ b/js/examples/pg-worker/src/jobs.ts @@ -0,0 +1,20 @@ +import { defineJob } from "riverqueue"; +import { z } from "zod"; + +/** + * Job definitions are shared by producers and workers. The schema validates + * arguments when a job is inserted and again before it is worked. + */ +export const chargeInvoice = defineJob({ + defaults: { maxAttempts: 5, queue: "billing" }, + kind: "example.charge_invoice", + schema: z.object({ + amountCents: z.number().int().positive(), + invoiceId: z.string().min(1), + }), +}); + +export const sendReceipt = defineJob({ + kind: "example.send_receipt", + schema: z.object({ invoiceId: z.string().min(1) }), +}); diff --git a/js/examples/pg-worker/tsconfig.json b/js/examples/pg-worker/tsconfig.json new file mode 100644 index 000000000..e46006227 --- /dev/null +++ b/js/examples/pg-worker/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/js/examples/sqlite-worker/README.md b/js/examples/sqlite-worker/README.md new file mode 100644 index 000000000..92cff0030 --- /dev/null +++ b/js/examples/sqlite-worker/README.md @@ -0,0 +1,18 @@ +# SQLite worker example + +This example applies River's SQLite migrations to an in-memory database, +inserts a typed job, works it in-process, observes its committed completion, +and shuts down cleanly. It needs only Node.js 26 and the workspace packages. + +From the repository root: + +```sh +pnpm run build:all +pnpm --filter riverqueue-example-sqlite-worker run build +pnpm --filter riverqueue-example-sqlite-worker run start +``` + +Applications normally pass a file-backed `DatabaseSync` to +`new SqliteDriver(database)` and run migrations as a separate deployment step. +`SqliteDriver.memory()` creates an in-memory database instead, which keeps this +example disposable. diff --git a/js/examples/sqlite-worker/package.json b/js/examples/sqlite-worker/package.json new file mode 100644 index 000000000..8b4c5c860 --- /dev/null +++ b/js/examples/sqlite-worker/package.json @@ -0,0 +1,23 @@ +{ + "name": "riverqueue-example-sqlite-worker", + "version": "0.0.0", + "private": true, + "type": "module", + "engines": { + "node": ">=26" + }, + "scripts": { + "build": "tsc", + "start": "node dist/index.js" + }, + "dependencies": { + "@riverqueue/driver-sqlite": "workspace:*", + "@riverqueue/migrate": "workspace:*", + "riverqueue": "workspace:*", + "zod": "^4.6.5" + }, + "devDependencies": { + "@types/node": "^26.1.1", + "typescript": "^6.0.3" + } +} diff --git a/js/examples/sqlite-worker/src/index.ts b/js/examples/sqlite-worker/src/index.ts new file mode 100644 index 000000000..bdbbe3275 --- /dev/null +++ b/js/examples/sqlite-worker/src/index.ts @@ -0,0 +1,36 @@ +import { SqliteDriver } from "@riverqueue/driver-sqlite"; +import { createMigrator } from "@riverqueue/migrate"; +import { Client, Workers, defineJob } from "riverqueue"; +import { z } from "zod"; + +const greet = defineJob({ + kind: "example.greet", + schema: z.object({ name: z.string().min(1) }), +}); + +const workers = new Workers(); +workers.add(greet, ({ job }) => { + console.log(`hello, ${job.args.name}`); +}); + +const driver = SqliteDriver.memory(); +await createMigrator(driver).migrateUp(); + +const client = new Client(driver, { + queues: { default: { maxWorkers: 1 } }, + workers, +}); +await using events = client.subscribe({ + kinds: ["job_completed"], + signal: AbortSignal.timeout(10_000), +}); +const inserted = await client.insert(greet, { name: "River" }); +await using run = await client.start(); +for await (const event of events) { + if (event.kind === "job_completed" && event.job.id === inserted.job.id) { + break; + } +} + +await run.stop({ mode: "graceful", timeout: { seconds: 5 } }); +driver.close(); diff --git a/js/examples/sqlite-worker/tsconfig.json b/js/examples/sqlite-worker/tsconfig.json new file mode 100644 index 000000000..e46006227 --- /dev/null +++ b/js/examples/sqlite-worker/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/js/examples/worker-thread-cpu/README.md b/js/examples/worker-thread-cpu/README.md new file mode 100644 index 000000000..1f06a389a --- /dev/null +++ b/js/examples/worker-thread-cpu/README.md @@ -0,0 +1,30 @@ +# CPU worker-thread example + +This self-contained SQLite example routes a CPU-heavy prime calculation through +the bounded `@riverqueue/worker-threads` executor. The main event loop remains +available for River's database, completion, cancellation, and shutdown work. + +```sh +pnpm --filter riverqueue-example-worker-thread-cpu run build +pnpm --filter riverqueue-example-worker-thread-cpu run start +``` + +The example also runs directly from its TypeScript sources with Node's +built-in type stripping, without a build: + +```sh +pnpm --filter riverqueue-example-worker-thread-cpu run start:source +``` + +The job definition lives in `jobs.ts`, so producers can import it without the +handler. `handler.ts` is the module that runs in worker threads; its export is +typed with the definition, and `index.ts` references it by URL with that +module's type, so a misspelled or mismatched export name fails to compile. The +definition's schema validates args in the main thread before an attempt +reaches a thread. + +The handler URL uses the compiled `.js` name. When only `handler.ts` exists, +River's threads load the source instead, so the same URL works from `dist` and +from `src`. Relative imports use `.ts` extensions, which +`rewriteRelativeImportExtensions` turns into `.js` in the build, because Node's +type stripping runs the main module from source without a loader. diff --git a/js/examples/worker-thread-cpu/package.json b/js/examples/worker-thread-cpu/package.json new file mode 100644 index 000000000..28125e35c --- /dev/null +++ b/js/examples/worker-thread-cpu/package.json @@ -0,0 +1,25 @@ +{ + "name": "riverqueue-example-worker-thread-cpu", + "version": "0.0.0", + "private": true, + "type": "module", + "engines": { + "node": ">=26" + }, + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "start:source": "node src/index.ts" + }, + "dependencies": { + "@riverqueue/driver-sqlite": "workspace:*", + "@riverqueue/migrate": "workspace:*", + "@riverqueue/worker-threads": "workspace:*", + "riverqueue": "workspace:*", + "zod": "^4.6.5" + }, + "devDependencies": { + "@types/node": "^26.1.1", + "typescript": "^6.0.3" + } +} diff --git a/js/examples/worker-thread-cpu/src/handler.ts b/js/examples/worker-thread-cpu/src/handler.ts new file mode 100644 index 000000000..1d3bf06a3 --- /dev/null +++ b/js/examples/worker-thread-cpu/src/handler.ts @@ -0,0 +1,30 @@ +import type { WorkerThreadWorkHandler } from "@riverqueue/worker-threads"; +import { complete } from "riverqueue"; + +import type { findPrime } from "./jobs.ts"; + +// Runs in a worker thread. `job.args` was already validated by `findPrime`'s +// schema in the main thread, so `ordinal` is a positive safe integer. +export const findPrimeHandler: WorkerThreadWorkHandler = ({ + job, + signal, +}) => complete({ output: { prime: nthPrime(job.args.ordinal, signal) } }); + +function nthPrime(ordinal: number, signal: AbortSignal): number { + let found = 0; + let candidate = 1; + while (found < ordinal) { + candidate++; + if (isPrime(candidate)) found++; + // Cooperate with cancellation; River terminates the thread otherwise. + if (candidate % 10_000 === 0) signal.throwIfAborted(); + } + return candidate; +} + +function isPrime(value: number): boolean { + for (let divisor = 2; divisor * divisor <= value; divisor++) { + if (value % divisor === 0) return false; + } + return true; +} diff --git a/js/examples/worker-thread-cpu/src/index.ts b/js/examples/worker-thread-cpu/src/index.ts new file mode 100644 index 000000000..0d0998847 --- /dev/null +++ b/js/examples/worker-thread-cpu/src/index.ts @@ -0,0 +1,48 @@ +import { SqliteDriver } from "@riverqueue/driver-sqlite"; +import { createMigrator } from "@riverqueue/migrate"; +import { + WorkerThreads, + type WorkerThreadModule, +} from "@riverqueue/worker-threads"; +import { Client, Workers } from "riverqueue"; + +import type * as primeHandlers from "./handler.ts"; +import { findPrime } from "./jobs.ts"; + +// The `.js` name works both from the build and from source: when only +// `handler.ts` exists, River's threads load it through Node's type stripping. +const handlerModule: WorkerThreadModule = new URL( + "./handler.js", + import.meta.url +); + +// The application owns the executor and closes it when this scope ends. +await using executor = new WorkerThreads({ maxThreads: 2 }); +const workers = new Workers().addExecutor( + findPrime, + executor.handler(findPrime, { + exportName: "findPrimeHandler", + module: handlerModule, + }) +); + +const driver = SqliteDriver.memory(); +await createMigrator(driver).migrateUp(); +const client = new Client(driver, { + queues: { default: { maxWorkers: 2 } }, + workers, +}); +await using events = client.subscribe({ + kinds: ["job_completed"], + signal: AbortSignal.timeout(10_000), +}); +const inserted = await client.insert(findPrime, { ordinal: 2_000 }); +await using run = await client.start(); +for await (const event of events) { + if (event.kind === "job_completed" && event.job.id === inserted.job.id) { + console.log("2,000th prime:", JSON.stringify(event.job.metadata["output"])); + break; + } +} +await run.stop({ mode: "graceful", timeout: { seconds: 5 } }); +driver.close(); diff --git a/js/examples/worker-thread-cpu/src/jobs.ts b/js/examples/worker-thread-cpu/src/jobs.ts new file mode 100644 index 000000000..bd2357d34 --- /dev/null +++ b/js/examples/worker-thread-cpu/src/jobs.ts @@ -0,0 +1,11 @@ +import { defineJob } from "riverqueue"; +import { z } from "zod"; + +/** + * Find the nth prime. The schema validates args wherever the job is worked, + * including jobs inserted by producers in other languages. + */ +export const findPrime = defineJob({ + kind: "example.find_prime", + schema: z.object({ ordinal: z.number().int().positive() }), +}); diff --git a/js/examples/worker-thread-cpu/tsconfig.json b/js/examples/worker-thread-cpu/tsconfig.json new file mode 100644 index 000000000..c9f75064d --- /dev/null +++ b/js/examples/worker-thread-cpu/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rewriteRelativeImportExtensions": true, + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml index bfda1cec8..2746a3a06 100644 --- a/js/pnpm-lock.yaml +++ b/js/pnpm-lock.yaml @@ -139,6 +139,75 @@ importers: specifier: workspace:0.50.0-alpha.1 version: link:../.. + examples/graceful-shutdown: + dependencies: + '@riverqueue/driver-sqlite': + specifier: workspace:* + version: link:../../driver/sqlite + '@riverqueue/migrate': + specifier: workspace:* + version: link:../../migrate + riverqueue: + specifier: workspace:* + version: link:../.. + zod: + specifier: ^4.6.5 + version: 4.6.5 + devDependencies: + '@types/node': + specifier: ^26.1.1 + version: 26.1.1 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + + examples/hooks-metrics: + dependencies: + '@riverqueue/driver-sqlite': + specifier: workspace:* + version: link:../../driver/sqlite + '@riverqueue/migrate': + specifier: workspace:* + version: link:../../migrate + riverqueue: + specifier: workspace:* + version: link:../.. + zod: + specifier: ^4.6.5 + version: 4.6.5 + devDependencies: + '@types/node': + specifier: ^26.1.1 + version: 26.1.1 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + + examples/mixed-language: + dependencies: + '@riverqueue/driver-pg': + specifier: workspace:* + version: link:../../driver/pg + pg: + specifier: ^8.22.0 + version: 8.22.0 + riverqueue: + specifier: workspace:* + version: link:../.. + zod: + specifier: ^4.6.5 + version: 4.6.5 + devDependencies: + '@types/node': + specifier: ^26.1.1 + version: 26.1.1 + '@types/pg': + specifier: ^8.20.0 + version: 8.20.0 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + examples/node-postgres: dependencies: '@riverqueue/driver-pg': @@ -164,6 +233,34 @@ importers: specifier: ^6.0.3 version: 6.0.3 + examples/pg-worker: + dependencies: + '@riverqueue/driver-pg': + specifier: workspace:* + version: link:../../driver/pg + '@riverqueue/migrate': + specifier: workspace:* + version: link:../../migrate + pg: + specifier: ^8.22.0 + version: 8.22.0 + riverqueue: + specifier: workspace:* + version: link:../.. + zod: + specifier: ^4.6.5 + version: 4.6.5 + devDependencies: + '@types/node': + specifier: ^26.1.1 + version: 26.1.1 + '@types/pg': + specifier: ^8.20.0 + version: 8.20.0 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + examples/prisma: dependencies: '@prisma/adapter-pg': @@ -192,6 +289,53 @@ importers: specifier: ^6.0.3 version: 6.0.3 + examples/sqlite-worker: + dependencies: + '@riverqueue/driver-sqlite': + specifier: workspace:* + version: link:../../driver/sqlite + '@riverqueue/migrate': + specifier: workspace:* + version: link:../../migrate + riverqueue: + specifier: workspace:* + version: link:../.. + zod: + specifier: ^4.6.5 + version: 4.6.5 + devDependencies: + '@types/node': + specifier: ^26.1.1 + version: 26.1.1 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + + examples/worker-thread-cpu: + dependencies: + '@riverqueue/driver-sqlite': + specifier: workspace:* + version: link:../../driver/sqlite + '@riverqueue/migrate': + specifier: workspace:* + version: link:../../migrate + '@riverqueue/worker-threads': + specifier: workspace:* + version: link:../../worker-threads + riverqueue: + specifier: workspace:* + version: link:../.. + zod: + specifier: ^4.6.5 + version: 4.6.5 + devDependencies: + '@types/node': + specifier: ^26.1.1 + version: 26.1.1 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + migrate: devDependencies: '@riverqueue/driver-pg': From 1605157ebee687a82f8385b6c979e150061355a3 Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sun, 4 Oct 2026 17:30:50 -0500 Subject: [PATCH 37/43] document the runtime, drivers, and packages Rewrite the README and guide for the current API: requirements (Node 26 with native `Temporal`, TypeScript 6, ESM), a PostgreSQL quickstart, and sections on defining, inserting, working, and querying jobs and on exact values. Add guides for databases, the runtime, errors and retries, periodic jobs, resumable jobs, observability, testing, deployment, and development, a README for each package, and changelog entries for the release. `docs:snippets` typechecks every TypeScript snippet in the READMEs and guides as its own module against the workspace sources, and `docs:api` builds TypeDoc reference documentation for every package's entry point. --- js/.gitignore | 1 + js/CHANGELOG.md | 53 +++ js/README.md | 130 +++++++ js/cli/README.md | 154 ++++++++ js/docs/README.md | 562 +++++++++++++++++++++------ js/docs/databases.md | 109 ++++++ js/docs/deployment.md | 81 ++++ js/docs/development.md | 250 +++++++++--- js/docs/errors-and-retries.md | 255 ++++++++++++ js/docs/observability.md | 182 +++++++++ js/docs/periodic-jobs.md | 198 ++++++++++ js/docs/resumable-jobs.md | 78 ++++ js/docs/runtime.md | 224 +++++++++++ js/docs/testing.md | 147 +++++++ js/driver/pg/README.md | 112 ++++++ js/driver/prisma/README.md | 93 +++++ js/driver/sqlite/README.md | 254 ++++++++++++ js/migrate/README.md | 132 +++++++ js/package.json | 7 +- js/pnpm-lock.yaml | 136 +++++++ js/scripts/check-readme-snippets.mjs | 165 ++++++++ js/test/README.md | 107 +++++ js/tsconfig.docs.json | 15 + js/typedoc.json | 23 ++ js/worker-threads/README.md | 197 ++++++++++ 25 files changed, 3496 insertions(+), 169 deletions(-) create mode 100644 js/README.md create mode 100644 js/cli/README.md create mode 100644 js/docs/databases.md create mode 100644 js/docs/deployment.md create mode 100644 js/docs/errors-and-retries.md create mode 100644 js/docs/observability.md create mode 100644 js/docs/periodic-jobs.md create mode 100644 js/docs/resumable-jobs.md create mode 100644 js/docs/runtime.md create mode 100644 js/docs/testing.md create mode 100644 js/driver/pg/README.md create mode 100644 js/driver/prisma/README.md create mode 100644 js/driver/sqlite/README.md create mode 100644 js/migrate/README.md create mode 100644 js/scripts/check-readme-snippets.mjs create mode 100644 js/test/README.md create mode 100644 js/tsconfig.docs.json create mode 100644 js/typedoc.json create mode 100644 js/worker-threads/README.md diff --git a/js/.gitignore b/js/.gitignore index 4a5cac04c..d04dbb7f2 100644 --- a/js/.gitignore +++ b/js/.gitignore @@ -1,6 +1,7 @@ node_modules/ coverage/ dist/ +docs/api/ temp/ */temp/ examples/prisma/src/generated/prisma/ diff --git a/js/CHANGELOG.md b/js/CHANGELOG.md index 64542ba07..e47217a3c 100644 --- a/js/CHANGELOG.md +++ b/js/CHANGELOG.md @@ -7,10 +7,63 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [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 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/docs/README.md b/js/docs/README.md index cad81cb2a..ad0e609d8 100644 --- a/js/docs/README.md +++ b/js/docs/README.md @@ -1,180 +1,502 @@ -# River TypeScript Client +# River for JavaScript and TypeScript: guide -TypeScript client for [River](https://github.com/riverqueue/river), a fast and reliable background job framework for PostgreSQL. +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. -This is an **insert-only** client — it can enqueue jobs for processing, but jobs are executed by a River server written in Go. Both the client and server share the same PostgreSQL database. +## Define jobs -## Packages +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. -The project is structured as a monorepo with a core package and driver packages: +Validate arguments with any [Standard Schema](https://standardschema.dev) +library (Zod, Valibot, ArkType, ...): -| Package | Description | -|---------|-------------| -| [`riverqueue`](.) | Core client, types, and job insertion logic. | -| [`@riverqueue/driver-pg`](./driver/pg) | Driver for [node-postgres (`pg`)](https://node-postgres.com/). | -| [`@riverqueue/driver-prisma`](./driver/prisma) | Driver for [Prisma](https://www.prisma.io/). | +```ts +import { defineJob } from "riverqueue"; +import { z } from "zod"; -Drivers are separate packages so that ORM/database libraries not in use don't become transitive dependencies. +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 }; + }, +}); +``` -## Installation +Producers insert the decoder's return type. When the decoder returns +something that is not JSON, declare the producer type separately with +`defineJob()`: -Install the core package along with the driver for your database library: +```ts +import { defineJob } from "riverqueue"; -```sh -# Using node-postgres (pg) -pnpm add riverqueue @riverqueue/driver-pg pg +interface ReportInput { + reportId: string; + since: string; // ISO 8601 +} -# Using Prisma -pnpm add riverqueue @riverqueue/driver-prisma +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), + }; + }, +}); ``` -## Usage +`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. -### Defining Job Args +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. -Job args must implement the `JobArgs` interface with a `kind` string that identifies the job type. Use `toJSON()` to control which fields are serialized as the job's args in the database: +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: -```typescript -import type { JobArgs } from "riverqueue"; +```ts +import { defineJob } from "riverqueue"; -class SortArgs implements JobArgs { - kind = "sort"; +export const sendInvoice = defineJob({ + kind: "send_invoice", + kindAliases: ["email_invoice"], +}); +``` - constructor(public strings: string[]) {} +## Insert jobs - toJSON() { - return { strings: this.strings }; - } + + +```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 } ``` -For quick one-off jobs, use `JobArgsObject`: +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}`); +} +``` -```typescript -import { JobArgsObject } from "riverqueue"; +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: + + -const args = new JobArgsObject("sort", { strings: ["whale", "tiger", "bear"] }); +```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)); ``` -### Inserting Jobs +### Transactions -#### With node-postgres +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`): -```typescript -import { Pool } from "pg"; -import { Client } from "riverqueue"; + -// Insert a single job -const result = await client.insert(new SortArgs(["whale", "tiger", "bear"])); -console.log(result.job.id); // inserted job ID - -// Insert with options -const result2 = await client.insert( - new SortArgs(["whale", "tiger", "bear"]), - { - queue: "high_priority", - priority: 2, - maxAttempts: 5, - } -); +```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(); +} +``` -// Insert many jobs at once -const results = await client.insertMany([ - new SortArgs(["whale", "tiger"]), - new SortArgs(["bear", "fox"]), -]); +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 } } +); ``` -#### With Prisma +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). -```typescript -import { PrismaClient } from "@prisma/client"; -import { Client } from "riverqueue"; -import { PrismaDriver } from "@riverqueue/driver-prisma"; +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"`. -const prisma = new PrismaClient(); -const client = new Client(new PrismaDriver(prisma)); +`ctx.recordOutput` stores JSON output even when the attempt fails, and +`ctx.setMetadata` merges into the job's metadata. -const result = await client.insert(new SortArgs(["whale", "tiger", "bear"])); +`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(); + } + } +); ``` -### Scheduled Jobs +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 -Schedule jobs to run at a future time: +Configure queues and start the client. `maxWorkers` bounds concurrent +handlers per queue in this process: -```typescript -await client.insert(new SortArgs(["whale", "tiger"]), { - scheduledAt: new Date(Date.now() + 60 * 60 * 1000), // 1 hour from now + + +```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); ``` -### Unique Jobs +`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. -Unique jobs prevent duplicate insertions based on configurable criteria: +## Query and control jobs -```typescript -await client.insert(new SortArgs(["whale", "tiger"]), { - uniqueOpts: { - byArgs: true, // unique per args - byQueue: true, // unique per queue - byPeriod: 900, // unique within 15-minute windows - }, +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); -### Batch Inserts +if (page.nextCursor !== null) { + await client.jobs.list({ after: page.nextCursor, limit: 100 }); +} -Use `insertMany` for efficient batch insertions: +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 +} -```typescript -import { InsertManyParams } from "riverqueue"; +await client.queues.pause("email"); // or "*" for every queue +await client.queues.resume("email"); +``` -const results = await client.insertMany([ - // Raw job args use default options - new SortArgs(["whale", "tiger"]), +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. - // InsertManyParams pairs args with per-job options - new InsertManyParams(new SortArgs(["bear", "fox"]), { - queue: "high_priority", - maxAttempts: 10, - }), -]); -``` +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. -### Transactions +## Exact values and JSON -#### With node-postgres +River never exposes a lossy database value: -```typescript -const poolClient = await pool.connect(); -try { - await poolClient.query("BEGIN"); +- 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`. - await client.insert(new SortArgs(["whale"]), { tx: poolClient }); - await client.insert(new SortArgs(["tiger"]), { tx: poolClient }); +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`. - await poolClient.query("COMMIT"); -} catch (e) { - await poolClient.query("ROLLBACK"); - throw e; -} finally { - poolClient.release(); -} -``` +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`: -#### With Prisma +```ts +import { isJsonNumber, jsonNumberToBigInt, parseJsonObject } from "riverqueue"; -```typescript -await prisma.$transaction(async (tx) => { - await client.insert(new SortArgs(["whale"]), { tx }); - await client.insert(new SortArgs(["tiger"]), { tx }); -}); +const args = parseJsonObject('{"userId":9223372036854775807}'); +if (isJsonNumber(args.userId)) { + console.log(jsonNumberToBigInt(args.userId)); // 9223372036854775807n +} ``` -## Development - -See [developing River TypeScript](https://github.com/riverqueue/river/blob/master/js/docs/development.md). +`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 index 74b85ee02..31580b72c 100644 --- a/js/docs/development.md +++ b/js/docs/development.md @@ -2,88 +2,244 @@ ## 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 packages (core + drivers) +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 the test database and apply migrations: +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 - river migrate-up --database-url "postgres://localhost/river_test" --line main - -The `river` CLI can be installed with Go: - - go install github.com/riverqueue/river/cmd/river@latest + 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 -## Releasing a new version +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: -The publishable packages are the root `riverqueue` package and the driver -packages under `driver/*`. The packages under `examples/*` are private examples -and should stay at `0.0.0`. + RIVER_STRESS_ITERATIONS=500 RIVER_STRESS_SEED=7 pnpm run test:integration \ + driver/pg/src/stress.integration.test.ts -1. Fetch changes to the repo. Export `VERSION` by incrementing the last tag: +## Running the CLI from a checkout - ```shell - git checkout master && git pull --rebase - export VERSION=0.x.y - git checkout -b $USER-$VERSION - ``` +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: -2. Update version numbers in the publishable `package.json` files: + node cli/dist/bin.js bench \ + --database-url "postgres://localhost/river_bench" --yes --duration 30s - ```shell - pnpm version $VERSION --no-git-tag-version - pnpm --filter './driver/*' exec npm version $VERSION --no-git-tag-version - ``` +## Continuous integration -3. Update `CHANGELOG.md` by moving the release notes from `Unreleased` into a - heading for the new version. +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. -4. Optional: Verify the release locally. Notably, changes must be committed for - this to work. +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. - ```shell - pnpm publish --dry-run - pnpm --filter './driver/*' publish --dry-run --access public - ``` +## Preparing a release -5. Prepare a PR with the version and changelog changes. Have it reviewed and - merged. +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. -6. Upon merge, pull down the changes, tag, and push: +1. Fetch changes to the repo. Export `VERSION` by incrementing the last tag: - ```shell - git checkout master && git pull --rebase - git tag v$VERSION -m "release v$VERSION" - git push origin v$VERSION - ``` + ```shell + git checkout master && git pull --rebase + export VERSION=0.x.y + git checkout -b $USER-$VERSION + ``` -7. Publish packages to npm. Publish the root package first because the driver - packages depend on it: +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. - ```shell - pnpm publish - pnpm --filter './driver/*' publish --access public - ``` +3. Update `CHANGELOG.md` by moving the release notes from `Unreleased` into a + heading for the new version. -8. Cut a new GitHub release by visiting [new release](https://github.com/riverqueue/riverqueue-js/releases/new), - selecting the new tag, and copying in the version's `CHANGELOG.md` content - as the release body. +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/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/prisma/README.md b/js/driver/prisma/README.md new file mode 100644 index 000000000..1f574eea2 --- /dev/null +++ b/js/driver/prisma/README.md @@ -0,0 +1,93 @@ +# `@riverqueue/driver-prisma` + +This package inserts River jobs through Prisma, including inside an existing +Prisma transaction. It is intentionally an insertion adapter, not a worker +runtime. Run workers with `@riverqueue/driver-pg` or another complete backend. + +```sh +npm install riverqueue @riverqueue/driver-prisma @prisma/client +``` + +```ts +import { Client, defineJob } from "riverqueue"; +import { z } from "zod"; +import { PrismaDriver, type PrismaClientLike } from "@riverqueue/driver-prisma"; + +interface ApplicationTransaction extends PrismaClientLike { + account: { + create(options: { data: { id: string } }): Promise; + }; +} +interface ApplicationPrismaClient extends PrismaClientLike { + $transaction( + callback: (transaction: ApplicationTransaction) => Promise + ): Promise; +} + +declare const prisma: ApplicationPrismaClient; // Your generated client. +const client = new Client(new PrismaDriver(prisma)); +const accountId = "acct_1"; +const syncAccount = defineJob({ + kind: "sync_account", + schema: z.object({ accountId: z.string() }), +}); + +await prisma.$transaction(async (tx) => { + await tx.account.create({ data: { id: accountId } }); + await client.insert(syncAccount, { accountId }, { tx }); +}); +``` + +See the [runnable Prisma example](../../examples/prisma) for Prisma's generated +client and PostgreSQL adapter setup. + +The Prisma client and transaction remain caller-owned. River neither connects +nor disconnects Prisma. Apply River migrations separately with +`@riverqueue/migrate` or `@riverqueue/cli`; River's tables are not Prisma model +state. + +Prisma's transaction object is structurally detected and bound to the operation +that receives it. A transaction cannot provide runtime features such as job +claiming, listening for notifications, leadership, or maintenance. + +Without `{ tx }`, River runs the insertion in an interactive transaction it +begins with the root client's `$transaction`, 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. Construct the driver with +your root `PrismaClient` for this; a transaction client has no `$transaction`, +so a driver built from one requires `{ tx }` on every insertion. + +Prisma bounds an interactive transaction with its defaults: a 2 second wait +for a connection and a 5 second timeout, which also covers any I/O insert +middleware awaits before calling `next()`. Change them with +`transactionOptions`: + +```ts continued +const patientClient = new Client( + new PrismaDriver(prisma, { + transactionOptions: { maxWait: { seconds: 5 }, timeout: { seconds: 15 } }, + }) +); +``` + +Like River's other clients, inserting an immediately available job sends an +insert notification, at most one per queue per client `fetchCooldown`, so +running workers claim it without waiting for their next poll. Inside a transaction the notification is delivered only when the +transaction commits. Inserted rows are decoded exactly, so integers beyond +JavaScript's safe range in arguments or metadata stay exact. + +## 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's tests run this driver against Prisma 7.9 with `@prisma/adapter-pg`. + +TypeScript users need TypeScript 6.0 or newer and `@types/node`, 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/sqlite/README.md b/js/driver/sqlite/README.md new file mode 100644 index 000000000..c6f781180 --- /dev/null +++ b/js/driver/sqlite/README.md @@ -0,0 +1,254 @@ +# `@riverqueue/driver-sqlite` + +This package is River's preview SQLite backend for Node 26 and newer. It uses +the built-in synchronous `node:sqlite` API. + +Apply River's generated canonical migrations explicitly before constructing a +client. The driver does not migrate during construction or startup: + +```ts +import { DatabaseSync } from "node:sqlite"; +import { SqliteDriver } from "@riverqueue/driver-sqlite"; +import { createMigrator } from "@riverqueue/migrate"; +import { Client, defineJob } from "riverqueue"; +import { z } from "zod"; + +const database = new DatabaseSync("river.db"); +const driver = new SqliteDriver(database); +await createMigrator(driver).migrateUp(); + +const client = new Client(driver); +const syncAccount = defineJob({ + kind: "sync_account", + schema: z.object({ accountId: z.string() }), +}); +``` + +## Connections + +Like River for Go, River runs on a connection of its own. `new +SqliteDriver(database)` opens a private `DatabaseSync` on the same file and +never uses, hands out, or closes `database`. Application statements therefore +never run inside River's transactions, and River's never run inside the +application's. Close River's connection with `driver.close()` (or `using`) +after stopping every client that uses the driver. + +The driver switches the database to WAL mode, which SQLite records in the +file. In WAL mode readers never wait for the writer, so application reads keep +working while River writes, and River's reads keep working during an +application transaction. When another connection keeps the database busy as +the driver is created, River makes the switch at its first operation instead, +waiting for the database like any other River statement. + +SQLite allows one writer at a time across every connection and process, +including Go or Rust River clients sharing the file. River's connection has a +zero `busy_timeout`, so SQLite never blocks the event loop waiting for a lock: +when another connection holds the write lock, River retries its operation with +an asynchronous exponential backoff for up to `busyTimeout` (five seconds by +default), so timers, I/O, and running jobs keep making progress. If the lock is +still held after `busyTimeout`, the operation fails with a +`DatabaseOperationError` whose `retryable` is `true`. + +```ts continued +const patientDriver = new SqliteDriver(database, { + busyTimeout: { seconds: 10 }, +}); +``` + +`node:sqlite` statements are synchronous and briefly block the event loop. Run +a heavily loaded SQLite worker in its own Node process so its short statements +can't delay an HTTP server's requests. + +### Application handles and busy timeouts + +How long an application statement should wait for another connection's write +lock depends on who else writes the database: + +- **Only this process writes.** River's own transactions hold the write lock + only within one turn of the event loop (see + [River's own transactions](#rivers-own-transactions)), so an application + statement run from a request handler, timer, or other callback never meets + it. Keep `node:sqlite`'s default zero `timeout`, and begin write + transactions with the `transaction` helper below, which waits + asynchronously for other application transactions. +- **Other processes write too**, such as a separate worker process or Go and + Rust River clients. Their transactions hold the lock across many + milliseconds, and a zero-`timeout` statement then fails at once with + `SQLITE_BUSY`. Either run every write through `transaction`, which retries + asynchronously, or give the handle a nonzero `timeout`. A nonzero timeout + blocks the event loop while it waits for another process. In this process + it can meet River's lock only when application code writes in the same turn + of the event loop as a River insertion that has insert middleware or hooks, + such as in a `Promise.all` beside `client.insert`. River can't finish until + the event loop runs again, so that statement blocks for its whole timeout + and then fails. + +```ts continued +// Wait up to a second for other processes' write locks. +const applicationDatabase = driver.connect({ timeout: 1_000 }); +``` + +## In-memory databases + +A `:memory:` database belongs to the one connection that opened it, so River +can't open its own connection to it, and `new SqliteDriver(database)` rejects +one. `SqliteDriver.memory()` creates a new in-memory database that River and +the application share instead. Open handles on it with `driver.connect()`: + +```ts continued +using memoryDriver = SqliteDriver.memory(); +await createMigrator(memoryDriver).migrateUp(); +const application = memoryDriver.connect(); +application.exec("CREATE TABLE accounts (id text PRIMARY KEY)"); +``` + +The database lives until the driver and every handle from `connect()` are +closed. `connect()` works for file databases too. An in-memory database has no +WAL: while one connection writes, a read on another connection fails with +`SQLITE_BUSY` at once when its `timeout` is zero, or waits up to its timeout. + +## Transactions + +Pass a handle as `{ tx }` while it has a transaction open to make River's +statements part of that transaction, so jobs commit or roll back with the +application's rows. The handle may be any `DatabaseSync` open on the driver's +database. River runs its statements directly in that transaction, opening no +savepoint, and never commits or rolls it back. When a River call fails, +statements it already ran stay in the transaction: roll the transaction back, +or wrap the call in a savepoint of your own to recover and continue. + +The `transaction` helper begins a transaction with `BEGIN IMMEDIATE`, commits +when its callback resolves, and rolls back when it throws. `BEGIN IMMEDIATE` +takes the write lock up front: a deferred `BEGIN` that reads first can fail +later with `SQLITE_BUSY_SNAPSHOT`, which no retry can fix. While another +connection holds the lock, the helper retries asynchronously for up to +`busyTimeout`: + +```ts continued +import { transaction } from "@riverqueue/driver-sqlite"; + +const accountId = "acct_1"; +await transaction(database, async (tx) => { + tx.prepare("INSERT INTO accounts (id) VALUES (?)").run(accountId); + await client.insert(syncAccount, { accountId }, { tx }); +}); +``` + +The callback may await freely: River's own work runs on its connection and +waits, asynchronously, for the application's transaction to end. Keep it +short anyway, because it holds the database's only write lock, and River's +background work fails after `busyTimeout`. + +Plain `node:sqlite` transactions work the same way. With a zero `timeout`, +`BEGIN IMMEDIATE` fails at once with `SQLITE_BUSY` when another connection +holds the lock, which the helper would have retried: + +```ts continued +database.exec("BEGIN IMMEDIATE"); +try { + database.prepare("INSERT INTO accounts (id) VALUES (?)").run("acct_2"); + await client.insert(syncAccount, { accountId: "acct_2" }, { tx: database }); + database.exec("COMMIT"); +} catch (error) { + if (database.isTransaction) database.exec("ROLLBACK"); + throw error; +} +``` + +An ORM built on the same handle, such as Drizzle's `node-sqlite` driver, uses +the helper's transaction too. Don't use the ORM's own transaction method for +this: Drizzle's and Bun's SQLite transactions can't await, and commit at the +callback's first `await`, so a later error rolls nothing back. + + + +```ts continued +// import { drizzle } from "drizzle-orm/node-sqlite"; +const orm = drizzle({ client: database }); +await transaction(database, async (tx) => { + await orm.insert(accounts).values({ id: "acct_3" }); + await client.insert(syncAccount, { accountId: "acct_3" }, { tx }); +}); +``` + +### River's own transactions + +Without `{ tx }`, an insertion runs in a transaction River owns on its +connection, like River for Go. Argument validation, insert middleware, +`beforeInsert` and `afterInsert` hooks, and the write commit together, and an +error thrown by any of them, even after middleware's `next()` returns, rolls +the jobs back. River takes the write lock with `BEGIN IMMEDIATE` only at the +insertion's first statement, so work a middleware does before calling `next()` +(such as encrypting arguments with a remote key service) holds no lock. + +From there until the transaction commits, River holds SQLite's write lock. On +SQLite, insert middleware and hooks must therefore not await I/O after +`next()` returns or in `afterInsert`: every other writer, in this process and +others, would wait for it. React to committed jobs with `client.subscribe` +instead, or do the I/O before calling `next()`. Awaiting promises that resolve +without I/O is fine, and an insertion without middleware or hooks never lets +the event loop turn while it holds the lock. + +River checks this. When its transaction is still open at the event loop's next +turn, River rolls it back at once, which releases the lock for every other +writer, and fails the insertion with a `TransactionScopeError` whose `reason` +is `"event_loop_turn"`. The check finds most mistakes, including all slow I/O, +such as network calls, and all I/O awaited by an insertion started from an I/O +callback, such as an HTTP request handler. It isn't deterministic for fast +local I/O, such as a cached `fs.stat` or a WebCrypto digest, awaited by an +insertion started from a timer or `setImmediate` callback: that I/O can finish +before the check runs, and the insertion then succeeds while briefly holding +the lock across a turn. Don't rely on the check to find every case. + +Once River holds the write lock, a River call without `{ tx }` from inside its +transaction, such as from middleware after `next()` or an `afterInsert` hook, +would wait for River's transaction forever. It fails at once with a +`TransactionScopeError` instead. So does `transaction` on the same database, +which could only fail after its `busyTimeout`, and so do nested `transaction` +calls on one database and River writes without `{ tx }` inside a +`transaction` on the same database. Before River's first statement, in +middleware before `next()` and in `beforeInsert` hooks, River holds no lock, +and such calls run normally. + +River can't see a transaction begun with a raw `BEGIN`. A River write without +`{ tx }` issued inside one waits for that transaction's lock and fails after +`busyTimeout` with a `DatabaseOperationError` whose message suggests passing +the handle as `{ tx }`. + +All result-producing statements use `StatementSync.setReadBigInts(true)`. +Persisted 64-bit values are therefore decoded as `bigint`, never a lossy +`number`, and timestamps cross the public boundary as `Temporal.Instant`. +SQLite stores River timestamps at exact millisecond precision. + +## Compatibility notes + +The driver reads every row Go's `riversqlite` accepts, including unbounded +`attempt` and `max_attempts` values and JSON `null` tags or errors. If a +claimed row still cannot be decoded, River doesn't work it: its attempt fails +with the error `job row couldn't be decoded: …` through the normal failure +path (the error handler runs, and the retry policy retries it or discards it +after its last attempt), while the rest of the batch is worked. The bad value +is left in place. + +Like River for Go's client, a client writes at most one insert notification per +queue per `fetchCooldown` (100 ms by default), so a large batch does not wake +listeners once per job. + +## 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. + +TypeScript users need TypeScript 6.0 or newer and `@types/node`, 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/migrate/README.md b/js/migrate/README.md new file mode 100644 index 000000000..ee3ffc14e --- /dev/null +++ b/js/migrate/README.md @@ -0,0 +1,132 @@ +# `@riverqueue/migrate` + +River's PostgreSQL and SQLite migrations, with a runner that applies them. +Requires Node.js 26 or newer. + +River never migrates on its own: clients don't touch the schema when they are +constructed or started. Run migrations as a deployment step, either with this +package or with the `riverqueue` command from `@riverqueue/cli`, and keep +`@riverqueue/migrate` on the same version as `riverqueue`. + +## PostgreSQL + +Pass the driver your client uses, so the pool and schema are configured once: + +```ts +import { PgDriver } from "@riverqueue/driver-pg"; +import { createMigrator } from "@riverqueue/migrate"; +import { Pool } from "pg"; + +const pool = new Pool({ connectionString: process.env.DATABASE_URL }); +const driver = new PgDriver(pool, { schema: "river" }); + +const result = await createMigrator(driver).migrateUp(); +for (const { name, version } of result.versions) { + console.log(`applied ${version} ${name}`); +} +``` + +A deploy script that doesn't build a driver can pass the connection directly, +as `{ pool, schema }` or, for a single connected `pg.Client`, +`{ client, schema }`. Leave out `schema` to use the connection's +`search_path`. + +## SQLite + +Pass the `SqliteDriver`, or `{ database }` to migrate without a driver: + +```ts +import { DatabaseSync } from "node:sqlite"; + +import { SqliteDriver } from "@riverqueue/driver-sqlite"; +import { createMigrator } from "@riverqueue/migrate"; + +const database = new DatabaseSync("river.db"); +await createMigrator(new SqliteDriver(database)).migrateUp(); +``` + +Through a driver, each migration waits for the driver's other operations and, +while another connection holds the database's write lock, retries +asynchronously for up to the driver's `busyTimeout`, like River's other +statements. With `{ database }`, migrations run directly on that handle, +which waits for a busy database for its own `timeout`. + +Version 8 rebuilds SQLite's `river_job` table with an `AUTOINCREMENT` key, so +a deleted job's ID is never handed to a new job; on PostgreSQL it changes +nothing. Like River for Go's migration, it refuses to run in either direction +while the database holds a `river_job_sequence`, `river_job_workflow_scheduling`, +or `river_workflow` object, which extensions install alongside River's tables +and which the rebuild would drop. Migrate the main line to version 8 before +installing such an extension. + +## Migrating up and down + +`migrateUp()` applies every missing version. `migrateDown()` reverts only the +newest version, because reverting drops tables and their data. Both accept: + +- `targetVersion`: the version to end at. Up applies versions through the + target, or nothing when the target is already applied. Down reverts + versions above it; `targetVersion: 0` reverts every version and removes + River's tables. +- `maxSteps`: the most versions to run. +- `dryRun`: return the versions and SQL that would run without running them. + +```ts +import type { PgDriver } from "@riverqueue/driver-pg"; +import { createMigrator, type Migration } from "@riverqueue/migrate"; + +declare const driver: PgDriver; +const migrator = createMigrator(driver); + +// Print the SQL that the next migrateUp() would run. +const pending = await migrator.migrateUp({ dryRun: true }); +for (const { sql } of pending.versions) console.log(sql); + +// Revert everything above version 5. +await migrator.migrateDown({ targetVersion: 5 }); + +// Fail a health check if the schema is behind. +const { messages, ok } = await migrator.validate(); +if (!ok) throw new Error(messages.join("; ")); +``` + +Each version runs in its own transaction along with its row in the +`river_migration` table. When several processes migrate the same database at +once, they take turns, and a version another process already applied is +skipped. Versions in the database that are newer than this package, for +example after a newer River release migrated it, are ignored, as River's Go +migrator ignores them. + +Failures throw `MigrationError`, which extends `RiverError` and names the +`backend` and `operation` that failed. + +## Additional migration lines + +A package that extends River can ship its own migration line and apply it with +the same runner once the main line is at version 5 or later: + +```ts continued +declare const extraMigrations: readonly Migration[]; + +await createMigrator(driver, { + line: "extra", + migrations: extraMigrations, +}).migrateUp(); +``` + +The SQL in this package is copied from River's canonical migrations and +checked for drift, so don't edit it by hand. + +## 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. + +TypeScript users need TypeScript 6.0 or newer and `@types/node`, 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/package.json b/js/package.json index 59a53cbc9..ada71e5a6 100644 --- a/js/package.json +++ b/js/package.json @@ -29,8 +29,10 @@ "build:all": "pnpm run build && pnpm --filter=@riverqueue/migrate run build && pnpm --filter='./driver/*' run build && pnpm --filter=@riverqueue/worker-threads run build && pnpm --filter=@riverqueue/test run build && pnpm --filter=@riverqueue/cli run build", "clean": "rm -rf dist", "clean:all": "pnpm run clean && pnpm --filter=@riverqueue/migrate run clean && pnpm --filter='./driver/*' run clean && pnpm --filter=@riverqueue/worker-threads run clean && pnpm --filter=@riverqueue/test run clean && pnpm --filter=@riverqueue/cli run clean", - "fmt": "prettier --write 'src/**/*.ts' 'driver/**/src/**/*.ts' 'migrate/src/**/*.ts' 'worker-threads/src/**/*.ts' 'test/src/**/*.ts' 'cli/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts' 'examples/*/{README.md,package.json,tsconfig.json}' 'examples/tsconfig.json' '{README.md,package.json}'", - "fmt:check": "prettier --check 'src/**/*.ts' 'driver/**/src/**/*.ts' 'migrate/src/**/*.ts' 'worker-threads/src/**/*.ts' 'test/src/**/*.ts' 'cli/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts' 'examples/*/{README.md,package.json,tsconfig.json}' 'examples/tsconfig.json' '{README.md,package.json}'", + "docs:api": "typedoc --options typedoc.json", + "docs:snippets": "node scripts/check-readme-snippets.mjs", + "fmt": "prettier --write 'src/**/*.ts' 'driver/**/src/**/*.ts' 'migrate/src/**/*.ts' 'worker-threads/src/**/*.ts' 'test/src/**/*.ts' 'cli/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts' 'examples/*/{README.md,package.json,tsconfig.json}' 'examples/tsconfig.json' '{README.md,package.json}' 'docs/*.md' 'driver/*/README.md' '{cli,migrate,test,worker-threads}/README.md'", + "fmt:check": "prettier --check 'src/**/*.ts' 'driver/**/src/**/*.ts' 'migrate/src/**/*.ts' 'worker-threads/src/**/*.ts' 'test/src/**/*.ts' 'cli/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts' 'examples/*/{README.md,package.json,tsconfig.json}' 'examples/tsconfig.json' '{README.md,package.json}' 'docs/*.md' 'driver/*/README.md' '{cli,migrate,test,worker-threads}/README.md'", "generate:migrations": "node scripts/sync-migrations.mjs", "lint": "eslint 'src/**/*.ts' 'driver/**/src/**/*.ts' 'migrate/src/**/*.ts' 'worker-threads/src/**/*.ts' 'test/src/**/*.ts' 'cli/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts'", "lint:fix": "eslint --fix 'src/**/*.ts' 'driver/**/src/**/*.ts' 'migrate/src/**/*.ts' 'worker-threads/src/**/*.ts' 'test/src/**/*.ts' 'cli/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts'", @@ -75,6 +77,7 @@ "pg": "^8.22.0", "pino": "^10.3.1", "prettier": "^3.9.6", + "typedoc": "^0.28.20", "typescript": "^6.0.3", "typescript-eslint": "^8.65.0", "typescript-next": "npm:typescript@7.1.0-dev.20260830.1", diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml index 2746a3a06..284ea8c71 100644 --- a/js/pnpm-lock.yaml +++ b/js/pnpm-lock.yaml @@ -43,6 +43,9 @@ importers: prettier: specifier: ^3.9.6 version: 3.9.6 + typedoc: + specifier: ^0.28.20 + version: 0.28.20(typescript@6.0.3) typescript: specifier: ^6.0.3 version: 6.0.3 @@ -457,6 +460,9 @@ packages: resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@gerrit0/mini-shiki@3.23.0': + resolution: {integrity: sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==} + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -729,6 +735,21 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@shikijs/engine-oniguruma@3.23.0': + resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==} + + '@shikijs/langs@3.23.0': + resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==} + + '@shikijs/themes@3.23.0': + resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==} + + '@shikijs/types@3.23.0': + resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -783,6 +804,9 @@ packages: '@types/geojson@7946.0.16': resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -798,6 +822,9 @@ packages: '@types/react@19.2.18': resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@typescript-eslint/eslint-plugin@8.65.0': resolution: {integrity: sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -988,6 +1015,9 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -1137,6 +1167,10 @@ packages: resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} engines: {node: '>=14'} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + env-paths@3.0.0: resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -1441,6 +1475,9 @@ packages: resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} + linkify-it@5.0.2: + resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -1455,6 +1492,9 @@ packages: resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + lunr@2.3.9: + resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1465,6 +1505,13 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + markdown-it@14.3.1: + resolution: {integrity: sha512-4Ej49aYTDFIQ+uBkfX8GBvJGccoARxxPep+7aWTs55ozbjQJpW9M26Fe53vnGgvLeVzva/amzjQQaQu9w0vMhA==} + hasBin: true + + mdurl@2.1.0: + resolution: {integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -1644,6 +1691,10 @@ packages: proper-lockfile@4.1.2: resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -1800,6 +1851,13 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + typedoc@0.28.20: + resolution: {integrity: sha512-uSKqkh8Cr48vllnEy+jdaAgOeR6Y+QCBW7usgUsKj7gJEfR7stw9U/fE49LBnj2tPRKPY0c0EBJSWe9Appmplg==} + engines: {node: '>= 18', pnpm: '>= 10'} + hasBin: true + peerDependencies: + typescript: 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x + typescript-eslint@8.65.0: resolution: {integrity: sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1817,6 +1875,9 @@ packages: engines: {node: '>=16.20.0'} hasBin: true + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} @@ -1941,6 +2002,11 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + yaml@2.9.1: resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} engines: {node: '>= 14.6'} @@ -2033,6 +2099,14 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 + '@gerrit0/mini-shiki@3.23.0': + dependencies: + '@shikijs/engine-oniguruma': 3.23.0 + '@shikijs/langs': 3.23.0 + '@shikijs/themes': 3.23.0 + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -2280,6 +2354,26 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@shikijs/engine-oniguruma@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + + '@shikijs/themes@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + + '@shikijs/types@3.23.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + '@shikijs/vscode-textmate@10.0.2': {} + '@standard-schema/spec@1.1.0': {} '@tybys/wasm-util@0.10.3': @@ -2330,6 +2424,10 @@ snapshots: '@types/geojson@7946.0.16': {} + '@types/hast@3.0.5': + dependencies: + '@types/unist': 3.0.3 + '@types/json-schema@7.0.15': {} '@types/lodash@4.17.25': {} @@ -2348,6 +2446,8 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/unist@3.0.3': {} + '@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -2612,6 +2712,8 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + argparse@2.0.1: {} + assertion-error@2.0.1: {} ast-v8-to-istanbul@1.0.7: @@ -2744,6 +2846,8 @@ snapshots: empathic@2.0.0: {} + entities@4.5.0: {} + env-paths@3.0.0: {} es-module-lexer@2.2.0: {} @@ -3011,6 +3115,10 @@ snapshots: lightningcss-win32-arm64-msvc: 1.33.0 lightningcss-win32-x64-msvc: 1.33.0 + linkify-it@5.0.2: + dependencies: + uc.micro: 2.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -3021,6 +3129,8 @@ snapshots: lru.min@1.1.4: {} + lunr@2.3.9: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -3035,6 +3145,17 @@ snapshots: dependencies: semver: 7.8.5 + markdown-it@14.3.1: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.2 + mdurl: 2.1.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + + mdurl@2.1.0: {} + minimatch@10.2.5: dependencies: brace-expansion: 5.0.9 @@ -3210,6 +3331,8 @@ snapshots: retry: 0.12.0 signal-exit: 3.0.7 + punycode.js@2.3.1: {} + punycode@2.3.1: {} pure-rand@6.1.0: {} @@ -3337,6 +3460,15 @@ snapshots: dependencies: prelude-ls: 1.2.1 + typedoc@0.28.20(typescript@6.0.3): + dependencies: + '@gerrit0/mini-shiki': 3.23.0 + lunr: 2.3.9 + markdown-it: 14.3.1 + minimatch: 10.2.5 + typescript: 6.0.3 + yaml: 2.9.0 + typescript-eslint@8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3): dependencies: '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) @@ -3360,6 +3492,8 @@ snapshots: '@typescript/typescript-win32-arm64': 7.1.0-dev.20260830.1 '@typescript/typescript-win32-x64': 7.1.0-dev.20260830.1 + uc.micro@2.1.0: {} + undici-types@8.3.0: {} uri-js@4.4.1: @@ -3428,6 +3562,8 @@ snapshots: xtend@4.0.2: {} + yaml@2.9.0: {} + yaml@2.9.1: optional: true diff --git a/js/scripts/check-readme-snippets.mjs b/js/scripts/check-readme-snippets.mjs new file mode 100644 index 000000000..a7b94a40a --- /dev/null +++ b/js/scripts/check-readme-snippets.mjs @@ -0,0 +1,165 @@ +import { readdir, readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import process from "node:process"; +import { URL, fileURLToPath } from "node:url"; + +import ts from "typescript"; + +const repositoryRoot = resolve(fileURLToPath(new URL("..", import.meta.url))); +const readmes = [ + "README.md", + ...(await readdir(resolve(repositoryRoot, "docs"))) + .filter((name) => name.endsWith(".md")) + .sort() + .map((name) => `docs/${name}`), + "cli/README.md", + "driver/pg/README.md", + "driver/prisma/README.md", + "driver/sqlite/README.md", + "migrate/README.md", + "test/README.md", + "worker-threads/README.md", +]; +const config = ts.readConfigFile( + resolve(repositoryRoot, "tsconfig.base.json"), + ts.sys.readFile +); +if (config.error !== undefined) fail([config.error]); +const parsed = ts.parseJsonConfigFileContent( + config.config, + ts.sys, + repositoryRoot +); + +let checked = 0; +for (const readme of readmes) { + const markdown = await readFile(resolve(repositoryRoot, readme), "utf8"); + // Each ```ts block compiles as its own module. A block fenced as + // ```ts continued extends the previous block, for prose that interleaves + // one example. A block fenced as ```ts file=name.ts is a module that the + // README's other blocks can import as "./name.js", for examples that span + // several files. An HTML comment `` directly before a + // fence supplies declarations (such as an application's `mailer`) that + // the prose assumes but readers need not see. A block fenced as + // ```ts ignore is not checked. + const programs = []; + const directory = resolve( + repositoryRoot, + ".readme-snippets", + readme.replaceAll("/", "-") + ); + const modules = new Map(); + for (const match of markdown.matchAll( + /^(?:\n\n?)?```(?:ts|typescript)(?: (continued)| (ignore)| file=([\w.-]+\.ts))?\n([\s\S]*?)^```$/gm + )) { + const [, setup, continued, ignored, file, snippet] = match; + // ```ts ignore marks code that is deliberately not current River, such + // as the 0.1 API in the migration guide. + if (ignored !== undefined) continue; + const code = setup === undefined ? snippet : `${setup}\n${snippet}`; + checked += 1; + if (file !== undefined) { + const fileName = resolve(directory, file); + if (modules.has(fileName)) { + throw new Error(`${readme} repeats snippet file ${file}`); + } + modules.set(fileName, code); + } else if (continued !== undefined && programs.length > 0) { + programs[programs.length - 1] += `\n${code}`; + } else { + programs.push(code); + } + } + for (const [index, source] of programs.entries()) { + checkProgram( + directory, + new Map([ + ...modules, + [resolve(directory, `${index}.ts`), `${source}\nexport {};\n`], + ]), + readme + ); + } + if (programs.length === 0 && modules.size > 0) { + checkProgram(directory, modules, readme); + } +} + +function checkProgram(directory, virtualFiles, readme) { + const options = { + ...parsed.options, + noEmit: true, + rootDir: undefined, + outDir: undefined, + paths: { + "@riverqueue/cli": [resolve(repositoryRoot, "cli/dist/index.d.ts")], + "@riverqueue/driver-pg": [ + resolve(repositoryRoot, "driver/pg/dist/index.d.ts"), + ], + "@riverqueue/driver-prisma": [ + resolve(repositoryRoot, "driver/prisma/dist/index.d.ts"), + ], + "@riverqueue/driver-sqlite": [ + resolve(repositoryRoot, "driver/sqlite/dist/index.d.ts"), + ], + "@riverqueue/migrate": [ + resolve(repositoryRoot, "migrate/dist/index.d.ts"), + ], + "@riverqueue/test": [resolve(repositoryRoot, "test/dist/index.d.ts")], + "@riverqueue/worker-threads": [ + resolve(repositoryRoot, "worker-threads/dist/index.d.ts"), + ], + riverqueue: [resolve(repositoryRoot, "dist/index.d.ts")], + "riverqueue/unstable-driver": [ + resolve(repositoryRoot, "dist/unstable-driver.d.ts"), + ], + }, + }; + const host = ts.createCompilerHost(options); + const getSourceFile = host.getSourceFile.bind(host); + const fileExists = host.fileExists.bind(host); + const readVirtualFile = host.readFile.bind(host); + const directoryExists = host.directoryExists?.bind(host); + host.directoryExists = (directoryName) => + resolve(directoryName) === directory || + (directoryExists?.(directoryName) ?? true); + host.fileExists = (fileName) => + virtualFiles.has(fileName) || fileExists(fileName); + host.readFile = (fileName) => + virtualFiles.get(fileName) ?? readVirtualFile(fileName); + host.getSourceFile = (fileName, languageVersion, onError, shouldCreate) => + virtualFiles.has(fileName) + ? ts.createSourceFile( + fileName, + virtualFiles.get(fileName), + languageVersion, + true + ) + : getSourceFile(fileName, languageVersion, onError, shouldCreate); + + const program = ts.createProgram({ + host, + options, + rootNames: [...virtualFiles.keys()], + }); + const diagnostics = ts.getPreEmitDiagnostics(program); + if (diagnostics.length > 0) { + process.stderr.write(`README TypeScript snippets failed: ${readme}\n`); + fail(diagnostics); + } +} + +process.stdout.write( + `typechecked ${checked} TypeScript snippets from ${readmes.length} Markdown files\n` +); + +function fail(diagnostics) { + process.stderr.write( + ts.formatDiagnosticsWithColorAndContext(diagnostics, { + getCanonicalFileName: (fileName) => fileName, + getCurrentDirectory: () => repositoryRoot, + getNewLine: () => "\n", + }) + ); + process.exit(1); +} diff --git a/js/test/README.md b/js/test/README.md new file mode 100644 index 000000000..aa5d35b63 --- /dev/null +++ b/js/test/README.md @@ -0,0 +1,107 @@ +# `@riverqueue/test` + +Typed, database-free helpers for testing River producers and workers. They use +the same exact `bigint`, `Temporal.Instant`, and JSON values as the production +API. + +## Testing producers + +`createTestClient` returns an insert-only client with deterministic IDs and a +log of every insertion. Type producer code against `InsertClient` so the same +function accepts the real client and the test client, then assert on the log +like Go's `rivertest`: + +```ts +import { defineJob, type InsertClient } from "riverqueue"; +import { z } from "zod"; +import { + createTestClient, + requireInserted, + requireNotInserted, +} from "@riverqueue/test"; + +const sendWelcomeEmail = defineJob({ + kind: "send_welcome_email", + schema: z.object({ to: z.email() }), +}); + +async function signUp(client: InsertClient, email: string) { + await client.insert(sendWelcomeEmail, { to: email }, { queue: "email" }); +} + +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.args.to === "person@example.com"); +requireNotInserted(insertions, sendWelcomeEmail, { + args: { to: "someone-else@example.com" }, +}); +``` + +`requireManyInserted` asserts the complete ordered list. Each insertion also +records the `{ tx }` value it received, so `createTestClient()` +fits code that inserts inside a node-postgres transaction. + +## Testing workers + +`testJob` builds a running job from producer input. It validates the input +through the job definition exactly as the runtime does before working, so +`job.args` has the worker's type and `job.rawArgs` holds the persisted JSON. +`workOnce` runs a handler, or the handler a `Workers` bundle registered for the +job's kind (with its timeout), and returns the outcome, output, metadata, and +logs: + +```ts +import { Workers, defineJob, snooze } from "riverqueue"; +import { z } from "zod"; +import { testJob, workOnce } from "@riverqueue/test"; + +const sendEmail = defineJob({ + kind: "send_email", + schema: z.object({ to: z.email() }), +}); +const workers = new Workers().add(sendEmail, ({ job, recordOutput }) => { + recordOutput({ recipient: job.args.to }); + return snooze({ seconds: 30 }); +}); + +const running = await testJob( + sendEmail, + { to: "person@example.com" }, + { id: 42n } +); +const worked = await workOnce(running, workers); + +if (worked.status !== "succeeded") throw worked.error; +console.assert(worked.outcome?.type === "snooze"); +console.assert(worked.output !== undefined); +``` + +`workOnce` initializes resumable state from the job's metadata and returns the +updated `metadata`, including checkpoints and recorded output. Pass it back +through `testJob(..., { metadata })` to test a retry. Inside `workOnce`, +`ctx.client` records insertions like `createTestClient`; pass `client` to use a +real one. + +`workOnce` does not run middleware, hooks, retries, scheduling, or +persistence. For those, run a real client against an in-memory SQLite database +(`SqliteDriver.memory()` from `@riverqueue/driver-sqlite`), which needs no +external service and exercises the complete runtime. + +## 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. + +TypeScript users need TypeScript 6.0 or newer and `@types/node`, 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/tsconfig.docs.json b/js/tsconfig.docs.json new file mode 100644 index 000000000..79fb047fb --- /dev/null +++ b/js/tsconfig.docs.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "noEmit": true + }, + "include": [ + "src", + "cli/src", + "driver/*/src", + "migrate/src", + "test/src", + "worker-threads/src" + ], + "exclude": ["**/*.integration.test.ts", "**/*.test.ts"] +} diff --git a/js/typedoc.json b/js/typedoc.json new file mode 100644 index 000000000..4a11f0bc1 --- /dev/null +++ b/js/typedoc.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "entryPoints": [ + "src/index.ts", + "cli/src/index.ts", + "driver/pg/src/index.ts", + "driver/prisma/src/index.ts", + "driver/sqlite/src/index.ts", + "migrate/src/index.ts", + "test/src/index.ts", + "worker-threads/src/index.ts" + ], + "entryPointStrategy": "resolve", + "excludeInternal": true, + "excludePrivate": true, + "includeVersion": true, + "out": "docs/api", + "readme": "docs/README.md", + "tsconfig": "tsconfig.docs.json", + "validation": { + "notExported": false + } +} diff --git a/js/worker-threads/README.md b/js/worker-threads/README.md new file mode 100644 index 000000000..5ba56e997 --- /dev/null +++ b/js/worker-threads/README.md @@ -0,0 +1,197 @@ +# `@riverqueue/worker-threads` + +This optional package runs CPU-heavy River handlers in a bounded pool of native +Node.js worker threads, so they cannot block the event loop that River uses for +claiming, completing, cancelling, and shutting down work. Ordinary I/O handlers +should stay in-process; promises already let them share the event loop +efficiently. + +## Defining a thread handler + +A thread handler is an ES module export, because functions cannot cross a +thread boundary. Keep the job definition in its own module so producers can +import it without the handler. In `jobs.ts`: + +```ts file=jobs.ts +import { defineJob } from "riverqueue"; +import { z } from "zod"; + +export const resizeImage = defineJob({ + kind: "resize_image", + schema: z.object({ + source: z.string(), + width: z.number().int().positive(), + }), +}); +``` + +Type the handler export with the definition so its args are typed. A +type-only import keeps the thread from loading anything it does not need. In +`image-handler.ts`: + +```ts file=image-handler.ts +import type { WorkerThreadWorkHandler } from "@riverqueue/worker-threads"; +import { complete } from "riverqueue"; + +import type { resizeImage } from "./jobs.js"; + +export const resizeImageHandler: WorkerThreadWorkHandler< + typeof resizeImage +> = async ({ job, signal }) => { + const bytes = await resize(job.args.source, job.args.width, signal); + return complete({ output: { bytes } }); +}; + +async function resize( + source: string, + width: number, + signal: AbortSignal +): Promise { + signal.throwIfAborted(); + return source.length * width; +} +``` + +## Registering it + +Reference the handler module by URL and export name. Annotating the URL with +the module's type lets TypeScript reject an export name that is missing or +that handles a different definition, without importing the handler's code into +the main thread: + +```ts +import { + WorkerThreads, + type WorkerThreadModule, +} from "@riverqueue/worker-threads"; +import { Workers } from "riverqueue"; + +import type * as imageHandlers from "./image-handler.js"; +import { resizeImage } from "./jobs.js"; + +const imageModule: WorkerThreadModule = new URL( + "./image-handler.js", + import.meta.url +); + +await using executor = new WorkerThreads({ maxThreads: 4 }); +const workers = new Workers().addExecutor( + resizeImage, + executor.handler(resizeImage, { + exportName: "resizeImageHandler", + module: imageModule, + }) +); +``` + +A plain `URL` also works, but then any export name compiles and a wrong one +fails only when an attempt runs. Pass `workers` to one or more clients as +usual. + +## Arguments, results, and errors + +River decodes and validates a job's args with its definition in the main +thread, exactly as for an in-process handler, and the thread receives the +decoded `job.args` alongside the persisted JSON in `job.rawArgs`. A job +inserted by another language with invalid args fails before it reaches a +thread. + +Decoded args that are River JSON cross the boundary as text, which preserves +exact JSON numbers such as a Go-produced int64 ID. Other decoded args must +arrive unchanged through structured clone: `bigint`, `Date`, `Map`, `Set`, +`Uint8Array`, and plain objects and arrays of those. An attempt whose decoded +args contain anything else, such as a class instance or a function, fails with +a `ConfigurationError` instead of reaching the handler with the wrong type. + +The handler's context has the job, execution metadata, logger, `signal`, +`recordOutput`, and `setMetadata`. It has no `client`, `completeTx`, or +`resumable`, which depend on the main thread. Outcomes, output, metadata, and +log attributes cross back as River JSON. Like output on the main thread, +`recordOutput` and each `setMetadata` value throw a `ValidationError` over +32 MiB. A log message is cut to 32 KiB, and log attributes whose JSON +exceeds 32 KiB are dropped with a note in the message. A thrown error keeps +its name, message, and stack, bounded to the same 32 KiB limits River applies +to persisted attempt errors, and fails the attempt with a +`WorkerThreadHandlerError`. An error whose properties can't be read still +fails only its attempt. + +## Cancellation, timeouts, and crashes + +When an attempt is cancelled, times out, or its client stops, the handler's +`signal` aborts. If the handler has not settled after the client's +`jobStuckThreshold` (10 seconds by default), River terminates its thread, so a CPU-bound handler +that never yields cannot hold River indefinitely. River records the attempt's +outcome only after the handler has settled or its thread has exited. If the +abort came from its client stopping, a handler terminated this way fails with a +`JobAbortedError`, so its attempt counts and the retry policy applies; after a +cancellation or a timeout, the attempt ends as if the handler had stopped +itself. + +Attempts wait for a thread when all `maxThreads` are busy, and their job +timeout starts only once a thread takes them. A client can therefore allow +more workers than the executor has threads without timing out queued jobs. + +A thread that crashes, whether from an uncaught error, an unhandled rejection, +`process.exit()`, or a resource limit, fails only the attempt it was running. +A crash while idle, typically from background work a handler left behind, only +discards the thread. Replacements start when later attempts need them, and +`diagnostics().crashedThreads` counts crashes. Limit each thread's V8 heap with +`resourceLimits`: + +```ts +import { WorkerThreads } from "@riverqueue/worker-threads"; + +const limited = new WorkerThreads({ + maxThreads: 2, + resourceLimits: { maxOldGenerationSizeMb: 256 }, +}); +await limited.close(); +``` + +## Ownership and shutdown + +The application owns the executor. Several clients may share one, and stopping +a client never closes it. Stop every client that uses it, then close it with +`await executor.close()` or let an `await using` scope do so. Closing fails any +attempts still queued or running. Idle threads do not keep the process alive, +so an executor that is never closed does not prevent exit. + +## TypeScript sources in development + +Node 26 strips TypeScript types natively, and River's threads load a module's +TypeScript source when the `.js` file that a handler URL or one of its relative +imports names does not exist but a `.ts` file beside it does. Keep referencing +handler modules by their compiled `.js` name, as above; the same URL then +works: + +- from a build, where the `.js` files exist; +- from source with `node src/main.ts`, provided the main thread's own relative + imports use `.ts` extensions, for example with TypeScript's + `rewriteRelativeImportExtensions`, since Node resolves those itself; and +- in Vitest, whose test files run from source. + +The fallback applies only after a resolution fails, so it never changes which +module a build loads. Loaders registered with `--import` also apply inside +River's threads, because Node passes the process's `execArgv` to worker +threads. Handler modules must use TypeScript syntax that Node can strip, which +excludes features such as enums and parameter properties. + +## Security + +Worker threads provide availability isolation, not a security sandbox. A +handler shares the process, its environment, and its file system access. Only +run trusted handler modules. + +## 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. + +TypeScript users need TypeScript 6.0 or newer and `@types/node`, with `"node"` +listed in `compilerOptions.types`. See [River's +requirements](https://github.com/riverqueue/river/tree/master/js#requirements) for +details. From 1bde98ee034a6b73fd36087abc5d9b70a205bf62 Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sun, 4 Oct 2026 17:32:32 -0500 Subject: [PATCH 38/43] check packed packages before publishing Prepare every package's metadata for publishing with provenance: ship compiled output with its sources and declaration maps, the README, and the license, and pack with `prepack`. `package:check` packs each package and inspects the archive with `publint` and `attw`, checks dependency metadata, source maps, and that no archive carries machine-local paths. It then installs the tarballs into fresh consumer projects: JavaScript consumers must not need type packages, TypeScript consumers compile under `node20` and `nodenext` with TypeScript 6 and the next release, CommonJS consumers load River through `require(esm)`, mismatched package versions are rejected, the examples build against the tarballs, and `node --test` suites exercise the packed packages on SQLite and, when configured, PostgreSQL. `license:check` limits production dependency licenses to an allow list. --- js/package.json | 21 +- js/pnpm-lock.yaml | 1442 ++++++++++++++++- js/scripts/check-licenses.mjs | 55 + js/scripts/check-packages.mjs | 980 +++++++++++ js/scripts/packed-tests/cli.test.mjs | 81 + .../packed-tests/fixtures/thread-handlers.mjs | 11 + js/scripts/packed-tests/helpers.mjs | 36 + js/scripts/packed-tests/packages.test.mjs | 213 +++ js/scripts/packed-tests/postgres.test.mjs | 98 ++ js/scripts/packed-tests/sqlite.test.mjs | 155 ++ js/scripts/packed-tests/test-helpers.test.mjs | 78 + 11 files changed, 3162 insertions(+), 8 deletions(-) create mode 100644 js/scripts/check-licenses.mjs create mode 100644 js/scripts/check-packages.mjs create mode 100644 js/scripts/packed-tests/cli.test.mjs create mode 100644 js/scripts/packed-tests/fixtures/thread-handlers.mjs create mode 100644 js/scripts/packed-tests/helpers.mjs create mode 100644 js/scripts/packed-tests/packages.test.mjs create mode 100644 js/scripts/packed-tests/postgres.test.mjs create mode 100644 js/scripts/packed-tests/sqlite.test.mjs create mode 100644 js/scripts/packed-tests/test-helpers.test.mjs diff --git a/js/package.json b/js/package.json index ada71e5a6..c6f9bfe65 100644 --- a/js/package.json +++ b/js/package.json @@ -22,7 +22,12 @@ "node": ">=26" }, "files": [ - "dist" + "dist", + "src", + "!src/**/*.test.ts", + "!src/testdata", + "README.md", + "LICENSE" ], "scripts": { "build": "node node_modules/typescript/bin/tsc", @@ -36,8 +41,11 @@ "generate:migrations": "node scripts/sync-migrations.mjs", "lint": "eslint 'src/**/*.ts' 'driver/**/src/**/*.ts' 'migrate/src/**/*.ts' 'worker-threads/src/**/*.ts' 'test/src/**/*.ts' 'cli/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts'", "lint:fix": "eslint --fix 'src/**/*.ts' 'driver/**/src/**/*.ts' 'migrate/src/**/*.ts' 'worker-threads/src/**/*.ts' 'test/src/**/*.ts' 'cli/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts'", + "license:check": "node scripts/check-licenses.mjs", "migration:legacy": "node scripts/check-legacy-fixture.mjs", - "prepublishOnly": "pnpm run clean && pnpm run build", + "package:check": "node scripts/check-packages.mjs", + "package:examples": "node scripts/check-packages.mjs --examples-only", + "prepack": "pnpm run clean && pnpm run build", "test": "vitest run --passWithNoTests", "test:coverage": "vitest run --coverage", "test:integration": "vitest run --passWithNoTests --config vitest.integration.config.ts", @@ -49,7 +57,7 @@ "url": "git+https://github.com/riverqueue/river.git", "directory": "js" }, - "authors": [ + "contributors": [ "Brandur Leach", "Blake Gentry" ], @@ -58,6 +66,10 @@ "url": "https://github.com/riverqueue/river/issues" }, "homepage": "https://github.com/riverqueue/river/tree/master/js#readme", + "publishConfig": { + "access": "public", + "provenance": true + }, "peerDependencies": { "@types/node": ">=26" }, @@ -67,6 +79,7 @@ } }, "devDependencies": { + "@arethetypeswrong/cli": "^0.18.5", "@eslint/js": "^10.0.1", "@types/node": "^26.1.1", "@types/pg": "^8.20.0", @@ -74,9 +87,11 @@ "eslint": "^10.8.0", "eslint-config-prettier": "^10.1.8", "fast-check": "^4.10.2", + "license-checker-rseidelsohn": "^5.0.1", "pg": "^8.22.0", "pino": "^10.3.1", "prettier": "^3.9.6", + "publint": "^0.3.24", "typedoc": "^0.28.20", "typescript": "^6.0.3", "typescript-eslint": "^8.65.0", diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml index 284ea8c71..379104c1e 100644 --- a/js/pnpm-lock.yaml +++ b/js/pnpm-lock.yaml @@ -13,6 +13,9 @@ importers: .: devDependencies: + '@arethetypeswrong/cli': + specifier: ^0.18.5 + version: 0.18.5 '@eslint/js': specifier: ^10.0.1 version: 10.0.1(eslint@10.8.0(jiti@2.7.0)) @@ -34,6 +37,9 @@ importers: fast-check: specifier: ^4.10.2 version: 4.10.2 + license-checker-rseidelsohn: + specifier: ^5.0.1 + version: 5.0.1 pg: specifier: ^8.22.0 version: 8.22.0 @@ -43,6 +49,9 @@ importers: prettier: specifier: ^3.9.6 version: 3.9.6 + publint: + specifier: ^0.3.24 + version: 0.3.24 typedoc: specifier: ^0.28.20 version: 0.28.20(typescript@6.0.3) @@ -377,6 +386,18 @@ importers: packages: + '@andrewbranch/untar.js@1.0.4': + resolution: {integrity: sha512-pVXSwPsLuw8IGLo2Di0EaOfsk+ntVvpkk942J/sHYIkwvtKUakEcPh7HBgZ6tuimgzKSEHgCvO4XgQ05DEbwDw==} + + '@arethetypeswrong/cli@0.18.5': + resolution: {integrity: sha512-gM+8vRsQOD/Uc7EnBedUhkG5OCsDWE4uoak5QvomGpMpaky0Eh41p04nIMgrWb8EOmqZUJGc6zz9hsP6E56R7g==} + engines: {node: '>=20'} + hasBin: true + + '@arethetypeswrong/core@0.18.5': + resolution: {integrity: sha512-9ytjzGwxjm9Uz7I9avfbt5vlQt6uk9uRRESzJjqrznl6WKvI6dwYTo+vJ3U02Wrq/mR3iql/PzhvHhKdJIAjDQ==} + engines: {node: '>=20'} + '@babel/helper-string-parser@7.29.7': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} @@ -398,6 +419,13 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@braidai/lang@1.1.2': + resolution: {integrity: sha512-qBcknbBufNHlui137Hft8xauQMTZDKdophmLFv05r2eNmdIv/MlPuP4TdUknHG68UdWLgVZwgxVe735HzJNIwA==} + + '@colors/colors@1.5.0': + resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} + engines: {node: '>=0.1.90'} + '@electric-sql/pglite-socket@0.1.3': resolution: {integrity: sha512-LAciWM0M1dCL8hlsxu2venbVZcdxema0BtDfpWYVqr+Y468UADw0pFWidhKw1M8sfJ8rdLT71tjMmnirf/IZRQ==} hasBin: true @@ -460,6 +488,10 @@ packages: resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@gar/promise-retry@1.0.3': + resolution: {integrity: sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA==} + engines: {node: ^20.17.0 || >=22.9.0} + '@gerrit0/mini-shiki@3.23.0': resolution: {integrity: sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==} @@ -483,6 +515,13 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + + '@isaacs/string-locale-compare@1.1.0': + resolution: {integrity: sha512-SQ7Kzhh9+D+ZW9MA0zkYv3VXhIDNx+LzM6EJ+/65I3QY+enU6Itte7E5XX7EWrqLW2FN4n06GWzBnPoC3th2aQ==} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -493,6 +532,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@loaderkit/resolve@1.0.6': + resolution: {integrity: sha512-G8FdIoF5CypfwmD9rl8BXod5HDn8JqB0CCNBXDTaRZ+yRYhARrrSToX1zg1zy9jX3zLqigsELwhT4gNtkdQAUg==} + '@napi-rs/wasm-runtime@1.2.2': resolution: {integrity: sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} @@ -500,6 +542,64 @@ packages: '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.3 '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.3 + '@npmcli/agent@4.0.2': + resolution: {integrity: sha512-EUEuWAxnL07Sp5/iC/1X6Xj+XThUvnbei9zfRWZdEXa7lss9RTHMhAHBeg+MZ5To9s/gGaSI+UwZTPdYMvKSeg==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@npmcli/arborist@9.6.0': + resolution: {integrity: sha512-Dku9UWbrrX+UCu8rQ1obGKaQAL4kwdt3hHCNXrd0n0R/4B8oq3CzloUAShwFjfsAGM6KY27gPuNftOUEZ4nhOw==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + '@npmcli/fs@5.0.0': + resolution: {integrity: sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@npmcli/git@7.0.2': + resolution: {integrity: sha512-oeolHDjExNAJAnlYP2qzNjMX/Xi9bmu78C9dIGr4xjobrSKbuMYCph8lTzn4vnW3NjIqVmw/f8BCfouqyJXlRg==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@npmcli/installed-package-contents@4.0.0': + resolution: {integrity: sha512-yNyAdkBxB72gtZ4GrwXCM0ZUedo9nIbOMKfGjt6Cu6DXf0p8y1PViZAKDC8q8kv/fufx0WTjRBdSlyrvnP7hmA==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + '@npmcli/map-workspaces@5.0.3': + resolution: {integrity: sha512-o2grssXo1e774E5OtEwwrgoszYRh0lqkJH+Pb9r78UcqdGJRDRfhpM8DvZPjzNLLNYeD/rNbjOKM3Ss5UABROw==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@npmcli/metavuln-calculator@9.0.3': + resolution: {integrity: sha512-94GLSYhLXF2t2LAC7pDwLaM4uCARzxShyAQKsirmlNcpidH89VA4/+K1LbJmRMgz5gy65E/QBBWQdUvGLe2Frg==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@npmcli/name-from-folder@4.0.0': + resolution: {integrity: sha512-qfrhVlOSqmKM8i6rkNdZzABj8MKEITGFAY+4teqBziksCQAOLutiAxM1wY2BKEd8KjUSpWmWCYxvXr0y4VTlPg==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@npmcli/node-gyp@5.0.0': + resolution: {integrity: sha512-uuG5HZFXLfyFKqg8QypsmgLQW7smiRjVc45bqD/ofZZcR/uxEjgQU8qDPv0s9TEeMUiAAU/GC5bR6++UdTirIQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@npmcli/package-json@7.0.5': + resolution: {integrity: sha512-iVuTlG3ORq2iaVa1IWUxAO/jIp77tUKBhoMjuzYW2kL4MLN1bi/ofqkZ7D7OOwh8coAx1/S2ge0rMdGv8sLSOQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@npmcli/promise-spawn@9.0.1': + resolution: {integrity: sha512-OLUaoqBuyxeTqUvjA3FZFiXUfYC1alp3Sa99gW3EUDz3tZ3CbXDdcZ7qWKBzicrJleIgucoWamWH1saAmH/l2Q==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@npmcli/query@5.0.0': + resolution: {integrity: sha512-8TZWfTQOsODpLqo9SVhVjHovmKXNpevHU0gO9e+y4V4fRIOneiXy0u0sMP9LmS71XivrEWfZWg50ReH4WRT4aQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@npmcli/redact@4.0.0': + resolution: {integrity: sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@npmcli/run-script@10.0.4': + resolution: {integrity: sha512-mGUWr1uMnf0le2TwfOZY4SFxZGXGfm4Jtay/nwAa2FLNAKXUoUwaGwBMNH36UHPtinWfTSJ3nqFQr0091CxVGg==} + engines: {node: ^20.17.0 || >=22.9.0} + '@oxc-project/types@0.133.0': resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} @@ -569,6 +669,10 @@ packages: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 + '@publint/pack@0.1.7': + resolution: {integrity: sha512-4EDEmvxWtgsCnnVeBvtFIFZtUhPPt1+bA9JrSwU4Sa//6oKtzCSlGGXYJr44OD9aGISymbieJ4mCKHUygUDU+g==} + engines: {node: '>=18'} + '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} @@ -750,9 +854,45 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@sigstore/bundle@4.0.0': + resolution: {integrity: sha512-NwCl5Y0V6Di0NexvkTqdoVfmjTaQwoLM236r89KEojGmq/jMls8S+zb7yOwAPdXvbwfKDlP+lmXgAL4vKSQT+A==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@sigstore/core@3.2.1': + resolution: {integrity: sha512-qRsxPnCrbC/puegGxKuynfnxgLiHqWStrSjxkoB4YKqq3Z3s4cyZyj42ZdWFAEblNP65C+rBH8EuREHIXoi83g==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@sigstore/protobuf-specs@0.5.2': + resolution: {integrity: sha512-SQqvFMt4V78fdjcDdYX6HbiVSOR4QK3ZgwCa2KOsopAgPIHy1rU5UDUmzLl02r5oyyaYcYHR1hpwDRk/yUe+Mw==} + engines: {node: ^18.17.0 || >=20.5.0} + + '@sigstore/sign@4.1.1': + resolution: {integrity: sha512-Hf4xglukg0XXQ2RiD5vSoLjdPe8OBUPA8XeVjUObheuDcWdYWrnH/BNmxZCzkAy68MzmNCxXLeurJvs6hcP2OQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@sigstore/tuf@4.0.2': + resolution: {integrity: sha512-TCAzTy0xzdP79EnxSjq9KQ3eaR7+FmudLC6eRKknVKZbV7ZNlGLClAAQb/HMNJ5n2OBNk2GT1tEmU0xuPr+SLQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@sigstore/verify@3.1.1': + resolution: {integrity: sha512-qv7+G3J2cc6wwFj3yKvXOamzqhMwSk1ogPGmhpS8iXllcPrJaIIBA+4HbttlHVu1pqWTdmaCH/WE7UOC51kdoA==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@sindresorhus/is@4.6.0': + resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} + engines: {node: '>=10'} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tufjs/canonical-json@2.0.0': + resolution: {integrity: sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==} + engines: {node: ^16.14.0 || >=18.0.0} + + '@tufjs/models@4.1.0': + resolution: {integrity: sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww==} + engines: {node: ^20.17.0 || >=22.9.0} + '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} @@ -999,6 +1139,14 @@ packages: '@vitest/utils@4.1.11': resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + abbrev@2.0.0: + resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + abbrev@4.0.0: + resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==} + engines: {node: ^20.17.0 || >=22.9.0} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1009,15 +1157,42 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.3.0: + resolution: {integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + array-find-index@1.0.2: + resolution: {integrity: sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw==} + engines: {node: '>=0.10.0'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -1040,6 +1215,10 @@ packages: better-result@2.10.0: resolution: {integrity: sha512-oQhh0y1qo2/ZKdAAEvHZAqKKiHOFU5k/bW96fE2ScgQOVkJRiHwB+nOS1SgFsYqRlxMDWvefXi9Q3px7QvgNDw==} + bin-links@6.0.2: + resolution: {integrity: sha512-frE1t78WOwJ45PKV2cF2tNPjTcs9L1J9s6VkrV59wanRP4GlaomuxYPVma7BwthMg8WnfSory4w5PTE6FZZ81w==} + engines: {node: ^20.17.0 || >=22.9.0} + brace-expansion@5.0.9: resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} engines: {node: 20 || >=22} @@ -1052,20 +1231,78 @@ packages: magicast: optional: true + cacache@20.0.4: + resolution: {integrity: sha512-M3Lab8NPYlZU2exsL3bMVvMrMqgwCnMWfdZbK28bn3pK6APT/Te/I8hjRPNu1uwORY9a1eEQoifXbKPQMfMTOA==} + engines: {node: ^20.17.0 || >=22.9.0} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + char-regex@1.0.2: + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} + chokidar@5.0.0: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + + cjs-module-lexer@1.4.3: + resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + classnames@2.5.1: resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + cli-highlight@2.1.11: + resolution: {integrity: sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==} + engines: {node: '>=8.0.0', npm: '>=5.0.0'} + hasBin: true + + cli-table3@0.6.5: + resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} + engines: {node: 10.* || >= 12.*} + + cliui@7.0.4: + resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} + + cmd-shim@8.0.0: + resolution: {integrity: sha512-Jk/BK6NCapZ58BKUxlSI+ouKRbjH1NLZCgJkYoab+vEHUY3f6OzpNBN9u7HFSv9J6TRDGs4PLOHezoKGaFRSCA==} + engines: {node: ^20.17.0 || >=22.9.0} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + + common-ancestor-path@2.0.0: + resolution: {integrity: sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==} + engines: {node: '>= 18'} + confbox@0.2.4: resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -1073,6 +1310,11 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -1163,6 +1405,12 @@ packages: elkjs@0.11.1: resolution: {integrity: sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emojilib@2.4.0: + resolution: {integrity: sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==} + empathic@2.0.0: resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} engines: {node: '>=14'} @@ -1171,13 +1419,25 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + env-paths@3.0.0: resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + es-module-lexer@2.2.0: resolution: {integrity: sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==} + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -1237,6 +1497,9 @@ packages: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} + exponential-backoff@3.1.3: + resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} @@ -1275,6 +1538,9 @@ packages: picomatch: optional: true + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -1298,6 +1564,10 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + fs-minipass@3.0.3: + resolution: {integrity: sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1306,6 +1576,10 @@ packages: generate-function@2.3.1: resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-port-please@3.2.0: resolution: {integrity: sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==} @@ -1317,6 +1591,10 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -1330,13 +1608,35 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + highlight.js@10.7.3: + resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} + + hosted-git-info@9.0.3: + resolution: {integrity: sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==} + engines: {node: ^20.17.0 || >=22.9.0} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + http-cache-semantics@4.2.0: + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + iconv-lite@0.7.3: resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} + ignore-walk@8.0.0: + resolution: {integrity: sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A==} + engines: {node: ^20.17.0 || >=22.9.0} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1349,14 +1649,26 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + ini@6.0.0: + resolution: {integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==} + engines: {node: ^20.17.0 || >=22.9.0} + internmap@2.0.3: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + ip-address@10.7.0: + resolution: {integrity: sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==} + engines: {node: '>= 12'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -1367,6 +1679,10 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isexe@4.0.0: + resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} + engines: {node: '>=20'} + istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -1389,6 +1705,10 @@ packages: json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-parse-even-better-errors@5.0.0: + resolution: {integrity: sha512-ZF1nxZ28VhQouRWhUcVlUIN3qwSgPuswK05s/HIaoetAoE/9tngVmCHjSxmSQPav1nd+lPtTL0YZ/2AFdR/iYQ==} + engines: {node: ^20.17.0 || >=22.9.0} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -1398,6 +1718,19 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json-stringify-nice@1.1.4: + resolution: {integrity: sha512-5Z5RFW63yxReJ7vANgW6eZFGWaQvnPE3WNmZoOJrSkGju2etKA2L5rrOa1sm877TVTFt57A80BH1bArcmlLfPw==} + + jsonparse@1.3.1: + resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==} + engines: {'0': node >= 0.2.0} + + just-diff-apply@5.5.0: + resolution: {integrity: sha512-OYTthRfSh55WOItVqwpefPtNt2VdKsq5AnAK6apdtR6yCH8pr0CmSr710J0Mf+WdQy7K/OzMy7K2MgAfdQURDw==} + + just-diff@6.0.2: + resolution: {integrity: sha512-S59eriX5u3/QhMNq3v/gm8Kd0w8OS6Tz2FS1NG4blv+z0MuQcBRJyFWjdovM0Rad4/P4aUPFtnkNjMjyMlMSYA==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -1405,6 +1738,11 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + license-checker-rseidelsohn@5.0.1: + resolution: {integrity: sha512-9X+ikKxt9Hy3zOrOZzW1dXL4St5akoYjLt63Am9JZVzU6aTdN+xfDvqySpnJT+gF/h5RmtMk2waW6TDNNCKbqQ==} + engines: {node: '>=24', npm: '>=11'} + hasBin: true + lightningcss-android-arm64@1.33.0: resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} engines: {node: '>= 12.0.0'} @@ -1482,12 +1820,19 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.clonedeep@4.5.0: + resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} + lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + lru.min@1.1.4: resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} @@ -1505,10 +1850,25 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + make-fetch-happen@15.0.6: + resolution: {integrity: sha512-Je0fLJ0F5atA7F+eIlLzk+Wkcl57JDf4kf+EW8xiP5E31xOQxkIxTbgf1Oi1Lw9tRI9UEMRdI5Vz2xTzoNU1Jw==} + engines: {node: ^20.17.0 || >=22.9.0} + markdown-it@14.3.1: resolution: {integrity: sha512-4Ej49aYTDFIQ+uBkfX8GBvJGccoARxxPep+7aWTs55ozbjQJpW9M26Fe53vnGgvLeVzva/amzjQQaQu9w0vMhA==} hasBin: true + marked-terminal@7.3.0: + resolution: {integrity: sha512-t4rBvPsHc57uE/2nJOLmMbZCQ4tgAccAED3ngXQqW6g+TxA488JzJ+FK3lQkzBQOI1mRV/r/Kq+1ZlJ4D0owQw==} + engines: {node: '>=16.0.0'} + peerDependencies: + marked: '>=1 <16' + + marked@9.1.6: + resolution: {integrity: sha512-jcByLnIFkd5gSXZmjNvS1TlmRhCXZjIzHYlaGkPlLIekG55JDR2Z4va9tZwCiP+/RDERiNhMOFu01xd6O5ct1Q==} + engines: {node: '>= 16'} + hasBin: true + mdurl@2.1.0: resolution: {integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==} @@ -1516,6 +1876,47 @@ packages: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} + minipass-collect@2.0.1: + resolution: {integrity: sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass-fetch@5.0.2: + resolution: {integrity: sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + minipass-flush@1.0.7: + resolution: {integrity: sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==} + engines: {node: '>= 8'} + + minipass-pipeline@1.2.4: + resolution: {integrity: sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==} + engines: {node: '>=8'} + + minipass-sized@2.0.0: + resolution: {integrity: sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA==} + engines: {node: '>=8'} + + minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1525,6 +1926,9 @@ packages: peerDependencies: '@types/node': '>= 8' + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + named-placeholders@1.1.6: resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} engines: {node: '>=8.0.0'} @@ -1537,6 +1941,61 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@1.1.0: + resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} + engines: {node: '>=18'} + + node-emoji@2.2.0: + resolution: {integrity: sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==} + engines: {node: '>=18'} + + node-gyp@12.4.0: + resolution: {integrity: sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + nopt@7.2.1: + resolution: {integrity: sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + hasBin: true + + nopt@9.0.0: + resolution: {integrity: sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + npm-bundled@5.0.0: + resolution: {integrity: sha512-JLSpbzh6UUXIEoqPsYBvVNVmyrjVZ1fzEFbqxKkTJQkWBO3xFzFT+KDnSKQWwOQNbuWRwt5LSD6HOTLGIWzfrw==} + engines: {node: ^20.17.0 || >=22.9.0} + + npm-install-checks@8.0.0: + resolution: {integrity: sha512-ScAUdMpyzkbpxoNekQ3tNRdFI8SJ86wgKZSQZdUxT+bj0wVFpsEMWnkXP0twVe1gJyNF5apBWDJhhIbgrIViRA==} + engines: {node: ^20.17.0 || >=22.9.0} + + npm-normalize-package-bin@5.0.0: + resolution: {integrity: sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag==} + engines: {node: ^20.17.0 || >=22.9.0} + + npm-package-arg@13.0.2: + resolution: {integrity: sha512-IciCE3SY3uE84Ld8WZU23gAPPV9rIYod4F+rc+vJ7h7cwAJt9Vk6TVsK60ry7Uj3SRS3bqRRIGuTp9YVlk6WNA==} + engines: {node: ^20.17.0 || >=22.9.0} + + npm-packlist@10.0.4: + resolution: {integrity: sha512-uMW73iajD8hiH4ZBxEV3HC+eTnppIqwakjOYuvgddnalIw2lJguKviK1pcUJDlIWm1wSJkchpDZDSVVsZEYRng==} + engines: {node: ^20.17.0 || >=22.9.0} + + npm-pick-manifest@11.0.3: + resolution: {integrity: sha512-buzyCfeoGY/PxKqmBqn1IUJrZnUi1VVJTdSSRPGI60tJdUhUoSQFhs0zycJokDdOznQentgrpf8LayEHyyYlqQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + npm-registry-fetch@19.1.1: + resolution: {integrity: sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw==} + engines: {node: ^20.17.0 || >=22.9.0} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + obug@2.1.3: resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} engines: {node: '>=12.20.0'} @@ -1560,6 +2019,31 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-map@7.0.7: + resolution: {integrity: sha512-VaWRu2i4FJNRtiRWCuuQRgfQ1B7a6+gMSrO+3j0EQi/k0ULfS9kosRxGoiqwzIjZTDI02tGfk5mXXltLg6QtfQ==} + engines: {node: '>=18'} + + package-manager-detector@1.8.0: + resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==} + + pacote@21.5.1: + resolution: {integrity: sha512-KvcJ9iy3crysCsgqc4+PknH/w6jkrp8JN36mpZBPwNaDRwTfMZD37YzRazNstiZUOhuF5pno9f78n9mEJBavwg==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + parse-conflict-json@5.0.1: + resolution: {integrity: sha512-ZHEmNKMq1wyJXNwLxyHnluPfRAFSIliBvbK/UiOceROt4Xh9Pz0fq49NytIaeaCUf5VR86hwQ/34FCcNU5/LKQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + parse5-htmlparser2-tree-adapter@6.0.1: + resolution: {integrity: sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==} + + parse5@5.1.1: + resolution: {integrity: sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==} + + parse5@6.0.1: + resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1568,6 +2052,10 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -1635,6 +2123,10 @@ packages: pkg-types@2.3.1: resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + postcss-selector-parser@7.1.5: + resolution: {integrity: sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw==} + engines: {node: '>=4'} + postcss@8.5.25: resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} engines: {node: ^10 || ^12 || >=14} @@ -1685,12 +2177,31 @@ packages: typescript: optional: true + proc-log@6.1.0: + resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==} + engines: {node: ^20.17.0 || >=22.9.0} + process-warning@5.1.0: resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==} + proggy@4.0.0: + resolution: {integrity: sha512-MbA4R+WQT76ZBm/5JUpV9yqcJt92175+Y0Bodg3HgiXzrmKu7Ggq+bpn6y6wHH+gN9NcyKn3yg1+d47VaKwNAQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + promise-all-reject-late@1.0.1: + resolution: {integrity: sha512-vuf0Lf0lOxyQREH7GDIOUMLS7kz+gs8i6B+Yi8dC68a2sychGrHTJYghMBD6k7eUcH0H5P73EckCA48xijWqXw==} + + promise-call-limit@3.0.2: + resolution: {integrity: sha512-mRPQO2T1QQVw11E7+UdCJu7S61eJVWknzml9sC1heAdj1jxl0fWMBypIt9ZOcLFf8FkG995ZD7RnVk7HH72fZw==} + proper-lockfile@4.1.2: resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + publint@0.3.24: + resolution: {integrity: sha512-9zS56KrKBoqi5Qt8h92uMP8TTM9AYZSgnmCo4u2priMqkOZvQnTsziZ2p5LJ2ywbYkAjoCDp2jda9u4cgFefIw==} + engines: {node: '>=18'} + hasBin: true + punycode.js@2.3.1: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} @@ -1720,6 +2231,10 @@ packages: resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} engines: {node: '>=0.10.0'} + read-cmd-shim@6.0.0: + resolution: {integrity: sha512-1zM5HuOfagXCBWMN83fuFI/x+T/UhZ7k+KIzhrHXcQoeX5+7gmaDYjELQHmmzIodumBHeByBJT4QYS7ufAgs7A==} + engines: {node: ^20.17.0 || >=22.9.0} + readdirp@5.0.0: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} @@ -1734,6 +2249,10 @@ packages: remeda@2.33.4: resolution: {integrity: sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -1754,6 +2273,10 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + safe-regex2@5.1.1: resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} hasBin: true @@ -1791,6 +2314,26 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + sigstore@4.1.1: + resolution: {integrity: sha512-endqECJkfhozrXMK5ngu/UAA0xVcVEFdnHJCElGaExypjW+HK5i6zu3NteLoaX/iFbRUbC3+DjttQs0GARr+5w==} + engines: {node: ^20.17.0 || >=22.9.0} + + skin-tone@2.0.0: + resolution: {integrity: sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==} + engines: {node: '>=8'} + + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + + socks-proxy-agent@8.0.5: + resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} + engines: {node: '>= 14'} + + socks@2.8.9: + resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} @@ -1798,6 +2341,30 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + spdx-compare@1.0.0: + resolution: {integrity: sha512-C1mDZOX0hnu0ep9dfmuoi03+eOdDoz2yvK79RxbcrVEG1NO1Ph35yW102DHWKN4pk80nwCgeMmSY5L25VE4D9A==} + + spdx-correct@3.2.0: + resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} + + spdx-exceptions@2.5.0: + resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} + + spdx-expression-parse@3.0.1: + resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} + + spdx-expression-parse@4.0.0: + resolution: {integrity: sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==} + + spdx-license-ids@3.0.23: + resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} + + spdx-ranges@2.1.1: + resolution: {integrity: sha512-mcdpQFV7UDAgLpXEE/jOMqvK4LBoO0uTQg0uvXUewmEFhpiZx5yJSZITHB8w1ZahKdhfZqP5GPEOKLyEq5p8XA==} + + spdx-satisfies@6.0.0: + resolution: {integrity: sha512-oOWQocnRbFVtBnBITfFgzjhnOklHossTvI+6C1hB2slvp3HgTsfru5wuo8HY2rQpwSm5JuIhNzIuqOfR5IuojQ==} + split2@4.2.0: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} @@ -1806,6 +2373,10 @@ packages: resolution: {integrity: sha512-6CKD38c31SENivxOADeMNLdukOnUxUcflKtzVWzace7Riv1v7cAEym5Cx9Q7gYZ3ezIJI7ZpqecQq8cqNeSSFg==} engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'} + ssri@13.0.1: + resolution: {integrity: sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==} + engines: {node: ^20.17.0 || >=22.9.0} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -1815,10 +2386,33 @@ packages: std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + supports-hyperlinks@3.2.0: + resolution: {integrity: sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==} + engines: {node: '>=14.18'} + + tar@7.5.22: + resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==} + engines: {node: '>=18'} + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + thread-stream@4.2.0: resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} engines: {node: '>=20'} @@ -1838,6 +2432,14 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} + treeify@1.1.0: + resolution: {integrity: sha512-1m4RA7xVAJrSGrrXGs0L3YTwyvBs2S8PbRHaLZAkFw7JR8oIFwYtysxlBZhYIa7xSyiYJKZ3iGrrk55cGA3i9A==} + engines: {node: '>=0.6'} + + treeverse@3.0.0: + resolution: {integrity: sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -1847,6 +2449,10 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tuf-js@4.1.0: + resolution: {integrity: sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ==} + engines: {node: ^20.17.0 || >=22.9.0} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -1865,6 +2471,11 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + typescript@5.6.1-rc: + resolution: {integrity: sha512-E3b2+1zEFu84jB0YQi9BORDjz9+jGbwwy1Zi3G0LUNw7a7cePUrHMRNy8aPh53nXpkFGVHSxIZo5vKTfYaFiBQ==} + engines: {node: '>=14.17'} + hasBin: true + typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} @@ -1881,9 +2492,20 @@ packages: undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + undici@6.28.0: + resolution: {integrity: sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==} + engines: {node: '>=18.17'} + + unicode-emoji-modifier-base@1.0.0: + resolution: {integrity: sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==} + engines: {node: '>=4'} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + valibot@1.4.2: resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==} peerDependencies: @@ -1900,6 +2522,14 @@ packages: typescript: optional: true + validate-npm-package-name@5.0.1: + resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + validate-npm-package-name@7.0.2: + resolution: {integrity: sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==} + engines: {node: ^20.17.0 || >=22.9.0} + vite@8.0.16: resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1984,11 +2614,20 @@ packages: jsdom: optional: true + walk-up-path@4.0.0: + resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} + engines: {node: 20 || >=22} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} hasBin: true + which@6.0.1: + resolution: {integrity: sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -1998,10 +2637,29 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + write-file-atomic@7.0.1: + resolution: {integrity: sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==} + engines: {node: ^20.17.0 || >=22.9.0} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} @@ -2012,6 +2670,14 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yargs-parser@20.2.9: + resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} + engines: {node: '>=10'} + + yargs@16.2.2: + resolution: {integrity: sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==} + engines: {node: '>=10'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -2024,6 +2690,29 @@ packages: snapshots: + '@andrewbranch/untar.js@1.0.4': {} + + '@arethetypeswrong/cli@0.18.5': + dependencies: + '@arethetypeswrong/core': 0.18.5 + chalk: 4.1.2 + cli-table3: 0.6.5 + commander: 10.0.1 + marked: 9.1.6 + marked-terminal: 7.3.0(marked@9.1.6) + semver: 7.8.5 + + '@arethetypeswrong/core@0.18.5': + dependencies: + '@andrewbranch/untar.js': 1.0.4 + '@loaderkit/resolve': 1.0.6 + cjs-module-lexer: 1.4.3 + fflate: 0.8.3 + lru-cache: 11.5.2 + semver: 7.8.5 + typescript: 5.6.1-rc + validate-npm-package-name: 5.0.1 + '@babel/helper-string-parser@7.29.7': {} '@babel/helper-validator-identifier@7.29.7': {} @@ -2039,6 +2728,11 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@braidai/lang@1.1.2': {} + + '@colors/colors@1.5.0': + optional: true + '@electric-sql/pglite-socket@0.1.3(@electric-sql/pglite@0.4.3)': dependencies: '@electric-sql/pglite': 0.4.3 @@ -2099,6 +2793,8 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 + '@gar/promise-retry@1.0.3': {} + '@gerrit0/mini-shiki@3.23.0': dependencies: '@shikijs/engine-oniguruma': 3.23.0 @@ -2123,6 +2819,12 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + + '@isaacs/string-locale-compare@1.1.0': {} + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.5.5': {} @@ -2132,6 +2834,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@loaderkit/resolve@1.0.6': + dependencies: + '@braidai/lang': 1.1.2 + '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -2139,11 +2845,129 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@oxc-project/types@0.133.0': {} - - '@pinojs/redact@0.4.0': {} - - '@prisma/adapter-pg@7.9.1': + '@npmcli/agent@4.0.2': + dependencies: + agent-base: 7.1.4 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + lru-cache: 11.5.2 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + + '@npmcli/arborist@9.6.0': + dependencies: + '@gar/promise-retry': 1.0.3 + '@isaacs/string-locale-compare': 1.1.0 + '@npmcli/fs': 5.0.0 + '@npmcli/installed-package-contents': 4.0.0 + '@npmcli/map-workspaces': 5.0.3 + '@npmcli/metavuln-calculator': 9.0.3 + '@npmcli/name-from-folder': 4.0.0 + '@npmcli/node-gyp': 5.0.0 + '@npmcli/package-json': 7.0.5 + '@npmcli/query': 5.0.0 + '@npmcli/redact': 4.0.0 + '@npmcli/run-script': 10.0.4 + bin-links: 6.0.2 + cacache: 20.0.4 + common-ancestor-path: 2.0.0 + hosted-git-info: 9.0.3 + json-stringify-nice: 1.1.4 + lru-cache: 11.5.2 + minimatch: 10.2.5 + nopt: 9.0.0 + npm-install-checks: 8.0.0 + npm-package-arg: 13.0.2 + npm-pick-manifest: 11.0.3 + npm-registry-fetch: 19.1.1 + pacote: 21.5.1 + parse-conflict-json: 5.0.1 + proc-log: 6.1.0 + proggy: 4.0.0 + promise-all-reject-late: 1.0.1 + promise-call-limit: 3.0.2 + semver: 7.8.5 + ssri: 13.0.1 + treeverse: 3.0.0 + walk-up-path: 4.0.0 + transitivePeerDependencies: + - supports-color + + '@npmcli/fs@5.0.0': + dependencies: + semver: 7.8.5 + + '@npmcli/git@7.0.2': + dependencies: + '@gar/promise-retry': 1.0.3 + '@npmcli/promise-spawn': 9.0.1 + ini: 6.0.0 + lru-cache: 11.5.2 + npm-pick-manifest: 11.0.3 + proc-log: 6.1.0 + semver: 7.8.5 + which: 6.0.1 + + '@npmcli/installed-package-contents@4.0.0': + dependencies: + npm-bundled: 5.0.0 + npm-normalize-package-bin: 5.0.0 + + '@npmcli/map-workspaces@5.0.3': + dependencies: + '@npmcli/name-from-folder': 4.0.0 + '@npmcli/package-json': 7.0.5 + glob: 13.0.6 + minimatch: 10.2.5 + + '@npmcli/metavuln-calculator@9.0.3': + dependencies: + cacache: 20.0.4 + json-parse-even-better-errors: 5.0.0 + pacote: 21.5.1 + proc-log: 6.1.0 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + + '@npmcli/name-from-folder@4.0.0': {} + + '@npmcli/node-gyp@5.0.0': {} + + '@npmcli/package-json@7.0.5': + dependencies: + '@npmcli/git': 7.0.2 + glob: 13.0.6 + hosted-git-info: 9.0.3 + json-parse-even-better-errors: 5.0.0 + proc-log: 6.1.0 + semver: 7.8.5 + spdx-expression-parse: 4.0.0 + + '@npmcli/promise-spawn@9.0.1': + dependencies: + which: 6.0.1 + + '@npmcli/query@5.0.0': + dependencies: + postcss-selector-parser: 7.1.5 + + '@npmcli/redact@4.0.0': {} + + '@npmcli/run-script@10.0.4': + dependencies: + '@npmcli/node-gyp': 5.0.0 + '@npmcli/package-json': 7.0.5 + '@npmcli/promise-spawn': 9.0.1 + node-gyp: 12.4.0 + proc-log: 6.1.0 + + '@oxc-project/types@0.133.0': {} + + '@pinojs/redact@0.4.0': {} + + '@prisma/adapter-pg@7.9.1': dependencies: '@prisma/driver-adapter-utils': 7.9.1 '@types/pg': 8.20.0 @@ -2249,6 +3073,10 @@ snapshots: transitivePeerDependencies: - '@types/react-dom' + '@publint/pack@0.1.7': + dependencies: + tinyexec: 1.3.0 + '@radix-ui/primitive@1.1.3': {} '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.18)(react@19.2.8)': @@ -2374,8 +3202,49 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@sigstore/bundle@4.0.0': + dependencies: + '@sigstore/protobuf-specs': 0.5.2 + + '@sigstore/core@3.2.1': {} + + '@sigstore/protobuf-specs@0.5.2': {} + + '@sigstore/sign@4.1.1': + dependencies: + '@gar/promise-retry': 1.0.3 + '@sigstore/bundle': 4.0.0 + '@sigstore/core': 3.2.1 + '@sigstore/protobuf-specs': 0.5.2 + make-fetch-happen: 15.0.6 + proc-log: 6.1.0 + transitivePeerDependencies: + - supports-color + + '@sigstore/tuf@4.0.2': + dependencies: + '@sigstore/protobuf-specs': 0.5.2 + tuf-js: 4.1.0 + transitivePeerDependencies: + - supports-color + + '@sigstore/verify@3.1.1': + dependencies: + '@sigstore/bundle': 4.0.0 + '@sigstore/core': 3.2.1 + '@sigstore/protobuf-specs': 0.5.2 + + '@sindresorhus/is@4.6.0': {} + '@standard-schema/spec@1.1.0': {} + '@tufjs/canonical-json@2.0.0': {} + + '@tufjs/models@4.1.0': + dependencies: + '@tufjs/canonical-json': 2.0.0 + minimatch: 10.2.5 + '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 @@ -2692,12 +3561,18 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + abbrev@2.0.0: {} + + abbrev@4.0.0: {} + acorn-jsx@5.3.2(acorn@8.17.0): dependencies: acorn: 8.17.0 acorn@8.17.0: {} + agent-base@7.1.4: {} + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -2712,8 +3587,24 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + + ansi-regex@5.0.1: {} + + ansi-regex@6.3.0: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + any-promise@1.3.0: {} + argparse@2.0.1: {} + array-find-index@1.0.2: {} + assertion-error@2.0.1: {} ast-v8-to-istanbul@1.0.7: @@ -2730,6 +3621,14 @@ snapshots: better-result@2.10.0: {} + bin-links@6.0.2: + dependencies: + cmd-shim: 8.0.0 + npm-normalize-package-bin: 5.0.0 + proc-log: 6.1.0 + read-cmd-shim: 6.0.0 + write-file-atomic: 7.0.1 + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -2751,16 +3650,77 @@ snapshots: optionalDependencies: magicast: 0.5.5 + cacache@20.0.4: + dependencies: + '@npmcli/fs': 5.0.0 + fs-minipass: 3.0.3 + glob: 13.0.6 + lru-cache: 11.5.2 + minipass: 7.1.3 + minipass-collect: 2.0.1 + minipass-flush: 1.0.7 + minipass-pipeline: 1.2.4 + p-map: 7.0.7 + ssri: 13.0.1 + chai@6.2.2: {} + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.6.2: {} + + char-regex@1.0.2: {} + chokidar@5.0.0: dependencies: readdirp: 5.0.0 + chownr@3.0.0: {} + + cjs-module-lexer@1.4.3: {} + classnames@2.5.1: {} + cli-highlight@2.1.11: + dependencies: + chalk: 4.1.2 + highlight.js: 10.7.3 + mz: 2.7.0 + parse5: 5.1.1 + parse5-htmlparser2-tree-adapter: 6.0.1 + yargs: 16.2.2 + + cli-table3@0.6.5: + dependencies: + string-width: 4.2.3 + optionalDependencies: + '@colors/colors': 1.5.0 + + cliui@7.0.4: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + cmd-shim@8.0.0: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + commander@10.0.1: {} + + common-ancestor-path@2.0.0: {} + confbox@0.2.4: {} + content-type@2.1.0: {} + convert-source-map@2.0.0: {} cross-spawn@7.0.6: @@ -2769,6 +3729,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + cssesc@3.0.0: {} + csstype@3.2.3: {} d3-array@3.2.1: @@ -2844,14 +3806,24 @@ snapshots: elkjs@0.11.1: {} + emoji-regex@8.0.0: {} + + emojilib@2.4.0: {} + empathic@2.0.0: {} entities@4.5.0: {} + env-paths@2.2.1: {} + env-paths@3.0.0: {} + environment@1.1.0: {} + es-module-lexer@2.2.0: {} + escalade@3.2.0: {} + escape-string-regexp@4.0.0: {} eslint-config-prettier@10.1.8(eslint@10.8.0(jiti@2.7.0)): @@ -2930,6 +3902,8 @@ snapshots: expect-type@1.4.0: {} + exponential-backoff@3.1.3: {} + exsolve@1.0.8: {} fast-check@3.23.2: @@ -2958,6 +3932,8 @@ snapshots: optionalDependencies: picomatch: 4.0.7 + fflate@0.8.3: {} + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -2985,6 +3961,10 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + fs-minipass@3.0.3: + dependencies: + minipass: 7.1.3 + fsevents@2.3.3: optional: true @@ -2992,6 +3972,8 @@ snapshots: dependencies: is-property: 1.0.2 + get-caller-file@2.0.5: {} + get-port-please@3.2.0: {} giget@3.3.1: {} @@ -3000,6 +3982,12 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + graceful-fs@4.2.11: {} grammex@3.1.13: {} @@ -3008,22 +3996,54 @@ snapshots: has-flag@4.0.0: {} + highlight.js@10.7.3: {} + + hosted-git-info@9.0.3: + dependencies: + lru-cache: 11.5.2 + html-escaper@2.0.2: {} + http-cache-semantics@4.2.0: {} + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + iconv-lite@0.7.3: dependencies: safer-buffer: 2.1.2 + ignore-walk@8.0.0: + dependencies: + minimatch: 10.2.5 + ignore@5.3.2: {} ignore@7.0.5: {} imurmurhash@0.1.4: {} + ini@6.0.0: {} + internmap@2.0.3: {} + ip-address@10.7.0: {} + is-extglob@2.1.1: {} + is-fullwidth-code-point@3.0.0: {} + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -3032,6 +4052,8 @@ snapshots: isexe@2.0.0: {} + isexe@4.0.0: {} + istanbul-lib-coverage@3.2.2: {} istanbul-lib-report@3.0.1: @@ -3051,12 +4073,22 @@ snapshots: json-buffer@3.0.1: {} + json-parse-even-better-errors@5.0.0: {} + json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} json-stable-stringify-without-jsonify@1.0.1: {} + json-stringify-nice@1.1.4: {} + + jsonparse@1.3.1: {} + + just-diff-apply@5.5.0: {} + + just-diff@6.0.2: {} + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -3066,6 +4098,23 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + license-checker-rseidelsohn@5.0.1: + dependencies: + '@npmcli/arborist': 9.6.0 + '@npmcli/package-json': 7.0.5 + chalk: 4.1.2 + debug: 4.4.3 + lodash.clonedeep: 4.5.0 + mkdirp: 1.0.4 + nopt: 7.2.1 + semver: 7.8.5 + spdx-correct: 3.2.0 + spdx-expression-parse: 4.0.0 + spdx-satisfies: 6.0.0 + treeify: 1.1.0 + transitivePeerDependencies: + - supports-color + lightningcss-android-arm64@1.33.0: optional: true @@ -3123,10 +4172,14 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.clonedeep@4.5.0: {} + lodash@4.18.1: {} long@5.3.2: {} + lru-cache@11.5.2: {} + lru.min@1.1.4: {} lunr@2.3.9: {} @@ -3145,6 +4198,23 @@ snapshots: dependencies: semver: 7.8.5 + make-fetch-happen@15.0.6: + dependencies: + '@gar/promise-retry': 1.0.3 + '@npmcli/agent': 4.0.2 + '@npmcli/redact': 4.0.0 + cacache: 20.0.4 + http-cache-semantics: 4.2.0 + minipass: 7.1.3 + minipass-fetch: 5.0.2 + minipass-flush: 1.0.7 + minipass-pipeline: 1.2.4 + negotiator: 1.1.0 + proc-log: 6.1.0 + ssri: 13.0.1 + transitivePeerDependencies: + - supports-color + markdown-it@14.3.1: dependencies: argparse: 2.0.1 @@ -3154,12 +4224,63 @@ snapshots: punycode.js: 2.3.1 uc.micro: 2.1.0 + marked-terminal@7.3.0(marked@9.1.6): + dependencies: + ansi-escapes: 7.3.0 + ansi-regex: 6.3.0 + chalk: 5.6.2 + cli-highlight: 2.1.11 + cli-table3: 0.6.5 + marked: 9.1.6 + node-emoji: 2.2.0 + supports-hyperlinks: 3.2.0 + + marked@9.1.6: {} + mdurl@2.1.0: {} minimatch@10.2.5: dependencies: brace-expansion: 5.0.9 + minipass-collect@2.0.1: + dependencies: + minipass: 7.1.3 + + minipass-fetch@5.0.2: + dependencies: + minipass: 7.1.3 + minipass-sized: 2.0.0 + minizlib: 3.1.0 + optionalDependencies: + iconv-lite: 0.7.3 + + minipass-flush@1.0.7: + dependencies: + minipass: 3.3.6 + + minipass-pipeline@1.2.4: + dependencies: + minipass: 3.3.6 + + minipass-sized@2.0.0: + dependencies: + minipass: 7.1.3 + + minipass@3.3.6: + dependencies: + yallist: 4.0.0 + + minipass@7.1.3: {} + + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + + mkdirp@1.0.4: {} + + mri@1.2.0: {} + ms@2.1.3: {} mysql2@3.24.4(@types/node@26.1.1): @@ -3173,6 +4294,12 @@ snapshots: named-placeholders: 1.1.6 sql-escaper: 1.5.2 + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + named-placeholders@1.1.6: dependencies: lru.min: 1.1.4 @@ -3181,6 +4308,82 @@ snapshots: natural-compare@1.4.0: {} + negotiator@1.1.0: + dependencies: + content-type: 2.1.0 + + node-emoji@2.2.0: + dependencies: + '@sindresorhus/is': 4.6.0 + char-regex: 1.0.2 + emojilib: 2.4.0 + skin-tone: 2.0.0 + + node-gyp@12.4.0: + dependencies: + env-paths: 2.2.1 + exponential-backoff: 3.1.3 + graceful-fs: 4.2.11 + nopt: 9.0.0 + proc-log: 6.1.0 + semver: 7.8.5 + tar: 7.5.22 + tinyglobby: 0.2.17 + undici: 6.28.0 + which: 6.0.1 + + nopt@7.2.1: + dependencies: + abbrev: 2.0.0 + + nopt@9.0.0: + dependencies: + abbrev: 4.0.0 + + npm-bundled@5.0.0: + dependencies: + npm-normalize-package-bin: 5.0.0 + + npm-install-checks@8.0.0: + dependencies: + semver: 7.8.5 + + npm-normalize-package-bin@5.0.0: {} + + npm-package-arg@13.0.2: + dependencies: + hosted-git-info: 9.0.3 + proc-log: 6.1.0 + semver: 7.8.5 + validate-npm-package-name: 7.0.2 + + npm-packlist@10.0.4: + dependencies: + ignore-walk: 8.0.0 + proc-log: 6.1.0 + + npm-pick-manifest@11.0.3: + dependencies: + npm-install-checks: 8.0.0 + npm-normalize-package-bin: 5.0.0 + npm-package-arg: 13.0.2 + semver: 7.8.5 + + npm-registry-fetch@19.1.1: + dependencies: + '@npmcli/redact': 4.0.0 + jsonparse: 1.3.1 + make-fetch-happen: 15.0.6 + minipass: 7.1.3 + minipass-fetch: 5.0.2 + minizlib: 3.1.0 + npm-package-arg: 13.0.2 + proc-log: 6.1.0 + transitivePeerDependencies: + - supports-color + + object-assign@4.1.1: {} + obug@2.1.3: {} ohash@2.0.11: {} @@ -3204,10 +4407,55 @@ snapshots: dependencies: p-limit: 3.1.0 + p-map@7.0.7: {} + + package-manager-detector@1.8.0: {} + + pacote@21.5.1: + dependencies: + '@gar/promise-retry': 1.0.3 + '@npmcli/git': 7.0.2 + '@npmcli/installed-package-contents': 4.0.0 + '@npmcli/package-json': 7.0.5 + '@npmcli/promise-spawn': 9.0.1 + '@npmcli/run-script': 10.0.4 + cacache: 20.0.4 + fs-minipass: 3.0.3 + minipass: 7.1.3 + npm-package-arg: 13.0.2 + npm-packlist: 10.0.4 + npm-pick-manifest: 11.0.3 + npm-registry-fetch: 19.1.1 + proc-log: 6.1.0 + sigstore: 4.1.1 + ssri: 13.0.1 + tar: 7.5.22 + transitivePeerDependencies: + - supports-color + + parse-conflict-json@5.0.1: + dependencies: + json-parse-even-better-errors: 5.0.0 + just-diff: 6.0.2 + just-diff-apply: 5.5.0 + + parse5-htmlparser2-tree-adapter@6.0.1: + dependencies: + parse5: 6.0.1 + + parse5@5.1.1: {} + + parse5@6.0.1: {} + path-exists@4.0.0: {} path-key@3.1.1: {} + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.2 + minipass: 7.1.3 + pathe@2.0.3: {} perfect-debounce@2.1.0: {} @@ -3281,6 +4529,11 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 + postcss-selector-parser@7.1.5: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + postcss@8.5.25: dependencies: nanoid: 3.3.18 @@ -3323,14 +4576,29 @@ snapshots: - react - react-dom + proc-log@6.1.0: {} + process-warning@5.1.0: {} + proggy@4.0.0: {} + + promise-all-reject-late@1.0.1: {} + + promise-call-limit@3.0.2: {} + proper-lockfile@4.1.2: dependencies: graceful-fs: 4.2.11 retry: 0.12.0 signal-exit: 3.0.7 + publint@0.3.24: + dependencies: + '@publint/pack': 0.1.7 + package-manager-detector: 1.8.0 + picocolors: 1.1.1 + sade: 1.8.1 + punycode.js@2.3.1: {} punycode@2.3.1: {} @@ -3353,6 +4621,8 @@ snapshots: react@19.2.8: {} + read-cmd-shim@6.0.0: {} + readdirp@5.0.0: {} real-require@0.2.0: {} @@ -3361,6 +4631,8 @@ snapshots: remeda@2.33.4: {} + require-directory@2.1.1: {} + require-from-string@2.0.2: {} ret@0.5.0: {} @@ -3390,6 +4662,10 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.3 '@rolldown/binding-win32-x64-msvc': 1.0.3 + sade@1.8.1: + dependencies: + mri: 1.2.0 + safe-regex2@5.1.1: dependencies: ret: 0.5.0 @@ -3414,26 +4690,124 @@ snapshots: signal-exit@4.1.0: {} + sigstore@4.1.1: + dependencies: + '@sigstore/bundle': 4.0.0 + '@sigstore/core': 3.2.1 + '@sigstore/protobuf-specs': 0.5.2 + '@sigstore/sign': 4.1.1 + '@sigstore/tuf': 4.0.2 + '@sigstore/verify': 3.1.1 + transitivePeerDependencies: + - supports-color + + skin-tone@2.0.0: + dependencies: + unicode-emoji-modifier-base: 1.0.0 + + smart-buffer@4.2.0: {} + + socks-proxy-agent@8.0.5: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + socks: 2.8.9 + transitivePeerDependencies: + - supports-color + + socks@2.8.9: + dependencies: + ip-address: 10.7.0 + smart-buffer: 4.2.0 + sonic-boom@4.2.1: dependencies: atomic-sleep: 1.0.0 source-map-js@1.2.1: {} + spdx-compare@1.0.0: + dependencies: + array-find-index: 1.0.2 + spdx-expression-parse: 3.0.1 + spdx-ranges: 2.1.1 + + spdx-correct@3.2.0: + dependencies: + spdx-expression-parse: 3.0.1 + spdx-license-ids: 3.0.23 + + spdx-exceptions@2.5.0: {} + + spdx-expression-parse@3.0.1: + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.23 + + spdx-expression-parse@4.0.0: + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.23 + + spdx-license-ids@3.0.23: {} + + spdx-ranges@2.1.1: {} + + spdx-satisfies@6.0.0: + dependencies: + spdx-compare: 1.0.0 + spdx-expression-parse: 3.0.1 + spdx-ranges: 2.1.1 + split2@4.2.0: {} sql-escaper@1.5.2: {} + ssri@13.0.1: + dependencies: + minipass: 7.1.3 + stackback@0.0.2: {} std-env@3.10.0: {} std-env@4.1.0: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 + supports-hyperlinks@3.2.0: + dependencies: + has-flag: 4.0.0 + supports-color: 7.2.0 + + tar@7.5.22: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + thread-stream@4.2.0: dependencies: real-require: 1.0.0 @@ -3449,6 +4823,10 @@ snapshots: tinyrainbow@3.1.0: {} + treeify@1.1.0: {} + + treeverse@3.0.0: {} + ts-api-utils@2.5.0(typescript@6.0.3): dependencies: typescript: 6.0.3 @@ -3456,6 +4834,14 @@ snapshots: tslib@2.8.1: optional: true + tuf-js@4.1.0: + dependencies: + '@tufjs/models': 4.1.0 + debug: 4.4.3 + make-fetch-happen: 15.0.6 + transitivePeerDependencies: + - supports-color + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -3480,6 +4866,8 @@ snapshots: transitivePeerDependencies: - supports-color + typescript@5.6.1-rc: {} + typescript@6.0.3: {} typescript@7.1.0-dev.20260830.1: @@ -3496,10 +4884,16 @@ snapshots: undici-types@8.3.0: {} + undici@6.28.0: {} + + unicode-emoji-modifier-base@1.0.0: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 + util-deprecate@1.0.2: {} + valibot@1.4.2(typescript@6.0.3): optionalDependencies: typescript: 6.0.3 @@ -3508,6 +4902,10 @@ snapshots: optionalDependencies: typescript: 6.0.3 + validate-npm-package-name@5.0.1: {} + + validate-npm-package-name@7.0.2: {} + vite@8.0.16(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.1): dependencies: lightningcss: 1.33.0 @@ -3549,10 +4947,16 @@ snapshots: transitivePeerDependencies: - msw + walk-up-path@4.0.0: {} + which@2.0.2: dependencies: isexe: 2.0.0 + which@6.0.1: + dependencies: + isexe: 4.0.0 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 @@ -3560,13 +4964,41 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + write-file-atomic@7.0.1: + dependencies: + signal-exit: 4.1.0 + xtend@4.0.2: {} + y18n@5.0.8: {} + + yallist@4.0.0: {} + + yallist@5.0.0: {} + yaml@2.9.0: {} yaml@2.9.1: optional: true + yargs-parser@20.2.9: {} + + yargs@16.2.2: + dependencies: + cliui: 7.0.4 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 20.2.9 + yocto-queue@0.1.0: {} zeptomatch@2.1.0: diff --git a/js/scripts/check-licenses.mjs b/js/scripts/check-licenses.mjs new file mode 100644 index 000000000..08eb80717 --- /dev/null +++ b/js/scripts/check-licenses.mjs @@ -0,0 +1,55 @@ +import { execFile } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const packageDirectories = [ + ".", + "migrate", + "driver/pg", + "driver/prisma", + "driver/sqlite", + "worker-threads", + "test", + "cli", +]; +const allowed = [ + "0BSD", + "Apache-2.0", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "LGPL-3.0-or-later", + "MIT", + "PostgreSQL", +].join(";"); +const checker = resolve( + repositoryRoot, + "node_modules/.bin/license-checker-rseidelsohn" +); + +for (const directory of packageDirectories) { + const packageRoot = resolve(repositoryRoot, directory); + try { + await execFileAsync(checker, [ + "--production", + "--onlyAllow", + allowed, + "--excludePrivatePackages", + "--start", + packageRoot, + "--summary", + ]); + } catch (error) { + if (error.stdout) process.stderr.write(error.stdout); + if (error.stderr) process.stderr.write(error.stderr); + throw error; + } +} + +process.stdout.write( + `validated production dependency licenses for ${packageDirectories.length} packages\n` +); diff --git a/js/scripts/check-packages.mjs b/js/scripts/check-packages.mjs new file mode 100644 index 000000000..f4bc47c0d --- /dev/null +++ b/js/scripts/check-packages.mjs @@ -0,0 +1,980 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { + cp, + copyFile, + mkdir, + mkdtemp, + readFile, + readdir, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, posix, relative, resolve } from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +import { + assertNoDeniedContent, + assertPortableArchivePath, + deniedSubstrings, +} from "./package-guard.mjs"; + +const execFileAsync = promisify(execFile); +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const examplesOnly = process.argv.includes("--examples-only"); +const rootPackageJson = JSON.parse( + await readFile(join(repositoryRoot, "package.json"), "utf8") +); +const packageSpecs = [ + { directory: ".", name: "riverqueue" }, + { directory: "migrate", name: "@riverqueue/migrate" }, + { directory: "driver/pg", name: "@riverqueue/driver-pg" }, + { directory: "driver/prisma", name: "@riverqueue/driver-prisma" }, + { directory: "driver/sqlite", name: "@riverqueue/driver-sqlite" }, + { directory: "worker-threads", name: "@riverqueue/worker-threads" }, + { directory: "test", name: "@riverqueue/test" }, + { directory: "cli", name: "@riverqueue/cli" }, +]; +const exampleSpecs = [ + { + directory: "graceful-shutdown", + name: "riverqueue-example-graceful-shutdown", + requiresDatabase: false, + }, + { + directory: "hooks-metrics", + name: "riverqueue-example-hooks-metrics", + requiresDatabase: false, + }, + { + directory: "mixed-language", + name: "riverqueue-example-mixed-language", + requiresDatabase: true, + }, + { + directory: "node-postgres", + name: "riverqueue-example-node-postgres", + requiresDatabase: true, + }, + { + directory: "pg-worker", + name: "riverqueue-example-pg-worker", + requiresDatabase: true, + }, + { + directory: "prisma", + name: "riverqueue-example-prisma", + requiresDatabase: true, + }, + { + directory: "sqlite-worker", + name: "riverqueue-example-sqlite-worker", + requiresDatabase: false, + }, + { + directory: "worker-thread-cpu", + name: "riverqueue-example-worker-thread-cpu", + requiresDatabase: false, + }, +]; + +const temporaryDirectory = await mkdtemp( + join(tmpdir(), "riverqueue-packages-") +); + +try { + const archivesDirectory = join(temporaryDirectory, "archives"); + const archiveByPackageName = new Map(); + await mkdir(archivesDirectory); + + for (const packageSpec of packageSpecs) { + const packageDirectory = resolve(repositoryRoot, packageSpec.directory); + const { stdout } = await run( + "pnpm", + ["pack", "--json", "--pack-destination", archivesDirectory], + { cwd: packageDirectory } + ); + const packResult = parseTrailingJson(stdout); + + assert.equal(packResult.name, packageSpec.name); + const archivePath = packResult.filename; + archiveByPackageName.set(packageSpec.name, archivePath); + + if (!examplesOnly) { + await run(resolveBin("publint"), ["run", archivePath, "--strict"]); + // `cjs-resolves-to-esm` is expected for an ESM-only package; the + // CommonJS consumer check below proves `require(esm)` instead. + await run(resolveBin("attw"), [ + archivePath, + "--profile", + "node16", + "--ignore-rules", + "cjs-resolves-to-esm", + ]); + await inspectArchive(packageSpec, archivePath); + } + } + + if (!examplesOnly) { + await verifyConsumers(archiveByPackageName); + await verifyVersionSkewRejected(archiveByPackageName); + } + await verifyExamples(archiveByPackageName); + process.stdout.write( + `validated ${packageSpecs.length} packed packages and ${exampleSpecs.length} packed examples\n` + ); +} finally { + await rm(temporaryDirectory, { force: true, recursive: true }); +} + +async function inspectArchive(packageSpec, archivePath) { + const extractionDirectory = join( + temporaryDirectory, + "extracted", + packageSpec.name.replaceAll("/", "-") + ); + await mkdir(extractionDirectory, { recursive: true }); + await run("tar", ["-xzf", archivePath, "-C", extractionDirectory]); + + const packageRoot = join(extractionDirectory, "package"); + const packageJson = JSON.parse( + await readFile(join(packageRoot, "package.json"), "utf8") + ); + const files = await listFiles(packageRoot); + + assert.equal(packageJson.name, packageSpec.name); + assert.ok( + files.includes("README.md"), + `${packageSpec.name} packages README.md` + ); + assert.deepEqual( + files.filter((file) => /(?:^|\/)LICENSE$/u.test(file)), + ["LICENSE"], + `${packageSpec.name} packages exactly one license, at the package root` + ); + assert.equal( + await readFile(join(packageRoot, "LICENSE"), "utf8"), + await readFile(join(repositoryRoot, "LICENSE"), "utf8"), + `${packageSpec.name} packages the canonical LGPL text` + ); + assert.equal(packageJson.engines?.node, ">=26"); + assert.equal(packageJson.publishConfig?.access, "public"); + assert.equal(packageJson.publishConfig?.provenance, true); + assert.equal(packageJson.authors, undefined, "`authors` is not an npm field"); + assert.ok( + Array.isArray(packageJson.contributors) && + packageJson.contributors.length > 0, + `${packageSpec.name} lists contributors` + ); + assert.ok( + packageJson.sideEffects === false || + (Array.isArray(packageJson.sideEffects) && + packageJson.sideEffects.every((entry) => + files.includes(posix.normalize(entry)) + )), + `${packageSpec.name} declares sideEffects as false or packed entry files` + ); + inspectDependencies(packageSpec, packageJson); + assert.deepEqual( + files.filter((file) => /(?:^|[./])(?:integration\.)?test\./.test(file)), + [], + `${packageSpec.name} does not package test builds` + ); + const denied = deniedSubstrings(repositoryRoot); + for (const file of files) { + assertPortableArchivePath(`${packageSpec.name}:${file}`, file); + assertNoDeniedContent(`${packageSpec.name}:${file} (path)`, file, denied); + if (/\.(?:[cm]?js|json|map|md|sql|ts)$/u.test(file)) { + assertNoDeniedContent( + `${packageSpec.name}:${file}`, + await readFile(join(packageRoot, file), "utf8"), + denied + ); + } + } + await inspectSourceMaps(packageSpec, packageRoot, files); + + if (packageSpec.name === "@riverqueue/cli") { + const binPath = join(packageRoot, packageJson.bin.riverqueue); + const binStats = await stat(binPath); + assert.notEqual( + binStats.mode & 0o111, + 0, + "the packaged CLI entry point is executable" + ); + } +} + +// Libraries share one `riverqueue` instance with the application: job +// definitions, drivers, and errors are recognized by identity, so a second +// copy silently breaks them. Every library therefore takes `riverqueue` as an +// exact peer, which makes version skew fail at install time. The CLI is a +// self-contained executable that never exchanges River objects with an +// application, so it keeps ordinary exact dependencies and works when +// installed on its own. Type packages are optional peers so JavaScript +// consumers do not install typings and TypeScript consumers choose versions. +function inspectDependencies(packageSpec, packageJson) { + const label = packageSpec.name; + assert.ok( + !JSON.stringify(packageJson).includes("workspace:"), + `${label} has no workspace protocol in published metadata` + ); + const dependencies = { + ...packageJson.dependencies, + ...packageJson.optionalDependencies, + }; + const peers = packageJson.peerDependencies ?? {}; + for (const [dependency, version] of Object.entries({ + ...dependencies, + ...peers, + })) { + if (dependency === "riverqueue" || dependency.startsWith("@riverqueue/")) { + assert.equal( + version, + packageJson.version, + `${label} pins ${dependency} to its exact release line` + ); + } + } + assert.deepEqual( + Object.keys(dependencies).filter((name) => name.startsWith("@types/")), + [], + `${label} does not install type packages for JavaScript consumers` + ); + for (const name of Object.keys(peers).filter((peer) => + peer.startsWith("@types/") + )) { + assert.equal( + packageJson.peerDependenciesMeta?.[name]?.optional, + true, + `${label} makes ${name} an optional peer` + ); + } + if (label === "riverqueue") return; + if (label === "@riverqueue/cli") { + assert.equal( + packageJson.dependencies?.riverqueue, + packageJson.version, + `${label} bundles its own exact riverqueue dependency` + ); + return; + } + assert.equal( + peers.riverqueue, + packageJson.version, + `${label} takes riverqueue as an exact peer` + ); + assert.equal( + dependencies.riverqueue, + undefined, + `${label} does not install a private riverqueue copy` + ); +} + +// Every emitted module and declaration file must carry a map whose sources +// resolve to TypeScript shipped in the same archive, so stack traces, +// debuggers, and editor go-to-definition land on real source. Conversely, +// every packed source file must be referenced by a map, which keeps test +// helpers and uncompiled files out of the tarball. +async function inspectSourceMaps(packageSpec, packageRoot, files) { + const packed = new Set(files); + const referencedSources = new Set(); + const generatedFiles = files.filter( + (file) => file.startsWith("dist/") && /\.(?:d\.ts|js)$/u.test(file) + ); + assert.ok(generatedFiles.length > 0, `${packageSpec.name} packages dist`); + for (const generated of generatedFiles) { + const label = `${packageSpec.name}:${generated}`; + const mapFile = `${generated}.map`; + assert.ok(packed.has(mapFile), `${label} packages ${mapFile}`); + const generatedText = await readFile(join(packageRoot, generated), "utf8"); + assert.ok( + generatedText + .trimEnd() + .endsWith(`//# sourceMappingURL=${posix.basename(mapFile)}`), + `${label} links its map` + ); + const sourceMap = JSON.parse( + await readFile(join(packageRoot, mapFile), "utf8") + ); + assert.equal(sourceMap.version, 3, `${label} map uses version 3`); + assert.equal( + sourceMap.file, + posix.basename(generated), + `${label} map names its generated file` + ); + assert.ok( + Array.isArray(sourceMap.sources) && sourceMap.sources.length > 0, + `${label} map lists its sources` + ); + for (const source of sourceMap.sources) { + const resolved = posix.join( + posix.dirname(mapFile), + sourceMap.sourceRoot ?? "", + source + ); + assertPortableArchivePath(`${label} map source`, resolved); + assert.ok( + resolved.startsWith("src/") && packed.has(resolved), + `${label} map source ${source} resolves to packed source` + ); + referencedSources.add(resolved); + } + } + assert.deepEqual( + files.filter( + (file) => + file.endsWith(".ts") && + !file.endsWith(".d.ts") && + !referencedSources.has(file) + ), + [], + `${packageSpec.name} packages only TypeScript source referenced by maps` + ); +} + +async function verifyConsumers(archiveByPackageName) { + const consumerDirectory = join(temporaryDirectory, "consumer"); + await mkdir(consumerDirectory); + const dependencies = Object.fromEntries( + [...archiveByPackageName].map(([name, archivePath]) => [ + name, + `file:${archivePath}`, + ]) + ); + await writeFile( + join(consumerDirectory, "package.json"), + `${JSON.stringify( + { + dependencies, + name: "riverqueue-packed-consumer", + private: true, + type: "module", + version: "0.0.0", + }, + null, + 2 + )}\n` + ); + // Install without type packages first: JavaScript consumers must not need + // them, and the published metadata must not pull them in. + await npmInstall(consumerDirectory); + for (const typesPackage of ["@types/node", "@types/pg"]) { + await assert.rejects( + stat(join(consumerDirectory, "node_modules", typesPackage)), + { code: "ENOENT" }, + `packed packages do not install ${typesPackage}` + ); + } + + await writeFile( + join(consumerDirectory, "import.mjs"), + ` +import * as river from "riverqueue"; +import * as cli from "@riverqueue/cli"; +import * as pg from "@riverqueue/driver-pg"; +import * as prisma from "@riverqueue/driver-prisma"; +import * as sqlite from "@riverqueue/driver-sqlite"; +import * as migrate from "@riverqueue/migrate"; +import * as testing from "@riverqueue/test"; +import * as workerThreads from "@riverqueue/worker-threads"; + +for (const packageNamespace of [river, cli, pg, prisma, sqlite, migrate, testing, workerThreads]) { + if (Object.keys(packageNamespace).length === 0) throw new Error("empty package namespace"); +} + +const exact = river.exactJsonNumber("9223372036854775807"); +if (JSON.stringify({ exact }) !== '{"exact":9223372036854775807}') { + throw new Error("packed exact JSON number lost precision"); +} +const parsed = river.parseJson('{"exact":9223372036854775807}'); +if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed) || + !river.isExactJsonNumber(parsed.exact)) { + throw new Error("packed exact JSON parser lost precision"); +} +` + ); + await run(process.execPath, [join(consumerDirectory, "import.mjs")]); + + await writeFile( + join(consumerDirectory, "require.cjs"), + ` +const packageNames = [ + "riverqueue", + "@riverqueue/cli", + "@riverqueue/driver-pg", + "@riverqueue/driver-prisma", + "@riverqueue/driver-sqlite", + "@riverqueue/migrate", + "@riverqueue/test", + "@riverqueue/worker-threads", +]; +const requiredPackages = new Map( + packageNames.map((packageName) => [packageName, require(packageName)]), +); +for (const [packageName, packageNamespace] of requiredPackages) { + if (Object.keys(packageNamespace).length === 0) { + throw new Error(\`empty required namespace for \${packageName}\`); + } +} + +import("riverqueue").then((imported) => { + if (requiredPackages.get("riverqueue").Client !== imported.Client) { + throw new Error("require(esm) and import resolved different River implementations"); + } +}); +` + ); + await run(process.execPath, [join(consumerDirectory, "require.cjs")]); + + const { stdout: cliVersion } = await run( + join(consumerDirectory, "node_modules", ".bin", "riverqueue"), + ["--version"] + ); + assert.match( + cliVersion, + /^riverqueue version \d+\.\d+\.\d+/, + "the packed riverqueue executable reports its version" + ); + + await writeFile( + join(consumerDirectory, "sqlite-worker.mjs"), + ` +import { SqliteDriver } from "@riverqueue/driver-sqlite"; +import { createMigrator } from "@riverqueue/migrate"; +import { Client, Workers, defineJob } from "riverqueue"; + +const job = defineJob({ + kind: "package.consumer", + decode(value) { return value; }, +}); +const workers = new Workers(); +workers.add(job, () => undefined); +const driver = SqliteDriver.memory(); +await createMigrator(driver).migrateUp(); +const client = new Client(driver, { + queues: { default: { maxWorkers: 1 } }, + workers, +}); +await using events = client.subscribe({ + kinds: ["job_completed"], + signal: AbortSignal.timeout(10_000), +}); +const inserted = await client.insert(job, { source: "packed-archive" }); +await using run = await client.start(); +let completed = false; +for await (const event of events) { + if (event.kind === "job_completed" && event.job.id === inserted.job.id) { + completed = true; + break; + } +} +await run.stop({ mode: "graceful", timeout: { seconds: 5 } }); +driver.close(); +if (!completed) throw new Error("packed SQLite worker did not complete its job"); +` + ); + await run(process.execPath, [join(consumerDirectory, "sqlite-worker.mjs")]); + + // Exercise real behavior through the installed tarballs with Node's own + // test runner, so no transpiler or workspace alias can hide a packaging + // failure. The PostgreSQL tests run only when DATABASE_URL is set. + await cp( + resolve(repositoryRoot, "scripts", "packed-tests"), + join(consumerDirectory, "packed-tests"), + { recursive: true } + ); + await run( + process.execPath, + ["--test", "--test-reporter=spec", "packed-tests/*.test.mjs"], + { cwd: consumerDirectory } + ); + + await writeFile( + join(consumerDirectory, "consumer.ts"), + ` +import { + Client, + Workers, + defineJob, + exactJsonNumber, + type ExactJsonNumber, + type InsertResult, + type RiverEvent, +} from "riverqueue"; +import * as stableRiver from "riverqueue"; +import { + PilotClient, + uniqueBitmaskFromStates, + uniqueBitmaskToStates, + type Pilot, + type PilotClientOptions, + type PilotDatabase, + type RuntimeDriver, +} from "riverqueue/unstable-driver"; +import type { + ClientDriver, + QueueConfig, + RunHandle, +} from "riverqueue"; +import { run as runCli } from "@riverqueue/cli"; +import { PgDriver } from "@riverqueue/driver-pg"; +import { PrismaDriver, type PrismaClientLike } from "@riverqueue/driver-prisma"; +import { + SqliteDriver, + transaction, +} from "@riverqueue/driver-sqlite"; +import { createMigrator, type Migrator } from "@riverqueue/migrate"; +import * as riverTest from "@riverqueue/test"; +import { WorkerThreads } from "@riverqueue/worker-threads"; +import { DatabaseSync } from "node:sqlite"; +import type { Pool, PoolClient } from "pg"; + +const emailJob = defineJob({ + kind: "email.send", + decode(value) { + if (typeof value.address !== "string") throw new TypeError("address"); + return { address: value.address }; + }, +}); +declare const driver: RuntimeDriver; +declare const prisma: PrismaClientLike; +declare const pgPool: Pool; +declare const pgTx: PoolClient; +declare const sqliteDatabase: DatabaseSync; +const client = new Client(driver); +const pgDriver = new PgDriver(pgPool); +const pgClient = new Client(pgDriver); +const sqliteDriver = new SqliteDriver(sqliteDatabase); +const sqliteClient = new Client(sqliteDriver); +const migrators: Migrator[] = [ + createMigrator(pgDriver), + createMigrator(sqliteDriver), + createMigrator({ pool: pgPool, schema: "river" }), + createMigrator({ database: sqliteDatabase }), +]; +const workers = new Workers(); +const period: Temporal.Duration = Temporal.Duration.from({ minutes: 5 }); +const exactNumber: ExactJsonNumber = exactJsonNumber("9223372036854775807"); +const uniqueMask = uniqueBitmaskFromStates(["available", "scheduled"]); +const uniqueStates = uniqueBitmaskToStates(Number.parseInt(uniqueMask, 2)); +const insertion: Promise = client.insert(emailJob, { address: "a@example.com" }, { + unique: { byPeriod: period }, +}); +const pgInsertion: Promise = pgClient.insert( + emailJob, + { address: "pg@example.com" }, + { tx: pgTx }, +); +const sqliteInsertion: Promise = transaction( + sqliteDatabase, + async (tx) => { + tx.prepare("SELECT 1"); + return sqliteClient.insert( + emailJob, + { address: "sqlite@example.com" }, + { tx }, + ); + }, +); +// A companion client extends PilotClient privately and publishes only an +// interface that extends Client, with its own queue configuration. +interface CompanionQueueConfig extends QueueConfig { + readonly limit?: number; +} +interface CompanionClient extends Client { + readonly companion: string; + start(): Promise>; +} +interface CompanionClientConstructor { + new ( + driver: ClientDriver, + options?: PilotClientOptions, + ): CompanionClient; + readonly prototype: CompanionClient; +} +class CompanionImplementation extends PilotClient< + Transaction, + CompanionQueueConfig +> { + readonly companion = "companion"; + constructor( + driver: ClientDriver, + options: PilotClientOptions = {}, + ) { + super( + driver, + options, + ( + database: PilotDatabase, + ): Pilot> => ({ + // The host's client is the final companion client. + init: (host) => void [database.backend, host.client.companion], + queueOptions: { keys: ["limit"], parse: () => 1 }, + }), + ); + } +} +const CompanionClient = + CompanionImplementation as unknown as CompanionClientConstructor; +const companion = new CompanionClient(sqliteDriver, { + queues: { limited: { limit: 1, maxWorkers: 1 } }, +}); +// @ts-expect-error Queue keys neither River nor the pilot owns are rejected. +void new CompanionClient(sqliteDriver, { queues: { q: { maxWorkers: 1, other: 1 } } }); +const companionAsClient: Client = + companion; +const companionRun: Promise = companion + .start() + .then((run) => run.addQueue("limited", { limit: 1, maxWorkers: 1 })); +const stableRun: Promise = sqliteClient + .start() + // @ts-expect-error River's own queue configuration has no pilot keys. + .then((run) => run.addQueue("limited", { limit: 1, maxWorkers: 1 })); +// @ts-expect-error PilotClient is abstract. +void new PilotClient(sqliteDriver, {}, () => ({})); +// @ts-expect-error The pilot client belongs to riverqueue/unstable-driver. +void stableRiver.PilotClient; +void [companionAsClient, companionRun, stableRun]; +// @ts-expect-error Drivers are opaque: no runtime methods, +void pgDriver.jobClaim; +// @ts-expect-error nor insertion methods, +void pgDriver.jobInsert; +// @ts-expect-error nor their connection or schema, +void pgDriver.pool; +// @ts-expect-error nor the application's SQLite handle. +void sqliteDriver.database; +// @ts-expect-error Protocol bitmask helpers belong to riverqueue/unstable-driver. +void stableRiver.uniqueBitmaskFromStates; +// @ts-expect-error Resolved insertion state is not a stable root API. +type StableResolvedInsertOpts = stableRiver.ResolvedInsertOptions; +// @ts-expect-error SQLite codecs are not package-entry exports. +void import("@riverqueue/driver-sqlite").then((module) => module.JOB_COLUMNS); +const event: RiverEvent | undefined = undefined; +void [workers, exactNumber, uniqueStates, insertion, pgInsertion, sqliteInsertion, event, runCli, + pgDriver, new PrismaDriver(prisma), sqliteDriver, + migrators, riverTest, WorkerThreads]; +` + ); + await copyFile( + resolve(repositoryRoot, "fixtures/migration-0.1/after.ts"), + join(consumerDirectory, "migration-0.1.ts") + ); + await writeFile( + join(consumerDirectory, "tsconfig.json"), + `${JSON.stringify( + { + compilerOptions: { + exactOptionalPropertyTypes: true, + lib: ["ES2024"], + module: "NodeNext", + moduleResolution: "NodeNext", + noEmit: true, + strict: true, + target: "ES2024", + }, + files: ["consumer.ts", "migration-0.1.ts"], + }, + null, + 2 + )}\n` + ); + + // TypeScript consumers install the optional type peers themselves. + await npmInstall(consumerDirectory, [ + "--save-dev", + ...["@types/node", "@types/pg"].map( + (name) => `${name}@${rootPackageJson.devDependencies[name]}` + ), + ]); + for (const compilerPackage of ["typescript", "typescript-next"]) { + await run( + process.execPath, + [ + resolve(repositoryRoot, "node_modules", compilerPackage, "bin", "tsc"), + "--project", + join(consumerDirectory, "tsconfig.json"), + ], + { cwd: consumerDirectory } + ); + } + + await verifyCommonJsTypeScriptConsumer(consumerDirectory); +} + +// The packages are ESM-only. Node 26 loads them from CommonJS through +// `require(esm)`, and TypeScript models that only for `module: node20` and +// `nodenext`; `node16` and `node18` report TS1479, which is what Are The Types +// Wrong's `cjs-resolves-to-esm` rule flags. Compile a CommonJS consumer in the +// supported modes, run the emitted `require` calls, and check that they share +// the module instance an ESM import sees. +async function verifyCommonJsTypeScriptConsumer(consumerDirectory) { + await writeFile( + join(consumerDirectory, "commonjs-consumer.cts"), + ` +import { Client, Workers, defineJob } from "riverqueue"; +import { SqliteDriver } from "@riverqueue/driver-sqlite"; +import { createMigrator } from "@riverqueue/migrate"; + +const job = defineJob({ kind: "commonjs.consumer", decode: (value) => value }); +const workers = new Workers(); +workers.add(job, () => undefined); +const period: Temporal.Duration = Temporal.Duration.from({ minutes: 1 }); +const driver = SqliteDriver.memory(); + +async function main(): Promise { + await createMigrator(driver).migrateUp(); + const client = new Client(driver, { workers }); + const inserted = await client.insert(job, { period: period.toString() }); + driver.close(); + const imported = await import("riverqueue"); + if (imported.Client !== Client || typeof require !== "function") { + throw new Error("require(esm) and import resolved different River modules"); + } + if (inserted.status !== "inserted") throw new Error("CommonJS insert failed"); +} + +main().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; +}); +` + ); + const lanes = [ + ["typescript", "node20"], + ["typescript", "nodenext"], + ["typescript-next", "nodenext"], + ]; + for (const [compilerPackage, moduleMode] of lanes) { + const outDir = join( + consumerDirectory, + `commonjs-${compilerPackage}-${moduleMode}` + ); + const project = join(consumerDirectory, `tsconfig.${moduleMode}.json`); + await writeFile( + project, + `${JSON.stringify( + { + compilerOptions: { + exactOptionalPropertyTypes: true, + lib: ["ES2024"], + module: moduleMode, + outDir, + strict: true, + target: "ES2024", + types: ["node"], + }, + files: ["commonjs-consumer.cts"], + }, + null, + 2 + )}\n` + ); + await run( + process.execPath, + [ + resolve(repositoryRoot, "node_modules", compilerPackage, "bin", "tsc"), + "--project", + project, + ], + { cwd: consumerDirectory } + ); + const emitted = await readFile( + join(outDir, "commonjs-consumer.cjs"), + "utf8" + ); + assert.match( + emitted, + /require\("riverqueue"\)/u, + `${compilerPackage} ${moduleMode} emits require() for riverqueue` + ); + await run(process.execPath, [join(outDir, "commonjs-consumer.cjs")]); + } +} + +// A library built for one riverqueue release must refuse to install next to a +// different one instead of silently loading a second copy. +async function verifyVersionSkewRejected(archiveByPackageName) { + const skewDirectory = join(temporaryDirectory, "skew"); + const skewPackageRoot = join(skewDirectory, "riverqueue", "package"); + await mkdir(skewPackageRoot, { recursive: true }); + await run("tar", [ + "-xzf", + archiveByPackageName.get("riverqueue"), + "-C", + dirname(skewPackageRoot), + ]); + const packageJsonPath = join(skewPackageRoot, "package.json"); + const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")); + packageJson.version = `${packageJson.version}-skew`; + await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`); + const skewedArchive = join(skewDirectory, "riverqueue-skew.tgz"); + await run("tar", [ + "-czf", + skewedArchive, + "-C", + dirname(skewPackageRoot), + "package", + ]); + + for (const packageName of archiveByPackageName.keys()) { + if (packageName === "riverqueue" || packageName === "@riverqueue/cli") { + continue; + } + const consumerDirectory = join( + skewDirectory, + packageName.replaceAll("/", "-") + ); + await mkdir(consumerDirectory); + await writeFile( + join(consumerDirectory, "package.json"), + `${JSON.stringify( + { + dependencies: { + [packageName]: `file:${archiveByPackageName.get(packageName)}`, + riverqueue: `file:${skewedArchive}`, + }, + name: "riverqueue-skewed-consumer", + private: true, + version: "0.0.0", + }, + null, + 2 + )}\n` + ); + await assert.rejects( + npmInstall(consumerDirectory, ["--dry-run"], { quiet: true }), + (error) => /ERESOLVE/u.test(`${error.stdout}${error.stderr}`), + `${packageName} refuses to install beside a different riverqueue` + ); + } +} + +async function verifyExamples(archiveByPackageName) { + const examplesRoot = join(temporaryDirectory, "examples"); + await cp(resolve(repositoryRoot, "examples"), examplesRoot, { + filter: (source) => + !source + .split(/[\\/]/u) + .some((part) => ["dist", "generated", "node_modules"].includes(part)), + recursive: true, + }); + + for (const spec of exampleSpecs) { + const packagePath = join(examplesRoot, spec.directory, "package.json"); + const packageJson = JSON.parse(await readFile(packagePath, "utf8")); + assert.equal(packageJson.name, spec.name); + for (const dependencies of [ + packageJson.dependencies, + packageJson.devDependencies, + packageJson.optionalDependencies, + ]) { + if (dependencies === undefined) continue; + for (const packageName of Object.keys(dependencies)) { + const archive = archiveByPackageName.get(packageName); + if (archive !== undefined) { + dependencies[packageName] = `file:${archive}`; + } + } + } + assert.ok( + !JSON.stringify(packageJson).includes("workspace:"), + `${spec.name} has only packed River dependencies` + ); + await writeFile(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`); + } + + await writeFile( + join(examplesRoot, "package.json"), + `${JSON.stringify( + { + name: "riverqueue-packed-examples", + private: true, + type: "module", + version: "0.0.0", + workspaces: exampleSpecs.map((spec) => spec.directory), + }, + null, + 2 + )}\n` + ); + await npmInstall(examplesRoot); + + const runDatabaseExamples = + typeof process.env.DATABASE_URL === "string" && + process.env.DATABASE_URL.length > 0; + for (const spec of exampleSpecs) { + await run("npm", ["run", "build", "--workspace", spec.name], { + cwd: examplesRoot, + }); + if (!spec.requiresDatabase || runDatabaseExamples) { + await run("npm", ["run", "start", "--workspace", spec.name], { + cwd: examplesRoot, + }); + } + } +} + +async function listFiles(root) { + const result = []; + + async function walk(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + await walk(path); + } else { + result.push(relative(root, path)); + } + } + } + + await walk(root); + return result.sort(); +} + +function resolveBin(name) { + return resolve(repositoryRoot, "node_modules", ".bin", name); +} + +function parseTrailingJson(output) { + const start = output.lastIndexOf("\n{"); + return JSON.parse(output.slice(start < 0 ? 0 : start + 1)); +} + +function npmInstall(cwd, args = [], options = {}) { + return run( + "npm", + [ + "install", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--package-lock=false", + ...args, + ], + { cwd, ...options } + ); +} + +async function run(command, args, { quiet = false, ...options } = {}) { + try { + return await execFileAsync(command, args, { + env: { + ...process.env, + NO_COLOR: "1", + npm_config_cache: join(temporaryDirectory, "npm-cache"), + }, + maxBuffer: 10 * 1024 * 1024, + ...options, + }); + } catch (error) { + if (!quiet && error.stdout) process.stderr.write(error.stdout); + if (!quiet && error.stderr) process.stderr.write(error.stderr); + throw error; + } +} diff --git a/js/scripts/packed-tests/cli.test.mjs b/js/scripts/packed-tests/cli.test.mjs new file mode 100644 index 000000000..e3e44de86 --- /dev/null +++ b/js/scripts/packed-tests/cli.test.mjs @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import process from "node:process"; +import { after, before, describe, it } from "node:test"; +import { fileURLToPath, URL } from "node:url"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const bin = fileURLToPath( + new URL("../node_modules/.bin/riverqueue", import.meta.url) +); + +function riverqueue(...args) { + return execFileAsync(bin, args, { + env: { ...process.env, NO_COLOR: "1", PGDATABASE: "" }, + }); +} + +describe("packed riverqueue CLI", () => { + let directory; + + before(async () => { + directory = await mkdtemp(join(tmpdir(), "riverqueue-packed-cli-")); + }); + + after(async () => { + await rm(directory, { force: true, recursive: true }); + }); + + it("reports its version", async () => { + const { stdout } = await riverqueue("--version"); + assert.match(stdout, /^riverqueue version \d+\.\d+\.\d+/); + }); + + it("prints help for the command set", async () => { + const { stdout } = await riverqueue("--help"); + for (const command of [ + "bench", + "migrate-down", + "migrate-get", + "migrate-list", + "migrate-up", + "validate", + "version", + ]) { + assert.match(stdout, new RegExp(`\\b${command}\\b`), command); + } + }); + + it("migrates and validates a SQLite database with bundled migrations", async () => { + const databaseUrl = `sqlite://${join(directory, "river.db")}`; + await assert.rejects( + riverqueue("validate", "--database-url", databaseUrl), + (error) => error.code !== 0 + ); + const { stdout: applied } = await riverqueue( + "migrate-up", + "--database-url", + databaseUrl + ); + assert.match(applied, /applied migration 001/); + await riverqueue("validate", "--database-url", databaseUrl); + const { stdout: listed } = await riverqueue( + "migrate-list", + "--database-url", + databaseUrl + ); + assert.doesNotMatch(listed, /not applied/i); + }); + + it("fails with a usage error for an unknown command", async () => { + await assert.rejects(riverqueue("no-such-command"), (error) => { + assert.notEqual(error.code, 0); + assert.match(`${error.stderr}${error.stdout}`, /no-such-command/); + return true; + }); + }); +}); diff --git a/js/scripts/packed-tests/fixtures/thread-handlers.mjs b/js/scripts/packed-tests/fixtures/thread-handlers.mjs new file mode 100644 index 000000000..da27b82ca --- /dev/null +++ b/js/scripts/packed-tests/fixtures/thread-handlers.mjs @@ -0,0 +1,11 @@ +// A worker-thread handler module loaded by the packed SQLite runtime test. +import { complete, isExactJsonNumber } from "riverqueue"; + +export async function echoAccount({ job, signal }) { + signal.throwIfAborted(); + const { accountId } = job.args; + if (!isExactJsonNumber(accountId)) { + throw new TypeError("accountId did not cross the thread boundary exactly"); + } + return complete({ output: { accountId, thread: true } }); +} diff --git a/js/scripts/packed-tests/helpers.mjs b/js/scripts/packed-tests/helpers.mjs new file mode 100644 index 000000000..ab0c39c81 --- /dev/null +++ b/js/scripts/packed-tests/helpers.mjs @@ -0,0 +1,36 @@ +// Shared helpers for the packed-package suite. These files run with Node's +// built-in test runner inside a scratch consumer that installed River from +// `pnpm pack` tarballs, so they exercise exactly what npm would publish. + +/* global AbortSignal */ + +/** + * Start a client and wait until each listed job reaches a terminal state. + * Resolves with the terminal events keyed by job ID. + */ +export async function workUntilFinalized(client, ids, timeoutMs = 10_000) { + const pending = new Set(ids); + const finalized = new Map(); + const subscription = client.subscribe({ + kinds: ["job_cancelled", "job_completed", "job_failed"], + signal: AbortSignal.timeout(timeoutMs), + }); + const run = await client.start(); + try { + for await (const event of subscription) { + if (event.kind === "job_failed" && event.job.state !== "discarded") { + continue; + } + if (!pending.delete(event.job.id)) continue; + finalized.set(event.job.id, event); + if (pending.size === 0) break; + } + } finally { + subscription.close(); + await run.stop({ mode: "graceful", timeout: { seconds: 5 } }); + } + if (pending.size > 0) { + throw new Error(`jobs did not finish: ${[...pending].join(", ")}`); + } + return finalized; +} diff --git a/js/scripts/packed-tests/packages.test.mjs b/js/scripts/packed-tests/packages.test.mjs new file mode 100644 index 000000000..594a79a3d --- /dev/null +++ b/js/scripts/packed-tests/packages.test.mjs @@ -0,0 +1,213 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { readFile, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import process from "node:process"; +import { describe, it } from "node:test"; +import { fileURLToPath, URL } from "node:url"; +import { promisify } from "node:util"; + +import * as migrate from "@riverqueue/migrate"; +import { SqliteDriver } from "@riverqueue/driver-sqlite"; +import { createTestClient } from "@riverqueue/test"; +import * as river from "riverqueue"; +import * as unstable from "riverqueue/unstable-driver"; + +const execFileAsync = promisify(execFile); +const require = createRequire(import.meta.url); +const consumerDirectory = fileURLToPath(new URL("..", import.meta.url)); + +const SATELLITES = [ + "@riverqueue/cli", + "@riverqueue/driver-pg", + "@riverqueue/driver-prisma", + "@riverqueue/driver-sqlite", + "@riverqueue/migrate", + "@riverqueue/test", + "@riverqueue/worker-threads", +]; + +describe("packed package graph", () => { + it("resolves exactly one riverqueue from every satellite package", () => { + const root = require.resolve("riverqueue"); + for (const name of SATELLITES) { + const fromPackage = createRequire(require.resolve(name)); + assert.equal( + fromPackage.resolve("riverqueue"), + root, + `${name} resolves the application's riverqueue` + ); + } + }); + + it("shares River error classes across packages", async () => { + assert.equal(migrate.MigrationError, river.MigrationError); + assert.throws( + () => migrate.createMigrator({}), + (error) => + error instanceof river.MigrationError && + error instanceof river.RiverError && + error.code === "migration" + ); + + const { client } = createTestClient(); + await assert.rejects( + client.jobs.get(1n), + (error) => + error instanceof river.UnsupportedCapabilityError && + error instanceof river.RiverError + ); + + assert.throws( + () => river.toJsonObject({ value: 1n }), + (error) => + error instanceof river.JsonValueError && + error instanceof river.ValidationError && + error instanceof river.RiverError && + error.path === "$.value" + ); + }); + + it("loads the same ESM instance through require(esm)", async () => { + const required = require("riverqueue"); + assert.equal(required.Client, river.Client); + assert.equal(required.RiverError, river.RiverError); + const requiredUnstable = require("riverqueue/unstable-driver"); + assert.equal(requiredUnstable.buildUniqueKey, unstable.buildUniqueKey); + for (const name of SATELLITES) { + assert.ok(Object.keys(require(name)).length > 0, `${name} requires`); + } + }); + + it("works from a CommonJS module file", async () => { + const path = join(consumerDirectory, "packed-require-check.cjs"); + await writeFile( + path, + `const river = require("riverqueue"); +const { MigrationError } = require("@riverqueue/migrate"); +if (MigrationError !== river.MigrationError) throw new Error("two River copies"); +const exact = river.parseJson('{"id":9223372036854775807}'); +process.stdout.write(JSON.stringify(exact)); +` + ); + const { stdout } = await execFileAsync(process.execPath, [path]); + assert.equal(stdout, '{"id":9223372036854775807}'); + }); + + it("exposes the unstable driver SPI only on its subpath", async () => { + assert.equal(typeof unstable.buildUniqueKey, "function"); + assert.equal(typeof unstable.decodeJobListCursor, "function"); + assert.equal(typeof unstable.encodeJobListCursor, "function"); + assert.equal("buildUniqueKey" in river, false); + assert.equal("uniqueBitmaskFromStates" in river, false); + await assert.rejects( + import("riverqueue/dist/index.js"), + (error) => error.code === "ERR_PACKAGE_PATH_NOT_EXPORTED" + ); + }); + + it("keeps driver objects opaque", () => { + const driver = SqliteDriver.memory(); + try { + assert.deepEqual(Reflect.ownKeys(driver), []); + assert.deepEqual( + new Set(Reflect.ownKeys(SqliteDriver.prototype)), + new Set(["close", "connect", "constructor", Symbol.dispose]) + ); + for (const name of ["jobInsert", "jobClaim", "database", "backend"]) { + assert.equal(name in driver, false, name); + } + assert.ok(new river.Client(driver) instanceof river.Client); + assert.throws( + () => new river.Client({ jobInsert() {}, jobInsertMany() {} }), + river.ConfigurationError + ); + } finally { + driver.close(); + } + }); + + it("keeps the pilot client base off the stable entry point", async () => { + assert.equal(typeof unstable.PilotClient, "function"); + assert.equal(typeof unstable.registerDriver, "function"); + assert.equal("PilotClient" in river, false); + assert.equal("registerDriver" in river, false); + const declarations = await readFile( + join(dirname(require.resolve("riverqueue")), "index.d.ts"), + "utf8" + ); + assert.equal(/Pilot/.test(declarations), false); + }); + + it("attaches a pilot through a PilotClient subclass", async () => { + class CompanionClient extends unstable.PilotClient { + constructor(driver, options = {}) { + // The pilot is created inside River's constructor, before `this` + // exists, so it records what it receives elsewhere. + const pilot = {}; + super(driver, options, (database) => ({ + init: (host) => { + pilot.host = host; + pilot.database = database; + }, + })); + this.companion = true; + this.host = pilot.host; + this.database = pilot.database; + } + } + const driver = SqliteDriver.memory(); + try { + assert.throws( + () => new unstable.PilotClient(driver, {}, () => ({})), + TypeError + ); + const client = new CompanionClient(driver); + assert.ok(client instanceof river.Client); + assert.equal(client.companion, true); + assert.equal(client.database.backend, "sqlite"); + assert.equal(client.host.client, client); + const job = river.defineJob({ kind: "packed_pilot" }); + const [inserted] = await client.host + .insertPrepared([ + { + encodedArgs: "{}", + kind: job.kind, + maxAttempts: 25, + metadata: {}, + priority: 1, + queue: "default", + scheduledAt: Temporal.Now.instant(), + state: "available", + tags: [], + uniqueKey: null, + uniqueStates: null, + }, + ]) + .catch((error) => [error]); + // The in-memory database has no River tables, so the insert reaches + // SQLite and fails there. + assert.ok(inserted instanceof river.DatabaseOperationError); + } finally { + driver.close(); + } + }); + + it("computes River unique keys identically through the packed SPI", () => { + const params = { + args: river.toJsonObject({ b: 2, a: { z: 1, y: 2 } }), + kind: "packed_unique", + queue: "default", + scheduledAt: Temporal.Instant.from("2026-09-01T12:34:56Z"), + }; + const [key] = unstable.buildUniqueKey(params, { byArgs: true }); + const reordered = { + ...params, + args: river.toJsonObject({ a: { z: 1, y: 2 }, b: 2 }), + }; + const [same] = unstable.buildUniqueKey(reordered, { byArgs: true }); + assert.deepEqual(same, key); + assert.equal(key.length, 32); + }); +}); diff --git a/js/scripts/packed-tests/postgres.test.mjs b/js/scripts/packed-tests/postgres.test.mjs new file mode 100644 index 000000000..6417297e7 --- /dev/null +++ b/js/scripts/packed-tests/postgres.test.mjs @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import { randomBytes } from "node:crypto"; +import process from "node:process"; +import { after, before, describe, it } from "node:test"; + +import { PgDriver } from "@riverqueue/driver-pg"; +import { createMigrator } from "@riverqueue/migrate"; +import pg from "pg"; +import { + Client, + defineJob, + exactJsonNumber, + jsonNumberToBigInt, + Workers, +} from "riverqueue"; + +import { workUntilFinalized } from "./helpers.mjs"; + +const DATABASE_URL = process.env.DATABASE_URL ?? ""; +if (DATABASE_URL === "" && process.env.RIVER_REQUIRE_POSTGRES === "1") { + throw new Error("RIVER_REQUIRE_POSTGRES is set but DATABASE_URL is not"); +} +const INT8_MAX = 9_223_372_036_854_775_807n; + +const accountJob = defineJob({ + kind: "packed_pg_account", + decode: (value) => value, +}); + +describe( + "packed PostgreSQL driver", + { skip: DATABASE_URL === "" && "set DATABASE_URL to run" }, + () => { + // Everything happens in a throwaway schema, so a shared database is safe. + const schema = `river_packed_${randomBytes(6).toString("hex")}`; + let pool; + + before(async () => { + pool = new pg.Pool({ connectionString: DATABASE_URL, max: 4 }); + await pool.query(`CREATE SCHEMA ${schema}`); + const result = await createMigrator({ pool, schema }).migrateUp(); + assert.ok(result.versions.length > 0); + }); + + after(async () => { + await pool.query(`DROP SCHEMA IF EXISTS ${schema} CASCADE`); + await pool.end(); + }); + + it("works exact int64 args end to end", async () => { + const workers = new Workers().add(accountJob, ({ job, recordOutput }) => { + recordOutput({ accountId: job.args.accountId }); + }); + const client = new Client(new PgDriver(pool, { schema }), { + queues: { default: { maxWorkers: 2 } }, + workers, + }); + const { job } = await client.insert(accountJob, { + accountId: exactJsonNumber(INT8_MAX.toString()), + }); + const events = await workUntilFinalized(client, [job.id]); + assert.equal(events.get(job.id)?.kind, "job_completed"); + const row = await client.jobs.get(job.id); + assert.equal(row?.state, "completed"); + assert.equal(jsonNumberToBigInt(row.metadata.output.accountId), INT8_MAX); + const { rows } = await pool.query( + `SELECT args->>'accountId' AS account_id FROM ${schema}.river_job WHERE id = $1`, + [job.id.toString()] + ); + assert.equal(rows[0]?.account_id, INT8_MAX.toString()); + }); + + it("keeps transactional inserts inside the caller's transaction", async () => { + const client = new Client(new PgDriver(pool, { schema })); + const connection = await pool.connect(); + let id; + try { + await connection.query("BEGIN"); + ({ + job: { id }, + } = await client.insert( + accountJob, + { accountId: 1 }, + { tx: connection } + )); + assert.equal( + await client.jobs.get(id), + null, + "invisible before commit" + ); + await connection.query("ROLLBACK"); + } finally { + connection.release(); + } + assert.equal(await client.jobs.get(id), null, "rolled back"); + }); + } +); diff --git a/js/scripts/packed-tests/sqlite.test.mjs b/js/scripts/packed-tests/sqlite.test.mjs new file mode 100644 index 000000000..487ab743f --- /dev/null +++ b/js/scripts/packed-tests/sqlite.test.mjs @@ -0,0 +1,155 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { URL } from "node:url"; + +import { SqliteDriver } from "@riverqueue/driver-sqlite"; +import { createMigrator } from "@riverqueue/migrate"; +import { WorkerThreads } from "@riverqueue/worker-threads"; +import { + Client, + complete, + DatabaseOperationError, + defineJob, + exactJsonNumber, + isExactJsonNumber, + jsonNumberToBigInt, + RiverError, + Workers, +} from "riverqueue"; + +import { workUntilFinalized } from "./helpers.mjs"; + +const INT8_MAX = 9_223_372_036_854_775_807n; + +const accountJob = defineJob({ + kind: "packed_account", + decode(value) { + if ( + !isExactJsonNumber(value.accountId) && + typeof value.accountId !== "number" + ) { + throw new TypeError("accountId must be a JSON number"); + } + return { accountId: value.accountId }; + }, +}); +const threadJob = defineJob({ + kind: "packed_thread_account", + decode: (value) => value, +}); + +async function migratedDriver() { + const driver = SqliteDriver.memory(); + const migrator = createMigrator(driver); + const applied = await migrator.migrateUp(); + assert.ok(applied.versions.length > 0, "migrations were applied"); + return driver; +} + +describe("packed SQLite runtime", () => { + it("inserts, works, and completes jobs with exact int64 args", async () => { + const driver = await migratedDriver(); + try { + const seen = []; + const workers = new Workers().add(accountJob, ({ job, recordOutput }) => { + seen.push(job.args.accountId); + recordOutput({ accountId: job.args.accountId }); + }); + const client = new Client(driver, { + queues: { default: { maxWorkers: 2 } }, + workers, + }); + const accountIds = [INT8_MAX, -INT8_MAX - 1n, 9_007_199_254_740_993n]; + const inserted = await client.insertMany( + accountIds.map((accountId) => ({ + args: { accountId: exactJsonNumber(accountId.toString()) }, + job: accountJob, + })) + ); + const ids = inserted.map((result) => result.job.id); + assert.ok(ids.every((id) => typeof id === "bigint")); + + const events = await workUntilFinalized(client, ids); + for (const [index, id] of ids.entries()) { + assert.equal(events.get(id)?.kind, "job_completed"); + const row = await client.jobs.get(id); + assert.equal(row?.state, "completed"); + assert.equal(jsonNumberToBigInt(row.args.accountId), accountIds[index]); + assert.equal( + jsonNumberToBigInt(row.metadata.output.accountId), + accountIds[index] + ); + } + assert.deepEqual( + seen.map((value) => jsonNumberToBigInt(value)).sort(), + [...accountIds].sort() + ); + } finally { + driver.close(); + } + }); + + it("runs a handler on a worker thread from the packed module", async () => { + const driver = await migratedDriver(); + const executor = new WorkerThreads({ maxThreads: 1 }); + try { + const workers = new Workers().addExecutor( + threadJob, + executor.handler(threadJob, { + exportName: "echoAccount", + module: new URL("./fixtures/thread-handlers.mjs", import.meta.url), + }) + ); + const client = new Client(driver, { + queues: { default: { maxWorkers: 1 } }, + workers, + }); + const { job } = await client.insert(threadJob, { + accountId: exactJsonNumber(INT8_MAX.toString()), + }); + const events = await workUntilFinalized(client, [job.id]); + assert.equal(events.get(job.id)?.kind, "job_completed"); + const row = await client.jobs.get(job.id); + assert.equal(row?.metadata.output.thread, true); + assert.equal(jsonNumberToBigInt(row.metadata.output.accountId), INT8_MAX); + } finally { + await executor.close(); + driver.close(); + } + }); + + it("reports an unmigrated database as a River error", async () => { + const driver = SqliteDriver.memory(); + try { + const client = new Client(driver); + await assert.rejects( + client.insert(accountJob, { accountId: 1 }), + (error) => + error instanceof DatabaseOperationError && error instanceof RiverError + ); + } finally { + driver.close(); + } + }); + + it("completes a job through the documented outcome helpers", async () => { + const driver = await migratedDriver(); + try { + const workers = new Workers().add(accountJob, () => + complete({ output: { done: true } }) + ); + const client = new Client(driver, { + queues: { default: { maxWorkers: 1 } }, + workers, + }); + const { job } = await client.insert(accountJob, { accountId: 7 }); + await workUntilFinalized(client, [job.id]); + assert.deepEqual( + { ...(await client.jobs.get(job.id))?.metadata.output }, + { done: true } + ); + } finally { + driver.close(); + } + }); +}); diff --git a/js/scripts/packed-tests/test-helpers.test.mjs b/js/scripts/packed-tests/test-helpers.test.mjs new file mode 100644 index 000000000..ad28fb2ad --- /dev/null +++ b/js/scripts/packed-tests/test-helpers.test.mjs @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + createTestClient, + requireInserted, + requireNotInserted, + testJob, + workOnce, +} from "@riverqueue/test"; +import { + defineJob, + exactJsonNumber, + isExactJsonNumber, + snooze, + Workers, +} from "riverqueue"; + +const sendEmail = defineJob({ + kind: "packed_send_email", + decode(value) { + if (typeof value.to !== "string") throw new TypeError("to"); + return { accountId: value.accountId ?? null, to: value.to }; + }, +}); + +describe("packed @riverqueue/test helpers", () => { + it("records deterministic insertions and matches them", async () => { + const { client, insertions } = createTestClient({ startingId: 41n }); + await client.insert( + sendEmail, + { + accountId: exactJsonNumber("9223372036854775807"), + to: "a@example.com", + }, + { queue: "email" } + ); + assert.equal(insertions[0]?.job.id, 41n); + const job = requireInserted(insertions, sendEmail, { + args: { to: "a@example.com" }, + queue: "email", + }); + assert.ok(isExactJsonNumber(job.args.accountId)); + assert.equal(job.args.accountId.rawJSON, "9223372036854775807"); + requireNotInserted(insertions, sendEmail, { + args: { to: "b@example.com" }, + }); + assert.throws(() => + requireInserted(insertions, sendEmail, { args: { to: "b@example.com" } }) + ); + }); + + it("works one job through a Workers bundle", async () => { + const workers = new Workers().add(sendEmail, ({ job, recordOutput }) => { + recordOutput({ recipient: job.args.to }); + return snooze({ seconds: 30 }); + }); + const running = await testJob( + sendEmail, + { to: "a@example.com" }, + { id: 7n } + ); + assert.equal(running.id, 7n); + const worked = await workOnce(running, workers); + assert.equal(worked.status, "succeeded"); + assert.equal(worked.outcome?.type, "snooze"); + assert.deepEqual({ ...worked.output }, { recipient: "a@example.com" }); + }); + + it("reports a handler failure without throwing", async () => { + const running = await testJob(sendEmail, { to: "a@example.com" }); + const worked = await workOnce(running, () => { + throw new Error("smtp down"); + }); + assert.equal(worked.status, "failed"); + assert.match(String(worked.error), /smtp down/); + }); +}); From 547ee65f5f77754e8c593162fb02d50df334ad0d Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sun, 4 Oct 2026 17:33:04 -0500 Subject: [PATCH 39/43] keep API reports for every published entry point Generate an `.api.md` report from the built declarations of each published entry point, listing every exported declaration with its TSDoc, so API and documentation changes show up in diffs. API Extractor can't analyze these packages, since its bundled compiler rejects the `ES2025` target and the global `Temporal` types, so the script uses the workspace's own compiler. Generation fails on a "forgotten export", an exported declaration that refers to a type its entry point doesn't export, unless the name is allow-listed with a reason. `api:report` writes the reports and `api:check` fails when they're out of date. --- js/cli/etc/cli.api.md | 77 + js/driver/pg/etc/driver-pg.api.md | 45 + js/driver/prisma/etc/driver-prisma.api.md | 84 + js/driver/sqlite/etc/driver-sqlite.api.md | 162 + js/etc/riverqueue.api.md | 3514 +++++++++++++++++++ js/etc/riverqueue.unstable-driver.api.md | 2566 ++++++++++++++ js/migrate/etc/migrate.api.md | 361 ++ js/package.json | 2 + js/scripts/api-reports.mjs | 373 ++ js/test/etc/test.api.md | 373 ++ js/worker-threads/etc/worker-threads.api.md | 292 ++ 11 files changed, 7849 insertions(+) create mode 100644 js/cli/etc/cli.api.md create mode 100644 js/driver/pg/etc/driver-pg.api.md create mode 100644 js/driver/prisma/etc/driver-prisma.api.md create mode 100644 js/driver/sqlite/etc/driver-sqlite.api.md create mode 100644 js/etc/riverqueue.api.md create mode 100644 js/etc/riverqueue.unstable-driver.api.md create mode 100644 js/migrate/etc/migrate.api.md create mode 100644 js/scripts/api-reports.mjs create mode 100644 js/test/etc/test.api.md create mode 100644 js/worker-threads/etc/worker-threads.api.md 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/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/prisma/etc/driver-prisma.api.md b/js/driver/prisma/etc/driver-prisma.api.md new file mode 100644 index 000000000..64567e244 --- /dev/null +++ b/js/driver/prisma/etc/driver-prisma.api.md @@ -0,0 +1,84 @@ +# API report: `@riverqueue/driver-prisma` + + + +This report contains declarations and TSDoc for names exported by the +package entry point. Private members, unexported implementation +declarations, and file layout are omitted. + +## `PrismaClientLike` + +```ts +/** The raw-query surface shared by Prisma clients and transaction clients. */ +export interface PrismaClientLike { + $queryRawUnsafe(query: string, ...values: unknown[]): Promise; + /** + * Prisma's interactive transaction, present on a root client (not on the + * transaction client it passes to the callback). River uses it to run an + * insertion without `{ tx }` in a transaction of its own, like River for + * Go. + */ + $transaction?( + callback: (tx: PrismaClientLike) => Promise, + options?: PrismaTransactionOptions + ): Promise; +} +``` + +## `PrismaDriver` + +```ts +/** + * River's insertion adapter for Prisma on PostgreSQL. + * + * The Prisma client is caller-owned. A caller-owned transaction client may be + * supplied as `{ tx }` and is used for the exact operation. Schema selection + * belongs to the adapter constructor so it cannot accidentally vary within a + * transaction. + */ +export declare class PrismaDriver { + /** + * Type-only marker: an insert-only driver whose transactions are Prisma + * interactive-transaction clients. `new Client(new PrismaDriver(prisma))` + * is therefore typed as an `InsertClient`. + */ + readonly "~river"?: { + readonly capability: "insert"; + readonly transaction: PrismaClientLike; + }; + constructor(prisma: PrismaClientLike, options?: PrismaDriverOptions); +} +``` + +## `PrismaDriverOptions` + +```ts +/** PostgreSQL configuration owned by the adapter. */ +export interface PrismaDriverOptions { + /** PostgreSQL schema containing River's tables and functions. */ + schema?: string; + /** + * Limits for the interactive transaction River opens for an insertion + * without `{ tx }`, such as `{ timeout: { seconds: 10 } }`. Prisma's + * defaults apply otherwise: a 2 second wait for a connection and a 5 + * second transaction timeout, which also bounds any I/O insert middleware + * awaits before calling `next()`. + */ + transactionOptions?: { + maxWait?: DurationInput; + timeout?: DurationInput; + }; +} +``` + +## `PrismaTransactionOptions` + +```ts +/** Options River passes to Prisma's interactive `$transaction`. */ +export interface PrismaTransactionOptions { + /** Longest wait, in milliseconds, for a connection from Prisma's pool. */ + maxWait?: number; + /** Longest time, in milliseconds, the transaction may stay open. */ + timeout?: number; +} +``` diff --git a/js/driver/sqlite/etc/driver-sqlite.api.md b/js/driver/sqlite/etc/driver-sqlite.api.md new file mode 100644 index 000000000..dcc66c513 --- /dev/null +++ b/js/driver/sqlite/etc/driver-sqlite.api.md @@ -0,0 +1,162 @@ +# API report: `@riverqueue/driver-sqlite` + + + +This report contains declarations and TSDoc for names exported by the +package entry point. Private members, unexported implementation +declarations, and file layout are omitted. + +## `SqliteDriver` + +```ts +/** + * Complete SQLite storage backend on Node's built-in `node:sqlite`. + * + * Like River for Go, River runs on a connection of its own. The driver + * opens a private `DatabaseSync` on the application's database file (in WAL + * mode, with a zero busy timeout) and never hands it out, so application + * statements can't join River's transactions, and River's can't join the + * application's. When another connection or process holds SQLite's write + * lock, River retries with an asynchronous backoff so the event loop keeps + * running. + * + * Pass `{ tx }` to run a River operation in an application transaction: any + * `DatabaseSync` on the same database with a transaction open, such as one + * begun with {@link transaction}. River runs its statements directly in + * that transaction, opening no savepoint, and never ends it. When a River + * call fails, statements it already ran stay in the transaction, so roll + * the transaction back; to recover and continue it instead, wrap the call + * in a savepoint of your own. + * + * An insertion without `{ tx }` runs in a transaction River owns, begun at + * its first statement, which holds SQLite's write lock until it commits. + * Insert middleware and hooks run inside it, so on SQLite they must not + * await I/O after `next()`. When River's transaction is still open at the + * event loop's next turn, River rolls it back, releasing the lock, and fails + * the operation with a `TransactionScopeError`. That check finds most such + * mistakes, including all slow I/O and every insertion started from an I/O + * callback such as an HTTP handler, but not fast local I/O awaited in an + * insertion started from a timer or `setImmediate` callback. + * + * Statements block the event loop while they run. Use a dedicated Node + * process for a heavily loaded worker so database work can't stall an HTTP + * server's event loop. + */ +export declare class SqliteDriver implements Disposable { + /** + * Type-only marker: a full runtime driver whose transactions are + * application handles with a transaction open. + */ + readonly "~river"?: { + readonly capability: "runtime"; + readonly transaction: DatabaseSync; + }; + /** + * Create a driver for the database `database` is open on. River opens its + * own connection to the same file and never uses or closes `database`. + * + * An in-memory database can't be shared between connections this way: + * use {@link SqliteDriver.memory} instead. + * + * @throws {ConfigurationError} when `database` is in memory or closed. + */ + constructor(database: DatabaseSync, options?: SqliteDriverOptions); + /** + * Create a driver for a new, empty in-memory database. + * + * A `:memory:` database belongs to one connection, so River can't open + * its own connection to an application's. This creates a uniquely named + * in-memory database (SQLite's `memdb` VFS) that River's connection and + * application handles share. Get handles with {@link connect}. The + * database lives until the driver and every handle from `connect()` are + * closed. + */ + static memory(options?: SqliteDriverOptions): SqliteDriver; + /** + * Close River's private connection. Stop every client using the driver + * first. Handles from {@link connect} and the one passed to the + * constructor stay open. Closing twice does nothing. + */ + close(): void; + /** + * Open another application handle on this driver's database. The caller + * owns and closes it. + * + * `options` are `node:sqlite`'s. The busy `timeout` defaults to zero, so a + * statement that meets another connection's write lock fails at once + * instead of blocking the event loop. Pass a nonzero `timeout` when other + * processes write the database (see the package README). + */ + connect(options?: DatabaseSyncOptions): DatabaseSync; + /** Same as {@link close}, for `using` declarations. */ + [Symbol.dispose](): void; +} +``` + +## `SqliteDriverOptions` + +```ts +/** SQLite setup owned by the backend, not by the common River client. */ +export interface SqliteDriverOptions { + /** + * Total time a River operation keeps retrying while another connection or + * process holds SQLite's lock, such as `{ seconds: 5 }`. Defaults to 5 + * seconds, the busy timeout River's Go conformance adapter and Rust + * implementation configure. + * + * River's private connection has a zero `busy_timeout`, so SQLite never + * blocks the event loop waiting for a lock. Between attempts River waits + * asynchronously with exponential backoff, so timers and I/O keep + * running. When the bound is exceeded the operation fails with a + * `DatabaseOperationError` whose `retryable` is `true`. + */ + busyTimeout?: DurationInput; +} +``` + +## `SqliteTransactionOptions` + +```ts +/** Options for {@link transaction}. */ +export interface SqliteTransactionOptions { + /** + * Total time to keep retrying `BEGIN IMMEDIATE`, and `COMMIT`, while + * another connection holds SQLite's write lock, such as `{ seconds: 5 }`. + * Retries wait asynchronously, so the event loop keeps running. Defaults + * to 5 seconds. + */ + busyTimeout?: DurationInput; +} +``` + +## `transaction` + +```ts +/** + * Run `callback` in an immediate transaction on an application handle, + * committing when it resolves and rolling back when it throws or rejects. + * + * Pass `database` to River as `{ tx }` inside the callback to insert jobs + * that commit or roll back with the application's rows. The callback may + * await freely: River runs on its own connection and waits for the + * transaction to end. + * + * `BEGIN IMMEDIATE` takes SQLite's write lock up front. While another + * connection or process holds it, the helper retries with an asynchronous + * backoff rather than blocking the event loop, and fails with a retryable + * `DatabaseOperationError` after `busyTimeout`. + * + * Transactions on one database don't nest. SQLite has a single writer, so + * calling `transaction` on a handle that already has a transaction open, + * inside another `transaction` callback on the same database, or inside + * River's own transaction on it once River holds the write lock (insert + * middleware after `next()`, `afterInsert` hooks) could only fail after + * `busyTimeout`. It fails at once with a `TransactionScopeError` instead. + * Transactions on different databases may nest. + */ +export declare function transaction( + database: DatabaseSync, + callback: (database: DatabaseSync) => T | PromiseLike, + options?: SqliteTransactionOptions +): Promise; +``` diff --git a/js/etc/riverqueue.api.md b/js/etc/riverqueue.api.md new file mode 100644 index 000000000..0f0b78f83 --- /dev/null +++ b/js/etc/riverqueue.api.md @@ -0,0 +1,3514 @@ +# API report: `riverqueue` + + + +This report contains declarations and TSDoc for names exported by the +package entry point. Private members, unexported implementation +declarations, and file layout are omitted. + +## `assertRuntimeSupport` + +```ts +/** + * Verify the runtime features River relies on before database work begins. + * + * Official Node.js 26 builds expose Temporal by default. This explicit check + * gives custom builds and unsupported runtimes a useful failure instead of a + * later `ReferenceError` or a lossy timestamp fallback. + */ +export declare function assertRuntimeSupport(): void; +``` + +## `AttemptError` + +```ts +/** A failed work attempt persisted with a job. */ +export interface AttemptError { + readonly at: Temporal.Instant; + readonly attempt: number; + readonly error: string; + readonly trace: string; +} +``` + +## `AttemptErrorJson` + +```ts +/** JSON-safe form of {@link AttemptError}. */ +export interface AttemptErrorJson extends JsonObject { + at: string; + attempt: number; + error: string; + trace: string; +} +``` + +## `BackendMismatchError` + +```ts +/** An operation received a transaction belonging to another backend. */ +export declare class BackendMismatchError extends RiverError<"backend_mismatch"> { + /** Backend whose operation rejected the transaction. */ + readonly backend: string; + constructor( + backend: string, + message: string, + options?: RiverErrorSubclassOptions + ); +} +``` + +## `cancel` + +```ts +/** + * Construct an outcome that cancels the job permanently, like Go's + * `river.JobCancel`. The job moves to `cancelled` without another retry and + * `JobCancelError: ` is recorded as the attempt error. + */ +export declare function cancel(options?: { + readonly reason?: string; +}): CancelOutcome; +``` + +## `CancelOutcome` + +```ts +/** + * Permanent cancellation without another retry, like Go's `river.JobCancel`. + * The reason is recorded as the attempt error. + */ +export interface CancelOutcome { + readonly reason?: string; + readonly type: "cancel"; +} +``` + +## `CheckedInsertManyItems` + +```ts +/** Checks each batch item's args against its own item's definition. */ +export type CheckedInsertManyItems = { + readonly [Index in keyof Items]: Items[Index] extends { + readonly job: infer Definition extends JobDefinition; + } + ? Omit & { + readonly args: JobDefinitionInput; + } + : never; +}; +``` + +## `Client` + +````ts +/** + * A River client: typed job insertion plus, for drivers that support it, job + * and queue operations and the worker runtime. + * + * `Transaction` is the driver's caller-owned transaction type (for example a + * node-postgres client); it is inferred from the driver passed to + * `new Client(driver)`. + */ +export interface Client< + Transaction = unknown, +> extends InsertClient { + /** Job queries and controls. */ + readonly jobs: JobOperations; + /** + * Dynamically configurable leader-owned periodic jobs. Modifying them throws + * a {@link ConfigurationError} when the client was created with + * `leaderElectionDisabled: true`, because it never leads. + */ + readonly periodicJobs: PeriodicJobs; + /** Queue queries and controls. */ + readonly queues: QueueOperations; + /** + * Ask whichever client currently leads maintenance to resign so another + * can take over, optionally when a caller-owned transaction commits. + */ + requestLeadershipResignation( + options?: TransactionOptions + ): Promise; + /** + * Start the worker runtime: queues, workers, notifications, and (when + * elected leader) maintenance. A client starts at most once; stop it with + * the returned handle or `await using`. + */ + start(): Promise; + /** + * Subscribe to bounded job, queue, leadership, and maintenance events, + * emitted after their database transitions commit. Filtering by `kinds` + * narrows the yielded event type. + * + * @example + * ```ts + * using failures = client.subscribe({ kinds: ["job_failed"] }); + * for await (const event of failures) { + * if (event.kind === "job_failed") report(event.job, event.error); + * } + * ``` + */ + subscribe( + options?: SubscribeOptions + ): EventSubscription>; +} + +/** + * Create a River client from a driver. + * + * @example + * ```ts + * const client = new Client(new PgDriver(pool), { + * queues: { default: { maxWorkers: 50 } }, + * workers, + * }); + * ``` + */ +export declare const Client: ClientConstructor; +```` + +## `ClientConstructor` + +```ts +/** + * Constructor for {@link Client}. + * + * A driver that supports the worker runtime, such as `PgDriver` or + * `SqliteDriver`, produces a full {@link Client}. An insert-only driver such as + * `PrismaDriver` produces an {@link InsertClient}, so runtime-only calls fail at + * compile time (and with {@link UnsupportedCapabilityError} from untyped + * JavaScript). + */ +export interface ClientConstructor { + /** Create a client for a driver that supports the worker runtime. */ + new ( + driver: ClientDriver, + options?: ClientOptions + ): Client; + /** Create an insert-only client for an insertion-only driver. */ + new ( + driver: ClientDriver, + options?: ClientOptions + ): InsertClient; + readonly prototype: Client; +} +``` + +## `ClientDriver` + +```ts +/** + * Type-level description of a River driver, used by `new Client(driver)` to + * infer the driver's transaction type and whether it supports the worker + * runtime. Applications never implement it; first-party drivers declare the + * marker with `declare readonly "~river"` so it has no runtime cost. + */ +export interface ClientDriver< + Transaction = unknown, + Capability extends DriverCapability = DriverCapability, +> { + /** Type-only marker. It is never set at runtime. */ + readonly "~river"?: + | { + readonly capability: Capability; + readonly transaction: Transaction; + } + | undefined; +} +``` + +## `ClientOptions` + +```ts +/** Options for constructing a River client. */ +export interface ClientOptions { + /** + * Stable identifier of this client, recorded in `attempted_by` and used + * for leadership. Defaults to a random value. + */ + readonly clientId?: string; + /** Maximum completions persisted in one query. Defaults to 1,000. */ + readonly completionBatchSize?: number; + /** + * How long a partly filled completion batch waits for more completions. + * Zero flushes immediately. + */ + readonly completionFlushInterval?: DurationInput; + /** Defaults below job-definition defaults and call-site options. */ + readonly defaultInsertOptions?: InsertOptions; + /** Invoked once for each failed attempt; may request cancellation. */ + readonly errorHandler?: RiverErrorHandler; + /** Event-loop delay monitoring; `false` disables it. */ + readonly eventLoopDelay?: false | EventLoopDelayOptions; + /** + * Minimum time between claim queries for queues that don't set their own + * `fetchCooldown`, like River for Go's `Config.FetchCooldown`. Defaults to + * 100 milliseconds and must be at least 1 millisecond. + * + * It also limits insert notifications: after this client notifies + * producers of a queue's new jobs, it sends no other notification for that + * queue until the cooldown passes. Producers fetch at most this often + * anyway, and poll for jobs whose notification was suppressed. + */ + readonly fetchCooldown?: DurationInput; + /** + * Claim only jobs whose kinds have workers in `workers`, like River for + * Go's `Config.FetchOnlyKnownKinds`. Defaults to false. Jobs of other kinds + * stay available without using an attempt, so clients with different + * workers can share a queue, such as while moving job kinds from one + * language to another. The kinds are those registered when the client + * starts. + * + * This affects only claiming. A leader's rescuer still handles stuck jobs + * of every queue and discards those whose kinds it doesn't know, so a + * client with some of the kinds should set `leaderElectionDisabled`, and + * another eligible client should have workers for every kind. Without + * this option, a job of an unknown kind is claimed and fails with an + * unknown job kind error. + */ + readonly fetchOnlyKnownKinds?: boolean; + /** Client-wide hooks, run after plugin hooks. */ + readonly hooks?: RiverHooks; + /** Client-wide insert middleware, run after plugin middleware. */ + readonly insertMiddleware?: readonly InsertMiddleware[]; + /** + * How long River waits after an attempt's timeout, or after it asks a + * running handler to stop, before treating the attempt as stuck, like + * River for Go's `Config.JobStuckThreshold`. Defaults to 10 seconds and + * must not be negative. + * + * A handler still running this long after its timeout is reported stuck + * and passed to `stuckHandler`. An executor that can end a handler by + * force, such as `@riverqueue/worker-threads`, waits this long after + * aborting a handler's signal before terminating it. + */ + readonly jobStuckThreshold?: DurationInput; + /** + * Default cooperative timeout for each job attempt. Defaults to 1 minute; + * `null` disables it. A worker's own `timeout` takes precedence. + */ + readonly jobTimeout?: DurationInput | null; + /** + * Keep this client out of leader election, like River for Go's + * `Config.LeaderElectionDisabled`. Defaults to false. The client never runs + * leader-owned maintenance or inserts periodic jobs, but still works jobs + * from its configured queues, including periodic jobs other clients insert. + * + * At least one other started client on the same database and schema must + * remain eligible to lead for scheduled jobs, retries, periodic jobs, + * stuck-job rescue, and cleanup to progress. A client with leader election + * disabled never leads, even when no other client is running. + * `periodicJobs` must be empty, and `maintenance` settings have no effect. + */ + readonly leaderElectionDisabled?: boolean; + /** + * Structured logger with pino's `(attributes, message)` argument order. + * Defaults to `console` for warnings and errors; `false` silences River. + */ + readonly logger?: Logger | false; + /** Settings for leader-owned maintenance. */ + readonly maintenance?: MaintenanceOptions; + /** Client-wide work middleware, wrapping every handler. */ + readonly middleware?: readonly WorkMiddleware[]; + /** + * Periodic jobs the leader inserts; see `periodicJob`. Must be empty when + * `leaderElectionDisabled` is true. + */ + readonly periodicJobs?: readonly PeriodicJob[]; + /** Named collections of hooks and middleware. */ + readonly plugins?: readonly RiverPlugin[]; + /** + * Disable notification streams and rely on polling alone. Running jobs + * then learn of cancellations by polling every `queueControlPollInterval`. + * A client of a PostgreSQL server without `LISTEN`/`NOTIFY`, such as + * YugabyteDB without `yb_enable_listen_notify`, polls this way on its own. + */ + readonly pollOnly?: boolean; + /** + * How often persisted queue pauses and resumes are polled, and, for a + * client without notifications, its running jobs' cancellations. + */ + readonly queueControlPollInterval?: DurationInput; + /** How often this client reports its configured queues. */ + readonly queueHeartbeatInterval?: DurationInput; + /** Queues this client works, keyed by name. */ + readonly queues?: Readonly>; + /** Override retry scheduling; invalid times fall back to River's default. */ + readonly retryPolicy?: RetryPolicy; + /** Policy invoked after a timed-out attempt exceeds `jobStuckThreshold`. */ + readonly stuckHandler?: JobStuckHandler; + /** Job handlers worked by `client.start()`. */ + readonly workers?: Workers; +} +``` + +## `complete` + +```ts +/** Construct an explicit successful-completion outcome. */ +export declare function complete(options?: { + readonly output?: JsonValue; +}): CompleteOutcome; +``` + +## `CompleteOutcome` + +```ts +/** Explicit successful completion, optionally recording JSON output. */ +export interface CompleteOutcome { + readonly output?: JsonValue; + readonly type: "complete"; +} +``` + +## `ConfigurationError` + +```ts +/** An invalid static client, job, driver, or worker configuration. */ +export declare class ConfigurationError extends RiverError<"configuration"> { + constructor(message: string, options?: RiverErrorSubclassOptions); +} +``` + +## `consoleLogger` + +```ts +/** The default logger: warnings and errors to `console`, nothing else. */ +export declare const consoleLogger: Logger; +``` + +## `cron` + +````ts +/** + * Parse a standard cron expression into a periodic schedule that fires at + * exactly the times River Go would. + * + * River Go documents robfig/cron's `ParseStandard` syntax, and this is a + * faithful port of that parser and its `Next` algorithm, so one expression + * string behaves the same whichever language holds leadership: + * + * - five fields: minute (0-59), hour (0-23), day of month (1-31), month + * (1-12 or `jan`-`dec`), and day of week (0-6 from Sunday, or + * `sun`-`sat`); names are case-insensitive and `7` is not Sunday; + * - `*` or `?` for every value, lists (`1,15`), ranges (`9-17`), and steps + * on wildcards, values, or ranges (`5/15` is `5-59/15`); + * - when both day of month and day of week are restricted, a day matching + * either one fires; when either is `*` or `?`, both must match. A wildcard + * with a step greater than one counts as restricted; + * - the descriptors `@yearly` (or `@annually`), `@monthly`, `@weekly`, + * `@daily` (or `@midnight`), and `@hourly`; + * - `@every ` with Go's duration syntax (`1h30m`, `1.5h`, `90s`), + * measured from the previous occurrence, truncated to whole seconds, and + * at least one second; + * - a leading `CRON_TZ=` or `TZ=` naming an IANA time zone. + * + * Seconds fields, `L`, `W`, `#`, and `@reboot` are rejected, as in Go. + * + * Occurrences follow wall-clock time in the schedule's time zone, including + * robfig's handling of daylight saving time: a time skipped by a transition + * does not fire that day, and a time repeated by one can fire twice. + * + * The time zone is the expression's `CRON_TZ=` prefix, else + * `options.timeZone`, else the process's local time zone, matching River Go, + * which evaluates unprefixed expressions in `time.Local`. Periodic jobs run + * on whichever client holds leadership, so every process in a fleet, + * including Go and Rust ones, must resolve the same zone. Pin it explicitly, + * preferably with a prefix such as `CRON_TZ=UTC` that every language reads + * from the shared expression, or with `options.timeZone`. + * + * @param expression - A standard cron expression or descriptor. + * @param options - Optional default time zone. + * @returns A schedule for {@link periodicJob}'s `schedule` option. + * @throws ConfigurationError when the expression is not valid River Go cron + * syntax or the time zone is unknown. The schedule's `next()` also throws + * it when an occurrence would have to cross a calendar day the zone + * skipped, as Samoa skipped 2011-12-30; River Go never returns there. + * + * @example + * ```ts + * const weekdayReport = periodicJob({ + * args: { scope: "all" }, + * id: "weekday_report", + * job: buildReport, + * // 09:00 New York time, Monday through Friday. + * schedule: cron("CRON_TZ=America/New_York 0 9 * * mon-fri"), + * }); + * ``` + */ +export declare function cron( + expression: string, + options?: CronOptions +): CronSchedule; +```` + +## `CronOptions` + +```ts +/** Options for {@link cron}. */ +export interface CronOptions { + /** + * Time zone the expression is evaluated in when it has no `CRON_TZ=` or + * `TZ=` prefix: an IANA name such as `"America/Chicago"`, `"UTC"`, or a + * fixed offset such as `"+05:30"`. Defaults to the process's local time + * zone, `Temporal.Now.timeZoneId()`, which is what River Go uses. + */ + readonly timeZone?: string; +} +``` + +## `CronSchedule` + +```ts +/** A standard cron schedule created by {@link cron}. */ +export interface CronSchedule extends PeriodicSchedule { + /** The expression the schedule was parsed from. */ + readonly expression: string; + /** + * Time zone occurrences are computed in: the expression's `CRON_TZ=` + * prefix, else {@link CronOptions.timeZone}, else the process's local + * zone when the schedule was created. + */ + readonly timeZone: string; +} +``` + +## `currentWorkContext` + +```ts +/** + * Return the context of the job attempt the caller is running inside, or + * undefined outside a job. River establishes it with `AsyncLocalStorage`, so + * code called from a handler can reach the job without passing `ctx` along. + */ +export declare function currentWorkContext(): CurrentWorkContext | undefined; +``` + +## `CurrentWorkContext` + +```ts +/** The work context `currentWorkContext()` returns inside a job. */ +export type CurrentWorkContext = WorkAttemptContext | WorkContext; +``` + +## `DatabaseOperationError` + +```ts +/** + * A database operation failed. + * + * The message never contains SQL text, bound values, or credentials; the + * underlying driver error is available as `cause`. `retryable` is true when + * the failure is transient (a lost connection, serialization failure, lock or + * statement timeout, or a busy SQLite database), so the whole operation can + * safely be attempted again. + */ +export declare class DatabaseOperationError extends RiverError<"database"> { + /** Backend that failed, such as `"postgres"` or `"sqlite"`. */ + readonly backend: string; + /** River operation that failed, such as `"jobInsert"`. */ + readonly operation: string; + constructor(message: string, options: DatabaseOperationErrorOptions); +} +``` + +## `DatabaseOperationErrorOptions` + +```ts +/** Options for {@link DatabaseOperationError}. */ +export interface DatabaseOperationErrorOptions extends RiverErrorSubclassOptions { + /** Backend that failed, such as `"postgres"` or `"sqlite"`. */ + backend: string; + /** River operation that failed, such as `"jobInsert"`. */ + operation: string; +} +``` + +## `DecodedJobInput` + +```ts +/** + * The producer input type for a definition whose decoder returns `Args`: + * `Args` itself when it is a JSON object, otherwise a type error asking for an + * explicit producer type. + */ +export type DecodedJobInput = [Args] extends [JsonCompatible] + ? Args extends readonly unknown[] + ? JobDefinitionTypeError<"job args must be a JSON object, not an array"> + : Args extends object + ? Args + : JobDefinitionTypeError<"job args must be a JSON object"> + : JobDefinitionTypeError<"decode() returns values that are not JSON; declare the producer input with defineJob()({ ... })">; +``` + +## `DecoderJobDefinitionConfig` + +```ts +/** A job definition whose arguments are validated by an explicit decoder. */ +export interface DecoderJobDefinitionConfig< + Args, + Kind extends string = string, +> extends JobDefinitionOptions { + /** + * Validate the persisted JSON arguments and return the worker's args. + * Throw to reject them. River calls it when inserting and before working. + */ + readonly decode: (value: JsonObject) => Args | PromiseLike; + readonly schema?: never; +} +``` + +## `defineJob` + +````ts +/** + * Define a job validated by a Standard Schema (Zod, Valibot, ArkType, ...). + * + * Producers pass the schema's input type; workers receive its output type. + * The schema's input must be JSON: River persists exactly what the producer + * passed so other languages can read it and unique hashes stay stable. + * + * @example + * ```ts + * import * as v from "valibot"; + * + * export const sendEmail = defineJob({ + * kind: "send_email", + * schema: v.object({ to: v.pipe(v.string(), v.email()) }), + * }); + * ``` + */ +export declare function defineJob< + const Schema extends StandardSchemaV1, + const Kind extends string, +>( + config: SchemaJobDefinitionConfig, Kind> +): JobDefinition< + SchemaJobInput extends object ? SchemaJobInput : never, + StandardSchemaOutput, + Kind +>; + +/** + * Define a job validated by an explicit decoder. + * + * The decoder receives the untrusted persisted JSON object and returns the + * worker's args. Producers insert values of the decoder's return type, which + * must therefore be JSON; to insert a different type, declare it with + * `defineJob()({ ... })`. + * + * @example + * ```ts + * export const resizeImage = defineJob({ + * kind: "resize_image", + * decode(value) { + * if (typeof value.url !== "string") throw new TypeError("url required"); + * return { url: value.url }; + * }, + * }); + * ``` + */ +export declare function defineJob( + config: DecoderJobDefinitionConfig +): JobDefinition>, Awaited, Kind>; + +/** + * Declare a job's producer input type explicitly, then define it. + * + * With a decoder, workers receive the decoder's return type. Without one, + * workers receive `JsonObject`: a type argument alone never makes persisted + * input from another producer appear validated. + * + * @example + * ```ts + * interface ReportInput { + * reportId: string; + * } + * + * export const buildReport = defineJob()({ + * kind: "build_report", + * decode(value) { + * if (typeof value.reportId !== "string") { + * throw new TypeError("reportId must be a string"); + * } + * return { reportId: value.reportId, requestedAt: Temporal.Now.instant() }; + * }, + * }); + * ``` + */ +export declare function defineJob(): [ + Input, +] extends [JsonCompatible] + ? DefineJobWithInput + : JobDefinitionTypeError<"the declared producer input must be JSON (no Date, bigint, undefined, Map, or class values)">; + +/** + * Define a job without runtime validation. Producers insert any JSON object + * and workers receive `JsonObject` args. + */ +export declare function defineJob( + config: UncheckedJobDefinitionConfig +): JobDefinition; +```` + +## `DefineJobWithInput` + +```ts +/** Second step of `defineJob()`, with the producer type fixed. */ +export interface DefineJobWithInput { + /** Define a job whose decoder returns the worker's args. */ + ( + config: DecoderJobDefinitionConfig + ): JobDefinition, Kind>; + /** Define a job whose workers receive unvalidated `JsonObject` args. */ + ( + config: UncheckedJobDefinitionConfig + ): JobDefinition; +} +``` + +## `discard` + +```ts +/** Construct an explicit discard outcome. */ +export declare function discard(options?: { + readonly reason?: string; +}): DiscardOutcome; +``` + +## `DiscardOutcome` + +```ts +/** Explicit discard without another retry. */ +export interface DiscardOutcome { + readonly reason?: string; + readonly type: "discard"; +} +``` + +## `DriverCapability` + +```ts +/** Whether a driver supports only insertion or the full worker runtime. */ +export type DriverCapability = "insert" | "runtime"; +``` + +## `DurablePeriodicJob` + +```ts +/** A durable periodic job record reported by a periodic job store. */ +export interface DurablePeriodicJob { + readonly createdAt: Temporal.Instant; + readonly id: string; + readonly nextRunAt: Temporal.Instant; + readonly updatedAt: Temporal.Instant; +} +``` + +## `DurationInput` + +```ts +/** A duration River accepts: `Temporal.Duration` or a like such as `{ seconds: 5 }`. */ +export type DurationInput = Temporal.Duration | Temporal.DurationLike; +``` + +## `ErrorHandlerContext` + +```ts +/** Attempt context visible after work extension processing has finished. */ +export type ErrorHandlerContext = Omit; +``` + +## `ErrorHandlerResult` + +```ts +/** What an {@link RiverErrorHandler} may ask River to do with a failed job. */ +export interface ErrorHandlerResult { + readonly cancel?: boolean; +} +``` + +## `EventLoopDelayEvent` + +```ts +/** The event loop was delayed past the configured threshold. */ +export interface EventLoopDelayEvent extends RiverEventBase<"runtime_event_loop_delay"> { + /** The delay measured over the reporting interval. */ + readonly eventLoopDelay: EventLoopDelayObservation; +} +``` + +## `EventLoopDelayObservation` + +```ts +/** Event-loop delay measured over one reporting interval. */ +export interface EventLoopDelayObservation { + /** Whether the longest delay reached the warning threshold. */ + readonly exceededThreshold: boolean; + /** Longest delay. */ + readonly max: Temporal.Duration; + readonly mean: Temporal.Duration; + /** 99th-percentile delay. */ + readonly p99: Temporal.Duration; +} +``` + +## `EventLoopDelayOptions` + +```ts +/** Event-loop delay monitoring, enabled by default. */ +export interface EventLoopDelayOptions { + /** How often to report `runtime_event_loop_delay` events. */ + readonly reportInterval?: DurationInput; + /** Sampling resolution of the delay histogram. */ + readonly resolution?: DurationInput; + /** Delay above which River logs a warning. */ + readonly warningThreshold?: DurationInput; +} +``` + +## `EventSubscription` + +```ts +/** + * A bounded, explicitly disposable async iterable of events emitted after + * their database transitions commit. Close it with `close()`, `using`, + * `await using`, or by breaking out of a `for await` loop. + */ +export declare class EventSubscription + implements AsyncIterable, AsyncIterator, Disposable +{ + [Symbol.asyncIterator](): AsyncIterator; + [Symbol.asyncDispose](): Promise; + [Symbol.dispose](): void; + /** Stop delivering events and release the subscription's listeners. */ + close(): void; + /** Wait for the next event, or `done` once closed. */ + next(): Promise>; + /** Close the subscription; called when a `for await` loop exits early. */ + return(): Promise>; +} +``` + +## `exactJsonNumber` + +```ts +/** Construct an exact JSON number from one valid JSON numeric token. */ +export declare function exactJsonNumber(source: string): ExactJsonNumber; +``` + +## `ExactJsonNumber` + +```ts +/** + * An exact JSON number represented by Node's immutable raw-JSON primitive. + * + * River returns this representation when a persisted number cannot round-trip + * through JavaScript `number` without changing its JSON value. Read the exact + * token through {@link rawJSON}; pass it back to River or `JSON.stringify` + * without losing precision. + */ +export interface ExactJsonNumber { + readonly rawJSON: string; +} +``` + +## `ExecutorWorkerRegistration` + +```ts +/** A handler owned by an optional executor integration. */ +export interface ExecutorWorkerRegistration< + Definition extends JobDefinition = JobDefinition, +> extends WorkerRegistrationBase { + readonly target: WorkExecutorTarget; + readonly type: "executor"; +} +``` + +## `ExtensionError` + +```ts +/** A work middleware, hook, or plugin failed. */ +export declare class ExtensionError extends RiverError<"extension"> { + constructor(message: string, options?: RiverErrorSubclassOptions); +} +``` + +## `InProcessWorkerRegistration` + +```ts +/** An ordinary handler that executes on the River runtime's event loop. */ +export interface InProcessWorkerRegistration< + Definition extends JobDefinition = JobDefinition, +> extends WorkerRegistrationBase { + readonly handler: WorkHandler; + readonly type: "in_process"; +} +``` + +## `InsertClient` + +```ts +/** + * Job insertion, available with every driver including insert-only ones such + * as `@riverqueue/driver-prisma`. Type producer-only code against this + * interface so it accepts full clients and test clients alike. + */ +export interface InsertClient { + /** + * Validate and insert one job, optionally in a caller-owned transaction. + * + * Resolves with `status: "duplicate"` and the existing row when a unique + * job already exists. + */ + insert( + definition: Definition, + args: JobDefinitionInput, + options?: InsertOptions & TransactionOptions + ): Promise>>; + /** + * Insert a heterogeneous batch atomically, preserving input order in the + * result tuple. An empty batch resolves to `[]` without a database call. + */ + insertMany( + items: Items & CheckedInsertManyItems, + options?: TransactionOptions + ): Promise>; +} +``` + +## `InsertContext` + +```ts +/** What an {@link InsertMiddleware} sees about the insertion it wraps. */ +export interface InsertContext { + readonly operation: "insert" | "insertMany"; + /** Immutable application-level insertion requests in call order. */ + readonly requests: readonly InsertRequest[]; +} +``` + +## `InsertManyItem` + +```ts +/** One plain-object item in an insertMany call. */ +export interface InsertManyItem< + Definition extends JobDefinition = JobDefinition, +> { + readonly args: JobDefinitionInput; + readonly job: Definition; + readonly options?: InsertOptions; +} +``` + +## `InsertManyResults` + +```ts +/** Exact result tuple corresponding to a heterogeneous insertion tuple. */ +export type InsertManyResults = { + readonly [Index in keyof Items]: Items[Index] extends { + readonly job: infer Definition extends JobDefinition; + } + ? InsertResult> + : never; +}; +``` + +## `InsertMiddleware` + +```ts +/** + * Wraps every insertion, like Koa middleware: call `next()` once to insert + * and return (or adjust) its results. + * + * As in River for Go, an insertion without `{ tx }` runs in one transaction + * River owns, from argument validation through every middleware and hook to + * the write, so an error thrown anywhere, even after `next()` returns, rolls + * the jobs back. With `{ tx }`, it runs in the caller's transaction, which + * the caller commits or rolls back. + * + * On SQLite, River holds the database's write lock from the write until it + * commits, so middleware must not await I/O after `next()` returns, and + * `afterInsert` hooks must not await I/O at all. River detects most such + * insertions and fails them, but not every one: see the SQLite driver's + * documentation. Do the I/O before calling `next()`, or react to committed + * jobs with `client.subscribe`. + */ +export type InsertMiddleware = ( + context: InsertContext, + next: () => Promise +) => PromiseLike; +``` + +## `InsertOptions` + +```ts +/** Options that may be supplied at any insertion-default level. */ +export interface InsertOptions { + /** + * Delay from insertion time before the job becomes eligible to run, such as + * `{ minutes: 5 }`. Mutually exclusive with `scheduledAt` at the same level; + * a call-site `delay` overrides a definition-level `scheduledAt` and vice + * versa. Calendar units (years, months, weeks) are rejected; a day is 24 + * hours. + */ + delay?: DurationInput; + /** Maximum total attempts, including the first attempt. */ + maxAttempts?: number; + /** Application metadata stored with the job. */ + metadata?: JsonObject; + /** Insert the job as pending rather than immediately available. */ + pending?: boolean; + /** Priority from 1 (highest) through 4 (lowest). */ + priority?: number; + /** Queue on which the job will be worked. */ + queue?: string; + /** + * Absolute time at which the job becomes eligible to run. A `Date` is + * converted to a `Temporal.Instant`; persisted rows always use `Instant`. + */ + scheduledAt?: Date | Temporal.Instant; + /** Tags used to group and query jobs. */ + tags?: readonly string[]; + /** Dimensions used to deduplicate jobs. */ + unique?: UniqueOptions; +} +``` + +## `InsertRequest` + +```ts +/** Immutable application-level view of an insertion request. */ +export interface InsertRequest { + readonly args: JsonObject; + /** + * The job definition the caller inserted (the same object identity), or + * undefined for an insertion without one. + */ + readonly definition: JobDefinition | undefined; + readonly kind: string; + readonly maxAttempts: number; + readonly metadata: JsonObject; + readonly priority: number; + readonly queue: string; + /** + * When the job becomes workable. For a job inserted without a schedule, + * when the insertion was requested: like River for Go, the database stores + * its own current time for such a job. + */ + readonly scheduledAt: Temporal.Instant; + readonly state: JobState; + readonly tags: readonly string[]; + readonly unique: boolean; +} +``` + +## `InsertResult` + +```ts +export interface InsertResult { + /** Inserted row, or the conflicting row for a duplicate unique job. */ + readonly job: JobRow; + /** Whether River inserted a new row or returned a unique conflict. */ + readonly status: "duplicate" | "inserted"; +} +``` + +## `isExactJsonNumber` + +```ts +export declare function isExactJsonNumber( + value: unknown +): value is ExactJsonNumber; +``` + +## `isJobDefinition` + +```ts +/** Whether a value was created by {@link defineJob}. */ +export declare function isJobDefinition(value: unknown): value is JobDefinition; +``` + +## `isJsonNumber` + +```ts +/** + * Whether a value is a JSON number: an ordinary `number` or an + * {@link ExactJsonNumber} that River uses for numbers JavaScript cannot + * represent exactly (such as a 64-bit ID written by another language). + * + * Prefer validating args with a schema; use this when reading untyped + * `JsonObject` values, where `typeof value === "number"` misses exact numbers. + */ +export declare function isJsonNumber( + value: unknown +): value is ExactJsonNumber | number; +``` + +## `isRetryableError` + +```ts +/** Whether retrying a failed River operation is explicitly safe. */ +export declare function isRetryableError( + error: unknown +): error is RiverError & { + readonly retryable: true; +}; +``` + +## `Job` + +```ts +/** A worked job with decoded args and transformed JSON before decoding. */ +export type Job = Omit< + JobRow, + "args" +> & { + readonly args: JobDefinitionArgs; + readonly rawArgs: JsonObject; +}; +``` + +## `JOB_STATE` + +```ts +/** Persisted River job states. */ +export declare const JOB_STATE: { + readonly available: "available"; + readonly cancelled: "cancelled"; + readonly completed: "completed"; + readonly discarded: "discarded"; + readonly pending: "pending"; + readonly retryable: "retryable"; + readonly running: "running"; + readonly scheduled: "scheduled"; +}; +``` + +## `JobAbortedError` + +```ts +/** + * The error an attempt fails with when its handler ignored the aborted + * `signal` of a stopping client, such as `stop({ mode: "cancel" })`, until + * its executor ended it by force after the client's `jobStuckThreshold`, + * such as by terminating its worker thread. Unlike a handler that stops + * because of the abort, the attempt counts, and the retry policy and + * `maxAttempts` apply. `cause` is the abort reason the handler ignored. + */ +export declare class JobAbortedError extends RiverError<"job_aborted"> { + readonly jobId: bigint; + constructor(jobId: bigint, options?: ErrorOptions); +} +``` + +## `JobAttemptFinishedError` + +```ts +/** + * Abort reason delivered to a handler's `signal` once its attempt finished, + * like River for Go cancelling a job's context when its executor returns, + * so work the handler left running stops instead of outliving the attempt. + */ +export declare class JobAttemptFinishedError extends RiverError<"job_attempt_finished"> { + readonly jobId: bigint; + constructor(jobId: bigint); +} +``` + +## `JobCancelledError` + +```ts +/** + * Abort reason delivered to a handler's `signal` when its job is cancelled + * remotely, for example with `client.cancel(id)` from any River client. + */ +export declare class JobCancelledError extends RiverError<"job_cancelled"> { + readonly jobId: bigint; + constructor(jobId: bigint); +} +``` + +## `JobCancelledEvent` + +```ts +/** A job was cancelled, by its handler or remotely. */ +export interface JobCancelledEvent extends RiverEventBase<"job_cancelled"> { + /** The attempt's error, when it failed as it was cancelled. */ + readonly error?: unknown; + /** The job as of the transition. */ + readonly job: JobRow; +} +``` + +## `JobCompletedEvent` + +```ts +/** A job's attempt succeeded and the job is `completed`. */ +export interface JobCompletedEvent extends RiverEventBase<"job_completed"> { + /** The job as of the transition. */ + readonly job: JobRow; +} +``` + +## `JobDefinition` + +```ts +/** + * Immutable identity and type information for one job kind. + * + * `Input` is what producers pass to `client.insert`; `Args` is what the worker + * receives after validation. Definitions contain no client, pool, or handler, + * so web producers can import them without worker dependencies. + */ +export interface JobDefinition< + Input extends object = object, + Args = unknown, + Kind extends string = string, +> { + /** Insertion defaults below call-site options and above client defaults. */ + readonly defaults: Readonly; + /** Stable persisted job kind. */ + readonly kind: Kind; + /** Other kinds its worker also works; see {@link JobDefinitionOptions.kindAliases}. */ + readonly kindAliases?: readonly string[]; + /** Type-only marker carrying the definition's input and args types. */ + readonly [definitionTypes]?: { + readonly args: Args; + readonly input: Input; + }; +} +``` + +## `JobDefinitionArgs` + +```ts +/** Worker args produced by a job definition. */ +export type JobDefinitionArgs = + Definition extends JobDefinition ? Args : never; +``` + +## `JobDefinitionInput` + +```ts +/** Producer input accepted by a job definition. */ +export type JobDefinitionInput = + Definition extends JobDefinition ? Input : never; +``` + +## `JobDefinitionOptions` + +```ts +export interface JobDefinitionOptions { + /** Insertion defaults below call-site options and above client defaults. */ + readonly defaults?: InsertOptions; + /** Stable persisted job kind shared by every language that works this job. */ + readonly kind: Kind; + /** + * Other kinds this job's worker also works, like River for Go's + * `JobArgsWithKindAliases`. To rename a kind safely, make the new name the + * `kind` and the old one an alias: jobs are inserted under the new kind, + * while jobs already stored under the old one are still worked. Remove the + * alias once those have finished, retries included. + */ + readonly kindAliases?: readonly string[]; +} +``` + +## `JobDefinitionTypeError` + +```ts +/** Readable compile-time error carried by an invalid job definition. */ +export interface JobDefinitionTypeError { + readonly "~riverTypeError": Message; +} +``` + +## `JobDeleteManyOptions` + +```ts +/** Safe filters for {@link JobOperations.deleteMany | `client.jobs.deleteMany`}. */ +export interface JobDeleteManyOptions { + /** Authorize an unfiltered deletion. Cannot be combined with filters. */ + readonly all?: true; + readonly ids?: readonly bigint[]; + readonly kinds?: readonly string[]; + readonly limit?: number; + readonly priorities?: readonly number[]; + readonly queues?: readonly string[]; + readonly states?: readonly JobState[]; +} +``` + +## `JobEvent` + +```ts +export type JobEvent = + | JobCancelledEvent + | JobCompletedEvent + | JobFailedEvent + | JobInterruptedEvent + | JobRaceEvent + | JobSnoozedEvent + | JobStartedEvent + | JobStuckEvent; +``` + +## `JobEventKind` + +```ts +export type JobEventKind = JobEvent["kind"]; +``` + +## `JobFailedEvent` + +```ts +/** + * A job's attempt failed; the job is `retryable`, `available` (a + * near-future retry), or `discarded` after its last attempt. + */ +export interface JobFailedEvent extends RiverEventBase<"job_failed"> { + readonly error: unknown; + /** The job as of the transition. */ + readonly job: JobRow; +} +``` + +## `jobFromJsonValue` + +```ts +/** Decode a JSON-safe job while restoring exact bigint and Temporal values. */ +export declare function jobFromJsonValue(value: unknown): JobRow; +``` + +## `JobInterruptedEvent` + +```ts +/** A job's attempt stopped for shutdown and the job is available again. */ +export interface JobInterruptedEvent extends RiverEventBase<"job_interrupted"> { + /** The job as of the transition. */ + readonly job: JobRow; +} +``` + +## `JobListOptions` + +```ts +/** Filters and exact keyset pagination for {@link JobOperations.list | `client.jobs.list`}. */ +export interface JobListOptions { + readonly after?: string; + readonly ids?: readonly bigint[]; + readonly kinds?: readonly string[]; + readonly limit?: number; + /** Require stored metadata to contain this JSON object. */ + readonly metadata?: JsonObject; + readonly orderBy?: JobListOrderBy; + readonly priorities?: readonly number[]; + readonly queues?: readonly string[]; + readonly sortDirection?: SortDirection; + readonly states?: readonly JobState[]; + readonly tagsAll?: readonly string[]; + readonly tagsAny?: readonly string[]; +} +``` + +## `JobListOrderBy` + +```ts +/** + * The field jobs are listed by. `time` is the time field of the first listed + * state (`scheduled_at` when no state is listed), like River for Go. + */ +export type JobListOrderBy = "finalizedAt" | "id" | "scheduledAt" | "time"; +``` + +## `JobListResult` + +```ts +/** + * One page of jobs. Pass `nextCursor` as `after` for the next page; it is + * `null` on the last page. The cursor is River for Go's `JobListCursor` + * text, so River for Go and Rust can continue a listing from it and vice + * versa. + */ +export interface JobListResult { + readonly jobs: readonly JobRow[]; + readonly nextCursor: string | null; +} +``` + +## `JobOperations` + +```ts +/** Job queries and controls, available as {@link Client.jobs}. */ +export interface JobOperations { + /** + * Cancel a job. A running attempt anywhere in the fleet is asked to stop + * cooperatively through its `signal`; returns null when the job does not + * exist. + */ + cancel( + id: bigint, + options?: TransactionOptions + ): Promise; + /** + * Delete a job that is not running, returning null when it does not exist. + * Throws {@link JobRunningError} for a running job. + */ + delete( + id: bigint, + options?: TransactionOptions + ): Promise; + /** Delete a bounded, explicitly filtered set of non-running jobs. */ + deleteMany( + options: JobDeleteManyOptions & TransactionOptions + ): Promise; + /** Get one job, returning null when it does not exist. */ + get( + id: bigint, + options?: TransactionOptions + ): Promise; + /** List jobs with exact, opaque keyset pagination through `nextCursor`. */ + list( + options?: JobListOptions & TransactionOptions + ): Promise; + /** + * Make a job that is not running immediately available for another + * attempt. Returns null when it does not exist. + */ + retry( + id: bigint, + options?: TransactionOptions + ): Promise; + /** + * Merge metadata into a job and set its output, like River for Go's + * `JobUpdate`. Returns null when the job does not exist. + */ + update( + id: bigint, + updates: JobUpdateOptions, + options?: TransactionOptions + ): Promise; +} +``` + +## `JobRaceEvent` + +```ts +/** + * A finished attempt no longer owned its row (another process completed, + * cancelled, or re-claimed the job), so its result was not persisted. + */ +export interface JobRaceEvent extends RiverEventBase<"job_race"> { + /** The job as of the transition. */ + readonly job: JobRow; +} +``` + +## `JobRow` + +```ts +/** Exact properties of a persisted River job. */ +export interface JobRow { + readonly args: TArgs; + readonly attempt: number; + readonly attemptedAt: Temporal.Instant | null; + readonly attemptedBy: readonly string[]; + readonly createdAt: Temporal.Instant; + readonly errors: readonly AttemptError[]; + readonly finalizedAt: Temporal.Instant | null; + readonly id: bigint; + readonly kind: string; + readonly maxAttempts: number; + readonly metadata: JsonObject; + readonly priority: number; + readonly queue: string; + readonly scheduledAt: Temporal.Instant; + readonly state: JobState; + readonly tags: readonly string[]; + readonly uniqueKey: Uint8Array | null; + readonly uniqueStates: readonly JobState[] | null; +} +``` + +## `JobRowJson` + +```ts +/** JSON-safe form of {@link JobRow}. */ +export interface JobRowJson extends JsonObject { + args: JsonObject; + attempt: number; + attemptedAt: string | null; + attemptedBy: string[]; + createdAt: string; + errors: AttemptErrorJson[]; + finalizedAt: string | null; + id: string; + kind: string; + maxAttempts: number; + metadata: JsonObject; + priority: number; + queue: string; + scheduledAt: string; + state: JobState; + tags: string[]; + uniqueKey: string | null; + uniqueStates: JobState[] | null; +} +``` + +## `JobRunningError` + +```ts +/** A protected running job cannot be deleted while its attempt is active. */ +export declare class JobRunningError extends RiverError<"job_running"> { + readonly jobId: bigint; + constructor(jobId: bigint); +} +``` + +## `JobSnoozedEvent` + +```ts +/** A job snoozed and will run again later without using an attempt. */ +export interface JobSnoozedEvent extends RiverEventBase<"job_snoozed"> { + /** The job as of the transition. */ + readonly job: JobRow; +} +``` + +## `JobStartedEvent` + +```ts +/** A claimed job started an attempt. */ +export interface JobStartedEvent extends RiverEventBase<"job_started"> { + /** The job as of the transition. */ + readonly job: JobRow; +} +``` + +## `JobState` + +```ts +/** A job's state, one of the {@link JOB_STATE} values. */ +export type JobState = (typeof JOB_STATE)[keyof typeof JOB_STATE]; +``` + +## `JobStuckError` + +```ts +/** Observation reported when an attempt remains unsettled past its threshold. */ +export declare class JobStuckError extends RiverError<"job_stuck"> { + readonly jobId: bigint; + /** Time the attempt stayed unsettled after its timeout. */ + readonly threshold: Temporal.Duration; + /** Timeout that expired before the attempt became stuck. */ + readonly timeout: Temporal.Duration; + constructor( + jobId: bigint, + timeout: Temporal.Duration, + threshold: Temporal.Duration + ); +} +``` + +## `JobStuckEvent` + +```ts +/** An attempt stayed unsettled past its timeout plus stuck threshold. */ +export interface JobStuckEvent extends RiverEventBase<"job_stuck"> { + /** Which timeout and threshold the attempt exceeded. */ + readonly error: JobStuckError; + /** The job as of the transition. */ + readonly job: JobRow; +} +``` + +## `JobStuckHandler` + +```ts +/** + * Called when an attempt keeps running past its timeout. Return + * `{ addWorkerSlot: true }` to let the queue start another job meanwhile. + */ +export type JobStuckHandler = ( + params: JobStuckHandlerParams +) => + | JobStuckHandlerResult + | PromiseLike + | undefined; +``` + +## `JobStuckHandlerParams` + +```ts +/** Information supplied when a timed-out attempt remains unsettled. */ +export interface JobStuckHandlerParams { + readonly id: bigint; + readonly kind: string; + readonly queue: string; + readonly totalStuckJobs: number; +} +``` + +## `JobStuckHandlerResult` + +```ts +/** Capacity policy returned after observing a stuck attempt. */ +export interface JobStuckHandlerResult { + readonly addWorkerSlot?: boolean; +} +``` + +## `JobTimeoutError` + +```ts +/** Abort reason delivered to a handler when its cooperative timeout expires. */ +export declare class JobTimeoutError extends RiverError<"job_timeout"> { + readonly jobId: bigint; + readonly timeout: Temporal.Duration; + constructor(jobId: bigint, timeout: Temporal.Duration); +} +``` + +## `jobToJsonValue` + +```ts +/** Convert a job to a form that is safe to pass to JSON.stringify. */ +export declare function jobToJsonValue(job: JobRow): JobRowJson; +``` + +## `JobUpdateOptions` + +```ts +/** + * Changes accepted by {@link JobOperations.update | `client.jobs.update`}, + * like River for Go's `JobUpdateParams`. Omitted fields leave the job + * unchanged. + */ +export interface JobUpdateOptions { + /** Merge these top-level keys into the job's metadata. */ + readonly metadata?: JsonObject; + /** Set the job's output, stored at `metadata.output`. */ + readonly output?: JsonValue; +} +``` + +## `JsonCompatible` + +```ts +/** + * `T` itself when every value it describes can be stored as River job JSON, + * otherwise a type that `T` is not assignable to. + * + * Unlike `T extends JsonObject`, this accepts interfaces (which have no + * implicit index signature) and optional properties (River omits properties + * whose value is `undefined`, like `JSON.stringify`). It rejects `Date`, + * `bigint`, functions, symbols, `Map`/`Set`, class instances, and `unknown`. + */ +export type JsonCompatible = [T] extends [JsonValue] + ? T + : T extends boolean | ExactJsonNumber | null | number | string + ? T + : T extends bigint | symbol | undefined | ((...args: never[]) => unknown) + ? never + : T extends readonly unknown[] + ? { + [Index in keyof T]: JsonCompatible; + } + : T extends object + ? { + [Key in keyof T]: JsonCompatibleProperty; + } + : never; +``` + +## `jsonNumberToBigInt` + +```ts +/** + * Convert an integral JSON number, ordinary or exact, to a `bigint` without + * losing precision. Throws {@link JsonValueError} for fractions. + */ +export declare function jsonNumberToBigInt( + value: ExactJsonNumber | number +): bigint; +``` + +## `JsonObject` + +```ts +/** A JSON object accepted by River at a persistence boundary. */ +export interface JsonObject { + [key: string]: JsonValue; +} +``` + +## `JsonValue` + +```ts +/** A value that can be represented by River's JSON protocol. */ +export type JsonValue = + boolean | ExactJsonNumber | JsonObject | JsonValue[] | null | number | string; +``` + +## `JsonValueError` + +```ts +/** An error raised when a value cannot safely cross River's JSON boundary. */ +export declare class JsonValueError extends ValidationError { + /** JSONPath-like location of the rejected value, such as `$.user.id`. */ + readonly path: string; + constructor(path: string, message: string); +} +``` + +## `LeaderEvent` + +```ts +/** This client won or lost maintenance leadership. */ +export interface LeaderEvent extends RiverEventBase< + "leader_acquired" | "leader_lost" +> { + /** The leadership term won or lost. */ + readonly leader: LeaderTerm; +} +``` + +## `LeaderTerm` + +```ts +/** + * One maintenance leadership term: the client that leads (`leaderId`), when it + * was elected, and when its lease expires unless renewed. A new election + * starts a new term with a new `electedAt`. + */ +export interface LeaderTerm { + readonly electedAt: Temporal.Instant; + readonly expiresAt: Temporal.Instant; + readonly leaderId: string; +} +``` + +## `LifecycleError` + +```ts +/** Runtime lifecycle or supervised background failure. */ +export declare class LifecycleError extends RiverError<"lifecycle"> { + constructor(message: string, options?: RiverErrorSubclassOptions); +} +``` + +## `LogAttributes` + +```ts +/** Structured fields attached to a log message. */ +export type LogAttributes = Readonly>; +``` + +## `Logger` + +```ts +/** + * A structured logger using pino's argument order: an attributes object + * first, then the message. Pino, Bunyan, and most structured loggers satisfy + * it directly, so `logger: pino()` works as is. + * + * River logs background failures (database retries, hook failures, dropped + * completions) at `warn` and `error`. Without a configured logger those two + * levels go to `console`; pass `logger: false` to silence them. + */ +export interface Logger { + debug(attributes: LogAttributes, message: string): void; + error(attributes: LogAttributes, message: string): void; + info(attributes: LogAttributes, message: string): void; + warn(attributes: LogAttributes, message: string): void; +} +``` + +## `LogLevel` + +```ts +/** A log severity River writes at. */ +export type LogLevel = "debug" | "error" | "info" | "warn"; +``` + +## `MaintenanceDiagnostics` + +```ts +/** The leader-owned maintenance services' state in {@link RunDiagnostics}. */ +export interface MaintenanceDiagnostics { + readonly isLeader: boolean; + readonly lastError: string | null; + readonly leader: LeaderTerm | null; + readonly runs: Readonly>; +} +``` + +## `MaintenanceFailedEvent` + +```ts +/** A leader-owned maintenance service pass failed. */ +export interface MaintenanceFailedEvent extends RiverEventBase<"maintenance_failed"> { + /** The error the maintenance pass failed with. */ + readonly error: unknown; + readonly service: MaintenanceServiceName; +} +``` + +## `MaintenanceOptions` + +```ts +/** + * Leader-owned maintenance: election, scheduling, rescue, cleaning, and + * reindexing. Retentions accept `null` to keep rows forever and timeouts + * accept `null` for no limit. + */ +export interface MaintenanceOptions { + /** Keep cancelled jobs this long. Defaults to 24 hours. */ + readonly cancelledJobRetention?: DurationInput | null; + /** Keep completed jobs this long. Defaults to 24 hours. */ + readonly completedJobRetention?: DurationInput | null; + /** Keep discarded jobs this long. Defaults to 7 days. */ + readonly discardedJobRetention?: DurationInput | null; + /** + * How often a leader renews its term and a follower bids for leadership. + * Defaults to 5 seconds. Like River for Go, a follower's interval is + * jittered by up to a fifth, and a follower bids within 50 ms of another + * client's resignation. + */ + readonly electionInterval?: DurationInput; + /** How often the job cleaner runs. Defaults to 30 seconds. */ + readonly jobCleanerInterval?: DurationInput; + /** Bound on one job cleaner pass. Defaults to 1 minute. */ + readonly jobCleanerTimeout?: DurationInput | null; + /** How often expired SQLite notification rows are deleted. */ + readonly notificationCleanerInterval?: DurationInput; + /** Keep SQLite notification rows this long. */ + readonly notificationRetention?: DurationInput; + /** How often the queue cleaner runs. */ + readonly queueCleanerInterval?: DurationInput; + /** Delete queues nothing has reported for this long. */ + readonly queueRetention?: DurationInput; + /** Indexes rebuilt by the PostgreSQL reindexer. */ + readonly reindexerIndexNames?: readonly string[]; + /** When the PostgreSQL reindexer runs next after a given instant. */ + readonly reindexerSchedule?: ReindexerSchedule; + /** Bound on one index rebuild. */ + readonly reindexerTimeout?: DurationInput | null; + /** + * Rescue a running job whose attempt started longer ago than this (jobs + * whose timeout is disabled are never rescued). Defaults to 1 hour. + */ + readonly rescueAfter?: DurationInput; + /** How often the rescuer runs. Defaults to 30 seconds. */ + readonly rescuerInterval?: DurationInput; + /** How often scheduled and retryable jobs are made available. */ + readonly schedulerInterval?: DurationInput; +} +``` + +## `MaintenanceServiceName` + +```ts +/** The maintenance services a leader runs. */ +export type MaintenanceServiceName = + | "job_cleaner" + | "notification_cleaner" + | "periodic" + | "queue_cleaner" + | "reindexer" + | "rescuer" + | "scheduler"; +``` + +## `MaintenanceSucceededEvent` + +```ts +/** A leader-owned maintenance service pass succeeded. */ +export interface MaintenanceSucceededEvent extends RiverEventBase<"maintenance_succeeded"> { + /** Rows the pass affected. */ + readonly count: number; + readonly service: MaintenanceServiceName; +} +``` + +## `MAX_ATTEMPTS_DEFAULT` + +```ts +export declare const MAX_ATTEMPTS_DEFAULT = 25; +``` + +## `MigrationError` + +```ts +/** A migration could not be planned, applied, or validated. */ +export declare class MigrationError extends RiverError<"migration"> { + /** Backend being migrated, such as `"postgres"` or `"sqlite"`. */ + readonly backend: string; + /** Migration operation that failed, such as `"migrate"` or `"validate"`. */ + readonly operation: string; + constructor(message: string, options: MigrationErrorOptions); +} +``` + +## `MigrationErrorOptions` + +```ts +/** Options for {@link MigrationError}. */ +export interface MigrationErrorOptions extends RiverErrorSubclassOptions { + /** Backend being migrated, such as `"postgres"` or `"sqlite"`. */ + backend: string; + /** Migration operation that failed, such as `"migrate"` or `"validate"`. */ + operation: string; +} +``` + +## `NormalizedInsertOptions` + +```ts +/** Snapshot of insertion options after validation and normalization. */ +export interface NormalizedInsertOptions extends Omit< + InsertOptions, + "delay" | "scheduledAt" | "unique" +> { + delay?: Temporal.Duration; + scheduledAt?: Temporal.Instant; + unique?: NormalizedUniqueOptions; +} +``` + +## `NormalizedUniqueOptions` + +```ts +/** Uniqueness options after validation and normalization. */ +export interface NormalizedUniqueOptions extends Omit< + UniqueOptions, + "byPeriod" +> { + byPeriod?: Temporal.Duration; +} +``` + +## `NormalizedWorkerOptions` + +```ts +/** A worker's options after validation. */ +export interface NormalizedWorkerOptions extends Omit< + WorkerOptions, + "timeout" +> { + /** Validated timeout, or null when disabled for this worker. */ + timeout?: Temporal.Duration | null; +} +``` + +## `parseJson` + +```ts +/** Parse JSON while preserving numbers that JavaScript cannot round-trip. */ +export declare function parseJson(text: string): JsonValue; +``` + +## `parseJsonObject` + +```ts +export declare function parseJsonObject(text: string): JsonObject; +``` + +## `PayloadValidationError` + +```ts +/** + * Job arguments rejected by their job definition's schema or decoder. + * + * `phase` is `"insert"` when a producer passed invalid arguments and `"work"` + * when a persisted job (possibly inserted by another language or an older + * producer) failed validation before its handler ran. + */ +export declare class PayloadValidationError extends RiverError<"payload_validation"> { + /** Kind of the job whose arguments were rejected. */ + readonly kind: string; + /** Whether validation failed while inserting or before working. */ + readonly phase: PayloadValidationPhase; + constructor( + kind: string, + phase: PayloadValidationPhase, + message: string, + options?: RiverErrorSubclassOptions + ); +} +``` + +## `PayloadValidationPhase` + +```ts +/** Where a job payload failed validation. */ +export type PayloadValidationPhase = "insert" | "work"; +``` + +## `periodicJob` + +````ts +/** + * Define a job that the elected leader inserts on a schedule. + * + * Periodic jobs run only on the client that currently holds River + * leadership. In a fleet mixing River implementations (Go, Rust, and + * JavaScript), register the same periodic jobs in every implementation, or + * leadership moving between languages silently changes which periodic jobs + * run. + * + * @example + * ```ts + * const hourlyReport = periodicJob({ + * args: { scope: "all" }, + * every: { hours: 1 }, + * id: "hourly_report", + * job: buildReport, + * runOnStart: true, + * }); + * const client = new Client(driver, { periodicJobs: [hourlyReport], workers }); + * ``` + */ +export declare function periodicJob( + options: PeriodicJobOptions +): PeriodicJob; +```` + +## `PeriodicJob` + +```ts +/** An immutable periodic job created by {@link periodicJob}. */ +export interface PeriodicJob { + /** Stable ID, or null when none was configured. */ + readonly id: string | null; + /** Job definition inserted on each occurrence. */ + readonly job: Definition; + /** Whether an occurrence is also inserted when leadership starts. */ + readonly runOnStart: boolean; + /** When occurrences are due. */ + readonly schedule: PeriodicSchedule; + /** Type-only brand; periodic jobs come from {@link periodicJob}. */ + readonly [periodicJobBrand]?: true; +} +``` + +## `PeriodicJobArgs` + +```ts +/** Static arguments, or a callback building each occurrence. */ +export type PeriodicJobArgs = + | { + /** Arguments inserted on every occurrence. */ + readonly args: JobDefinitionInput; + readonly construct?: never; + /** Insertion options for every occurrence. */ + readonly options?: InsertOptions; + } + | { + readonly args?: never; + /** + * Build each occurrence's args and options, or return null to skip it. + * A thrown error is logged and that occurrence is skipped. + */ + readonly construct: () => + | PeriodicJobInsert + | null + | PromiseLike | null>; + readonly options?: never; + }; +``` + +## `PeriodicJobHandle` + +```ts +/** Opaque removal handle returned by {@link PeriodicJobs.add}. */ +export interface PeriodicJobHandle { + readonly "~periodicJobHandle": number; +} +``` + +## `PeriodicJobInsert` + +```ts +/** One occurrence built by a periodic job's `construct` callback. */ +export interface PeriodicJobInsert { + readonly args: JobDefinitionInput; + readonly options?: InsertOptions; +} +``` + +## `PeriodicJobOptions` + +```ts +/** Options for {@link periodicJob}. */ +export type PeriodicJobOptions = { + /** + * Stable ID, unique within a client. It is recorded in the inserted job's + * `river:periodic_job_id` metadata and lets a durable schedule store (an + * extension) remember the next run across leader changes. + */ + readonly id?: string; + /** Job definition inserted on each occurrence. */ + readonly job: Definition; + /** Also insert once each time this client becomes leader. */ + readonly runOnStart?: boolean; +} & PeriodicJobArgs & + PeriodicJobTiming; +``` + +## `PeriodicJobs` + +```ts +/** + * A client's mutable registry of periodic jobs, available as + * `client.periodicJobs`. Jobs may be added and removed while the client runs; + * the leader picks up changes immediately. + * + * Only the elected leader inserts periodic jobs, so a change takes full effect + * only when it's made on every client in the fleet that may lead. The registry + * of a client configured with `leaderElectionDisabled: true` can't be + * modified, because that client never leads. + */ +export declare class PeriodicJobs { + constructor(jobs?: readonly PeriodicJob[]); + /** Number of registered periodic jobs. */ + get size(): number; + /** Register a periodic job and return a handle for removing it. */ + add(job: PeriodicJob): PeriodicJobHandle; + /** Register several periodic jobs at once. */ + addMany(jobs: readonly PeriodicJob[]): readonly PeriodicJobHandle[]; + /** Remove every periodic job. */ + clear(): void; + /** Remove the job registered with `handle`; false when already removed. */ + remove(handle: PeriodicJobHandle): boolean; + /** Remove the job registered with `id`; false when none matches. */ + removeById(id: string): boolean; +} +``` + +## `PeriodicJobsStartParams` + +```ts +/** Parameters for an `onPeriodicJobsStart` hook. */ +export interface PeriodicJobsStartParams { + /** + * Durable periodic job records found by a configured periodic job store, + * including records for jobs that were removed but not yet reaped. Empty + * unless an extension provides a store. + */ + readonly durableJobs: readonly DurablePeriodicJob[]; + /** The client's periodic job registry, which the hook may modify. */ + readonly periodicJobs: PeriodicJobs; +} +``` + +## `PeriodicJobTiming` + +```ts +/** A fixed interval or a custom schedule. */ +export type PeriodicJobTiming = + | { + /** + * Fixed interval between occurrences, such as `{ hours: 1 }`. Calendar + * units (years, months, weeks) are rejected; a day is 24 hours. + */ + readonly every: DurationInput; + readonly schedule?: never; + } + | { + readonly every?: never; + /** Custom schedule, such as one built by `cron()`. */ + readonly schedule: PeriodicSchedule; + }; +``` + +## `PeriodicSchedule` + +```ts +/** + * When a periodic job runs: `next(after)` returns the first occurrence + * strictly after `after`, or null to stop scheduling it. + * + * `cron()` builds one from a River Go-compatible cron expression; implement + * it directly for other calendar rules. + */ +export interface PeriodicSchedule { + next(after: Temporal.Instant): Temporal.Instant | null; +} +``` + +## `PRIORITY_DEFAULT` + +```ts +export declare const PRIORITY_DEFAULT = 1; +``` + +## `QUEUE_DEFAULT` + +```ts +export declare const QUEUE_DEFAULT = "default"; +``` + +## `QueueConfig` + +```ts +/** + * Local configuration of one queue this client works. + * + * Durations accept a `Temporal.Duration` or a duration-like object such as + * `{ seconds: 5 }`. + */ +export interface QueueConfig { + /** + * Minimum time between claim queries. Defaults to the client's + * `fetchCooldown`. + */ + readonly fetchCooldown?: DurationInput; + /** Maximum jobs from this queue worked concurrently by this client. */ + readonly maxWorkers: number; + /** + * How often to poll for jobs when no insert notification arrives. Defaults + * to 1 second. + */ + readonly pollInterval?: DurationInput; +} +``` + +## `QueueEvent` + +```ts +export interface QueueEvent extends RiverEventBase< + | "queue_added" + | "queue_paused" + | "queue_reconfigured" + | "queue_resumed" + | "queue_updated" +> { + /** The queue as of the change. */ + readonly queue: QueueRow; +} +``` + +## `QueueEventKind` + +```ts +export type QueueEventKind = QueueEvent["kind"]; +``` + +## `QueueListOptions` + +```ts +/** Pagination for listing queues: `after` is a previous page's `nextCursor`. */ +export interface QueueListOptions { + readonly after?: string; + readonly limit?: number; +} +``` + +## `QueueListResult` + +```ts +/** One page of queues; `nextCursor` is `null` on the last page. */ +export interface QueueListResult { + readonly nextCursor: string | null; + readonly queues: readonly QueueRow[]; +} +``` + +## `QueueOperations` + +```ts +/** + * Queue queries and controls, available as {@link Client.queues}. Like River + * for Go, these look queues up by + * name without checking it against the queue-name grammar, so a name no + * queue can have is simply not found (null). + */ +export interface QueueOperations { + /** Get one queue, returning null when it does not exist. */ + get( + name: string, + options?: TransactionOptions + ): Promise; + /** List queues with opaque name pagination through `nextCursor`. */ + list( + options?: QueueListOptions & TransactionOptions + ): Promise; + /** + * Pause a queue across the fleet. Returns null when the queue does not + * exist. + * + * `"*"` pauses every queue, like River for Go, and always resolves null + * because no single queue row describes the result. + */ + pause( + name: string, + options?: TransactionOptions + ): Promise; + /** + * Resume a paused queue across the fleet. Returns null when the queue does + * not exist. + * + * `"*"` resumes every queue, like River for Go, and always resolves null + * because no single queue row describes the result. + */ + resume( + name: string, + options?: TransactionOptions + ): Promise; + /** Update queue metadata. Returns null when the queue does not exist. */ + update( + name: string, + updates: QueueUpdateOptions, + options?: TransactionOptions + ): Promise; +} +``` + +## `QueueRemovedEvent` + +```ts +/** A queue was removed from this client. */ +export interface QueueRemovedEvent extends RiverEventBase<"queue_removed"> { + readonly queueName: string; +} +``` + +## `QueueRow` + +```ts +/** Persisted dynamic queue row. */ +export interface QueueRow { + readonly createdAt: Temporal.Instant; + readonly metadata: JsonObject; + readonly name: string; + readonly pausedAt: Temporal.Instant | null; + readonly updatedAt: Temporal.Instant; +} +``` + +## `QueueRuntimeDiagnostics` + +```ts +/** One queue's configuration and pause state in {@link RunDiagnostics}. */ +export interface QueueRuntimeDiagnostics { + readonly fetchCooldown: Temporal.Duration; + readonly maxWorkers: number; + readonly paused: boolean; + readonly pollInterval: Temporal.Duration; +} +``` + +## `QueueUpdateOptions` + +```ts +/** Changes to a queue's persisted settings. Omitted fields are unchanged. */ +export interface QueueUpdateOptions { + readonly metadata?: JsonObject; +} +``` + +## `recordOutput` + +```ts +/** Record JSON output on the current attempt, with last write winning. */ +export declare function recordOutput(value: JsonValue): void; +``` + +## `RegisteredTransaction` + +```ts +/** + * Union of transaction types from installed drivers, or `unknown` when no + * driver package registered one. + */ +export type RegisteredTransaction = [keyof RiverTransactionRegistry] extends [ + never, +] + ? unknown + : RiverTransactionRegistry[keyof RiverTransactionRegistry]; +``` + +## `REINDEXER_INDEX_NAMES_DEFAULT` + +```ts +/** PostgreSQL indexes River rebuilds by default to control table bloat. */ +export declare const REINDEXER_INDEX_NAMES_DEFAULT: readonly [ + "river_job_args_index", + "river_job_kind", + "river_job_metadata_index", + "river_job_pkey", + "river_job_prioritized_fetching_index", + "river_job_state_and_finalized_at_index", + "river_job_unique_idx", +]; +``` + +## `ReindexerSchedule` + +```ts +/** Returns when the reindexer should next run after `after`. */ +export type ReindexerSchedule = (after: Temporal.Instant) => Temporal.Instant; +``` + +## `Resumable` + +```ts +/** Attempt-scoped resumable-step coordinator exposed on WorkContext. */ +export declare class Resumable { + /** + * Run a named step unless an earlier failed attempt completed it. + * Await steps sequentially; nested steps are supported, concurrent steps are not. + */ + step(name: string, callback: () => PromiseLike | void): Promise; + /** Run a named cursor step with the last JSON cursor, or null initially. */ + stepWithCursor( + name: string, + callback: (cursor: JsonValue | null) => PromiseLike | void + ): Promise; + /** Record the JSON cursor for the currently running cursor step. */ + setCursor(cursor: JsonValue): void; + /** Persist the current step and optional cursor in a caller transaction. */ + checkpoint( + options: ResumableCheckpointOptions + ): Promise; +} +``` + +## `ResumableCheckpointOptions` + +```ts +/** + * Save resumable progress now, atomically with `tx`, instead of when the + * attempt ends. + */ +export interface ResumableCheckpointOptions { + readonly cursor?: JsonValue; + readonly tx: Transaction; +} +``` + +## `RetryPolicy` + +```ts +/** Returns when a failed job should next run. */ +export type RetryPolicy = ( + job: Readonly, + now: Temporal.Instant +) => Temporal.Instant; +``` + +## `RIVER_ERROR_CODE` + +```ts +/** + * Stable error categories exposed by River. + * + * Every error River throws deliberately is a {@link RiverError} whose `code` + * is one of these values. Each subclass narrows `code` to its own category, so + * either `instanceof` or a `switch` on `code` identifies the failure. + */ +export declare const RIVER_ERROR_CODE: { + readonly backendMismatch: "backend_mismatch"; + readonly configuration: "configuration"; + readonly database: "database"; + readonly extension: "extension"; + readonly jobAborted: "job_aborted"; + readonly jobAttemptFinished: "job_attempt_finished"; + readonly jobCancelled: "job_cancelled"; + readonly jobRunning: "job_running"; + readonly jobStuck: "job_stuck"; + readonly jobTimeout: "job_timeout"; + readonly lifecycle: "lifecycle"; + readonly migration: "migration"; + readonly payloadValidation: "payload_validation"; + readonly subscriptionLag: "subscription_lag"; + readonly transactionScope: "transaction_scope"; + readonly unknownJobKind: "unknown_job_kind"; + readonly unsupportedCapability: "unsupported_capability"; + readonly validation: "validation"; +}; +``` + +## `RiverError` + +```ts +/** + * Base class for every error River throws deliberately. + * + * Catch this class to handle all River failures, including database, + * migration, and job-abort errors. Internal invariant violations remain + * ordinary `Error` values so they stay visibly different. + * + * A companion package roots its errors here too, so one `instanceof + * RiverError` catches them. Where one of River's categories fits, it + * extends that subclass, such as {@link ValidationError}. Otherwise it + * extends `RiverError` with codes of its own, prefixed with its name and a + * dot, such as `"example.cycle"`, so they never collide with River's + * unprefixed codes; code switching on `code` keeps a default branch for + * such codes. + */ +export declare class RiverError< + Code extends string = RiverErrorCode, +> extends Error { + /** Stable error category. */ + readonly code: Code; + /** Structured, secret-free context such as an operation or job ID. */ + readonly details?: Readonly>; + /** Whether retrying the failed operation is known to be safe. */ + readonly retryable: boolean; + constructor(message: string, options: RiverErrorOptions); +} +``` + +## `RiverErrorCode` + +```ts +/** One of River's stable error categories. */ +export type RiverErrorCode = + (typeof RIVER_ERROR_CODE)[keyof typeof RIVER_ERROR_CODE]; +``` + +## `RiverErrorHandler` + +```ts +/** + * Nonfatal application policy invoked once for each failed attempt, + * including an attempt that stopped because its timeout expired (the error + * is then a `JobTimeoutError`). Like River for Go, it isn't invoked + * for an attempt interrupted by a client stop, or for one that fails after + * the job was cancelled remotely (the cancellation decides that attempt's + * outcome), unless the failure is a JavaScript runtime fault such as a + * `TypeError`, River's analog of a Go panic. + */ +export type RiverErrorHandler = ( + context: ErrorHandlerContext, + error: unknown +) => + ErrorHandlerResult | PromiseLike | undefined; +``` + +## `RiverErrorOptions` + +```ts +/** Options accepted by {@link RiverError} and its subclasses. */ +export interface RiverErrorOptions< + Code extends string = RiverErrorCode, +> extends ErrorOptions { + /** Stable error category. */ + code: Code; + /** Structured, secret-free context such as an operation or job ID. */ + details?: Readonly>; + /** Whether retrying the failed operation is known to be safe. */ + retryable?: boolean; +} +``` + +## `RiverErrorSubclassOptions` + +```ts +/** Options accepted by River error subclasses, which fix their own `code`. */ +export type RiverErrorSubclassOptions = Omit; +``` + +## `RiverEvent` + +```ts +/** Every observation River emits, discriminated by `kind`. */ +export type RiverEvent = + | EventLoopDelayEvent + | JobEvent + | LeaderEvent + | MaintenanceFailedEvent + | MaintenanceSucceededEvent + | QueueEvent + | QueueRemovedEvent + | SubscriptionLagEvent; +``` + +## `RiverEventBase` + +```ts +/** Fields shared by every River event. */ +export interface RiverEventBase { + /** When River observed the transition. */ + readonly at: Temporal.Instant; + /** Discriminates the event type. */ + readonly kind: Kind; +} +``` + +## `RiverEventKind` + +```ts +export type RiverEventKind = RiverEvent["kind"]; +``` + +## `RiverHooks` + +```ts +/** + * Ordered extension points. As in River for Go, insert and work hooks run + * inside the innermost middleware: insert middleware wraps `beforeInsert`, + * the database write, and `afterInsert`, and work middleware wraps + * `beforeWork`, argument decoding, the worker, and `afterWork`. A job whose + * kind has no worker, or whose row can't be decoded, fails before any + * middleware or hook runs. + */ +export interface RiverHooks { + /** Observe inserted jobs, inside the innermost insert middleware. */ + afterInsert?( + context: InsertContext, + results: readonly InsertResult[] + ): PromiseLike | void; + /** Observe any River event after it is published to subscribers. */ + onEvent?(event: RiverEvent): PromiseLike | void; + /** Observe a runtime metric without blocking job fetching. */ + onMetric?(metric: RiverMetric): PromiseLike | void; + /** + * Observe, or replace by returning another, an attempt's result. The + * returned result becomes the attempt's result, like River for Go's + * `HookWorkEnd`; returning nothing keeps it. + */ + afterWork?( + context: WorkContext, + result: WorkAttemptResult + ): PromiseLike | WorkAttemptResult | void; + /** + * Runs before arguments are decoded and the worker runs. An error it + * throws becomes the attempt's error, and the worker doesn't run. + */ + beforeWork?(context: WorkAttemptContext): PromiseLike | void; + /** Runs before jobs are inserted; an error fails the insertion. */ + beforeInsert?(context: InsertContext): PromiseLike | void; + /** + * Runs each time this client becomes leader and starts inserting periodic + * jobs, with durable records from a periodic job store when one is + * configured. A rejection is logged and periodic enqueuing retries on the + * next loop, like Go River's `HookPeriodicJobsStart`. + */ + onPeriodicJobsStart?( + params: PeriodicJobsStartParams + ): PromiseLike | void; +} +``` + +## `RiverMetric` + +```ts +/** + * Strongly typed runtime observations. + * + * Fetch metrics are emitted after every successful claim. Completion metrics + * report persistence failures: a requeued batch is retried, while dropped + * completions leave their jobs `running` until the rescuer recovers them. + */ +export type RiverMetric = + | { + readonly count: number; + readonly name: "job_completion_dropped" | "job_completion_requeued"; + } + | { + readonly duration: Temporal.Duration; + readonly name: "job_get_available_duration"; + readonly queue: string; + } + | { + readonly count: number; + readonly name: "job_get_available_count"; + readonly queue: string; + }; +``` + +## `RiverPlugin` + +```ts +/** A named collection of ordinary middleware and hooks. */ +export interface RiverPlugin { + readonly hooks?: RiverHooks; + readonly insertMiddleware?: readonly InsertMiddleware[]; + readonly middleware?: readonly WorkMiddleware[]; + readonly name: string; +} +``` + +## `RiverTransactionRegistry` + +```ts +/** + * Transaction types contributed by installed River drivers. + * + * Driver packages augment this interface (for example `@riverqueue/driver-pg` + * adds node-postgres clients) so worker contexts accept exactly the + * transaction types of the drivers an application uses. Applications never + * need to augment it. + */ +export interface RiverTransactionRegistry {} + +interface RiverTransactionRegistry { + /** node-postgres clients (`pg.Client` or a pool's `PoolClient`). */ + "@riverqueue/driver-pg": ClientBase; +} + +interface RiverTransactionRegistry { + /** Prisma interactive-transaction clients. */ + "@riverqueue/driver-prisma": PrismaClientLike; +} + +interface RiverTransactionRegistry { + /** Application handles on a SQLite driver's database with a transaction open. */ + "@riverqueue/driver-sqlite": DatabaseSync; +} +``` + +## `RunDiagnostics` + +```ts +/** A snapshot of a running client, from `run.diagnostics`. */ +export interface RunDiagnostics { + readonly activeAttempts: number; + readonly clientId: string; + readonly completionCapacity: number; + readonly completionQueries: number; + readonly eventLoopDelay: EventLoopDelayObservation | null; + readonly maintenance: MaintenanceDiagnostics | null; + readonly pendingCompletions: number; + readonly queues: Readonly>; + readonly state: RunState; + readonly executors: Readonly>; +} +``` + +## `RunHandle` + +```ts +/** + * Handle owning one supervised Client runtime. + * + * `Config` is the queue configuration `addQueue` and `updateQueue` accept. + * TypeScript compares handles structurally, so a handle for River's own + * queue configuration also type-checks as one accepting more keys; the + * runtime rejects any queue key the client doesn't know. + */ +export declare class RunHandle { + /** Rejects immediately if an owned background task fails. */ + readonly completed: Promise; + get diagnostics(): RunDiagnostics; + get state(): RunState; + /** Add and persist a queue, starting claims only after its controls load. */ + addQueue(name: string, config: Config): Promise; + /** Stop locally claiming a queue; its persisted row expires naturally. */ + removeQueue(name: string): Promise; + /** Ask whichever runtime currently leads to resign its exact term. */ + requestLeadershipResignation(): Promise; + /** Atomically replace local queue capacity/polling configuration. */ + updateQueue(name: string, config: Config): Promise; + /** + * Stop the runtime. A graceful stop (the default) waits for running jobs; + * `mode: "cancel"`, the `signal`, or an elapsed `timeout` aborts them. + */ + stop(options?: StopOptions): Promise; + [Symbol.asyncDispose](): Promise; +} +``` + +## `RunState` + +```ts +/** A running client's lifecycle state. */ +export type RunState = "failed" | "running" | "stopped" | "stopping"; +``` + +## `SchemaJobDefinitionConfig` + +```ts +/** A job definition whose arguments are validated by a Standard Schema. */ +export interface SchemaJobDefinitionConfig< + Schema extends StandardSchemaV1, + Kind extends string = string, +> extends JobDefinitionOptions { + readonly decode?: never; + /** + * Standard Schema validator for the persisted JSON arguments. River runs it + * when inserting and again before working, because another producer (an + * older deploy or another language) may have inserted the job. + */ + readonly schema: Schema; +} +``` + +## `setMetadata` + +```ts +/** Merge one JSON value into metadata on the current work attempt. */ +export declare function setMetadata(key: string, value: JsonValue): void; +``` + +## `snooze` + +```ts +/** + * Snooze the job: run it again after `duration` without using an attempt, + * for example `return snooze({ minutes: 5 })`. Like River for Go, a snooze + * no longer than the scheduler interval (including zero) is stored as + * available with its future scheduled time, so it runs on time without + * waiting for the scheduler. + */ +export declare function snooze(duration: DurationInput): SnoozeOutcome; +``` + +## `SnoozeOutcome` + +```ts +/** Reschedule work without consuming an attempt. */ +export interface SnoozeOutcome { + /** How long to snooze, rounded up to whole milliseconds by {@link snooze}. */ + readonly duration: Temporal.Duration; + readonly type: "snooze"; +} +``` + +## `SortDirection` + +```ts +/** A list order. */ +export type SortDirection = "asc" | "desc"; +``` + +## `StandardSchemaInput` + +```ts +/** Input type accepted by a Standard Schema validator. */ +export type StandardSchemaInput = + Schema extends StandardSchemaV1 ? Input : never; +``` + +## `StandardSchemaIssue` + +```ts +/** The Standard Schema issue fields River retains for diagnostics. */ +export interface StandardSchemaIssue { + readonly message: string; + readonly path?: + | ReadonlyArray< + | PropertyKey + | { + readonly key: PropertyKey; + } + > + | undefined; +} +``` + +## `StandardSchemaOutput` + +```ts +/** Output type produced by a Standard Schema validator. */ +export type StandardSchemaOutput = + Schema extends StandardSchemaV1 ? Output : never; +``` + +## `StandardSchemaResult` + +```ts +/** Successful or failed Standard Schema validation. */ +export type StandardSchemaResult = + | { + readonly issues?: undefined; + readonly value: Output; + } + | { + readonly issues: readonly StandardSchemaIssue[]; + }; +``` + +## `StandardSchemaV1` + +```ts +/** + * The structural contract implemented by Standard Schema validators such as + * Zod, Valibot, and ArkType. See https://standardschema.dev. + */ +export interface StandardSchemaV1 { + readonly "~standard": { + readonly types?: + | { + readonly input: Input; + readonly output: Output; + } + | undefined; + readonly validate: ( + value: unknown + ) => + PromiseLike> | StandardSchemaResult; + readonly vendor: string; + readonly version: 1; + }; +} +``` + +## `StopOptions` + +```ts +/** Options for `RunHandle.stop`. */ +export interface StopOptions { + /** + * `"graceful"` (the default) stops claiming and lets running jobs finish; + * `"cancel"` also aborts every running job's `signal`. + */ + readonly mode?: "cancel" | "graceful"; + /** Abort a graceful stop early, escalating to cancellation. */ + readonly signal?: AbortSignal; + /** + * Escalate a graceful stop to cancellation after this long. Without it, a + * graceful stop waits for running jobs indefinitely. + */ + readonly timeout?: DurationInput; +} +``` + +## `stringifyJson` + +```ts +/** Serialize a previously unknown value without JSON's lossy coercions. */ +export declare function stringifyJson(value: unknown): string; +``` + +## `SubscribedEvent` + +```ts +/** Events a subscription filtered to `Kind` yields. */ +export type SubscribedEvent = + | Extract< + RiverEvent, + { + readonly kind: Kind; + } + > + | SubscriptionLagEvent; +``` + +## `SubscribeOptions` + +```ts +/** Options for `client.subscribe`. */ +export interface SubscribeOptions< + Kind extends RiverEventKind = RiverEventKind, +> { + /** + * Maximum buffered events before the oldest are dropped and a + * `subscription_lag` event reports how many. Defaults to 256. + */ + readonly capacity?: number; + /** Only deliver these kinds (plus `subscription_lag`). */ + readonly kinds?: readonly Kind[]; + /** Close the subscription when this signal aborts. */ + readonly signal?: AbortSignal; +} +``` + +## `SubscriptionLagError` + +```ts +/** A bounded subscription dropped events because its consumer fell behind. */ +export declare class SubscriptionLagError extends RiverError<"subscription_lag"> { + /** Number of events dropped since the previous delivered event. */ + readonly dropped: number; + constructor(dropped: number); +} +``` + +## `SubscriptionLagEvent` + +```ts +/** + * A subscription dropped events because its consumer fell behind. Delivered + * to every subscription regardless of its `kinds` filter. + */ +export interface SubscriptionLagEvent extends RiverEventBase<"subscription_lag"> { + /** Events dropped since the previous lag event. */ + readonly dropped: number; + /** Describes the lag, for logging. */ + readonly error: SubscriptionLagError; +} +``` + +## `toJsonObject` + +```ts +/** Validate and copy an unknown value as a JSON object. */ +export declare function toJsonObject(value: unknown): JsonObject; +``` + +## `toJsonValue` + +```ts +/** + * Validate and copy an unknown value into River's JSON domain. + * + * Objects are copied into null-prototype dictionaries. This intentionally + * rejects accessors, class instances, sparse arrays, cycles, non-finite + * numbers, unsafe integers, `bigint`, and a top-level or array-element + * `undefined` instead of relying on JSON.stringify's lossy coercions. Like + * JSON.stringify, an object property whose value is `undefined` is omitted, so + * optional properties behave as they do in every JSON library. Keys such as + * `__proto__` remain ordinary data because the copy has a null prototype and + * is never merged into application objects. + */ +export declare function toJsonValue(value: unknown): JsonValue; +``` + +## `TransactionOptions` + +```ts +/** Operation options that may run in a caller-owned transaction. */ +export interface TransactionOptions { + /** + * Run the operation in this caller-owned transaction, such as a + * node-postgres client after `BEGIN`. Like River for Go, River runs its + * statements directly in it, opening no savepoint, and never commits or + * rolls it back. When the operation fails, writes it already made, such + * as a job inserted before insert middleware or a hook threw, stay in the + * transaction, so roll it back. To recover from a failure and continue + * the transaction, wrap the call in a savepoint of your own. + */ + tx?: Transaction; +} +``` + +## `TransactionScopeError` + +```ts +/** + * A database transaction was used in a way that can't work, such as calling + * River without `{ tx }` from inside insert middleware while River's own + * transaction holds SQLite's write lock. River fails the call at once + * instead of waiting for a lock that can't be released. Retrying the same + * code fails the same way. + */ +export declare class TransactionScopeError extends RiverError<"transaction_scope"> { + /** What was wrong with the transaction's use. */ + readonly reason: TransactionScopeErrorReason; + constructor( + reason: TransactionScopeErrorReason, + message: string, + options?: RiverErrorSubclassOptions + ); +} +``` + +## `TransactionScopeErrorReason` + +```ts +/** Why a {@link TransactionScopeError} was thrown. */ +export type TransactionScopeErrorReason = + /** + * River's own SQLite transaction stayed open across a turn of the event + * loop, because insert middleware or a hook awaited I/O while River held + * SQLite's write lock. River rolled the operation back to release it. + */ + | "event_loop_turn" + /** A transaction was begun inside one that is already open. */ + | "nested" + /** A value passed as `{ tx }` has no open transaction. */ + | "no_transaction" + /** + * River was called without `{ tx }` from inside a transaction that the + * call would have to wait for, such as River's own transaction around an + * insertion, which its insert middleware and hooks run inside. + */ + | "reentrant"; +``` + +## `UncheckedJobDefinitionConfig` + +```ts +/** A job definition without runtime validation. */ +export interface UncheckedJobDefinitionConfig< + Kind extends string = string, +> extends JobDefinitionOptions { + readonly decode?: never; + readonly schema?: never; +} +``` + +## `UniqueOptions` + +```ts +/** Dimensions used to deduplicate a job. */ +export interface UniqueOptions { + /** Include all args or the named fields, using dot-separated nested paths. */ + byArgs?: true | readonly string[]; + /** + * Deduplicate within fixed windows of this length, such as `{ hours: 1 }`, + * aligned like Go River's by-period uniqueness. At least one second; + * calendar units (years, months, weeks) are rejected and a day is 24 hours. + */ + byPeriod?: DurationInput; + /** Include the queue in the unique key. */ + byQueue?: boolean; + /** States in which an existing job conflicts with an insertion. */ + byState?: readonly JobState[]; + /** + * Omit kind from the unique key, deduplicating across all jobs regardless + * of kind. Requires `byArgs`, `byQueue`, or `byPeriod`. + */ + excludeKind?: boolean; +} +``` + +## `UnknownJobKindError` + +```ts +/** A runtime claimed a job whose kind has no registered worker. */ +export declare class UnknownJobKindError extends RiverError<"unknown_job_kind"> { + readonly kind: string; + constructor(kind: string); +} +``` + +## `UnsupportedCapabilityError` + +```ts +/** The selected backend cannot perform an operation required by the client. */ +export declare class UnsupportedCapabilityError extends RiverError<"unsupported_capability"> { + /** Backend that lacks the capability, such as `"prisma"`. */ + readonly backend: string; + /** Capability that was requested, such as `"runtime"`. */ + readonly capability: string; + constructor( + backend: string, + capability: string, + options?: RiverErrorSubclassOptions & { + message?: string; + } + ); +} +``` + +## `ValidationError` + +```ts +/** A value rejected at a public River boundary. */ +export declare class ValidationError extends RiverError<"validation"> { + constructor(message: string, options?: RiverErrorSubclassOptions); +} +``` + +## `WorkAttemptContext` + +```ts +/** Raw attempt context passed to middleware and pre-work hooks. */ +export interface WorkAttemptContext { + /** The client working this job, for inserting follow-up jobs. */ + readonly client: Client; + readonly execution: WorkExecution; + readonly job: Readonly; + /** + * The client's logger with `jobId`, `jobKind`, and `attempt` attached. + * Call it as `logger.info("message")` or `logger.info({ key }, "message")`. + */ + readonly logger: WorkLogger; + /** Record JSON output for this attempt, including on failure or cancellation. */ + readonly recordOutput: (value: JsonValue) => void; + /** Merge one JSON value into persisted metadata for this attempt. */ + readonly setMetadata: (key: string, value: JsonValue) => void; + readonly signal: AbortSignal; +} +``` + +## `WorkAttemptResult` + +```ts +/** Closed result of one handler attempt, narrowed by `status`. */ +export type WorkAttemptResult = + | (WorkAttemptResultBase & { + readonly cancel?: never; + readonly error: unknown; + readonly outcome?: never; + readonly status: "cancelled"; + }) + | (WorkAttemptResultBase & { + readonly cancel?: boolean; + readonly error: unknown; + readonly outcome?: never; + readonly status: "failed"; + }) + | (WorkAttemptResultBase & { + readonly cancel?: never; + readonly error?: never; + readonly outcome?: WorkOutcome; + readonly status: "succeeded"; + }); +``` + +## `WorkContext` + +```ts +/** Decoded context passed to a registered job handler. */ +export interface WorkContext< + Definition extends JobDefinition = JobDefinition, + Transaction = RegisteredTransaction, +> extends Omit, "job"> { + /** + * Complete this exact attempt inside a caller-owned transaction, so the + * job's completion commits or rolls back with the handler's own writes. + * `Transaction` defaults to the transaction types of installed drivers. + */ + readonly completeTx: ( + tx: Transaction, + options?: { + readonly output?: JsonValue; + } + ) => Promise; + readonly job: Job; + /** Named steps whose progress is checkpointed when an attempt fails. */ + readonly resumable: Resumable; +} +``` + +## `WorkerHooks` + +```ts +/** Job-kind-specific work lifecycle hooks. */ +export interface WorkerHooks { + afterWork?( + context: WorkContext, + result: WorkAttemptResult + ): PromiseLike | WorkAttemptResult | void; + beforeWork?(context: WorkAttemptContext): PromiseLike | void; +} +``` + +## `WorkerOptions` + +```ts +/** Per-handler runtime policy. */ +export interface WorkerOptions { + /** Job-kind-specific hooks, ordered after global hooks. */ + hooks?: WorkerHooks; + /** Job-kind-specific work middleware, wrapping global middleware. */ + middleware?: readonly WorkMiddleware[]; + /** Named job-kind-specific extensions. */ + plugins?: readonly WorkerPlugin[]; + /** + * When this worker's failed jobs run next, like River for Go's + * `Worker.NextRetry`. Consulted before the client's `retryPolicy` once a + * job's arguments decode, both after a failed attempt and when the rescuer + * retries a stuck job. When it throws or returns something other than a + * `Temporal.Instant`, the client's policy decides; a time in the past + * uses River's default schedule. + */ + retryPolicy?: RetryPolicy; + /** + * Cooperative timeout for this worker's attempts, such as `{ minutes: 5 }`. + * Overrides the client's `jobTimeout`; `null` disables it. + */ + timeout?: DurationInput | null; +} +``` + +## `WorkerPlugin` + +```ts +/** Named job-kind-specific work extensions. */ +export interface WorkerPlugin { + readonly hooks?: WorkerHooks; + readonly middleware?: readonly WorkMiddleware[]; + readonly name: string; +} +``` + +## `WorkerRegistration` + +```ts +/** A worker registered in {@link Workers}, run in process or by an executor. */ +export type WorkerRegistration< + Definition extends JobDefinition = JobDefinition, +> = + | InProcessWorkerRegistration + | ExecutorWorkerRegistration; +``` + +## `Workers` + +```ts +/** + * Typed registry of River job handlers. + * + * `Transaction` types `ctx.client` and `ctx.completeTx` inside handlers. It + * defaults to the transaction types of installed drivers; narrow it with + * `new Workers()` when a codebase uses one driver. + */ +export declare class Workers { + /** + * Register exactly one handler for a job definition: a handler function, + * or a {@link WorkHandlerFactory} that builds one for the definition. + */ + add( + definition: Definition, + handler: + | WorkHandler + | WorkHandlerFactory, + options?: WorkerOptions + ): this; + /** Register a handler produced by an optional executor integration. */ + addExecutor( + definition: Definition, + target: WorkExecutorTarget, + options?: WorkerOptions + ): this; + /** Return whether this registry contains a job kind. */ + has(kind: string): boolean; + /** + * Return registered job kinds, each definition's kind aliases included, in + * registration order. + */ + kinds(): readonly string[]; + /** Number of registered job kinds, kind aliases included. */ + get size(): number; +} +``` + +## `WorkExecution` + +```ts +/** Identity and timing metadata for one claimed attempt. */ +export interface WorkExecution { + readonly attemptedBy: string; + readonly startedAt: Temporal.Instant; +} +``` + +## `WorkExecutor` + +```ts +/** + * Runs handlers somewhere other than River's own event loop, such as in + * worker threads (`@riverqueue/worker-threads`). + * + * An executor belongs to the application that constructs it and may serve + * several clients or runtimes. River never closes an executor; stopping a + * runtime only aborts that runtime's own attempts. + */ +export interface WorkExecutor { + readonly name: string; + diagnostics?(): JsonObject; + start(context: WorkContext, handler: unknown): WorkExecutorHandle; +} +``` + +## `WorkExecutorAbortOptions` + +```ts +/** How River asks a {@link WorkExecutor} to stop one attempt. */ +export interface WorkExecutorAbortOptions { + /** + * How long the handler has to settle after its signal aborts before the + * executor may end it by force. River passes the client's + * `jobStuckThreshold`. + */ + readonly gracePeriod: Temporal.Duration; +} +``` + +## `WorkExecutorAbortResult` + +```ts +/** The result of asking a {@link WorkExecutor} to stop one attempt. */ +export interface WorkExecutorAbortResult { + /** + * Whether the executor stopped the attempt itself, by removing it before + * it began or by ending its handler by force, rather than the handler + * settling on its own. True only once the handler no longer executes. + * When the abort came from stopping the client, rather than from the + * job's cancellation or timeout, an attempt ended by force after it began + * fails with a `JobAbortedError`, so its attempt counts. + */ + readonly terminated: boolean; +} +``` + +## `WorkExecutorHandle` + +```ts +/** One running attempt owned by a pluggable executor. */ +export interface WorkExecutorHandle { + readonly result: PromiseLike; + /** + * Settles once the attempt begins executing, for executors that queue + * attempts for capacity of their own. River arms the job timeout and stuck + * detection only after it resolves, so waiting for capacity does not spend + * the attempt's time. Omit it when attempts start immediately. + */ + readonly started?: PromiseLike; + /** + * Abort the handler's signal with `reason`. An executor that can end a + * handler by force should wait `options.gracePeriod` for it to settle + * first. + */ + abort( + reason: unknown, + options: WorkExecutorAbortOptions + ): PromiseLike; +} +``` + +## `WorkExecutorTarget` + +```ts +/** Opaque handler registration produced by an optional executor package. */ +export interface WorkExecutorTarget { + readonly executor: WorkExecutor; + readonly handler: unknown; +} +``` + +## `WorkHandler` + +```ts +/** A handler succeeds by returning nothing or an explicit River outcome. */ +export type WorkHandler< + Definition extends JobDefinition, + Transaction = RegisteredTransaction, +> = ( + context: WorkContext +) => PromiseLike | WorkOutcome | void; +``` + +## `WorkHandlerFactory` + +````ts +/** + * Builds a job's handler from the definition it is registered with, for an + * integration whose handler needs the definition, such as to decode other + * jobs of the same kind. `Workers.add` calls `createWorkHandler` once. + * + * ```ts + * workers.add(definition, integrationWorker(options)); + * ``` + */ +export interface WorkHandlerFactory< + Definition extends JobDefinition, + Transaction = RegisteredTransaction, +> { + readonly createWorkHandler: ( + definition: Definition + ) => WorkHandler; +} +```` + +## `WorkLogFunction` + +```ts +/** One level of a {@link WorkLogger}: `(message)` or `(attributes, message)`. */ +export interface WorkLogFunction { + (message: string): void; + (attributes: LogAttributes, message: string): void; +} +``` + +## `WorkLogger` + +```ts +/** + * The job-scoped logger in a work context. It writes to the client's + * {@link Logger} with `jobId`, `jobKind`, and `attempt` attached, and accepts + * either `logger.info("message")` or `logger.info({ key }, "message")`. + */ +export interface WorkLogger { + readonly debug: WorkLogFunction; + readonly error: WorkLogFunction; + readonly info: WorkLogFunction; + readonly warn: WorkLogFunction; +} +``` + +## `WorkMiddleware` + +```ts +/** A Koa-style around-work extension. `next` may be called exactly once. */ +export type WorkMiddleware = ( + context: WorkAttemptContext, + next: () => Promise +) => PromiseLike | WorkOutcome | void; +``` + +## `WorkOutcome` + +```ts +/** + * An explicit handler outcome, built by `complete()`, `snooze()`, + * `discard()`, or `cancel()`. + */ +export type WorkOutcome = + CancelOutcome | CompleteOutcome | DiscardOutcome | SnoozeOutcome; +``` + +# Referenced but unexported + +Exported declarations refer to these types, which the entry point +intentionally does not export. + +## `JsonCompatibleProperty` + +Type-level helper of `JsonCompatible`; never named by applications. + +```ts +/** Object properties may also be `undefined`, which River omits. */ +type JsonCompatibleProperty = T extends undefined + ? undefined + : JsonCompatible; +``` + +## `SchemaCheck` + +Type-level helper that rejects schemas whose input is not JSON-compatible. + +```ts +type SchemaCheck = [SchemaJobInput] extends [ + JsonCompatible>, +] + ? SchemaJobInput extends readonly unknown[] + ? JobDefinitionTypeError<"schema input must be a JSON object, not an array"> + : unknown + : JobDefinitionTypeError<"schema input must be JSON (no Date, bigint, undefined, Map, or class values); validate the persisted JSON shape and convert in the worker">; +``` + +## `SchemaJobInput` + +Type-level helper that extracts a schema's input type for `defineJob`. + +```ts +type SchemaJobInput = + unknown extends StandardSchemaInput + ? JsonObject + : StandardSchemaInput; +``` + +## `WorkAttemptResultBase` + +Fields shared by every `WorkAttemptResult` variant; name the union instead. + +```ts +interface WorkAttemptResultBase { + readonly metadata?: JsonObject; + readonly output?: JsonValue; +} +``` + +## `WorkerRegistrationBase` + +Fields shared by the exported worker registration interfaces. + +```ts +interface WorkerRegistrationBase< + Definition extends JobDefinition = JobDefinition, +> { + readonly definition: Definition; + readonly options: Readonly; +} +``` diff --git a/js/etc/riverqueue.unstable-driver.api.md b/js/etc/riverqueue.unstable-driver.api.md new file mode 100644 index 000000000..0b7d5ccf9 --- /dev/null +++ b/js/etc/riverqueue.unstable-driver.api.md @@ -0,0 +1,2566 @@ +# API report: `riverqueue/unstable-driver` + + + +This report contains declarations and TSDoc for names exported by the +package entry point. Private members, unexported implementation +declarations, and file layout are omitted. + +## `abortableDelay` + +```ts +/** + * Resolve after `milliseconds`, or reject with `signal.reason` as soon as the + * signal aborts. + */ +export declare function abortableDelay( + milliseconds: number, + signal: AbortSignal +): Promise; +``` + +## `BackendResult` + +```ts +/** A backend operation may be native-async or synchronously serialized. */ +export type BackendResult = PromiseLike | T; +``` + +## `buildUniqueKey` + +```ts +/** + * Exact-version hashing seam shared by insertion and conformance. Like + * River for Go, a period key uses the job's scheduled time, or the current + * time for a job without one. + */ +export declare function buildUniqueKey( + params: Pick, + options: UniqueOptions +): [Uint8Array, readonly JobState[]]; +``` + +## `canonicalDecimal` + +```ts +/** + * The canonical form of the JSON number `source`, such as `1e2` for + * `100.0`, so numerically equal tokens compare equal as text. + * + * @throws {JsonValueError} when `source` isn't a JSON number. + */ +export declare function canonicalDecimal(source: string): string; +``` + +## `canonicalEqualityDecimal` + +```ts +/** Like {@link canonicalDecimal}, but with `-0` equal to `0`. */ +export declare function canonicalEqualityDecimal(source: string): string; +``` + +## `compareUtf8` + +```ts +/** Compare two strings by their UTF-8 bytes, as Go compares strings. */ +export declare function compareUtf8(left: string, right: string): number; +``` + +## `createJobArgsTransformPlugin` + +```ts +/** + * Create a plugin that transforms job arguments as they are written to and + * read from the database. + */ +export declare function createJobArgsTransformPlugin( + transformer: JobArgsTransformer +): JobArgsTransformPlugin; +``` + +## `createJobInsertMetadataTransformPlugin` + +```ts +/** + * Create a plugin that adds or rewrites a job's metadata at insert time, + * before argument transforms run, and may insert the job as `pending`. + */ +export declare function createJobInsertMetadataTransformPlugin( + transformer: JobInsertMetadataTransformer +): JobInsertMetadataTransformPlugin; +``` + +## `createResumable` + +```ts +/** Construct an attempt-scoped resumable coordinator for first-party tooling. */ +export declare function createResumable(client: Client, job: JobRow): Resumable; +``` + +## `decodeAttemptError` + +```ts +/** + * Decode one persisted attempt error from its JSON text, as River for Go's + * drivers do when they read a job's `errors`. + * + * River always writes attempt errors in one shape, which decodes as Go's + * `encoding/json` decodes it. Because a job can't be read or worked unless + * all of its attempt errors decode, an element written by another tool or + * edited by hand that is valid JSON in any other shape decodes on a best + * effort basis instead: + * + * - Field names match case-insensitively, and unknown fields are ignored. + * - `at` accepts only what Go's `time.Time` does: RFC 3339 as Go's + * `time.Parse` reads it, taken from the string as written without + * unescaping it. Anything else is Go's zero time. + * - `attempt` accepts integers, and numbers or strings holding a number with + * an integral value no larger in magnitude than 2^53. Anything else is + * `0`. + * - `error` and `trace` keep any value other than a string or `null` as its + * compacted JSON text. + * - A string element is used as `error`, and any other element that isn't + * an object is kept as its compacted JSON text in `error`. + * + * Only text that isn't valid JSON throws. This tolerance is for database + * reads only: decoding a job's public JSON form stays strict. + */ +export declare function decodeAttemptError(json: string): AttemptError; +``` + +## `decodeAttemptErrors` + +```ts +/** + * Decode a persisted JSON array of attempt errors, decoding each element + * like {@link decodeAttemptError}. `null` is empty. Text that isn't valid + * JSON, or that isn't an array, throws, so the job can be reported as + * undecodable. + */ +export declare function decodeAttemptErrors(json: string): AttemptError[]; +``` + +## `decodeJobArgs` + +```ts +/** Validate persisted args with a definition and return the worker's args. */ +export declare function decodeJobArgs( + definition: Definition, + value: unknown +): Promise>; +``` + +## `decodeJobListCursor` + +```ts +/** + * Decode a job list cursor from any River implementation. Like River for + * Rust, this accepts the URL-safe or standard base64 alphabet, with or + * without padding, so tokens from every River for Go release decode. Fields + * other than Go's five are ignored. A zero time, as Go writes for ID + * ordering, decodes to no time. + */ +export declare function decodeJobListCursor(cursor: string): JobListCursorValue; +``` + +## `decodeJobState` + +```ts +/** Validate a persisted job state, rejecting values River does not define. */ +export declare function decodeJobState(value: string): JobState; +``` + +## `DriverAttemptError` + +```ts +/** An attempt error as a completion command persists it. */ +export interface DriverAttemptError { + readonly at: Temporal.Instant; + readonly error: string; + readonly trace: string; +} +``` + +## `DriverInsertResult` + +```ts +/** A row returned by an insertion adapter. */ +export interface DriverInsertResult { + readonly job: JobRow; + readonly status: "duplicate" | "inserted"; +} +``` + +## `driverMigrationTarget` + +```ts +/** + * The connection `createMigrator` migrates for a registered driver, or + * undefined when `handle` isn't one or its driver can't migrate. + */ +export declare function driverMigrationTarget( + handle: unknown +): DriverMigrationTarget | undefined; +``` + +## `DriverMigrationTarget` + +```ts +/** + * The connection a driver's migrations run on: a node-postgres pool or + * connected client with River's schema (undefined for the `search_path`), or + * a `node:sqlite` database. + */ +export type DriverMigrationTarget = + | { + readonly client: object; + readonly schema: string | undefined; + } + | { + readonly database: object; + /** + * Run one synchronous migration attempt on `database` under the + * driver's lock, retrying the whole attempt while another connection + * holds SQLite's write lock. The attempt leaves no transaction open + * when it throws. + */ + readonly run?: (attempt: (database: object) => T) => Promise; + } + | { + readonly pool: object; + readonly schema: string | undefined; + }; +``` + +## `DriverRecord` + +```ts +/** + * What a first-party driver registers about one driver instance with + * {@link registerDriver}. + */ +export interface DriverRecord { + /** The backend's name, such as `postgres` or `sqlite`, for errors. */ + readonly backend: string; + /** Whether the driver can run workers, or only insert. */ + readonly capability: "insert" | "runtime"; + /** The database a pilot uses, when the driver supports pilots. */ + readonly database?: PilotDatabase; + /** + * The connection and schema `createMigrator` migrates when given this + * driver, when the driver supports migrations. + */ + readonly migration?: DriverMigrationTarget; + /** + * The operations River runs through the driver, kept off the public + * driver object. A runtime driver's must implement every runtime + * operation. + */ + readonly operations: InsertDriver; +} +``` + +## `DurablePeriodicJobUpsert` + +```ts +/** A durable next-run time persisted with a periodic insertion batch. */ +export interface DurablePeriodicJobUpsert { + readonly id: string; + readonly nextRunAt: Temporal.Instant; + readonly updatedAt: Temporal.Instant; +} +``` + +## `DurationInput` + +```ts +/** A duration River accepts: `Temporal.Duration` or a like such as `{ seconds: 5 }`. */ +export type DurationInput = Temporal.Duration | Temporal.DurationLike; +``` + +## `DurationRules` + +```ts +/** How {@link toDuration} and its variants validate a duration. */ +export interface DurationRules { + /** Whether zero is allowed. Defaults to false. */ + readonly allowZero?: boolean; + /** Error class to throw; defaults to `ConfigurationError`. */ + readonly error?: new (message: string, options?: ErrorOptions) => Error; +} +``` + +## `durationToMilliseconds` + +```ts +/** + * Convert a duration option to whole milliseconds for timers, rounding a + * sub-millisecond remainder up so a positive duration never becomes zero. + */ +export declare function toMilliseconds( + name: string, + value: DurationInput, + rules?: DurationRules +): number; +``` + +## `encodeJobListCursor` + +```ts +/** + * Encode the cursor after `job` in a list with `params`' ordering exactly as + * River for Go's `JobListCursor` marshals it, so a token from any River + * implementation can continue a listing in any other. + */ +export declare function encodeJobListCursor( + job: JobRow, + params: Pick +): string; +``` + +## `encodeUniqueArgs` + +```ts +/** + * Encode the arguments part of a unique key exactly as River for Go does: + * the text hashed after `&args=`. With `byArgs: true` it is every top-level + * argument, keys sorted bytewise; with a list of paths it is the selected + * values assembled in sorted path order, or an empty string when none of the + * paths is present. An unescaped dot descends into an object; a backslash + * quotes the following character for a literal field name. Paths sort by + * their unescaped field names. Keys are written the way Go's `sjson` writes + * them, and nested values keep their own order. + * + * Arguments must be a JSON object. Like River for Go, `byArgs: true` hashes + * an empty array as `{}`. + * + * Extensions that derive other keys from job arguments use this so they + * hash arguments identically to River's unique keys. + * + * @throws {ValidationError} for arguments that aren't a JSON object (other + * than an empty array with `byArgs: true`), an invalid selected path, + * including one with a segment River for Go reads as an array index (an + * unsigned integer or `-1`), or an empty path list. + */ +export declare function encodeUniqueArgs( + args: JsonObject, + byArgs: true | readonly string[] +): string; +``` + +## `FinalizedJobDeleteParams` + +```ts +/** + * Filters for one batch of {@link PilotDatabase.deleteFinalizedJobs}, the + * same ones River's job cleaner uses. A job is deleted when it's in a state + * whose cutoff is set and was finalized before that cutoff. + */ +export interface FinalizedJobDeleteParams { + /** Delete cancelled jobs finalized before this, or none when `null`. */ + readonly cancelledBefore: Temporal.Instant | null; + /** Delete completed jobs finalized before this, or none when `null`. */ + readonly completedBefore: Temporal.Instant | null; + /** Delete discarded jobs finalized before this, or none when `null`. */ + readonly discardedBefore: Temporal.Instant | null; + /** The most jobs the batch deletes, taking the lowest IDs first. */ + readonly limit: number; + /** Queues whose jobs are kept, even when `queuesIncluded` lists them. */ + readonly queuesExcluded?: readonly string[]; + /** + * Queues the batch is limited to. Absent or `null` matches every queue, + * while an empty list matches none. + */ + readonly queuesIncluded?: readonly string[] | null; +} +``` + +## `finishResumable` + +```ts +/** Finalize resumable state for first-party worker test helpers. */ +export declare function finishResumable( + resumable: Resumable, + workerFailed: boolean +): ResumableFinish; +``` + +## `InsertDriver` + +```ts +/** + * Narrow protocol implemented by River's producer adapters. + * + * This is not the full runtime database engine boundary. PostgreSQL schema and + * other backend-specific configuration belong to the adapter constructor. + */ +export interface InsertDriver< + Transaction = unknown, + Capability extends DriverCapability = "insert", +> extends ClientDriver { + jobInsert( + params: JobInsertParams, + options?: InsertDriverOptions + ): BackendResult; + jobInsertMany( + params: readonly JobInsertParams[], + options?: InsertDriverOptions + ): BackendResult; + /** + * Send an insert notification for each of `queues`, in `options.tx` when + * given, like River for Go's `NotifyMany` on its insert topic. Insertion + * itself notifies nobody: after inserting available jobs, the client calls + * this in the same transaction for the queues its insert notification + * limiter allows. A driver without this method sends no insert + * notifications. + */ + notifyInsert?( + queues: readonly string[], + options?: InsertDriverOptions + ): BackendResult; + /** + * Run one River operation in a transaction, like River for Go's + * `dbutil.WithTxV`. The client runs a whole insertion in one scope + * (validation, insert middleware, hooks, and the write), and the leader + * runs a durable periodic batch together with its next-run times. + * + * With `tx`, the operation joins that caller-owned transaction: the driver + * checks it and calls `callback` with it, and never commits or rolls it + * back. Without `tx`, the driver opens a transaction River owns, calls + * `callback` with a value that stands for it, commits when `callback` + * resolves, and rolls back when it rejects. + * + * The value passed to `callback` is only for River operations on this + * driver, passed as `{ tx }`. For some backends it is an opaque token + * rather than a usable connection. A driver without this method runs + * every operation without a scope. + */ + operationScope?( + tx: Transaction | undefined, + callback: (tx: Transaction) => Promise + ): Promise; +} +``` + +## `InsertDriverOptions` + +```ts +/** Options passed to an insertion adapter operation. */ +export interface InsertDriverOptions { + /** Optional caller-owned transaction for this operation. */ + tx?: Transaction; +} +``` + +## `interruptibleDelay` + +```ts +/** + * Resolve after `milliseconds`, or early as soon as the signal aborts. Unlike + * {@link abortableDelay} this never rejects, which suits loops that check + * `signal.aborted` themselves after each pause. + */ +export declare function interruptibleDelay( + milliseconds: number, + signal: AbortSignal +): Promise; +``` + +## `JobArgsInsertTransformInput` + +```ts +/** Immutable insertion input for an exact-version argument transformer. */ +export interface JobArgsInsertTransformInput { + readonly args: ReadonlyJsonObject; + /** + * The job definition the caller inserted (the same object identity), or + * undefined for an insertion without one. + */ + readonly definition: JobDefinition | undefined; + readonly encodedArgs: string; + readonly kind: string; +} +``` + +## `JobArgsInsertTransformOutput` + +```ts +/** Validated insertion output from an exact-version argument transformer. */ +export interface JobArgsInsertTransformOutput { + readonly args: ReadonlyJsonObject; + readonly encodedArgs: string; +} +``` + +## `JobArgsReadTransformInput` + +```ts +/** Immutable persisted input for an exact-version argument transformer. */ +export interface JobArgsReadTransformInput { + readonly args: ReadonlyJsonObject; + readonly kind: string; +} +``` + +## `JobArgsTransformer` + +```ts +/** + * Transforms job arguments as they are written to and read from the + * database, for extensions that store arguments in another form. Extensions + * using it must pin the exact `riverqueue` version. + * + * Insertion transformations run in plugin order. Read transformations run in + * reverse order so independently composed codecs unwrap in the natural order. + * Insert middleware and hooks observe storage-shaped arguments. Immediate + * insert results are unwrapped to preserve their typed input contract; query + * and administrative operations continue to expose storage-shaped rows. + * Omitting `onInsert` is a read-only migration mode; in that mode `onRead` + * must pass through the plaintext rows this client continues to insert. + */ +export interface JobArgsTransformer { + readonly name: string; + readonly onInsert?: ( + input: JobArgsInsertTransformInput + ) => JobArgsInsertTransformOutput; + readonly onRead: (input: JobArgsReadTransformInput) => ReadonlyJsonObject; +} +``` + +## `JobArgsTransformPlugin` + +```ts +/** Opaque River plugin produced by {@link createJobArgsTransformPlugin}. */ +export interface JobArgsTransformPlugin extends RiverPlugin { + readonly [jobArgsTransformPluginBrand]: true; +} +``` + +## `JobCancellationNotice` + +```ts +/** Remote cancellation of an attempt currently owned by this process. */ +export interface JobCancellationNotice { + readonly attemptedBy: string; + readonly id: bigint; +} +``` + +## `JobClaimOptions` + +```ts +/** + * Options for {@link RuntimeDriver.jobClaim}. With `tx`, the claim runs in + * that transaction, which its owner commits or rolls back; without it, the + * claim commits on its own. + */ +export interface JobClaimOptions< + Transaction = unknown, +> extends RuntimeWaitOptions { + readonly tx?: Transaction; +} +``` + +## `JobClaimParams` + +```ts +/** Which jobs a claim may lock, and the client that claims them. */ +export interface JobClaimParams { + readonly attemptedBy: string; + /** + * Claim only jobs of these kinds, filtered before the limit and locking, + * or jobs of every kind when empty. + */ + readonly kinds: readonly string[]; + readonly queues: readonly JobClaimQueue[]; +} +``` + +## `JobClaimQueue` + +```ts +/** One queue and capacity request in an atomic claim operation. */ +export interface JobClaimQueue { + readonly limit: number; + readonly name: string; +} +``` + +## `JobClaimResult` + +```ts +/** + * Jobs locked by {@link RuntimeDriver.jobClaim}, in the order they were + * claimed. Every job has been moved to `running`, including any whose row + * couldn't be fully decoded, so the runtime must finish an attempt for each + * of them. + */ +export interface JobClaimResult { + /** + * Why rows couldn't be fully decoded, by job ID. Absent or empty when + * every row decoded. River doesn't work such a job: its attempt fails with + * the decode error through the normal failure path. + */ + readonly decodeErrors?: ReadonlyMap; + /** + * Every claimed job, including those whose rows couldn't be fully decoded, + * which have the fields that couldn't be decoded left empty (`{}` or + * `[]`) and their errors in `decodeErrors`. + */ + readonly jobs: readonly JobRow[]; +} +``` + +## `JobCompletionCommand` + +```ts +/** Attempt-identity-safe terminal command. */ +export interface JobCompletionCommand { + readonly attempt: number; + readonly attemptedBy: string; + /** + * Persist a `retry` or `snooze` as `available` rather than `retryable` or + * `scheduled` because its delay is within the scheduler interval, like + * River's near-future fast path. Producers claim it once `scheduledAt` + * passes. Whether the attempt is refunded still follows `kind`: a snooze + * or interruption refunds it and a retry never does. + */ + readonly available?: boolean; + readonly error: DriverAttemptError | null; + /** Captured handler-finish time for terminal transitions; otherwise null. */ + readonly finalizedAt: Temporal.Instant | null; + readonly id: bigint; + readonly kind: + "cancel" | "complete" | "discard" | "interrupt" | "retry" | "snooze"; + /** Atomically merged attempt metadata (resumable checkpoints, etc.). */ + readonly metadata?: JsonObject; + readonly output: JsonValue | null; + /** Distinguishes no output update from recording the JSON value `null`. */ + readonly outputSet: boolean; + readonly scheduledAt: Temporal.Instant | null; +} +``` + +## `jobCompletionKey` + +```ts +/** + * Identify one completion command by its attempt: job ID, attempt number, and + * the claiming client. Drivers return it as {@link JobCompletionResult.key} + * so the runtime can match each result to the attempt that produced it. + */ +export declare function jobCompletionKey( + command: Pick +): string; +``` + +## `JobCompletionResult` + +```ts +/** A completion applies only when the claimed attempt still owns the row. */ +export interface JobCompletionResult { + readonly job: JobRow | null; + /** The {@link jobCompletionKey} of the command this result answers. */ + readonly key: string; + readonly status: "applied" | "stale"; +} +``` + +## `JobDeleteManyParams` + +```ts +/** Bounded filters for deleting non-running jobs. */ +export interface JobDeleteManyParams { + readonly all: boolean; + readonly ids: readonly bigint[]; + readonly kinds: readonly string[]; + readonly limit: number; + readonly priorities: readonly number[]; + readonly queues: readonly string[]; + readonly states: readonly JobState[]; +} +``` + +## `JobDeleteResult` + +```ts +/** Semantic result of deleting one job without deleting running work. */ +export type JobDeleteResult = + | { + readonly job: JobRow; + readonly status: "deleted"; + } + | { + readonly job: JobRow; + readonly status: "running"; + } + | { + readonly status: "not_found"; + }; +``` + +## `JobInsertMetadataTransformer` + +```ts +/** + * Adjusts an insertion's metadata (and optionally makes it `pending`) before + * uniqueness is computed and before argument transforms run. Transformers + * run in plugin order on every insertion path. + */ +export interface JobInsertMetadataTransformer { + readonly name: string; + readonly onInsert: ( + input: JobInsertMetadataTransformInput + ) => JobInsertMetadataTransformResult; +} +``` + +## `JobInsertMetadataTransformInput` + +```ts +/** Plaintext insertion input for a matched-version metadata transformer. */ +export interface JobInsertMetadataTransformInput { + readonly args: ReadonlyJsonObject; + /** + * The job definition the caller inserted (the same object identity), or + * undefined for an insertion without one. Extensions may key per-definition + * behavior off it, for example with a `WeakMap`. + */ + readonly definition: JobDefinition | undefined; + readonly kind: string; + readonly metadata: ReadonlyJsonObject; + /** Whether the job will be inserted `pending` so far. */ + readonly pending: boolean; + readonly queue: string; +} +``` + +## `JobInsertMetadataTransformPlugin` + +```ts +/** Opaque plugin produced by {@link createJobInsertMetadataTransformPlugin}. */ +export interface JobInsertMetadataTransformPlugin extends RiverPlugin { + readonly [jobInsertMetadataTransformPluginBrand]: true; +} +``` + +## `JobInsertMetadataTransformResult` + +```ts +/** What a metadata transformer returns for one insertion. */ +export interface JobInsertMetadataTransformResult { + /** The job's complete metadata after this transformer. */ + readonly metadata: ReadonlyJsonObject; + /** + * Insert the job `pending` instead of available or scheduled, like the + * `pending` insert option. A transformer can only set it, never clear it. + */ + readonly pending?: true; +} +``` + +## `JobInsertParams` + +```ts +/** Internal exact insertion parameters sent to a first-party adapter. */ +export interface JobInsertParams { + readonly args: JsonObject; + /** + * The row's creation time. Omit it, as River's own inserts do, to use the + * current time. A caller that reinserts a job it took out of River sets it + * to keep the job's original creation time, like the `CreatedAt` of River + * for Go's driver insert parameters. + */ + readonly createdAt?: Temporal.Instant; + /** + * The arguments as JSON text, which drivers store instead of re-encoding + * `args`. + */ + readonly encodedArgs: string; + readonly kind: string; + readonly maxAttempts: number; + readonly metadata: JsonObject; + readonly priority: number; + readonly queue: string; + /** + * When the job becomes workable. Omit it, as River does for a job + * inserted without a schedule, to use the database's current time, like + * River for Go, so an application clock ahead of the database's doesn't + * delay the job. + */ + readonly scheduledAt?: Temporal.Instant; + readonly state: JobState; + readonly tags: readonly string[]; + readonly uniqueKey: Uint8Array | null; + /** Persisted states participating in uniqueness conflicts. */ + readonly uniqueStates: readonly JobState[] | null; +} +``` + +## `JobListAfter` + +```ts +/** + * Where a job list resumes, relative to its ordering: after an ID alone, + * after a cursor job whose time field is null, or after a cursor job's time. + */ +export type JobListAfter = + | { + readonly id: bigint; + readonly kind: "id"; + } + | { + readonly id: bigint; + readonly kind: "nullTime"; + } + | { + readonly id: bigint; + readonly kind: "time"; + readonly time: Temporal.Instant; + }; +``` + +## `jobListCursorValue` + +```ts +/** + * The cursor value after `job` in a list with `params`' ordering. Like River + * for Go, its time is the job's value of the field the list is ordered by, + * which for `time` ordering over several states can differ from the field of + * the job's own state, and `null` when that field is null for the job. + */ +export declare function jobListCursorValue( + job: JobRow, + params: Pick +): JobListCursorValue; +``` + +## `JobListCursorValue` + +```ts +/** + * Exact keyset boundary passed to full-engine backends. Like River for Go, + * `time` is the value of the field the list is ordered by, and `null` when + * ordering by ID or when that field is null for the cursor's job. For a field + * that can't be null for the listed states, a boundary without a `time` + * resumes after `id` alone. + */ +export interface JobListCursorValue { + readonly id: bigint; + readonly kind: string; + readonly queue: string; + readonly sortField: JobListOrderBy; + readonly time: Temporal.Instant | null; +} +``` + +## `jobListKeyset` + +```ts +/** + * How a job list with `params` is ordered and where it resumes. Every + * backend renders it with {@link jobListKeysetSql}. + */ +export declare function jobListKeyset( + params: Pick< + JobListParams, + "after" | "sortDirection" | "sortField" | "states" + > +): JobListKeyset; +``` + +## `JobListKeyset` + +```ts +/** + * How a job list is ordered and where it resumes, which each backend renders + * as SQL so that every backend orders and pages identically. + */ +export interface JobListKeyset { + readonly after: JobListAfter | null; + readonly direction: SortDirection; + /** + * Whether the time field may be null for listed jobs. Nulls then sort + * explicitly last ascending and first descending, PostgreSQL's default, so + * every backend agrees and cursors can match them. + */ + readonly nullable: boolean; + /** The time column ordered before ID, or `null` to order by ID alone. */ + readonly timeField: JobListTimeField | null; +} +``` + +## `jobListKeysetSql` + +```ts +/** + * Render `keyset` as SQL: the condition selecting rows after its cursor, or + * `null` without one, and the `ORDER BY` terms. `bind` returns the + * placeholder of each parameter, in the order they appear in the condition. + */ +export declare function jobListKeysetSql( + keyset: JobListKeyset, + bind: (value: bigint | Temporal.Instant) => string +): { + readonly after: string | null; + readonly orderBy: string; +}; +``` + +## `JobListOrderBy` + +```ts +/** + * The field jobs are listed by. `time` is the time field of the first listed + * state (`scheduled_at` when no state is listed), like River for Go. + */ +export type JobListOrderBy = "finalizedAt" | "id" | "scheduledAt" | "time"; +``` + +## `JobListParams` + +```ts +/** Normalized, backend-neutral job list operation. */ +export interface JobListParams { + readonly after: JobListCursorValue | null; + readonly ids: readonly bigint[]; + readonly kinds: readonly string[]; + readonly limit: number; + readonly metadata: JsonObject | null; + readonly priorities: readonly number[]; + readonly queues: readonly string[]; + readonly sortDirection: SortDirection; + readonly sortField: JobListOrderBy; + readonly states: readonly JobState[]; + readonly tagsAll: readonly string[]; + readonly tagsAny: readonly string[]; +} +``` + +## `JobListTimeField` + +```ts +/** A time column a job list can be ordered by. */ +export type JobListTimeField = "attempted_at" | "finalized_at" | "scheduled_at"; +``` + +## `JobUpdateParams` + +```ts +/** Job update. Omitted fields leave the job unchanged. */ +export interface JobUpdateParams { + /** Merge these top-level keys into the job's metadata. */ + readonly metadata?: JsonObject; + /** Set the job's output at `metadata.output`. */ + readonly output?: JsonValue; +} +``` + +## `jsonValuesEqual` + +```ts +/** Compare two validated River JSON values using database JSON semantics. */ +export declare function jsonValuesEqual( + left: JsonValue, + right: JsonValue +): boolean; +``` + +## `LeaderTerm` + +```ts +/** + * One maintenance leadership term: the client that leads (`leaderId`), when it + * was elected, and when its lease expires unless renewed. A new election + * starts a new term with a new `electedAt`. + */ +export interface LeaderTerm { + readonly electedAt: Temporal.Instant; + readonly expiresAt: Temporal.Instant; + readonly leaderId: string; +} +``` + +## `LinkedAbortSignal` + +```ts +/** + * A signal that aborts when the first of its parents aborts, with that + * parent's reason, as `AbortSignal.any(parents)` does, until it is disposed. + * + * Node cleans up each signal `AbortSignal.any` creates by scanning every + * other dependent of the same parent, so dependents of a long-lived parent, + * such as a runtime's run signal, cost time quadratic in their number. This + * signal instead holds a `{ once: true }` listener on each parent, which + * disposing it removes. Dispose it when its operation settles; the parents + * no longer abort it after that. + * + * A parent that is already aborted aborts it at once, the first such parent + * in order. Otherwise it aborts inside its parent's abort event, after the + * parent's listeners added before it. + */ +export declare class LinkedAbortSignal implements Disposable { + readonly signal: AbortSignal; + constructor(parents: readonly AbortSignal[]); + /** Stop listening to the parents. */ + [Symbol.dispose](): void; +} +``` + +## `ManualTimeout` + +```ts +/** A timeout signal from {@link ManualTimer.timeout}. */ +export interface ManualTimeout { + readonly signal: AbortSignal; + /** Cancel the timeout once the guarded work settled. */ + dispose(): void; +} +``` + +## `ManualTimer` + +````ts +/** + * A timer on a virtual clock that only moves when a test advances it, so + * code that waits on delays and deadlines runs deterministically. Pass it + * as the `timer` of `overrideRuntimeTiming` to drive a client's + * runtime, including a pilot's producer reports and services: + * + * ```ts + * const timer = new ManualTimer(); + * overrideRuntimeTiming(client, { timer }); + * await client.start(); + * await timer.waitFor((entry) => entry.ms === 30_000); + * await timer.advance(30_000); + * ``` + */ +export declare class ManualTimer { + /** + * Move the clock forward by `ms`, firing each delay and timeout that + * comes due in order, and letting the code they wake run before the next + * fires, so timers it creates within the window fire too. + */ + advance(ms: number): Promise; + /** + * Resolve after `ms` on the virtual clock, or reject with `signal.reason` + * as soon as `signal` aborts. + */ + delay(ms: number, signal: AbortSignal): Promise; + /** The virtual clock's time, in milliseconds. */ + now(): number; + /** Pending delays and timeouts, earliest first. */ + pending(): readonly ManualTimerEntry[]; + /** A signal that aborts with `reason()` once `ms` elapsed. */ + timeout(ms: number, reason: () => unknown): ManualTimeout; + /** + * Wait, in real time, until a pending entry matches `predicate`, and + * return it. Rejects after `timeoutMs` (default 5 s) of real time. + */ + waitFor( + predicate: (entry: ManualTimerEntry) => boolean, + options?: { + readonly timeoutMs?: number; + } + ): Promise; +} +```` + +## `ManualTimerEntry` + +```ts +/** A pending delay or timeout of a {@link ManualTimer}. */ +export interface ManualTimerEntry { + /** Virtual time, in milliseconds, at which it fires. */ + readonly dueAt: number; + readonly kind: "delay" | "timeout"; + /** The duration it was created with. */ + readonly ms: number; +} +``` + +## `numberRoundTrips` + +```ts +/** + * Whether `value`, parsed from the JSON number `source`, is exactly the + * number `source` denotes, so re-encoding it can't change its value. + */ +export declare function numberRoundTrips( + source: string, + value: number +): boolean; +``` + +## `OperationTimeout` + +```ts +/** A disposable timeout signal returned by {@link RuntimeTimer.timeout}. */ +export interface OperationTimeout { + readonly signal: AbortSignal; + dispose(): void; +} +``` + +## `overrideRuntimeTiming` + +```ts +/** + * Replace the clock, randomness, and timers of the runtime `client` starts + * next, including those it runs a companion's producer sessions and + * services with: report jitter and intervals, keep-alive and shutdown + * deadlines, service backoff, and leadership deadlines. Call it before + * `start()`; a running runtime keeps its timing. For tests only. + */ +export declare function overrideRuntimeTiming( + client: object, + timing: RuntimeTiming +): void; +``` + +## `PeerClaimContext` + +```ts +/** What a peer claim's callback receives. */ +export interface PeerClaimContext { + /** + * The claiming attempt's signal. It aborts when the attempt is cancelled, + * by a hard stop, its job's cancellation, or its timeout, and once the + * attempt finished; River then rolls the claim back if it hasn't + * committed. A graceful stop doesn't abort it: a coordinator still + * running keeps claiming, and the stop waits for the peers it claims. + */ + readonly signal: AbortSignal; + /** The transaction the claim commits in, once the callback resolves. */ + readonly tx: Transaction; +} +``` + +## `PeerOutcome` + +```ts +/** The outcome of one peer, for {@link PilotAttempts.complete}. */ +export interface PeerOutcome { + /** + * The peer, as {@link PilotAttempts.claim} returned it. Its ID, attempt, + * and attempting client identify it; River persists the row it tracks. + */ + readonly job: JobRow; + readonly result: WorkAttemptResult; +} +``` + +## `PeriodicJobStore` + +```ts +/** + * A pilot's durable storage for periodic job schedules, so the next run of + * a periodic job with an `id` survives leader changes and restarts. It + * mirrors River for Go's periodic-job pilot operations. + * + * The leader calls `getAll` when it starts enqueuing periodic jobs and seeds + * each job's next run from the matching record; calls `upsertMany` inside the + * same transaction that inserts each batch of periodic jobs; and calls + * `keepAliveAndReap` with the registered IDs every ten minutes so the store + * can delete records for jobs no client registers anymore. + */ +export interface PeriodicJobStore { + /** Return every durable periodic job record. */ + getAll(options: { + readonly signal: AbortSignal; + }): PromiseLike; + /** Refresh records for `ids` and delete records not refreshed recently. */ + keepAliveAndReap( + ids: readonly string[], + options: { + readonly signal: AbortSignal; + } + ): PromiseLike; + /** Persist next-run times in the transaction inserting their jobs. */ + upsertMany( + tx: Transaction, + jobs: readonly DurablePeriodicJobUpsert[] + ): PromiseLike; +} +``` + +## `Pilot` + +```ts +/** + * The one companion attached to a client. Every member is optional. `init` + * runs with the pilot as `this`, and interceptors with `intercept`. + */ +export interface Pilot< + Transaction, + QueueSettings = unknown, + ClientType extends Client = Client, +> { + /** + * Called once, synchronously, while the client is constructed. It must + * not perform I/O or call the client: `host.client` is usable only once + * construction returns. + */ + init?(host: PilotHost): void; + readonly intercept?: PilotInterceptors; + readonly queueOptions?: PilotQueueOptions; + /** + * Start the producer session of one queue generation, once River has + * persisted the queue and before its first claim. River owns the + * session's lifetime: it claims through it, reports configuration + * changes and finished jobs to it, keeps it alive, and shuts it down once + * the queue drained. A rejection fails the queue's start, and the pilot + * must first release whatever it allocated. + */ + startProducer?( + context: ProducerStartContext + ): Promise>; + /** + * The most background completion batches this client persists at once. + * It counts local batches, not database connections, and never limits a + * worker's transactional completion. A batch waiting its turn doesn't + * spend its timeout or retries, and a stop still persists every pending + * completion. Default: no limit. + */ + readonly completionConcurrency?: number; + /** Queues River's job cleaner leaves alone, for the pilot to clean. */ + readonly jobCleanerQueuesExcluded?: readonly string[]; + /** + * Durable storage for periodic job schedules, used instead of a periodic + * job store plugin. River runs `upsertMany` in the transaction inserting + * the periodic jobs, and passes it a native handle, as it does to + * interceptors. + */ + readonly periodicJobs?: PeriodicJobStore; + /** + * Services River runs for as long as the runtime runs, listed once per + * run and started before any queue claims. See {@link PilotService}. + * Services run in the background, so a service can't fail the client's + * `start()`; one that keeps failing is logged and restarted instead. + */ + services?(): readonly PilotService[]; + /** + * Services River runs while this client leads maintenance, once per + * leadership term, with the term and a signal that aborts when the term + * ends. A new term's services start only once the previous term's + * settled. Listed once per run. + */ + maintenanceServices?(): readonly PilotService[]; +} +``` + +## `PilotAttempts` + +```ts +/** + * Peer attempts: jobs a running attempt of this client, their coordinator, + * works together with its own job, such as a group of related jobs handled + * in one go. River tracks each peer under its coordinator from the claim's + * commit until its outcome persists. Peers don't take the queue's worker + * slots, and their producer session never hears of them. River doesn't + * cancel a peer remotely; cancelling the coordinator's job reaches peers + * only through the coordinator's signal. + * + * `attempt` is the coordinator's context, as its handler or middleware + * received it. Both methods reject once the coordinator's attempt ended, + * including while its own outcome persists, and a claim also rejects once + * the attempt is cancelled. A graceful stop ends neither: a running + * coordinator keeps claiming and completing peers, and the stop resolves + * only after they have outcomes. When the attempt ends, River waits for + * the calls it accepted, then completes every peer still without an + * outcome: it interrupts them only when the runtime stopped or cancelled + * its work (the attempt's abort reason is a `LifecycleError`), and + * otherwise, including after the coordinator's job was cancelled or timed + * out, fails them with an `ExtensionError`, so the retry policy applies. + */ +export interface PilotAttempts { + /** + * Claim peers of `attempt`. River begins a transaction and calls `run` + * with it; `run` moves the peers to `running` for this client with its + * own statements in that transaction and resolves with them read back by + * {@link PilotDatabase.loadClaimed}. River checks them before committing + * and rolls back, rejecting with an `ExtensionError`, unless each is + * running, on an attempt of this client, listed once, and neither + * `attempt`'s own job nor a job this client already works as an attempt + * or peer, nor one this coordinator already finished at that attempt. + * + * Resolves with the peers after the client's argument transforms. A peer + * River can't decode or transform isn't returned: River completes it as + * a failed attempt. + */ + claim( + attempt: WorkAttemptContext | WorkContext, + run: (context: PeerClaimContext) => Promise + ): Promise; + /** + * Complete peers of `attempt` through River's completion pipeline, like + * the coordinator's own outcome: the error handler runs for failures, + * metadata the coordinator set is added, and the pilot's `complete` + * interceptor sees the persistence. Resolves once every outcome + * persisted. + * + * River accepts the outcomes all or none: each job must be a peer of + * `attempt` without an outcome yet, listed once. An outcome that fails + * before River's completer accepts it, such as an invalid result or one + * whose output is too large, leaves its peer without one, and the call + * rejects; once accepted, the outcome is River's, and its peer is never + * completed again. + */ + complete( + attempt: WorkAttemptContext | WorkContext, + outcomes: readonly PeerOutcome[] + ): Promise; +} +``` + +## `PilotClient` + +```ts +/** + * A River client with a pilot attached. A companion package's client + * extends {@link PilotClient}, and its public declarations name only an + * interface extending `Client`, so applications never see the pilot. + * + * `Config` is the queue configuration the run handle accepts, including the + * keys the pilot owns. + * + * Because an intercepted operation runs statements on a caller's + * transaction across its interceptor's awaits, this client's operations and + * its pilot's statements on one caller transaction run one at a time, in + * arrival order, like node-postgres runs a client's queries. One started + * from inside another, such as from an interceptor, runs inside it. + */ +export interface PilotClient< + Transaction = unknown, + Config extends QueueConfig = QueueConfig, +> extends Client { + start(): Promise>; +} + +/** The base class of a client with a pilot; see {@link PilotClient}. */ +export declare const PilotClient: PilotClientConstructor; +``` + +## `PilotClientConstructor` + +```ts +/** + * Constructor of {@link PilotClient}. It is abstract: only a subclass can + * call it, passing the factory of the client's pilot. + * + * River calls `createPilot` once, synchronously, with the driver's + * database, then the pilot's `init`, before the constructor returns, so a + * subclass's own fields aren't assigned yet when they run. The driver must + * be a first-party driver that supports pilots: `PgDriver` constructed with + * a `Pool`, or `SqliteDriver`. + */ +export type PilotClientConstructor = abstract new < + Transaction, + Config extends QueueConfig = QueueConfig, +>( + driver: ClientDriver, + options: PilotClientOptions, + createPilot: PilotFactory +) => PilotClient; +``` + +## `PilotClientOptions` + +```ts +/** + * Options of a {@link PilotClient}: a client's options, with queues that + * may use the keys its pilot owns. + */ +export type PilotClientOptions< + Transaction, + Config extends QueueConfig = QueueConfig, +> = Omit, "queues"> & { + /** Queues this client works, keyed by name. */ + readonly queues?: Readonly>; +}; +``` + +## `PilotCompleteContext` + +```ts +/** An intercepted completion batch. */ +export interface PilotCompleteContext< + Transaction, +> extends PilotTransactionContext { + readonly commands: readonly JobCompletionCommand[]; +} +``` + +## `PilotDatabase` + +```ts +/** + * A driver's database, as River hands it to a pilot: native connections and + * transactions on the connection River uses, plus the few River statements a + * companion needs inside its own transactions. + * + * `Transaction` is the driver's native handle: a node-postgres client, or a + * `node:sqlite` `DatabaseSync`. River's own transactions reach a pilot as + * native handles too, never as opaque values. Callbacks must not keep or + * close a handle, and must not begin, commit, or roll back transactions on + * it themselves. River can't detect a handle kept past its callback: a + * statement run on it later joins whatever that connection then has open. + */ +export interface PilotDatabase { + /** The backend's name, such as `"postgres"` or `"sqlite"`. */ + readonly backend: string; + /** The schema holding River's tables, or null for the default. */ + readonly schema: string | null; + /** + * Run `callback` with a native handle River has borrowed for it, which is + * not in a transaction: a pooled client on PostgreSQL, or River's own + * connection on SQLite while River's lock on it is held. Use it for reads + * and single autocommit statements. `signal` stops only the wait for the + * handle. A transaction the callback leaves open is rolled back, and the + * call rejects with a `TransactionScopeError`. + */ + connection( + callback: (handle: Transaction) => PromiseLike | Result, + options?: { + readonly signal?: AbortSignal; + } + ): Promise; + /** + * Run `callback` in a new transaction, committed once it resolves and + * rolled back when it rejects, or directly in a supplied `tx`. Like River + * for Go, River opens no savepoint in `tx` and never commits or rolls it + * back: when `callback` rejects, its writes stay in `tx` until the + * transaction's owner rolls it back. + * + * `signal` stops the wait to begin, and when it has aborted by the time + * `callback` resolves the work is rolled back instead of committed, or, + * in a supplied `tx`, the call rejects. + * `callback` runs at most once: it never runs when River can't begin, and + * is never run again. On SQLite, River retries beginning while another + * connection holds the write lock; on PostgreSQL, a failure to lease a + * connection or begin rejects at once. A failed commit rejects without + * claiming whether the database kept the changes. + * + * On SQLite, River's transaction holds the database's write lock, so + * `callback` may await only promises (other River work and statements), + * not I/O or timers, as for insert middleware. + */ + transaction( + callback: (tx: Transaction) => PromiseLike | Result, + options?: { + readonly signal?: AbortSignal; + readonly tx?: Transaction; + } + ): Promise; + /** + * Delete one batch of finalized jobs with River's job cleaner statement, + * resolving with how many it deleted, so an extension's own cleaner + * passes, such as per-queue retention, delete exactly what River's + * would. Like River for Go's driver `JobDeleteBefore`, the queue filters + * apply before the limit, so jobs they keep never use up a batch. It + * needs no leadership term. + * + * With `tx`, it runs in that transaction, otherwise in a transaction of + * its own. It has no timeout or cancellation; to bound the wait for a + * connection, run it in {@link PilotDatabase.transaction} with a signal. + */ + deleteFinalizedJobs( + params: FinalizedJobDeleteParams, + options?: { + readonly tx?: Transaction; + } + ): Promise; + /** + * Read claimed jobs in `tx`, decoding them as River's own claim does: a + * row that can't be fully decoded is returned with its error in + * `decodeErrors`. Rejects when an ID is repeated or has no row. + */ + loadClaimed( + ids: readonly bigint[], + options: { + readonly tx: Transaction; + } + ): Promise; + /** + * Send River notifications on `topic` in `tx`. They reach listeners only + * once `tx` commits, and never if it rolls back. + */ + notify( + topic: "control" | "insert", + payloads: readonly string[], + options: { + readonly tx: Transaction; + } + ): Promise; +} +``` + +## `PilotFactory` + +```ts +/** Creates the pilot of one client from its driver's database. */ +export type PilotFactory< + Transaction, + QueueSettings = unknown, + ClientType extends Client = Client, +> = ( + database: PilotDatabase +) => Pilot; +``` + +## `PilotHost` + +```ts +/** What River gives a pilot in {@link Pilot.init}. */ +export interface PilotHost< + Transaction, + ClientType extends Client = Client, +> { + /** Peer attempts of this client's running attempts. */ + readonly attempts: PilotAttempts; + /** + * The client, usable once its construction returns. It is the final + * client object, such as a companion package's subclass of + * `PilotClient`, which the pilot names as `ClientType`: River creates the + * pilot before that subclass exists, so it can't check the type. + */ + readonly client: ClientType; + readonly clientId: string; + readonly database: PilotDatabase; + readonly logger: Logger; + /** How often producers report their queues. */ + readonly producerReportInterval: Temporal.Duration; + /** The job kinds this client has workers for. */ + readonly workerKinds: readonly string[]; + /** + * Insert rows already prepared, such as jobs taken out of River that go + * back in with their stored fields, like an ordinary insertion of them: + * the client's insert metadata and argument transforms, insert + * middleware, and insert hooks each run once, then the pilot's insert + * interceptor, and inserts notify as usual. + * + * Transforms, middleware, and hooks see the stored arguments, read from + * `encodedArgs`, and no job definition. What they return is stored, so a + * transform keeps a row as it is by returning it unchanged, such as + * arguments it already transformed on an earlier insertion: + * `encodedArgs` passes through them byte for byte. Stored arguments that + * aren't a JSON object, which other River clients may insert, are kept + * as they are: argument transforms don't run for them, and metadata + * transforms, middleware, and hooks see empty arguments in their place. + * The unique key and states, creation time, and schedule are kept, and + * no unique key is computed. + */ + insertPrepared( + params: readonly PreparedInsertParams[], + options?: { + readonly signal?: AbortSignal; + readonly tx?: Transaction; + } + ): Promise; + /** + * Wake this client's producers for inserted jobs another transaction + * owner has committed. It's only a local optimization: producers find the + * jobs anyway. + */ + notifyCommitted(results: readonly DriverInsertResult[]): void; +} +``` + +## `PilotInsertContext` + +```ts +/** An intercepted insertion. */ +export interface PilotInsertContext< + Transaction, +> extends PilotTransactionContext { + readonly operation: "insert" | "insertMany"; + /** + * Each prepared row's arguments as JSON text from before the client's + * argument transforms rewrote them, in the order of `params`. A + * transform, such as one that encrypts arguments, runs before the + * interceptor, so `params` carries its output, which is what River + * stores. An interceptor that derives values from the arguments, such as + * keys or routing, reads them here instead. A row no transform changed + * has its `encodedArgs` here, and a row from + * {@link PilotHost.insertPrepared} has the `encodedArgs` it was given. + */ + readonly originalEncodedArgs: readonly string[]; + /** Prepared rows, in order. */ + readonly params: readonly JobInsertParams[]; +} +``` + +## `PilotInsertReplacement` + +```ts +/** Rows that replace an insertion's prepared rows, one for one. */ +export interface PilotInsertReplacement { + /** + * The replacement rows. Each row's `encodedArgs` is what River stores and + * returns, whatever its `args`. Insert hooks and middleware still see the + * requests as prepared before the replacement. + */ + readonly params: readonly JobInsertParams[]; +} +``` + +## `PilotInterceptors` + +```ts +/** + * Operations a pilot wraps, Koa-style, around River's standard operation, + * which `next` runs. `next` is bound to the context's transaction. + * + * `insert`, `complete`, `cancel`, and `retry` must call `next` exactly once + * and resolve with exactly what it resolved with; they add effects in the + * same transaction. `insert` alone may pass replacement rows to `next`, one + * for each prepared row, in order. `getStuck` and `rescue` may instead + * replace River's operation: they call `next` at most once, and resolve with + * its result when they do. + * + * River awaits `next` before settling the operation, even when the + * interceptor doesn't. A second call, or one after the interceptor settled, + * rejects. Any violation, and any rejection, fails the operation and rolls + * its transaction back with an `ExtensionError`. River freezes result + * lists and checks the rows' identities, but doesn't copy rows: an + * interceptor must not change their contents, which callers see. + */ +export interface PilotInterceptors { + cancel?( + context: PilotJobContext, + next: () => Promise + ): Promise; + complete?( + context: PilotCompleteContext, + next: () => Promise + ): Promise; + getStuck?( + context: PilotStuckContext, + next: () => Promise + ): Promise; + insert?( + context: PilotInsertContext, + next: ( + replacement?: PilotInsertReplacement + ) => Promise + ): Promise; + rescue?( + context: PilotRescueContext, + next: () => Promise + ): Promise; + retry?( + context: PilotJobContext, + next: () => Promise + ): Promise; +} +``` + +## `PilotJobContext` + +```ts +/** An intercepted cancellation or retry of one job. */ +export interface PilotJobContext< + Transaction, +> extends PilotTransactionContext { + readonly id: bigint; +} +``` + +## `PilotProducer` + +```ts +/** + * A pilot's producer session for one queue generation. Every member is + * optional; River calls them with the session as `this`. + * + * At most one claim and one keep-alive run at a time, and they may overlap + * each other; `jobFinished` may run during either. Configuration changes + * run between claims. Once River starts draining the queue it starts no + * new claim or configuration change, and after `shutdown` settles it calls + * nothing more. + * + * River waits for every call it starts to settle, so a `keepAlive` or + * `shutdown` that ignores its aborted signal and never settles stalls the + * client's `stop()`. + */ +export interface PilotProducer { + /** + * Claim jobs for the queue. `next({ tx })` runs River's standard claim in + * the pilot's transaction `tx`. The claim may instead select jobs itself, + * reading them with `database.loadClaimed`, and calls `next` at most + * once; when it does, it resolves with `next`'s result. + * + * Resolve only with rows whose claim committed, and record them before + * resolving. River checks them before working any: each must be running, + * in this queue, owned by `attemptedBy`, on an attempt of at least 1, + * listed once, not already worked here, and no more than `limit`. A + * claim that breaks those rules stops the runtime; its rows are left to + * the rescuer. A rejection is retried after backoff like River's own + * claim failures, so the pilot must undo reservations of a claim that + * didn't commit. + */ + claim?( + context: ProducerClaimContext, + next: ProducerClaimNext + ): Promise; + /** + * Validate and adopt a new configuration, synchronously and without I/O. + * Throw to reject it: River keeps the previous configuration, and rejects + * an `updateQueue` that asked for it or logs a persisted change. + */ + configurationChanged?(configuration: ProducerConfiguration): void; + /** + * One claimed job's attempt ended and its outcome went to River's + * completer, which may not have persisted it yet. `job` is the row as + * claimed. Called exactly once for each job the session claimed and + * River accepted, including jobs River couldn't decode or work. + */ + jobFinished?(job: JobRow): void; + /** + * Report the session as alive, after an initial jitter and then at the + * client's producer report interval, including while the queue drains. + * Reports keep a fixed rate and never overlap: a slow report delays the + * next one. A rejection is logged and the next report runs on schedule. + */ + keepAlive?(context: ProducerKeepAliveContext): Promise; + /** + * Release the session once its queue drained and its reports stopped. + * River tries up to four times, one after another, aborting `signal` + * after 100 ms, 500 ms, 2.5 s, and 12.5 s, and logs the failure when all + * four fail. + */ + shutdown?(context: ProducerShutdownContext): Promise; +} +``` + +## `PilotQueueOptions` + +```ts +/** + * Queue configuration keys a pilot owns. River rejects queue keys that + * neither it nor the pilot owns. + */ +export interface PilotQueueOptions { + /** The owned keys. They must not be River's own queue keys. */ + readonly keys: readonly string[]; + /** + * Validate one queue's owned keys, those present in `config`, and return + * its settings. It runs synchronously for every configured queue, and for + * every `addQueue` and `updateQueue`, before River changes anything. Throw + * a `ValidationError` to reject the configuration. + */ + parse(queue: string, config: Readonly>): Settings; +} +``` + +## `PilotRescueContext` + +```ts +/** The rescuer's update of one page of stuck jobs. */ +export interface PilotRescueContext< + Transaction, +> extends PilotTransactionContext { + readonly attemptedBefore: Temporal.Instant; + readonly jobs: readonly RuntimeJobRescue[]; + readonly leader: LeaderTerm; +} +``` + +## `PilotService` + +```ts +/** + * A background service River supervises. `run` should resolve only once + * `signal` aborted. River restarts a run that rejects, or resolves before + * then, after capped exponential backoff with jitter, which resets after a + * long healthy run; it waits for a run to settle before starting another, + * and stops restarting once `signal` aborts. + */ +export interface PilotService { + /** Names the service in River's logs. */ + readonly name: string; + run(context: { + readonly signal: AbortSignal; + readonly term: Term; + }): Promise; +} +``` + +## `PilotStuckContext` + +```ts +/** + * The rescuer's read of one page of stuck jobs. It runs in no transaction; + * River's standard read fences it by `leader` itself. + */ +export interface PilotStuckContext { + readonly afterId: bigint; + readonly attemptedBefore: Temporal.Instant; + readonly database: PilotDatabase; + readonly leader: LeaderTerm; + readonly limit: number; + /** Aborts when the read's timeout elapses or the leadership term ends. */ + readonly signal: AbortSignal; + /** + * The read's timeout in milliseconds, or `null` for none. River's standard + * read also sets it as the statement's timeout on PostgreSQL; a read the + * pilot runs itself should do the same. + */ + readonly timeoutMs: number | null; +} +``` + +## `PilotTransactionContext` + +```ts +/** What every interceptor that runs in a transaction receives. */ +export interface PilotTransactionContext { + readonly database: PilotDatabase; + /** Aborts when River abandons the operation. */ + readonly signal: AbortSignal; + /** + * The transaction the operation runs in: River's own, or the caller's. + * `next` runs River's standard operation in it too. Its owner commits or + * rolls it back, never the interceptor, which keeps the operation's + * related writes in it. + */ + readonly tx: Transaction; +} +``` + +## `POSTGRES_CAPABILITIES_SQL` + +```ts +/** + * Reads the server's product, version, and Yugabyte notification setting, + * and the session's `DateStyle`, which a driver reading timestamps as text + * needs to be ISO. The functions are unqualified, as in River for Go, so + * they resolve through the connection's `search_path`. + */ +export declare const POSTGRES_CAPABILITIES_SQL = + "\n SELECT\n current_setting('DateStyle') AS date_style,\n version()::text AS product,\n current_setting('server_version_num')::int AS version_num,\n coalesce(current_setting('yb_enable_listen_notify', true), 'off')::boolean\n AS yb_listen_notify_enabled\n"; +``` + +## `PostgresCapabilities` + +```ts +/** Features detected from a PostgreSQL-compatible server. */ +export interface PostgresCapabilities { + /** + * Whether `pg_notify` delivers notifications to listeners. Without it, + * River sends no notifications and clients poll instead. + */ + readonly supportsListenNotify: boolean; + readonly uniqueInsertMode: UniqueInsertMode; +} +``` + +## `postgresCapabilitiesFromRow` + +```ts +/** + * Decode a row of {@link POSTGRES_CAPABILITIES_SQL}, whose `version_num` + * may arrive as a number or a decimal string depending on the client's type + * parsers. + */ +export declare function postgresCapabilitiesFromRow(row: { + readonly product: unknown; + readonly version_num: unknown; + readonly yb_listen_notify_enabled: unknown; +}): PostgresCapabilities; +``` + +## `postgresTimestamp` + +```ts +/** + * Encode an instant as a PostgreSQL `timestamptz` parameter the way Go's pgx + * does: truncated to whole microseconds, PostgreSQL's precision, rather than + * leaving PostgreSQL to round the sub-microsecond digits of its text input. + * Truncation is toward the past, like pgx, so every engine stores the same + * instant for the same value. + */ +export declare function postgresTimestamp(value: Temporal.Instant): string; +``` + +## `PreparedInsertParams` + +```ts +/** + * A job's stored fields, for {@link PilotHost.insertPrepared}. Its + * arguments are `encodedArgs`, any JSON text, stored as given. + */ +export type PreparedInsertParams = Omit; +``` + +## `ProducerClaimContext` + +```ts +/** One claim of a producer session. */ +export interface ProducerClaimContext { + /** The client ID claimed jobs must record as their attempt's owner. */ + readonly attemptedBy: string; + readonly database: PilotDatabase; + /** + * The kinds the claim may return, sorted, or empty for every kind. A + * client with `fetchOnlyKnownKinds` passes the kinds it has workers for, + * like River for Go's `JobGetAvailableParams.Kind`. + */ + readonly kinds: readonly string[]; + /** The most jobs the claim may return. */ + readonly limit: number; + readonly queue: string; + /** + * Aborts once River stops claiming the queue. It ends retries and + * backoff; a claim that already committed must still be returned. + */ + readonly retrySignal: AbortSignal; + /** Aborts when River abandons the claim's work entirely. */ + readonly signal: AbortSignal; +} +``` + +## `ProducerClaimNext` + +```ts +/** + * River's standard claim, which a producer session's claim may call once + * in its transaction `tx`. + */ +export type ProducerClaimNext = (options: { + readonly tx: Transaction; +}) => Promise; +``` + +## `ProducerConfiguration` + +```ts +/** One queue generation's configuration, replaced as a whole. */ +export interface ProducerConfiguration { + /** The most jobs of the queue this client works at once. */ + readonly maxWorkers: number; + /** + * The queue's metadata as its database stores it, such as `{"retries": + * 1.0}`, for a pilot that decodes it more strictly than River's parsed + * `queue.metadata`, whose number literals `1.0` and `1e2` read as plain + * numbers. + */ + readonly metadataText: string; + /** The persisted queue, including its metadata. */ + readonly queue: QueueRow; + /** The pilot's own settings, parsed by its `queueOptions`. */ + readonly settings: Settings; +} +``` + +## `ProducerKeepAliveContext` + +```ts +/** What {@link PilotProducer.keepAlive} receives. */ +export interface ProducerKeepAliveContext { + /** Aborts when the report times out, after 10 s, or reports stop. */ + readonly signal: AbortSignal; + /** Sessions that haven't reported since this time are stale. */ + readonly staleBefore: Temporal.Instant; +} +``` + +## `ProducerShutdownContext` + +```ts +/** What {@link PilotProducer.shutdown} receives. */ +export interface ProducerShutdownContext { + /** Aborts at the attempt's deadline. */ + readonly signal: AbortSignal; +} +``` + +## `ProducerStartContext` + +```ts +/** What {@link Pilot.startProducer} receives. */ +export interface ProducerStartContext< + Transaction, + Settings = unknown, +> extends ProducerConfiguration { + readonly clientId: string; + readonly database: PilotDatabase; + /** Aborts when the runtime stops while the producer starts. */ + readonly signal: AbortSignal; +} +``` + +## `QueueListParams` + +```ts +/** Keyset pagination for listing queues, ordered by name. */ +export interface QueueListParams { + readonly limit: number; + readonly nameAfter: string | null; +} +``` + +## `queueMetadataUpdate` + +```ts +/** + * What a queue update with new metadata writes: `text`, the metadata to + * store, and `notification`, the `metadata_changed` notification's payload + * for queue `queue`. Undefined when the update keeps the queue's metadata. + */ +export declare function queueMetadataUpdate( + queue: string, + params: QueueUpdateParams +): + | { + readonly notification: string; + readonly text: string; + } + | undefined; +``` + +## `QueueRow` + +```ts +/** Persisted dynamic queue row. */ +export interface QueueRow { + readonly createdAt: Temporal.Instant; + readonly metadata: JsonObject; + readonly name: string; + readonly pausedAt: Temporal.Instant | null; + readonly updatedAt: Temporal.Instant; +} +``` + +## `QueueUpdateParams` + +```ts +/** Changes to a queue's persisted settings. */ +export interface QueueUpdateParams { + readonly metadata?: JsonObject; +} +``` + +## `quoteIdentifier` + +```ts +/** + * Quote an identifier for PostgreSQL or SQLite, doubling embedded quotes. + * It doesn't check the identifier's length or characters. + */ +export declare function quoteIdentifier(value: string): string; +``` + +## `ReadonlyJsonObject` + +```ts +/** A recursively immutable JSON object supplied to an argument transformer. */ +export interface ReadonlyJsonObject { + readonly [key: string]: ReadonlyJsonValue; +} +``` + +## `ReadonlyJsonValue` + +```ts +/** A recursively immutable JSON value supplied to an argument transformer. */ +export type ReadonlyJsonValue = + | boolean + | ExactJsonNumber + | null + | number + | ReadonlyJsonObject + | readonly ReadonlyJsonValue[] + | string; +``` + +## `recordQueueMetadataText` + +```ts +/** + * Record `text`, the metadata of `queue` as its database stores it. A + * first-party driver calls it for each queue row it decodes. + */ +export declare function recordQueueMetadataText( + queue: QueueRow, + text: string +): void; +``` + +## `registerDriver` + +```ts +/** + * Register what River needs to know privately about a first-party driver + * instance. A driver registers itself once, from its constructor. + * + * @throws {ConfigurationError} when `handle` is already registered. + */ +export declare function registerDriver( + handle: ClientDriver, + record: DriverRecord +): void; +``` + +## `RuntimeDriver` + +```ts +/** + * Unstable semantic contract implemented by first-party full-engine backends. + * + * This SPI intentionally exposes no SQL or backend client types. It is public + * only so separately packaged first-party drivers can implement it; user code + * must not implement it. Its shape may change in any release before 1.0. + */ +export interface RuntimeDriver extends InsertDriver< + Transaction, + "runtime" +> { + /** Synchronous capability/configuration preflight before tasks are started. */ + runtimeStartPreflight?(options: { + readonly maintenance: boolean; + readonly notifications: boolean; + readonly reindex: boolean; + }): void; + jobCancel( + id: bigint, + options?: InsertDriverOptions + ): BackendResult; + /** + * Lock available jobs for work, moving them to `running`. A locked row + * that can't be fully decoded doesn't fail the call; it is returned with + * its error in `decodeErrors` so the runtime fails its attempt instead of + * stranding it. + * `options.signal` aborts only waiting to start: a backend stops waiting + * for a connection or lock when it aborts, but never abandons a claim + * that has begun, so no job is claimed by a runtime that stopped. With + * `options.tx`, the claim runs in that transaction instead of committing + * on its own. + */ + jobClaim( + params: JobClaimParams, + options?: JobClaimOptions + ): BackendResult; + jobCompleteMany( + commands: readonly JobCompletionCommand[], + options?: { + readonly signal?: AbortSignal; + readonly tx?: Transaction; + } + ): BackendResult; + jobDelete( + id: bigint, + options?: InsertDriverOptions + ): BackendResult; + jobDeleteMany( + params: JobDeleteManyParams, + options?: InsertDriverOptions + ): BackendResult; + jobGet( + id: bigint, + options?: InsertDriverOptions + ): BackendResult; + jobList( + params: JobListParams, + options?: InsertDriverOptions + ): BackendResult; + jobRetry( + id: bigint, + options?: InsertDriverOptions + ): BackendResult; + jobUpdate( + id: bigint, + params: JobUpdateParams, + options?: InsertDriverOptions + ): BackendResult; + queueGet( + name: string, + options?: InsertDriverOptions + ): BackendResult; + queueList( + params: QueueListParams, + options?: InsertDriverOptions + ): BackendResult; + queuePause( + name: string, + options?: InsertDriverOptions + ): BackendResult; + queueResume( + name: string, + options?: InsertDriverOptions + ): BackendResult; + queueUpdate( + name: string, + params: QueueUpdateParams, + options?: InsertDriverOptions + ): BackendResult; + /** Refresh one locally configured queue without replacing its controls. */ + runtimeQueueUpsert?( + name: string, + now: Temporal.Instant, + options?: RuntimeWaitOptions + ): BackendResult; + /** + * Whether the database delivers notifications to listeners, detecting the + * server the first time, like River for Go's `InitDriver` followed by + * `SupportsListener`. A runtime whose database doesn't, such as YugabyteDB + * without `yb_enable_listen_notify`, polls instead as if `pollOnly` were + * set. A driver without this method always delivers them. + */ + runtimeDeliversNotifications?( + options?: RuntimeWaitOptions + ): BackendResult; + /** Notification hints for inserts, controls, and leadership changes. */ + runtimeNotificationSubscribe?( + topics: readonly RuntimeNotification["topic"][], + signal: AbortSignal, + ready: () => void + ): AsyncIterable; + /** Broadcast a request for whichever runtime leads to resign its term. */ + runtimeRequestLeadershipResignation?( + options?: InsertDriverOptions + ): BackendResult; + /** + * Renew `held`, the exact term this client leads, or elect a new term when + * it holds none. Like Go River, a held term is renewed only while the + * persisted row still has its `elected_at`, and an unexpired row is never + * adopted, even one with this client's `leaderId`: another process using + * the same client ID must lose its term before a fresh one is elected. + */ + maintenanceLeaderAcquire?( + leaderId: string, + now: Temporal.Instant, + ttlMs: number, + held: RuntimeLeader | null, + options?: RuntimeWaitOptions + ): BackendResult; + /** Resign only the supplied exact term. */ + maintenanceLeaderResign?(leader: RuntimeLeader): BackendResult; + /** Move one bounded page of due jobs toward availability. */ + maintenanceSchedule?( + leader: RuntimeLeader, + params: RuntimeScheduleParams, + batch?: RuntimeMaintenanceBatch + ): BackendResult; + /** Read one stable page of attempts eligible for rescue. */ + maintenanceGetStuck?( + leader: RuntimeLeader, + attemptedBefore: Temporal.Instant, + afterId: bigint, + limit: number, + batch?: RuntimeMaintenanceBatch + ): BackendResult; + /** + * Rescue a previously inspected page with an attempted-at fence. + * + * `attemptedBefore` is the same horizon the pass passed to + * `maintenanceGetStuck`. Implementations update only rows that are still + * `running` with `attempted_at` strictly before it, so a job completed, + * released, or claimed again after it was selected is left untouched. + * With `options.tx`, the fenced update runs in that transaction. + */ + maintenanceRescue?( + leader: RuntimeLeader, + attemptedBefore: Temporal.Instant, + jobs: readonly RuntimeJobRescue[], + options?: InsertDriverOptions + ): BackendResult; + /** Delete one bounded page of terminal jobs. */ + maintenanceCleanJobs?( + leader: RuntimeLeader, + params: RuntimeJobCleanupParams, + timeoutMs: number | null, + signal: AbortSignal + ): BackendResult; + /** Delete one bounded page of inactive queue records. */ + maintenanceCleanQueues?( + leader: RuntimeLeader, + updatedBefore: Temporal.Instant, + limit: number, + batch?: RuntimeMaintenanceBatch + ): BackendResult; + /** + * Delete up to `limit` durable notification hints created before + * `createdBefore`, oldest first, where applicable. + */ + maintenanceCleanNotifications?( + leader: RuntimeLeader, + createdBefore: Temporal.Instant, + limit: number, + batch?: RuntimeMaintenanceBatch + ): BackendResult; + /** Rebuild configured backend indexes when the backend supports it. */ + maintenanceReindex?( + leader: RuntimeLeader, + indexNames: readonly string[], + timeoutMs: number | null, + signal: AbortSignal + ): BackendResult; + /** + * The IDs among `ids` of running jobs with a cancellation request, like + * River for Go's `JobGetCancelRequested`. A runtime without notifications + * checks its running attempts with it every two seconds. + */ + jobGetCancelRequested?( + ids: readonly bigint[], + options?: RuntimeWaitOptions + ): BackendResult; + /** + * Optional backend-native remote-cancellation stream. `ready` runs once the + * stream receives every later cancellation, so the runtime can check for + * cancellations missed while it reconnected. + */ + jobCancellationSubscribe?( + attemptedBy: string, + signal: AbortSignal, + ready?: () => void + ): AsyncIterable; +} +``` + +## `RuntimeJobCleanupParams` + +```ts +/** Retention horizons for one bounded leader-owned cleaner pass. */ +export interface RuntimeJobCleanupParams { + readonly cancelledBefore: Temporal.Instant | null; + readonly completedBefore: Temporal.Instant | null; + readonly discardedBefore: Temporal.Instant | null; + readonly limit: number; + /** Queues whose jobs the pass leaves alone. */ + readonly queuesExcluded?: readonly string[]; +} +``` + +## `RuntimeJobRescue` + +```ts +/** One semantic transition selected by the stuck-job rescuer. */ +export interface RuntimeJobRescue { + readonly error: AttemptError; + readonly finalizedAt: Temporal.Instant | null; + readonly id: bigint; + readonly scheduledAt: Temporal.Instant; + readonly state: "cancelled" | "discarded" | "retryable"; +} +``` + +## `RuntimeLeader` + +```ts +/** A leadership term as passed to leader-fenced driver operations. */ +export type RuntimeLeader = LeaderTerm; +``` + +## `RuntimeMaintenanceBatch` + +```ts +/** + * Bounds of one leader-owned maintenance batch. A backend that can cancel + * database work should stop the batch after `timeoutMs`, like PostgreSQL's + * `statement_timeout`; `signal` aborts at the timeout or when the leadership + * term ends. + */ +export interface RuntimeMaintenanceBatch { + readonly signal: AbortSignal; + /** The batch's timeout in milliseconds, or `null` for none. */ + readonly timeoutMs: number | null; +} +``` + +## `RuntimeNotification` + +```ts +/** A backend-neutral notification hint. Notifications are never authoritative. */ +export interface RuntimeNotification { + readonly payload: string; + readonly topic: "control" | "insert" | "leadership"; +} +``` + +## `RuntimeScheduleParams` + +```ts +/** Exact horizons for one leader-owned scheduler pass. */ +export interface RuntimeScheduleParams { + /** + * The queues among `queues` to send an insert notification for, each once, + * from the client's insert notification limiter. The pass calls it with + * the queue of every job it scheduled at or before `notificationHorizon`, + * and sends the notifications in its own transaction. + */ + readonly allowInsertNotifications: ( + queues: readonly string[] + ) => readonly string[]; + readonly limit: number; + /** Timestamp used for terminal metadata written by this pass. */ + readonly now: Temporal.Instant; + /** Scheduled jobs at or before this instant may wake waiting producers. */ + readonly notificationHorizon: Temporal.Instant; + /** Scheduled jobs at or before this instant may be promoted early. */ + readonly scheduledAtHorizon: Temporal.Instant; +} +``` + +## `RuntimeTimer` + +```ts +/** + * Timers and the monotonic clock River's runtime waits on. By default they + * are unreferenced `setTimeout` handles and `performance.now()`; tests + * replace them, for example with `overrideRuntimeTiming`, so backoff, + * intervals, and deadlines run deterministically instead of by wall-clock + * sleeps. + */ +export interface RuntimeTimer { + /** + * Resolve after `milliseconds`, or reject with `signal.reason` as soon as + * the signal aborts. Timers never keep the process alive on their own. + */ + delay(milliseconds: number, signal: AbortSignal): Promise; + /** + * Monotonic milliseconds from an arbitrary origin, the clock `delay` and + * `timeout` count against, like `performance.now()`. + */ + now(): number; + /** + * Return a signal that aborts with `reason()` after `milliseconds`. Call + * `dispose` once the guarded operation settles to clear the timer. + */ + timeout(milliseconds: number, reason: () => unknown): OperationTimeout; +} +``` + +## `RuntimeTiming` + +```ts +/** + * Replacement clock, randomness, and timers for a client's runtime, so + * tests drive River's timing deterministically. See + * `overrideRuntimeTiming` in `riverqueue/unstable-driver`. + */ +export interface RuntimeTiming { + /** Wall-clock time River records. Default: `Temporal.Now.instant()`. */ + readonly now?: () => Temporal.Instant; + /** Numbers in `[0, 1)` for jitter. Default: `Math.random`. */ + readonly random?: () => number; + /** + * Every delay, deadline, and interval River waits for, and the monotonic + * clock they count against. Default: unreferenced `setTimeout` handles + * and `performance.now()`. + */ + readonly timer?: RuntimeTimer; +} +``` + +## `RuntimeWaitOptions` + +```ts +/** + * Bounds on a runtime operation's wait to start. `signal` aborts waiting + * for a connection or lock, such as when the runtime stops during an + * outage; an operation that has started always finishes, so no write is + * abandoned half done. + */ +export interface RuntimeWaitOptions { + readonly signal?: AbortSignal; +} +``` + +## `sjsonKey` + +```ts +/** + * Write an object key the way `sjson` does when River Go assembles unique + * arguments: verbatim when it is printable ASCII without a quote or + * backslash, even though `encoding/json` would escape `<`, `>`, or `&`, and + * otherwise with Go's `encoding/json` escaping. + */ +export declare function sjsonKey(key: string): string; +``` + +## `SortDirection` + +```ts +/** A list order. */ +export type SortDirection = "asc" | "desc"; +``` + +## `UNIQUE_INSERT_NONCE_KEY` + +```ts +/** The metadata key of a unique insert's nonce, shared with River for Go. */ +export declare const UNIQUE_INSERT_NONCE_KEY = "river:unique_nonce"; +``` + +## `uniqueBitmaskFromStates` + +```ts +/** Convert an array of job states to an 8-bit bitmask string. */ +export declare function uniqueBitmaskFromStates( + states: readonly JobState[] +): string; +``` + +## `uniqueBitmaskToStates` + +```ts +/** Convert a bitmask integer to an array of job states. */ +export declare function uniqueBitmaskToStates(mask: number): JobState[]; +``` + +## `uniqueInsertConflictSql` + +```ts +/** + * The SQL expression that's true for an existing row a unique insert + * returned, in a `RETURNING` clause. It's always false for + * `metadata_nonce`, which compares nonces after the insert instead. + */ +export declare function uniqueInsertConflictSql(mode: UniqueInsertMode): string; +``` + +## `UniqueInsertMode` + +```ts +/** + * How an insert that may conflict on its unique key tells a new row from an + * existing one it returned instead: + * + * - `metadata_nonce`: each proposed row's metadata carries a random nonce + * under {@link UNIQUE_INSERT_NONCE_KEY}, and a returned row without the + * one its insert wrote already existed. Used where `xmax` is unavailable. + * - `returning_old`: PostgreSQL 18's `OLD` row in `RETURNING`. + * - `xmax`: PostgreSQL's `xmax` system column, nonzero for an updated row. + */ +export type UniqueInsertMode = "metadata_nonce" | "returning_old" | "xmax"; +``` + +## `workerRegistration` + +```ts +/** + * The registration for `kind` in `workers`, or undefined for an unknown + * kind, for River's runtime and test packages. + */ +export declare function workerRegistration( + workers: Workers, + kind: string +): WorkerRegistration | undefined; +``` + +# Referenced but unexported + +Exported declarations refer to these types, which the entry point +intentionally does not export. + +## `ResumableFinish` + +Result of the internal resumable completion step used by first-party runtimes. + +```ts +interface ResumableFinish { + readonly error: Error | null; + readonly metadata: JsonObject; +} +``` diff --git a/js/migrate/etc/migrate.api.md b/js/migrate/etc/migrate.api.md new file mode 100644 index 000000000..58535d1b9 --- /dev/null +++ b/js/migrate/etc/migrate.api.md @@ -0,0 +1,361 @@ +# API report: `@riverqueue/migrate` + + + +This report contains declarations and TSDoc for names exported by the +package entry point. Private members, unexported implementation +declarations, and file layout are omitted. + +## `createMigrator` + +````ts +/** + * Create a migrator for a River driver or a database connection. + * + * Passing the driver used by the client migrates the same database and + * schema, so the schema is configured in one place: + * + * ```ts + * const driver = new PgDriver(pool, { schema: "river" }); + * await createMigrator(driver).migrateUp(); + * ``` + * + * Deploy scripts can pass a connection instead, such as `{ pool, schema }`, + * `{ client, schema }`, or `{ database }` for SQLite. + * + * Migrations never run implicitly; run them as a deployment step, before + * starting workers. + * + * @throws {@link MigrationError} if the source or options are invalid. + */ +export declare function createMigrator( + source: MigratorSource, + options?: MigratorOptions +): Migrator; +```` + +## `loadMigrations` + +```ts +/** + * Load River's bundled main migration line for a backend, ordered by version. + * + * The SQL files ship with this package and are verified against recorded + * checksums on every load. PostgreSQL SQL contains a schema placeholder that + * a migrator fills in, so run migrations through {@link createMigrator} + * instead of executing this SQL directly. + * + * @throws {@link MigrationError} if the bundled files are missing or altered. + */ +export declare function loadMigrations( + backend: MigrationBackend +): readonly Migration[]; +``` + +## `MigrateOptions` + +```ts +/** Options for {@link Migrator.migrateDown} and {@link Migrator.migrateUp}. */ +export interface MigrateOptions { + /** + * Report the versions and SQL that would run without changing the + * database. Defaults to `false`. + */ + readonly dryRun?: boolean | undefined; + /** + * Run at most this many versions. `0` runs none, which is useful with + * `dryRun` to check the plan. + * + * Up migrations are unlimited by default. Down migrations revert one + * version by default, or every version above `targetVersion` when it is + * set. + */ + readonly maxSteps?: number | undefined; + /** + * Version the schema should end at. + * + * Migrating up applies missing versions up to and including this one; if + * it is already applied, nothing runs. Migrating down reverts versions + * above it, so it must be applied, or `0` to revert every version. + */ + readonly targetVersion?: number | undefined; +} +``` + +## `MigrateResult` + +```ts +export interface MigrateResult { + readonly direction: MigrationDirection; + /** + * Versions applied (up) or reverted (down) in the order they ran. For a + * dry run, the versions that would run. + */ + readonly versions: readonly MigrateVersion[]; +} +``` + +## `MigrateVersion` + +```ts +/** One version applied or reverted by a migrate call. */ +export interface MigrateVersion { + /** Time taken to run the version's SQL, or zero for a dry run. */ + readonly duration: Temporal.Duration; + /** Short name of the migration, such as `bulk_unique`. */ + readonly name: string; + /** SQL that ran, or would run, with the schema filled in. */ + readonly sql: string; + readonly version: number; +} +``` + +## `Migration` + +```ts +/** One River migration version with SQL for both directions. */ +export interface Migration { + readonly downSql: string; + /** Short name derived from the migration file name, such as `bulk_unique`. */ + readonly name: string; + readonly upSql: string; + /** Positive version number. A line starts at 1 and increases by 1. */ + readonly version: number; +} +``` + +## `MIGRATION_LINE_MAIN` + +```ts +/** River's main migration line, bundled with this package. */ +export declare const MIGRATION_LINE_MAIN = "main"; +``` + +## `MigrationBackend` + +```ts +/** Database backend that a migration bundle targets. */ +export type MigrationBackend = "postgres" | "sqlite"; +``` + +## `MigrationDirection` + +```ts +export type MigrationDirection = "down" | "up"; +``` + +## `MigrationError` + +```ts +/** A migration could not be planned, applied, or validated. */ +export declare class MigrationError extends RiverError<"migration"> { + /** Backend being migrated, such as `"postgres"` or `"sqlite"`. */ + readonly backend: string; + /** Migration operation that failed, such as `"migrate"` or `"validate"`. */ + readonly operation: string; + constructor(message: string, options: MigrationErrorOptions); +} +``` + +## `MigrationTarget` + +```ts +/** A database connection to migrate, given without a River driver. */ +export type MigrationTarget = + PgClientMigrationTarget | PgPoolMigrationTarget | SqliteMigrationTarget; +``` + +## `Migrator` + +```ts +/** + * A migration runner for one database, schema, and migration line. + * + * Create one with {@link createMigrator}. Each version runs in its own + * transaction together with its `river_migration` bookkeeping. Concurrent + * migrators for the same schema serialize: PostgreSQL uses a + * transaction-scoped advisory lock and SQLite uses an immediate write + * transaction, and a version finished by another migrator is skipped rather + * than run twice. + */ +export interface Migrator { + /** Database backend being migrated. */ + readonly backend: MigrationBackend; + /** Migration line being migrated, such as {@link MIGRATION_LINE_MAIN}. */ + readonly line: string; + /** Every migration version known for the line, ordered by version. */ + readonly migrations: readonly Migration[]; + /** + * Read the versions of the line that are applied in the database, in + * ascending order. The result can include versions newer than + * {@link Migrator.migrations} if a newer River release migrated the + * database. + */ + existingVersions(): Promise; + /** + * Revert applied versions, newest first. Reverts one version unless + * `maxSteps` or `targetVersion` says otherwise; `targetVersion: 0` removes + * every version, dropping River's tables and their data. + */ + migrateDown(options?: MigrateOptions): Promise; + /** Apply missing versions, oldest first. Applies every version by default. */ + migrateUp(options?: MigrateOptions): Promise; + /** + * Check that every known version, or every version up to `targetVersion`, + * is applied. Applied versions newer than this package are ignored. + */ + validate(options?: ValidateOptions): Promise; +} +``` + +## `MigratorOptions` + +```ts +/** Options for {@link createMigrator}. */ +export interface MigratorOptions { + /** + * Migration line to operate on. Defaults to {@link MIGRATION_LINE_MAIN}. + * + * Lines other than main require `migrations` and a database whose main + * line is at version 5 or later. + */ + readonly line?: string | undefined; + /** + * Migrations for `line`, which packages that ship their own migration + * line provide. Versions must start at 1 and increase by 1. Defaults to + * River's bundled main line for the backend. + */ + readonly migrations?: readonly Migration[] | undefined; +} +``` + +## `MigratorSource` + +```ts +/** + * Something to build a migrator from: a River driver such as `PgDriver` or + * `SqliteDriver`, or a connection given directly. + */ +export type MigratorSource = ClientDriver | MigrationTarget; +``` + +## `PgClientMigrationTarget` + +```ts +/** Migrate PostgreSQL through one dedicated connection. */ +export interface PgClientMigrationTarget { + /** + * A connected node-postgres `Client` or `PoolClient` that is not inside a + * transaction. The caller keeps ownership and closes it. + */ + readonly client: PgMigrationClient; + /** + * Schema containing River's tables. Defaults to the connection's + * `search_path`. Must match the schema given to `PgDriver`. + */ + readonly schema?: string | undefined; +} +``` + +## `PgMigrationClient` + +```ts +/** + * A PostgreSQL connection that can run River's migrations, such as a + * node-postgres `Client` or a `PoolClient` checked out of a pool. + * + * The connection must not be inside a transaction: every migration version + * runs in its own transaction. + */ +export interface PgMigrationClient { + /** Run one SQL command, optionally with positional parameters. */ + query>( + text: string, + values?: readonly unknown[] + ): Promise>; +} +``` + +## `PgMigrationPool` + +```ts +/** A PostgreSQL connection pool such as a node-postgres `Pool`. */ +export interface PgMigrationPool { + /** Check out a dedicated connection for migrating. */ + connect(): Promise; + /** Run one SQL command on any pooled connection. */ + query>( + text: string, + values?: readonly unknown[] + ): Promise>; +} +``` + +## `PgMigrationPoolClient` + +```ts +/** A connection checked out of a {@link PgMigrationPool}. */ +export interface PgMigrationPoolClient extends PgMigrationClient { + /** Return the connection, destroying it when `error` is given. */ + release(error?: Error): void; +} +``` + +## `PgMigrationQueryResult` + +```ts +/** Result shape that River reads from PostgreSQL queries. */ +export interface PgMigrationQueryResult { + rows: TRow[]; +} +``` + +## `PgPoolMigrationTarget` + +```ts +/** Migrate PostgreSQL through a connection pool. */ +export interface PgPoolMigrationTarget { + /** + * A node-postgres `Pool`. The migrator checks out one connection per call + * and never ends the pool. + */ + readonly pool: PgMigrationPool; + /** + * Schema containing River's tables. Defaults to the connection's + * `search_path`. Must match the schema given to `PgDriver`. + */ + readonly schema?: string | undefined; +} +``` + +## `SqliteMigrationTarget` + +```ts +/** Migrate a `node:sqlite` database. */ +export interface SqliteMigrationTarget { + /** An open database. The caller keeps ownership and closes it. */ + readonly database: DatabaseSync; +} +``` + +## `ValidateOptions` + +```ts +/** Options for {@link Migrator.validate}. */ +export interface ValidateOptions { + /** Only require versions up to and including this one. */ + readonly targetVersion?: number | undefined; +} +``` + +## `ValidateResult` + +```ts +/** The result of {@link Migrator.validate}. */ +export interface ValidateResult { + /** Why validation failed. Empty when `ok` is `true`. */ + readonly messages: readonly string[]; + /** Whether every required version is applied. */ + readonly ok: boolean; +} +``` diff --git a/js/package.json b/js/package.json index c6f9bfe65..44fefdcc8 100644 --- a/js/package.json +++ b/js/package.json @@ -34,6 +34,8 @@ "build:all": "pnpm run build && pnpm --filter=@riverqueue/migrate run build && pnpm --filter='./driver/*' run build && pnpm --filter=@riverqueue/worker-threads run build && pnpm --filter=@riverqueue/test run build && pnpm --filter=@riverqueue/cli run build", "clean": "rm -rf dist", "clean:all": "pnpm run clean && pnpm --filter=@riverqueue/migrate run clean && pnpm --filter='./driver/*' run clean && pnpm --filter=@riverqueue/worker-threads run clean && pnpm --filter=@riverqueue/test run clean && pnpm --filter=@riverqueue/cli run clean", + "api:check": "node scripts/api-reports.mjs --check", + "api:report": "node scripts/api-reports.mjs", "docs:api": "typedoc --options typedoc.json", "docs:snippets": "node scripts/check-readme-snippets.mjs", "fmt": "prettier --write 'src/**/*.ts' 'driver/**/src/**/*.ts' 'migrate/src/**/*.ts' 'worker-threads/src/**/*.ts' 'test/src/**/*.ts' 'cli/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts' 'examples/*/{README.md,package.json,tsconfig.json}' 'examples/tsconfig.json' '{README.md,package.json}' 'docs/*.md' 'driver/*/README.md' '{cli,migrate,test,worker-threads}/README.md'", diff --git a/js/scripts/api-reports.mjs b/js/scripts/api-reports.mjs new file mode 100644 index 000000000..843abbd8c --- /dev/null +++ b/js/scripts/api-reports.mjs @@ -0,0 +1,373 @@ +// Generate reviewable API reports from the built declarations of every +// published entry point, in the spirit of API Extractor's `.api.md` files. +// +// API Extractor itself cannot analyze these packages: its bundled compiler +// (TypeScript 5.9 as of 7.59) rejects the `ES2025` target and fails on the +// global `Temporal` namespace from TypeScript 6's `esnext.temporal` library. +// This script uses the workspace's own compiler instead. +// +// Each report lists every exported declaration with its TSDoc, including the +// documentation on class and interface members, so documentation changes show +// up in review diffs. Generation fails when an exported declaration refers to +// a type declared in the same package that the entry point does not export (a +// "forgotten export"), unless the name is allow-listed below with a reason. + +import { mkdir, readFile, realpath, writeFile } from "node:fs/promises"; +import { dirname, resolve, sep } from "node:path"; +import process from "node:process"; +import { fileURLToPath, URL } from "node:url"; + +import prettier from "prettier"; +import ts from "typescript"; + +const repositoryRoot = resolve(fileURLToPath(new URL("..", import.meta.url))); +const check = process.argv.includes("--check"); +const prettierConfig = + (await prettier.resolveConfig(resolve(repositoryRoot, "package.json"))) ?? {}; + +/** + * Types that exported declarations may reference without the entry point + * exporting them. Every entry needs a reason, and stale entries fail. + * + * @type {Record>} + */ +const intentionallyUnexported = { + "@riverqueue/test": { + WorkOnceResultBase: + "Fields shared by every `WorkOnceResult` variant; name the union instead.", + }, + riverqueue: { + JsonCompatibleProperty: + "Type-level helper of `JsonCompatible`; never named by applications.", + SchemaCheck: + "Type-level helper that rejects schemas whose input is not JSON-compatible.", + SchemaJobInput: + "Type-level helper that extracts a schema's input type for `defineJob`.", + WorkAttemptResultBase: + "Fields shared by every `WorkAttemptResult` variant; name the union instead.", + WorkerRegistrationBase: + "Fields shared by the exported worker registration interfaces.", + }, + "riverqueue/unstable-driver": { + ResumableFinish: + "Result of the internal resumable completion step used by first-party runtimes.", + }, +}; + +// Each report covers one entry point. `alsoSee` names sibling entry points of +// the same package whose exports the report may reference: the unstable +// driver subpath builds on the root API, so it need not re-export it, while +// the stable root must never depend on a type only the unstable subpath +// exports. +const reports = [ + { + entry: "dist/index.d.ts", + name: "riverqueue", + output: "etc/riverqueue.api.md", + }, + { + alsoSee: ["riverqueue"], + entry: "dist/unstable-driver.d.ts", + name: "riverqueue/unstable-driver", + output: "etc/riverqueue.unstable-driver.api.md", + }, + { + entry: "cli/dist/index.d.ts", + name: "@riverqueue/cli", + output: "cli/etc/cli.api.md", + }, + { + entry: "driver/pg/dist/index.d.ts", + name: "@riverqueue/driver-pg", + output: "driver/pg/etc/driver-pg.api.md", + }, + { + entry: "driver/prisma/dist/index.d.ts", + name: "@riverqueue/driver-prisma", + output: "driver/prisma/etc/driver-prisma.api.md", + }, + { + entry: "driver/sqlite/dist/index.d.ts", + name: "@riverqueue/driver-sqlite", + output: "driver/sqlite/etc/driver-sqlite.api.md", + }, + { + entry: "migrate/dist/index.d.ts", + name: "@riverqueue/migrate", + output: "migrate/etc/migrate.api.md", + }, + { + entry: "test/dist/index.d.ts", + name: "@riverqueue/test", + output: "test/etc/test.api.md", + }, + { + entry: "worker-threads/dist/index.d.ts", + name: "@riverqueue/worker-threads", + output: "worker-threads/etc/worker-threads.api.md", + }, +]; + +const program = ts.createProgram({ + rootNames: reports.map(({ entry }) => resolve(repositoryRoot, entry)), + options: { + module: ts.ModuleKind.NodeNext, + moduleResolution: ts.ModuleResolutionKind.NodeNext, + skipLibCheck: true, + target: ts.ScriptTarget.ES2025, + types: ["node"], + }, +}); +const checker = program.getTypeChecker(); +const exportsByReport = new Map( + reports.map((report) => [report.name, entryExports(report.entry)]) +); + +const failures = []; +for (const report of reports) { + const exported = exportsByReport.get(report.name); + const visible = new Set( + [report.name, ...(report.alsoSee ?? [])].flatMap((name) => + [...exportsByReport.get(name).values()].map(({ target }) => target) + ) + ); + const packageDist = `${await realpath( + dirname(resolve(repositoryRoot, report.entry)) + )}${sep}`; + const allowed = intentionallyUnexported[report.name] ?? {}; + const { forgotten, unexported } = findUnexportedReferences( + exported, + visible, + packageDist, + allowed + ); + for (const [name, referencedBy] of forgotten) { + failures.push( + `${report.name}: \`${name}\` is referenced by ${[...referencedBy] + .sort() + .map((referrer) => `\`${referrer}\``) + .join(", ")} but not exported` + ); + } + for (const name of Object.keys(allowed)) { + if (!unexported.has(name)) { + failures.push( + `${report.name}: stale allow-list entry \`${name}\` is exported or no longer referenced` + ); + } + } + + const section = (name, symbol, note = []) => [ + `## \`${name}\``, + "", + ...note, + "```ts", + uniqueNodes(symbol.getDeclarations() ?? []) + .map(reportableNode) + .map(declarationText) + .join("\n\n"), + "```", + "", + ]; + const body = await prettier.format( + [ + `# API report: \`${report.name}\``, + "", + "", + "", + "This report contains declarations and TSDoc for names exported by the", + "package entry point. Private members, unexported implementation", + "declarations, and file layout are omitted.", + "", + ...[...exported] + .sort(([left], [right]) => left.localeCompare(right)) + .flatMap(([name, { target }]) => section(name, target)), + ...(unexported.size === 0 + ? [] + : [ + "# Referenced but unexported", + "", + "Exported declarations refer to these types, which the entry point", + "intentionally does not export.", + "", + ...[...unexported] + .sort(([left], [right]) => left.localeCompare(right)) + .flatMap(([name, symbol]) => + section(name, symbol, [allowed[name], ""]) + ), + ]), + ].join("\n"), + { ...prettierConfig, parser: "markdown" } + ); + const output = resolve(repositoryRoot, report.output); + if (check) { + const existing = await readFile(output, "utf8").catch(() => null); + if (existing !== body) { + failures.push( + `API declaration report is stale: ${report.output} (run \`pnpm run api:report\`)` + ); + } + } else { + await mkdir(dirname(output), { recursive: true }); + await writeFile(output, body); + } +} + +if (failures.length > 0) { + process.stderr.write(`${failures.map((line) => `- ${line}`).join("\n")}\n`); + process.stderr.write( + "Export forgotten types from the entry point (types only when possible) " + + "or allow-list them with a reason in scripts/api-reports.mjs.\n" + ); + process.exitCode = 1; +} + +// Map each exported name to its resolved declaration symbol. +function entryExports(entryRelative) { + const entry = resolve(repositoryRoot, entryRelative); + const source = program.getSourceFile(entry); + if (source === undefined) { + throw new Error(`declaration entry missing: ${entry}`); + } + const moduleSymbol = checker.getSymbolAtLocation(source); + if (moduleSymbol === undefined) { + throw new Error(`declaration entry is not a module: ${entry}`); + } + return new Map( + checker.getExportsOfModule(moduleSymbol).map((exported) => { + const target = resolveAlias(checker, exported); + if ((target.getDeclarations() ?? []).length === 0) { + throw new Error(`no declaration found for exported ${exported.name}`); + } + return [exported.name, { target }]; + }) + ); +} + +// Walk the declarations reachable from the exports. A referenced symbol +// declared in this package must be visible from the entry point; allow-listed +// ones are collected so the report can show them, and their own references +// are checked in turn. +function findUnexportedReferences(exported, visible, packageDist, allowed) { + const forgotten = new Map(); + const unexported = new Map(); + const pending = [...exported].map(([name, { target }]) => [name, target]); + const inPackage = (declaration) => + ts.sys + .realpath(declaration.getSourceFile().fileName) + .startsWith(packageDist); + while (pending.length > 0) { + const [referrer, symbol] = pending.pop(); + for (const node of symbol.getDeclarations() ?? []) { + for (const referenced of referencedSymbols(reportableNode(node))) { + if ( + referenced === symbol || + visible.has(referenced) || + !(referenced.getDeclarations() ?? []).some(inPackage) + ) { + continue; + } + if (Object.hasOwn(allowed, referenced.name)) { + if (!unexported.has(referenced.name)) { + unexported.set(referenced.name, referenced); + pending.push([referenced.name, referenced]); + } + continue; + } + const referencedBy = forgotten.get(referenced.name) ?? new Set(); + referencedBy.add(referrer); + forgotten.set(referenced.name, referencedBy); + } + } + } + return { forgotten, unexported }; +} + +function resolveAlias(checker, symbol) { + return (symbol.flags & ts.SymbolFlags.Alias) === 0 + ? symbol + : checker.getAliasedSymbol(symbol); +} + +// Report whole variable statements so `declare const` keeps its keyword. +function reportableNode(node) { + return ts.isVariableDeclaration(node) ? node.parent.parent : node; +} + +// Symbols named by type positions inside a reported declaration, excluding +// private class members, which are not part of the public surface. +function referencedSymbols(root) { + const symbols = new Set(); + const visit = (node) => { + if (isPrivateMember(node)) return; + let name; + if (ts.isTypeReferenceNode(node)) name = node.typeName; + else if (ts.isExpressionWithTypeArguments(node)) name = node.expression; + else if (ts.isTypeQueryNode(node)) name = node.exprName; + else if (ts.isImportTypeNode(node)) name = node.qualifier; + if (name !== undefined) { + const symbol = checker.getSymbolAtLocation( + ts.isPropertyAccessExpression(name) ? name.name : name + ); + if (symbol !== undefined) { + const target = resolveAlias(checker, symbol); + if ((target.flags & ts.SymbolFlags.TypeParameter) === 0) { + symbols.add(target); + } + } + } + ts.forEachChild(node, visit); + }; + visit(root); + return symbols; +} + +function isPrivateMember(node) { + if (!ts.isClassElement(node)) return false; + if (node.name !== undefined && ts.isPrivateIdentifier(node.name)) return true; + const modifiers = ts.canHaveModifiers(node) ? ts.getModifiers(node) : []; + return (modifiers ?? []).some( + (modifier) => modifier.kind === ts.SyntaxKind.PrivateKeyword + ); +} + +// The declaration's source text with its own TSDoc comment and without +// private class members. Member TSDoc is part of the text and kept. +function declarationText(node) { + const sourceFile = node.getSourceFile(); + const text = sourceFile.text; + const removed = ts.isClassDeclaration(node) + ? node.members + .filter(isPrivateMember) + .map((member) => [member.getFullStart(), member.end]) + : []; + let body = ""; + let position = node.getStart(sourceFile); + for (const [start, end] of removed) { + body += text.slice(position, start); + position = end; + } + body += text.slice(position, node.end); + const documentation = ( + ts.getLeadingCommentRanges(text, node.getFullStart()) ?? [] + ) + .filter( + (range) => + range.kind === ts.SyntaxKind.MultiLineCommentTrivia && + text.startsWith("/**", range.pos) + ) + .at(-1); + return documentation === undefined + ? body + : `${text.slice(documentation.pos, documentation.end)}\n${body}`; +} + +function uniqueNodes(nodes) { + const seen = new Set(); + return nodes.filter((node) => { + const key = `${node.getSourceFile().fileName}:${node.pos}:${node.end}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} diff --git a/js/test/etc/test.api.md b/js/test/etc/test.api.md new file mode 100644 index 000000000..6df9078bc --- /dev/null +++ b/js/test/etc/test.api.md @@ -0,0 +1,373 @@ +# API report: `@riverqueue/test` + + + +This report contains declarations and TSDoc for names exported by the +package entry point. Private members, unexported implementation +declarations, and file layout are omitted. + +## `createTestClient` + +````ts +/** + * Create a deterministic, database-free client that records insertions. + * + * `Transaction` is the transaction type application code passes as `{ tx }` + * (for example `PoolClient`); it is recorded but never used. + * + * @example + * ```ts + * const { client, insertions } = createTestClient(); + * await signUp(client, "person@example.com"); + * requireInserted(insertions, sendWelcomeEmail, { + * args: { to: "person@example.com" }, + * }); + * ``` + */ +export declare function createTestClient( + options?: TestClientOptions +): TestClient; +```` + +## `ExpectedInsertion` + +```ts +/** One expected insertion for {@link requireManyInserted}. */ +export interface ExpectedInsertion< + Definition extends JobDefinition = JobDefinition, +> extends InsertedJobMatch { + /** Definition the inserted job must belong to. */ + readonly job: Definition; +} +``` + +## `InsertedJobMatch` + +```ts +/** Fields an inserted job must match; omitted fields are not compared. */ +export interface InsertedJobMatch { + /** Top-level args that must be equal (compared as JSON). */ + readonly args?: Partial>; + /** Attempts allowed before the job is discarded. */ + readonly maxAttempts?: number; + /** Metadata that must be equal (compared as JSON). */ + readonly metadata?: JsonObject; + /** Priority the job was inserted with. */ + readonly priority?: number; + /** Queue the job was inserted into. */ + readonly queue?: string; + /** Time the job was scheduled for, compared exactly. */ + readonly scheduledAt?: Temporal.Instant; + /** State the job was inserted in, such as `scheduled`. */ + readonly state?: JobState; + /** Tags that must be equal, in order. */ + readonly tags?: readonly string[]; +} +``` + +## `InsertionLog` + +```ts +/** A test client or its insertion log. */ +export type InsertionLog = + Pick | readonly TestInsertion[]; +``` + +## `JobListingClient` + +```ts +/** A client whose persisted jobs can be listed, such as a runtime `Client`. */ +export interface JobListingClient { + readonly jobs: Pick, "list">; +} +``` + +## `requireInserted` + +```ts +/** + * Assert that exactly one recorded insertion matches `definition` (and + * `match`, when given) and return it with its producer args typed. Throws an + * `AssertionError` describing the recorded jobs otherwise. + */ +export declare function requireInserted( + log: InsertionLog, + definition: Definition, + match?: InsertedJobMatch +): JobRow>; +``` + +## `requireInsertedInDatabase` + +```ts +/** + * Assert that exactly one job in the database matches `definition` (and + * `match`, when given) and return it with its producer args typed, like + * Go's `rivertest.RequireInsertedTx`. Pass `tx` to look inside a transaction + * that hasn't committed yet. Throws an `AssertionError` otherwise. + */ +export declare function requireInsertedInDatabase< + Definition extends JobDefinition, + Transaction = unknown, +>( + client: JobListingClient, + definition: Definition, + match?: InsertedJobMatch, + options?: { + readonly tx?: Transaction; + } +): Promise>>; +``` + +## `requireManyInserted` + +```ts +/** + * Assert that the recorded insertions are exactly `expected`, in order, and + * return them. + */ +export declare function requireManyInserted( + log: InsertionLog, + expected: readonly ExpectedInsertion[] +): readonly JobRow[]; +``` + +## `requireNotInserted` + +```ts +/** + * Assert that no recorded insertion matches `definition` (and `match`, when + * given). + */ +export declare function requireNotInserted( + log: InsertionLog, + definition: Definition, + match?: InsertedJobMatch +): void; +``` + +## `requireNotInsertedInDatabase` + +```ts +/** + * Assert that no job in the database matches `definition` (and `match`, + * when given), like Go's `rivertest.RequireNotInsertedTx`. + */ +export declare function requireNotInsertedInDatabase< + Definition extends JobDefinition, + Transaction = unknown, +>( + client: JobListingClient, + definition: Definition, + match?: InsertedJobMatch, + options?: { + readonly tx?: Transaction; + } +): Promise; +``` + +## `TestClient` + +```ts +/** An insert-only test client and its insertion log. */ +export interface TestClient { + /** + * An insert-only client. Pass it wherever application code takes an + * `InsertClient`. + */ + readonly client: InsertClient; + /** Insertions in call order. The array grows as the client inserts. */ + readonly insertions: readonly TestInsertion[]; +} +``` + +## `TestClientOptions` + +```ts +/** + * Options for {@link createTestClient}. Insert options, hooks, insert + * middleware, and plugins configure the client as they would a real one, so + * a test sees the jobs they produce. + */ +export interface TestClientOptions extends Pick< + ClientOptions, + "defaultInsertOptions" | "hooks" | "insertMiddleware" | "plugins" +> { + /** Clock used for `createdAt`. Defaults to `Temporal.Now.instant`. */ + readonly now?: () => Temporal.Instant; + /** First ID assigned to an inserted job. Defaults to `1n`. */ + readonly startingId?: bigint; +} +``` + +## `TestInsertion` + +```ts +/** One job recorded by a test client, with the transaction it was given. */ +export interface TestInsertion { + /** The row River would have persisted, with a deterministic ID. */ + readonly job: JobRow; + /** The caller-owned transaction passed as `{ tx }`, if any. */ + readonly transaction: Transaction | undefined; +} +``` + +## `testJob` + +```ts +/** + * Build a realistic running job for a worker test. + * + * `input` is what a producer would insert. It is converted to River JSON and + * validated with the definition's schema or decoder, exactly as the runtime + * does before working, so `job.args` is the worker's typed args and + * `job.rawArgs` the persisted JSON. Row fields default from the definition's + * insertion defaults. + */ +export declare function testJob( + definition: Definition, + input: JobDefinitionInput, + options?: TestJobOptions +): Promise>; +``` + +## `TestJobOptions` + +```ts +/** Row fields that {@link testJob} lets a test override. */ +export interface TestJobOptions { + /** Attempt number of the running attempt. Defaults to 1. */ + readonly attempt?: number; + /** When the attempt started. Defaults to `now`. */ + readonly attemptedAt?: Temporal.Instant | null; + /** Clients that attempted the job, oldest first; `riverqueue-test` by default. */ + readonly attemptedBy?: readonly string[]; + /** When the job was inserted. Defaults to `now`. */ + readonly createdAt?: Temporal.Instant; + /** Errors recorded by earlier attempts, oldest first. */ + readonly errors?: JobRow["errors"]; + /** When the job reached a final state; null while it can still run. */ + readonly finalizedAt?: Temporal.Instant | null; + /** Job ID. Defaults to 1. */ + readonly id?: bigint; + /** Attempts allowed. Defaults to the definition's default, else River's. */ + readonly maxAttempts?: number; + /** Job metadata. Defaults to `{}`. */ + readonly metadata?: JsonObject; + /** Clock for unspecified timestamps. Defaults to `Temporal.Now.instant()`. */ + readonly now?: Temporal.Instant; + /** Priority from 1 (first) to 4. Defaults like `maxAttempts`. */ + readonly priority?: number; + /** Queue of the job. Defaults like `maxAttempts`. */ + readonly queue?: string; + /** When the job became available. Defaults to the definition's, else `now`. */ + readonly scheduledAt?: Temporal.Instant; + /** Job state. Defaults to `running`, as a handler sees it. */ + readonly state?: JobState; + readonly tags?: readonly string[]; + /** Unique key bytes of a unique job, or null. */ + readonly uniqueKey?: Uint8Array | null; + /** States in which the unique key is enforced, or null. */ + readonly uniqueStates?: readonly JobState[] | null; +} +``` + +## `TestLogEntry` + +```ts +/** One message logged through the work context's logger. */ +export interface TestLogEntry { + readonly attributes: LogAttributes | undefined; + readonly level: "debug" | "error" | "info" | "warn"; + readonly message: string; +} +``` + +## `workOnce` + +```ts +/** + * Run one handler against a job without a database or background runtime. + * + * `handler` is either a handler function or a `Workers` registry, in which + * case the in-process handler registered for `job.kind` runs with its + * configured timeout applied to `ctx.signal`. Middleware, hooks, retries, + * and persistence are not simulated. + */ +export declare function workOnce< + Definition extends JobDefinition, + Transaction = unknown, +>( + job: Job, + handler: WorkHandler | Workers, + options?: WorkOnceOptions +): Promise>; +``` + +## `WorkOnceOptions` + +```ts +/** Options for {@link workOnce}. */ +export interface WorkOnceOptions { + /** Worker identity recorded in `ctx.execution`. */ + readonly attemptedBy?: string; + /** + * Client exposed as `ctx.client`. Defaults to a {@link createTestClient} + * client that records insertions; its job and queue operations throw + * `UnsupportedCapabilityError`. + */ + readonly client?: Client; + /** Implementation of `ctx.completeTx`. Defaults to one that rejects. */ + readonly completeTx?: ( + tx: Transaction, + options?: { + readonly output?: JsonValue; + } + ) => Promise; + /** Start time recorded in `ctx.execution`. */ + readonly now?: Temporal.Instant; + /** Resumable state. Defaults to one read from the job's metadata. */ + readonly resumable?: Resumable; + /** Abort signal exposed as `ctx.signal`. */ + readonly signal?: AbortSignal; +} +``` + +## `WorkOnceResult` + +```ts +/** Result of {@link workOnce}, narrowed by `status`. */ +export type WorkOnceResult< + Definition extends JobDefinition, + Transaction = unknown, +> = + | (WorkOnceResultBase & { + readonly outcome: WorkOutcome | undefined; + readonly status: "succeeded"; + }) + | (WorkOnceResultBase & { + readonly error: unknown; + readonly status: "failed"; + }); +``` + +# Referenced but unexported + +Exported declarations refer to these types, which the entry point +intentionally does not export. + +## `WorkOnceResultBase` + +Fields shared by every `WorkOnceResult` variant; name the union instead. + +```ts +interface WorkOnceResultBase { + readonly context: WorkContext; + /** Messages the handler logged. */ + readonly logs: readonly TestLogEntry[]; + /** Metadata updates to merge with this attempt, including resumable progress. */ + readonly metadata: JsonObject; + /** Output recorded with `recordOutput` or `complete({ output })`. */ + readonly output: JsonValue | undefined; +} +``` diff --git a/js/worker-threads/etc/worker-threads.api.md b/js/worker-threads/etc/worker-threads.api.md new file mode 100644 index 000000000..188cbfe31 --- /dev/null +++ b/js/worker-threads/etc/worker-threads.api.md @@ -0,0 +1,292 @@ +# API report: `@riverqueue/worker-threads` + + + +This report contains declarations and TSDoc for names exported by the +package entry point. Private members, unexported implementation +declarations, and file layout are omitted. + +## `WorkerThreadExportName` + +```ts +/** + * Names of `Module`'s exports that can handle jobs of `Definition`. + * + * Resolves to `string` when the module's type is unknown, as it is for a + * plain `URL`. + */ +export type WorkerThreadExportName< + Module, + Definition extends JobDefinition = JobDefinition, +> = unknown extends Module + ? string + : { + [ + Name in keyof Module & string + ]: Module[Name] extends WorkerThreadWorkHandler + ? Name + : never; + }[keyof Module & string]; +``` + +## `WorkerThreadHandlerError` + +```ts +/** + * An error reported by an isolated handler or by the thread running it. + * + * An error thrown by the handler keeps its original `name`, `message`, and + * `stack`, bounded to the limits River applies to persisted attempt errors. + * An attempt whose thread crashes or exits also fails with this error. + */ +export declare class WorkerThreadHandlerError extends Error { + /** + * @param message - The original error's message. + * @param options - The original error's `name` and `stack`, if known. + */ + constructor( + message: string, + options?: { + readonly name?: string; + readonly stack?: string; + } + ); +} +``` + +## `WorkerThreadHandlerTarget` + +```ts +/** + * Where a worker thread finds a job's handler. + * + * Give `module` the {@link WorkerThreadModule} type of the handler module to + * have TypeScript check that `exportName` names an export typed as + * {@link WorkerThreadWorkHandler} for the registered definition. + */ +export interface WorkerThreadHandlerTarget< + Definition extends JobDefinition = JobDefinition, + Module = unknown, +> { + /** Name of the module export that handles the job. */ + readonly exportName: WorkerThreadExportName; + /** Absolute URL of the ESM module that exports the handler. */ + readonly module: WorkerThreadModule; +} +``` + +## `WorkerThreadModule` + +````ts +/** + * An ES module URL annotated with the type of the module it points to. + * + * Any `URL` is assignable, so annotate the URL to give + * {@link WorkerThreads.handler} the module's exports without importing the + * module's code into the main thread: + * + * ```ts + * import type * as primeHandlers from "./prime-handlers.js"; + * + * const primeModule: WorkerThreadModule = new URL( + * "./prime-handlers.js", + * import.meta.url + * ); + * ``` + */ +export type WorkerThreadModule = URL & { + readonly [workerThreadModuleExports]?: Module; +}; +```` + +## `WorkerThreads` + +````ts +/** + * A bounded pool of native threads that runs CPU-heavy job handlers without + * blocking River's event loop. + * + * Handlers are ESM exports referenced by module URL and export name, because + * closures cannot cross a thread boundary. Each attempt runs alone in a + * reusable thread. Aborting an attempt aborts its handler's signal, waits + * the client's `jobStuckThreshold`, then terminates the thread, and River + * persists the outcome only after the thread has settled or exited. A thread + * that crashes fails only the attempt it was running and is replaced lazily. + * + * The application owns the executor: several clients may share one, and + * stopping a client never closes it. Close it with {@link WorkerThreads.close} + * or `await using` once every client using it has stopped. Idle threads do + * not keep the process alive. + * + * Worker threads isolate availability, not security. Only run trusted + * handler modules. + */ +export declare class WorkerThreads implements AsyncDisposable, WorkExecutor { + /** Executor name under which River reports this executor's diagnostics. */ + readonly name = "worker_threads"; + /** + * Create an executor. Threads start lazily as attempts need them. + * + * @throws {ConfigurationError} when an option is out of range. + */ + constructor(options: WorkerThreadsOptions); + /** + * Permanently close the executor and wait for its threads to exit. + * + * Queued attempts and attempts still running fail with a `LifecycleError`, + * and later attempts fail immediately. Stop every client using the executor + * first so running attempts finish or abort normally. Closing is idempotent. + */ + close(): Promise; + /** Report the executor's current threads and queue. */ + diagnostics(): WorkerThreadsDiagnostics; + /** + * Describe a thread handler for `Workers.addExecutor`. + * + * ```ts + * import type * as primeHandlers from "./prime-handlers.js"; + * + * const primeModule: WorkerThreadModule = new URL( + * "./prime-handlers.js", + * import.meta.url + * ); + * workers.addExecutor( + * findPrime, + * executor.handler(findPrime, { + * exportName: "findPrimeHandler", + * module: primeModule, + * }) + * ); + * ``` + * + * With a typed `module`, TypeScript rejects an `exportName` that is missing + * or not a {@link WorkerThreadWorkHandler} for `definition`. With a plain + * `URL`, any name compiles and a missing export fails the attempt. Register + * the returned target for the same definition; an attempt of another kind + * fails with a `ConfigurationError`. + * + * @throws {ConfigurationError} when the definition, module, or export name + * is invalid. + */ + handler( + definition: Definition, + target: WorkerThreadHandlerTarget + ): WorkExecutorTarget; + /** + * Queue one attempt for a thread. + * + * River's runtime calls this for jobs registered with a target from + * {@link WorkerThreads.handler}; applications do not call it directly. + * + * @throws {ConfigurationError} when the handler was not created by this + * executor, belongs to another job kind, or the decoded args cannot cross + * the thread boundary unchanged. + */ + start(context: WorkContext, handler: unknown): WorkExecutorHandle; + /** Close the executor at the end of an `await using` scope. */ + [Symbol.asyncDispose](): Promise; +} +```` + +## `WorkerThreadsDiagnostics` + +```ts +/** + * Snapshot of a {@link WorkerThreads} executor's threads and queue. + * + * River includes it under `executors.worker_threads` in client diagnostics. + */ +export type WorkerThreadsDiagnostics = { + /** Threads currently running an attempt. */ + readonly activeThreads: number; + /** + * Threads lost since construction to an uncaught error, an unhandled + * rejection, `process.exit()`, or a resource limit rather than to an abort + * or `close()`. A rising count usually means handlers leave failing + * background work behind after they return. + */ + readonly crashedThreads: number; + /** Live threads waiting for an attempt. */ + readonly idleThreads: number; + /** Attempts waiting for a thread. */ + readonly pendingTasks: number; + /** Live native threads, including any being terminated. */ + readonly totalThreads: number; +}; +``` + +## `WorkerThreadsOptions` + +```ts +/** + * Options for a {@link WorkerThreads} executor. + */ +export interface WorkerThreadsOptions { + /** + * Maximum number of live native threads. + * + * Attempts beyond this limit wait for a thread without spending their job + * timeout. + */ + readonly maxThreads: number; + /** + * V8 heap and stack limits applied to every thread. + * + * A thread that exceeds its heap limit is terminated by Node; the attempt + * it was running fails and the thread is replaced for later attempts. + */ + readonly resourceLimits?: ResourceLimits; +} +``` + +## `WorkerThreadWorkContext` + +```ts +/** + * The context passed to a handler running in a worker thread. + * + * It carries the members of River's `WorkContext` that can cross a thread + * boundary. `job.args` holds the args decoded and validated by the job + * definition in the main thread, and `job.rawArgs` the persisted JSON. + * `client`, `completeTx`, and `resumable` are unavailable because they depend + * on the main thread's connections and state. + * + * `logger`, `recordOutput`, and `setMetadata` accept River JSON values and + * are forwarded to the main thread asynchronously. + */ +export type WorkerThreadWorkContext< + Definition extends JobDefinition = JobDefinition, +> = Pick< + WorkContext, + "execution" | "job" | "logger" | "recordOutput" | "setMetadata" | "signal" +>; +``` + +## `WorkerThreadWorkHandler` + +````ts +/** + * A job handler exported from a module that runs in a worker thread. + * + * Type the export with the job's definition so its args are typed, and so + * {@link WorkerThreads.handler} accepts the export for that definition: + * + * ```ts + * import type { findPrime } from "./jobs.js"; + * + * export const findPrimeHandler: WorkerThreadWorkHandler = ({ + * job, + * }) => complete({ output: { prime: nthPrime(job.args.ordinal) } }); + * ``` + * + * A type-only import of the definition keeps the handler module from loading + * anything it does not need. Like an in-process handler, it succeeds by + * returning nothing or a River outcome and fails by throwing. Outcomes cross + * back to the main thread as River JSON. + */ +export type WorkerThreadWorkHandler< + Definition extends JobDefinition = JobDefinition, +> = ( + context: WorkerThreadWorkContext +) => PromiseLike | WorkOutcome | void; +```` From 752f0298a4551d850e66afc1b65a048df5db4b2b Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sun, 4 Oct 2026 17:33:31 -0500 Subject: [PATCH 40/43] catch unused exports and check the next TypeScript Run knip as part of `lint`, so an export, namespace member, or exported type that nothing in the workspace uses fails the check. The worker-threads package's thread module is loaded by URL rather than imported, so knip is told it's an entry point. `typecheck:next` typechecks sources and tests with the next TypeScript release as well as TypeScript 6. --- js/knip.json | 9 + js/package.json | 4 +- js/pnpm-lock.yaml | 491 +++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 501 insertions(+), 3 deletions(-) create mode 100644 js/knip.json diff --git a/js/knip.json b/js/knip.json new file mode 100644 index 000000000..e099cf6f8 --- /dev/null +++ b/js/knip.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://unpkg.com/knip@6/schema.json", + "include": ["exports", "nsExports", "nsTypes", "types"], + "workspaces": { + "worker-threads": { + "entry": ["src/thread.ts"] + } + } +} diff --git a/js/package.json b/js/package.json index 44fefdcc8..cfed1b4bc 100644 --- a/js/package.json +++ b/js/package.json @@ -41,7 +41,7 @@ "fmt": "prettier --write 'src/**/*.ts' 'driver/**/src/**/*.ts' 'migrate/src/**/*.ts' 'worker-threads/src/**/*.ts' 'test/src/**/*.ts' 'cli/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts' 'examples/*/{README.md,package.json,tsconfig.json}' 'examples/tsconfig.json' '{README.md,package.json}' 'docs/*.md' 'driver/*/README.md' '{cli,migrate,test,worker-threads}/README.md'", "fmt:check": "prettier --check 'src/**/*.ts' 'driver/**/src/**/*.ts' 'migrate/src/**/*.ts' 'worker-threads/src/**/*.ts' 'test/src/**/*.ts' 'cli/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts' 'examples/*/{README.md,package.json,tsconfig.json}' 'examples/tsconfig.json' '{README.md,package.json}' 'docs/*.md' 'driver/*/README.md' '{cli,migrate,test,worker-threads}/README.md'", "generate:migrations": "node scripts/sync-migrations.mjs", - "lint": "eslint 'src/**/*.ts' 'driver/**/src/**/*.ts' 'migrate/src/**/*.ts' 'worker-threads/src/**/*.ts' 'test/src/**/*.ts' 'cli/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts'", + "lint": "eslint 'src/**/*.ts' 'driver/**/src/**/*.ts' 'migrate/src/**/*.ts' 'worker-threads/src/**/*.ts' 'test/src/**/*.ts' 'cli/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts' && knip", "lint:fix": "eslint --fix 'src/**/*.ts' 'driver/**/src/**/*.ts' 'migrate/src/**/*.ts' 'worker-threads/src/**/*.ts' 'test/src/**/*.ts' 'cli/src/**/*.ts' 'scripts/**/*.mjs' 'examples/*/src/*.ts'", "license:check": "node scripts/check-licenses.mjs", "migration:legacy": "node scripts/check-legacy-fixture.mjs", @@ -51,6 +51,7 @@ "test": "vitest run --passWithNoTests", "test:coverage": "vitest run --coverage", "test:integration": "vitest run --passWithNoTests --config vitest.integration.config.ts", + "typecheck:next": "node node_modules/typescript-next/bin/tsc -p tsconfig.tests.json", "typecheck:tests": "node node_modules/typescript/bin/tsc -p tsconfig.tests.json", "verify:migrations": "node scripts/sync-migrations.mjs --check" }, @@ -89,6 +90,7 @@ "eslint": "^10.8.0", "eslint-config-prettier": "^10.1.8", "fast-check": "^4.10.2", + "knip": "^6.38.0", "license-checker-rseidelsohn": "^5.0.1", "pg": "^8.22.0", "pino": "^10.3.1", diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml index 379104c1e..7b0125cdd 100644 --- a/js/pnpm-lock.yaml +++ b/js/pnpm-lock.yaml @@ -37,6 +37,9 @@ importers: fast-check: specifier: ^4.10.2 version: 4.10.2 + knip: + specifier: ^6.38.0 + version: 6.38.0 license-checker-rseidelsohn: specifier: ^5.0.1 version: 5.0.1 @@ -443,12 +446,21 @@ packages: '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + '@emnapi/core@1.11.2': + resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} + '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -600,9 +612,221 @@ packages: resolution: {integrity: sha512-mGUWr1uMnf0le2TwfOZY4SFxZGXGfm4Jtay/nwAa2FLNAKXUoUwaGwBMNH36UHPtinWfTSJ3nqFQr0091CxVGg==} engines: {node: ^20.17.0 || >=22.9.0} + '@oxc-parser/binding-android-arm-eabi@0.150.0': + resolution: {integrity: sha512-oQef2Zu4Prz1KLKznz3HqZzU9uVoA5PMoDZuuLmqms7hKmKSAPzlaMnLllJq3t+rgKmfJJ2siPrpZfFrW06btw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-parser/binding-android-arm64@0.150.0': + resolution: {integrity: sha512-B6ofpoFiAUwIZ0MJ2IgHPvZK8FAtL4qzSZRwOkAKIfxEobE1mQC8nDdiFZj7pUaJiVUVCsfkMsBJKedzO8rZvA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-parser/binding-darwin-arm64@0.150.0': + resolution: {integrity: sha512-J+9IHKzx/bSz1JetOfD4zKXSK9sOm4/a7+0qomJODcTL6JsRXGeV/ZdkAPSIDydfFmWzYCraMlQEosSZEyBYkQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.150.0': + resolution: {integrity: sha512-v6IPfcAcSYrBWXV5Tce1DmxDMXLhEYpIIWiRFPJopgCVscZdLaV4MRQOUxvL3usQlw+yrwYOjBFB9IwBx+jUiQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.150.0': + resolution: {integrity: sha512-AoR/4jD02HET0KO0yNT4nbTE+XJUxiO9jB1X/ycEjUE3WrIJ3IjV/Vf+gskhWLcqqeXfvNnkDtBR7epllAm88A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.150.0': + resolution: {integrity: sha512-A/hycCFjLUrCmLoL5O/vBp40ohlaXO1Ta3v5hqYZxicYFs129wZmgZJggcW4mYGYbZebQLhup+N8JjeQl8qwyw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.150.0': + resolution: {integrity: sha512-pbqahg1Pkz7J4RKmXm30s/iQu99hv64ayXCu8P4p75HcEH5azOdR4eGqiB6lFGkFR3mMpzaYsSKLkS8oJbjmeQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.150.0': + resolution: {integrity: sha512-HV11aRbBQGwqv8bEo+K/6qr88uB4fEe1mhXncMhlVCrj+WpBSraxrUzHaPVmeQRU6x6x8v/3DuCbbo93rpp+fw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxc-parser/binding-linux-arm64-musl@0.150.0': + resolution: {integrity: sha512-k6pVkJqALwtEuP4zukVhGRhdIy4+ofChUIUUsAHHyqDZlNsknmel3JjbggrJiWGaes6h/dIcWmEXsKW4SmK9oQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxc-parser/binding-linux-ppc64-gnu@0.150.0': + resolution: {integrity: sha512-tEFg39mw/rHO5n8GK2DR4ZMFfsPQZtnQIPbsIpjoQRomJc/2tRfsCSmCT6gNit+NZGoeJG5aH9ATDH5bf0WnoQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@oxc-parser/binding-linux-riscv64-gnu@0.150.0': + resolution: {integrity: sha512-0JO7IFkoek6HqV589Menx3hJySNXi6quRhO4tq2P7kpEHKEXoDATnoPVUpn5B7gDH2KLkdo751N0uIrGJFCTCw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxc-parser/binding-linux-riscv64-musl@0.150.0': + resolution: {integrity: sha512-7XPzREnyAS5wHU9aB+49Uaqgoo1gVCklHc3DRlc3fJy2tXD/eAJFN1QFz/crj+Ulup5P1+axNREF+/RGaB/IRA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxc-parser/binding-linux-s390x-gnu@0.150.0': + resolution: {integrity: sha512-7n7ZxcbRWDFaTdyj8M8p0O9OfzoMKm8O6syBAIgcSutbOALq0Bb9G52Me+XH0anMLZV+NgXc726gqgG2q39vcQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@oxc-parser/binding-linux-x64-gnu@0.150.0': + resolution: {integrity: sha512-Vx0GSA9ZCRTUiczoEQnIIyXkMvtEY8uc6IiwLQtMe5ci2sXPqjrry0Ek5fsPP1k2kCzkML3ow9UOOEgnEDi7Bw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxc-parser/binding-linux-x64-musl@0.150.0': + resolution: {integrity: sha512-ii4/9m3viDLssMnfXU7/Pni/3nYplApuGayceX4qsVGaqqQJCx3tHIH9M/HWIeSty5i8LjS21zPYT6lVIkwLxw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxc-parser/binding-openharmony-arm64@0.150.0': + resolution: {integrity: sha512-ZtoxX5rez36ZWkwtiEhjkCPnlTPoQ2I7lIL0IYZ1yjxMYohbvoOjBmUIZzrf9W/HouONq0omIfTeCWEY+R380w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-parser/binding-win32-arm64-msvc@0.150.0': + resolution: {integrity: sha512-VqeRb5JX/bKYBrff7TUDvJyKY0914UzuCkuXIGxJS4FUmvMNfqkCbc7Sq90iMz9DlmAtlggwaBYQ9eg3h3ltiw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-ia32-msvc@0.150.0': + resolution: {integrity: sha512-EPqJfeZ4Pgg2BJSsCV8GozxV0FDltRzpVtGVa1r2noJu1iu+opOw3gtBWXwIo9odCT/MdFfyHxdy0/CRqs3OqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.150.0': + resolution: {integrity: sha512-n5YMzbqwPQqozbkrexi0lNZrUz5cnPHalYpFWe6Dau7AmKIWNo+y24IwzDuZEQtEdUHuKhXmMMih/MPjOI+CMw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@oxc-project/types@0.133.0': resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + '@oxc-project/types@0.150.0': + resolution: {integrity: sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==} + + '@oxc-resolver/binding-android-arm-eabi@11.24.2': + resolution: {integrity: sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==} + cpu: [arm] + os: [android] + + '@oxc-resolver/binding-android-arm64@11.24.2': + resolution: {integrity: sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==} + cpu: [arm64] + os: [android] + + '@oxc-resolver/binding-darwin-arm64@11.24.2': + resolution: {integrity: sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==} + cpu: [arm64] + os: [darwin] + + '@oxc-resolver/binding-darwin-x64@11.24.2': + resolution: {integrity: sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==} + cpu: [x64] + os: [darwin] + + '@oxc-resolver/binding-freebsd-x64@11.24.2': + resolution: {integrity: sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==} + cpu: [x64] + os: [freebsd] + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': + resolution: {integrity: sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': + resolution: {integrity: sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': + resolution: {integrity: sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==} + cpu: [arm64] + os: [linux] + + '@oxc-resolver/binding-linux-arm64-musl@11.24.2': + resolution: {integrity: sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==} + cpu: [arm64] + os: [linux] + + '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': + resolution: {integrity: sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==} + cpu: [ppc64] + os: [linux] + + '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': + resolution: {integrity: sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==} + cpu: [riscv64] + os: [linux] + + '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': + resolution: {integrity: sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==} + cpu: [riscv64] + os: [linux] + + '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': + resolution: {integrity: sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==} + cpu: [s390x] + os: [linux] + + '@oxc-resolver/binding-linux-x64-gnu@11.24.2': + resolution: {integrity: sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==} + cpu: [x64] + os: [linux] + + '@oxc-resolver/binding-linux-x64-musl@11.24.2': + resolution: {integrity: sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==} + cpu: [x64] + os: [linux] + + '@oxc-resolver/binding-openharmony-arm64@11.24.2': + resolution: {integrity: sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==} + cpu: [arm64] + os: [openharmony] + + '@oxc-resolver/binding-wasm32-wasi@11.24.2': + resolution: {integrity: sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': + resolution: {integrity: sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==} + cpu: [arm64] + os: [win32] + + '@oxc-resolver/binding-win32-x64-msvc@11.24.2': + resolution: {integrity: sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==} + cpu: [x64] + os: [win32] + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -1529,6 +1753,9 @@ packages: fast-uri@3.1.8: resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} + fd-package-json@2.0.0: + resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1564,6 +1791,11 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + formatly@0.7.1: + resolution: {integrity: sha512-ESuCEJE0SGgabl9q+zABI8LhJDmF+8nhpuqDzuM48HfJ9QTD5o6PqFJDFZY7w6f8xsh5lixuj5nJsQNvYOWvKQ==} + engines: {node: '>=18.3.0'} + hasBin: true + fs-minipass@3.0.3: resolution: {integrity: sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -1583,6 +1815,9 @@ packages: get-port-please@3.2.0: resolution: {integrity: sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==} + get-tsconfig@4.14.3: + resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==} + giget@3.3.1: resolution: {integrity: sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg==} hasBin: true @@ -1734,6 +1969,11 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + knip@6.38.0: + resolution: {integrity: sha512-umytiMCaZf02MV9SOO1FEDgbDf6h20VZk3ruJE0kh52kkGjYZFRUAXHJWYoLhvhoHGINO/7GXVJmQ2vxTBvl6A==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -2011,6 +2251,13 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + oxc-parser@0.150.0: + resolution: {integrity: sha512-zwajKw1GUa57IOdafIS7/yOObGF2NihwIfZfs5yJTRprMSkiwhWOc/k+n8bwnUPbHr2/XsjfBO8KyTPaW3VpWg==} + engines: {node: ^20.19.0 || >=22.12.0} + + oxc-resolver@11.24.2: + resolution: {integrity: sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -2257,6 +2504,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + ret@0.5.0: resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} engines: {node: '>=10'} @@ -2326,6 +2576,10 @@ packages: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + smol-toml@1.9.0: + resolution: {integrity: sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==} + engines: {node: '>= 18'} + socks-proxy-agent@8.0.5: resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} engines: {node: '>= 14'} @@ -2394,6 +2648,10 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -2489,6 +2747,10 @@ packages: uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + unbash@4.0.11: + resolution: {integrity: sha512-FoSOKV7NEofQSkAefMVHam4ZPKYMxjAydxiV72UFEDNV/YofxjGfiZ2A9pZjdL/lRJzTjcu4PABo1JYJX8N5iQ==} + engines: {node: '>=14'} + undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} @@ -2749,16 +3011,32 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/core@1.11.2': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.10.0': dependencies: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/wasi-threads@1.2.1': dependencies: tslib: 2.8.1 optional: true + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@10.8.0(jiti@2.7.0))': dependencies: eslint: 10.8.0(jiti@2.7.0) @@ -2845,6 +3123,13 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true + '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@tybys/wasm-util': 0.10.3 + optional: true + '@npmcli/agent@4.0.2': dependencies: agent-base: 7.1.4 @@ -2963,8 +3248,128 @@ snapshots: node-gyp: 12.4.0 proc-log: 6.1.0 + '@oxc-parser/binding-android-arm-eabi@0.150.0': + optional: true + + '@oxc-parser/binding-android-arm64@0.150.0': + optional: true + + '@oxc-parser/binding-darwin-arm64@0.150.0': + optional: true + + '@oxc-parser/binding-darwin-x64@0.150.0': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.150.0': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.150.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.150.0': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.150.0': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.150.0': + optional: true + + '@oxc-parser/binding-linux-ppc64-gnu@0.150.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.150.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-musl@0.150.0': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.150.0': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.150.0': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.150.0': + optional: true + + '@oxc-parser/binding-openharmony-arm64@0.150.0': + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.150.0': + optional: true + + '@oxc-parser/binding-win32-ia32-msvc@0.150.0': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.150.0': + optional: true + '@oxc-project/types@0.133.0': {} + '@oxc-project/types@0.150.0': {} + + '@oxc-resolver/binding-android-arm-eabi@11.24.2': + optional: true + + '@oxc-resolver/binding-android-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-darwin-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-darwin-x64@11.24.2': + optional: true + + '@oxc-resolver/binding-freebsd-x64@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-x64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-x64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-openharmony-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-wasm32-wasi@11.24.2': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + optional: true + + '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': + optional: true + + '@oxc-resolver/binding-win32-x64-msvc@11.24.2': + optional: true + '@pinojs/redact@0.4.0': {} '@prisma/adapter-pg@7.9.1': @@ -3928,6 +4333,10 @@ snapshots: fast-uri@3.1.8: {} + fd-package-json@2.0.0: + dependencies: + walk-up-path: 4.0.0 + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: picomatch: 4.0.7 @@ -3961,6 +4370,11 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + formatly@0.7.1: + dependencies: + fd-package-json: 2.0.0 + package-manager-detector: 1.8.0 + fs-minipass@3.0.3: dependencies: minipass: 7.1.3 @@ -3976,6 +4390,10 @@ snapshots: get-port-please@3.2.0: {} + get-tsconfig@4.14.3: + dependencies: + resolve-pkg-maps: 1.0.0 + giget@3.3.1: {} glob-parent@6.0.2: @@ -4093,6 +4511,22 @@ snapshots: dependencies: json-buffer: 3.0.1 + knip@6.38.0: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + formatly: 0.7.1 + get-tsconfig: 4.14.3 + jiti: 2.7.0 + oxc-parser: 0.150.0 + oxc-resolver: 11.24.2 + picomatch: 4.0.7 + smol-toml: 1.9.0 + strip-json-comments: 5.0.3 + tinyglobby: 0.2.17 + unbash: 4.0.11 + yaml: 2.9.1 + zod: 4.6.5 + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -4399,6 +4833,52 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + oxc-parser@0.150.0: + dependencies: + '@oxc-project/types': 0.150.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.150.0 + '@oxc-parser/binding-android-arm64': 0.150.0 + '@oxc-parser/binding-darwin-arm64': 0.150.0 + '@oxc-parser/binding-darwin-x64': 0.150.0 + '@oxc-parser/binding-freebsd-x64': 0.150.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.150.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.150.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.150.0 + '@oxc-parser/binding-linux-arm64-musl': 0.150.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.150.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.150.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.150.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.150.0 + '@oxc-parser/binding-linux-x64-gnu': 0.150.0 + '@oxc-parser/binding-linux-x64-musl': 0.150.0 + '@oxc-parser/binding-openharmony-arm64': 0.150.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.150.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.150.0 + '@oxc-parser/binding-win32-x64-msvc': 0.150.0 + + oxc-resolver@11.24.2: + optionalDependencies: + '@oxc-resolver/binding-android-arm-eabi': 11.24.2 + '@oxc-resolver/binding-android-arm64': 11.24.2 + '@oxc-resolver/binding-darwin-arm64': 11.24.2 + '@oxc-resolver/binding-darwin-x64': 11.24.2 + '@oxc-resolver/binding-freebsd-x64': 11.24.2 + '@oxc-resolver/binding-linux-arm-gnueabihf': 11.24.2 + '@oxc-resolver/binding-linux-arm-musleabihf': 11.24.2 + '@oxc-resolver/binding-linux-arm64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-arm64-musl': 11.24.2 + '@oxc-resolver/binding-linux-ppc64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-riscv64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-riscv64-musl': 11.24.2 + '@oxc-resolver/binding-linux-s390x-gnu': 11.24.2 + '@oxc-resolver/binding-linux-x64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-x64-musl': 11.24.2 + '@oxc-resolver/binding-openharmony-arm64': 11.24.2 + '@oxc-resolver/binding-wasm32-wasi': 11.24.2 + '@oxc-resolver/binding-win32-arm64-msvc': 11.24.2 + '@oxc-resolver/binding-win32-x64-msvc': 11.24.2 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -4635,6 +5115,8 @@ snapshots: require-from-string@2.0.2: {} + resolve-pkg-maps@1.0.0: {} + ret@0.5.0: {} retry@0.12.0: {} @@ -4707,6 +5189,8 @@ snapshots: smart-buffer@4.2.0: {} + smol-toml@1.9.0: {} + socks-proxy-agent@8.0.5: dependencies: agent-base: 7.1.4 @@ -4783,6 +5267,8 @@ snapshots: dependencies: ansi-regex: 5.0.1 + strip-json-comments@5.0.3: {} + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -4882,6 +5368,8 @@ snapshots: uc.micro@2.1.0: {} + unbash@4.0.11: {} + undici-types@8.3.0: {} undici@6.28.0: {} @@ -4984,8 +5472,7 @@ snapshots: yaml@2.9.0: {} - yaml@2.9.1: - optional: true + yaml@2.9.1: {} yargs-parser@20.2.9: {} From 57648a44f604d79f997efde51bcf6ff66e3cd639 Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sun, 4 Oct 2026 17:34:13 -0500 Subject: [PATCH 41/43] run every JavaScript gate in CI Build and typecheck with both compilers; check API reports and docs, documentation snippets, the 0.1 migration fixture, and the migration mirror; lint, check formatting and licenses, and audit dependencies. Run unit tests on Node 26 and integration tests on PostgreSQL 14 through 18, and check the packed archives and build the examples against them. The workflow runs when the workspace, River's migrations or SQL, or the workflow itself changes, and a shared action sets up Node.js, pnpm, and the workspace. Add `make` targets that delegate to the workspace's scripts with `pnpm -C js`: `build/js`, `lint/js`, `test/js`, `test/js/integration`, `doc/js`, `check/js/dependencies`, `check/js/package`, and `generate/js-migrations` and `verify/js-migrations`, which join the `generate` and `verify` aggregates. Limit Dependabot's grouped JavaScript updates to minor and patch releases so major updates arrive separately, and have it update the composite action too. --- .github/actions/setup-js/action.yaml | 36 +++++ .github/dependabot.yml | 16 ++- .github/workflows/js.yaml | 191 ++++++++++++++++----------- Makefile | 59 +++++++++ js/pnpm-lock.yaml | 24 ++-- js/pnpm-workspace.yaml | 7 + 6 files changed, 240 insertions(+), 93 deletions(-) create mode 100644 .github/actions/setup-js/action.yaml 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 293fc54d3..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,6 +33,11 @@ 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: @@ -39,10 +47,16 @@ updates: 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" diff --git a/.github/workflows/js.yaml b/.github/workflows/js.yaml index 525e39f23..24a90a713 100644 --- a/.github/workflows/js.yaml +++ b/.github/workflows/js.yaml @@ -1,19 +1,31 @@ name: JavaScript -# Runs for changes to the JavaScript workspace, the canonical migrations its -# examples and integration tests run against, and this workflow. +# Runs for changes to the JavaScript workspace and to everything its gates +# read from the rest of River: the canonical migrations and SQL it mirrors and +# runs against, and this workflow. Release tags always run it. on: push: branches: [master] paths: &paths + - ".github/actions/setup-js/**" - ".github/workflows/js.yaml" + - "Makefile" - "js/**" - "riverdriver/**/*.sql" + tags: ["v*"] pull_request: paths: *paths -# Node versions to test against (last two LTS releases). -# Update these when new LTS versions are released. +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: @@ -23,58 +35,66 @@ jobs: build: name: Build runs-on: ubuntu-latest - - strategy: - matrix: &last_two_lts_node_versions - node-version: [22, 24] + timeout-minutes: 20 steps: - uses: actions/checkout@v6 - - - uses: pnpm/action-setup@v6 with: - package_json_file: js/package.json + persist-credentials: false - - uses: actions/setup-node@v6 - with: - cache: pnpm - cache-dependency-path: js/pnpm-lock.yaml - node-version: ${{ matrix.node-version }} - - - run: pnpm install --frozen-lockfile + - 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 - - - uses: pnpm/action-setup@v6 with: - package_json_file: js/package.json + persist-credentials: false - - uses: actions/setup-node@v6 - with: - node-version: 24 - cache: pnpm - cache-dependency-path: js/pnpm-lock.yaml + - uses: ./.github/actions/setup-js - - run: pnpm install --frozen-lockfile + # Type-aware lint reads `riverqueue` through its built declarations. + - run: pnpm run build - run: pnpm run lint - run: pnpm run fmt:check - examples: - name: Examples + - 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:17 + image: postgres:18 env: POSTGRES_DB: river_test POSTGRES_PASSWORD: postgres @@ -86,72 +106,98 @@ jobs: ports: - 5432:5432 - strategy: - matrix: *last_two_lts_node_versions - env: DATABASE_URL: postgres://postgres:postgres@localhost:5432/river_test?sslmode=disable + RIVER_REQUIRE_POSTGRES: "1" steps: - uses: actions/checkout@v6 - - - uses: pnpm/action-setup@v6 with: - package_json_file: js/package.json + persist-credentials: false - - uses: actions/setup-node@v6 - with: - cache: pnpm - cache-dependency-path: js/pnpm-lock.yaml - node-version: ${{ matrix.node-version }} + - uses: ./.github/actions/setup-js - - uses: actions/setup-go@v6 - with: - go-version: "1.27" + - 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" - # Migrate with this revision's River CLI and migrations. - - run: go run . migrate-up --database-url "$DATABASE_URL" - working-directory: cmd/river + - run: pnpm run package:check - - run: pnpm install --frozen-lockfile + 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: pnpm --filter='./examples/*' run build + - run: node cli/dist/bin.js migrate-up --database-url "$DATABASE_URL" - - run: pnpm --filter='./examples/*' run start + - run: pnpm run package:examples test: - name: Test + name: Test (Node ${{ matrix.node-version || 'from .node-version' }}) runs-on: ubuntu-latest + timeout-minutes: 15 strategy: - matrix: *last_two_lts_node_versions + fail-fast: false + matrix: + # The minimum supported release and the current Node 26 release. + node-version: ["26.0.0", ""] steps: - uses: actions/checkout@v6 - - - uses: pnpm/action-setup@v6 with: - package_json_file: js/package.json + persist-credentials: false - - uses: actions/setup-node@v6 + - uses: ./.github/actions/setup-js with: - cache: pnpm - cache-dependency-path: js/pnpm-lock.yaml node-version: ${{ matrix.node-version }} - - run: pnpm install --frozen-lockfile + # 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) + 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:17 + image: postgres:${{ matrix.postgres-version }} env: POSTGRES_DB: river_test POSTGRES_PASSWORD: postgres @@ -163,33 +209,18 @@ jobs: ports: - 5432:5432 - strategy: - matrix: *last_two_lts_node_versions - env: TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/river_test?sslmode=disable steps: - uses: actions/checkout@v6 - - - 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: ${{ matrix.node-version }} + persist-credentials: false - - uses: actions/setup-go@v6 - with: - go-version: "1.27" + - uses: ./.github/actions/setup-js - # Migrate with this revision's River CLI and migrations. - - run: go run . migrate-up --database-url "$TEST_DATABASE_URL" - working-directory: cmd/river + - run: pnpm run build:all - - run: pnpm install --frozen-lockfile + - 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/pnpm-lock.yaml b/js/pnpm-lock.yaml index 7b0125cdd..043b83c30 100644 --- a/js/pnpm-lock.yaml +++ b/js/pnpm-lock.yaml @@ -1443,8 +1443,8 @@ packages: resolution: {integrity: sha512-frE1t78WOwJ45PKV2cF2tNPjTcs9L1J9s6VkrV59wanRP4GlaomuxYPVma7BwthMg8WnfSory4w5PTE6FZZ81w==} engines: {node: ^20.17.0 || >=22.9.0} - brace-expansion@5.0.9: - resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} engines: {node: 20 || >=22} c12@3.3.4: @@ -1892,8 +1892,8 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} - ip-address@10.7.0: - resolution: {integrity: sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==} + ip-address@10.7.3: + resolution: {integrity: sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==} engines: {node: '>= 12'} is-extglob@2.1.1: @@ -2754,8 +2754,8 @@ packages: undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} - undici@6.28.0: - resolution: {integrity: sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==} + undici@6.29.0: + resolution: {integrity: sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==} engines: {node: '>=18.17'} unicode-emoji-modifier-base@1.0.0: @@ -4034,7 +4034,7 @@ snapshots: read-cmd-shim: 6.0.0 write-file-atomic: 7.0.1 - brace-expansion@5.0.9: + brace-expansion@5.0.12: dependencies: balanced-match: 4.0.4 @@ -4456,7 +4456,7 @@ snapshots: internmap@2.0.3: {} - ip-address@10.7.0: {} + ip-address@10.7.3: {} is-extglob@2.1.1: {} @@ -4675,7 +4675,7 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.9 + brace-expansion: 5.0.12 minipass-collect@2.0.1: dependencies: @@ -4763,7 +4763,7 @@ snapshots: semver: 7.8.5 tar: 7.5.22 tinyglobby: 0.2.17 - undici: 6.28.0 + undici: 6.29.0 which: 6.0.1 nopt@7.2.1: @@ -5201,7 +5201,7 @@ snapshots: socks@2.8.9: dependencies: - ip-address: 10.7.0 + ip-address: 10.7.3 smart-buffer: 4.2.0 sonic-boom@4.2.1: @@ -5372,7 +5372,7 @@ snapshots: undici-types@8.3.0: {} - undici@6.28.0: {} + undici@6.29.0: {} unicode-emoji-modifier-base@1.0.0: {} diff --git a/js/pnpm-workspace.yaml b/js/pnpm-workspace.yaml index efd8d2d6e..f93d5ef4f 100644 --- a/js/pnpm-workspace.yaml +++ b/js/pnpm-workspace.yaml @@ -10,3 +10,10 @@ ignoredBuiltDependencies: - "@prisma/client" - "@prisma/engines" - prisma + +auditConfig: + ignoreGhsas: + # http-cache-semantics has no fixed release. It's reachable only through + # license-checker-rseidelsohn's npm registry client, which the offline + # `license:check` never uses. + - GHSA-ch52-4w7c-c8xp From 5eef0ef18ca03071880241fe2f19600f9681deb4 Mon Sep 17 00:00:00 2001 From: Brandur Date: Mon, 5 Oct 2026 16:45:39 -0500 Subject: [PATCH 42/43] Align JavaScript CI path filtering with Rust --- .github/workflows/js.yaml | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/.github/workflows/js.yaml b/.github/workflows/js.yaml index 24a90a713..2a4959522 100644 --- a/.github/workflows/js.yaml +++ b/.github/workflows/js.yaml @@ -1,12 +1,12 @@ name: JavaScript -# Runs for changes to the JavaScript workspace and to everything its gates -# read from the rest of River: the canonical migrations and SQL it mirrors and -# runs against, and this workflow. Release tags always run it. 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: &paths + branches: + - master + paths: - ".github/actions/setup-js/**" - ".github/workflows/js.yaml" - "Makefile" @@ -14,7 +14,12 @@ on: - "riverdriver/**/*.sql" tags: ["v*"] pull_request: - paths: *paths + paths: + - ".github/actions/setup-js/**" + - ".github/workflows/js.yaml" + - "Makefile" + - "js/**" + - "riverdriver/**/*.sql" concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} From 53423e832934c6f69de61dedc03db56be54ed019 Mon Sep 17 00:00:00 2001 From: Brandur Date: Mon, 5 Oct 2026 16:59:18 -0500 Subject: [PATCH 43/43] Fix for intermittent failing tests From Codex: > Fixed the shared timing helper in js/src/pilot-runtime.test.ts:273. It > stopped after 10,000 event-loop turns, which could finish before a real > cooldown expired. It now uses bounded, time-based polling. --- js/src/pilot-runtime.test.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/js/src/pilot-runtime.test.ts b/js/src/pilot-runtime.test.ts index c60213955..1d0372194 100644 --- a/js/src/pilot-runtime.test.ts +++ b/js/src/pilot-runtime.test.ts @@ -271,11 +271,9 @@ function deferred(): Deferred { } async function waitUntil(predicate: () => boolean): Promise { - for (let turn = 0; turn < 10_000; turn++) { - if (predicate()) return; - await new Promise((resolve) => setImmediate(resolve)); - } - throw new Error("condition was not reached"); + // Queue polling and fetch cooldowns use real timers. A fixed number of + // event-loop turns can run out before those timers fire on a fast runner. + await expect.poll(predicate, { interval: 1, timeout: 2_000 }).toBe(true); } const silentLogger: Logger = {