@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/time.js CHANGED
@@ -1,2 +1,625 @@
1
- import { i as parseCron, r as flumeCronNext, t as FlumeTimeSource } from "./time-source.js";
2
- export { FlumeTimeSource, flumeCronNext, parseCron };
1
+ import { a as safeInvokeCallback, c as FlumeParseError, d as safeErrorMessage, i as safeNow, l as attempt, n as FlumeSerialQueue, r as FlumeLogger, s as FlumeStartError, t as FlumeSource, u as safeNormalizeError } from "./flume-source.js";
2
+ import { t as isRecord } from "./is-record.js";
3
+ //#region lib/time/parse-cron-field.ts
4
+ /**
5
+ * 単一 cron フィールド (minute など) の spec を許可値の Set に展開する。
6
+ * 対応: `*` / `* /n` / `a` / `a-b` / `a-b/n` とそれらのカンマ区切り。名前 (JAN, MON) は非対応。
7
+ * 空トークン (`"5,"` / `"-5"` / `"/5"` など) は `Number("") === 0` の暗黙変換で
8
+ * 0 に化けるため、数値が期待される位置の空文字列は明示的に拒否する
9
+ */
10
+ function parseCronField(spec, min, max) {
11
+ const values = /* @__PURE__ */ new Set();
12
+ for (const part of spec.split(",")) {
13
+ if (part === "") return new FlumeParseError(`empty cron list segment: "${spec}"`);
14
+ const expanded = expandCronPart(part, min, max);
15
+ if (expanded instanceof FlumeParseError) return expanded;
16
+ for (const value of expanded) values.add(value);
17
+ }
18
+ if (values.size === 0) return new FlumeParseError(`cron field empty: "${spec}"`);
19
+ return values;
20
+ }
21
+ function expandCronPart(part, min, max) {
22
+ const slash = part.indexOf("/");
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
+ const bounds = resolveBounds(range, min, max);
29
+ if (bounds instanceof FlumeParseError) return bounds;
30
+ const numbers = [];
31
+ for (let value = bounds.lo; value <= bounds.hi; value += step) numbers.push(value);
32
+ return numbers;
33
+ }
34
+ function resolveBounds(range, min, max) {
35
+ if (range === "*") return {
36
+ lo: min,
37
+ hi: max
38
+ };
39
+ const dash = range.indexOf("-");
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);
45
+ if (!Number.isInteger(lo) || !Number.isInteger(hi)) return new FlumeParseError(`invalid cron range: "${range}"`);
46
+ if (lo < min || hi > max || lo > hi) return new FlumeParseError(`cron value out of range [${min}-${max}]: "${range}"`);
47
+ return {
48
+ lo,
49
+ hi
50
+ };
51
+ }
52
+ //#endregion
53
+ //#region lib/time/parse-cron.ts
54
+ /**
55
+ * 5 フィールド cron 式をパースする。dow は 0-7 を許可し 7 を 0 (日曜) に正規化する
56
+ */
57
+ function parseCron(expression) {
58
+ const trimmed = expression.trim();
59
+ const fields = trimmed.split(/\s+/);
60
+ if (fields.length !== 5) return new FlumeParseError(`cron must have 5 fields, got ${fields.length}: "${expression}"`);
61
+ const minutes = parseCronField(fields[0] ?? "", 0, 59);
62
+ if (minutes instanceof FlumeParseError) return minutes;
63
+ const hours = parseCronField(fields[1] ?? "", 0, 23);
64
+ if (hours instanceof FlumeParseError) return hours;
65
+ const daysOfMonth = parseCronField(fields[2] ?? "", 1, 31);
66
+ if (daysOfMonth instanceof FlumeParseError) return daysOfMonth;
67
+ const months = parseCronField(fields[3] ?? "", 1, 12);
68
+ if (months instanceof FlumeParseError) return months;
69
+ const rawDaysOfWeek = parseCronField(fields[4] ?? "", 0, 7);
70
+ if (rawDaysOfWeek instanceof FlumeParseError) return rawDaysOfWeek;
71
+ const daysOfWeek = /* @__PURE__ */ new Set();
72
+ for (const value of rawDaysOfWeek) daysOfWeek.add(value === 7 ? 0 : value);
73
+ return {
74
+ source: trimmed,
75
+ minutes,
76
+ hours,
77
+ daysOfMonth,
78
+ months,
79
+ daysOfWeek,
80
+ domRestricted: !fields[2]?.includes("*"),
81
+ dowRestricted: !fields[4]?.includes("*")
82
+ };
83
+ }
84
+ //#endregion
85
+ //#region lib/time/cron-next.ts
86
+ const MINUTE_MS$1 = 6e4;
87
+ const MAX_ITERATIONS = 5e5;
88
+ /**
89
+ * `afterMs` より後の最初の cron マッチ時刻 (epoch ms) を壁時計 (local time) で求める。
90
+ * 到達不能なら FlumeParseError を返す
91
+ */
92
+ function flumeCronNext(cron, afterMs) {
93
+ let candidate = Math.floor(afterMs / MINUTE_MS$1) * MINUTE_MS$1 + MINUTE_MS$1;
94
+ for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) {
95
+ const date = new Date(candidate);
96
+ if (!cron.months.has(date.getMonth() + 1)) {
97
+ candidate = new Date(date.getFullYear(), date.getMonth() + 1, 1, 0, 0, 0, 0).getTime();
98
+ continue;
99
+ }
100
+ if (!matchesDay(cron, date)) {
101
+ candidate = new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1, 0, 0, 0, 0).getTime();
102
+ continue;
103
+ }
104
+ if (!cron.hours.has(date.getHours())) {
105
+ candidate = new Date(date.getFullYear(), date.getMonth(), date.getDate(), date.getHours() + 1, 0, 0, 0).getTime();
106
+ continue;
107
+ }
108
+ if (!cron.minutes.has(date.getMinutes())) {
109
+ candidate += MINUTE_MS$1;
110
+ continue;
111
+ }
112
+ return candidate;
113
+ }
114
+ return new FlumeParseError(`cron "${cron.source}" has no next time within bound`);
115
+ }
116
+ function matchesDay(cron, date) {
117
+ const domMatch = cron.daysOfMonth.has(date.getDate());
118
+ const dowMatch = cron.daysOfWeek.has(date.getDay());
119
+ if (cron.domRestricted && cron.dowRestricted) return domMatch || dowMatch;
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();
150
+ }
151
+ //#endregion
152
+ //#region lib/time/time-scheduler.ts
153
+ const MAX_TIMEOUT_MS = 2e9;
154
+ const FIRE_TOLERANCE_MS = 1e3;
155
+ const DST_HISTORY_WINDOW_MS = 7200 * 1e3;
156
+ const MAX_DST_SKIPS = 180;
157
+ /**
158
+ * cron に従って `onTick` を駆動するタイマーループ。外部接続を持たないため reconnect 不要。
159
+ * IO 境界は全て `attempt` 経由で扱い、停止後はコールバックを発火しない。
160
+ * sleep-wake で複数回分を取り逃した場合は 1 回だけ発火して now まで早送りする
161
+ * (取り逃しは `scheduler.skipped` info で観測可能)
162
+ */
163
+ var FlumeTimeScheduler = class {
164
+ props;
165
+ log;
166
+ isStoppedFlag = false;
167
+ timer = null;
168
+ target = 0;
169
+ recentFires = [];
170
+ constructor(props) {
171
+ this.props = props;
172
+ this.log = new FlumeLogger({
173
+ source: "time.scheduler",
174
+ handler: props.onLog,
175
+ deps: props.deps
176
+ });
177
+ }
178
+ get isStopped() {
179
+ return this.isStoppedFlag;
180
+ }
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);
188
+ if (next instanceof FlumeParseError) {
189
+ this.log.error({
190
+ action: "cron.no-next",
191
+ message: next.message,
192
+ error: next
193
+ });
194
+ return next;
195
+ }
196
+ this.target = next;
197
+ this.log.info({
198
+ action: "scheduler.start",
199
+ message: `next fire at ${new Date(next).toISOString()}`,
200
+ detail: { target: next }
201
+ });
202
+ return this.arm();
203
+ }
204
+ stop() {
205
+ this.isStoppedFlag = true;
206
+ this.clearTimer();
207
+ }
208
+ arm() {
209
+ if (this.isStoppedFlag) return null;
210
+ this.clearTimer();
211
+ const delay = Math.max(0, this.target - safeNow({ deps: this.props.deps }));
212
+ const capped = Math.min(delay, MAX_TIMEOUT_MS);
213
+ const result = attempt(() => this.props.deps.setTimeout(() => this.onWake(), capped));
214
+ if (result instanceof Error) {
215
+ this.log.error({
216
+ action: "scheduler.arm.error",
217
+ message: safeErrorMessage({ error: result }),
218
+ error: result
219
+ });
220
+ this.timer = null;
221
+ this.isStoppedFlag = true;
222
+ return result;
223
+ }
224
+ this.timer = result;
225
+ return null;
226
+ }
227
+ onWake() {
228
+ this.timer = null;
229
+ if (this.isStoppedFlag) return;
230
+ if (this.target - safeNow({ deps: this.props.deps }) > FIRE_TOLERANCE_MS) {
231
+ const error = this.arm();
232
+ if (error instanceof Error) this.halt(error);
233
+ return;
234
+ }
235
+ const firedAt = this.target;
236
+ this.rememberFire(firedAt);
237
+ safeInvokeCallback({
238
+ fn: () => this.props.onTick(firedAt),
239
+ onError: (error) => {
240
+ this.log.error({
241
+ action: "scheduler.tick.error",
242
+ message: safeErrorMessage({ error }),
243
+ error
244
+ });
245
+ }
246
+ });
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);
270
+ if (next instanceof FlumeParseError) {
271
+ this.halt(next);
272
+ return;
273
+ }
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`);
319
+ }
320
+ clearTimer() {
321
+ if (this.timer === null) return;
322
+ const handle = this.timer;
323
+ const result = attempt(() => this.props.deps.clearTimeout(handle));
324
+ if (result instanceof Error) this.log.error({
325
+ action: "scheduler.timer.clear.error",
326
+ message: safeErrorMessage({ error: result }),
327
+ error: result
328
+ });
329
+ this.timer = null;
330
+ }
331
+ };
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
432
+ //#region lib/time/time-source.ts
433
+ /**
434
+ * cron スケジュールで tick を emit する Source。外部接続を持たないため
435
+ * 起動成功と同時に `connected` になり reconnect の対象外。
436
+ *
437
+ * options.statePersister + options.catchupPolicy を渡すと:
438
+ * 1. 起動時に lastFiredAt を読み出す
439
+ * 2. lastFiredAt から now までの過ぎ去った cron マッチを policy に従って再発火する
440
+ * 3. 各 tick 後に lastFiredAt を順番に保存する (tick をブロックせず、停止時に完了を待つ)
441
+ *
442
+ * 保存先や形式は flume の関知ではなく statePersister の実装が決める (純粋 DI)。
443
+ *
444
+ * DST 制限: fall-back (時計の巻き戻し) の二重発火は dedup で防ぐが、spring-forward
445
+ * (時計の飛び越し) でスキップされた壁時計時刻 (例: 02:30 が存在しない日) にスケジュール
446
+ * された job はその日は実行されない。cron は壁時計 (local time) 基準のため仕様とする
447
+ */
448
+ var FlumeTimeSource = class extends FlumeSource {
449
+ options;
450
+ name = "time";
451
+ scheduler = null;
452
+ loadCancelled = Promise.withResolvers();
453
+ saveQueue = new FlumeSerialQueue();
454
+ constructor(options) {
455
+ super();
456
+ this.options = options;
457
+ }
458
+ async connect(ctx) {
459
+ this.setStatus("connecting");
460
+ const cron = parseCron(this.options.cron);
461
+ if (cron instanceof FlumeParseError) {
462
+ const error = new FlumeStartError(`Time source: invalid cron "${this.options.cron}": ${cron.message}`);
463
+ ctx.log.error({
464
+ action: "source.start.failed",
465
+ message: safeErrorMessage({ error }),
466
+ error
467
+ });
468
+ this.setStatus("disconnected", error.message);
469
+ return error;
470
+ }
471
+ const persister = this.options.statePersister ?? null;
472
+ const lastFiredAt = persister === null ? null : await this.loadLastFiredAt(ctx, persister);
473
+ if (this.isStopped) return new FlumeStartError("Time source: stopped during state load");
474
+ this.scheduler = new FlumeTimeScheduler({
475
+ cron,
476
+ onLog: ctx.log.handler,
477
+ deps: ctx.deps,
478
+ onTick: (firedAt) => this.handleTick(ctx, firedAt, persister),
479
+ onHalt: () => this.handleSchedulerHalt(ctx)
480
+ });
481
+ const startedAt = safeNow({ deps: ctx.deps });
482
+ const result = this.scheduler.start(startedAt);
483
+ if (result instanceof Error) {
484
+ const error = new FlumeStartError(`Time source: ${safeErrorMessage({ error: result })}`);
485
+ this.setStatus("disconnected", error.message);
486
+ return error;
487
+ }
488
+ this.setStatus("connected");
489
+ if (lastFiredAt !== null && persister !== null) this.runCatchup({
490
+ ctx,
491
+ cron,
492
+ lastFiredAt,
493
+ persister,
494
+ now: startedAt
495
+ });
496
+ return null;
497
+ }
498
+ async disconnect() {
499
+ this.loadCancelled.resolve(null);
500
+ this.scheduler?.stop();
501
+ this.scheduler = null;
502
+ await this.saveQueue.drain();
503
+ }
504
+ handleTick(ctx, firedAt, persister) {
505
+ this.emitTick(ctx, firedAt);
506
+ if (persister !== null) this.saveLastFiredAt(ctx, persister, firedAt);
507
+ }
508
+ emitTick(ctx, firedAt) {
509
+ const tick = {
510
+ firedAt,
511
+ cron: this.options.cron
512
+ };
513
+ const custom = this.safeMessage(ctx, tick);
514
+ this.emit({
515
+ source: "time",
516
+ type: typeof custom.type === "string" ? custom.type : "tick",
517
+ data: isRecord(custom.data) ? custom.data : {
518
+ firedAt,
519
+ cron: this.options.cron
520
+ },
521
+ meta: this.normalizeMeta(custom.meta, this.options.cron),
522
+ receivedAt: safeNow({ deps: ctx.deps })
523
+ });
524
+ }
525
+ runCatchup(props) {
526
+ const policy = this.options.catchupPolicy ?? { mode: "off" };
527
+ if (policy.mode === "off") return;
528
+ const collected = flumeCollectCatchupMatches({
529
+ cron: props.cron,
530
+ lastFiredAt: props.lastFiredAt,
531
+ now: props.now,
532
+ policy
533
+ });
534
+ if (collected instanceof FlumeParseError) {
535
+ props.ctx.log.warn({
536
+ action: "time.catchup.failed",
537
+ message: collected.message,
538
+ error: collected
539
+ });
540
+ return;
541
+ }
542
+ if (collected.truncated) props.ctx.log.warn({
543
+ action: "time.catchup.truncated",
544
+ message: "catchup exceeded the match cap; oldest missed tick(s) were dropped",
545
+ detail: {
546
+ kept: collected.matches.length,
547
+ policy: policy.mode
548
+ }
549
+ });
550
+ const matches = collected.matches;
551
+ if (matches.length === 0) return;
552
+ props.ctx.log.info({
553
+ action: "time.catchup.fired",
554
+ message: `catchup ${matches.length} missed tick(s) since ${new Date(props.lastFiredAt).toISOString()}`,
555
+ detail: {
556
+ count: matches.length,
557
+ policy: policy.mode
558
+ }
559
+ });
560
+ for (const firedAt of matches) this.emitTick(props.ctx, firedAt);
561
+ const last = matches[matches.length - 1];
562
+ if (last !== void 0) this.saveLastFiredAt(props.ctx, props.persister, last);
563
+ }
564
+ /** スケジューラが cron エラーで恒久停止した (dead-but-green を防ぐため接続状態を落とす) */
565
+ handleSchedulerHalt(ctx) {
566
+ ctx.log.error({
567
+ action: "time.scheduler.halted",
568
+ message: "scheduler halted due to cron error; time source will not tick again"
569
+ });
570
+ this.setStatus("disconnected", "scheduler halted");
571
+ }
572
+ async loadLastFiredAt(ctx, persister) {
573
+ const result = await Promise.race([attempt(() => persister.load()), this.loadCancelled.promise]);
574
+ if (result instanceof Error) {
575
+ ctx.log.warn({
576
+ action: "time.state.load.error",
577
+ message: safeErrorMessage({ error: result }),
578
+ error: result
579
+ });
580
+ return null;
581
+ }
582
+ if (result === null) return null;
583
+ if (typeof result.lastFiredAt !== "number" || !Number.isFinite(result.lastFiredAt)) return null;
584
+ return result.lastFiredAt;
585
+ }
586
+ saveLastFiredAt(ctx, persister, lastFiredAt) {
587
+ this.saveQueue.add(async () => {
588
+ const error = await attempt(() => persister.save({ lastFiredAt }));
589
+ if (!(error instanceof Error)) return;
590
+ ctx.log.warn({
591
+ action: "time.state.save.error",
592
+ message: safeErrorMessage({ error }),
593
+ error
594
+ });
595
+ });
596
+ }
597
+ safeMessage(ctx, tick) {
598
+ const message = this.options.message;
599
+ if (!message) return {};
600
+ const result = attempt(() => message(tick));
601
+ if (result instanceof Error) {
602
+ const error = safeNormalizeError({ value: result });
603
+ ctx.log.warn({
604
+ action: "message.error",
605
+ message: safeErrorMessage({ error }),
606
+ error,
607
+ detail: { firedAt: tick.firedAt }
608
+ });
609
+ return {};
610
+ }
611
+ return isRecord(result) ? result : {};
612
+ }
613
+ normalizeMeta(meta, cron) {
614
+ if (!isRecord(meta)) return { cron };
615
+ const normalized = {};
616
+ for (const key of Object.keys(meta)) {
617
+ const value = meta[key];
618
+ if (typeof value === "string") normalized[key] = value;
619
+ }
620
+ if (Object.keys(normalized).length === 0) return { cron };
621
+ return normalized;
622
+ }
623
+ };
624
+ //#endregion
625
+ export { FlumeTimeSource, flumeCollectCatchupMatches, flumeCronNext, parseCron };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@interactive-inc/flume",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Unified notification listener for Discord, Slack, and GitHub. Raw WebSocket + fetch + Zod. No SDK dependencies.",
5
5
  "keywords": [
6
6
  "discord",
@@ -76,9 +76,16 @@
76
76
  "zod": "^4.4.3"
77
77
  },
78
78
  "devDependencies": {
79
- "typescript": "^5.6.0",
80
- "vite-plus": "^0.1.21",
81
- "vitest": "^4.1.9"
79
+ "typescript": "^5.9.3",
80
+ "vite-plus": "^0.1.24",
81
+ "vitest": "^4.1.10"
82
+ },
83
+ "devEngines": {
84
+ "packageManager": {
85
+ "name": "bun",
86
+ "version": "1.3.14",
87
+ "onFail": "download"
88
+ }
82
89
  },
83
90
  "engines": {
84
91
  "node": ">=22"
@@ -1,16 +0,0 @@
1
- //#region lib/errors/connection-error.ts
2
- /**
3
- * 接続失敗を表す。Discord Gateway / Slack Socket Mode / その他 WebSocket 系の close で発生。
4
- * `code` は接続が落ちた際の close code (Discord は 4xxx 帯が再接続可否を示す)
5
- */
6
- var FlumeConnectionError = class extends Error {
7
- code;
8
- constructor(message, options) {
9
- super(message, options?.cause === void 0 ? void 0 : { cause: options.cause });
10
- this.name = "FlumeConnectionError";
11
- this.code = options?.code ?? null;
12
- Object.freeze(this);
13
- }
14
- };
15
- //#endregion
16
- export { FlumeConnectionError as t };
@@ -1,12 +0,0 @@
1
- //#region lib/errors/http-error.ts
2
- var FlumeHttpError = class extends Error {
3
- status;
4
- constructor(props) {
5
- super(props.message, props.cause === void 0 ? void 0 : { cause: props.cause });
6
- this.name = "FlumeHttpError";
7
- this.status = props.status;
8
- Object.freeze(this);
9
- }
10
- };
11
- //#endregion
12
- export { FlumeHttpError as t };