diff --git a/packages/jobs/src/driver-nats.test.ts b/packages/jobs/src/driver-nats.test.ts index 8ba04c8d..0ac55ed8 100644 --- a/packages/jobs/src/driver-nats.test.ts +++ b/packages/jobs/src/driver-nats.test.ts @@ -10,7 +10,7 @@ import { createNatsDriver } from './driver-nats'; import { JobsNotImplementedError } from './errors'; const FIX = - "set jobs: { driver: 'postgres' } in app.config.ts, then: x jobs drain --to memory --json"; + 'call setJobDriver(createPgDriver()) at boot instead of this driver, then move what is already queued: x jobs drain --to memory --json'; // Every stub method throws SYNCHRONOUSLY (`unavailable` is `throw`, not a rejected promise), so // the call under test has to happen inside the try — passed as a promise, `driver.enqueue(...)` diff --git a/packages/jobs/src/driver-nats.ts b/packages/jobs/src/driver-nats.ts index 4c1eb806..434a5144 100644 --- a/packages/jobs/src/driver-nats.ts +++ b/packages/jobs/src/driver-nats.ts @@ -16,10 +16,13 @@ import type { import { JobsNotImplementedError } from './errors'; import type { StepRecord, StepStore } from './steps'; -// Names the config edit that actually removes the stub, plus the runnable command for whatever -// is already queued. The nats driver lands in v2; there is no flag that turns this one on. +// Names the seam that actually replaces the stub, plus the runnable command for whatever is +// already queued. NOT `jobs: { driver }` in app.config.ts, which this line said until 2026-08-20: +// `JobsConfig.driver` has no reader anywhere (see `driver.ts`'s header), so that edit repairs +// nothing and the reader is sent back to the same throw. #223 removes the field. +// The nats driver lands in v2; there is no flag that turns this one on. const FIX = - "set jobs: { driver: 'postgres' } in app.config.ts, then: x jobs drain --to memory --json"; + 'call setJobDriver(createPgDriver()) at boot instead of this driver, then move what is already queued: x jobs drain --to memory --json'; const unavailable = (method: string): never => { throw new JobsNotImplementedError({ feature: `nats jobs driver (${method})`, fix: FIX }); diff --git a/packages/jobs/src/driver-redis.test.ts b/packages/jobs/src/driver-redis.test.ts index 8d7c32f5..dc54ccea 100644 --- a/packages/jobs/src/driver-redis.test.ts +++ b/packages/jobs/src/driver-redis.test.ts @@ -10,7 +10,7 @@ import { createRedisDriver } from './driver-redis'; import { JobsNotImplementedError } from './errors'; const FIX = - "set jobs: { driver: 'postgres' } in app.config.ts, then: x jobs drain --to memory --json"; + 'call setJobDriver(createPgDriver()) at boot instead of this driver, then move what is already queued: x jobs drain --to memory --json'; // Every stub method throws SYNCHRONOUSLY (`unavailable` is `throw`, not a rejected promise), so // the call under test has to happen inside the try — passed as a promise, `driver.enqueue(...)` diff --git a/packages/jobs/src/driver-redis.ts b/packages/jobs/src/driver-redis.ts index 7baf3759..19b7d769 100644 --- a/packages/jobs/src/driver-redis.ts +++ b/packages/jobs/src/driver-redis.ts @@ -19,10 +19,13 @@ import type { import { JobsNotImplementedError } from './errors'; import type { StepRecord, StepStore } from './steps'; -// Names the config edit that actually removes the stub, plus the runnable command for whatever -// is already queued. The redis driver lands in v2; there is no flag that turns this one on. +// Names the seam that actually replaces the stub, plus the runnable command for whatever is +// already queued. NOT `jobs: { driver }` in app.config.ts, which this line said until 2026-08-20: +// `JobsConfig.driver` has no reader anywhere (see `driver.ts`'s header), so that edit repairs +// nothing and the reader is sent back to the same throw. #223 removes the field. +// The redis driver lands in v2; there is no flag that turns this one on. const FIX = - "set jobs: { driver: 'postgres' } in app.config.ts, then: x jobs drain --to memory --json"; + 'call setJobDriver(createPgDriver()) at boot instead of this driver, then move what is already queued: x jobs drain --to memory --json'; const unavailable = (method: string): never => { throw new JobsNotImplementedError({ feature: `redis jobs driver (${method})`, fix: FIX }); diff --git a/packages/jobs/src/errors.ts b/packages/jobs/src/errors.ts index 91e2529a..2cdca01c 100644 --- a/packages/jobs/src/errors.ts +++ b/packages/jobs/src/errors.ts @@ -355,7 +355,7 @@ export class CancelUnsupportedError extends UltimateError { super({ code: 'X_JOB_NOT_CANCELLABLE', cause: `the "${input.driver}" jobs driver cannot cancel a single job`, - fix: "set jobs: { driver: 'postgres' } in app.config.ts, then: x jobs cancel --json", + fix: 'call setJobDriver(createPgDriver()) at boot — only the pg driver implements introspect.cancel — then: x jobs cancel --json', docs: docsFor('X_JOB_NOT_CANCELLABLE'), }); } @@ -372,7 +372,7 @@ export class ConcurrencyUnenforceableError extends UltimateError { super({ code: 'X_JOB_CONCURRENCY_UNENFORCEABLE', cause: `${input.jobs.join(', ')} declare concurrency and the "${input.driver}" jobs driver has no lease store, so the cap would hold per process and the fleet would run concurrency x replicas`, - fix: `remove concurrency from job("${input.jobs[0] ?? 'the job'}"), or set jobs: { driver: 'postgres' } in app.config.ts`, + fix: `remove concurrency from job("${input.jobs[0] ?? 'the job'}"), or call setJobDriver(createPgDriver()) at boot — the pg driver is the one with a lease store`, docs: docsFor('X_JOB_CONCURRENCY_UNENFORCEABLE'), }); } diff --git a/packages/jobs/src/fix-lines.test.ts b/packages/jobs/src/fix-lines.test.ts new file mode 100644 index 00000000..a045cf51 --- /dev/null +++ b/packages/jobs/src/fix-lines.test.ts @@ -0,0 +1,56 @@ +// A `fix:` in this package may not tell the reader to edit `jobs.driver`. +// +// `JobsConfig.driver` is declared and read by NOTHING — boot always builds `createPgDriver` +// (`driver.ts`'s header states it, `dev-queue.ts` is where it happens). Six shipped `fix:` lines +// named that field as the repair for `X_NOT_IMPLEMENTED`, so six error paths handed an agent an +// instruction that changes nothing and returns it to the same throw. Axiom 4 asks for the exact +// command that repairs the failure; a no-op is the one thing a `fix:` may never be. +// +// The `errors` gate step cannot see this: it checks that a fix names a *command* that exists, and +// `set jobs: { driver: 'postgres' } in app.config.ts` names none. #223 deletes the field, which is +// breaking; this test is what stops the instruction coming back before then, and after. + +import { describe, expect, test } from 'bun:test'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const SRC = import.meta.dir; + +/** `jobs: { driver: … }` or `jobs.driver`, in either quote style, anywhere in a fix string. */ +const DEAD_FIELD = /jobs\s*[:.]\s*\{?\s*driver|jobs\.driver/; + +/** Every `fix:` string literal in a source file, single- or double-quoted or a template. */ +const FIX_LINE = /fix:\s*(['"`])((?:\\.|(?!\1).)*)\1/gs; + +const sources = (): readonly string[] => + readdirSync(SRC).filter((name) => name.endsWith('.ts') && !name.endsWith('.test.ts')); + +describe('fix lines', () => { + test('no fix: instructs the reader to edit jobs.driver, which selects nothing', () => { + const offenders: string[] = []; + for (const name of sources()) { + const text = readFileSync(join(SRC, name), 'utf8'); + for (const [, , body] of text.matchAll(FIX_LINE)) { + if (body !== undefined && DEAD_FIELD.test(body)) offenders.push(`${name}: ${body}`); + } + } + expect(offenders).toEqual([]); + }); + + // Guards the guard: a regex that matched nothing would pass the test above forever. This is the + // exact string that shipped in five places, and it must be caught. + test('the rule catches the string that actually shipped', () => { + expect(DEAD_FIELD.test("set jobs: { driver: 'postgres' } in app.config.ts")).toBe(true); + expect(DEAD_FIELD.test('or set jobs.driver in app.config.ts and run `x dev`')).toBe(true); + expect(DEAD_FIELD.test('call setJobDriver(createPgDriver()) at boot')).toBe(false); + }); + + test('every fix: in this package is non-empty', () => { + for (const name of sources()) { + const text = readFileSync(join(SRC, name), 'utf8'); + for (const [, , body] of text.matchAll(FIX_LINE)) { + expect(body?.trim().length ?? 0).toBeGreaterThan(0); + } + } + }); +}); diff --git a/packages/jobs/src/inspect.test.ts b/packages/jobs/src/inspect.test.ts index fcb8ff1d..4f422490 100644 --- a/packages/jobs/src/inspect.test.ts +++ b/packages/jobs/src/inspect.test.ts @@ -134,7 +134,8 @@ function driverWithoutIntrospect(): JobDriver { return rest; } -const INTROSPECT_FIX = "set jobs: { driver: 'postgres' } in app.config.ts, then: x jobs ls --json"; +const INTROSPECT_FIX = + 'call setJobDriver(createPgDriver()) at boot — only the pg driver implements introspect — then: x jobs ls --json'; async function expectIntrospectionRequired(call: () => Promise): Promise { let thrown: unknown; diff --git a/packages/jobs/src/inspect.ts b/packages/jobs/src/inspect.ts index 2e5e5f01..319cca04 100644 --- a/packages/jobs/src/inspect.ts +++ b/packages/jobs/src/inspect.ts @@ -91,7 +91,7 @@ function requireIntrospection(driver: JobDriver): NonNullable