@objectstack/trigger-schedule 17.3.0 → 17.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,195 @@
1
1
  # @objectstack/plugin-trigger-schedule
2
2
 
3
+ ## 17.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 8341ed2: A scheduled (cron) flow is now delivered once per tick window, and replaying a window that was already delivered is refused instead of silently sent again.
8
+
9
+ A `time_relative` flow has taken a persisted dispatch claim per `(flow, window, record)` since #10220, so per-record once-only delivery is free for it. A `schedule` flow runs once per tick with no record and had no claim surface at all, so "this batch already went out" fell back to whatever each app remembered for itself. A scheduled digest that was replayed by an operator, or whose process restarted inside its window, delivered twice.
10
+
11
+ Scheduled flows now claim `(flow, tick-window)` in the same `sys_flow_dispatch` ledger, and settle that claim with what the run turned into:
12
+
13
+ - **A second fire inside one window does nothing.** The window key is a pure function of the schedule descriptor and the clock — the previous occurrence of the very same cron expression in the very same timezone, computed with the same library the job adapter schedules with — so a restart inside the window computes the same key and hits the same claim.
14
+ - **`IJobService.replay()` refuses a delivered window**, with the ADR-0112 envelope its contract declares: `code: 'RESOURCE_CONFLICT'`, `status: 409`, and a message naming the window and the claim that refused it. The promise rejects — an operator who presses replay and sees nothing happen is exactly the outcome this replaces.
15
+ - **`replay(name, data, { force: true })` sends anyway.** The duplicate is the operator's, taken knowingly.
16
+ - **A window whose claim is absent, failed or unsettled re-runs** on a plain `replay()`, with no force needed. A job that takes no claim at all — every job that is not a scheduled flow — is the absent row and behaves exactly as before.
17
+ - **`succeeded` is absorbing.** A replay that repairs a failed window records `succeeded`, so the next unforced replay is refused. A *forced* replay that throws leaves the window recorded delivered rather than rewriting it to `failed` — otherwise a failed re-send would silently reopen the unforced re-delivery door. An operator whose forced replay failed forces again.
18
+ - **A `once` schedule now has a tick window too** — the single instant it is due, which is one window for the job's whole life. The visible consequence is on replay: an operator who replays a one-shot job *before* its due instant claims that single window, so the real fire then finds the claim and does nothing. Previously both ran.
19
+
20
+ The error-isolation `catch` that keeps a throwing flow from crashing the ticker is unchanged and still swallows. What it no longer does is leave the run indistinguishable from a delivered one: the throw settles the window's claim as `failed`, so a replay repairs it.
21
+
22
+ `sys_flow_dispatch` gains two optional columns, `outcome` and `settled_at`. Rows written before this release read as unsettled, which reads as not delivered — the safe direction, since a replay of one re-runs rather than being refused. Only `schedule:` claims are ever settled; a `time_relative` sweep's rows stay `null` by design.
23
+
24
+ ⚠️ **If you manage this table's DDL out of band** — anything other than letting the platform sync `sys_flow_dispatch` from its object definition — add `outcome` (text) and `settled_at` (datetime) yourself before upgrading. Without them every `settle()` throws against the driver. Dispatch dedup still works and no flow fails (the settle is best-effort and logged), but no claim ever records an outcome, so the replay refusal never fires and this release's headline change is silently absent.
25
+
26
+ Interface changes for hosts that implement the ledger themselves:
27
+
28
+ - `FlowDispatchStore` gains **optional** `settle()` and `read()`. A store without them still deduplicates; it announces once that the refusal cannot fire.
29
+ - `FlowDispatchStoreEngine` — the narrow ObjectQL slice the bundled store demands — now **requires** `update` alongside `find` and `insert`. A custom engine adapter typed against it must add the method.
30
+ - New exported types: `FlowDispatchClaim` and `FlowDispatchOutcome` from `@objectstack/service-automation`; `ReplayGuard` and `ReplayGuardDecision` from `@objectstack/service-job` (the parameter type of `DbJobAdapter.setReplayGuard`, exported so it can be named); `ScheduleDispatchLedger`, `ScheduleDispatchClaim`, `ScheduleDispatchOutcome`, `ReplayGuard` and `ReplayGuardDecision` from `@objectstack/trigger-schedule`.
31
+ - `croner` moves from a devDependency to a **dependency** of `@objectstack/trigger-schedule`, which now imports it at runtime to compute the cron tick window. It is already a runtime dependency of `@objectstack/service-job` at the same range, so the platform's dependency set does not grow.
32
+
33
+ ### Patch Changes
34
+
35
+ - b224324: `@objectstack/trigger-record-change` and `@objectstack/trigger-schedule` now declare a `repository.directory` that resolves to the directory they actually live in.
36
+
37
+ Both manifests declared a path under `packages/plugins/` that no longer exists in the repository:
38
+
39
+ | package | declared | actual |
40
+ |---|---|---|
41
+ | `@objectstack/trigger-record-change` | `packages/plugins/plugin-trigger-record-change` | `packages/triggers/trigger-record-change` |
42
+ | `@objectstack/trigger-schedule` | `packages/plugins/plugin-trigger-schedule` | `packages/triggers/trigger-schedule` |
43
+
44
+ `repository.directory` is what npm uses to build the **Repository** deep link on a package page, and what tooling uses to locate a monorepo package's source from its tarball. Pointing it at a path that does not exist sends a reader to a 404 instead of to the source — on packages published today at `17.3.0`. The value ships inside the tarball, so this correction only reaches npm by being published; that is why it carries a changeset rather than `skip-changeset`.
45
+
46
+ The residue came from a three-commit sequence on 2026-06-12, and only one of those commits was a pure rename. `f15d6f6f6` **copied** the two packages to `packages/plugins/trigger-*` (26 files, +2222/-19, with all four directories briefly coexisting) and edited exactly one line of each copied manifest — its `name` — leaving `directory` pointing at the path it was copied from; `290c62514` deleted the originals five minutes later; and `ea4941ad8` then promoted `packages/plugins/trigger-*` to a first-class `packages/triggers/` directory as a pure 16-file rename with zero content changes, which made the declared value wrong in a second segment. Six weeks after that, `9a43e042f` (#3380) rewrote `repository.url` and `bugs` in both of these manifests, with the stale `directory` line sitting as unchanged context one line below the edited `url`. So the field was not merely never in anyone's way: one commit edited its immediate neighbour inside the same object, and a later reviewed hunk had the wrong line on screen. Nothing caught it because nothing reads it.
47
+
48
+ Scope of this change, stated as a measured set rather than a general claim: over all **81** tracked `package.json` files in the repository, **57** declare `repository.directory`; before this change **55** resolved to the manifest's own directory and **2** did not — the two above. After it, **57 of 57** resolve. No other manifest field is edited, and no package's code, exports or behaviour is touched. The remaining **24** manifests declare no `repository.directory` at all; that population is deliberately left alone here and is reported separately, because whether declaring the field is mandatory is a policy question rather than a correction.
49
+ - Updated dependencies [fe0d9a4]
50
+ - Updated dependencies [ecd2158]
51
+ - Updated dependencies [f2b5e46]
52
+ - Updated dependencies [2ed6be6]
53
+ - Updated dependencies [ed7243d]
54
+ - Updated dependencies [6ba0db4]
55
+ - Updated dependencies [625b0c3]
56
+ - Updated dependencies [233222e]
57
+ - Updated dependencies [07f40e5]
58
+ - Updated dependencies [ceb4877]
59
+ - Updated dependencies [e9fcd6b]
60
+ - Updated dependencies [90e7e6d]
61
+ - Updated dependencies [2bdabe6]
62
+ - Updated dependencies [ca326b5]
63
+ - Updated dependencies [8f404a5]
64
+ - Updated dependencies [68437d4]
65
+ - Updated dependencies [abb140c]
66
+ - Updated dependencies [8333a6c]
67
+ - Updated dependencies [3e3ecb0]
68
+ - Updated dependencies [3030369]
69
+ - Updated dependencies [d5d8d50]
70
+ - Updated dependencies [e08892d]
71
+ - Updated dependencies [ae05f2e]
72
+ - Updated dependencies [b548e43]
73
+ - Updated dependencies [c463d03]
74
+ - Updated dependencies [64bd6a3]
75
+ - Updated dependencies [13c48c2]
76
+ - Updated dependencies [b0529e1]
77
+ - Updated dependencies [66dc6ab]
78
+ - Updated dependencies [6f94458]
79
+ - Updated dependencies [6e67b86]
80
+ - Updated dependencies [132742f]
81
+ - Updated dependencies [85a2459]
82
+ - Updated dependencies [50dc214]
83
+ - Updated dependencies [e89fa92]
84
+ - Updated dependencies [e9fcd6b]
85
+ - Updated dependencies [8976ea1]
86
+ - Updated dependencies [56fe8c2]
87
+ - Updated dependencies [acabd24]
88
+ - Updated dependencies [ab50c8f]
89
+ - Updated dependencies [6491463]
90
+ - Updated dependencies [89cf4d6]
91
+ - Updated dependencies [21c5dcb]
92
+ - Updated dependencies [6d4d5d3]
93
+ - Updated dependencies [ed5d557]
94
+ - Updated dependencies [bca21f7]
95
+ - Updated dependencies [e9fcd6b]
96
+ - Updated dependencies [2025b1f]
97
+ - Updated dependencies [1a7a7c9]
98
+ - Updated dependencies [e9fcd6b]
99
+ - Updated dependencies [ef3a138]
100
+ - Updated dependencies [68d5dfd]
101
+ - Updated dependencies [3e21cf0]
102
+ - Updated dependencies [4cfc93b]
103
+ - Updated dependencies [efd6b43]
104
+ - Updated dependencies [859ded3]
105
+ - Updated dependencies [fa125f3]
106
+ - Updated dependencies [74628d9]
107
+ - Updated dependencies [a646120]
108
+ - Updated dependencies [6f1ce7d]
109
+ - Updated dependencies [7778115]
110
+ - Updated dependencies [2c753fe]
111
+ - Updated dependencies [52804cd]
112
+ - Updated dependencies [3f89967]
113
+ - Updated dependencies [53cf263]
114
+ - Updated dependencies [21aabbc]
115
+ - Updated dependencies [9c270bb]
116
+ - Updated dependencies [76c8c5a]
117
+ - Updated dependencies [a84e1ce]
118
+ - Updated dependencies [bf1054a]
119
+ - Updated dependencies [d8d2776]
120
+ - Updated dependencies [222dc0f]
121
+ - Updated dependencies [e9fcd6b]
122
+ - Updated dependencies [32c917d]
123
+ - Updated dependencies [f9a3c32]
124
+ - Updated dependencies [f502898]
125
+ - Updated dependencies [51ae731]
126
+ - Updated dependencies [af7edfe]
127
+ - Updated dependencies [b60f48b]
128
+ - Updated dependencies [c78c918]
129
+ - Updated dependencies [cf9bda4]
130
+ - Updated dependencies [784cb92]
131
+ - Updated dependencies [7629f4d]
132
+ - Updated dependencies [51df9fd]
133
+ - Updated dependencies [a7da4de]
134
+ - Updated dependencies [de0bcdd]
135
+ - Updated dependencies [70f7d6d]
136
+ - Updated dependencies [c677cda]
137
+ - Updated dependencies [554a160]
138
+ - Updated dependencies [f7da71e]
139
+ - Updated dependencies [7f745c3]
140
+ - Updated dependencies [5eb24f8]
141
+ - Updated dependencies [2a3decc]
142
+ - Updated dependencies [cc00df2]
143
+ - Updated dependencies [cc00df2]
144
+ - Updated dependencies [f4e6adf]
145
+ - Updated dependencies [ee4a59b]
146
+ - Updated dependencies [4db3c61]
147
+ - Updated dependencies [5ca314a]
148
+ - Updated dependencies [e0af1a8]
149
+ - Updated dependencies [4771bd9]
150
+ - Updated dependencies [414c1fc]
151
+ - Updated dependencies [22c0279]
152
+ - Updated dependencies [0db2947]
153
+ - Updated dependencies [92b5d7f]
154
+ - Updated dependencies [613bfbd]
155
+ - Updated dependencies [abae16a]
156
+ - Updated dependencies [094b8fd]
157
+ - Updated dependencies [c7aca0d]
158
+ - Updated dependencies [c1d8f98]
159
+ - Updated dependencies [8e0b297]
160
+ - Updated dependencies [d4f9b2a]
161
+ - Updated dependencies [5f7fa1d]
162
+ - Updated dependencies [87f0ccc]
163
+ - Updated dependencies [aedbaef]
164
+ - Updated dependencies [a727043]
165
+ - Updated dependencies [c5d6803]
166
+ - Updated dependencies [10d05bb]
167
+ - Updated dependencies [69602e5]
168
+ - Updated dependencies [c3ce76c]
169
+ - Updated dependencies [7936b29]
170
+ - Updated dependencies [46803fa]
171
+ - Updated dependencies [c2a336c]
172
+ - Updated dependencies [9f890d3]
173
+ - Updated dependencies [0bb2318]
174
+ - Updated dependencies [f7db8f4]
175
+ - Updated dependencies [1ecee3e]
176
+ - Updated dependencies [9408b7f]
177
+ - Updated dependencies [e9fcd6b]
178
+ - Updated dependencies [9bcd9be]
179
+ - Updated dependencies [b398ad2]
180
+ - Updated dependencies [99261a7]
181
+ - Updated dependencies [81b426f]
182
+ - Updated dependencies [001af1c]
183
+ - Updated dependencies [fb77aa5]
184
+ - Updated dependencies [581d8f8]
185
+ - Updated dependencies [f81afe3]
186
+ - Updated dependencies [40a44b9]
187
+ - Updated dependencies [f89812e]
188
+ - Updated dependencies [7a7fb03]
189
+ - Updated dependencies [8fd246d]
190
+ - @objectstack/spec@17.4.0
191
+ - @objectstack/core@17.4.0
192
+
3
193
  ## 17.3.0
4
194
 
5
195
  ### Patch Changes
package/dist/index.d.mts CHANGED
@@ -61,14 +61,71 @@ interface FlowTrigger {
61
61
  start(binding: FlowTriggerBinding, callback: (ctx: AutomationContext) => Promise<void>): void;
62
62
  stop(flowName: string): void;
63
63
  }
64
+ /**
65
+ * What a {@link ReplayGuard} answers when a job service is about to replay a
66
+ * job it does not itself understand. Structural mirror of the job adapter's
67
+ * own type — see the note on {@link JobServiceSurface}.
68
+ */
69
+ type ReplayGuardDecision = {
70
+ readonly allow: true;
71
+ } | {
72
+ readonly allow: false;
73
+ /** Human-readable identity of the window that was already delivered. */
74
+ readonly window: string;
75
+ /** When that window's claim was taken (ISO-8601), if the ledger knows. */
76
+ readonly claimedAt: string | null;
77
+ };
78
+ /**
79
+ * A per-job pre-flight the job service runs before `replay()` (#14501).
80
+ *
81
+ * It is asked, and it also PREPARES: a guard that answers `{ allow: true }`
82
+ * has already armed whatever its owner needs to let the replayed run through
83
+ * its own idempotency gate. So a job service must call it exactly once per
84
+ * replay, and must not call it for a replay it then abandons.
85
+ */
86
+ type ReplayGuard = (options: {
87
+ readonly force: boolean;
88
+ }) => Promise<ReplayGuardDecision>;
64
89
  /**
65
90
  * The slice of `IJobService` this trigger needs: schedule a named job and
66
91
  * cancel it. Typed structurally so the plugin depends on the spec contract
67
92
  * shape, not a concrete adapter.
93
+ *
94
+ * `setReplayGuard` is OPTIONAL and is NOT part of the `IJobService` spec
95
+ * contract — it is the adapter-local registration `DbJobAdapter` grew for
96
+ * #14501, and a job service without it (the bootstrap `IntervalJobAdapter`,
97
+ * any third-party adapter) simply never installs the guard. That degradation
98
+ * is declared, not silent: see {@link ScheduleTrigger} for what is lost.
68
99
  */
69
100
  interface JobServiceSurface {
70
101
  schedule(name: string, schedule: JobSchedule, handler: JobHandler): Promise<void>;
71
102
  cancel(name: string): Promise<void>;
103
+ setReplayGuard?(name: string, guard: ReplayGuard | null): void;
104
+ }
105
+ /** What a claimed dispatch turned into — mirror of the ledger's own type. */
106
+ type ScheduleDispatchOutcome = 'succeeded' | 'failed';
107
+ /** One dispatch-claim row, as this trigger reads it back. */
108
+ interface ScheduleDispatchClaim {
109
+ readonly outcome: ScheduleDispatchOutcome | null;
110
+ readonly claimedAt: string | null;
111
+ }
112
+ /**
113
+ * The slice of the automation service this trigger needs for once-per-window
114
+ * delivery (#14501): the same `sys_flow_dispatch` claim ledger the
115
+ * time-relative trigger uses for its per-record keys (#10220), plus the
116
+ * outcome half the #14501 ruling added.
117
+ *
118
+ * Typed structurally — like {@link JobServiceSurface} — so this plugin never
119
+ * learns the ledger's table name and takes no build dependency on
120
+ * `@objectstack/service-automation`. `settleDispatch` / `readDispatch` are
121
+ * optional for the same reason `claim` is resolved defensively: an automation
122
+ * service predating either one resolves to a partial surface, and the trigger
123
+ * degrades honestly rather than throwing at bind time.
124
+ */
125
+ interface ScheduleDispatchLedger {
126
+ claim(key: string): Promise<boolean>;
127
+ settleDispatch?(key: string, outcome: ScheduleDispatchOutcome): Promise<void>;
128
+ readDispatch?(key: string): Promise<ScheduleDispatchClaim | null>;
72
129
  }
73
130
  /** Minimal logger surface (matches core's `ctx.logger`). */
74
131
  interface TriggerLogger {
@@ -105,6 +162,26 @@ declare function normalizeSchedule(raw: unknown): JobSchedule | null;
105
162
  * The job service is resolved lazily (per `start()`) via the supplied accessor,
106
163
  * so we always pick up the job service's *upgraded* adapter (e.g. the durable
107
164
  * DbJobAdapter that replaces the bootstrap interval adapter on `kernel:ready`).
165
+ *
166
+ * ## Once-per-window delivery (#14501)
167
+ *
168
+ * A scheduled flow claims a `(flow, tick-window)` key in the shared
169
+ * `sys_flow_dispatch` ledger before it launches, and settles that claim with
170
+ * the run's outcome afterwards — the same ledger the time-relative trigger
171
+ * claims per `(flow, record, window)` (#10220), with the key shape the
172
+ * maintainer's A + a2 ruling named. Three doors close at once:
173
+ *
174
+ * - a second tick inside one window finds the claim and does nothing;
175
+ * - a restart inside a window is that same case, because the key is a pure
176
+ * function of the schedule descriptor and the clock, not of process state;
177
+ * - an operator `replay()` of a window that was DELIVERED is refused with an
178
+ * ADR-0112 `RESOURCE_CONFLICT` / 409 envelope, via the
179
+ * {@link ReplayGuard} this trigger registers on the job service.
180
+ *
181
+ * What did NOT change is the error isolation: a throwing flow is still caught
182
+ * and swallowed so the ticker survives. It stopped being SILENT — the throw
183
+ * settles the window's claim `failed`, and a plain `replay()` re-runs a failed
184
+ * window — but the ticker's protection is unchanged and must stay that way.
108
185
  */
109
186
  declare class ScheduleTrigger implements FlowTrigger {
110
187
  readonly type = "schedule";
@@ -112,8 +189,77 @@ declare class ScheduleTrigger implements FlowTrigger {
112
189
  private readonly logger;
113
190
  /** flowName → job name registered for it, so stop() can cancel it. */
114
191
  private readonly bound;
115
- constructor(getJobService: () => JobServiceSurface | null, logger: TriggerLogger);
192
+ /** Dispatch-claim ledger (#14501), resolved lazily per fire. */
193
+ private readonly getLedger;
194
+ /** Injectable clock so window math is deterministic under test. */
195
+ private readonly now;
196
+ /**
197
+ * flowName → the ONE dispatch key a {@link ReplayGuard} has authorised for
198
+ * re-dispatch (#14501). A replay of a window whose claim is absent or
199
+ * failed must actually re-run it — but the handler's own claim gate would
200
+ * see the existing row and no-op, so the guard leaves a one-shot pass here
201
+ * and the handler consumes it. In-process by construction and correctly
202
+ * so: the pass is written and read inside a single `replay()` call chain.
203
+ *
204
+ * Keyed by FLOW rather than accumulated in a set, so a pass a job service
205
+ * asked for and then abandoned is overwritten by the next one instead of
206
+ * outliving its window — at most one outstanding pass per bound flow, and
207
+ * `stop()` takes it with the binding.
208
+ *
209
+ * ⚠️ Residue is therefore bounded but not zero: an abandoned pass survives
210
+ * until this flow's next guard call replaces it, or `stop()` drops it. A
211
+ * later fire does NOT clear it — the handler deletes the entry only when
212
+ * the pass MATCHES the window it just computed — so an abandoned pass
213
+ * outlives every fire in every other window. It stays inert through all of
214
+ * them for the same reason: a pass naming a window that has passed can
215
+ * never match again. Its blast radius is one fire of one flow inside the
216
+ * window the pass names, and only if that window is still current — a fire
217
+ * that would have been a no-op runs instead.
218
+ */
219
+ private readonly replayPasses;
220
+ /** Whether the in-process-only dedup degradation has been said (once). */
221
+ private claimDegradationWarned;
222
+ /** Whether the "no replay guard could be installed" degradation has been said (once). */
223
+ private replayGuardDegradationWarned;
224
+ constructor(getJobService: () => JobServiceSurface | null, logger: TriggerLogger, getLedger?: () => ScheduleDispatchLedger | null, now?: () => Date);
116
225
  start(binding: FlowTriggerBinding, callback: (ctx: AutomationContext) => Promise<void>): void;
226
+ /**
227
+ * Install the `replay()` pre-flight for this job (#14501), when the job
228
+ * service has somewhere to put one.
229
+ *
230
+ * Degradation contract, declared once: a job service without
231
+ * `setReplayGuard` (the bootstrap `IntervalJobAdapter`, any adapter
232
+ * predating #14501) keeps every other guarantee here — a second tick in a
233
+ * window is still a no-op, a throw is still recorded failed — but an
234
+ * operator replay of a DELIVERED window can no longer be refused loudly.
235
+ * It hits the handler's claim gate and returns having done nothing, which
236
+ * is the silent no-op the ruling exists to prevent, so it is said out loud
237
+ * here instead.
238
+ *
239
+ * ⚠ Said only when a ledger is actually attached. With no ledger nothing is
240
+ * ever RECORDED as delivered, so there is no refusal to lose and the line
241
+ * would be a false alarm — that deployment's real degradation is the
242
+ * "delivery is NOT deduplicated" warning {@link claimDispatch} already
243
+ * emits, and stacking a second, vacuous warning on top of it buries the
244
+ * one that matters.
245
+ */
246
+ private installReplayGuard;
247
+ /**
248
+ * Claim one `(flow, tick-window)` dispatch key (#14501): `true` = launch,
249
+ * `false` = this window was already dispatched (an earlier tick this
250
+ * process, or a previous process lifetime).
251
+ *
252
+ * Degradation contract, deliberately identical to the time-relative
253
+ * trigger's: a ledger call that THROWS dispatches anyway (availability
254
+ * over strict-once — a broken ledger must never silently swallow a
255
+ * digest), and a missing ledger is warned once because the once-per-window
256
+ * guarantee then no longer survives a kernel rebuild.
257
+ */
258
+ private claimDispatch;
259
+ /** Record what a dispatch turned into. Best-effort: never fails the run. */
260
+ private settleDispatch;
261
+ /** Read one dispatch claim. A ledger that cannot answer reports `null`. */
262
+ private readDispatch;
117
263
  stop(flowName: string): void;
118
264
  }
119
265
 
@@ -304,4 +450,4 @@ declare class TimeRelativeTrigger implements FlowTrigger {
304
450
  stop(flowName: string): void;
305
451
  }
306
452
 
307
- export { type DateWindow, type FlowDispatchClaimSurface, type FlowTrigger, type FlowTriggerBinding, type JobServiceSurface, ScheduleTrigger, ScheduleTriggerPlugin, type TimeRelativeDataEngine, TimeRelativeTrigger, TimeRelativeTriggerPlugin, type TriggerLogger, type WindowClaimScope, buildWindowWhere, computeDateWindows, computeWindowClaimScopes, normalizeSchedule };
453
+ export { type DateWindow, type FlowDispatchClaimSurface, type FlowTrigger, type FlowTriggerBinding, type JobServiceSurface, type ReplayGuard, type ReplayGuardDecision, type ScheduleDispatchClaim, type ScheduleDispatchLedger, type ScheduleDispatchOutcome, ScheduleTrigger, ScheduleTriggerPlugin, type TimeRelativeDataEngine, TimeRelativeTrigger, TimeRelativeTriggerPlugin, type TriggerLogger, type WindowClaimScope, buildWindowWhere, computeDateWindows, computeWindowClaimScopes, normalizeSchedule };
package/dist/index.d.ts CHANGED
@@ -61,14 +61,71 @@ interface FlowTrigger {
61
61
  start(binding: FlowTriggerBinding, callback: (ctx: AutomationContext) => Promise<void>): void;
62
62
  stop(flowName: string): void;
63
63
  }
64
+ /**
65
+ * What a {@link ReplayGuard} answers when a job service is about to replay a
66
+ * job it does not itself understand. Structural mirror of the job adapter's
67
+ * own type — see the note on {@link JobServiceSurface}.
68
+ */
69
+ type ReplayGuardDecision = {
70
+ readonly allow: true;
71
+ } | {
72
+ readonly allow: false;
73
+ /** Human-readable identity of the window that was already delivered. */
74
+ readonly window: string;
75
+ /** When that window's claim was taken (ISO-8601), if the ledger knows. */
76
+ readonly claimedAt: string | null;
77
+ };
78
+ /**
79
+ * A per-job pre-flight the job service runs before `replay()` (#14501).
80
+ *
81
+ * It is asked, and it also PREPARES: a guard that answers `{ allow: true }`
82
+ * has already armed whatever its owner needs to let the replayed run through
83
+ * its own idempotency gate. So a job service must call it exactly once per
84
+ * replay, and must not call it for a replay it then abandons.
85
+ */
86
+ type ReplayGuard = (options: {
87
+ readonly force: boolean;
88
+ }) => Promise<ReplayGuardDecision>;
64
89
  /**
65
90
  * The slice of `IJobService` this trigger needs: schedule a named job and
66
91
  * cancel it. Typed structurally so the plugin depends on the spec contract
67
92
  * shape, not a concrete adapter.
93
+ *
94
+ * `setReplayGuard` is OPTIONAL and is NOT part of the `IJobService` spec
95
+ * contract — it is the adapter-local registration `DbJobAdapter` grew for
96
+ * #14501, and a job service without it (the bootstrap `IntervalJobAdapter`,
97
+ * any third-party adapter) simply never installs the guard. That degradation
98
+ * is declared, not silent: see {@link ScheduleTrigger} for what is lost.
68
99
  */
69
100
  interface JobServiceSurface {
70
101
  schedule(name: string, schedule: JobSchedule, handler: JobHandler): Promise<void>;
71
102
  cancel(name: string): Promise<void>;
103
+ setReplayGuard?(name: string, guard: ReplayGuard | null): void;
104
+ }
105
+ /** What a claimed dispatch turned into — mirror of the ledger's own type. */
106
+ type ScheduleDispatchOutcome = 'succeeded' | 'failed';
107
+ /** One dispatch-claim row, as this trigger reads it back. */
108
+ interface ScheduleDispatchClaim {
109
+ readonly outcome: ScheduleDispatchOutcome | null;
110
+ readonly claimedAt: string | null;
111
+ }
112
+ /**
113
+ * The slice of the automation service this trigger needs for once-per-window
114
+ * delivery (#14501): the same `sys_flow_dispatch` claim ledger the
115
+ * time-relative trigger uses for its per-record keys (#10220), plus the
116
+ * outcome half the #14501 ruling added.
117
+ *
118
+ * Typed structurally — like {@link JobServiceSurface} — so this plugin never
119
+ * learns the ledger's table name and takes no build dependency on
120
+ * `@objectstack/service-automation`. `settleDispatch` / `readDispatch` are
121
+ * optional for the same reason `claim` is resolved defensively: an automation
122
+ * service predating either one resolves to a partial surface, and the trigger
123
+ * degrades honestly rather than throwing at bind time.
124
+ */
125
+ interface ScheduleDispatchLedger {
126
+ claim(key: string): Promise<boolean>;
127
+ settleDispatch?(key: string, outcome: ScheduleDispatchOutcome): Promise<void>;
128
+ readDispatch?(key: string): Promise<ScheduleDispatchClaim | null>;
72
129
  }
73
130
  /** Minimal logger surface (matches core's `ctx.logger`). */
74
131
  interface TriggerLogger {
@@ -105,6 +162,26 @@ declare function normalizeSchedule(raw: unknown): JobSchedule | null;
105
162
  * The job service is resolved lazily (per `start()`) via the supplied accessor,
106
163
  * so we always pick up the job service's *upgraded* adapter (e.g. the durable
107
164
  * DbJobAdapter that replaces the bootstrap interval adapter on `kernel:ready`).
165
+ *
166
+ * ## Once-per-window delivery (#14501)
167
+ *
168
+ * A scheduled flow claims a `(flow, tick-window)` key in the shared
169
+ * `sys_flow_dispatch` ledger before it launches, and settles that claim with
170
+ * the run's outcome afterwards — the same ledger the time-relative trigger
171
+ * claims per `(flow, record, window)` (#10220), with the key shape the
172
+ * maintainer's A + a2 ruling named. Three doors close at once:
173
+ *
174
+ * - a second tick inside one window finds the claim and does nothing;
175
+ * - a restart inside a window is that same case, because the key is a pure
176
+ * function of the schedule descriptor and the clock, not of process state;
177
+ * - an operator `replay()` of a window that was DELIVERED is refused with an
178
+ * ADR-0112 `RESOURCE_CONFLICT` / 409 envelope, via the
179
+ * {@link ReplayGuard} this trigger registers on the job service.
180
+ *
181
+ * What did NOT change is the error isolation: a throwing flow is still caught
182
+ * and swallowed so the ticker survives. It stopped being SILENT — the throw
183
+ * settles the window's claim `failed`, and a plain `replay()` re-runs a failed
184
+ * window — but the ticker's protection is unchanged and must stay that way.
108
185
  */
109
186
  declare class ScheduleTrigger implements FlowTrigger {
110
187
  readonly type = "schedule";
@@ -112,8 +189,77 @@ declare class ScheduleTrigger implements FlowTrigger {
112
189
  private readonly logger;
113
190
  /** flowName → job name registered for it, so stop() can cancel it. */
114
191
  private readonly bound;
115
- constructor(getJobService: () => JobServiceSurface | null, logger: TriggerLogger);
192
+ /** Dispatch-claim ledger (#14501), resolved lazily per fire. */
193
+ private readonly getLedger;
194
+ /** Injectable clock so window math is deterministic under test. */
195
+ private readonly now;
196
+ /**
197
+ * flowName → the ONE dispatch key a {@link ReplayGuard} has authorised for
198
+ * re-dispatch (#14501). A replay of a window whose claim is absent or
199
+ * failed must actually re-run it — but the handler's own claim gate would
200
+ * see the existing row and no-op, so the guard leaves a one-shot pass here
201
+ * and the handler consumes it. In-process by construction and correctly
202
+ * so: the pass is written and read inside a single `replay()` call chain.
203
+ *
204
+ * Keyed by FLOW rather than accumulated in a set, so a pass a job service
205
+ * asked for and then abandoned is overwritten by the next one instead of
206
+ * outliving its window — at most one outstanding pass per bound flow, and
207
+ * `stop()` takes it with the binding.
208
+ *
209
+ * ⚠️ Residue is therefore bounded but not zero: an abandoned pass survives
210
+ * until this flow's next guard call replaces it, or `stop()` drops it. A
211
+ * later fire does NOT clear it — the handler deletes the entry only when
212
+ * the pass MATCHES the window it just computed — so an abandoned pass
213
+ * outlives every fire in every other window. It stays inert through all of
214
+ * them for the same reason: a pass naming a window that has passed can
215
+ * never match again. Its blast radius is one fire of one flow inside the
216
+ * window the pass names, and only if that window is still current — a fire
217
+ * that would have been a no-op runs instead.
218
+ */
219
+ private readonly replayPasses;
220
+ /** Whether the in-process-only dedup degradation has been said (once). */
221
+ private claimDegradationWarned;
222
+ /** Whether the "no replay guard could be installed" degradation has been said (once). */
223
+ private replayGuardDegradationWarned;
224
+ constructor(getJobService: () => JobServiceSurface | null, logger: TriggerLogger, getLedger?: () => ScheduleDispatchLedger | null, now?: () => Date);
116
225
  start(binding: FlowTriggerBinding, callback: (ctx: AutomationContext) => Promise<void>): void;
226
+ /**
227
+ * Install the `replay()` pre-flight for this job (#14501), when the job
228
+ * service has somewhere to put one.
229
+ *
230
+ * Degradation contract, declared once: a job service without
231
+ * `setReplayGuard` (the bootstrap `IntervalJobAdapter`, any adapter
232
+ * predating #14501) keeps every other guarantee here — a second tick in a
233
+ * window is still a no-op, a throw is still recorded failed — but an
234
+ * operator replay of a DELIVERED window can no longer be refused loudly.
235
+ * It hits the handler's claim gate and returns having done nothing, which
236
+ * is the silent no-op the ruling exists to prevent, so it is said out loud
237
+ * here instead.
238
+ *
239
+ * ⚠ Said only when a ledger is actually attached. With no ledger nothing is
240
+ * ever RECORDED as delivered, so there is no refusal to lose and the line
241
+ * would be a false alarm — that deployment's real degradation is the
242
+ * "delivery is NOT deduplicated" warning {@link claimDispatch} already
243
+ * emits, and stacking a second, vacuous warning on top of it buries the
244
+ * one that matters.
245
+ */
246
+ private installReplayGuard;
247
+ /**
248
+ * Claim one `(flow, tick-window)` dispatch key (#14501): `true` = launch,
249
+ * `false` = this window was already dispatched (an earlier tick this
250
+ * process, or a previous process lifetime).
251
+ *
252
+ * Degradation contract, deliberately identical to the time-relative
253
+ * trigger's: a ledger call that THROWS dispatches anyway (availability
254
+ * over strict-once — a broken ledger must never silently swallow a
255
+ * digest), and a missing ledger is warned once because the once-per-window
256
+ * guarantee then no longer survives a kernel rebuild.
257
+ */
258
+ private claimDispatch;
259
+ /** Record what a dispatch turned into. Best-effort: never fails the run. */
260
+ private settleDispatch;
261
+ /** Read one dispatch claim. A ledger that cannot answer reports `null`. */
262
+ private readDispatch;
117
263
  stop(flowName: string): void;
118
264
  }
119
265
 
@@ -304,4 +450,4 @@ declare class TimeRelativeTrigger implements FlowTrigger {
304
450
  stop(flowName: string): void;
305
451
  }
306
452
 
307
- export { type DateWindow, type FlowDispatchClaimSurface, type FlowTrigger, type FlowTriggerBinding, type JobServiceSurface, ScheduleTrigger, ScheduleTriggerPlugin, type TimeRelativeDataEngine, TimeRelativeTrigger, TimeRelativeTriggerPlugin, type TriggerLogger, type WindowClaimScope, buildWindowWhere, computeDateWindows, computeWindowClaimScopes, normalizeSchedule };
453
+ export { type DateWindow, type FlowDispatchClaimSurface, type FlowTrigger, type FlowTriggerBinding, type JobServiceSurface, type ReplayGuard, type ReplayGuardDecision, type ScheduleDispatchClaim, type ScheduleDispatchLedger, type ScheduleDispatchOutcome, ScheduleTrigger, ScheduleTriggerPlugin, type TimeRelativeDataEngine, TimeRelativeTrigger, TimeRelativeTriggerPlugin, type TriggerLogger, type WindowClaimScope, buildWindowWhere, computeDateWindows, computeWindowClaimScopes, normalizeSchedule };