@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 +190 -0
- package/dist/index.d.mts +148 -2
- package/dist/index.d.ts +148 -2
- package/dist/index.js +208 -2
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +208 -2
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -6
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
|
-
|
|
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
|
-
|
|
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 };
|