@interactive-inc/flume 0.9.4 → 0.10.1

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/dist/time.js CHANGED
@@ -1,13 +1,16 @@
1
- import { a as FlumeStartError, c as safeNormalizeError, i as safeNow, l as safeErrorMessage, n as safeInvokeCallback, o as FlumeParseError, r as FlumeLogger, s as attempt, t as FlumeSource } from "./flume-source.js";
1
+ import { a as safeNow, c as attempt, i as FlumeLogger, l as safeNormalizeError, o as FlumeStartError, r as safeInvokeCallback, s as FlumeParseError, t as FlumeSource, u as safeErrorMessage } from "./flume-source.js";
2
2
  import { t as isRecord } from "./is-record.js";
3
3
  //#region lib/time/parse-cron-field.ts
4
4
  /**
5
5
  * 単一 cron フィールド (minute など) の spec を許可値の Set に展開する。
6
- * 対応: `*` / `* /n` / `a` / `a-b` / `a-b/n` とそれらのカンマ区切り。名前 (JAN, MON) は非対応
6
+ * 対応: `*` / `* /n` / `a` / `a-b` / `a-b/n` とそれらのカンマ区切り。名前 (JAN, MON) は非対応。
7
+ * 空トークン (`"5,"` / `"-5"` / `"/5"` など) は `Number("") === 0` の暗黙変換で
8
+ * 0 に化けるため、数値が期待される位置の空文字列は明示的に拒否する
7
9
  */
