@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/slack.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as FlumeStartError, c as safeNormalizeError, i as safeNow, l as safeErrorMessage, 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, s as FlumeParseError, t as FlumeSource, u as safeErrorMessage } from "./flume-source.js";
2
2
  import { t as FlumeConnectionError } from "./connection-error.js";
3
3
  import { t as FlumeHttpError } from "./http-error.js";
4
4
  import { n as scheduleFlumeReconnect, r as FlumeReconnector, t as safeStringify } from "./safe-stringify.js";
@@ -10,11 +10,12 @@ import { z } from "zod/v4";
10
10
  function flumeExtractSlackMeta(envelope) {
11
11
  const meta = { event_type: envelope.type };
12
12
  const eventPayload = isRecord(envelope.payload.event) ? envelope.payload.event : null;
13
- if (!eventPayload) return meta;
14
- if (typeof eventPayload.channel === "string") meta.channel_id = eventPayload.channel;
15
- if (typeof eventPayload.user === "string") meta.user_id = eventPayload.user;
16
- if (typeof eventPayload.thread_ts === "string") meta.thread_ts = eventPayload.thread_ts;
17
- if (typeof eventPayload.type === "string") meta.slack_event_type = eventPayload.type;
13
+ if (typeof envelope.payload.channel_id === "string") meta.channel_id = envelope.payload.channel_id;
14
+ else if (eventPayload && typeof eventPayload.channel === "string") meta.channel_id = eventPayload.channel;
15
+ if (typeof envelope.payload.user_id === "string") meta.user_id = envelope.payload.user_id;
16
+ else if (eventPayload && typeof eventPayload.user === "string") meta.user_id = eventPayload.user;
17
+ if (eventPayload && typeof eventPayload.thread_ts === "string") meta.thread_ts = eventPayload.thread_ts;
18
+ if (eventPayload && typeof eventPayload.type === "string") meta.slack_event_type = eventPayload.type;
18
19
  return meta;
19
20
  }
20
21
  //#endregion
@@ -65,6 +66,22 @@ const FlumeSlackConnectionResponseSchema = z.object({
65
66
  error: z.string().optional()
66
67
  });
67
68
  //#endregion
69
+ //#region lib/slack/read-slack-retry-after-ms.ts
70
+ /**
71
+ * `Retry-After` ヘッダを ms で読む。整数秒表記のみ受理し、HTTP-date 形式・非数値・
72
+ * headers 欠落 (モック Response 等) はすべて null。throw しない
73
+ */
74
+ function readSlackRetryAfterMs(props) {
75
+ const raw = attempt(() => props.response.headers.get("retry-after"));
76
+ if (raw instanceof Error) return null;
77
+ if (typeof raw !== "string") return null;
78
+ const trimmed = raw.trim();
79
+ if (!/^\d+$/.test(trimmed)) return null;
80
+ const seconds = Number(trimmed);
81
+ if (!Number.isFinite(seconds)) return null;
82
+ return seconds * 1e3;
83
+ }
84
+ //#endregion
68
85
  //#region lib/slack/obtain-slack-url.ts
69
86
  const URL_ENDPOINT = "https://slack.com/api/apps.connections.open";
70
87
  async function obtainSlackUrl(props) {
@@ -99,6 +116,19 @@ async function obtainSlackUrl(props) {
99
116
  url: URL_ENDPOINT
100
117
  }
101
118
  });
