Skip to content

Commit 222dc0f

Browse files
os-justinclaude
andauthored
feat(spec): IJobService.replay gains an optional options argument carrying force: true (#14766) (#15377)
The contract half of the #14501 A+a2 ruling: `replay?(name, data?, options?: JobReplayOptions)`. The TSDoc declares the three outcomes by claim state (absent / failed re-run the window; succeeded is refused with an ADR-0112 envelope — RESOURCE_CONFLICT / 409 — naming the window and the claim; `{ force: true }` sends anyway). Behaviour stays on #14501. - exported `JobReplayOptions { force?: boolean }` (options object, the `schedule` convention) - type-level pin block in job-service.test.ts (additivity, identity, source-reading) - regenerated api-surface/contracts.json and export-origins/contracts.json - changeset: @objectstack/spec minor Claude-Session: https://claude.ai/code/session_01H2oQebDDxYKfWZusyd8GXk Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent b337a13 commit 222dc0f

5 files changed

Lines changed: 189 additions & 2 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): `IJobService.replay` gains an optional third argument, `options?: JobReplayOptions`, carrying `force: true` (#14766 — the contract half of the #14501 A+a2 ruling)
6+
7+
Additive: the argument is optional, an existing two-argument `replay(name, data?)` implementation keeps compiling and behaving as before, and omitting it is the pre-#14766 call exactly. `JobReplayOptions` is exported from `@objectstack/spec` (`contracts`), with one member, `force?: boolean`.
8+
9+
**What the contract now declares** (`packages/spec/src/contracts/job-service.ts`, the `replay` TSDoc), for a scheduled (cron) flow whose tick window takes a `(flow, tick-window)` dispatch claim in `sys_flow_dispatch`:
10+
11+
- `replay(name, data)` on a window whose claim is **absent or failed** re-runs the window — unchanged behaviour, and every job that never takes a claim is this row;
12+
- `replay(name, data)` on a window whose claim **succeeded** is **refused loudly**: the promise rejects with an ADR-0112 envelope — `code: 'RESOURCE_CONFLICT'` (the standard-catalog member HTTP 409 derives; no new extension code) and `status: 409` — whose message names the window asked for and the claim that refused it. Never a silent no-op;
13+
- `replay(name, data, { force: true })` sends anyway; the duplicate is the operator's, taken knowingly.
14+
15+
**Declared here, enforced by #14501.** This release changes the contract text and the signature only. The refusal semantics are implemented by the services half (#14501: the `(flow, tick-window)` claim through `sys_flow_dispatch`, and `DbJobAdapter.replay` reading it); until that lands, shipped adapters still accept the third argument and ignore it, re-running the window as before. A third-party `IJobService` implementation that already declares `replay` needs no change to keep compiling; one that wants the once-only guarantee implements the table above.

packages/spec/api-surface/contracts.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@
170170
"IntrospectedTable (interface)",
171171
"JobExecution (type)",
172172
"JobHandler (type)",
173+
"JobReplayOptions (interface)",
173174
"JobRetryPolicy (interface)",
174175
"JobRunOutcome (interface)",
175176
"JobSchedule (interface)",

packages/spec/export-origins/contracts.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@
170170
"IntrospectedTable": "src/contracts/schema-diff-service.ts#IntrospectedTable (interface)",
171171
"JobExecution": "src/system/job.zod.ts#JobExecution (type)",
172172
"JobHandler": "src/contracts/job-service.ts#JobHandler (type)",
173+
"JobReplayOptions": "src/contracts/job-service.ts#JobReplayOptions (interface)",
173174
"JobRetryPolicy": "src/contracts/job-service.ts#JobRetryPolicy (interface)",
174175
"JobRunOutcome": "src/contracts/job-service.ts#JobRunOutcome (interface)",
175176
"JobSchedule": "src/contracts/job-service.ts#JobSchedule (interface)",

packages/spec/src/contracts/job-service.test.ts

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { describe, it, expect } from 'vitest';
2-
import type { IJobService, JobHandler, JobRunOutcome, JobExecution } from './job-service';
2+
import { readFileSync } from 'node:fs';
3+
import { fileURLToPath } from 'node:url';
4+
import type { IJobService, JobHandler, JobRunOutcome, JobExecution, JobReplayOptions } from './job-service';
35

46
describe('Job Service Contract', () => {
57
it('should allow a minimal IJobService implementation with required methods', () => {
@@ -244,3 +246,103 @@ describe('[#6617] JobHandler degraded-outcome channel', () => {
244246
expect(thrownAttempts).toBe(4); // ← a throw still retries, unchanged
245247
});
246248
});
249+
250+
/**
251+
* [#14766] `IJobService.replay` gains an optional third argument carrying
252+
* `force: true` — the contract half of the maintainer's A + a2 ruling on
253+
* #14501 (whose behaviour half lands in `DbJobAdapter.replay`).
254+
*
255+
* COMPILE-TIME pins first (`tsconfig.test.json` type-checks this file under
256+
* `check:test-typecheck`, so an assignability pin here is a real check), and
257+
* one source-reading pin for the prose the services implementer codes
258+
* against: the refusal is declared on the contract, and prose is unassertable
259+
* except by reading it. Reverse verification for the block: narrow the
260+
* signature back to `(name, data?)` — (b), (c) and the identity pin go red,
261+
* (a) stays green. That asymmetry IS additivity.
262+
*/
263+
describe('[#14766] IJobService.replay force option — contract half of the #14501 A+a2 ruling', () => {
264+
type Eq<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
265+
type ReplayParams = Parameters<NonNullable<IJobService['replay']>>;
266+
267+
/**
268+
* The replay signature EXACTLY as it stood before #14766, pinned standalone
269+
* so the additivity claim is falsifiable: if the third parameter ever stops
270+
* being optional, this implementation stops being assignable.
271+
*/
272+
type PreIssue14766Replay = (name: string, data?: unknown) => Promise<void>;
273+
274+
const base = {
275+
schedule: async () => {},
276+
cancel: async () => {},
277+
trigger: async () => {},
278+
} satisfies Pick<IJobService, 'schedule' | 'cancel' | 'trigger'>;
279+
280+
it('(a) an existing two-argument replay implementation is unchanged — additivity, implementer side', async () => {
281+
// `DbJobAdapter.replay(name, data?)` as it stands on main: it declares no
282+
// third parameter, and must keep compiling untouched (#14501 widens it).
283+
const legacy: PreIssue14766Replay = async (_name, _data) => {};
284+
const service: IJobService = { ...base, replay: legacy };
285+
286+
await expect(service.replay!('digest')).resolves.toBeUndefined();
287+
await expect(service.replay!('digest', { since: 'yesterday' })).resolves.toBeUndefined();
288+
});
289+
290+
it('(b) the options type is exported, and a call site passes { force: true } through it', async () => {
291+
const seen: Array<JobReplayOptions | undefined> = [];
292+
const service: IJobService = {
293+
...base,
294+
replay: async (_name, _data, options) => {
295+
seen.push(options);
296+
},
297+
};
298+
299+
// THE pin that goes red when the third parameter is removed.
300+
const force: JobReplayOptions = { force: true };
301+
await service.replay!('digest', undefined, force);
302+
await service.replay!('digest', undefined, { force: false });
303+
await service.replay!('digest');
304+
305+
expect(seen).toEqual([{ force: true }, { force: false }, undefined]);
306+
});
307+
308+
it('(c) the third parameter IS JobReplayOptions, optional, and force is its only member', () => {
309+
// Identity, not assignability: a widening or a narrowing on either side
310+
// turns this alias red under check:test-typecheck.
311+
const identity: Eq<ReplayParams[2], JobReplayOptions | undefined> = true;
312+
expect(identity).toBe(true);
313+
314+
const bare: JobReplayOptions = {};
315+
expect(bare.force).toBeUndefined();
316+
317+
// @ts-expect-error force is a boolean — a truthy string is not the door.
318+
const stringly: JobReplayOptions = { force: 'yes' };
319+
expect(stringly.force).toBe('yes');
320+
321+
// @ts-expect-error force is the ONLY knob; a second one is a spec decision
322+
// (a new key on this interface), not a free-text field.
323+
const extra: JobReplayOptions = { force: true, skipClaim: true };
324+
expect(extra.force).toBe(true);
325+
});
326+
327+
it('(d) the contract text declares the refusal the services half codes against', () => {
328+
const source = readFileSync(fileURLToPath(new URL('./job-service.ts', import.meta.url)), 'utf8');
329+
const start = source.indexOf('replay?(name: string, data?: unknown, options?: JobReplayOptions)');
330+
expect(start).toBeGreaterThan(0);
331+
// The doc block immediately above the declaration.
332+
const docBlock = source.slice(source.lastIndexOf('/**', start), start);
333+
334+
// The three outcomes, by the claim state that selects them…
335+
expect(docBlock).toContain('(flow, tick-window)');
336+
expect(docBlock).toContain('**absent**');
337+
expect(docBlock).toContain('**failed**');
338+
expect(docBlock).toContain('**succeeded**');
339+
// …the refusal as an ADR-0112 envelope on code AND status, naming what it refuses…
340+
expect(docBlock).toContain('ADR-0112');
341+
expect(docBlock).toContain("code: 'RESOURCE_CONFLICT'");
342+
expect(docBlock).toContain('status: 409');
343+
expect(docBlock).toContain('**the window**');
344+
expect(docBlock).toContain('**the claim**');
345+
// …and the one door past it.
346+
expect(docBlock).toContain('options.force: true');
347+
});
348+
});

packages/spec/src/contracts/job-service.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,32 @@ export interface JobScheduleOptions {
158158
timeout?: number;
159159
}
160160

161+
/**
162+
* The optional third argument of {@link IJobService.replay} (#14766 — the
163+
* contract half of the maintainer's A + a2 ruling on #14501).
164+
*
165+
* An options object rather than a bare positional boolean, by the convention
166+
* this interface already follows for `schedule(…, options?: JobScheduleOptions)`:
167+
* a named, exported options type reads at the call site —
168+
* `replay(name, data, { force: true })` says what it does, `replay(name, data,
169+
* true)` does not — and a later knob is a second key here, never a fourth
170+
* positional. Omitting the argument is the pre-#14766 call exactly.
171+
*/
172+
export interface JobReplayOptions {
173+
/**
174+
* Re-send a scheduled flow's tick window even though that window's
175+
* `(flow, tick-window)` dispatch claim has already **succeeded**.
176+
*
177+
* Absent or `false`, a replay of a delivered window is refused with the
178+
* ADR-0112 envelope {@link IJobService.replay} declares. `true` is the
179+
* explicit operator door the ruling kept open (option a2): the operator is
180+
* stating that the window is known to have been delivered and is to be
181+
* delivered again, and takes the duplicate knowingly. It never touches a
182+
* window whose claim is absent or failed — those re-run either way.
183+
*/
184+
force?: boolean;
185+
}
186+
161187
export interface IJobService {
162188
/**
163189
* Schedule a recurring or one-time job
@@ -202,8 +228,50 @@ export interface IJobService {
202228
* `sys_audit_log`, with its own opt-in, writer and retention. Recording
203229
* anything durable depends on an adapter that persists run history at all
204230
* (e.g. `DbJobAdapter`'s `recordRuns` option).
231+
*
232+
* **Once-only delivery on the scheduled path** (#14766, the contract half
233+
* of the maintainer's A + a2 ruling on #14501; the behaviour half is
234+
* #14501 and lands in `DbJobAdapter.replay`). A scheduled (cron) flow
235+
* takes a dispatch claim in the `sys_flow_dispatch` ledger keyed
236+
* `(flow, tick-window)` — the same ledger a `time_relative` flow claims per
237+
* `(flow, window, record)` (#10220). A replay of a scheduled flow reads
238+
* that window's claim, and the contract admits exactly three outcomes:
239+
*
240+
* | The `(flow, tick-window)` claim is… | `replay(name, data)` | `replay(name, data, { force: true })` |
241+
* |:---|:---|:---|
242+
* | **absent** — the window was never claimed | re-runs the window (unchanged behaviour) | re-runs the window |
243+
* | **failed** — claimed, and the dispatch did not succeed | re-runs the window (unchanged behaviour) | re-runs the window |
244+
* | **succeeded** — the window was delivered | **refused**, loudly — see below | re-runs the window: sends anyway, the duplicate is the operator's, taken knowingly |
245+
*
246+
* A job that never takes a claim — every job that is not a scheduled
247+
* flow — is the **absent** row: it re-runs, with or without `force`, and
248+
* nothing about it changes.
249+
*
250+
* **The refusal is an ADR-0112 envelope, never a silent no-op.** The
251+
* promise **rejects** (it does not resolve having done nothing — the
252+
* ruling rejected that shape outright: an operator who pressed replay and
253+
* saw nothing happen is the bad experience this clause exists to prevent)
254+
* with an error carrying `code: 'RESOURCE_CONFLICT'` — the standard-catalog
255+
* member HTTP 409 derives (`HttpStatusErrorCodeMap[409]`, `api/errors.zod.ts`;
256+
* no service extension code is registered for this) — and `status: 409`,
257+
* and its message names **the window** that was asked for (the flow and
258+
* its tick window) and **the claim** that refused it (the
259+
* `sys_flow_dispatch` row: when the window was claimed and that the
260+
* dispatch succeeded). A consumer asserts the refusal on `code` and
261+
* `status`; the message is for the operator reading it.
262+
*
263+
* `options.force: true` is the only door past the refusal (option a2).
264+
* Option a1 (refuse always, no door) and option a3 (send anyway, accept
265+
* the duplicate silently) were considered on #14501 and **not** taken.
266+
*
267+
* @param name - Job name
268+
* @param data - Optional data to pass to the handler
269+
* @param options - {@link JobReplayOptions}; omitted is the pre-#14766
270+
* call and refuses a delivered window
271+
* @throws `RESOURCE_CONFLICT` / 409 when the window's claim succeeded and
272+
* `options.force` is not `true`
205273
*/
206-
replay?(name: string, data?: unknown): Promise<void>;
274+
replay?(name: string, data?: unknown, options?: JobReplayOptions): Promise<void>;
207275

208276
/**
209277
* List executions filtered by status across all jobs (admin/observability).

0 commit comments

Comments
 (0)