@objectstack/trigger-schedule 17.0.0 → 17.2.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,244 @@
1
1
  # @objectstack/plugin-trigger-schedule
2
2
 
3
+ ## 17.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 73d9795: Time-relative sweeps are now idempotent per matched window (#10220). Previously the sweep
8
+ held no cross-tick memory, so every re-scan of the same window re-dispatched the same
9
+ records — a 5s-interval flow minted 15 duplicate reminders in ~70s, and even under a daily
10
+ cron a kernel rebuild re-dispatched the day's window.
11
+
12
+ - `@objectstack/service-automation` — new platform object `sys_flow_dispatch`: a persisted
13
+ dispatch-claim ledger (ADR-0057 telemetry retention, 30 days), registered alongside
14
+ `sys_automation_run` and exposed as `AutomationEngine.claim(key): Promise<boolean>` on
15
+ the automation service surface (check-and-record; a concurrent duplicate insert re-reads
16
+ and reports the key as already claimed). When no ObjectQL engine / registration is
17
+ available the engine degrades to in-process dedup and logs the weakened guarantee once;
18
+ when the ledger errors, the claim falls back to the in-process check for that key so a
19
+ store outage never blocks a dispatch (availability over strict-once).
20
+ - `@objectstack/trigger-schedule` — the time-relative sweep computes a dispatch key from
21
+ the MATCHED WINDOW's identity and claims it before launching: offset mode keys on
22
+ `(flowName, recordId, windowDay, offset)` — so a dateField edit that moves the window
23
+ legitimately re-fires — and range mode keys on `(flowName, recordId, sweepDay,
24
+ rangeSpec)`, preserving the documented `withinDays` semantic ("fires every day the
25
+ record stays in range") while never firing twice in one day. The trigger resolves the
26
+ claim surface structurally from the automation service; without one it dedups
27
+ in-process and warns once.
28
+ - `@objectstack/spec` — `sys_flow_dispatch` added to `PLATFORM_OBJECTS_BY_PACKAGE` under
29
+ `service-automation` (registry conformance).
30
+
31
+ ### Patch Changes
32
+
33
+ - 6ceaa4b: docs: name packages that exist in seven published documents, and gate the class (#10893)
34
+
35
+ A published README ships inside the npm tarball, so an install instruction in one
36
+ reaches every reader of the package. Nine `@objectstack/` names across seven
37
+ published documents named a package that is in **no directory of this repo**, and
38
+ five of those sat on `import` lines inside runnable fences.
39
+
40
+ `check:published-readme-exports` could not see any of it, by construction. It
41
+ resolves a documented import against the package's built type surface through the
42
+ workspace member map, so a specifier that is not a member has no type entry to
43
+ compare against and the gate reads no further — strict about a member that exists,
44
+ silent about one that does not. The gate now makes the member-existence claim
45
+ first: an `@objectstack/`-scoped specifier that names no workspace member is a
46
+ finding, and the run header prints the scoped population as `N/N` so a recogniser
47
+ that stops matching shows up as a denominator that fell.
48
+
49
+ What each dead claim now says, and why:
50
+
51
+ - **`@objectstack/trigger-schedule`** and **`@objectstack/trigger-record-change`**
52
+ each misnamed **themselves**. Both READMEs — including their `# ` titles and
53
+ every fenced import — said `@objectstack/plugin-trigger-…`, a name that has
54
+ never been published. The exported class names (`ScheduleTriggerPlugin`,
55
+ `TimeRelativeTriggerPlugin`, `RecordChangeTriggerPlugin`) were correct all
56
+ along; only the package name was wrong, so this is a rename pinned by each
57
+ package's own `name` field.
58
+ - **`@objectstack/plugin-security`** told readers to `install
59
+ @objectstack/plugin-org-scoping` and register an `OrgScopingPlugin` from it. No
60
+ such package exists. The organization wall ships as the enterprise
61
+ `@objectstack/organizations` runtime, whose `OrganizationsPlugin` registers the
62
+ `org-scoping` service this plugin probes — the name `objectstack serve` and
63
+ `objectstack doctor` both print. Asking for the wall without it is a refusal to
64
+ boot (ADR-0093 D5), not a silent downgrade, and the page now says so. The
65
+ tenant-isolation bullet pointed at `@objectstack/service-tenant`, which is the
66
+ cloud control-plane runtime from the separate `cloud` repository and not where
67
+ the wall comes from either.
68
+ - **`@objectstack/service-package`** described packages being "delivered to
69
+ runtime kernels that load them through `@objectstack/service-marketplace`". That
70
+ package was never built: ADR-0003, ADR-0016 and ADR-0025 all name it as future
71
+ work. The loading half that does exist here is
72
+ `@objectstack/cloud-connection`'s `MarketplaceInstallLocalPlugin`.
73
+ - **`@objectstack/embedder-openai`** had a fenced example importing
74
+ `KnowledgeTursoPlugin` from `@objectstack/knowledge-turso` — the worst shape,
75
+ because a reader pastes it. No knowledge adapter in this repository consumes an
76
+ `IEmbedder` at all: `knowledge-memory` and `knowledge-ragflow` take no embedder
77
+ option, and the adapters the contract is written for are not here. The example
78
+ is now the `embed()` surface that does exist, with the gap stated rather than
79
+ papered over with a substitute package name.
80
+ - **`@objectstack/driver-sqlite-wasm`**'s "When to use" table compared it against
81
+ `@objectstack/driver-sqlite` and `@objectstack/driver-postgres`. Neither has
82
+ ever existed; `@objectstack/driver-sql` covers PostgreSQL, MySQL and SQLite
83
+ through Knex, choosing the client from its optional peers.
84
+ - **`@objectstack/spec`**'s published `prompts/architecture.md` instructed code
85
+ generators to write `import { User } from '@objectstack/protocol'`. The package
86
+ is `@objectstack/spec`, which the same sentence names as the path being
87
+ replaced.
88
+
89
+ Four `@objectstack/` names that are **not** in this repo are deliberately left as
90
+ they are, because prose may name a package this repo does not build and a runnable
91
+ import may not: `@objectstack/security-enterprise` (the enterprise edition, whose
92
+ install hint the CLI prints and a CLI test pins), `@objectstack/service-tenant`
93
+ (the cloud runtime), `@objectstack/framework` (the umbrella install name), and the
94
+ two names `service-datasource`'s README recalls as its own past.
95
+ - Updated dependencies [6936d07]
96
+ - Updated dependencies [59eb04d]
97
+ - Updated dependencies [9f05b7d]
98
+ - Updated dependencies [3b2af5e]
99
+ - Updated dependencies [7d2d112]
100
+ - Updated dependencies [5fa0d72]
101
+ - Updated dependencies [02b3b07]
102
+ - Updated dependencies [914c413]
103
+ - Updated dependencies [55809a0]
104
+ - Updated dependencies [ee2ff45]
105
+ - Updated dependencies [47cd3ec]
106
+ - Updated dependencies [52db1d1]
107
+ - Updated dependencies [5649efb]
108
+ - Updated dependencies [9d7d2de]
109
+ - Updated dependencies [c815c50]
110
+ - Updated dependencies [795ea05]
111
+ - Updated dependencies [2306a76]
112
+ - Updated dependencies [e5ea701]
113
+ - Updated dependencies [a40dcc1]
114
+ - Updated dependencies [def0d3e]
115
+ - Updated dependencies [8d0bb79]
116
+ - Updated dependencies [5acb58d]
117
+ - Updated dependencies [2e3cf95]
118
+ - Updated dependencies [4c93387]
119
+ - Updated dependencies [504c8d5]
120
+ - Updated dependencies [a037f7c]
121
+ - Updated dependencies [3ee8ddf]
122
+ - Updated dependencies [16cef97]
123
+ - Updated dependencies [a79bd35]
124
+ - Updated dependencies [6ceaa4b]
125
+ - Updated dependencies [15ea214]
126
+ - Updated dependencies [de19489]
127
+ - Updated dependencies [c684d00]
128
+ - Updated dependencies [923c424]
129
+ - Updated dependencies [1ec36b7]
130
+ - Updated dependencies [5f2e54c]
131
+ - Updated dependencies [189373b]
132
+ - Updated dependencies [35ad101]
133
+ - Updated dependencies [ceb33a9]
134
+ - Updated dependencies [73d9795]
135
+ - Updated dependencies [8012960]
136
+ - Updated dependencies [f34f56b]
137
+ - Updated dependencies [f399618]
138
+ - Updated dependencies [75e9301]
139
+ - Updated dependencies [2810695]
140
+ - @objectstack/spec@17.2.0
141
+ - @objectstack/core@17.2.0
142
+
143
+ ## 17.1.0
144
+
145
+ ### Patch Changes
146
+
147
+ - Updated dependencies [56656aa]
148
+ - Updated dependencies [07e630e]
149
+ - Updated dependencies [2f65b1b]
150
+ - Updated dependencies [720ee95]
151
+ - Updated dependencies [f287435]
152
+ - Updated dependencies [2782805]
153
+ - Updated dependencies [e43d63a]
154
+ - Updated dependencies [9aa8890]
155
+ - Updated dependencies [7c9c1dd]
156
+ - Updated dependencies [75b7c24]
157
+ - Updated dependencies [d5552ca]
158
+ - Updated dependencies [d9813a9]
159
+ - Updated dependencies [8640fb2]
160
+ - Updated dependencies [2420641]
161
+ - Updated dependencies [2ad91c3]
162
+ - Updated dependencies [f57fb38]
163
+ - Updated dependencies [00777a0]
164
+ - Updated dependencies [d491625]
165
+ - Updated dependencies [420804d]
166
+ - Updated dependencies [716ac9b]
167
+ - Updated dependencies [a38408a]
168
+ - Updated dependencies [62b1427]
169
+ - Updated dependencies [7ea1372]
170
+ - Updated dependencies [23abe27]
171
+ - Updated dependencies [985a9cd]
172
+ - Updated dependencies [5f5e234]
173
+ - Updated dependencies [a8189ae]
174
+ - Updated dependencies [26e70fb]
175
+ - Updated dependencies [42b05af]
176
+ - Updated dependencies [2b292ce]
177
+ - Updated dependencies [abcf853]
178
+ - Updated dependencies [8b9eba5]
179
+ - Updated dependencies [d575779]
180
+ - Updated dependencies [94f7ef8]
181
+ - Updated dependencies [c5ac5e4]
182
+ - Updated dependencies [a777944]
183
+ - Updated dependencies [dd88e1c]
184
+ - Updated dependencies [856527c]
185
+ - Updated dependencies [870f710]
186
+ - Updated dependencies [79c46da]
187
+ - Updated dependencies [7ff3975]
188
+ - Updated dependencies [29d055b]
189
+ - Updated dependencies [65589d6]
190
+ - Updated dependencies [2c86fe3]
191
+ - Updated dependencies [e196c6a]
192
+ - Updated dependencies [24173e9]
193
+ - Updated dependencies [4ab7523]
194
+ - Updated dependencies [19539b4]
195
+ - Updated dependencies [f8eb736]
196
+ - Updated dependencies [11b779e]
197
+ - Updated dependencies [739fe5b]
198
+ - Updated dependencies [4bfe1a5]
199
+ - Updated dependencies [2065e31]
200
+ - Updated dependencies [b69d0f5]
201
+ - Updated dependencies [4d47afe]
202
+ - Updated dependencies [e4e5c6e]
203
+ - Updated dependencies [9a56784]
204
+ - Updated dependencies [d00d2f6]
205
+ - Updated dependencies [df0c12d]
206
+ - Updated dependencies [d31785f]
207
+ - Updated dependencies [c308a4f]
208
+ - Updated dependencies [e2899f6]
209
+ - Updated dependencies [3851f87]
210
+ - Updated dependencies [2a29caa]
211
+ - Updated dependencies [09a6eee]
212
+ - Updated dependencies [1a7f907]
213
+ - Updated dependencies [cd455c8]
214
+ - Updated dependencies [e1bb0ca]
215
+ - Updated dependencies [30d3752]
216
+ - Updated dependencies [c80e7ae]
217
+ - Updated dependencies [09a9a8a]
218
+ - Updated dependencies [07026cf]
219
+ - Updated dependencies [5d4f3d5]
220
+ - Updated dependencies [4d80e8b]
221
+ - Updated dependencies [30b1c63]
222
+ - Updated dependencies [079b457]
223
+ - Updated dependencies [e43b211]
224
+ - Updated dependencies [890b38f]
225
+ - Updated dependencies [8bee54b]
226
+ - Updated dependencies [7a537ce]
227
+ - Updated dependencies [593c4bf]
228
+ - Updated dependencies [ff08691]
229
+ - Updated dependencies [60e0f90]
230
+ - Updated dependencies [90c5285]
231
+ - Updated dependencies [402c125]
232
+ - Updated dependencies [7901b2d]
233
+ - Updated dependencies [56bca91]
234
+ - Updated dependencies [79394d7]
235
+ - Updated dependencies [730fd9a]
236
+ - Updated dependencies [44bc51d]
237
+ - Updated dependencies [73cfddf]
238
+ - Updated dependencies [d634e66]
239
+ - @objectstack/spec@17.1.0
240
+ - @objectstack/core@17.1.0
241
+
3
242
  ## 17.0.0
4
243
 
5
244
  ### Patch Changes
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # @objectstack/plugin-trigger-schedule
1
+ # @objectstack/trigger-schedule
2
2
 
3
3
  Auto-launch ObjectStack flows on a schedule (cron / interval / once).
4
4
 
@@ -9,7 +9,7 @@ platform `IJobService` (the `'job'` service), so it stays adapter-agnostic: the
9
9
  job service selects a cron-capable adapter (e.g. the durable `DbJobAdapter` or
10
10
  `CronJobAdapter`) for cron schedules and the interval adapter for the rest.
11
11
 
12
- This is the sibling of `@objectstack/plugin-trigger-record-change` — same
12
+ This is the sibling of `@objectstack/trigger-record-change` — same
13
13
  engine baseline, a different event source.
14
14
 
15
15
  ## What it does
@@ -48,7 +48,7 @@ in its context.
48
48
  ```ts
49
49
  import { AutomationServicePlugin } from '@objectstack/service-automation';
50
50
  import { JobServicePlugin } from '@objectstack/service-job';
51
- import { ScheduleTriggerPlugin } from '@objectstack/plugin-trigger-schedule';
51
+ import { ScheduleTriggerPlugin } from '@objectstack/trigger-schedule';
52
52
 
53
53
  kernel
54
54
  .use(new AutomationServicePlugin()) // engine + flows
@@ -110,7 +110,7 @@ It needs both the job service (sweep cadence) **and** the ObjectQL engine (the
110
110
  date-window query); register it alongside the schedule trigger:
111
111
 
112
112
  ```ts
113
- import { ScheduleTriggerPlugin, TimeRelativeTriggerPlugin } from '@objectstack/plugin-trigger-schedule';
113
+ import { ScheduleTriggerPlugin, TimeRelativeTriggerPlugin } from '@objectstack/trigger-schedule';
114
114
 
115
115
  kernel
116
116
  .use(new ScheduleTriggerPlugin()) // plain schedule flows
package/dist/index.d.mts CHANGED
@@ -170,6 +170,18 @@ interface TimeRelativeDataEngine {
170
170
  */
171
171
  getObject?(name: string): unknown;
172
172
  }
173
+ /**
174
+ * The slice of the automation service this trigger needs for dispatch
175
+ * idempotency (#10220): claim a dispatch key against the persisted
176
+ * `sys_flow_dispatch` ledger. `true` = this caller owns the dispatch; `false` =
177
+ * an earlier sweep (possibly in a previous process lifetime) already made it.
178
+ * Typed structurally — like {@link TimeRelativeDataEngine} — so this plugin
179
+ * never learns the ledger's table name and takes no build dependency on
180
+ * `@objectstack/service-automation`.
181
+ */
182
+ interface FlowDispatchClaimSurface {
183
+ claim(key: string): Promise<boolean>;
184
+ }
173
185
  /** A closed, inclusive instant window `[gte, lte]` as ISO-8601 strings. */
174
186
  interface DateWindow {
175
187
  /** Lower bound (inclusive), ISO-8601. */
@@ -190,6 +202,32 @@ interface DateWindow {
190
202
  * `date` field (compared as `YYYY-MM-DD` after the driver truncates) is inclusive.
191
203
  */
192
204
  declare function computeDateWindows(desc: TimeRelativeTrigger$1, now: Date): DateWindow[];
205
+ /**
206
+ * A date window paired with the **claim scope** naming its identity for the
207
+ * dispatch dedup key (#10220, maintainer ruling 2026-08-20).
208
+ */
209
+ interface WindowClaimScope {
210
+ window: DateWindow;
211
+ /**
212
+ * Window-identity fragment of the dispatch key — what makes a re-scan of
213
+ * the SAME window dedup while a genuinely new window fires again:
214
+ *
215
+ * - offset mode → `<windowDay>:offset<n>`: the window day is the date the
216
+ * record's field must fall on, so editing the field to a new day (or a
217
+ * different offset matching) yields a new key and legitimately re-fires.
218
+ * Re-scans of one window all derive the same day → deduped.
219
+ * - range mode → `<sweepDay>:within<n>`: keyed on the SWEEP day, not the
220
+ * (constant) field value, so the documented `withinDays` semantic —
221
+ * "fires every day the record stays in range" — remains true: each new
222
+ * day is a new key, but never twice in one day.
223
+ */
224
+ scope: string;
225
+ }
226
+ /**
227
+ * {@link computeDateWindows}, with each window's claim scope (#10220). One
228
+ * derivation for both so the matching rule and the dedup key can never drift.
229
+ */
230
+ declare function computeWindowClaimScopes(desc: TimeRelativeTrigger$1, now: Date): WindowClaimScope[];
193
231
  /**
194
232
  * Build the ObjectQL `where` map for one date window: the descriptor's static
195
233
  * `filter` (if any) ANDed with a `$gte`/`$lte` range on the date field. The map
@@ -229,7 +267,17 @@ declare class TimeRelativeTrigger implements FlowTrigger {
229
267
  private readonly now;
230
268
  /** flowName → job name registered for it, so stop() can cancel it. */
231
269
  private readonly bound;
232
- constructor(getJobService: () => JobServiceSurface | null, getDataEngine: () => TimeRelativeDataEngine | null, logger: TriggerLogger, now?: () => Date);
270
+ /** Dispatch-idempotency claim surface (#10220), resolved lazily per sweep. */
271
+ private readonly getClaimSurface;
272
+ /**
273
+ * In-process claim fallback when no claim surface resolves (#10220):
274
+ * key → claim time (epoch ms), TTL-pruned. Dedups re-scans within THIS
275
+ * process only — which is why falling to it is warned once, below.
276
+ */
277
+ private readonly localClaims;
278
+ /** Whether the in-process-only dedup degradation has been said (once). */
279
+ private claimDegradationWarned;
280
+ constructor(getJobService: () => JobServiceSurface | null, getDataEngine: () => TimeRelativeDataEngine | null, logger: TriggerLogger, now?: () => Date, getClaimSurface?: () => FlowDispatchClaimSurface | null);
233
281
  start(binding: FlowTriggerBinding, callback: (ctx: AutomationContext) => Promise<void>): void;
234
282
  /**
235
283
  * Run one sweep: query each date window, union the matched records (deduped
@@ -237,7 +285,23 @@ declare class TimeRelativeTrigger implements FlowTrigger {
237
285
  * per-record failure is isolated so one bad row never aborts the batch.
238
286
  */
239
287
  private sweep;
288
+ /**
289
+ * Claim one dispatch key (#10220): `true` = launch, `false` = an earlier
290
+ * sweep already dispatched this (flow, record, window).
291
+ *
292
+ * Degradation contract:
293
+ * - Claim surface resolves (the automation service's `claim()`, backed by
294
+ * the persisted `sys_flow_dispatch` ledger) → its answer is used; if the
295
+ * CALL throws, the failure is logged and the dispatch proceeds —
296
+ * availability over strict-once: a broken ledger must never silently
297
+ * swallow reminders.
298
+ * - No claim surface (automation service missing, or one predating
299
+ * `claim()`) → in-process dedup only, warned ONCE: a silent fallback
300
+ * would hide that the once-per-window guarantee no longer survives a
301
+ * kernel rebuild.
302
+ */
303
+ private claimDispatch;
240
304
  stop(flowName: string): void;
241
305
  }
242
306
 
243
- export { type DateWindow, type FlowTrigger, type FlowTriggerBinding, type JobServiceSurface, ScheduleTrigger, ScheduleTriggerPlugin, type TimeRelativeDataEngine, TimeRelativeTrigger, TimeRelativeTriggerPlugin, type TriggerLogger, buildWindowWhere, computeDateWindows, normalizeSchedule };
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 };
package/dist/index.d.ts CHANGED
@@ -170,6 +170,18 @@ interface TimeRelativeDataEngine {
170
170
  */
171
171
  getObject?(name: string): unknown;
172
172
  }
173
+ /**
174
+ * The slice of the automation service this trigger needs for dispatch
175
+ * idempotency (#10220): claim a dispatch key against the persisted
176
+ * `sys_flow_dispatch` ledger. `true` = this caller owns the dispatch; `false` =
177
+ * an earlier sweep (possibly in a previous process lifetime) already made it.
178
+ * Typed structurally — like {@link TimeRelativeDataEngine} — so this plugin
179
+ * never learns the ledger's table name and takes no build dependency on
180
+ * `@objectstack/service-automation`.
181
+ */
182
+ interface FlowDispatchClaimSurface {
183
+ claim(key: string): Promise<boolean>;
184
+ }
173
185
  /** A closed, inclusive instant window `[gte, lte]` as ISO-8601 strings. */
174
186
  interface DateWindow {
175
187
  /** Lower bound (inclusive), ISO-8601. */
@@ -190,6 +202,32 @@ interface DateWindow {
190
202
  * `date` field (compared as `YYYY-MM-DD` after the driver truncates) is inclusive.
191
203
  */
192
204
  declare function computeDateWindows(desc: TimeRelativeTrigger$1, now: Date): DateWindow[];
205
+ /**
206
+ * A date window paired with the **claim scope** naming its identity for the
207
+ * dispatch dedup key (#10220, maintainer ruling 2026-08-20).
208
+ */
209
+ interface WindowClaimScope {
210
+ window: DateWindow;
211
+ /**
212
+ * Window-identity fragment of the dispatch key — what makes a re-scan of
213
+ * the SAME window dedup while a genuinely new window fires again:
214
+ *
215
+ * - offset mode → `<windowDay>:offset<n>`: the window day is the date the
216
+ * record's field must fall on, so editing the field to a new day (or a
217
+ * different offset matching) yields a new key and legitimately re-fires.
218
+ * Re-scans of one window all derive the same day → deduped.
219
+ * - range mode → `<sweepDay>:within<n>`: keyed on the SWEEP day, not the
220
+ * (constant) field value, so the documented `withinDays` semantic —
221
+ * "fires every day the record stays in range" — remains true: each new
222
+ * day is a new key, but never twice in one day.
223
+ */
224
+ scope: string;
225
+ }
226
+ /**
227
+ * {@link computeDateWindows}, with each window's claim scope (#10220). One
228
+ * derivation for both so the matching rule and the dedup key can never drift.
229
+ */
230
+ declare function computeWindowClaimScopes(desc: TimeRelativeTrigger$1, now: Date): WindowClaimScope[];
193
231
  /**
194
232
  * Build the ObjectQL `where` map for one date window: the descriptor's static
195
233
  * `filter` (if any) ANDed with a `$gte`/`$lte` range on the date field. The map
@@ -229,7 +267,17 @@ declare class TimeRelativeTrigger implements FlowTrigger {
229
267
  private readonly now;
230
268
  /** flowName → job name registered for it, so stop() can cancel it. */
231
269
  private readonly bound;
232
- constructor(getJobService: () => JobServiceSurface | null, getDataEngine: () => TimeRelativeDataEngine | null, logger: TriggerLogger, now?: () => Date);
270
+ /** Dispatch-idempotency claim surface (#10220), resolved lazily per sweep. */
271
+ private readonly getClaimSurface;
272
+ /**
273
+ * In-process claim fallback when no claim surface resolves (#10220):
274
+ * key → claim time (epoch ms), TTL-pruned. Dedups re-scans within THIS
275
+ * process only — which is why falling to it is warned once, below.
276
+ */
277
+ private readonly localClaims;
278
+ /** Whether the in-process-only dedup degradation has been said (once). */
279
+ private claimDegradationWarned;
280
+ constructor(getJobService: () => JobServiceSurface | null, getDataEngine: () => TimeRelativeDataEngine | null, logger: TriggerLogger, now?: () => Date, getClaimSurface?: () => FlowDispatchClaimSurface | null);
233
281
  start(binding: FlowTriggerBinding, callback: (ctx: AutomationContext) => Promise<void>): void;
234
282
  /**
235
283
  * Run one sweep: query each date window, union the matched records (deduped
@@ -237,7 +285,23 @@ declare class TimeRelativeTrigger implements FlowTrigger {
237
285
  * per-record failure is isolated so one bad row never aborts the batch.
238
286
  */
239
287
  private sweep;
288
+ /**
289
+ * Claim one dispatch key (#10220): `true` = launch, `false` = an earlier
290
+ * sweep already dispatched this (flow, record, window).
291
+ *
292
+ * Degradation contract:
293
+ * - Claim surface resolves (the automation service's `claim()`, backed by
294
+ * the persisted `sys_flow_dispatch` ledger) → its answer is used; if the
295
+ * CALL throws, the failure is logged and the dispatch proceeds —
296
+ * availability over strict-once: a broken ledger must never silently
297
+ * swallow reminders.
298
+ * - No claim surface (automation service missing, or one predating
299
+ * `claim()`) → in-process dedup only, warned ONCE: a silent fallback
300
+ * would hide that the once-per-window guarantee no longer survives a
301
+ * kernel rebuild.
302
+ */
303
+ private claimDispatch;
240
304
  stop(flowName: string): void;
241
305
  }
242
306
 
243
- export { type DateWindow, type FlowTrigger, type FlowTriggerBinding, type JobServiceSurface, ScheduleTrigger, ScheduleTriggerPlugin, type TimeRelativeDataEngine, TimeRelativeTrigger, TimeRelativeTriggerPlugin, type TriggerLogger, buildWindowWhere, computeDateWindows, normalizeSchedule };
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 };
package/dist/index.js CHANGED
@@ -26,6 +26,7 @@ __export(index_exports, {
26
26
  TimeRelativeTriggerPlugin: () => TimeRelativeTriggerPlugin,
27
27
  buildWindowWhere: () => buildWindowWhere,
28
28
  computeDateWindows: () => computeDateWindows,
29
+ computeWindowClaimScopes: () => computeWindowClaimScopes,
29
30
  normalizeSchedule: () => normalizeSchedule
30
31
  });
31
32
  module.exports = __toCommonJS(index_exports);
@@ -179,6 +180,7 @@ var ScheduleTriggerPlugin = class {
179
180
  var import_automation = require("@objectstack/spec/automation");
180
181
  var JOB_PREFIX2 = "flow-time-relative";
181
182
  var MS_PER_DAY = 864e5;
183
+ var LOCAL_CLAIM_TTL_MS = 48 * 60 * 60 * 1e3;
182
184
  function startOfUtcDay(d) {
183
185
  return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 0, 0, 0, 0));
184
186
  }
@@ -189,18 +191,21 @@ function addUtcDays(d, n) {
189
191
  return new Date(startOfUtcDay(d).getTime() + n * MS_PER_DAY);
190
192
  }
191
193
  function computeDateWindows(desc, now) {
194
+ return computeWindowClaimScopes(desc, now).map((s) => s.window);
195
+ }
196
+ function computeWindowClaimScopes(desc, now) {
192
197
  const today = startOfUtcDay(now);
193
198
  if (desc.offsetDays && desc.offsetDays.length > 0) {
194
199
  return desc.offsetDays.map((offset) => {
195
200
  const day = addUtcDays(today, offset);
196
- return { gte: startOfUtcDay(day).toISOString(), lte: endOfUtcDay(day).toISOString() };
201
+ const window2 = { gte: startOfUtcDay(day).toISOString(), lte: endOfUtcDay(day).toISOString() };
202
+ return { window: window2, scope: `${window2.gte.slice(0, 10)}:offset${offset}` };
197
203
  });
198
204
  }
199
205
  const n = desc.withinDays ?? 0;
200
- if (n >= 0) {
201
- return [{ gte: startOfUtcDay(today).toISOString(), lte: endOfUtcDay(addUtcDays(today, n)).toISOString() }];
202
- }
203
- return [{ gte: startOfUtcDay(addUtcDays(today, n)).toISOString(), lte: endOfUtcDay(today).toISOString() }];
206
+ const sweepDay = today.toISOString().slice(0, 10);
207
+ const window = n >= 0 ? { gte: startOfUtcDay(today).toISOString(), lte: endOfUtcDay(addUtcDays(today, n)).toISOString() } : { gte: startOfUtcDay(addUtcDays(today, n)).toISOString(), lte: endOfUtcDay(today).toISOString() };
208
+ return [{ window, scope: `${sweepDay}:within${n}` }];
204
209
  }
205
210
  function buildWindowWhere(desc, window) {
206
211
  return {
@@ -212,14 +217,23 @@ function errMessage(err) {
212
217
  return err?.message ?? String(err);
213
218
  }
214
219
  var TimeRelativeTrigger = class {
215
- constructor(getJobService, getDataEngine, logger, now = () => /* @__PURE__ */ new Date()) {
220
+ constructor(getJobService, getDataEngine, logger, now = () => /* @__PURE__ */ new Date(), getClaimSurface = () => null) {
216
221
  this.type = "time_relative";
217
222
  /** flowName → job name registered for it, so stop() can cancel it. */
218
223
  this.bound = /* @__PURE__ */ new Map();
224
+ /**
225
+ * In-process claim fallback when no claim surface resolves (#10220):
226
+ * key → claim time (epoch ms), TTL-pruned. Dedups re-scans within THIS
227
+ * process only — which is why falling to it is warned once, below.
228
+ */
229
+ this.localClaims = /* @__PURE__ */ new Map();
230
+ /** Whether the in-process-only dedup degradation has been said (once). */
231
+ this.claimDegradationWarned = false;
219
232
  this.getJobService = getJobService;
220
233
  this.getDataEngine = getDataEngine;
221
234
  this.logger = logger;
222
235
  this.now = now;
236
+ this.getClaimSurface = getClaimSurface;
223
237
  }
224
238
  start(binding, callback) {
225
239
  const raw = binding.config?.timeRelative;
@@ -289,10 +303,10 @@ var TimeRelativeTrigger = class {
289
303
  );
290
304
  return;
291
305
  }
292
- const windows = computeDateWindows(desc, this.now());
306
+ const scopes = computeWindowClaimScopes(desc, this.now());
293
307
  const seenIds = /* @__PURE__ */ new Set();
294
308
  const matched = [];
295
- for (const window of windows) {
309
+ for (const { window, scope } of scopes) {
296
310
  if (matched.length >= maxRecords) break;
297
311
  const where = buildWindowWhere(desc, window);
298
312
  const rows = await engine.find(desc.object, {
@@ -306,7 +320,8 @@ var TimeRelativeTrigger = class {
306
320
  if (seenIds.has(id)) continue;
307
321
  seenIds.add(id);
308
322
  }
309
- matched.push(row);
323
+ const claimKey = id != null ? `time-relative:${flowName}:${scope}:${String(id)}` : null;
324
+ matched.push({ record: row, claimKey });
310
325
  if (matched.length >= maxRecords) break;
311
326
  }
312
327
  }
@@ -317,7 +332,12 @@ var TimeRelativeTrigger = class {
317
332
  }
318
333
  let launched = 0;
319
334
  let failed = 0;
320
- for (const record of matched) {
335
+ let deduped = 0;
336
+ for (const { record, claimKey } of matched) {
337
+ if (claimKey != null && !await this.claimDispatch(flowName, claimKey)) {
338
+ deduped++;
339
+ continue;
340
+ }
321
341
  try {
322
342
  const ctx = {
323
343
  record,
@@ -339,9 +359,51 @@ var TimeRelativeTrigger = class {
339
359
  }
340
360
  }
341
361
  this.logger.debug?.(
342
- `[time-relative] flow '${flowName}' swept '${desc.object}': ${matched.length} matched, ${launched} launched, ${failed} failed`
362
+ `[time-relative] flow '${flowName}' swept '${desc.object}': ${matched.length} matched, ${launched} launched, ${deduped} already dispatched, ${failed} failed`
343
363
  );
344
364
  }
365
+ /**
366
+ * Claim one dispatch key (#10220): `true` = launch, `false` = an earlier
367
+ * sweep already dispatched this (flow, record, window).
368
+ *
369
+ * Degradation contract:
370
+ * - Claim surface resolves (the automation service's `claim()`, backed by
371
+ * the persisted `sys_flow_dispatch` ledger) → its answer is used; if the
372
+ * CALL throws, the failure is logged and the dispatch proceeds —
373
+ * availability over strict-once: a broken ledger must never silently
374
+ * swallow reminders.
375
+ * - No claim surface (automation service missing, or one predating
376
+ * `claim()`) → in-process dedup only, warned ONCE: a silent fallback
377
+ * would hide that the once-per-window guarantee no longer survives a
378
+ * kernel rebuild.
379
+ */
380
+ async claimDispatch(flowName, key) {
381
+ const surface = this.getClaimSurface();
382
+ if (surface && typeof surface.claim === "function") {
383
+ try {
384
+ return await surface.claim(key);
385
+ } catch (err) {
386
+ this.logger.warn(
387
+ `[time-relative] flow '${flowName}' dispatch-claim failed for key '${key}' \u2014 dispatching anyway (availability over strict-once; the same window may re-fire until the claim store recovers): ${errMessage(err)}`
388
+ );
389
+ return true;
390
+ }
391
+ }
392
+ if (!this.claimDegradationWarned) {
393
+ this.claimDegradationWarned = true;
394
+ this.logger.warn(
395
+ `[time-relative] no dispatch-claim surface (automation service missing or without claim()) \u2014 sweep dedup is IN-PROCESS ONLY and will NOT survive a kernel rebuild: the same record/window can re-fire after a restart.`
396
+ );
397
+ }
398
+ const now = this.now().getTime();
399
+ const cutoff = now - LOCAL_CLAIM_TTL_MS;
400
+ for (const [k, t] of this.localClaims) {
401
+ if (t < cutoff) this.localClaims.delete(k);
402
+ }
403
+ if (this.localClaims.has(key)) return false;
404
+ this.localClaims.set(key, now);
405
+ return true;
406
+ }
345
407
  stop(flowName) {
346
408
  const jobName = this.bound.get(flowName);
347
409
  if (!jobName) return;
@@ -389,7 +451,18 @@ var TimeRelativeTriggerPlugin = class {
389
451
  const trigger = new TimeRelativeTrigger(
390
452
  () => this.resolveService(ctx, "job"),
391
453
  () => this.resolveDataEngine(ctx),
392
- ctx.logger
454
+ ctx.logger,
455
+ void 0,
456
+ // default wall clock
457
+ // #10220 — dispatch-idempotency claims go through the SAME
458
+ // automation service this plugin already resolves; the trigger
459
+ // computes the key and never learns the ledger's table name. An
460
+ // automation service predating claim() resolves to null and the
461
+ // trigger degrades (honestly, warned once) to in-process dedup.
462
+ () => {
463
+ const svc = this.resolveService(ctx, "automation");
464
+ return svc && typeof svc.claim === "function" ? svc : null;
465
+ }
393
466
  );
394
467
  automation.registerTrigger(trigger);
395
468
  ctx.logger.info("TimeRelativeTriggerPlugin: time-relative trigger registered");
@@ -414,6 +487,7 @@ var TimeRelativeTriggerPlugin = class {
414
487
  TimeRelativeTriggerPlugin,
415
488
  buildWindowWhere,
416
489
  computeDateWindows,
490
+ computeWindowClaimScopes,
417
491
  normalizeSchedule
418
492
  });
419
493
  //# sourceMappingURL=index.js.map