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