diff --git a/js/CHANGELOG.md b/js/CHANGELOG.md index e23d41677..df78eda2f 100644 --- a/js/CHANGELOG.md +++ b/js/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Allow `@riverqueue/cli` to install alongside TypeScript 7 by removing its TypeScript compiler peer dependency. Running migrations and benchmarks does not require a compiler. + +### Removed + +- Removed the `riverqueue codemod-0.1` upgrade command. Follow [migrating from 0.1](./docs/migrating-from-0.1.md) for before/after examples and changes that need manual review. `@riverqueue/cli@0.3.2` is the last release containing the command and requires TypeScript 5 or 6 to run it. + ## [0.3.2] - 2026-11-09 ### Fixed diff --git a/js/README.md b/js/README.md index 481b32820..955715f9a 100644 --- a/js/README.md +++ b/js/README.md @@ -149,17 +149,17 @@ packages; it uses Node's built-in `node:sqlite`. See the ## Packages -| Package | Purpose | -| ---------------------------- | ------------------------------------------------------------------- | -| `riverqueue` | Job definitions, insertion, workers, runtime, queries, and events | -| `@riverqueue/driver-knex` | Insert jobs inside Knex transactions on Postgres | -| `@riverqueue/driver-pg` | Postgres through `node-postgres` | -| `@riverqueue/driver-prisma` | Insert jobs inside Prisma transactions | -| `@riverqueue/driver-sqlite` | SQLite through Node's built-in `node:sqlite` | -| `@riverqueue/migrate` | Postgres 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 | +| Package | Purpose | +| ---------------------------- | ----------------------------------------------------------------- | +| `riverqueue` | Job definitions, insertion, workers, runtime, queries, and events | +| `@riverqueue/driver-knex` | Insert jobs inside Knex transactions on Postgres | +| `@riverqueue/driver-pg` | Postgres through `node-postgres` | +| `@riverqueue/driver-prisma` | Insert jobs inside Prisma transactions | +| `@riverqueue/driver-sqlite` | SQLite through Node's built-in `node:sqlite` | +| `@riverqueue/migrate` | Postgres 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 and benchmarks | ## Documentation diff --git a/js/cli/README.md b/js/cli/README.md index 8d8e18e84..30c4eb343 100644 --- a/js/cli/README.md +++ b/js/cli/README.md @@ -1,8 +1,7 @@ # `@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 -24 or newer. +benchmark. It requires Node.js 24 or newer. ```sh npm install --save-dev @riverqueue/cli @@ -62,18 +61,8 @@ 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. +See [Migrating from riverqueue 0.1][migrating] for before/after examples and +changes to review when upgrading an insert-only client. [migrating]: https://github.com/riverqueue/river/blob/master/js/docs/migrating-from-0.1.md @@ -153,7 +142,8 @@ must supply a global Temporal implementation, as with other River libraries. On Node.js 24, [preload a polyfill](../README.md#temporal-on-nodejs-24) before importing it. Programmatic imports never install a global. -The CLI installs its own matching `riverqueue` and database packages. +The CLI installs its own matching `riverqueue` and database packages. Running +its commands does not require a TypeScript compiler. TypeScript users need TypeScript 6.0 or newer and `@types/node` and `@types/pg`, with `"node"` listed in `compilerOptions.types`. See [River's diff --git a/js/cli/package.json b/js/cli/package.json index 4fe44a331..4b30c61b9 100644 --- a/js/cli/package.json +++ b/js/cli/package.json @@ -1,7 +1,7 @@ { "name": "@riverqueue/cli", "version": "0.3.2", - "description": "Command-line migrations, benchmarks, and upgrade codemods for River TypeScript.", + "description": "Command-line migrations and benchmarks for River TypeScript.", "type": "module", "sideEffects": [ "./dist/bin.js" @@ -57,8 +57,7 @@ }, "peerDependencies": { "@types/node": ">=24", - "@types/pg": ">=8", - "typescript": "^5.0.0 || ^6.0.0" + "@types/pg": ">=8" }, "peerDependenciesMeta": { "@types/node": { @@ -66,9 +65,6 @@ }, "@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 deleted file mode 100644 index 38928d378..000000000 --- a/js/cli/src/codemod-command.test.ts +++ /dev/null @@ -1,225 +0,0 @@ -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 deleted file mode 100644 index 037e3ee5e..000000000 --- a/js/cli/src/codemod-command.ts +++ /dev/null @@ -1,281 +0,0 @@ -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 deleted file mode 100644 index 74a0e0901..000000000 --- a/js/cli/src/codemod-edits.ts +++ /dev/null @@ -1,112 +0,0 @@ -/** - * 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 deleted file mode 100644 index c36e80b7c..000000000 --- a/js/cli/src/codemod.test.ts +++ /dev/null @@ -1,806 +0,0 @@ -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 deleted file mode 100644 index 7f14f365d..000000000 --- a/js/cli/src/codemod.ts +++ /dev/null @@ -1,2114 +0,0 @@ -/** - * Source-to-source migration from the `riverqueue@0.1.0` insert-only client - * to the current job-definition API. - * - * The codemod rewrites only patterns with one unambiguous meaning: argument - * classes whose serialized shape is exactly their constructor parameter - * properties, `JobArgsObject`, `InsertManyParams`, renamed option types, - * the `uniqueOpts` rename, `byPeriod` seconds, `uniqueSkippedAsDuplicated`, - * and the `JOB_STATE_*` constants. Everything else gets a `TODO(riverqueue-0.1):` comment so the - * remaining sites line up with the compiler errors they produce. It never - * guesses at ID arithmetic, timestamp handling, or string formatting. - * - * The TypeScript compiler API is passed in rather than imported so the CLI - * can use whichever compatible `typescript` the project being migrated has - * installed. Syntax kinds differ between TypeScript versions, so this module - * reads every kind from that instance. - */ - -import { dirname, extname, resolve } from "node:path"; - -import type * as TS from "typescript"; - -import { SourceEdits } from "./codemod-edits.js"; - -/** The `typescript` module namespace. */ -export type TypeScriptApi = typeof TS; - -/** Prefix of every comment the codemod leaves at a site it cannot rewrite. */ -export const TODO_MARKER = "TODO(riverqueue-0.1):"; - -/** One file to migrate. */ -export interface CodemodSource { - /** Absolute path, used for parsing mode and relative import resolution. */ - readonly path: string; - /** Current contents. */ - readonly text: string; -} - -/** A site left for manual review, marked with {@link TODO_MARKER}. */ -interface CodemodSite { - /** One-based line in the migrated text of the code the comment marks. */ - readonly line: number; - /** The comment's text after the marker. */ - readonly message: string; -} - -export interface CodemodFileResult { - /** Whether {@link CodemodFileResult.output} differs from the input. */ - readonly changed: boolean; - /** Migrated contents. */ - readonly output: string; - /** - * Why the file could not be migrated, if it could not; `output` is then - * the unchanged input. - */ - readonly error?: string; - /** Absolute path of the file. */ - readonly path: string; - /** Every marked site in the migrated text, including earlier runs' marks. */ - readonly sites: readonly CodemodSite[]; -} - -const RIVER_MODULE = "riverqueue"; -const JOB_ROW_TIMESTAMPS = new Set([ - "attemptedAt", - "createdAt", - "finalizedAt", - "scheduledAt", -]); -const JOB_STATE_PREFIX = "JOB_STATE_"; -const CLIENT_SCHEMA_MESSAGE = - "the `schema` client option moved to the driver: `new PgDriver(pool, { schema })`"; -const BY_PERIOD_MESSAGE = - "`byPeriod` was a number of seconds and is now a duration; check the value"; -const UNIQUE_OPTIONS_MESSAGE = - "`unique.byPeriod` is now a duration such as `{ seconds: 60 }`, not a number of seconds"; -/** 0.1 exports replaced by a rewrite; the import is dropped once unused. */ -const REPLACED_EXPORTS = new Set([ - "InsertManyParams", - "JobArgs", - "JobArgsObject", -]); -/** 0.1 exports with no direct replacement, and what to do instead. */ -const REMOVED_EXPORTS: ReadonlyMap = new Map([ - ["Driver", "drivers now implement `riverqueue/unstable-driver`"], - ["DriverOptions", "drivers now implement `riverqueue/unstable-driver`"], - ["JobInsertParams", "drivers now implement `riverqueue/unstable-driver`"], - ["uniqueBitmaskFromStates", "it moved to `riverqueue/unstable-driver`"], - ["uniqueBitmaskToStates", "it moved to `riverqueue/unstable-driver`"], -]); -/** - * 0.1 exports under a new name. Imports keep their local name, so the - * code using them needs no change. - */ -const RENAMED_EXPORTS: ReadonlyMap = new Map([ - ["ClientOpts", "ClientOptions"], - ["InsertOpts", "InsertOptions"], - ["UniqueOpts", "UniqueOptions"], -]); -const RESERVED_WORDS = new Set( - ( - "arguments await break case catch class const continue debugger default " + - "delete do else enum eval export extends false finally for function if " + - "implements import in instanceof interface let new null package private " + - "protected public return static super switch this throw true try typeof " + - "undefined var void while with yield" - ).split(" ") -); -/** Extensions of the files the codemod parses. */ -export const SOURCE_EXTENSIONS: readonly string[] = [ - ".ts", - ".tsx", - ".mts", - ".cts", - ".js", - ".jsx", - ".mjs", - ".cjs", -]; - -/** - * Migrate a set of files together. Argument classes are resolved across the - * set, so `new SortArgs(...)` in one file is rewritten against the class - * declared in another when both are processed. - */ -export function migrateSources( - ts: TypeScriptApi, - sources: readonly CodemodSource[] -): CodemodFileResult[] { - const files = sources.map((source) => parseFile(ts, source)); - const byPath = new Map(files.map((file) => [file.path, file])); - const project = new Project(ts, files, byPath); - return files.map((file) => { - try { - return new FileMigration(ts, project, file).run(); - } catch (error: unknown) { - return { - changed: false, - error: error instanceof Error ? error.message : String(error), - output: file.text, - path: file.path, - sites: [], - }; - } - }); -} - -interface ParsedFile { - /** Job argument classes declared at the top level, by class name. */ - readonly classes: Map; - readonly isTypeScript: boolean; - readonly newline: string; - readonly path: string; - /** Local names bound by named imports from `riverqueue` to imported names. */ - readonly riverImports: Map; - /** Local name of `import * as name from "riverqueue"`, if any. */ - readonly riverNamespace: string | undefined; - readonly sourceFile: TS.SourceFile; - /** Identifier texts that name bindings or references in the file. */ - readonly taken: Set; - readonly text: string; -} - -interface JobParam { - readonly comments: readonly string[]; - readonly name: string; - readonly optional: boolean; - readonly type: string | undefined; -} - -interface ConvertibleJobClass { - readonly className: string; - readonly convertible: true; - readonly declaration: TS.ClassDeclaration; - readonly defaults: TS.Expression | undefined; - readonly defaultsComments: readonly string[]; - readonly file: ParsedFile; - /** Source text of the `kind` literal, keeping its quote style. */ - readonly kind: string; - readonly kindComments: readonly string[]; - readonly memberIndent: string; - readonly params: readonly JobParam[]; -} - -interface UnconvertibleJobClass { - readonly className: string; - readonly convertible: false; - readonly declaration: TS.ClassDeclaration; - readonly file: ParsedFile; - readonly reason: string; -} - -type JobClass = ConvertibleJobClass | UnconvertibleJobClass; - -/** How an exported name reaches a job class, and whether it changes. */ -interface ResolvedExport { - readonly jobClass: JobClass; - /** Whether the exported name follows the class name to its definition name. */ - readonly renamed: boolean; -} - -function parseFile(ts: TypeScriptApi, source: CodemodSource): ParsedFile { - const extension = extname(source.path).toLowerCase(); - const scriptKind = - extension === ".tsx" - ? ts.ScriptKind.TSX - : extension === ".jsx" - ? ts.ScriptKind.JSX - : [".js", ".mjs", ".cjs"].includes(extension) - ? ts.ScriptKind.JS - : ts.ScriptKind.TS; - const sourceFile = ts.createSourceFile( - source.path, - source.text, - ts.ScriptTarget.Latest, - true, - scriptKind - ); - const riverImports = new Map(); - let riverNamespace: string | undefined; - for (const statement of sourceFile.statements) { - if (!isRiverImport(ts, statement)) continue; - const bindings = statement.importClause?.namedBindings; - if (bindings === undefined) continue; - if (ts.isNamespaceImport(bindings)) { - riverNamespace = bindings.name.text; - } else { - for (const element of bindings.elements) { - riverImports.set( - element.name.text, - (element.propertyName ?? element.name).text - ); - } - } - } - const taken = new Set(); - const collect = (node: TS.Node): void => { - if (ts.isIdentifier(node) && !isPropertyName(ts, node)) { - taken.add(node.text); - } - ts.forEachChild(node, collect); - }; - collect(sourceFile); - - const file: ParsedFile = { - classes: new Map(), - isTypeScript: - scriptKind === ts.ScriptKind.TS || scriptKind === ts.ScriptKind.TSX, - newline: source.text.includes("\r\n") ? "\r\n" : "\n", - path: source.path, - riverImports, - riverNamespace, - sourceFile, - taken, - text: source.text, - }; - for (const statement of sourceFile.statements) { - if ( - ts.isClassDeclaration(statement) && - statement.name !== undefined && - implementsJobArgs(ts, file, statement) - ) { - file.classes.set( - statement.name.text, - analyzeClass(ts, file, statement, statement.name.text) - ); - } - } - return file; -} - -function isRiverImport( - ts: TypeScriptApi, - node: TS.Node -): node is TS.ImportDeclaration { - return ( - ts.isImportDeclaration(node) && - ts.isStringLiteral(node.moduleSpecifier) && - node.moduleSpecifier.text === RIVER_MODULE - ); -} - -/** Whether `identifier` names a property rather than a binding or reference. */ -function isPropertyName(ts: TypeScriptApi, identifier: TS.Identifier): boolean { - const parent = identifier.parent; - return ( - ((ts.isPropertyAccessExpression(parent) || - ts.isPropertyAssignment(parent) || - ts.isPropertyDeclaration(parent) || - ts.isPropertySignature(parent) || - ts.isMethodDeclaration(parent) || - ts.isMethodSignature(parent) || - ts.isGetAccessorDeclaration(parent) || - ts.isSetAccessorDeclaration(parent) || - ts.isEnumMember(parent)) && - parent.name === identifier) || - (ts.isQualifiedName(parent) && parent.right === identifier) || - ((ts.isImportSpecifier(parent) || ts.isExportSpecifier(parent)) && - parent.propertyName === identifier) || - (ts.isBindingElement(parent) && parent.propertyName === identifier) - ); -} - -/** Resolve `expression` to the name it imports from `riverqueue`, if any. */ -function riverName( - ts: TypeScriptApi, - file: ParsedFile, - expression: TS.Node -): string | undefined { - if (ts.isIdentifier(expression)) { - return file.riverImports.get(expression.text); - } - if ( - ts.isPropertyAccessExpression(expression) && - ts.isIdentifier(expression.expression) && - expression.expression.text === file.riverNamespace - ) { - return expression.name.text; - } - return undefined; -} - -function implementsJobArgs( - ts: TypeScriptApi, - file: ParsedFile, - declaration: TS.ClassDeclaration -): boolean { - return (declaration.heritageClauses ?? []).some( - (clause) => - clause.token === ts.SyntaxKind.ImplementsKeyword && - clause.types.some( - (type) => riverName(ts, file, type.expression) === "JobArgs" - ) - ); -} - -function analyzeClass( - ts: TypeScriptApi, - file: ParsedFile, - declaration: TS.ClassDeclaration, - className: string -): JobClass { - const unconvertible = (reason: string): UnconvertibleJobClass => ({ - className, - convertible: false, - declaration, - file, - reason, - }); - const modifiers = ts.getModifiers(declaration) ?? []; - if ( - modifiers.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword) - ) { - return unconvertible("it is a default export"); - } - if ( - modifiers.some( - (modifier) => - modifier.kind === ts.SyntaxKind.AbstractKeyword || - modifier.kind === ts.SyntaxKind.DeclareKeyword - ) || - (ts.getDecorators(declaration) ?? []).length > 0 - ) { - return unconvertible("it is abstract, declared, or decorated"); - } - if (declaration.typeParameters !== undefined) { - return unconvertible("it has type parameters"); - } - const heritage = declaration.heritageClauses ?? []; - if ( - heritage.some( - (clause) => - clause.token === ts.SyntaxKind.ExtendsKeyword || - clause.types.length !== 1 - ) - ) { - return unconvertible("it extends a class or implements other interfaces"); - } - - let kind: string | undefined; - let kindComments: readonly string[] = []; - let memberIndent: string | undefined; - let defaults: TS.Expression | undefined; - let defaultsComments: readonly string[] = []; - let params: JobParam[] = []; - let toJson: TS.MethodDeclaration | undefined; - let sawConstructor = false; - for (const member of declaration.members) { - const name = - member.name !== undefined && ts.isIdentifier(member.name) - ? member.name.text - : undefined; - if ( - ts.isPropertyDeclaration(member) && - (name === "kind" || name === "insertOpts") && - !(ts.getModifiers(member) ?? []).some( - (modifier) => modifier.kind === ts.SyntaxKind.StaticKeyword - ) && - (ts.getDecorators(member) ?? []).length === 0 - ) { - if (name === "kind") { - const literal = stringLiteral(ts, member.initializer); - if (literal === undefined) { - return unconvertible("its `kind` is not a string literal"); - } - kind = literal.getText(); - kindComments = leadingComments(ts, file.text, member); - memberIndent = lineIndent(file.text, member.getStart()); - } else if (member.initializer !== undefined) { - defaults = member.initializer; - defaultsComments = leadingComments(ts, file.text, member); - } - continue; - } - if (ts.isConstructorDeclaration(member) && !sawConstructor) { - sawConstructor = true; - if (member.body === undefined || member.body.statements.length > 0) { - return unconvertible("its constructor has a body"); - } - const parsed = parseParameters(ts, file, member.parameters); - if (typeof parsed === "string") return unconvertible(parsed); - params = parsed; - continue; - } - if ( - ts.isMethodDeclaration(member) && - name === "toJSON" && - toJson === undefined - ) { - toJson = member; - continue; - } - if (ts.isSemicolonClassElement(member)) continue; - return unconvertible( - "it has members other than `kind`, `insertOpts`, and constructor parameter properties" - ); - } - if (kind === undefined) { - return unconvertible("it has no `kind` property initialized to a string"); - } - if (toJson !== undefined && !isIdentityToJson(ts, toJson, params)) { - return unconvertible("its `toJSON` does not return exactly its parameters"); - } - return { - className, - convertible: true, - declaration, - defaults, - defaultsComments, - file, - kind, - kindComments, - memberIndent: memberIndent ?? " ", - params, - }; -} - -function parseParameters( - ts: TypeScriptApi, - file: ParsedFile, - parameters: TS.NodeArray -): JobParam[] | string { - const params: JobParam[] = []; - for (const parameter of parameters) { - const modifiers = ts.getModifiers(parameter) ?? []; - const isProperty = modifiers.some((modifier) => - [ - ts.SyntaxKind.PublicKeyword, - ts.SyntaxKind.PrivateKeyword, - ts.SyntaxKind.ProtectedKeyword, - ts.SyntaxKind.ReadonlyKeyword, - ].includes(modifier.kind) - ); - if (!isProperty || !ts.isIdentifier(parameter.name)) { - return "a constructor parameter is not a parameter property"; - } - if ( - parameter.dotDotDotToken !== undefined || - parameter.initializer !== undefined || - (ts.getDecorators(parameter) ?? []).length > 0 - ) { - return "a constructor parameter is a rest, default, or decorated parameter"; - } - if (parameter.type === undefined && file.isTypeScript) { - return `constructor parameter \`${parameter.name.text}\` has no type annotation`; - } - params.push({ - comments: leadingComments(ts, file.text, parameter), - name: parameter.name.text, - optional: parameter.questionToken !== undefined, - type: parameter.type?.getText(), - }); - } - return params; -} - -/** Whether `toJSON` returns `{ a: this.a, ... }` for exactly `params`. */ -function isIdentityToJson( - ts: TypeScriptApi, - method: TS.MethodDeclaration, - params: readonly JobParam[] -): boolean { - const statements = method.body?.statements ?? []; - const statement = statements[0]; - if ( - method.parameters.length > 0 || - statements.length !== 1 || - statement === undefined || - !ts.isReturnStatement(statement) || - statement.expression === undefined - ) { - return false; - } - const expression = skipParentheses(ts, statement.expression); - if (!ts.isObjectLiteralExpression(expression)) return false; - const names = new Set(); - for (const property of expression.properties) { - if ( - !ts.isPropertyAssignment(property) || - !ts.isIdentifier(property.name) || - !ts.isPropertyAccessExpression(property.initializer) || - property.initializer.expression.kind !== ts.SyntaxKind.ThisKeyword || - property.initializer.name.text !== property.name.text - ) { - return false; - } - names.add(property.name.text); - } - return ( - names.size === params.length && params.every(({ name }) => names.has(name)) - ); -} - -/** The string literal `expression` is, ignoring `as const` and `satisfies`. */ -function stringLiteral( - ts: TypeScriptApi, - expression: TS.Expression | undefined -): TS.NoSubstitutionTemplateLiteral | TS.StringLiteral | undefined { - if (expression === undefined) return undefined; - const current = skipOuterExpressions(ts, expression); - return ts.isStringLiteral(current) || - ts.isNoSubstitutionTemplateLiteral(current) - ? current - : undefined; -} - -function skipParentheses( - ts: TypeScriptApi, - expression: TS.Expression -): TS.Expression { - let current = expression; - while (ts.isParenthesizedExpression(current)) current = current.expression; - return current; -} - -/** Skip parentheses, `as const`, and `satisfies` around `expression`. */ -function skipOuterExpressions( - ts: TypeScriptApi, - expression: TS.Expression -): TS.Expression { - let current = skipParentheses(ts, expression); - while ( - (ts.isAsExpression(current) && current.type.getText() === "const") || - ts.isSatisfiesExpression(current) - ) { - current = skipParentheses(ts, current.expression); - } - return current; -} - -/** Full text of the comments immediately before `node`. */ -function leadingComments( - ts: TypeScriptApi, - text: string, - node: TS.Node -): string[] { - return (ts.getLeadingCommentRanges(text, node.getFullStart()) ?? []).map( - (range) => text.slice(range.pos, range.end) - ); -} - -function lineStart(text: string, position: number): number { - return text.lastIndexOf("\n", position - 1) + 1; -} - -function lineIndent(text: string, position: number): string { - const start = lineStart(text, position); - return /^[ \t]*/.exec(text.slice(start))?.[0] ?? ""; -} - -/** Cross-file knowledge: job classes, their definition names, and exports. */ -class Project { - readonly #byPath: ReadonlyMap; - readonly #definitionNames = new Map(); - readonly #files: readonly ParsedFile[]; - readonly #ts: TypeScriptApi; - - constructor( - ts: TypeScriptApi, - files: readonly ParsedFile[], - byPath: ReadonlyMap - ) { - this.#ts = ts; - this.#files = files; - this.#byPath = byPath; - for (const file of files) { - for (const jobClass of file.classes.values()) { - if (!jobClass.convertible) continue; - const base = - definitionBaseName(jobClass.className) || - `${camelCase(jobClass.kind.slice(1, -1))}Job`; - const name = claimName(file.taken, base, [`${base}Job`]); - this.#definitionNames.set(jobClass, name); - } - } - } - - /** The module-level name of a converted class's definition. */ - definitionName(jobClass: ConvertibleJobClass): string { - const name = this.#definitionNames.get(jobClass); - if (name === undefined) { - throw new Error( - `internal codemod error: no name for ${jobClass.className}` - ); - } - return name; - } - - /** Resolve an import or re-export of `name` from `specifier` in `from`. */ - resolveImport( - from: ParsedFile, - specifier: string, - name: string - ): ResolvedExport | undefined { - const target = this.#resolveModule(from.path, specifier); - if (target !== undefined) return this.#resolveExport(target, name, 0); - if (specifier.startsWith(".") || specifier === RIVER_MODULE) { - return undefined; - } - // A path alias or package name this codemod cannot resolve: match an - // exported class of the same name when exactly one processed file has one. - const matches = this.#files.flatMap((file) => { - const jobClass = file.classes.get(name); - return jobClass !== undefined && - hasModifier( - this.#ts, - jobClass.declaration, - this.#ts.SyntaxKind.ExportKeyword - ) - ? [jobClass] - : []; - }); - const [only] = matches; - return matches.length === 1 && only !== undefined - ? { jobClass: only, renamed: true } - : undefined; - } - - #resolveExport( - file: ParsedFile, - name: string, - depth: number - ): ResolvedExport | undefined { - const ts = this.#ts; - if (depth > 16) return undefined; - const declared = file.classes.get(name); - if ( - declared !== undefined && - hasModifier(ts, declared.declaration, ts.SyntaxKind.ExportKeyword) - ) { - return { jobClass: declared, renamed: true }; - } - for (const statement of file.sourceFile.statements) { - if (!ts.isExportDeclaration(statement) || statement.isTypeOnly) continue; - const module = - statement.moduleSpecifier !== undefined && - ts.isStringLiteral(statement.moduleSpecifier) - ? statement.moduleSpecifier.text - : undefined; - const clause = statement.exportClause; - if (clause === undefined) { - if (module === undefined) continue; - const target = this.#resolveModule(file.path, module); - const resolved = - target === undefined - ? undefined - : this.#resolveExport(target, name, depth + 1); - if (resolved !== undefined) return resolved; - continue; - } - if (!ts.isNamedExports(clause)) continue; - for (const element of clause.elements) { - if (element.name.text !== name || element.isTypeOnly) continue; - const local = (element.propertyName ?? element.name).text; - const aliased = element.propertyName !== undefined; - let resolved: ResolvedExport | undefined; - if (module === undefined) { - const jobClass = file.classes.get(local); - resolved = - jobClass === undefined ? undefined : { jobClass, renamed: true }; - } else { - const target = this.#resolveModule(file.path, module); - resolved = - target === undefined - ? undefined - : this.#resolveExport(target, local, depth + 1); - } - if (resolved !== undefined) { - return { - jobClass: resolved.jobClass, - renamed: resolved.renamed && !aliased, - }; - } - } - } - return undefined; - } - - #resolveModule(from: string, specifier: string): ParsedFile | undefined { - if (!specifier.startsWith(".")) return undefined; - const base = resolve(dirname(from), specifier); - const extension = extname(base); - const stem = base.slice(0, base.length - extension.length); - const candidates = [ - base, - ...( - { - ".cjs": [".cts"], - ".js": [".ts", ".tsx"], - ".jsx": [".tsx"], - ".mjs": [".mts"], - }[extension] ?? [] - ).map((replacement) => stem + replacement), - ...SOURCE_EXTENSIONS.map((candidate) => base + candidate), - ...SOURCE_EXTENSIONS.map((candidate) => - resolve(base, `index${candidate}`) - ), - ]; - for (const candidate of candidates) { - const file = this.#byPath.get(candidate); - if (file !== undefined) return file; - } - return undefined; - } -} - -function hasModifier( - ts: TypeScriptApi, - node: TS.HasModifiers, - kind: TS.SyntaxKind -): boolean { - return (ts.getModifiers(node) ?? []).some( - (modifier) => modifier.kind === kind - ); -} - -/** `SortArgs` becomes `sort`, `URLFetchArgs` becomes `urlFetch`. */ -function definitionBaseName(className: string): string { - const base = className.replace(/Args$/, ""); - const leading = /^[A-Z]+(?=[A-Z][a-z]|\d|$)/.exec(base)?.[0]; - if (leading !== undefined) { - return leading.toLowerCase() + base.slice(leading.length); - } - return base.charAt(0).toLowerCase() + base.slice(1); -} - -/** `send_email` becomes `sendEmail`. */ -function camelCase(kind: string): string { - const words = kind.split(/[^A-Za-z0-9]+/).filter((word) => word !== ""); - const joined = words - .map((word, index) => - index === 0 - ? word.charAt(0).toLowerCase() + word.slice(1) - : word.charAt(0).toUpperCase() + word.slice(1) - ) - .join(""); - return joined === "" || /^\d/.test(joined) ? `job${joined}` : joined; -} - -/** Take the first free name among `base`, `fallbacks`, and numbered forms. */ -function claimName( - taken: Set, - base: string, - fallbacks: readonly string[] = [] -): string { - const free = (name: string): boolean => - !taken.has(name) && !RESERVED_WORDS.has(name); - let name = [base, ...fallbacks].find(free); - const last = fallbacks.at(-1) ?? base; - for (let suffix = 2; name === undefined; suffix++) { - if (free(`${last}${suffix}`)) name = `${last}${suffix}`; - } - taken.add(name); - return name; -} - -/** Where a `new` expression sits relative to insertion calls. */ -type InsertPosition = "element" | "insert" | "other" | "params"; - -/** The pieces of a legacy job argument expression. */ -interface JobArgsParts { - readonly args: string; - readonly definition: string; -} - -interface NamedImportDeclaration { - readonly bindings: TS.NamedImports; - readonly clause: TS.ImportClause; - readonly statement: TS.ImportDeclaration; -} - -interface PendingTodo { - readonly message: string; - readonly node: TS.Node; - /** - * Skip the comment when an existing marker comment above the line already - * mentions this text, for messages whose wording depends on which files a - * run processed. - */ - readonly unlessMentioned: string | undefined; -} - -/** Rewrites one file against the shared {@link Project}. */ -class FileMigration { - readonly #edits: SourceEdits; - readonly #file: ParsedFile; - /** Hoisted unchecked definitions for `JobArgsObject` kinds. */ - readonly #hoisted = new Map< - string, - { readonly literal: string; readonly name: string } - >(); - /** Local names bound to job classes, and their definition names here. */ - readonly #localClasses = new Map< - string, - { readonly definition: string | undefined; readonly jobClass: JobClass } - >(); - /** Object literals known to be insert options. */ - readonly #optionObjects = new Set(); - readonly #project: Project; - /** Import and export specifiers to rename, with their new text. */ - readonly #specifierRenames = new Map(); - readonly #todos: PendingTodo[] = []; - readonly #ts: TypeScriptApi; - /** Object literals known to be unique options. */ - readonly #uniqueObjects = new Set(); - #usesDefineJob = false; - #usesJobState = false; - - constructor(ts: TypeScriptApi, project: Project, file: ParsedFile) { - this.#ts = ts; - this.#project = project; - this.#file = file; - this.#edits = new SourceEdits(file.text); - } - - get #isRiverFile(): boolean { - return ( - this.#file.riverImports.size > 0 || - this.#file.riverNamespace !== undefined || - this.#localClasses.size > 0 - ); - } - - run(): CodemodFileResult { - this.#bindClasses(); - this.#collectOptionObjects(this.#file.sourceFile); - this.#visit(this.#file.sourceFile); - this.#flagRemainingReferences(); - const keptImports = this.#rewriteImports(); - this.#insertHoisted(keptImports); - this.#insertTodos(); - const output = this.#edits.apply(); - return { - changed: output !== this.#file.text, - output, - path: this.#file.path, - sites: collectSites(output), - }; - } - - /** Bind local and imported class names, and plan specifier renames. */ - #bindClasses(): void { - const ts = this.#ts; - const file = this.#file; - for (const [name, jobClass] of file.classes) { - this.#localClasses.set(name, { - definition: jobClass.convertible - ? this.#project.definitionName(jobClass) - : undefined, - jobClass, - }); - } - for (const statement of file.sourceFile.statements) { - if ( - ts.isImportDeclaration(statement) && - ts.isStringLiteral(statement.moduleSpecifier) && - statement.importClause?.namedBindings !== undefined && - ts.isNamedImports(statement.importClause.namedBindings) - ) { - for (const element of statement.importClause.namedBindings.elements) { - const imported = (element.propertyName ?? element.name).text; - const resolved = this.#project.resolveImport( - file, - statement.moduleSpecifier.text, - imported - ); - if (resolved === undefined) continue; - const { jobClass } = resolved; - if (!jobClass.convertible) { - this.#localClasses.set(element.name.text, { - definition: undefined, - jobClass, - }); - continue; - } - const definition = this.#project.definitionName(jobClass); - // eslint-disable-next-line @typescript-eslint/no-deprecated -- phaseModifier is unavailable in TypeScript 5, which the codemod supports - if (element.isTypeOnly || statement.importClause.isTypeOnly) { - // A type-only import cannot construct the class; its remaining - // type references are flagged. - this.#localClasses.set(element.name.text, { definition, jobClass }); - continue; - } - const exported = resolved.renamed ? definition : imported; - let local = element.name.text; - if (element.propertyName === undefined && resolved.renamed) { - file.taken.delete(local); - local = claimName(file.taken, definition, [`${definition}Job`]); - } - this.#localClasses.set(element.name.text, { - definition: local, - jobClass, - }); - const text = exported === local ? local : `${exported} as ${local}`; - if (text !== element.getText()) { - this.#specifierRenames.set(element, text); - } - } - } - if ( - ts.isExportDeclaration(statement) && - !statement.isTypeOnly && - statement.exportClause !== undefined && - ts.isNamedExports(statement.exportClause) - ) { - const module = - statement.moduleSpecifier !== undefined && - ts.isStringLiteral(statement.moduleSpecifier) - ? statement.moduleSpecifier.text - : undefined; - for (const element of statement.exportClause.elements) { - if (element.isTypeOnly) continue; - const local = (element.propertyName ?? element.name).text; - let jobClass: JobClass | undefined; - let renamed = true; - if (module === undefined) { - jobClass = file.classes.get(local); - } else { - const resolved = this.#project.resolveImport(file, module, local); - jobClass = resolved?.jobClass; - renamed = resolved?.renamed ?? false; - } - if (jobClass === undefined || !jobClass.convertible) continue; - const definition = this.#project.definitionName(jobClass); - const source = module === undefined || renamed ? definition : local; - const exported = - element.propertyName === undefined ? source : element.name.text; - const text = - source === exported ? source : `${source} as ${exported}`; - if (text !== element.getText()) { - this.#specifierRenames.set(element, text); - } - } - } - } - } - - #collectOptionObjects(node: TS.Node): void { - const ts = this.#ts; - const addOptions = (expression: TS.Expression | undefined): void => { - if (expression === undefined) return; - const object = skipParentheses(ts, expression); - if (ts.isObjectLiteralExpression(object)) this.#optionObjects.add(object); - }; - if (ts.isCallExpression(node) && calleeName(ts, node) === "insert") { - const [, second, third] = node.arguments; - addOptions(node.arguments.length === 2 ? second : third); - } - if ( - ts.isNewExpression(node) && - riverName(ts, this.#file, node.expression) === "InsertManyParams" - ) { - addOptions(node.arguments?.[1]); - } - if ( - ts.isPropertyDeclaration(node) && - ts.isIdentifier(node.name) && - node.name.text === "insertOpts" && - ts.isClassDeclaration(node.parent) && - node.parent.name !== undefined && - this.#file.classes.has(node.parent.name.text) - ) { - addOptions(node.initializer); - } - const typed = this.#declaredRiverType(node); - if (typed?.type === "InsertOpts") addOptions(typed.expression); - if (typed?.type === "UniqueOpts") { - const object = skipParentheses(ts, typed.expression); - if (ts.isObjectLiteralExpression(object)) this.#uniqueObjects.add(object); - } - ts.forEachChild(node, (child) => this.#collectOptionObjects(child)); - } - - /** `const x: InsertOpts = {...}` and `{...} satisfies InsertOpts`. */ - #declaredRiverType( - node: TS.Node - ): { readonly expression: TS.Expression; readonly type: string } | undefined { - const ts = this.#ts; - let typeNode: TS.TypeNode | undefined; - let expression: TS.Expression | undefined; - if (ts.isVariableDeclaration(node)) { - typeNode = node.type; - expression = node.initializer; - } else if (ts.isSatisfiesExpression(node) || ts.isAsExpression(node)) { - typeNode = node.type; - expression = node.expression; - } - if ( - typeNode === undefined || - expression === undefined || - !ts.isTypeReferenceNode(typeNode) - ) { - return undefined; - } - const typeName = typeNode.typeName; - const name = ts.isIdentifier(typeName) - ? this.#file.riverImports.get(typeName.text) - : ts.isIdentifier(typeName.left) && - typeName.left.text === this.#file.riverNamespace - ? typeName.right.text - : undefined; - return name === undefined ? undefined : { expression, type: name }; - } - - /** Post-order traversal, so inner rewrites exist before outer ones. */ - #visit(node: TS.Node): void { - const ts = this.#ts; - ts.forEachChild(node, (child) => this.#visit(child)); - if (ts.isClassDeclaration(node)) this.#visitClass(node); - else if (ts.isNewExpression(node)) this.#visitNew(node); - else if (ts.isPropertyAccessExpression(node)) - this.#visitPropertyAccess(node); - else if ( - ts.isPrefixUnaryExpression(node) && - node.operator === ts.SyntaxKind.ExclamationToken - ) { - this.#visitNegation(node); - } else if ( - ts.isPropertyAssignment(node) || - ts.isShorthandPropertyAssignment(node) - ) { - this.#visitProperty(node); - } else if (ts.isIdentifier(node)) this.#visitIdentifier(node); - else if ( - ts.isBindingElement(node) && - (node.propertyName ?? node.name).getText() === "uniqueSkippedAsDuplicated" - ) { - this.#todo( - node, - 'replace `uniqueSkippedAsDuplicated` with `status === "duplicate"`' - ); - } - } - - #visitClass(node: TS.ClassDeclaration): void { - if (node.name === undefined) return; - const jobClass = this.#file.classes.get(node.name.text); - if (jobClass?.declaration !== node) return; - if (!jobClass.convertible) { - this.#todo( - node, - `convert \`${jobClass.className}\` to \`defineJob\` by hand: ${jobClass.reason}` - ); - return; - } - const definition = this.#project.definitionName(jobClass); - this.#edits.replace( - node.getStart(), - node.getEnd(), - this.#definitionText(jobClass, definition) - ); - } - - #definitionText(jobClass: ConvertibleJobClass, name: string): string { - const ts = this.#ts; - const newline = this.#file.newline; - const indent = lineIndent(this.#file.text, jobClass.declaration.getStart()); - const member = jobClass.memberIndent; - const exported = hasModifier( - ts, - jobClass.declaration, - ts.SyntaxKind.ExportKeyword - ) - ? "export " - : ""; - const head = `${exported}const ${name} = ${this.#defineJobReference()}`; - let call: string; - if (jobClass.params.length === 0) { - call = `${head}({`; - } else { - const property = (param: JobParam): string => - `${param.name}${param.optional ? "?" : ""}: ${param.type ?? "unknown"}`; - const inline = `${head}<{ ${jobClass.params.map(property).join("; ")} }>()({`; - const multiline = - jobClass.params.some((param) => param.comments.length > 0) || - inline.includes("\n") || - indent.length + inline.length > 80; - call = multiline - ? [ - `${head}<{`, - ...jobClass.params.flatMap((param) => [ - ...param.comments.map((comment) => member + comment), - `${member}${property(param)};`, - ]), - `${indent}}>()({`, - ].join(newline) - : inline; - } - const lines = [ - call, - ...jobClass.kindComments.map((comment) => member + comment), - `${member}kind: ${jobClass.kind},`, - ]; - if (jobClass.defaults !== undefined) { - lines.push( - ...jobClass.defaultsComments.map((comment) => member + comment), - `${member}defaults: ${this.#render(jobClass.defaults)},` - ); - } - lines.push(`${indent}});`); - return lines.join(newline); - } - - #defineJobReference(): string { - for (const [local, imported] of this.#file.riverImports) { - if (imported === "defineJob") return local; - } - if ( - this.#file.riverNamespace !== undefined && - !this.#hasNamedRiverImport() - ) { - return `${this.#file.riverNamespace}.defineJob`; - } - this.#usesDefineJob = true; - return "defineJob"; - } - - #jobStateReference(): string { - for (const [local, imported] of this.#file.riverImports) { - if (imported === "JOB_STATE") return local; - } - if ( - this.#file.riverNamespace !== undefined && - !this.#hasNamedRiverImport() - ) { - return `${this.#file.riverNamespace}.JOB_STATE`; - } - this.#usesJobState = true; - return "JOB_STATE"; - } - - #hasNamedRiverImport(): boolean { - const ts = this.#ts; - return this.#file.sourceFile.statements.some( - (statement) => - isRiverImport(ts, statement) && - statement.importClause?.namedBindings !== undefined && - ts.isNamedImports(statement.importClause.namedBindings) - ); - } - - #visitNew(node: TS.NewExpression): void { - const ts = this.#ts; - const position = this.#insertPosition(node); - const river = riverName(ts, this.#file, node.expression); - if (river === "InsertManyParams") { - this.#rewriteInsertManyParams(node); - return; - } - if (river === "Client") { - this.#rewriteClientSchema(node); - return; - } - if (position === "params") return; // The InsertManyParams rewrite uses it. - const parts = this.#jobArgsParts(node, position !== "other"); - if (parts === undefined) { - if ( - position !== "other" && - this.#isRiverFile && - ts.isIdentifier(node.expression) - ) { - const name = `\`${node.expression.text}\``; - this.#todo( - node, - `${name} was not converted; insert a job definition with a plain args object`, - name - ); - } - return; - } - if (typeof parts === "string") { - this.#todo(node, parts); - return; - } - switch (position) { - case "element": - this.#replace( - node, - `{ job: ${parts.definition}, args: ${parts.args} }` - ); - return; - case "insert": - this.#replace(node, `${parts.definition}, ${parts.args}`); - return; - case "other": - this.#todo( - node, - `pass \`${parts.definition}\` and the args object \`${abbreviate(parts.args)}\` to insert or insertMany` - ); - return; - } - } - - /** - * The definition and args object a legacy job expression becomes, a TODO - * message when it is legacy but cannot be converted, or undefined when it - * is not a legacy job expression. Without `hoist`, a `JobArgsObject` - * kind is described rather than given a module-level definition. - */ - #jobArgsParts( - node: TS.Expression, - hoist = true - ): JobArgsParts | string | undefined { - const ts = this.#ts; - if (!ts.isNewExpression(node)) return undefined; - const args = node.arguments ?? []; - if (riverName(ts, this.#file, node.expression) === "JobArgsObject") { - const [kindArgument, objectArgument] = args; - const kind = stringLiteral(ts, kindArgument); - if (kind === undefined || args.some((arg) => ts.isSpreadElement(arg))) { - return "define this `JobArgsObject` kind with `defineJob({ kind })` and insert a plain args object"; - } - return { - args: - objectArgument === undefined ? "{}" : this.#render(objectArgument), - definition: hoist - ? this.#hoist(kind) - : `defineJob({ kind: ${kind.getText()} })`, - }; - } - if (!ts.isIdentifier(node.expression)) return undefined; - const bound = this.#localClasses.get(node.expression.text); - if (bound === undefined) return undefined; - const { jobClass } = bound; - if (!jobClass.convertible || bound.definition === undefined) { - return `\`${jobClass.className}\` could not be converted automatically; insert a job definition with a plain args object`; - } - if (args.some((arg) => ts.isSpreadElement(arg))) { - return `spell out the \`${jobClass.className}\` arguments as a plain args object for \`${bound.definition}\``; - } - const properties = jobClass.params.flatMap((param, index) => { - const arg = args[index]; - if (arg === undefined) return []; - const text = this.#render(arg); - return [text === param.name ? text : `${param.name}: ${text}`]; - }); - return { - args: properties.length === 0 ? "{}" : `{ ${properties.join(", ")} }`, - definition: bound.definition, - }; - } - - #insertPosition(node: TS.NewExpression): InsertPosition { - const ts = this.#ts; - const parent = node.parent; - if ( - ts.isCallExpression(parent) && - parent.arguments[0] === node && - calleeName(ts, parent) === "insert" - ) { - return "insert"; - } - if ( - ts.isArrayLiteralExpression(parent) && - ts.isCallExpression(parent.parent) && - parent.parent.arguments[0] === parent && - calleeName(ts, parent.parent) === "insertMany" - ) { - return "element"; - } - if ( - ts.isNewExpression(parent) && - parent.arguments?.[0] === node && - riverName(ts, this.#file, parent.expression) === "InsertManyParams" - ) { - return "params"; - } - return "other"; - } - - #rewriteInsertManyParams(node: TS.NewExpression): void { - const [argsNode, optionsNode] = node.arguments ?? []; - const parts = - argsNode === undefined ? undefined : this.#jobArgsParts(argsNode); - if (parts === undefined || typeof parts === "string") { - this.#todo( - node, - "replace `InsertManyParams` with a `{ job, args, options }` item built from a job definition" - ); - return; - } - const options = - optionsNode === undefined - ? "" - : `, options: ${this.#render(optionsNode)}`; - this.#replace( - node, - `{ job: ${parts.definition}, args: ${parts.args}${options} }` - ); - } - - /** `new Client(new PgDriver(pool), { schema })` moves the schema. */ - #rewriteClientSchema(node: TS.NewExpression): void { - const ts = this.#ts; - const [driver, options] = node.arguments ?? []; - if (options === undefined) return; - const object = skipParentheses(ts, options); - if (!ts.isObjectLiteralExpression(object)) { - this.#todo(node, CLIENT_SCHEMA_MESSAGE); - return; - } - const names = object.properties.map((property) => - property.name !== undefined && ts.isIdentifier(property.name) - ? property.name.text - : undefined - ); - if (!names.includes("schema")) return; - const pool = - driver !== undefined && - ts.isNewExpression(driver) && - ts.isIdentifier(driver.expression) && - driver.expression.text === "PgDriver" && - driver.arguments?.length === 1 - ? driver.arguments[0] - : undefined; - const onlySchema = - names.length === 1 && - object.properties.every( - (property) => - ts.isPropertyAssignment(property) || - ts.isShorthandPropertyAssignment(property) - ); - if (driver === undefined || pool === undefined || !onlySchema) { - this.#todo(node, CLIENT_SCHEMA_MESSAGE); - return; - } - this.#edits.replace( - driver.getStart(), - options.getEnd(), - `new PgDriver(${this.#render(pool)}, ${this.#render(options)})` - ); - } - - #visitPropertyAccess(node: TS.PropertyAccessExpression): void { - const ts = this.#ts; - const name = node.name.text; - if (name === "uniqueSkippedAsDuplicated") { - if ( - ts.isPrefixUnaryExpression(node.parent) && - node.parent.operator === ts.SyntaxKind.ExclamationToken - ) { - return; - } - this.#replaceStatus(node, node, "===", "duplicate"); - return; - } - if (name.startsWith(JOB_STATE_PREFIX)) { - const state = name.slice(JOB_STATE_PREFIX.length).toLowerCase(); - if (riverName(ts, this.#file, node) === name) { - this.#replace(node, `${this.#file.riverNamespace}.JOB_STATE.${state}`); - } - return; - } - if ( - !this.#isRiverFile || - !ts.isPropertyAccessExpression(node.expression) || - node.expression.name.text !== "job" - ) { - return; - } - if (name === "id" && !isStringConversion(ts, node)) { - this.#todo( - node, - "`JobRow.id` is now a `bigint`; review number annotations, arithmetic, and JSON serialization" - ); - } else if (JOB_ROW_TIMESTAMPS.has(name)) { - this.#todo( - node, - `\`JobRow.${name}\` is now a \`Temporal.Instant\`, not a \`Date\`` - ); - } - } - - #visitNegation(node: TS.PrefixUnaryExpression): void { - const ts = this.#ts; - const operand = node.operand; - if ( - ts.isPropertyAccessExpression(operand) && - operand.name.text === "uniqueSkippedAsDuplicated" - ) { - // `!undefined` is true, so an optional chain must not compare equal. - if (operand.questionDotToken === undefined) { - this.#replaceStatus(node, operand, "===", "inserted"); - } else { - this.#replaceStatus(node, operand, "!==", "duplicate"); - } - } - } - - #replaceStatus( - node: TS.Expression, - access: TS.PropertyAccessExpression, - operator: "!==" | "===", - status: "duplicate" | "inserted" - ): void { - const receiver = this.#render(access.expression); - const dot = access.questionDotToken === undefined ? "." : "?."; - const comparison = `${receiver}${dot}status ${operator} "${status}"`; - this.#replace( - node, - needsParentheses(this.#ts, node) ? `(${comparison})` : comparison - ); - } - - #visitProperty( - node: TS.PropertyAssignment | TS.ShorthandPropertyAssignment - ): void { - const ts = this.#ts; - if (!ts.isIdentifier(node.name)) return; - const name = node.name.text; - const object = node.parent; - if (name === "byPeriod" && this.#uniqueObjects.has(object)) { - this.#rewriteByPeriod(node); - return; - } - if (name === "byArgs" && this.#uniqueObjects.has(object)) { - const value = ts.isPropertyAssignment(node) - ? node.initializer - : undefined; - if ( - value === undefined || - !( - value.kind === ts.SyntaxKind.TrueKeyword || - ts.isArrayLiteralExpression(value) - ) - ) { - this.#todo( - node, - "`byArgs` takes `true` or a list of fields; omit it instead of passing false" - ); - } - return; - } - if (name !== "uniqueOpts") return; - if (!this.#optionObjects.has(object)) { - if (this.#isRiverFile) { - this.#todo( - node, - "if these are River insert options, rename `uniqueOpts` to `unique` (`byPeriod` is now a duration)" - ); - } - return; - } - if (ts.isShorthandPropertyAssignment(node)) { - this.#replace(node, "unique: uniqueOpts"); - this.#todo(node, UNIQUE_OPTIONS_MESSAGE); - return; - } - const value = skipOuterExpressions(ts, node.initializer); - if (ts.isObjectLiteralExpression(value)) { - // The post-order walk visited this object's properties before it was - // known to hold unique options, so rewrite them now. - if (!this.#uniqueObjects.has(value)) { - this.#uniqueObjects.add(value); - for (const property of value.properties) { - if ( - ts.isPropertyAssignment(property) || - ts.isShorthandPropertyAssignment(property) - ) { - this.#visitProperty(property); - } - } - } - } else { - this.#todo(node, UNIQUE_OPTIONS_MESSAGE); - } - this.#replace(node.name, "unique"); - } - - #rewriteByPeriod( - node: TS.PropertyAssignment | TS.ShorthandPropertyAssignment - ): void { - const ts = this.#ts; - if (ts.isShorthandPropertyAssignment(node)) { - this.#replace(node, "byPeriod: { seconds: byPeriod }"); - this.#todo(node, BY_PERIOD_MESSAGE); - return; - } - const value = skipParentheses(ts, node.initializer); - if (isNumericConstant(ts, value)) { - this.#replace( - node.initializer, - `{ seconds: ${this.#render(node.initializer)} }` - ); - return; - } - if ( - ts.isObjectLiteralExpression(value) || - isTemporalExpression(ts, value) - ) { - return; - } - this.#replace( - node.initializer, - `{ seconds: ${this.#render(node.initializer)} }` - ); - this.#todo(node, BY_PERIOD_MESSAGE); - } - - #visitIdentifier(node: TS.Identifier): void { - const ts = this.#ts; - const imported = this.#file.riverImports.get(node.text); - if ( - imported === undefined || - !imported.startsWith(JOB_STATE_PREFIX) || - isPropertyName(ts, node) || - ts.isImportSpecifier(node.parent) || - ts.isExportSpecifier(node.parent) - ) { - return; - } - const state = imported.slice(JOB_STATE_PREFIX.length).toLowerCase(); - if (ts.isShorthandPropertyAssignment(node.parent)) { - this.#replace( - node.parent, - `${node.text}: ${this.#jobStateReference()}.${state}` - ); - } else { - this.#replace(node, `${this.#jobStateReference()}.${state}`); - } - } - - /** Flag references to removed APIs that no rewrite consumed. */ - #flagRemainingReferences(): void { - const ts = this.#ts; - for (const reference of this.#remainingReferences()) { - const { node, local } = reference; - if (ts.isNewExpression(node.parent) && node.parent.expression === node) { - continue; // #visitNew already rewrote or flagged it. - } - if (ts.isExpressionWithTypeArguments(node.parent)) { - continue; // The unconvertible class itself is flagged. - } - const imported = this.#file.riverImports.get(local); - if (imported !== undefined) { - this.#todo(node, removedReferenceMessage(imported)); - continue; - } - const bound = this.#localClasses.get(local); - if (bound?.definition !== undefined) { - this.#todo( - node, - `\`${bound.jobClass.className}\` is now the \`${bound.definition}\` job definition; pass it with a plain args object` - ); - } - } - } - - /** Identifier references to replaced APIs outside every rewritten range. */ - #remainingReferences(): { - readonly local: string; - readonly node: TS.Identifier; - }[] { - const watched = new Set(); - for (const [local, imported] of this.#file.riverImports) { - if ( - REPLACED_EXPORTS.has(imported) || - imported.startsWith(JOB_STATE_PREFIX) - ) { - watched.add(local); - } - } - for (const [local, bound] of this.#localClasses) { - if (bound.jobClass.convertible) watched.add(local); - } - return this.#references(watched, true).filter( - ({ node }) => !this.#ts.isExportSpecifier(node.parent) - ); - } - - /** - * References to `names` as bindings or values, other than their import - * specifiers, optionally skipping those inside rewritten ranges. - */ - #references( - names: ReadonlySet, - afterEdits: boolean - ): { local: string; node: TS.Identifier }[] { - const ts = this.#ts; - const references: { local: string; node: TS.Identifier }[] = []; - const visit = (node: TS.Node): void => { - if ( - ts.isIdentifier(node) && - names.has(node.text) && - !isPropertyName(ts, node) && - !ts.isImportSpecifier(node.parent) && - !( - ts.isExportSpecifier(node.parent) && - node.parent.parent.parent.moduleSpecifier !== undefined - ) && - !(ts.isClassDeclaration(node.parent) && node.parent.name === node) && - !(afterEdits && this.#edits.intersects(node.getStart(), node.getEnd())) - ) { - references.push({ local: node.text, node }); - } - ts.forEachChild(node, visit); - }; - visit(this.#file.sourceFile); - return references; - } - - /** - * Rewrite `riverqueue` import lists and job-class specifiers. Returns the - * import declarations that remain, for placing hoisted definitions. - */ - #rewriteImports(): TS.ImportDeclaration[] { - const ts = this.#ts; - const kept: TS.ImportDeclaration[] = []; - const riverImports: NamedImportDeclaration[] = []; - for (const statement of this.#file.sourceFile.statements) { - if (ts.isImportDeclaration(statement)) { - const clause = statement.importClause; - const bindings = clause?.namedBindings; - if ( - isRiverImport(ts, statement) && - clause !== undefined && - bindings !== undefined && - ts.isNamedImports(bindings) - ) { - riverImports.push({ bindings, clause, statement }); - continue; - } - kept.push(statement); - if (bindings !== undefined && ts.isNamedImports(bindings)) { - this.#renameSpecifiers(bindings.elements); - } - } else if ( - ts.isExportDeclaration(statement) && - statement.exportClause !== undefined && - ts.isNamedExports(statement.exportClause) - ) { - this.#renameSpecifiers(statement.exportClause.elements); - } - } - - const hasValueImport = (name: string): boolean => - riverImports.some( - ({ bindings, clause }) => - // eslint-disable-next-line @typescript-eslint/no-deprecated -- phaseModifier is unavailable in TypeScript 5, which the codemod supports - !clause.isTypeOnly && - bindings.elements.some( - (element) => - !element.isTypeOnly && - (element.propertyName ?? element.name).text === name - ) - ); - const additions: string[] = []; - if (this.#usesDefineJob && !hasValueImport("defineJob")) { - additions.push("defineJob"); - } - if (this.#usesJobState && !hasValueImport("JOB_STATE")) { - additions.push("JOB_STATE"); - } - const target = - // eslint-disable-next-line @typescript-eslint/no-deprecated -- phaseModifier is unavailable in TypeScript 5, which the codemod supports - riverImports.find(({ clause }) => !clause.isTypeOnly) ?? riverImports[0]; - const locals = new Set(this.#file.riverImports.keys()); - const countReferences = (afterEdits: boolean): Map => { - const counts = new Map(); - for (const { local } of this.#references(locals, afterEdits)) { - counts.set(local, (counts.get(local) ?? 0) + 1); - } - return counts; - }; - const referencedBefore = countReferences(false); - const referencedAfter = countReferences(true); - - for (const declaration of riverImports) { - const { bindings, clause, statement } = declaration; - let specifiers: string[] = []; - let changed = false; - for (const element of bindings.elements) { - const imported = (element.propertyName ?? element.name).text; - const removed = REMOVED_EXPORTS.get(imported); - if (removed !== undefined) { - this.#todo( - statement, - `\`${imported}\` is no longer exported: ${removed}` - ); - } - // Drop replaced APIs, and anything whose every use was rewritten. - const local = element.name.text; - if ( - (referencedAfter.get(local) ?? 0) === 0 && - (REPLACED_EXPORTS.has(imported) || - imported.startsWith(JOB_STATE_PREFIX) || - (referencedBefore.get(local) ?? 0) > 0) - ) { - changed = true; - continue; - } - const renamed = RENAMED_EXPORTS.get(imported); - if (renamed !== undefined) { - const type = element.isTypeOnly ? "type " : ""; - specifiers.push(`${type}${renamed} as ${local}`); - changed = true; - continue; - } - specifiers.push(element.getText()); - } - const toValue = - // eslint-disable-next-line @typescript-eslint/no-deprecated -- phaseModifier is unavailable in TypeScript 5, which the codemod supports - declaration === target && clause.isTypeOnly && additions.length > 0; - if (declaration === target && additions.length > 0) { - if (toValue) { - specifiers = specifiers.map((specifier) => - /^type\s/.test(specifier) ? specifier : `type ${specifier}` - ); - } - specifiers = insertSorted(specifiers, additions); - changed = true; - } - if (!changed) { - kept.push(statement); - } else if (specifiers.length === 0 && clause.name === undefined) { - this.#removeLine(statement); - } else { - kept.push(statement); - this.#replaceImportList(declaration, specifiers, toValue); - } - } - - if ( - target === undefined && - additions.length > 0 && - this.#file.riverNamespace === undefined - ) { - const newline = this.#file.newline; - const statement = `import { ${additions.join(", ")} } from "${RIVER_MODULE}";`; - const last = kept.at(-1); - if (last === undefined) { - this.#edits.insert(0, statement + newline); - } else { - this.#edits.insert(last.getEnd(), newline + statement); - } - } - return kept; - } - - #renameSpecifiers( - elements: readonly (TS.ExportSpecifier | TS.ImportSpecifier)[] - ): void { - for (const element of elements) { - const text = this.#specifierRenames.get(element); - if (text !== undefined) this.#replace(element, text); - } - } - - /** Replace an import's `{ ... }` list, as a value import when `toValue`. */ - #replaceImportList( - { bindings, clause, statement }: NamedImportDeclaration, - specifiers: readonly string[], - toValue: boolean - ): void { - const text = this.#file.text; - const newline = this.#file.newline; - const start = toValue ? clause.getStart() : bindings.getStart(); - const prefix = - toValue && clause.name !== undefined ? `${clause.name.text}, ` : ""; - const inline = - specifiers.length === 0 ? "{}" : `{ ${specifiers.join(", ")} }`; - const line = - text.slice(lineStart(text, statement.getStart()), start) + - prefix + - inline + - text.slice(bindings.getEnd(), statement.getEnd()); - const indent = lineIndent(text, statement.getStart()); - const list = - line.length <= 80 - ? inline - : [ - "{", - ...specifiers.map((specifier) => `${indent} ${specifier},`), - `${indent}}`, - ].join(newline); - this.#edits.replace(start, bindings.getEnd(), prefix + list); - } - - #removeLine(node: TS.Node): void { - const text = this.#file.text; - const end = text.indexOf("\n", node.getEnd()); - this.#edits.replace( - lineStart(text, node.getStart()), - end === -1 ? node.getEnd() : end + 1, - "" - ); - } - - #hoist(kind: TS.NoSubstitutionTemplateLiteral | TS.StringLiteral): string { - const existing = this.#hoisted.get(kind.text); - if (existing !== undefined) return existing.name; - const name = claimName(this.#file.taken, `${camelCase(kind.text)}Job`); - this.#hoisted.set(kind.text, { literal: kind.getText(), name }); - this.#defineJobReference(); - return name; - } - - #insertHoisted(keptImports: readonly TS.ImportDeclaration[]): void { - if (this.#hoisted.size === 0) return; - const newline = this.#file.newline; - const reference = this.#defineJobReference(); - const definitions = [...this.#hoisted.values()].map( - ({ literal, name }) => - `const ${name} = ${reference}({ kind: ${literal} });` - ); - const last = [...keptImports] - .sort((left, right) => left.getEnd() - right.getEnd()) - .at(-1); - if (last === undefined) { - this.#edits.insert(0, definitions.join(newline) + newline + newline); - } else { - this.#edits.insert( - last.getEnd(), - newline + newline + definitions.join(newline) - ); - } - } - - #todo(node: TS.Node, message: string, unlessMentioned?: string): void { - this.#todos.push({ message, node, unlessMentioned }); - } - - /** Insert each pending TODO above its line, skipping existing copies. */ - #insertTodos(): void { - const text = this.#file.text; - const newline = this.#file.newline; - const byPosition = new Map(); - for (const todo of this.#todos) { - const position = this.#commentPosition(todo.node); - const todos = byPosition.get(position) ?? []; - if (!todos.some(({ message }) => message === todo.message)) { - todos.push(todo); - } - byPosition.set(position, todos); - } - for (const [position, todos] of byPosition) { - const existing = commentBlockAbove(text, position).filter((line) => - line.startsWith(`// ${TODO_MARKER} `) - ); - const indent = lineIndent(text, position); - const fresh = todos - .filter( - ({ message, unlessMentioned }) => - !existing.includes(`// ${TODO_MARKER} ${message}`) && - (unlessMentioned === undefined || - !existing.some((line) => line.includes(unlessMentioned))) - ) - .map(({ message }) => message); - if (fresh.length === 0) continue; - this.#edits.insert( - position, - fresh - .map((message) => `${indent}// ${TODO_MARKER} ${message}${newline}`) - .join("") - ); - } - } - - /** - * The start of the line to put a comment above `node`: its own line, - * unless that would land inside a rewritten range, a template literal, or - * JSX text, in which case an enclosing line. - */ - #commentPosition(node: TS.Node): number { - const ts = this.#ts; - const text = this.#file.text; - let position = lineStart(text, node.getStart()); - for (;;) { - const edit = this.#edits.containing(position); - if (edit !== undefined) { - position = lineStart(text, edit.start); - continue; - } - let enclosing: TS.Node | undefined; - for ( - let current: TS.Node | undefined = node; - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- a source file's parent is undefined at runtime - current !== undefined; - current = current.parent - ) { - if ( - (ts.isTemplateExpression(current) || - ts.isNoSubstitutionTemplateLiteral(current) || - ts.isJsxElement(current) || - ts.isJsxFragment(current) || - ts.isJsxSelfClosingElement(current)) && - current.getStart() < position && - position < current.getEnd() - ) { - enclosing = current; - } - } - if (enclosing === undefined) return position; - position = lineStart(text, enclosing.getStart()); - } - } - - #render(node: TS.Node): string { - return this.#edits.render(node.getStart(), node.getEnd()); - } - - #replace(node: TS.Node, text: string): void { - this.#edits.replace(node.getStart(), node.getEnd(), text); - } -} - -function calleeName( - ts: TypeScriptApi, - call: TS.CallExpression -): string | undefined { - return ts.isPropertyAccessExpression(call.expression) - ? call.expression.name.text - : undefined; -} - -/** `id.toString()`, `String(id)`, and `${id}` read a bigint like a number. */ -function isStringConversion(ts: TypeScriptApi, node: TS.Expression): boolean { - const parent = node.parent; - return ( - ts.isTemplateSpan(parent) || - (ts.isPropertyAccessExpression(parent) && - parent.name.text === "toString" && - ts.isCallExpression(parent.parent)) || - (ts.isCallExpression(parent) && - ts.isIdentifier(parent.expression) && - parent.expression.text === "String") - ); -} - -/** A numeric literal, or integer arithmetic (`+`, `-`, `*`) on them. */ -function isNumericConstant(ts: TypeScriptApi, node: TS.Expression): boolean { - const expression = skipParentheses(ts, node); - if (ts.isNumericLiteral(expression)) return true; - return ( - ts.isBinaryExpression(expression) && - [ - ts.SyntaxKind.AsteriskToken, - ts.SyntaxKind.MinusToken, - ts.SyntaxKind.PlusToken, - ].includes(expression.operatorToken.kind) && - isNumericConstant(ts, expression.left) && - isNumericConstant(ts, expression.right) - ); -} - -function isTemporalExpression(ts: TypeScriptApi, node: TS.Expression): boolean { - let current: TS.Expression = node; - while ( - ts.isCallExpression(current) || - ts.isPropertyAccessExpression(current) - ) { - current = current.expression; - } - return ts.isIdentifier(current) && current.text === "Temporal"; -} - -/** Whether a comparison replacing `node` needs parentheses in its context. */ -function needsParentheses(ts: TypeScriptApi, node: TS.Node): boolean { - const parent = node.parent; - if (ts.isCallExpression(parent) || ts.isNewExpression(parent)) { - return parent.expression === node; - } - if ( - ts.isParenthesizedExpression(parent) || - ts.isIfStatement(parent) || - ts.isWhileStatement(parent) || - ts.isDoStatement(parent) || - ts.isForStatement(parent) || - ts.isReturnStatement(parent) || - ts.isExpressionStatement(parent) || - ts.isVariableDeclaration(parent) || - ts.isPropertyAssignment(parent) || - ts.isArrowFunction(parent) || - ts.isArrayLiteralExpression(parent) || - ts.isTemplateSpan(parent) || - ts.isJsxExpression(parent) || - // `===` binds tighter than `?:`, so every operand position is safe. - ts.isConditionalExpression(parent) - ) { - return false; - } - if (ts.isBinaryExpression(parent)) { - return ![ - ts.SyntaxKind.AmpersandAmpersandToken, - ts.SyntaxKind.BarBarToken, - ts.SyntaxKind.QuestionQuestionToken, - ts.SyntaxKind.CommaToken, - ts.SyntaxKind.EqualsToken, - ].includes(parent.operatorToken.kind); - } - return true; -} - -/** Add `additions` to a specifier list, alphabetically when it is sorted. */ -function insertSorted( - specifiers: readonly string[], - additions: readonly string[] -): string[] { - const key = (specifier: string): string => - specifier.replace(/^type\s+/, "").toLowerCase(); - const sorted = specifiers.every( - (specifier, index) => - index === 0 || key(specifiers[index - 1] ?? "") <= key(specifier) - ); - const result = [...specifiers]; - for (const addition of additions) { - if (!sorted) { - result.push(addition); - continue; - } - const index = result.findIndex( - (specifier) => key(specifier) > key(addition) - ); - result.splice(index === -1 ? result.length : index, 0, addition); - } - return result; -} - -/** The comment lines directly above the line starting at `position`. */ -function commentBlockAbove(text: string, position: number): string[] { - const lines: string[] = []; - let end = position - 1; - while (end > 0) { - const start = lineStart(text, end); - const line = text.slice(start, end).replace(/\r$/, "").trim(); - if (!line.startsWith("//")) break; - lines.push(line); - end = start - 1; - } - return lines; -} - -/** Every marker comment in `text`, with the line of the code it marks. */ -function collectSites(text: string): CodemodSite[] { - const lines = text.split("\n"); - const sites: CodemodSite[] = []; - const pattern = `// ${TODO_MARKER} `; - lines.forEach((line, index) => { - const at = line.indexOf(pattern); - if (at === -1 || line.slice(0, at).trim() !== "") return; - let target = index + 1; - while ( - target < lines.length && - (lines[target] ?? "").trim().startsWith("//") - ) { - target++; - } - sites.push({ - line: target + 1, - message: line - .slice(at + pattern.length) - .replace(/\r$/, "") - .trim(), - }); - }); - return sites; -} - -function removedReferenceMessage(imported: string): string { - if (imported === "JobArgs") { - return "`JobArgs` was removed; accept a `JobDefinition` and its args, or an `InsertManyItem`"; - } - if (imported.startsWith(JOB_STATE_PREFIX)) { - const state = imported.slice(JOB_STATE_PREFIX.length).toLowerCase(); - return `\`${imported}\` was removed; use \`JOB_STATE.${state}\``; - } - return `\`${imported}\` was removed; use a job definition with a plain args object`; -} - -/** A single-line, bounded rendering of code for a message. */ -function abbreviate(code: string): string { - const flat = code.replaceAll(/\s+/g, " "); - return flat.length <= 60 ? flat : `${flat.slice(0, 57)}...`; -} diff --git a/js/cli/src/command.ts b/js/cli/src/command.ts index 97e8e647f..af79ebf0e 100644 --- a/js/cli/src/command.ts +++ b/js/cli/src/command.ts @@ -36,19 +36,10 @@ export interface Command { 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; + run(values: OptionValues, context: CommandContext): Promise; } export function writeLine(stream: OutputStream, line = ""): void { diff --git a/js/cli/src/index.ts b/js/cli/src/index.ts index 255d878cf..e1adfce68 100644 --- a/js/cli/src/index.ts +++ b/js/cli/src/index.ts @@ -1,6 +1,5 @@ /** - * The `riverqueue` command line for River migrations, benchmarks, and the - * `riverqueue@0.1` upgrade codemod. + * 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. diff --git a/js/cli/src/options.ts b/js/cli/src/options.ts index 5418bcb8e..dbfa4662a 100644 --- a/js/cli/src/options.ts +++ b/js/cli/src/options.ts @@ -43,16 +43,14 @@ const HELP_OPTION: OptionSpec = { /** * 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. + * arguments are rejected. */ export function parseOptions( command: string, specs: OptionSpecs, - argv: readonly string[], - allowPositionals = false + argv: readonly string[] ): { help: boolean; - positionals: readonly string[]; values: OptionValues; } { const options: Record< @@ -66,7 +64,7 @@ export function parseOptions( let parsed: ReturnType; try { parsed = parseArgs({ - allowPositionals, + allowPositionals: false, args: [...argv], options, strict: true, @@ -75,7 +73,7 @@ export function parseOptions( throw new UsageError(parseErrorMessage(error), command); } const { help, ...values } = parsed.values; - return { help: help === true, positionals: parsed.positionals, values }; + return { help: help === true, values }; } const HELP_WIDTH = 80; diff --git a/js/cli/src/run.ts b/js/cli/src/run.ts index 09aa7325e..a67b6b29c 100644 --- a/js/cli/src/run.ts +++ b/js/cli/src/run.ts @@ -7,7 +7,6 @@ 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, @@ -75,7 +74,6 @@ const versionCommand: Command = { const COMMANDS: ReadonlyMap = new Map( [ benchCommand, - codemodCommand, migrateDownCommand, migrateGetCommand, migrateListCommand, @@ -137,17 +135,12 @@ export async function run( ); } - const { help, positionals, values } = parseOptions( - command.name, - command.options, - rest, - command.positionals !== undefined - ); + const { help, values } = parseOptions(command.name, command.options, rest); if (help) { context.stdout.write(commandHelp(context.program, command)); return 0; } - return await command.run(values, context, positionals); + return await command.run(values, context); } catch (error: unknown) { reportError(context, error, command?.name); return 1; @@ -158,9 +151,7 @@ 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}` - }`, + usage: `${program} ${command.name} [flags]`, }); } diff --git a/js/docs/development.md b/js/docs/development.md index 5cc952ccc..14749a1ef 100644 --- a/js/docs/development.md +++ b/js/docs/development.md @@ -66,8 +66,8 @@ changes. 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)`, +without type packages or a compiler, 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 @@ -76,6 +76,9 @@ 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. CLI subprocesses clear `NODE_OPTIONS` to verify help, version, and SQLite migrations without an application preload. +Separate clean projects install TypeScript 6 and the latest stable TypeScript +before adding the packed CLI, then apply and validate SQLite migrations. This +checks compiler peer conflicts using the quickstart's installation order. The same suite runs on Node 24 and Node 26 in CI. Its Postgres 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 Postgres when diff --git a/js/docs/migrating-from-0.1.md b/js/docs/migrating-from-0.1.md index 5d6c8e357..603258c1e 100644 --- a/js/docs/migrating-from-0.1.md +++ b/js/docs/migrating-from-0.1.md @@ -2,8 +2,8 @@ `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. +locks them in. Most changes are mechanical. Use the compiler to find type +changes, then review the behavioral changes described below. ## Runtime requirements @@ -12,65 +12,23 @@ The package needs Node.js 24 or newer and ships as ESM; see [preload an application-provided polyfill](../README.md#temporal-on-nodejs-24) before importing River. -## Codemod +## Upgrade checklist -`@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: +Use this guide alongside the complete [before](../fixtures/migration-0.1/before.ts.txt) +and [after](../fixtures/migration-0.1/after.ts) examples. CI compiles the original +example against 0.1 and the upgraded example against the current package. -```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 -``` +1. Replace argument classes and `JobArgsObject` with job definitions and plain + JSON arguments, preserving the serialized shape and insertion defaults. +2. Update batch items, insert options, result handling, and renamed exports. +3. Review job IDs, timestamps, JSON serialization, and transaction ownership. +4. Run your formatter, type checker, and tests. Type checking alone cannot + verify that persisted arguments and transaction behavior are unchanged. -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. +The CLI no longer includes an upgrade codemod. If you need the historical +`codemod-0.1` command, `@riverqueue/cli@0.3.2` is the last release containing +it and needs TypeScript 5 or 6 for its compiler API. Its output still requires +manual review using this guide. ## Job definitions replace argument classes @@ -105,8 +63,14 @@ 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 +accepts any JSON object. `JobArgsObject` did not inherit an argument class's +`insertOpts`, even when both used the same kind. Preserve that distinction with +a separate definition or explicit insert options if you combine them. + +If an argument class had a constructor body or custom `toJSON`, preserve its +serialized arguments when replacing it with a plain 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 @@ -153,6 +117,12 @@ the first job and reported the others as duplicates. 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. +## Renamed exports + +`ClientOpts` is now `ClientOptions`. The `JOB_STATE_AVAILABLE`, +`JOB_STATE_RUNNING`, and other state constants are properties of `JOB_STATE`, +such as `JOB_STATE.available` and `JOB_STATE.running`. + ## Drivers A driver is only passed to River: `PgDriver` no longer has `jobInsert` or @@ -195,9 +165,9 @@ 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 }`. + with a `ConfigurationError`. In 0.1 they ran directly on that client. + 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. diff --git a/js/fixtures/migration-0.1/README.md b/js/fixtures/migration-0.1/README.md index 7b3950175..5d7338b8c 100644 --- a/js/fixtures/migration-0.1/README.md +++ b/js/fixtures/migration-0.1/README.md @@ -2,18 +2,10 @@ 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 +intentionally uses the 0.1 names and types; `after.ts` is the completed 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 diff --git a/js/fixtures/migration-0.1/codemod.ts.txt b/js/fixtures/migration-0.1/codemod.ts.txt deleted file mode 100644 index 909707cc1..000000000 --- a/js/fixtures/migration-0.1/codemod.ts.txt +++ /dev/null @@ -1,30 +0,0 @@ -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/scripts/check-legacy-fixture.mjs b/js/scripts/check-legacy-fixture.mjs index c1ab9c148..96a65ac51 100644 --- a/js/scripts/check-legacy-fixture.mjs +++ b/js/scripts/check-legacy-fixture.mjs @@ -7,7 +7,6 @@ import { mkdtemp, readFile, rm, - symlink, writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -114,125 +113,14 @@ try { 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` + `validated riverqueue@${manifest.version} archive, docs, and TS6/TS-next consumer\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/check-packages.mjs b/js/scripts/check-packages.mjs index 6bf8e2ec0..c726762fe 100644 --- a/js/scripts/check-packages.mjs +++ b/js/scripts/check-packages.mjs @@ -126,6 +126,7 @@ try { } if (!examplesOnly) { + await verifyCliTypeScriptConsumers(archiveByPackageName); await verifyConsumers(archiveByPackageName); await verifyVersionSkewRejected(archiveByPackageName); } @@ -366,6 +367,72 @@ async function inspectSourceMaps(packageSpec, packageRoot, files) { ); } +// Follow the quickstart's install order: an application's compiler is already +// installed when it adds the CLI. An optional peer can still reject that +// compiler, which invoking the workspace's compilers externally cannot catch. +async function verifyCliTypeScriptConsumers(archiveByPackageName) { + for (const compilerVersion of ["6", "latest"]) { + const consumerDirectory = join( + temporaryDirectory, + `cli-typescript-${compilerVersion}` + ); + await mkdir(consumerDirectory); + await writeFile( + join(consumerDirectory, "package.json"), + `${JSON.stringify( + { + name: `riverqueue-cli-typescript-${compilerVersion}`, + private: true, + type: "module", + version: "0.0.0", + }, + null, + 2 + )}\n` + ); + await npmInstall(consumerDirectory, [ + "--save-dev", + `typescript@${compilerVersion}`, + ]); + const compilerPackagePath = join( + consumerDirectory, + "node_modules/typescript/package.json" + ); + const compiler = JSON.parse(await readFile(compilerPackagePath, "utf8")); + await npmInstall(consumerDirectory, [ + "--save-dev", + ...[ + "riverqueue", + "@riverqueue/driver-pg", + "@riverqueue/migrate", + "@riverqueue/cli", + ].map((name) => archiveByPackageName.get(name)), + ]); + assert.equal( + JSON.parse(await readFile(compilerPackagePath, "utf8")).version, + compiler.version, + "installing the CLI preserves the application's TypeScript version" + ); + + const bin = join(consumerDirectory, "node_modules/.bin/riverqueue"); + const databaseUrl = `sqlite://${join(consumerDirectory, "river.db")}`; + const options = { + cwd: consumerDirectory, + env: { ...process.env, NODE_OPTIONS: "", PGDATABASE: "" }, + }; + const { stdout } = await run( + bin, + ["migrate-up", "--database-url", databaseUrl], + options + ); + assert.match(stdout, /applied migration 001/u); + await run(bin, ["validate", "--database-url", databaseUrl], options); + process.stdout.write( + `validated CLI installation and migrations with TypeScript ${compiler.version}\n` + ); + } +} + async function verifyConsumers(archiveByPackageName) { const consumerDirectory = join(temporaryDirectory, "consumer"); await mkdir(consumerDirectory); @@ -436,7 +503,7 @@ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed) || `file:${archiveByPackageName.get("@riverqueue/cli")}`; await writeFile(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`); await npmInstall(consumerDirectory); - for (const dependency of ["@types/node", "@types/pg"]) { + for (const dependency of ["@types/node", "@types/pg", "typescript"]) { await assert.rejects( stat(join(consumerDirectory, "node_modules", dependency)), { code: "ENOENT" },