From c2e8461f9c31aff82071d6aa2436f11563b26fc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 19 Apr 2026 23:01:11 +0200 Subject: [PATCH] Upgrade pg-boss from 10 to 12 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supersedes dependabot #264 — this one involves code changes that PR couldn't make. ## Breaking changes in our usage surface ### v12: default export removed → named `PgBoss` export Affects every file that imports the SDK: - `packages/jobs/src/boss.ts`: `import PgBoss` → `import { PgBoss }` - `packages/jobs/src/worker.ts`: same for the type import - `packages/jobs/src/types.ts`: `PgBoss.Job` → `Job` (types.ts re-exports `Job` at the package root in v12) ### v11: queue names restricted to `[A-Za-z0-9_.-]` Colon `:` is no longer allowed. Renamed the two journal queues that had it: - `demo-bot:generate` → `demo-bot-generate` - `demo-bot:prune` → `demo-bot-prune` The planner's `expire-sessions` was already valid. ### v12: minimum Node 22.12 Not a code change for us — journal + planner Dockerfiles are on `node:25-slim`, CI runners on 24. ## Regression guard Added `assertValidJobName()` in `@trails-cool/jobs`, called by `startWorker()` before any side effects. Pg-boss v11+ silently accepts an invalid name then rejects the underlying SQL call later — we fail loudly at boot instead. Unit tests cover the character-class rule and exercise the exact old-name (`demo-bot:generate`) as a regression fence. ## Prod rollout note Pg-boss v11 dropped the auto-migration path from v10. On deploy, the live `pgboss` schema from v10 won't migrate cleanly. Simplest path: `DROP SCHEMA pgboss CASCADE` before the first v12 worker starts — our jobs are all cron-scheduled and will re-register themselves on boot, so there's nothing durable to preserve in the queue. ## Verified - `pnpm typecheck` / `pnpm lint` / `pnpm test` — all clean - `pnpm exec playwright test --workers=2` — 50/50 passed Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/journal/app/jobs/demo-bot-generate.ts | 2 +- apps/journal/app/jobs/demo-bot-prune.ts | 2 +- packages/jobs/package.json | 2 +- packages/jobs/src/boss.ts | 2 +- packages/jobs/src/types.ts | 6 +- packages/jobs/src/worker.test.ts | 47 ++++++++++++++- packages/jobs/src/worker.ts | 23 +++++++- pnpm-lock.yaml | 68 +++++++++++++--------- 8 files changed, 117 insertions(+), 35 deletions(-) diff --git a/apps/journal/app/jobs/demo-bot-generate.ts b/apps/journal/app/jobs/demo-bot-generate.ts index 9d6f67e..a25020a 100644 --- a/apps/journal/app/jobs/demo-bot-generate.ts +++ b/apps/journal/app/jobs/demo-bot-generate.ts @@ -22,7 +22,7 @@ import { logger } from "../lib/logger.server.ts"; * daily cap; on pass, insert one route+activity via `generateOneWalk`. */ export const demoBotGenerateJob: JobDefinition = { - name: "demo-bot:generate", + name: "demo-bot-generate", cron: "0,30 * * * *", retryLimit: 1, expireInSeconds: 120, diff --git a/apps/journal/app/jobs/demo-bot-prune.ts b/apps/journal/app/jobs/demo-bot-prune.ts index d3f4d31..6ac2855 100644 --- a/apps/journal/app/jobs/demo-bot-prune.ts +++ b/apps/journal/app/jobs/demo-bot-prune.ts @@ -12,7 +12,7 @@ import { logger } from "../lib/logger.server.ts"; * `DEMO_BOT_RETENTION_DAYS` (default 14). Never touches real users. */ export const demoBotPruneJob: JobDefinition = { - name: "demo-bot:prune", + name: "demo-bot-prune", cron: "15 3 * * *", retryLimit: 1, expireInSeconds: 60, diff --git a/packages/jobs/package.json b/packages/jobs/package.json index 7b8221f..b1fe242 100644 --- a/packages/jobs/package.json +++ b/packages/jobs/package.json @@ -13,7 +13,7 @@ "typecheck": "tsc" }, "dependencies": { - "pg-boss": "^10.3.1" + "pg-boss": "^12.0.0" }, "devDependencies": { "@types/node": "catalog:" diff --git a/packages/jobs/src/boss.ts b/packages/jobs/src/boss.ts index e947bd5..360322e 100644 --- a/packages/jobs/src/boss.ts +++ b/packages/jobs/src/boss.ts @@ -1,4 +1,4 @@ -import PgBoss from "pg-boss"; +import { PgBoss } from "pg-boss"; export function createBoss(connectionString: string): PgBoss { return new PgBoss({ connectionString }); diff --git a/packages/jobs/src/types.ts b/packages/jobs/src/types.ts index 32eb27d..0dc923e 100644 --- a/packages/jobs/src/types.ts +++ b/packages/jobs/src/types.ts @@ -1,8 +1,8 @@ -import type PgBoss from "pg-boss"; +import type { Job } from "pg-boss"; -export interface JobDefinition { +export interface JobDefinition { name: string; - handler: (jobs: PgBoss.Job[]) => Promise; + handler: (jobs: Job[]) => Promise; cron?: string; retryLimit?: number; expireInSeconds?: number; diff --git a/packages/jobs/src/worker.test.ts b/packages/jobs/src/worker.test.ts index 76a3020..b2a0ad4 100644 --- a/packages/jobs/src/worker.test.ts +++ b/packages/jobs/src/worker.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { startWorker } from "./worker.ts"; +import { assertValidJobName, startWorker } from "./worker.ts"; import type { JobDefinition } from "./types.ts"; function createMockBoss() { @@ -87,4 +87,49 @@ describe("startWorker", () => { expect(boss.work).toHaveBeenCalledTimes(2); expect(boss.schedule).toHaveBeenCalledTimes(1); }); + + it("rejects queue names with characters that pg-boss v11+ forbids", async () => { + const boss = createMockBoss(); + // `:` was allowed by pg-boss v10 and got us into trouble on upgrade + // (our queues were named demo-bot:generate / demo-bot:prune until we + // renamed them for v11+). Regression-guard that shape. + const jobs: JobDefinition[] = [{ name: "demo-bot:generate", handler: vi.fn() }]; + + await expect(startWorker(boss as never, jobs)).rejects.toThrow( + /Invalid pg-boss queue name/, + ); + // start/createQueue/schedule/work must not have been called — we + // fail before any side effects. + expect(boss.start).not.toHaveBeenCalled(); + expect(boss.createQueue).not.toHaveBeenCalled(); + expect(boss.schedule).not.toHaveBeenCalled(); + expect(boss.work).not.toHaveBeenCalled(); + }); +}); + +describe("assertValidJobName", () => { + it("accepts letters, numbers, hyphens, underscores, and periods", () => { + for (const name of [ + "demo-bot-generate", + "expire_sessions", + "job.v2", + "DemoBot123", + "a", + ]) { + expect(() => assertValidJobName(name)).not.toThrow(); + } + }); + + it("rejects colon, slash, whitespace, and other punctuation", () => { + for (const name of [ + "demo-bot:generate", + "foo/bar", + "foo bar", + "foo@bar", + "foo#bar", + "", + ]) { + expect(() => assertValidJobName(name)).toThrow(/Invalid pg-boss queue name/); + } + }); }); diff --git a/packages/jobs/src/worker.ts b/packages/jobs/src/worker.ts index ee56806..3a054a0 100644 --- a/packages/jobs/src/worker.ts +++ b/packages/jobs/src/worker.ts @@ -1,10 +1,31 @@ -import type PgBoss from "pg-boss"; +import type { PgBoss } from "pg-boss"; import type { JobDefinition } from "./types.ts"; +/** + * Characters pg-boss v11+ accepts for queue and schedule keys. Stricter + * than v10, which silently accepted `:` (and therefore let our + * `demo-bot:generate` etc. names through on older pg-boss). On the + * upgrade path we renamed them, and this guard keeps us from regressing. + */ +const VALID_NAME = /^[A-Za-z0-9_.-]+$/; + +export function assertValidJobName(name: string): void { + if (!VALID_NAME.test(name)) { + throw new Error( + `Invalid pg-boss queue name "${name}": only letters, numbers, hyphens, underscores, and periods are allowed.`, + ); + } +} + export async function startWorker( boss: PgBoss, jobs: JobDefinition[], ): Promise { + // Validate every job name before any side effects so a bad name fails + // loudly at worker boot instead of silently producing an unreachable + // queue somewhere downstream. + for (const job of jobs) assertValidJobName(job.name); + await boss.start(); for (const job of jobs) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d75532c..523ad78 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -620,8 +620,8 @@ importers: packages/jobs: dependencies: pg-boss: - specifier: ^10.3.1 - version: 10.4.2 + specifier: ^12.0.0 + version: 12.15.0 devDependencies: '@types/node': specifier: 'catalog:' @@ -4230,9 +4230,9 @@ packages: crelt@1.0.6: resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} - cron-parser@4.9.0: - resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} - engines: {node: '>=12.0.0'} + cron-parser@5.5.0: + resolution: {integrity: sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww==} + engines: {node: '>=18'} cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} @@ -6088,6 +6088,10 @@ packages: resolution: {integrity: sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==} engines: {node: '>=6.0.0'} + non-error@0.1.0: + resolution: {integrity: sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==} + engines: {node: '>=20'} + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -6241,9 +6245,10 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - pg-boss@10.4.2: - resolution: {integrity: sha512-AttEWOtSzn53av8OnCMWEanwRBvjkZCE1y5nLrZnwvkkMnlZ5XpWDpZ7sKI/BYjvi2OVieMX37arD2ACgJ750w==} - engines: {node: '>=20'} + pg-boss@12.15.0: + resolution: {integrity: sha512-xw0n3M6PtAOEtPYAF/PULFqba1a27BTJOJ+tVfbd/kG++GjF5RM1xj24n5xQyek3VwDR9LObftc+ygAJ0V2kVw==} + engines: {node: '>=22.12.0'} + hasBin: true pg-cloudflare@1.3.0: resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} @@ -6778,14 +6783,14 @@ packages: resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} engines: {node: '>= 0.8.0'} + serialize-error@13.0.1: + resolution: {integrity: sha512-bBZaRwLH9PN5HbLCjPId4dP5bNGEtumcErgOX952IsvOhVPrm3/AeK1y0UHA/QaPG701eg0yEnOKsCOC6X/kaA==} + engines: {node: '>=20'} + serialize-error@2.1.0: resolution: {integrity: sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==} engines: {node: '>=0.10.0'} - serialize-error@8.1.0: - resolution: {integrity: sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==} - engines: {node: '>=10'} - serve-static@1.16.3: resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} engines: {node: '>= 0.8.0'} @@ -7018,6 +7023,10 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + tailwindcss@4.2.2: resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==} @@ -7142,10 +7151,6 @@ packages: resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} engines: {node: '>=4'} - type-fest@0.20.2: - resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} - engines: {node: '>=10'} - type-fest@0.21.3: resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} engines: {node: '>=10'} @@ -7154,6 +7159,10 @@ packages: resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} engines: {node: '>=8'} + type-fest@5.6.0: + resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==} + engines: {node: '>=20'} + type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} @@ -11700,7 +11709,7 @@ snapshots: crelt@1.0.6: {} - cron-parser@4.9.0: + cron-parser@5.5.0: dependencies: luxon: 3.7.2 @@ -14115,6 +14124,8 @@ snapshots: nodemailer@8.0.5: {} + non-error@0.1.0: {} + normalize-path@3.0.0: {} npm-package-arg@11.0.3: @@ -14265,11 +14276,11 @@ snapshots: pathe@2.0.3: {} - pg-boss@10.4.2: + pg-boss@12.15.0: dependencies: - cron-parser: 4.9.0 + cron-parser: 5.5.0 pg: 8.20.0 - serialize-error: 8.1.0 + serialize-error: 13.0.1 transitivePeerDependencies: - pg-native @@ -14889,11 +14900,12 @@ snapshots: transitivePeerDependencies: - supports-color - serialize-error@2.1.0: {} - - serialize-error@8.1.0: + serialize-error@13.0.1: dependencies: - type-fest: 0.20.2 + non-error: 0.1.0 + type-fest: 5.6.0 + + serialize-error@2.1.0: {} serve-static@1.16.3: dependencies: @@ -15108,6 +15120,8 @@ snapshots: symbol-tree@3.2.4: {} + tagged-tag@1.0.0: {} + tailwindcss@4.2.2: {} tapable@2.3.2: {} @@ -15226,12 +15240,14 @@ snapshots: type-detect@4.0.8: {} - type-fest@0.20.2: {} - type-fest@0.21.3: {} type-fest@0.7.1: {} + type-fest@5.6.0: + dependencies: + tagged-tag: 1.0.0 + type-is@1.6.18: dependencies: media-typer: 0.3.0