8
10
  function parseCronField(spec, min, max) {
9
11
  const values = /* @__PURE__ */ new Set();
10
12
  for (const part of spec.split(",")) {
13
+ if (part === "") return new FlumeParseError(`empty cron list segment: "${spec}"`);
11
14
  const expanded = expandCronPart(part, min, max);
12
15
  if (expanded instanceof FlumeParseError) return expanded;
13
16
  for (const value of expanded) values.add(value);
@@ -16,15 +19,12 @@ function parseCronField(spec, min, max) {
16
19
  return values;
17
20
  }
18
21
  function expandCronPart(part, min, max) {
19
- let range = part;
20
- let step = 1;
21
22
  const slash = part.indexOf("/");
22
- if (slash !== -1) {
23
- range = part.slice(0, slash);
24
- const parsed = Number(part.slice(slash + 1));
25
- if (!Number.isInteger(parsed) || parsed <= 0) return new FlumeParseError(`invalid cron step: "${part}"`);
26
- step = parsed;
27
- }
23
+ const range = slash === -1 ? part : part.slice(0, slash);
24
+ const stepToken = slash === -1 ? null : part.slice(slash + 1);
25
+ if (stepToken === "") return new FlumeParseError(`invalid cron step: "${part}"`);
26
+ const step = stepToken === null ? 1 : Number(stepToken);
27
+ if (!Number.isInteger(step) || step <= 0) return new FlumeParseError(`invalid cron step: "${part}"`);
28
28
  const bounds = resolveBounds(range, min, max);
29
29
  if (bounds instanceof FlumeParseError) return bounds;
30
30
  const numbers = [];
@@ -37,8 +37,11 @@ function resolveBounds(range, min, max) {
37
37
  hi: max
38
38
  };
39
39
  const dash = range.indexOf("-");
40
- const lo = dash === -1 ? Number(range) : Number(range.slice(0, dash));
41
- const hi = dash === -1 ? lo : Number(range.slice(dash + 1));
40
+ const loToken = dash === -1 ? range : range.slice(0, dash);
41
+ const hiToken = dash === -1 ? loToken : range.slice(dash + 1);
42
+ if (loToken === "" || hiToken === "") return new FlumeParseError(`invalid cron range: "${range}"`);
43
+ const lo = Number(loToken);
44
+ const hi = Number(hiToken);
42
45
  if (!Number.isInteger(lo) || !Number.isInteger(hi)) return new FlumeParseError(`invalid cron range: "${range}"`);
43
46
  if (lo < min || hi > max || lo > hi) return new FlumeParseError(`cron value out of range [${min}-${max}]: "${range}"`);
44
47
  return {
@@ -74,20 +77,20 @@ function parseCron(expression) {
74
77
  daysOfMonth,
75
78
  months,
76
79
  daysOfWeek,
77
- domRestricted: fields[2] !== "*",
78
- dowRestricted: fields[4] !== "*"
80
+ domRestricted: !fields[2]?.includes("*"),
81
+ dowRestricted: !fields[4]?.includes("*")
79
82
  };
80
83
  }
81
84
  //#endregion
82
85
  //#region lib/time/cron-next.ts
83
- const MINUTE_MS = 6e4;
86
+ const MINUTE_MS$1 = 6e4;
84
87
  const MAX_ITERATIONS = 5e5;
85
88
  /**
86
89
  * `afterMs` より後の最初の cron マッチ時刻 (epoch ms) を壁時計 (local time) で求める。
87
90
  * 到達不能なら FlumeParseError を返す
88
91
  */
89
92
  function flumeCronNext(cron, afterMs) {
90
- let candidate = Math.floor(afterMs / MINUTE_MS) * MINUTE_MS + MINUTE_MS;
93
+ let candidate = Math.floor(afterMs / MINUTE_MS$1) * MINUTE_MS$1 + MINUTE_MS$1;
91
94
  for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) {
92
95
  const date = new Date(candidate);
93
96
  if (!cron.months.has(date.getMonth() + 1)) {
@@ -103,7 +106,7 @@ function flumeCronNext(cron, afterMs) {
103
106
  continue;
104
107
  }
105
108
  if (!cron.minutes.has(date.getMinutes())) {
106
- candidate += MINUTE_MS;
109
+ candidate += MINUTE_MS$1;
107
110
  continue;
108
111
  }
109
112
  return candidate;
@@ -114,17 +117,48 @@ function matchesDay(cron, date) {
114
117
  const domMatch = cron.daysOfMonth.has(date.getDate());
115
118
  const dowMatch = cron.daysOfWeek.has(date.getDay());
116
119
  if (cron.domRestricted && cron.dowRestricted) return domMatch || dowMatch;
117
- if (cron.domRestricted) return domMatch;
118
- if (cron.dowRestricted) return dowMatch;
119
- return true;
120
+ return domMatch && dowMatch;
121
+ }
122
+ //#endregion
123
+ //#region lib/time/is-dst-duplicate-fire.ts
124
+ const DEDUP_WINDOW_MS = 7200 * 1e3;
125
+ /**
126
+ * DST fall-back では同一壁時計分 (y/m/d/h/min) が 2 つの epoch に存在し、
127
+ * 分単位の epoch ウォークが両方にマッチして二重発火する。fire 直後に計算した
128
+ * 次ターゲットが直近 2 時間の fire のいずれかと同じ壁時計分なら重複と判定する。
129
+ * 複数分にマッチする cron では巻き戻し後の最初の時刻と直前 fire の分が異なるため、
130
+ * 直前 1 件でなく履歴を受け取る
131
+ */
132
+ function isDstDuplicateFire(firedTimes, nextTarget) {
133
+ for (let index = firedTimes.length - 1; index >= 0; index--) {
134
+ const firedAt = firedTimes[index];
135
+ if (firedAt === void 0) continue;
136
+ if (nextTarget <= firedAt) continue;
137
+ if (nextTarget - firedAt > DEDUP_WINDOW_MS) return false;
138
+ if (hasSameLocalMinute(firedAt, nextTarget)) return true;
139
+ }
140
+ return false;
141
+ }
142
+ function hasSameLocalMinute(firedAt, nextTarget) {
143
+ const fired = new Date(firedAt);
144
+ const next = new Date(nextTarget);
145
+ if (fired.getFullYear() !== next.getFullYear()) return false;
146
+ if (fired.getMonth() !== next.getMonth()) return false;
147
+ if (fired.getDate() !== next.getDate()) return false;
148
+ if (fired.getHours() !== next.getHours()) return false;
149
+ return fired.getMinutes() === next.getMinutes();
120
150
  }
121
151
  //#endregion
122
152
  //#region lib/time/time-scheduler.ts
123
153
  const MAX_TIMEOUT_MS = 2e9;
124
154
  const FIRE_TOLERANCE_MS = 1e3;
155
+ const DST_HISTORY_WINDOW_MS = 7200 * 1e3;
156
+ const MAX_DST_SKIPS = 180;
125
157
  /**
126
158
  * cron に従って `onTick` を駆動するタイマーループ。外部接続を持たないため reconnect 不要。
127
- * IO 境界は全て `attempt` 経由で扱い、停止後はコールバックを発火しない
159
+ * IO 境界は全て `attempt` 経由で扱い、停止後はコールバックを発火しない。
160
+ * sleep-wake で複数回分を取り逃した場合は 1 回だけ発火して now まで早送りする
161
+ * (取り逃しは `scheduler.skipped` info で観測可能)
128
162
  */
129
163
  var FlumeTimeScheduler = class {
130
164
  props;
@@ -132,6 +166,7 @@ var FlumeTimeScheduler = class {
132
166
  isStoppedFlag = false;
133
167
  timer = null;
134
168
  target = 0;
169
+ recentFires = [];
135
170
  constructor(props) {
136
171
  this.props = props;
137
172
  this.log = new FlumeLogger({
@@ -143,8 +178,13 @@ var FlumeTimeScheduler = class {
143
178
  get isStopped() {
144
179
  return this.isStoppedFlag;
145
180
  }
146
- start() {
147
- const next = flumeCronNext(this.props.cron, safeNow({ deps: this.props.deps }));
181
+ /**
182
+ * `fromMs` より後の最初のマッチを狙う。catchup と同じ基準時刻を共有できるよう
183
+ * 呼び出し側から 1 つのタイムスタンプを渡す (未指定なら now)
184
+ */
185
+ start(fromMs) {
186
+ const basis = fromMs ?? safeNow({ deps: this.props.deps });
187
+ const next = flumeCronNext(this.props.cron, basis);
148
188
  if (next instanceof FlumeParseError) {
149
189
  this.log.error({
150
190
  action: "cron.no-next",
@@ -159,14 +199,14 @@ var FlumeTimeScheduler = class {
159
199
  message: `next fire at ${new Date(next).toISOString()}`,
160
200
  detail: { target: next }
161
201
  });
162
- this.arm();
163
- return null;
202
+ return this.arm();
164
203
  }
165
204
  stop() {
166
205
  this.isStoppedFlag = true;
167
206
  this.clearTimer();
168
207
  }
169
208
  arm() {
209
+ if (this.isStoppedFlag) return null;
170
210
  this.clearTimer();
171
211
  const delay = Math.max(0, this.target - safeNow({ deps: this.props.deps }));
172
212
  const capped = Math.min(delay, MAX_TIMEOUT_MS);
@@ -178,18 +218,22 @@ var FlumeTimeScheduler = class {
178
218
  error: result
179
219
  });
180
220
  this.timer = null;
181
- return;
221
+ this.isStoppedFlag = true;
222
+ return result;
182
223
  }
183
224
  this.timer = result;
225
+ return null;
184
226
  }
185
227
  onWake() {
186
228
  this.timer = null;
187
229
  if (this.isStoppedFlag) return;
188
230
  if (this.target - safeNow({ deps: this.props.deps }) > FIRE_TOLERANCE_MS) {
189
- this.arm();
231
+ const error = this.arm();
232
+ if (error instanceof Error) this.halt(error);
190
233
  return;
191
234
  }
192
235
  const firedAt = this.target;
236
+ this.rememberFire(firedAt);
193
237
  safeInvokeCallback({
194
238
  fn: () => this.props.onTick(firedAt),
195
239
  onError: (error) => {
@@ -200,17 +244,78 @@ var FlumeTimeScheduler = class {
200
244
  });
201
245
  }
202
246
  });
203
- const next = flumeCronNext(this.props.cron, firedAt);
247
+ if (this.isStoppedFlag) return;
248
+ this.advanceAfterFire(firedAt);
249
+ }
250
+ /**
251
+ * fire 後の次ターゲット計算。sleep 明けで firedAt が過去に沈んでいる場合は
252
+ * now まで早送りして取り逃し分の replay burst を防ぐ (catchup は opt-in の別機構)
253
+ */
254
+ advanceAfterFire(firedAt) {
255
+ const nowMs = safeNow({ deps: this.props.deps });
256
+ const firstAfterFired = flumeCronNext(this.props.cron, firedAt);
257
+ if (firstAfterFired instanceof FlumeParseError) {
258
+ this.halt(firstAfterFired);
259
+ return;
260
+ }
261
+ if (firstAfterFired <= nowMs) this.log.info({
262
+ action: "scheduler.skipped",
263
+ message: `late wake: skipped occurrence(s) between ${new Date(firedAt).toISOString()} and now`,
264
+ detail: {
265
+ latenessMs: nowMs - firedAt,
266
+ firedAt
267
+ }
268
+ });
269
+ const next = firstAfterFired > nowMs ? firstAfterFired : flumeCronNext(this.props.cron, nowMs);
204
270
  if (next instanceof FlumeParseError) {
205
- this.log.error({
206
- action: "cron.no-next",
207
- message: next.message,
208
- error: next
209
- });
271
+ this.halt(next);
210
272
  return;
211
273
  }
212
- this.target = next;
213
- this.arm();
274
+ const deduped = this.skipDstDuplicates(next);
275
+ if (deduped instanceof FlumeParseError) {
276
+ this.halt(deduped);
277
+ return;
278
+ }
279
+ this.target = deduped;
280
+ const armError = this.arm();
281
+ if (armError instanceof Error) this.halt(armError);
282
+ }
283
+ /** cron エラーによる恒久停止。source が接続済みのまま沈黙しないよう onHalt で通知する */
284
+ halt(error) {
285
+ this.isStoppedFlag = true;
286
+ this.clearTimer();
287
+ this.log.error({
288
+ action: "scheduler.halted",
289
+ message: error.message,
290
+ error
291
+ });
292
+ const onHalt = this.props.onHalt;
293
+ if (!onHalt) return;
294
+ safeInvokeCallback({
295
+ fn: () => onHalt(),
296
+ onError: (haltError) => {
297
+ this.log.error({
298
+ action: "scheduler.halt.error",
299
+ message: safeErrorMessage({ error: haltError }),
300
+ error: haltError
301
+ });
302
+ }
303
+ });
304
+ }
305
+ rememberFire(firedAt) {
306
+ this.recentFires.push(firedAt);
307
+ const cutoff = firedAt - DST_HISTORY_WINDOW_MS;
308
+ while (this.recentFires[0] !== void 0 && this.recentFires[0] < cutoff) this.recentFires.shift();
309
+ }
310
+ skipDstDuplicates(initialTarget) {
311
+ let target = initialTarget;
312
+ for (let iteration = 0; iteration < MAX_DST_SKIPS; iteration++) {
313
+ if (!isDstDuplicateFire(this.recentFires, target)) return target;
314
+ const next = flumeCronNext(this.props.cron, target);
315
+ if (next instanceof FlumeParseError) return next;
316
+ target = next;
317
+ }
318
+ return new FlumeParseError(`cron "${this.props.cron.source}" exceeded DST duplicate bound`);
214
319
  }
215
320
  clearTimer() {
216
321
  if (this.timer === null) return;
@@ -225,11 +330,120 @@ var FlumeTimeScheduler = class {
225
330
  }
226
331
  };
227
332
  //#endregion
333
+ //#region lib/time/time-catchup.ts
334
+ const DEFAULT_MISSED_WINDOW_MS = 1440 * 60 * 1e3;
335
+ const MAX_CATCHUP_MATCHES = 1e4;
336
+ const MINUTE_MS = 6e4;
337
+ /**
338
+ * `lastFiredAt` から `now` までに過ぎ去った cron マッチを policy に従って列挙する。
339
+ *
340
+ * - policy.mode === "off" : 常に空
341
+ * - policy.mode === "lastOnly" : 過ぎ去ったマッチの中で最も新しいもの 1 件 (件数上限なし・O(1) メモリ)
342
+ * - policy.mode === "missed" : maxWindowMs (既定 24h) 以内に過ぎ去ったすべてのマッチ。
343
+ * window の起点は `max(lastFiredAt, now - maxWindowMs)`。
344
+ * 10,000 件を超えた場合は古い方を捨てて新しい 10,000 件を返し
345
+ * truncated: true で通知する
346
+ *
347
+ * 到達不能 cron や catastrophic な policy ミス指定の場合は FlumeParseError を返す
348
+ * (catchup 列挙だけで失敗させる。source 本体の起動は別判断)
349
+ */
350
+ function flumeCollectCatchupMatches(props) {
351
+ const policy = props.policy;
352
+ if (policy.mode === "off") return {
353
+ matches: [],
354
+ truncated: false
355
+ };
356
+ if (props.lastFiredAt >= props.now) return {
357
+ matches: [],
358
+ truncated: false
359
+ };
360
+ if (policy.mode === "lastOnly") return collectLastOnly({
361
+ cron: props.cron,
362
+ windowStart: props.lastFiredAt,
363
+ now: props.now
364
+ });
365
+ const windowStart = Math.max(props.lastFiredAt, props.now - (policy.maxWindowMs ?? DEFAULT_MISSED_WINDOW_MS));
366
+ return collectMissed({
367
+ cron: props.cron,
368
+ windowStart,
369
+ now: props.now
370
+ });
371
+ }
372
+ function collectLastOnly(props) {
373
+ let lookbackMs = MINUTE_MS;
374
+ while (true) {
375
+ const recentStart = Math.max(props.windowStart, props.now - lookbackMs);
376
+ const walked = walkMatches({
377
+ ...props,
378
+ windowStart: recentStart
379
+ });
380
+ if (walked instanceof FlumeParseError) return walked;
381
+ const latest = walked[walked.length - 1];
382
+ if (latest !== void 0) return {
383
+ matches: [latest],
384
+ truncated: false
385
+ };
386
+ if (recentStart === props.windowStart) return {
387
+ matches: [],
388
+ truncated: false
389
+ };
390
+ lookbackMs *= 2;
391
+ }
392
+ }
393
+ function collectMissed(props) {
394
+ let lookbackMs = MAX_CATCHUP_MATCHES * MINUTE_MS;
395
+ while (true) {
396
+ const recentStart = Math.max(props.windowStart, props.now - lookbackMs);
397
+ const walked = walkMatches({
398
+ ...props,
399
+ windowStart: recentStart
400
+ });
401
+ if (walked instanceof FlumeParseError) return walked;
402
+ if (walked.length >= MAX_CATCHUP_MATCHES || recentStart === props.windowStart) {
403
+ const matches = walked.slice(Math.max(0, walked.length - MAX_CATCHUP_MATCHES));
404
+ const older = hasMatchBefore(props.cron, props.windowStart, recentStart);
405
+ if (older instanceof FlumeParseError) return older;
406
+ return {
407
+ matches,
408
+ truncated: walked.length > MAX_CATCHUP_MATCHES || older
409
+ };
410
+ }
411
+ lookbackMs *= 2;
412
+ }
413
+ }
414
+ function walkMatches(props) {
415
+ const matches = [];
416
+ let cursor = props.windowStart;
417
+ while (true) {
418
+ const next = flumeCronNext(props.cron, cursor);
419
+ if (next instanceof FlumeParseError) return next;
420
+ if (next > props.now) return matches;
421
+ if (!isDstDuplicateFire(matches, next)) matches.push(next);
422
+ cursor = next;
423
+ }
424
+ }
425
+ function hasMatchBefore(cron, windowStart, recentStart) {
426
+ if (recentStart === windowStart) return false;
427
+ const first = flumeCronNext(cron, windowStart);
428
+ if (first instanceof FlumeParseError) return first;
429
+ return first <= recentStart;
430
+ }
431
+ //#endregion
228
432
  //#region lib/time/time-source.ts
229
433
  /**
230
434
  * cron スケジュールで tick を emit する Source。外部接続を持たないため
231
435
  * 起動成功と同時に `connected` になり reconnect の対象外。
232
- * `options.message` で tick ごとの type / data / meta を上書きできる
436
+ *
437
+ * options.statePersister + options.catchupPolicy を渡すと:
438
+ * 1. 起動時に lastFiredAt を読み出す
439
+ * 2. lastFiredAt から now までの過ぎ去った cron マッチを policy に従って再発火する
440
+ * 3. 各 tick 後に lastFiredAt を保存する (best-effort, ブロックしない)
441
+ *
442
+ * 保存先や形式は flume の関知ではなく statePersister の実装が決める (純粋 DI)。
443
+ *
444
+ * DST 制限: fall-back (時計の巻き戻し) の二重発火は dedup で防ぐが、spring-forward
445
+ * (時計の飛び越し) でスキップされた壁時計時刻 (例: 02:30 が存在しない日) にスケジュール
446
+ * された job はその日は実行されない。cron は壁時計 (local time) 基準のため仕様とする
233
447
  */
234
448
  var FlumeTimeSource = class extends FlumeSource {
235
449
  options;
@@ -252,26 +466,41 @@ var FlumeTimeSource = class extends FlumeSource {
252
466
  this.setStatus("disconnected", error.message);
253
467
  return error;
254
468
  }
469
+ const persister = this.options.statePersister ?? null;
470
+ const lastFiredAt = persister === null ? null : await this.loadLastFiredAt(ctx, persister);
255
471
  this.scheduler = new FlumeTimeScheduler({
256
472
  cron,
257
473
  onLog: ctx.log.handler,
258
474
  deps: ctx.deps,
259
- onTick: (firedAt) => this.handleTick(ctx, firedAt)
475
+ onTick: (firedAt) => this.handleTick(ctx, firedAt, persister),
476
+ onHalt: () => this.handleSchedulerHalt(ctx)
260
477
  });
261
- const result = this.scheduler.start();
478
+ const startedAt = safeNow({ deps: ctx.deps });
479
+ const result = this.scheduler.start(startedAt);
262
480
  if (result instanceof Error) {
263
481
  const error = new FlumeStartError(`Time source: ${safeErrorMessage({ error: result })}`);
264
482
  this.setStatus("disconnected", error.message);
265
483
  return error;
266
484
  }
267
485
  this.setStatus("connected");
486
+ if (lastFiredAt !== null && persister !== null) this.runCatchup({
487
+ ctx,
488
+ cron,
489
+ lastFiredAt,
490
+ persister,
491
+ now: startedAt
492
+ });
268
493
  return null;
269
494
  }
270
495
  disconnect() {
271
496
  this.scheduler?.stop();
272
497
  this.scheduler = null;
273
498
  }
274
- handleTick(ctx, firedAt) {
499
+ handleTick(ctx, firedAt, persister) {
500
+ this.emitTick(ctx, firedAt);
501
+ if (persister !== null) this.saveLastFiredAt(ctx, persister, firedAt);
502
+ }
503
+ emitTick(ctx, firedAt) {
275
504
  const tick = {
276
505
  firedAt,
277
506
  cron: this.options.cron
@@ -288,6 +517,79 @@ var FlumeTimeSource = class extends FlumeSource {
288
517
  receivedAt: safeNow({ deps: ctx.deps })
289
518
  });
290
519
  }
520
+ runCatchup(props) {
521
+ const policy = this.options.catchupPolicy ?? { mode: "off" };
522
+ if (policy.mode === "off") return;
523
+ const collected = flumeCollectCatchupMatches({
524
+ cron: props.cron,
525
+ lastFiredAt: props.lastFiredAt,
526
+ now: props.now,
527
+ policy
528
+ });
529
+ if (collected instanceof FlumeParseError) {
530
+ props.ctx.log.warn({
531
+ action: "time.catchup.failed",
532
+ message: collected.message,
533
+ error: collected
534
+ });
535
+ return;
536
+ }
537
+ if (collected.truncated) props.ctx.log.warn({
538
+ action: "time.catchup.truncated",
539
+ message: "catchup exceeded the match cap; oldest missed tick(s) were dropped",
540
+ detail: {
541
+ kept: collected.matches.length,
542
+ policy: policy.mode
543
+ }
544
+ });
545
+ const matches = collected.matches;
546
+ if (matches.length === 0) return;
547
+ props.ctx.log.info({
548
+ action: "time.catchup.fired",
549
+ message: `catchup ${matches.length} missed tick(s) since ${new Date(props.lastFiredAt).toISOString()}`,
550
+ detail: {
551
+ count: matches.length,
552
+ policy: policy.mode
553
+ }
554
+ });
555
+ for (const firedAt of matches) this.emitTick(props.ctx, firedAt);
556
+ const last = matches[matches.length - 1];
557
+ if (last !== void 0) this.saveLastFiredAt(props.ctx, props.persister, last);
558
+ }
559
+ /** スケジューラが cron エラーで恒久停止した (dead-but-green を防ぐため接続状態を落とす) */
560
+ handleSchedulerHalt(ctx) {
561
+ ctx.log.error({
562
+ action: "time.scheduler.halted",
563
+ message: "scheduler halted due to cron error; time source will not tick again"
564
+ });
565
+ this.setStatus("disconnected", "scheduler halted");
566
+ }
567
+ async loadLastFiredAt(ctx, persister) {
568
+ const result = await attempt(() => persister.load());
569
+ if (result instanceof Error) {
570
+ ctx.log.warn({
571
+ action: "time.state.load.error",
572
+ message: safeErrorMessage({ error: result }),
573
+ error: result
574
+ });
575
+ return null;
576
+ }
577
+ if (result === null) return null;
578
+ if (typeof result.lastFiredAt !== "number" || !Number.isFinite(result.lastFiredAt)) return null;
579
+ return result.lastFiredAt;
580
+ }
581
+ saveLastFiredAt(ctx, persister, lastFiredAt) {
582
+ safeInvokeCallback({
583
+ fn: () => persister.save({ lastFiredAt }),
584
+ onError: (error) => {
585
+ ctx.log.warn({
586
+ action: "time.state.save.error",
587
+ message: safeErrorMessage({ error: safeNormalizeError({ value: error }) }),
588
+ error
589
+ });
590
+ }
591
+ });
592
+ }
291
593
  safeMessage(ctx, tick) {
292
594
  const message = this.options.message;
293
595
  if (!message) return {};
@@ -316,4 +618,4 @@ var FlumeTimeSource = class extends FlumeSource {
316
618
  }
317
619
  };
318
620
  //#endregion
319
- export { FlumeTimeSource, flumeCronNext, parseCron };
621
+ export { FlumeTimeSource, flumeCollectCatchupMatches, flumeCronNext, parseCron };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@interactive-inc/flume",
3
- "version": "0.9.4",
3
+ "version": "0.10.1",
4
4
  "description": "Unified notification listener for Discord, Slack, and GitHub. Raw WebSocket + fetch + Zod. No SDK dependencies.",
5
5
  "keywords": [
6
6
  "discord",