119
+ const retryAfterMs = readSlackRetryAfterMs({ response });
120
+ if (response.status === 429) {
121
+ log.warn({
122
+ action: "http.rate-limited",
123
+ message: `apps.connections.open: rate limited (429), retry-after ${retryAfterMs ?? "unknown"}ms`,
124
+ detail: { retryAfterMs }
125
+ });
126
+ return new FlumeHttpError({
127
+ message: "apps.connections.open: rate limited (429)",
128
+ status: response.status,
129
+ retryAfterMs
130
+ });
131
+ }
102
132
  const text = await safeReadText({
103
133
  response,
104
134
  context: "apps.connections.open"
@@ -121,7 +151,8 @@ async function obtainSlackUrl(props) {
121
151
  return new FlumeHttpError({
122
152
  message: `apps.connections.open: invalid JSON body`,
123
153
  status: response.status,
124
- cause: json
154
+ cause: json,
155
+ retryAfterMs
125
156
  });
126
157
  }
127
158
  const peek = isRecord(json) ? json : {};
@@ -146,17 +177,21 @@ async function obtainSlackUrl(props) {
146
177
  return new FlumeHttpError({
147
178
  message: "apps.connections.open: invalid response shape",
148
179
  status: response.status,
149
- cause: parsed.error
180
+ cause: parsed.error,
181
+ retryAfterMs
150
182
  });
151
183
  }
152
184
  if (!parsed.data.ok || !parsed.data.url) {
153
185
  log.warn({
154
186
  action: "slack.api.error",
155
- message: `apps.connections.open failed: ${parsed.data.error ?? "no url"}`
187
+ message: `apps.connections.open failed: ${parsed.data.error ?? "no url"}`,
188
+ detail: { code: parsed.data.error ?? null }
156
189
  });
157
190
  return new FlumeHttpError({
158
191
  message: `apps.connections.open failed: ${parsed.data.error ?? "no url"}`,
159
- status: response.status
192
+ status: response.status,
193
+ code: parsed.data.error ?? null,
194
+ retryAfterMs
160
195
  });
161
196
  }
162
197
  log.info({
@@ -178,6 +213,12 @@ const FlumeSlackEnvelopeSchema = z.object({
178
213
  //#endregion
179
214
  //#region lib/slack/slack-socket-mode.ts
180
215
  const IDLE_CHECK_INTERVAL_MS = 15e3;
216
+ const HANDSHAKE_TIMEOUT_DEFAULT_MS = 3e4;
217
+ /**
218
+ * 強制 close 後に close イベントの配達を待つ猶予。silently-dropped な NAT / proxy 経路では
219
+ * close が永遠に来ないことがあり、その場合 teardown を合成して reconnect へ渡す
220
+ */
221
+ const CLOSE_FALLBACK_MS = 5e3;
181
222
  const WS_OPEN = 1;
182
223
  /**
183
224
  * Slack Socket Mode の最小 WebSocket 実装。`apps.connections.open` で URL を取得し
@@ -194,6 +235,9 @@ var FlumeSlackSocketMode = class {
194
235
  pendingResolved = false;
195
236
  lastFrameAt = 0;
196
237
  idleWatchdog = null;
238
+ handshakeTimer = null;
239
+ closeFallbackTimer = null;
240
+ closeHandled = false;
197
241
  constructor(props) {
198
242
  this.props = props;
199
243
  this.log = new FlumeLogger({
@@ -253,13 +297,15 @@ var FlumeSlackSocketMode = class {
253
297
  });
254
298
  this.isStoppedFlag = true;
255
299
  this.disarmIdleWatchdog();
300
+ this.clearCloseFallback();
301
+ this.completeConnect(new FlumeConnectionError("stopped before hello"));
256
302
  this.closeSocket(this.ws);
257
303
  this.ws = null;
258
304
  }
259
305
  armIdleWatchdog() {
260
306
  this.disarmIdleWatchdog();
261
307
  const idleLimit = this.props.idleTimeoutMs;
262
- if (idleLimit === null || idleLimit === void 0 || idleLimit <= 0) return;
308
+ if (typeof idleLimit !== "number" || !Number.isFinite(idleLimit) || idleLimit <= 0) return;
263
309
  const handle = attempt(() => this.props.deps.setInterval(() => this.checkIdle(idleLimit), IDLE_CHECK_INTERVAL_MS));
264
310
  if (handle instanceof Error) {
265
311
  this.log.error({
@@ -284,7 +330,7 @@ var FlumeSlackSocketMode = class {
284
330
  }
285
331
  checkIdle(idleLimit) {
286
332
  if (!this.hasConnected || this.isStoppedFlag) return;
287
- const elapsed = this.props.deps.now() - this.lastFrameAt;
333
+ const elapsed = safeNow({ deps: this.props.deps }) - this.lastFrameAt;
288
334
  if (elapsed < idleLimit) return;
289
335
  this.log.warn({
290
336
  action: "idle.timeout",
@@ -295,7 +341,7 @@ var FlumeSlackSocketMode = class {
295
341
  }
296
342
  });
297
343
  this.disarmIdleWatchdog();
298
- this.closeSocket(this.ws);
344
+ this.forceClose();
299
345
  }
300
346
  isConnected() {
301
347
  return this.ws !== null && this.ws.readyState === WS_OPEN;
@@ -313,6 +359,7 @@ var FlumeSlackSocketMode = class {
313
359
  }
314
360
  this.pendingResolved = false;
315
361
  this.hasConnected = false;
362
+ this.closeHandled = false;
316
363
  return new Promise((resolve) => {
317
364
  this.pendingResolve = resolve;
318
365
  const socketResult = attempt(() => new WS(url));
@@ -342,17 +389,121 @@ var FlumeSlackSocketMode = class {
342
389
  message: safeErrorMessage({ error }),
343
390
  error
344
391
  });
392
+ this.closeSocket(socket);
345
393
  this.ws = null;
346
394
  this.pendingResolved = true;
347
395
  resolve(error);
396
+ return;
348
397
  }
398
+ this.armHandshakeTimer();
349
399
  });
350
400
  }
351
401
  completeConnect(error) {
402
+ this.clearHandshakeTimer();
352
403
  if (this.pendingResolved || !this.pendingResolve) return;
353
404
  this.pendingResolved = true;
354
405
  this.pendingResolve(error);
355
406
  }
407
+ armHandshakeTimer() {
408
+ const configured = this.props.handshakeTimeoutMs;
409
+ const timeoutMs = typeof configured === "number" && Number.isFinite(configured) && configured > 0 ? configured : HANDSHAKE_TIMEOUT_DEFAULT_MS;
410
+ const handle = attempt(() => this.props.deps.setTimeout(() => this.onHandshakeTimeout(timeoutMs), timeoutMs));
411
+ if (handle instanceof Error) {
412
+ const error = new FlumeConnectionError(`handshake timer scheduling failed: ${safeErrorMessage({ error: handle })}`, { cause: handle });
413
+ this.log.error({
414
+ action: "handshake.timer.schedule.error",
415
+ message: safeErrorMessage({ error }),
416
+ error
417
+ });
418
+ this.completeConnect(error);
419
+ this.forceClose();
420
+ return;
421
+ }
422
+ this.handshakeTimer = handle;
423
+ }
424
+ clearHandshakeTimer() {
425
+ if (this.handshakeTimer === null) return;
426
+ const handle = this.handshakeTimer;
427
+ this.handshakeTimer = null;
428
+ const result = attempt(() => this.props.deps.clearTimeout(handle));
429
+ if (result instanceof Error) this.log.error({
430
+ action: "handshake.timer.clear.error",
431
+ message: safeErrorMessage({ error: result }),
432
+ error: result
433
+ });
434
+ }
435
+ onHandshakeTimeout(timeoutMs) {
436
+ this.handshakeTimer = null;
437
+ if (this.pendingResolved || this.isStoppedFlag) return;
438
+ const error = new FlumeConnectionError(`handshake timeout: no hello within ${timeoutMs}ms — force-closing socket`);
439
+ this.log.error({
440
+ action: "handshake.timeout",
441
+ message: safeErrorMessage({ error }),
442
+ error,
443
+ detail: { timeoutMs }
444
+ });
445
+ this.completeConnect(error);
446
+ this.forceClose();
447
+ }
448
+ /**
449
+ * watchdog / handshake timeout / server disconnect 指示による強制 close の入口。
450
+ * close イベントが配達されない dead pipe に備え、close() より先に fallback timer を張る
451
+ */
452
+ forceClose() {
453
+ const socket = this.ws;
454
+ if (socket === null) return;
455
+ this.armCloseFallback();
456
+ this.closeSocket(socket);
457
+ }
458
+ armCloseFallback() {
459
+ if (this.closeFallbackTimer !== null) return;
460
+ const handle = attempt(() => this.props.deps.setTimeout(() => this.onCloseFallback(), CLOSE_FALLBACK_MS));
461
+ if (handle instanceof Error) {
462
+ this.log.error({
463
+ action: "ws.close.fallback.schedule.error",
464
+ message: safeErrorMessage({ error: handle }),
465
+ error: handle
466
+ });
467
+ this.onCloseFallback();
468
+ return;
469
+ }
470
+ this.closeFallbackTimer = handle;
471
+ }
472
+ clearCloseFallback() {
473
+ if (this.closeFallbackTimer === null) return;
474
+ const handle = this.closeFallbackTimer;
475
+ this.closeFallbackTimer = null;
476
+ const result = attempt(() => this.props.deps.clearTimeout(handle));
477
+ if (result instanceof Error) this.log.error({
478
+ action: "ws.close.fallback.clear.error",
479
+ message: safeErrorMessage({ error: result }),
480
+ error: result
481
+ });
482
+ }
483
+ onCloseFallback() {
484
+ this.closeFallbackTimer = null;
485
+ if (this.closeHandled || this.isStoppedFlag) return;
486
+ this.log.warn({
487
+ action: "ws.close.fallback",
488
+ message: `close event not delivered within ${CLOSE_FALLBACK_MS}ms — synthesizing teardown`
489
+ });
490
+ this.settleClose({ code: null });
491
+ }
492
+ /**
493
+ * close teardown の唯一の入口。実 close イベントと fallback の合成 close が競合しても
494
+ * `closeHandled` で一度だけ実行する
495
+ */
496
+ settleClose(props) {
497
+ if (this.closeHandled) return;
498
+ this.closeHandled = true;
499
+ this.clearCloseFallback();
500
+ this.disarmIdleWatchdog();
501
+ this.ws = null;
502
+ if (this.hasConnected) this.props.onDisconnected();
503
+ if (this.pendingResolved) return;
504
+ const error = props.code === null ? new FlumeConnectionError("WebSocket closed before hello (code=none)") : new FlumeConnectionError(`WebSocket closed before hello (code=${props.code})`, { code: props.code });
505
+ this.completeConnect(error);
506
+ }
356
507
  safeOnMessage(ev, socket) {
357
508
  const r = attempt(() => this.onMessage(String(ev.data), socket));
358
509
  if (r instanceof Error) this.log.error({
@@ -378,6 +529,7 @@ var FlumeSlackSocketMode = class {
378
529
  });
379
530
  }
380
531
  onMessage(raw, socket) {
532
+ this.lastFrameAt = safeNow({ deps: this.props.deps });
381
533
  if (this.isStoppedFlag) return;
382
534
  const json = safeJsonParse(raw);
383
535
  if (json instanceof FlumeParseError) {
@@ -402,7 +554,6 @@ var FlumeSlackSocketMode = class {
402
554
  message: `type=${typeof json.type === "string" ? json.type : "-"} length=${raw.length}`,
403
555
  detail: { length: raw.length }
404
556
  });
405
- this.lastFrameAt = this.props.deps.now();
406
557
  if (json.type === "hello") {
407
558
  this.log.info({
408
559
  action: "socket.hello",
@@ -421,7 +572,7 @@ var FlumeSlackSocketMode = class {
421
572
  message: `reason=${reason}`,
422
573
  detail: { reason }
423
574
  });
424
- this.closeSocket(socket);
575
+ this.forceClose();
425
576
  return;
426
577
  }
427
578
  if (typeof json.envelope_id === "string") {
@@ -466,13 +617,14 @@ var FlumeSlackSocketMode = class {
466
617
  reason: ev.reason
467
618
  }
468
619
  });
469
- this.disarmIdleWatchdog();
470
- this.ws = null;
471
- if (this.hasConnected) this.props.onDisconnected();
472
- if (!this.pendingResolved) {
473
- const error = new FlumeConnectionError(`WebSocket closed before hello (code=${ev.code})`, { code: ev.code });
474
- this.completeConnect(error);
620
+ if (this.closeHandled) {
621
+ this.log.debug({
622
+ action: "ws.close.late",
623
+ message: "close event arrived after synthesized teardown, ignoring"
624
+ });
625
+ return;
475
626
  }
627
+ this.settleClose({ code: ev.code });
476
628
  }
477
629
  onError() {
478
630
  const error = new FlumeConnectionError("WebSocket connection error");
@@ -525,6 +677,19 @@ var FlumeSlackSocketMode = class {
525
677
  //#region lib/slack/slack-source.ts
526
678
  const SEEN_CACHE_MAX = 1024;
527
679
  const SEEN_CACHE_TTL_MS = 300 * 1e3;
680
+ /**
681
+ * `apps.connections.open` が返す恒久エラー。トークンが無効な状態で再接続しても
682
+ * 回復しないため、reconnect を打ち切って呼び出し側へエラーを返す
683
+ */
684
+ const TERMINAL_SLACK_ERROR_CODES = new Set([
685
+ "invalid_auth",
686
+ "account_inactive",
687
+ "token_revoked",
688
+ "not_authed",
689
+ "not_allowed_token_type",
690
+ "token_expired",
691
+ "missing_scope"
692
+ ]);
528
693
  var FlumeSlackSource = class extends FlumeSource {
529
694
  options;
530
695
  name = "slack";
@@ -587,9 +752,10 @@ var FlumeSlackSource = class extends FlumeSource {
587
752
  }
588
753
  async connectInternal(ctx) {
589
754
  this.setStatus("connecting");
590
- this.socket = new FlumeSlackSocketMode({
755
+ const socket = new FlumeSlackSocketMode({
591
756
  appToken: this.options.appToken,
592
757
  idleTimeoutMs: this.options.idleTimeoutMs,
758
+ handshakeTimeoutMs: this.options.handshakeTimeoutMs,
593
759
  onLog: ctx.log.handler,
594
760
  deps: ctx.deps,
595
761
  onMessage: (envelope) => this.handleMessage(ctx, envelope),
@@ -602,43 +768,64 @@ var FlumeSlackSource = class extends FlumeSource {
602
768
  this.setStatus("connected");
603
769
  },
604
770
  onDisconnected: () => {
605
- if (this.socket?.isStopped) {
771
+ if (socket.isStopped) {
606
772
  this.setStatus("disconnected");
607
773
  return;
608
774
  }
609
775
  this.scheduleReconnect(ctx);
610
776
  }
611
777
  });
612
- const error = await this.socket.connect({ signal: this.internalController?.signal });
778
+ this.socket = socket;
779
+ const error = await socket.connect({ signal: this.internalController?.signal });
613
780
  if (error instanceof Error) {
614
781
  ctx.log.error({
615
782
  action: "connect.failed",
616
783
  message: safeErrorMessage({ error }),
617
784
  error
618
785
  });
619
- if (this.socket.isStopped || !this.reconnector || this.reconnector.aborted) {
786
+ if (this.isTerminalSlackError(error)) {
787
+ ctx.log.error({
788
+ action: "reconnect.terminal",
789
+ message: `permanent Slack API error, not reconnecting: ${safeErrorMessage({ error })}`,
790
+ error
791
+ });
792
+ this.setStatus("disconnected");
793
+ return error;
794
+ }
795
+ if (socket.isStopped || !this.reconnector || this.reconnector.aborted) {
620
796
  this.setStatus("disconnected");
621
797
  return error;
622
798
  }
623
- this.scheduleReconnect(ctx);
799
+ this.scheduleReconnect(ctx, this.minRetryDelayMs(error));
624
800
  }
625
801
  return null;
626
802
  }
803
+ isTerminalSlackError(error) {
804
+ if (!(error instanceof FlumeHttpError)) return false;
805
+ if (error.code === null) return false;
806
+ return TERMINAL_SLACK_ERROR_CODES.has(error.code);
807
+ }
808
+ minRetryDelayMs(error) {
809
+ if (!(error instanceof FlumeHttpError)) return void 0;
810
+ return error.retryAfterMs ?? void 0;
811
+ }
627
812
  handleMessage(ctx, envelope) {
628
813
  const seen = this.seen;
629
814
  if (!seen) return;
630
- if (seen.has(envelope.envelope_id)) {
815
+ const dedupKey = this.toDedupKey(envelope);
816
+ if (seen.has(dedupKey)) {
631
817
  ctx.log.debug({
632
818
  action: "dedup.skip",
633
- message: `duplicate envelope_id=${envelope.envelope_id}`,
819
+ message: `duplicate key=${dedupKey}`,
634
820
  detail: {
821
+ dedup_key: dedupKey,
635
822
  envelope_id: envelope.envelope_id,
636
823
  retry_attempt: envelope.retry_attempt
637
824
  }
638
825
  });
639
826
  return;
640
827
  }
641
- seen.add(envelope.envelope_id);
828
+ seen.add(dedupKey);
642
829
  seen.trim();
643
830
  this.emit({
644
831
  source: "slack",
@@ -648,6 +835,15 @@ var FlumeSlackSource = class extends FlumeSource {
648
835
  receivedAt: safeNow({ deps: ctx.deps })
649
836
  });
650
837
  }
838
+ /**
839
+ * Events API の再配送は envelope_id が変わり得るため、payload.event_id があれば
840
+ * そちらを重複判定キーとして優先する (再配送をまたいで安定な識別子)
841
+ */
842
+ toDedupKey(envelope) {
843
+ const eventId = envelope.payload.event_id;
844
+ if (typeof eventId === "string" && eventId.length > 0) return eventId;
845
+ return envelope.envelope_id;
846
+ }
651
847
  safeExtractMeta(ctx, envelope) {
652
848
  const result = attempt(() => flumeExtractSlackMeta(envelope));
653
849
  if (result instanceof Error) {
@@ -662,11 +858,12 @@ var FlumeSlackSource = class extends FlumeSource {
662
858
  }
663
859
  return result;
664
860
  }
665
- scheduleReconnect(ctx) {
861
+ scheduleReconnect(ctx, minDelayMs) {
666
862
  scheduleFlumeReconnect({
667
863
  reconnector: this.reconnector,
668
864
  log: ctx.log,
669
865
  setStatus: (status) => this.setStatus(status),
866
+ minDelayMs,
670
867
  retry: () => {
671
868
  this.connectInternal(ctx).catch((err) => {
672
869
  const error = safeNormalizeError({ value: err });
package/dist/time.d.ts CHANGED
@@ -1,11 +1,21 @@
1
- import { N as FlumeTimeSourceOptions, t as FlumeSource, w as FlumeSourceStartContext } from "./flume-source.js";
1
+ import { O as FlumeSourceStartContext, R as FlumeTimeSourceOptions, n as FlumeCatchupPolicy, t as FlumeSource } from "./flume-source.js";
2
2
  import { t as FlumeParseError } from "./parse-error.js";
3
3
 
4
4
  //#region lib/time/time-source.d.ts
5
5
  /**
6
6
  * cron スケジュールで tick を emit する Source。外部接続を持たないため
7
7
  * 起動成功と同時に `connected` になり reconnect の対象外。
8
- * `options.message` で tick ごとの type / data / meta を上書きできる
8
+ *
9
+ * options.statePersister + options.catchupPolicy を渡すと:
10
+ * 1. 起動時に lastFiredAt を読み出す
11
+ * 2. lastFiredAt から now までの過ぎ去った cron マッチを policy に従って再発火する
12
+ * 3. 各 tick 後に lastFiredAt を保存する (best-effort, ブロックしない)
13
+ *
14
+ * 保存先や形式は flume の関知ではなく statePersister の実装が決める (純粋 DI)。
15
+ *
16
+ * DST 制限: fall-back (時計の巻き戻し) の二重発火は dedup で防ぐが、spring-forward
17
+ * (時計の飛び越し) でスキップされた壁時計時刻 (例: 02:30 が存在しない日) にスケジュール
18
+ * された job はその日は実行されない。cron は壁時計 (local time) 基準のため仕様とする
9
19
  */
10
20
  declare class FlumeTimeSource extends FlumeSource {
11
21
  private readonly options;
@@ -15,6 +25,12 @@ declare class FlumeTimeSource extends FlumeSource {
15
25
  protected connect(ctx: FlumeSourceStartContext): Promise<Error | null>;
16
26
  protected disconnect(): void;
17
27
  private handleTick;
28
+ private emitTick;
29
+ private runCatchup;
30
+ /** スケジューラが cron エラーで恒久停止した (dead-but-green を防ぐため接続状態を落とす) */
31
+ private handleSchedulerHalt;
32
+ private loadLastFiredAt;
33
+ private saveLastFiredAt;
18
34
  private safeMessage;
19
35
  private normalizeMeta;
20
36
  }
@@ -26,7 +42,7 @@ type FlumeCron = {
26
42
  hours: ReadonlySet<number>;
27
43
  daysOfMonth: ReadonlySet<number>;
28
44
  months: ReadonlySet<number>;
29
- daysOfWeek: ReadonlySet<number>; /** day-of-month フィールドが `*` 以外か。dow と両方制限時は OR マッチ (標準 cron 準拠) */
45
+ daysOfWeek: ReadonlySet<number>; /** day-of-month フィールドが wildcard を含まないか。dow と両方制限時は OR マッチ */
30
46
  domRestricted: boolean;
31
47
  dowRestricted: boolean;
32
48
  };
@@ -42,4 +58,30 @@ declare function parseCron(expression: string): FlumeCron | FlumeParseError;
42
58
  */
43
59
  declare function flumeCronNext(cron: FlumeCron, afterMs: number): number | FlumeParseError;
44
60
  //#endregion
45
- export { type FlumeCron, FlumeTimeSource, flumeCronNext, parseCron };
61
+ //#region lib/time/time-catchup.d.ts
62
+ type Props = {
63
+ cron: FlumeCron;
64
+ lastFiredAt: number;
65
+ now: number;
66
+ policy: FlumeCatchupPolicy;
67
+ };
68
+ type FlumeCatchupMatches = {
69
+ matches: ReadonlyArray<number>; /** missed: 上限超過で古いマッチを切り捨てた / lastOnly: 反復上限で now まで走査しきれなかった */
70
+ truncated: boolean;
71
+ };
72
+ /**
73
+ * `lastFiredAt` から `now` までに過ぎ去った cron マッチを policy に従って列挙する。
74
+ *
75
+ * - policy.mode === "off" : 常に空
76
+ * - policy.mode === "lastOnly" : 過ぎ去ったマッチの中で最も新しいもの 1 件 (件数上限なし・O(1) メモリ)
77
+ * - policy.mode === "missed" : maxWindowMs (既定 24h) 以内に過ぎ去ったすべてのマッチ。
78
+ * window の起点は `max(lastFiredAt, now - maxWindowMs)`。
79
+ * 10,000 件を超えた場合は古い方を捨てて新しい 10,000 件を返し
80
+ * truncated: true で通知する
81
+ *
82
+ * 到達不能 cron や catastrophic な policy ミス指定の場合は FlumeParseError を返す
83
+ * (catchup 列挙だけで失敗させる。source 本体の起動は別判断)
84
+ */
85
+ declare function flumeCollectCatchupMatches(props: Props): FlumeCatchupMatches | FlumeParseError;
86
+ //#endregion
87
+ export { type FlumeCron, FlumeTimeSource, flumeCollectCatchupMatches, flumeCronNext, parseCron };