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<T>` → `Job<T>` (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) <noreply@anthropic.com>
135 lines
4 KiB
TypeScript
135 lines
4 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { assertValidJobName, startWorker } from "./worker.ts";
|
|
import type { JobDefinition } from "./types.ts";
|
|
|
|
function createMockBoss() {
|
|
return {
|
|
start: vi.fn().mockResolvedValue(undefined),
|
|
stop: vi.fn().mockResolvedValue(undefined),
|
|
createQueue: vi.fn().mockResolvedValue(undefined),
|
|
schedule: vi.fn().mockResolvedValue(undefined),
|
|
work: vi.fn().mockResolvedValue("worker-id"),
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("startWorker", () => {
|
|
it("starts the boss instance", async () => {
|
|
const boss = createMockBoss();
|
|
await startWorker(boss as never, []);
|
|
expect(boss.start).toHaveBeenCalled();
|
|
});
|
|
|
|
it("creates queues before registering handlers", async () => {
|
|
const boss = createMockBoss();
|
|
const jobs: JobDefinition[] = [{ name: "test-job", handler: vi.fn() }];
|
|
|
|
await startWorker(boss as never, jobs);
|
|
|
|
expect(boss.createQueue).toHaveBeenCalledWith("test-job");
|
|
expect(boss.createQueue).toHaveBeenCalledBefore(boss.work);
|
|
});
|
|
|
|
it("registers job handlers", async () => {
|
|
const boss = createMockBoss();
|
|
const handler = vi.fn();
|
|
const jobs: JobDefinition[] = [{ name: "test-job", handler }];
|
|
|
|
await startWorker(boss as never, jobs);
|
|
|
|
expect(boss.work).toHaveBeenCalledWith("test-job", handler);
|
|
});
|
|
|
|
it("schedules cron jobs with options", async () => {
|
|
const boss = createMockBoss();
|
|
const handler = vi.fn();
|
|
const jobs: JobDefinition[] = [
|
|
{
|
|
name: "cron-job",
|
|
handler,
|
|
cron: "0 * * * *",
|
|
retryLimit: 3,
|
|
expireInSeconds: 120,
|
|
},
|
|
];
|
|
|
|
await startWorker(boss as never, jobs);
|
|
|
|
expect(boss.schedule).toHaveBeenCalledWith("cron-job", "0 * * * *", undefined, {
|
|
retryLimit: 3,
|
|
expireInSeconds: 120,
|
|
});
|
|
expect(boss.work).toHaveBeenCalledWith("cron-job", handler);
|
|
});
|
|
|
|
it("does not schedule non-cron jobs", async () => {
|
|
const boss = createMockBoss();
|
|
const jobs: JobDefinition[] = [{ name: "one-shot", handler: vi.fn() }];
|
|
|
|
await startWorker(boss as never, jobs);
|
|
|
|
expect(boss.schedule).not.toHaveBeenCalled();
|
|
expect(boss.work).toHaveBeenCalledWith("one-shot", jobs[0]!.handler);
|
|
});
|
|
|
|
it("registers multiple jobs", async () => {
|
|
const boss = createMockBoss();
|
|
const jobs: JobDefinition[] = [
|
|
{ name: "job-a", handler: vi.fn() },
|
|
{ name: "job-b", handler: vi.fn(), cron: "*/5 * * * *" },
|
|
];
|
|
|
|
await startWorker(boss as never, jobs);
|
|
|
|
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/);
|
|
}
|
|
});
|
|
});
|