Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/jobs/src/driver-nats.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(...)`
Expand Down
9 changes: 6 additions & 3 deletions packages/jobs/src/driver-nats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
2 changes: 1 addition & 1 deletion packages/jobs/src/driver-redis.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(...)`
Expand Down
9 changes: 6 additions & 3 deletions packages/jobs/src/driver-redis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
4 changes: 2 additions & 2 deletions packages/jobs/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id> --json",
fix: 'call setJobDriver(createPgDriver()) at boot — only the pg driver implements introspect.cancel — then: x jobs cancel <id> --json',
docs: docsFor('X_JOB_NOT_CANCELLABLE'),
});
}
Expand All @@ -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'),
});
}
Expand Down
56 changes: 56 additions & 0 deletions packages/jobs/src/fix-lines.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
});
});
3 changes: 2 additions & 1 deletion packages/jobs/src/inspect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>): Promise<void> {
let thrown: unknown;
Expand Down
2 changes: 1 addition & 1 deletion packages/jobs/src/inspect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ function requireIntrospection(driver: JobDriver): NonNullable<JobDriver['introsp
if (driver.introspect === undefined) {
throw new JobsNotImplementedError({
feature: `introspection for the "${driver.name}" jobs driver`,
fix: "set jobs: { driver: 'postgres' } in app.config.ts, then: x jobs ls --json",
fix: 'call setJobDriver(createPgDriver()) at boot — only the pg driver implements introspect — then: x jobs ls --json',
});
}
return driver.introspect;
Expand Down
2 changes: 1 addition & 1 deletion packages/jobs/src/outbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ export function jobsFacade(): JobsFacade {
throw new DriverUnavailableError({
driver: 'none',
cause: 'no queue driver is installed in this process',
fix: 'call setJobDriver(createMemoryDriver()) before enqueuingor set jobs.driver in app.config.ts and run `x dev`',
fix: 'call setJobDriver(createMemoryDriver()) before enqueuing, or setJobDriver(createPgDriver()) for a real queue',
});
}
return installed;
Expand Down