@interactive-inc/flume 0.10.0 → 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.
@@ -1,427 +0,0 @@
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";
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
- */
8
- function parseCronField(spec, min, max) {
9
- const values = /* @__PURE__ */ new Set();
10
- for (const part of spec.split(",")) {
11
- const expanded = expandCronPart(part, min, max);
12
- if (expanded instanceof FlumeParseError) return expanded;
13
- for (const value of expanded) values.add(value);
14
- }
15
- if (values.size === 0) return new FlumeParseError(`cron field empty: "${spec}"`);
16
- return values;
17
- }
18
- function expandCronPart(part, min, max) {
19
- let range = part;
20
- let step = 1;
21
- 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
- }
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 lo = dash === -1 ? Number(range) : Number(range.slice(0, dash));
41
- const hi = dash === -1 ? lo : Number(range.slice(dash + 1));
42
- if (!Number.isInteger(lo) || !Number.isInteger(hi)) return new FlumeParseError(`invalid cron range: "${range}"`);
43
- if (lo < min || hi > max || lo > hi) return new FlumeParseError(`cron value out of range [${min}-${max}]: "${range}"`);
44
- return {
45
- lo,
46
- hi
47
- };
48
- }
49
- //#endregion
50
- //#region lib/time/parse-cron.ts
51
- /**
52
- * 5 フィールド cron 式をパースする。dow は 0-7 を許可し 7 を 0 (日曜) に正規化する
53
- */
54
- function parseCron(expression) {
55
- const trimmed = expression.trim();
56
- const fields = trimmed.split(/\s+/);
57
- if (fields.length !== 5) return new FlumeParseError(`cron must have 5 fields, got ${fields.length}: "${expression}"`);
58
- const minutes = parseCronField(fields[0] ?? "", 0, 59);
59
- if (minutes instanceof FlumeParseError) return minutes;
60
- const hours = parseCronField(fields[1] ?? "", 0, 23);
61
- if (hours instanceof FlumeParseError) return hours;
62
- const daysOfMonth = parseCronField(fields[2] ?? "", 1, 31);
63
- if (daysOfMonth instanceof FlumeParseError) return daysOfMonth;
64
- const months = parseCronField(fields[3] ?? "", 1, 12);
65
- if (months instanceof FlumeParseError) return months;
66
- const rawDaysOfWeek = parseCronField(fields[4] ?? "", 0, 7);
67
- if (rawDaysOfWeek instanceof FlumeParseError) return rawDaysOfWeek;
68
- const daysOfWeek = /* @__PURE__ */ new Set();
69
- for (const value of rawDaysOfWeek) daysOfWeek.add(value === 7 ? 0 : value);
70
- return {
71
- source: trimmed,
72
- minutes,
73
- hours,
74
- daysOfMonth,
75
- months,
76
- daysOfWeek,
77
- domRestricted: fields[2] !== "*",
78
- dowRestricted: fields[4] !== "*"
79
- };
80
- }
81
- //#endregion
82
- //#region lib/time/cron-next.ts
83
- const MINUTE_MS = 6e4;
84
- const MAX_ITERATIONS = 5e5;
85
- /**
86
- * `afterMs` より後の最初の cron マッチ時刻 (epoch ms) を壁時計 (local time) で求める。
87
- * 到達不能なら FlumeParseError を返す
88
- */
89
- function flumeCronNext(cron, afterMs) {
90
- let candidate = Math.floor(afterMs / MINUTE_MS) * MINUTE_MS + MINUTE_MS;
91
- for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) {
92
- const date = new Date(candidate);
93
- if (!cron.months.has(date.getMonth() + 1)) {
94
- candidate = new Date(date.getFullYear(), date.getMonth() + 1, 1, 0, 0, 0, 0).getTime();
95
- continue;
96
- }
97
- if (!matchesDay(cron, date)) {
98
- candidate = new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1, 0, 0, 0, 0).getTime();
99
- continue;
100
- }
101
- if (!cron.hours.has(date.getHours())) {
102
- candidate = new Date(date.getFullYear(), date.getMonth(), date.getDate(), date.getHours() + 1, 0, 0, 0).getTime();
103
- continue;
104
- }
105
- if (!cron.minutes.has(date.getMinutes())) {
106
- candidate += MINUTE_MS;
107
- continue;
108
- }
109
- return candidate;
110
- }
111
- return new FlumeParseError(`cron "${cron.source}" has no next time within bound`);
112
- }
113
- function matchesDay(cron, date) {
114
- const domMatch = cron.daysOfMonth.has(date.getDate());
115
- const dowMatch = cron.daysOfWeek.has(date.getDay());
116
- if (cron.domRestricted && cron.dowRestricted) return domMatch || dowMatch;
117
- if (cron.domRestricted) return domMatch;
118
- if (cron.dowRestricted) return dowMatch;
119
- return true;
120
- }
121
- //#endregion
122
- //#region lib/time/time-scheduler.ts
123
- const MAX_TIMEOUT_MS = 2e9;
124
- const FIRE_TOLERANCE_MS = 1e3;
125
- /**
126
- * cron に従って `onTick` を駆動するタイマーループ。外部接続を持たないため reconnect 不要。
127
- * IO 境界は全て `attempt` 経由で扱い、停止後はコールバックを発火しない
128
- */
129
- var FlumeTimeScheduler = class {
130
- props;
131
- log;
132
- isStoppedFlag = false;
133
- timer = null;
134
- target = 0;
135
- constructor(props) {
136
- this.props = props;
137
- this.log = new FlumeLogger({
138
- source: "time.scheduler",
139
- handler: props.onLog,
140
- deps: props.deps
141
- });
142
- }
143
- get isStopped() {
144
- return this.isStoppedFlag;
145
- }
146
- start() {
147
- const next = flumeCronNext(this.props.cron, safeNow({ deps: this.props.deps }));
148
- if (next instanceof FlumeParseError) {
149
- this.log.error({
150
- action: "cron.no-next",
151
- message: next.message,
152
- error: next
153
- });
154
- return next;
155
- }
156
- this.target = next;
157
- this.log.info({
158
- action: "scheduler.start",
159
- message: `next fire at ${new Date(next).toISOString()}`,
160
- detail: { target: next }
161
- });
162
- this.arm();
163
- return null;
164
- }
165
- stop() {
166
- this.isStoppedFlag = true;
167
- this.clearTimer();
168
- }
169
- arm() {
170
- this.clearTimer();
171
- const delay = Math.max(0, this.target - safeNow({ deps: this.props.deps }));
172
- const capped = Math.min(delay, MAX_TIMEOUT_MS);
173
- const result = attempt(() => this.props.deps.setTimeout(() => this.onWake(), capped));
174
- if (result instanceof Error) {
175
- this.log.error({
176
- action: "scheduler.arm.error",
177
- message: safeErrorMessage({ error: result }),
178
- error: result
179
- });
180
- this.timer = null;
181
- return;
182
- }
183
- this.timer = result;
184
- }
185
- onWake() {
186
- this.timer = null;
187
- if (this.isStoppedFlag) return;
188
- if (this.target - safeNow({ deps: this.props.deps }) > FIRE_TOLERANCE_MS) {
189
- this.arm();
190
- return;
191
- }
192
- const firedAt = this.target;
193
- safeInvokeCallback({
194
- fn: () => this.props.onTick(firedAt),
195
- onError: (error) => {
196
- this.log.error({
197
- action: "scheduler.tick.error",
198
- message: safeErrorMessage({ error }),
199
- error
200
- });
201
- }
202
- });
203
- const next = flumeCronNext(this.props.cron, firedAt);
204
- if (next instanceof FlumeParseError) {
205
- this.log.error({
206
- action: "cron.no-next",
207
- message: next.message,
208
- error: next
209
- });
210
- return;
211
- }
212
- this.target = next;
213
- this.arm();
214
- }
215
- clearTimer() {
216
- if (this.timer === null) return;
217
- const handle = this.timer;
218
- const result = attempt(() => this.props.deps.clearTimeout(handle));
219
- if (result instanceof Error) this.log.error({
220
- action: "scheduler.timer.clear.error",
221
- message: safeErrorMessage({ error: result }),
222
- error: result
223
- });
224
- this.timer = null;
225
- }
226
- };
227
- //#endregion
228
- //#region lib/time/time-catchup.ts
229
- const DEFAULT_MISSED_WINDOW_MS = 1440 * 60 * 1e3;
230
- const MAX_CATCHUP_MATCHES = 1e4;
231
- /**
232
- * `lastFiredAt` から `now` までに過ぎ去った cron マッチを policy に従って列挙する。
233
- *
234
- * - policy.mode === "off" : 常に空配列
235
- * - policy.mode === "lastOnly" : 過ぎ去ったマッチの中で最も新しいもの 1 件
236
- * - policy.mode === "missed" : maxWindowMs (既定 24h) 以内に過ぎ去ったすべてのマッチ。
237
- * window の起点は `max(lastFiredAt, now - maxWindowMs)`
238
- *
239
- * 到達不能 cron や catastrophic な policy ミス指定の場合は FlumeParseError を返す
240
- * (catchup 列挙だけで失敗させる。source 本体の起動は別判断)
241
- */
242
- function flumeCollectCatchupMatches(props) {
243
- if (props.policy.mode === "off") return [];
244
- if (props.lastFiredAt >= props.now) return [];
245
- const windowStart = props.policy.mode === "missed" ? Math.max(props.lastFiredAt, props.now - (props.policy.maxWindowMs ?? DEFAULT_MISSED_WINDOW_MS)) : props.lastFiredAt;
246
- const matches = [];
247
- let cursor = windowStart;
248
- for (let i = 0; i < MAX_CATCHUP_MATCHES; i++) {
249
- const next = flumeCronNext(props.cron, cursor);
250
- if (next instanceof FlumeParseError) return next;
251
- if (next > props.now) break;
252
- matches.push(next);
253
- cursor = next;
254
- }
255
- if (props.policy.mode === "lastOnly") {
256
- const last = matches[matches.length - 1];
257
- return last === void 0 ? [] : [last];
258
- }
259
- return matches;
260
- }
261
- //#endregion
262
- //#region lib/time/time-source.ts
263
- /**
264
- * cron スケジュールで tick を emit する Source。外部接続を持たないため
265
- * 起動成功と同時に `connected` になり reconnect の対象外。
266
- *
267
- * options.statePersister + options.catchupPolicy を渡すと:
268
- * 1. 起動時に lastFiredAt を読み出す
269
- * 2. lastFiredAt から now までの過ぎ去った cron マッチを policy に従って再発火する
270
- * 3. 各 tick 後に lastFiredAt を保存する (best-effort, ブロックしない)
271
- *
272
- * 保存先や形式は flume の関知ではなく statePersister の実装が決める (純粋 DI)
273
- */
274
- var FlumeTimeSource = class extends FlumeSource {
275
- options;
276
- name = "time";
277
- scheduler = null;
278
- constructor(options) {
279
- super();
280
- this.options = options;
281
- }
282
- async connect(ctx) {
283
- this.setStatus("connecting");
284
- const cron = parseCron(this.options.cron);
285
- if (cron instanceof FlumeParseError) {
286
- const error = new FlumeStartError(`Time source: invalid cron "${this.options.cron}": ${cron.message}`);
287
- ctx.log.error({
288
- action: "source.start.failed",
289
- message: safeErrorMessage({ error }),
290
- error
291
- });
292
- this.setStatus("disconnected", error.message);
293
- return error;
294
- }
295
- const persister = this.options.statePersister ?? null;
296
- const lastFiredAt = persister === null ? null : await this.loadLastFiredAt(ctx, persister);
297
- this.scheduler = new FlumeTimeScheduler({
298
- cron,
299
- onLog: ctx.log.handler,
300
- deps: ctx.deps,
301
- onTick: (firedAt) => this.handleTick(ctx, firedAt, persister)
302
- });
303
- const result = this.scheduler.start();
304
- if (result instanceof Error) {
305
- const error = new FlumeStartError(`Time source: ${safeErrorMessage({ error: result })}`);
306
- this.setStatus("disconnected", error.message);
307
- return error;
308
- }
309
- this.setStatus("connected");
310
- if (lastFiredAt !== null && persister !== null) this.runCatchup({
311
- ctx,
312
- cron,
313
- lastFiredAt,
314
- persister
315
- });
316
- return null;
317
- }
318
- disconnect() {
319
- this.scheduler?.stop();
320
- this.scheduler = null;
321
- }
322
- handleTick(ctx, firedAt, persister) {
323
- this.emitTick(ctx, firedAt);
324
- if (persister !== null) this.saveLastFiredAt(ctx, persister, firedAt);
325
- }
326
- emitTick(ctx, firedAt) {
327
- const tick = {
328
- firedAt,
329
- cron: this.options.cron
330
- };
331
- const custom = this.safeMessage(ctx, tick);
332
- this.emit({
333
- source: "time",
334
- type: typeof custom.type === "string" ? custom.type : "tick",
335
- data: isRecord(custom.data) ? custom.data : {
336
- firedAt,
337
- cron: this.options.cron
338
- },
339
- meta: this.normalizeMeta(custom.meta, this.options.cron),
340
- receivedAt: safeNow({ deps: ctx.deps })
341
- });
342
- }
343
- runCatchup(props) {
344
- const policy = this.options.catchupPolicy ?? { mode: "off" };
345
- if (policy.mode === "off") return;
346
- const matches = flumeCollectCatchupMatches({
347
- cron: props.cron,
348
- lastFiredAt: props.lastFiredAt,
349
- now: safeNow({ deps: props.ctx.deps }),
350
- policy
351
- });
352
- if (matches instanceof FlumeParseError) {
353
- props.ctx.log.warn({
354
- action: "time.catchup.failed",
355
- message: matches.message,
356
- error: matches
357
- });
358
- return;
359
- }
360
- if (matches.length === 0) return;
361
- props.ctx.log.info({
362
- action: "time.catchup.fired",
363
- message: `catchup ${matches.length} missed tick(s) since ${new Date(props.lastFiredAt).toISOString()}`,
364
- detail: {
365
- count: matches.length,
366
- policy: policy.mode
367
- }
368
- });
369
- for (const firedAt of matches) this.emitTick(props.ctx, firedAt);
370
- const last = matches[matches.length - 1];
371
- if (last !== void 0) this.saveLastFiredAt(props.ctx, props.persister, last);
372
- }
373
- async loadLastFiredAt(ctx, persister) {
374
- const result = await attempt(() => persister.load());
375
- if (result instanceof Error) {
376
- ctx.log.warn({
377
- action: "time.state.load.error",
378
- message: safeErrorMessage({ error: result }),
379
- error: result
380
- });
381
- return null;
382
- }
383
- if (result === null) return null;
384
- if (typeof result.lastFiredAt !== "number" || !Number.isFinite(result.lastFiredAt)) return null;
385
- return result.lastFiredAt;
386
- }
387
- saveLastFiredAt(ctx, persister, lastFiredAt) {
388
- safeInvokeCallback({
389
- fn: () => persister.save({ lastFiredAt }),
390
- onError: (error) => {
391
- ctx.log.warn({
392
- action: "time.state.save.error",
393
- message: safeErrorMessage({ error: safeNormalizeError({ value: error }) }),
394
- error
395
- });
396
- }
397
- });
398
- }
399
- safeMessage(ctx, tick) {
400
- const message = this.options.message;
401
- if (!message) return {};
402
- const result = attempt(() => message(tick));
403
- if (result instanceof Error) {
404
- const error = safeNormalizeError({ value: result });
405
- ctx.log.warn({
406
- action: "message.error",
407
- message: safeErrorMessage({ error }),
408
- error,
409
- detail: { firedAt: tick.firedAt }
410
- });
411
- return {};
412
- }
413
- return isRecord(result) ? result : {};
414
- }
415
- normalizeMeta(meta, cron) {
416
- if (!isRecord(meta)) return { cron };
417
- const normalized = {};
418
- for (const key of Object.keys(meta)) {
419
- const value = meta[key];
420
- if (typeof value === "string") normalized[key] = value;
421
- }
422
- if (Object.keys(normalized).length === 0) return { cron };
423
- return normalized;
424
- }
425
- };
426
- //#endregion
427
- export { parseCron as i, flumeCollectCatchupMatches as n, flumeCronNext as r, FlumeTimeSource as t };