Skip to content

Commit dc61fba

Browse files
fix(services): the DbJobAdapter class JSDoc stops promising a sys_job_run row recordRuns: false never writes (#9631)
The class-level "persisted side effects" list said `every execution writes a sys_job_run row`. `wrap()` gates that insert on `recordRuns`: current = { id: this.recordRuns ? await this.startRun(...) : undefined, ... }; and `settle()` only updates a row when one exists, so with `recordRuns: false` no execution writes one. `tsup` emits this comment into the package's built `index.d.ts`, which is the class-level editor tooltip an npm consumer reads — the same "published documentation asserting behaviour the runtime does not have" class as #9611, in the same file and the same emitted declaration. The corrected bullet defers to `DbJobAdapterOptions.recordRuns` with `{@link}` instead of restating the rule. Two true-but-divergent descriptions of one flag is the next version of this defect, and the field's own JSDoc is where the meaning belongs; the class list only says where it shows up. It also names the one row the flag does not govern — `replay()`'s synthetic `trigger: 'replay'` row, written either way, measured rather than assumed. Without that clause the corrected sentence would be false today for exactly the reason #9633 records. The fourth bullet was already true and gains the matching negative: `bumpJob` is called from `settle` OUTSIDE the `if (run.id)` guard, so the `sys_job` counters are updated whether or not a run row exists. Left implicit, a reader carries the `recordRuns` caveat down onto it. No behaviour change. Five cases pin the flag, reusing this file's existing engine double rather than minting a new one. Nothing in the package referenced `recordRuns` in any direction before, so the wording corrected here and the field wording corrected on #9611 were both accurate but unenforced. The discriminator asserts the execution REALLY RAN — handler fired, `sys_job.run_count` bumped — and that no row was written anyway; without that half, "0 rows" would also pass for a job that never fired. The replay case pins today's behaviour because that is what the JSDoc now states, not as an endorsement: #9633 holds the open disposition, and if it lands the case and the bullet it mirrors change together. Co-authored-by: Claude <noreply@anthropic.com>
1 parent 40162f1 commit dc61fba

3 files changed

Lines changed: 120 additions & 2 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/service-job": patch
3+
---
4+
5+
fix(services): the `DbJobAdapter` class JSDoc stops promising a `sys_job_run` row that `recordRuns: false` never writes (#9631)
6+
7+
`tsup` emits this comment into `packages/services/service-job/dist/index.d.ts`, so it is
8+
the class-level editor tooltip an npm consumer of `@objectstack/service-job` reads. Its
9+
third "persisted side effects" bullet said **every execution writes a `sys_job_run` row**;
10+
`wrap()` gates that insert on `recordRuns`, which defaults to `true` but writes nothing at
11+
all when set to `false`. The same emitted `index.d.ts` states the truthful field-level rule
12+
for `recordRuns` sixty lines above, so the published declaration disagreed with itself
13+
about one flag — a reader hovering either one got a different answer.
14+
15+
No runtime behaviour changes. This is a patch because the entire deliverable is text inside
16+
a published package's `.d.ts`: with no version bump the corrected tooltip never reaches npm
17+
and the fix is unmet in the only channel it is about.
18+
19+
The corrected bullet defers to `DbJobAdapterOptions.recordRuns` via `{@link}` rather than
20+
restating the rule, so the two cannot drift apart again, and it names the one row the flag
21+
does not govern — `replay()`'s synthetic `trigger: 'replay'` row, written either way. The
22+
fourth bullet gains the matching negative: the `sys_job` counters are bumped
23+
unconditionally, `recordRuns` gating only the per-attempt rows.
24+
25+
Five cases now pin the flag in both directions. Nothing in this package referenced
26+
`recordRuns` before, so both corrected sentences were accurate but unenforced.

‎packages/services/service-job/src/db-job-adapter.test.ts‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,3 +234,89 @@ describe('DbJobAdapter — kernel rebuild (#8362)', () => {
234234
await rebuilt.db.destroy();
235235
});
236236
});
237+
238+
// ─── #9631 — `recordRuns`, the flag two published `.d.ts` comments describe ──
239+
//
240+
// Until this block, NOTHING in this package referenced `recordRuns` in either
241+
// direction: not that `true` writes a row, not that `false` writes none, not
242+
// that the default is `true`. The class JSDoc and the field JSDoc both describe
243+
// the flag to every npm consumer through the emitted `index.d.ts`, and both
244+
// were free to drift from the code — which is exactly what #9611 and #9631
245+
// each found one of. These cases exist so the sentences stop being unenforced.
246+
//
247+
// The discriminator is case 2: it asserts the execution REALLY RAN (the handler
248+
// fired and `sys_job.run_count` bumped) and that no row was written anyway.
249+
// Without that half, "0 rows" would also pass for a job that never fired, which
250+
// is the way a test like this goes quietly blind.
251+
describe('DbJobAdapter — recordRuns (#9631)', () => {
252+
const adapters: DbJobAdapter[] = [];
253+
const build = (options?: { recordRuns?: boolean }) => {
254+
const engine = makeFakeEngine();
255+
const adapter = new DbJobAdapter({ engine, options });
256+
adapters.push(adapter);
257+
return { engine, adapter };
258+
};
259+
afterEach(async () => {
260+
while (adapters.length) await adapters.pop()!.destroy();
261+
});
262+
263+
it('defaults to true: a triggered execution writes one sys_job_run row', async () => {
264+
const { engine, adapter } = build(); // no options at all — the documented default
265+
await adapter.schedule('d', { type: 'cron', expression: '* * * * *' }, async () => {});
266+
await adapter.trigger('d');
267+
expect(engine.tables.get('sys_job_run') ?? []).toHaveLength(1);
268+
});
269+
270+
it('recordRuns: false writes NO sys_job_run row, though the execution really ran', async () => {
271+
const { engine, adapter } = build({ recordRuns: false });
272+
let ran = 0;
273+
await adapter.schedule('off', { type: 'cron', expression: '* * * * *' }, async () => { ran++; });
274+
await adapter.trigger('off');
275+
276+
expect(ran, 'the handler must actually have run — otherwise "no rows" proves nothing').toBe(1);
277+
expect(engine.tables.get('sys_job_run') ?? []).toHaveLength(0);
278+
});
279+
280+
it('recordRuns: false does NOT gate the sys_job counters — only the per-attempt rows', async () => {
281+
// The class JSDoc's fourth bullet: `bumpJob` is called from `settle`
282+
// outside the `if (run.id)` guard, so the job row is updated either way.
283+
const { engine, adapter } = build({ recordRuns: false });
284+
await adapter.schedule('c', { type: 'cron', expression: '* * * * *' }, async () => {
285+
throw new Error('boom');
286+
});
287+
await adapter.trigger('c');
288+
289+
const job = (engine.tables.get('sys_job') ?? [])[0];
290+
expect(job.last_status).toBe('failed');
291+
expect(job.run_count).toBe(1);
292+
expect(job.failure_count).toBe(1);
293+
expect(engine.tables.get('sys_job_run') ?? []).toHaveLength(0);
294+
});
295+
296+
it('recordRuns: true is the same as the default', async () => {
297+
const { engine, adapter } = build({ recordRuns: true });
298+
await adapter.schedule('on', { type: 'cron', expression: '* * * * *' }, async () => {});
299+
await adapter.trigger('on');
300+
const runs = engine.tables.get('sys_job_run') ?? [];
301+
expect(runs).toHaveLength(1);
302+
expect(runs[0]).toMatchObject({ job_name: 'on', trigger: 'schedule', status: 'success' });
303+
});
304+
305+
it("replay() writes its synthetic row even when recordRuns is false — the exception the JSDoc names", async () => {
306+
// This pins TODAY'S behaviour, which is what the class JSDoc now states;
307+
// it is not an endorsement of it. #9633 holds the open disposition on
308+
// whether `replay()` should honour the flag. If that lands, this case and
309+
// the class-JSDoc bullet it mirrors change together — which is the whole
310+
// point of writing it down here: the sentence cannot go stale in silence
311+
// again.
312+
const { engine, adapter } = build({ recordRuns: false });
313+
await adapter.schedule('rp', { type: 'cron', expression: '* * * * *' }, async () => {});
314+
await adapter.replay('rp');
315+
316+
const runs = engine.tables.get('sys_job_run') ?? [];
317+
// Exactly one: the synthetic replay row. The wrapped execution the replay
318+
// drives underneath it is gated by the flag and writes nothing.
319+
expect(runs.map((r) => r.trigger)).toEqual(['replay']);
320+
expect(runs[0].status).toBe('success');
321+
});
322+
});

‎packages/services/service-job/src/db-job-adapter.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,14 @@ function uid(prefix: string): string {
6969
* Persisted side effects:
7070
* - `schedule(name, …)` upserts a `sys_job` row (active=true)
7171
* - `cancel(name)` marks the row inactive
72-
* - every execution writes a `sys_job_run` row
73-
* - every execution updates `sys_job.last_run_at / last_status / run_count / failure_count`
72+
* - every execution writes a `sys_job_run` row per attempt — unless
73+
* {@link DbJobAdapterOptions.recordRuns} is `false`, the on/off switch for
74+
* run history, which writes none of them. The one row it does not govern is
75+
* {@link DbJobAdapter.replay}'s synthetic `trigger: 'replay'` row, written
76+
* either way.
77+
* - every execution updates `sys_job.last_run_at / last_status / run_count /
78+
* failure_count` — unconditionally: `recordRuns` gates the per-attempt rows
79+
* above, never these counters.
7480
*
7581
* The persistence is best-effort: a DB failure is logged but does not
7682
* break job execution. This keeps a healthy job system resilient to

0 commit comments

Comments
 (0)