@interactive-inc/flume 0.9.4 → 0.10.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/discord.d.ts +1 -1
- package/dist/discord.js +1 -1
- package/dist/flume-source.d.ts +44 -1
- package/dist/github.d.ts +1 -1
- package/dist/index.d.ts +61 -17
- package/dist/index.js +65 -11
- package/dist/parse-cron.d.ts +55 -0
- package/dist/slack.d.ts +1 -1
- package/dist/slack.js +1 -1
- package/dist/time-source.js +427 -0
- package/dist/time.d.ts +1 -36
- package/dist/time.js +1 -318
- package/package.json +1 -1
- package/dist/parse-error.d.ts +0 -9
package/dist/discord.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { D as FlumeSourceStartContext, o as FlumeDiscordSourceOptions, t as FlumeSource } from "./flume-source.js";
|
|
2
2
|
|
|
3
3
|
//#region lib/discord/discord-source.d.ts
|
|
4
4
|
declare class FlumeDiscordSource extends FlumeSource {
|
package/dist/discord.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
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
2
|
import { t as FlumeConnectionError } from "./connection-error.js";
|
|
3
|
-
import { i as safeRandom, n as scheduleFlumeReconnect, r as FlumeReconnector, t as safeStringify } from "./safe-stringify.js";
|
|
4
3
|
import { t as isRecord } from "./is-record.js";
|
|
4
|
+
import { i as safeRandom, n as scheduleFlumeReconnect, r as FlumeReconnector, t as safeStringify } from "./safe-stringify.js";
|
|
5
5
|
import { t as safeJsonParse } from "./safe-json-parse.js";
|
|
6
6
|
import { z } from "zod/v4";
|
|
7
7
|
//#region lib/discord/discord-gateway-session.ts
|
package/dist/flume-source.d.ts
CHANGED
|
@@ -113,6 +113,15 @@ type FlumeStreamItem = {
|
|
|
113
113
|
log: FlumeLog;
|
|
114
114
|
};
|
|
115
115
|
type FlumeStreamHandler = (item: FlumeStreamItem) => void;
|
|
116
|
+
/**
|
|
117
|
+
* `FlumeConfluence` が onEvent に渡す item。Flume 単体の `FlumeStreamItem` に
|
|
118
|
+
* `groupId` (`add(id, ...)` で渡したグループ識別子) をスタンプしたもの。
|
|
119
|
+
* 合流ストリームの provenance を呼び出し側で復元する必要がなくなる
|
|
120
|
+
*/
|
|
121
|
+
type FlumeConfluenceItem = FlumeStreamItem & {
|
|
122
|
+
readonly groupId: string;
|
|
123
|
+
};
|
|
124
|
+
type FlumeConfluenceItemHandler = (item: FlumeConfluenceItem) => void;
|
|
116
125
|
type FlumeStreamOverflow = "drop-oldest" | "drop-newest";
|
|
117
126
|
type FlumeStreamOptions = {
|
|
118
127
|
/** バッファ上限 (既定 1000)。consumer が遅れて溢れたら onOverflow に従う */buffer?: number; /** バッファ溢れ時の方針 (既定 "drop-oldest") */
|
|
@@ -207,9 +216,43 @@ type FlumeTimeMessage = {
|
|
|
207
216
|
data?: Record<string, unknown>;
|
|
208
217
|
meta?: Record<string, string>;
|
|
209
218
|
};
|
|
219
|
+
/**
|
|
220
|
+
* 起動 / 終了をまたいだ状態を 1 つ載せる純粋な DI ポート。flume 内部で fs / db / network を
|
|
221
|
+
* 触らないように、I/O の場所と方式は host が決める。load の失敗は null 復帰扱い、save の
|
|
222
|
+
* 失敗は best-effort (source 側で log するが throw しない)
|
|
223
|
+
*/
|
|
224
|
+
type FlumeStatePersister<S> = {
|
|
225
|
+
load(): Promise<S | null>;
|
|
226
|
+
save(state: S): Promise<void>;
|
|
227
|
+
};
|
|
228
|
+
/** `FlumeTimeSource` の statePersister が保存する形 */
|
|
229
|
+
type FlumeTimeSourceState = {
|
|
230
|
+
readonly lastFiredAt: number;
|
|
231
|
+
};
|
|
232
|
+
/**
|
|
233
|
+
* `FlumeTimeSource` の起動時 catch-up 方針。statePersister が読めた lastFiredAt から
|
|
234
|
+
* 現在時刻までに過ぎ去った cron マッチを再発火するかどうかを決める。
|
|
235
|
+
* - "off" : 何もしない (既定。後方互換)
|
|
236
|
+
* - "lastOnly" : 直近に過ぎ去ったマッチを 1 件だけ再発火
|
|
237
|
+
* - "missed" : maxWindowMs (既定 24h) 以内のすべての過ぎ去ったマッチを順に再発火
|
|
238
|
+
*/
|
|
239
|
+
type FlumeCatchupPolicy = {
|
|
240
|
+
readonly mode: "off";
|
|
241
|
+
} | {
|
|
242
|
+
readonly mode: "lastOnly";
|
|
243
|
+
} | {
|
|
244
|
+
readonly mode: "missed";
|
|
245
|
+
readonly maxWindowMs?: number;
|
|
246
|
+
};
|
|
210
247
|
type FlumeTimeSourceOptions = {
|
|
211
248
|
/** 5 フィールド cron 式 (minute hour day-of-month month day-of-week)。壁時計 (local time) 基準 */cron: string;
|
|
212
249
|
message?: (tick: FlumeTimeTick) => FlumeTimeMessage;
|
|
250
|
+
/**
|
|
251
|
+
* 起動 / 終了をまたいで `lastFiredAt` を覚えておく口。未指定なら catchup は無効化される
|
|
252
|
+
* (lastFiredAt が分からないので)。flume は fs / db を触らないので host が実装を渡す
|
|
253
|
+
*/
|
|
254
|
+
statePersister?: FlumeStatePersister<FlumeTimeSourceState>; /** statePersister が読めた lastFiredAt を元に過去 tick を再発火する方針 (既定 "off") */
|
|
255
|
+
catchupPolicy?: FlumeCatchupPolicy;
|
|
213
256
|
};
|
|
214
257
|
type FlumeGatewayMessage = z.infer<typeof FlumeGatewayMessageSchema>;
|
|
215
258
|
type FlumeSlackEnvelope = z.infer<typeof FlumeSlackEnvelopeSchema>;
|
|
@@ -272,4 +315,4 @@ declare abstract class FlumeSource {
|
|
|
272
315
|
protected abstract disconnect(): Promise<void> | void;
|
|
273
316
|
}
|
|
274
317
|
//#endregion
|
|
275
|
-
export {
|
|
318
|
+
export { FlumeStatus as A, FlumeTimerHandle as B, FlumeSlackEvent as C, FlumeSourceStartContext as D, FlumeSourceName as E, FlumeTimeEvent as F, FlumeTimeMessage as I, FlumeTimeSourceOptions as L, FlumeStreamItem as M, FlumeStreamOptions as N, FlumeSourceStatus as O, FlumeStreamOverflow as P, FlumeTimeSourceState as R, FlumeSlackEnvelope as S, FlumeSourceLocalStatusHandler as T, FlumeLogger as V, FlumeLogLevel as _, FlumeDiscordEvent as a, FlumeRuntimeDeps as b, FlumeEvent as c, FlumeGitHubEvent as d, FlumeGitHubNotification as f, FlumeLogInput as g, FlumeLogHandler as h, FlumeConfluenceItemHandler as i, FlumeStreamHandler as j, FlumeStatePersister as k, FlumeEventHandler as l, FlumeLog as m, FlumeCatchupPolicy as n, FlumeDiscordSourceOptions as o, FlumeGitHubSourceOptions as p, FlumeConfluenceItem as r, FlumeErrorHandler as s, FlumeSource as t, FlumeGatewayMessage as u, FlumeReconnectConfig as v, FlumeSlackSourceOptions as w, FlumeSlackConnectionResponse as x, FlumeReconnectOptions as y, FlumeTimeTick as z };
|
package/dist/github.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { D as FlumeSourceStartContext, f as FlumeGitHubNotification, p as FlumeGitHubSourceOptions, t as FlumeSource } from "./flume-source.js";
|
|
2
2
|
|
|
3
3
|
//#region lib/github/github-source.d.ts
|
|
4
4
|
declare class FlumeGitHubSource extends FlumeSource {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { A as
|
|
2
|
-
import {
|
|
1
|
+
import { A as FlumeStatus, B as FlumeTimerHandle, C as FlumeSlackEvent, D as FlumeSourceStartContext, E as FlumeSourceName, F as FlumeTimeEvent, I as FlumeTimeMessage, L as FlumeTimeSourceOptions, M as FlumeStreamItem, N as FlumeStreamOptions, O as FlumeSourceStatus, P as FlumeStreamOverflow, R as FlumeTimeSourceState, S as FlumeSlackEnvelope, T as FlumeSourceLocalStatusHandler, V as FlumeLogger, _ as FlumeLogLevel, a as FlumeDiscordEvent, b as FlumeRuntimeDeps, c as FlumeEvent, d as FlumeGitHubEvent, f as FlumeGitHubNotification, g as FlumeLogInput, h as FlumeLogHandler, i as FlumeConfluenceItemHandler, j as FlumeStreamHandler, k as FlumeStatePersister, l as FlumeEventHandler, m as FlumeLog, n as FlumeCatchupPolicy, o as FlumeDiscordSourceOptions, p as FlumeGitHubSourceOptions, r as FlumeConfluenceItem, s as FlumeErrorHandler, t as FlumeSource, u as FlumeGatewayMessage, v as FlumeReconnectConfig, w as FlumeSlackSourceOptions, x as FlumeSlackConnectionResponse, y as FlumeReconnectOptions, z as FlumeTimeTick } from "./flume-source.js";
|
|
2
|
+
import { i as FlumeParseError, r as FlumeTimeSource, t as FlumeCron } from "./parse-cron.js";
|
|
3
3
|
|
|
4
4
|
//#region lib/deps.d.ts
|
|
5
5
|
/**
|
|
@@ -29,14 +29,14 @@ declare class FlumeConnectionError extends Error {
|
|
|
29
29
|
}
|
|
30
30
|
//#endregion
|
|
31
31
|
//#region lib/errors/http-error.d.ts
|
|
32
|
-
type Props$
|
|
32
|
+
type Props$5 = {
|
|
33
33
|
message: string;
|
|
34
34
|
status: number;
|
|
35
35
|
cause?: unknown;
|
|
36
36
|
};
|
|
37
37
|
declare class FlumeHttpError extends Error {
|
|
38
38
|
readonly status: number;
|
|
39
|
-
constructor(props: Props$
|
|
39
|
+
constructor(props: Props$5);
|
|
40
40
|
}
|
|
41
41
|
//#endregion
|
|
42
42
|
//#region lib/errors/start-error.d.ts
|
|
@@ -48,7 +48,7 @@ declare class FlumeStartError extends Error {
|
|
|
48
48
|
}
|
|
49
49
|
//#endregion
|
|
50
50
|
//#region lib/flume-stream.d.ts
|
|
51
|
-
type Props$
|
|
51
|
+
type Props$4 = {
|
|
52
52
|
buffer: number;
|
|
53
53
|
onOverflow: FlumeStreamOverflow;
|
|
54
54
|
onClose: () => void;
|
|
@@ -63,7 +63,7 @@ declare class FlumeStream implements AsyncIterableIterator<FlumeStreamItem> {
|
|
|
63
63
|
private readonly items;
|
|
64
64
|
private readonly resolvers;
|
|
65
65
|
private closed;
|
|
66
|
-
constructor(props: Props$
|
|
66
|
+
constructor(props: Props$4);
|
|
67
67
|
push(item: FlumeStreamItem): void;
|
|
68
68
|
close(): void;
|
|
69
69
|
next(): Promise<IteratorResult<FlumeStreamItem>>;
|
|
@@ -89,7 +89,7 @@ type FlumeCloseError = {
|
|
|
89
89
|
source: string;
|
|
90
90
|
error: Error;
|
|
91
91
|
};
|
|
92
|
-
type Props$
|
|
92
|
+
type Props$3 = {
|
|
93
93
|
finalStatuses: ReadonlyArray<FlumeSourceStatus>;
|
|
94
94
|
closeErrors: ReadonlyArray<FlumeCloseError>;
|
|
95
95
|
};
|
|
@@ -101,7 +101,7 @@ type Props$2 = {
|
|
|
101
101
|
declare class FlumeClosed {
|
|
102
102
|
private readonly props;
|
|
103
103
|
readonly kind: "closed";
|
|
104
|
-
constructor(props: Props$
|
|
104
|
+
constructor(props: Props$3);
|
|
105
105
|
statuses(): ReadonlyArray<FlumeSourceStatus>;
|
|
106
106
|
/**
|
|
107
107
|
* `runClose` 中に `source.stop()` が rejected で settle した source の名前と
|
|
@@ -112,7 +112,7 @@ declare class FlumeClosed {
|
|
|
112
112
|
}
|
|
113
113
|
//#endregion
|
|
114
114
|
//#region lib/flume-running.d.ts
|
|
115
|
-
type Props$
|
|
115
|
+
type Props$2 = {
|
|
116
116
|
sources: ReadonlyArray<FlumeSource>;
|
|
117
117
|
signal?: AbortSignal;
|
|
118
118
|
log: FlumeLogger;
|
|
@@ -128,7 +128,7 @@ declare class FlumeRunning {
|
|
|
128
128
|
readonly kind: "running";
|
|
129
129
|
private closePromise;
|
|
130
130
|
private readonly onAbort;
|
|
131
|
-
constructor(props: Props$
|
|
131
|
+
constructor(props: Props$2);
|
|
132
132
|
close(): Promise<FlumeClosed>;
|
|
133
133
|
statuses(): ReadonlyArray<FlumeSourceStatus>;
|
|
134
134
|
/**
|
|
@@ -198,8 +198,12 @@ declare class Flume {
|
|
|
198
198
|
}
|
|
199
199
|
//#endregion
|
|
200
200
|
//#region lib/flume-confluence.d.ts
|
|
201
|
-
type Props = {
|
|
202
|
-
/**
|
|
201
|
+
type Props$1 = {
|
|
202
|
+
/**
|
|
203
|
+
* 配下の全 Flume の firehose をここへ合流させる単一 sink。
|
|
204
|
+
* 各 item には発信元グループの id が `groupId` としてスタンプされる
|
|
205
|
+
*/
|
|
206
|
+
onEvent?: FlumeConfluenceItemHandler; /** error レベル log だけ (全 Flume 共通) */
|
|
203
207
|
onError?: FlumeErrorHandler;
|
|
204
208
|
deps?: FlumeRuntimeDeps;
|
|
205
209
|
reconnect?: FlumeReconnectOptions;
|
|
@@ -207,22 +211,62 @@ type Props = {
|
|
|
207
211
|
/**
|
|
208
212
|
* 複数の `Flume` を束ねて動的に増減させる上位レイヤー。各 Flume は immutable のまま、
|
|
209
213
|
* `add()` で新しいグループを起動し `remove()` で個別に停止する。全グループの firehose は
|
|
210
|
-
* `onEvent` 1
|
|
214
|
+
* `onEvent` 1 本に合流し、各 item には発信元グループ id が `groupId` としてスタンプされる。
|
|
215
|
+
* Flume 本体の FSM / rollback / reconnect はそのまま再利用される。
|
|
211
216
|
*
|
|
212
|
-
* id
|
|
213
|
-
*
|
|
217
|
+
* `replace(id, sources)` は同じ id のグループを差し替える。新グループを先に起動し、
|
|
218
|
+
* 起動成功時にのみ旧グループを停止するので連続稼働を維持できる (token rotation 用途)。
|
|
219
|
+
* 起動失敗時は旧グループはそのまま走り続ける。
|
|
220
|
+
*
|
|
221
|
+
* throw しない流儀に従い `add()` / `replace()` は `Error | null` を返す
|
|
214
222
|
*/
|
|
215
223
|
declare class FlumeConfluence {
|
|
216
224
|
private readonly props;
|
|
217
225
|
private readonly running;
|
|
218
|
-
|
|
226
|
+
private readonly deps;
|
|
227
|
+
constructor(props?: Props$1);
|
|
219
228
|
/** sources を 1 グループとして起動。id 重複や起動失敗は `Error` で返す (throw しない) */
|
|
220
229
|
add(id: string, sources: ReadonlyArray<FlumeSource>): Promise<Error | null>;
|
|
230
|
+
/**
|
|
231
|
+
* 既存グループを新しい sources で差し替える。新グループを先に起動し、成功時のみ旧を停止する。
|
|
232
|
+
* 起動失敗 / replaceTimeoutMs (既定 10s) 経過時は新グループを破棄し旧を走らせたまま返す。
|
|
233
|
+
* 旧グループが存在しない場合は `Error` を返す (replace は add と違ってグループの存在を前提とする)
|
|
234
|
+
*/
|
|
235
|
+
replace(id: string, sources: ReadonlyArray<FlumeSource>, options?: {
|
|
236
|
+
readonly replaceTimeoutMs?: number;
|
|
237
|
+
}): Promise<Error | null>;
|
|
221
238
|
/** 指定グループだけ close。他グループは無停止。未知の id は no-op */
|
|
222
239
|
remove(id: string): Promise<void>;
|
|
223
240
|
closeAll(): Promise<void>;
|
|
224
241
|
has(id: string): boolean;
|
|
225
242
|
ids(): ReadonlyArray<string>;
|
|
243
|
+
/**
|
|
244
|
+
* 1 グループ分の Flume を開いて FlumeRunning を返す。timeoutMs を指定すると open() を
|
|
245
|
+
* AbortSignal でレース掛けし、超過時に新グループ起動を中止する。失敗時の rollback は
|
|
246
|
+
* Flume 本体に任せる
|
|
247
|
+
*/
|
|
248
|
+
private openGroup;
|
|
249
|
+
private wrapOnEvent;
|
|
226
250
|
}
|
|
227
251
|
//#endregion
|
|
228
|
-
|
|
252
|
+
//#region lib/time/time-catchup.d.ts
|
|
253
|
+
type Props = {
|
|
254
|
+
cron: FlumeCron;
|
|
255
|
+
lastFiredAt: number;
|
|
256
|
+
now: number;
|
|
257
|
+
policy: FlumeCatchupPolicy;
|
|
258
|
+
};
|
|
259
|
+
/**
|
|
260
|
+
* `lastFiredAt` から `now` までに過ぎ去った cron マッチを policy に従って列挙する。
|
|
261
|
+
*
|
|
262
|
+
* - policy.mode === "off" : 常に空配列
|
|
263
|
+
* - policy.mode === "lastOnly" : 過ぎ去ったマッチの中で最も新しいもの 1 件
|
|
264
|
+
* - policy.mode === "missed" : maxWindowMs (既定 24h) 以内に過ぎ去ったすべてのマッチ。
|
|
265
|
+
* window の起点は `max(lastFiredAt, now - maxWindowMs)`
|
|
266
|
+
*
|
|
267
|
+
* 到達不能 cron や catastrophic な policy ミス指定の場合は FlumeParseError を返す
|
|
268
|
+
* (catchup 列挙だけで失敗させる。source 本体の起動は別判断)
|
|
269
|
+
*/
|
|
270
|
+
declare function flumeCollectCatchupMatches(props: Props): ReadonlyArray<number> | FlumeParseError;
|
|
271
|
+
//#endregion
|
|
272
|
+
export { Flume, type FlumeCatchupPolicy, type FlumeCloseError, FlumeClosed, FlumeConfluence, type FlumeConfluenceItem, type FlumeConfluenceItemHandler, FlumeConnectionError, type FlumeDiscordEvent, type FlumeDiscordSourceOptions, type FlumeErrorHandler, type FlumeEvent, type FlumeEventHandler, type FlumeGatewayMessage, type FlumeGitHubEvent, type FlumeGitHubNotification, type FlumeGitHubSourceOptions, FlumeHttpError, type FlumeLog, type FlumeLogHandler, type FlumeLogInput, type FlumeLogLevel, type FlumeOptions, FlumeParseError, type FlumeReconnectConfig, type FlumeReconnectOptions, FlumeRunning, type FlumeRuntimeDeps, type FlumeSlackConnectionResponse, type FlumeSlackEnvelope, type FlumeSlackEvent, type FlumeSlackSourceOptions, FlumeSource, type FlumeSourceLocalStatusHandler, type FlumeSourceName, type FlumeSourceStartContext, type FlumeSourceStatus, FlumeStartError, type FlumeStatePersister, type FlumeStatus, type FlumeStreamHandler, type FlumeStreamItem, type FlumeStreamOptions, type FlumeStreamOverflow, type FlumeTimeEvent, type FlumeTimeMessage, FlumeTimeSource, type FlumeTimeSourceOptions, type FlumeTimeSourceState, type FlumeTimeTick, type FlumeTimerHandle, createFlumeDefaultDeps, flumeCollectCatchupMatches };
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { a as FlumeStartError, c as safeNormalizeError, l as safeErrorMessage, n as safeInvokeCallback, o as FlumeParseError, r as FlumeLogger, s as attempt, t as FlumeSource } 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
|
+
import { n as flumeCollectCatchupMatches, t as FlumeTimeSource } from "./time-source.js";
|
|
4
5
|
//#region lib/deps.ts
|
|
5
6
|
/**
|
|
6
7
|
* `globalThis.WebSocket` の現在の値を返す。
|
|
@@ -520,30 +521,31 @@ var Flume = class {
|
|
|
520
521
|
};
|
|
521
522
|
//#endregion
|
|
522
523
|
//#region lib/flume-confluence.ts
|
|
524
|
+
const DEFAULT_REPLACE_TIMEOUT_MS = 1e4;
|
|
523
525
|
/**
|
|
524
526
|
* 複数の `Flume` を束ねて動的に増減させる上位レイヤー。各 Flume は immutable のまま、
|
|
525
527
|
* `add()` で新しいグループを起動し `remove()` で個別に停止する。全グループの firehose は
|
|
526
|
-
* `onEvent` 1
|
|
528
|
+
* `onEvent` 1 本に合流し、各 item には発信元グループ id が `groupId` としてスタンプされる。
|
|
529
|
+
* Flume 本体の FSM / rollback / reconnect はそのまま再利用される。
|
|
527
530
|
*
|
|
528
|
-
* id
|
|
529
|
-
*
|
|
531
|
+
* `replace(id, sources)` は同じ id のグループを差し替える。新グループを先に起動し、
|
|
532
|
+
* 起動成功時にのみ旧グループを停止するので連続稼働を維持できる (token rotation 用途)。
|
|
533
|
+
* 起動失敗時は旧グループはそのまま走り続ける。
|
|
534
|
+
*
|
|
535
|
+
* throw しない流儀に従い `add()` / `replace()` は `Error | null` を返す
|
|
530
536
|
*/
|
|
531
537
|
var FlumeConfluence = class {
|
|
532
538
|
props;
|
|
533
539
|
running = /* @__PURE__ */ new Map();
|
|
540
|
+
deps;
|
|
534
541
|
constructor(props = {}) {
|
|
535
542
|
this.props = props;
|
|
543
|
+
this.deps = props.deps ?? createFlumeDefaultDeps();
|
|
536
544
|
}
|
|
537
545
|
/** sources を 1 グループとして起動。id 重複や起動失敗は `Error` で返す (throw しない) */
|
|
538
546
|
async add(id, sources) {
|
|
539
547
|
if (this.running.has(id)) return new FlumeStartError(`FlumeConfluence: id already added: ${id}`);
|
|
540
|
-
const running = await
|
|
541
|
-
sources,
|
|
542
|
-
onEvent: this.props.onEvent,
|
|
543
|
-
onError: this.props.onError,
|
|
544
|
-
deps: this.props.deps,
|
|
545
|
-
reconnect: this.props.reconnect
|
|
546
|
-
}).open();
|
|
548
|
+
const running = await this.openGroup(id, sources, void 0);
|
|
547
549
|
if (running instanceof Error) return running;
|
|
548
550
|
if (this.running.has(id)) {
|
|
549
551
|
await running.close();
|
|
@@ -552,6 +554,25 @@ var FlumeConfluence = class {
|
|
|
552
554
|
this.running.set(id, running);
|
|
553
555
|
return null;
|
|
554
556
|
}
|
|
557
|
+
/**
|
|
558
|
+
* 既存グループを新しい sources で差し替える。新グループを先に起動し、成功時のみ旧を停止する。
|
|
559
|
+
* 起動失敗 / replaceTimeoutMs (既定 10s) 経過時は新グループを破棄し旧を走らせたまま返す。
|
|
560
|
+
* 旧グループが存在しない場合は `Error` を返す (replace は add と違ってグループの存在を前提とする)
|
|
561
|
+
*/
|
|
562
|
+
async replace(id, sources, options) {
|
|
563
|
+
const previous = this.running.get(id);
|
|
564
|
+
if (!previous) return new FlumeStartError(`FlumeConfluence: id not running: ${id}`);
|
|
565
|
+
const timeoutMs = options?.replaceTimeoutMs ?? DEFAULT_REPLACE_TIMEOUT_MS;
|
|
566
|
+
const next = await this.openGroup(id, sources, timeoutMs);
|
|
567
|
+
if (next instanceof Error) return next;
|
|
568
|
+
if (this.running.get(id) !== previous) {
|
|
569
|
+
await next.close();
|
|
570
|
+
return new FlumeStartError(`FlumeConfluence: ${id} concurrently mutated during replace`);
|
|
571
|
+
}
|
|
572
|
+
this.running.set(id, next);
|
|
573
|
+
await previous.close();
|
|
574
|
+
return null;
|
|
575
|
+
}
|
|
555
576
|
/** 指定グループだけ close。他グループは無停止。未知の id は no-op */
|
|
556
577
|
async remove(id) {
|
|
557
578
|
const running = this.running.get(id);
|
|
@@ -569,6 +590,39 @@ var FlumeConfluence = class {
|
|
|
569
590
|
ids() {
|
|
570
591
|
return [...this.running.keys()];
|
|
571
592
|
}
|
|
593
|
+
/**
|
|
594
|
+
* 1 グループ分の Flume を開いて FlumeRunning を返す。timeoutMs を指定すると open() を
|
|
595
|
+
* AbortSignal でレース掛けし、超過時に新グループ起動を中止する。失敗時の rollback は
|
|
596
|
+
* Flume 本体に任せる
|
|
597
|
+
*/
|
|
598
|
+
async openGroup(id, sources, timeoutMs) {
|
|
599
|
+
const controller = timeoutMs === void 0 ? null : new AbortController();
|
|
600
|
+
const timeoutHandle = controller === null ? null : this.deps.setTimeout(() => controller.abort(), timeoutMs ?? DEFAULT_REPLACE_TIMEOUT_MS);
|
|
601
|
+
const result = await new Flume({
|
|
602
|
+
sources,
|
|
603
|
+
onEvent: this.wrapOnEvent(id),
|
|
604
|
+
onError: this.props.onError,
|
|
605
|
+
deps: this.props.deps,
|
|
606
|
+
reconnect: this.props.reconnect,
|
|
607
|
+
signal: controller?.signal
|
|
608
|
+
}).open();
|
|
609
|
+
if (timeoutHandle !== null) this.deps.clearTimeout(timeoutHandle);
|
|
610
|
+
if (result instanceof Error) {
|
|
611
|
+
if (controller !== null && controller.signal.aborted) return new FlumeStartError(`FlumeConfluence: open of "${id}" timed out after ${timeoutMs}ms`);
|
|
612
|
+
return result;
|
|
613
|
+
}
|
|
614
|
+
return result;
|
|
615
|
+
}
|
|
616
|
+
wrapOnEvent(id) {
|
|
617
|
+
const onEvent = this.props.onEvent;
|
|
618
|
+
if (!onEvent) return void 0;
|
|
619
|
+
return (item) => {
|
|
620
|
+
onEvent({
|
|
621
|
+
...item,
|
|
622
|
+
groupId: id
|
|
623
|
+
});
|
|
624
|
+
};
|
|
625
|
+
}
|
|
572
626
|
};
|
|
573
627
|
//#endregion
|
|
574
|
-
export { Flume, FlumeClosed, FlumeConfluence, FlumeConnectionError, FlumeHttpError, FlumeParseError, FlumeRunning, FlumeSource, FlumeStartError, createFlumeDefaultDeps };
|
|
628
|
+
export { Flume, FlumeClosed, FlumeConfluence, FlumeConnectionError, FlumeHttpError, FlumeParseError, FlumeRunning, FlumeSource, FlumeStartError, FlumeTimeSource, createFlumeDefaultDeps, flumeCollectCatchupMatches };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { D as FlumeSourceStartContext, L as FlumeTimeSourceOptions, t as FlumeSource } from "./flume-source.js";
|
|
2
|
+
|
|
3
|
+
//#region lib/errors/parse-error.d.ts
|
|
4
|
+
type Options = {
|
|
5
|
+
cause?: unknown;
|
|
6
|
+
};
|
|
7
|
+
declare class FlumeParseError extends Error {
|
|
8
|
+
constructor(message: string, options?: Options);
|
|
9
|
+
}
|
|
10
|
+
//#endregion
|
|
11
|
+
//#region lib/time/time-source.d.ts
|
|
12
|
+
/**
|
|
13
|
+
* cron スケジュールで tick を emit する Source。外部接続を持たないため
|
|
14
|
+
* 起動成功と同時に `connected` になり reconnect の対象外。
|
|
15
|
+
*
|
|
16
|
+
* options.statePersister + options.catchupPolicy を渡すと:
|
|
17
|
+
* 1. 起動時に lastFiredAt を読み出す
|
|
18
|
+
* 2. lastFiredAt から now までの過ぎ去った cron マッチを policy に従って再発火する
|
|
19
|
+
* 3. 各 tick 後に lastFiredAt を保存する (best-effort, ブロックしない)
|
|
20
|
+
*
|
|
21
|
+
* 保存先や形式は flume の関知ではなく statePersister の実装が決める (純粋 DI)
|
|
22
|
+
*/
|
|
23
|
+
declare class FlumeTimeSource extends FlumeSource {
|
|
24
|
+
private readonly options;
|
|
25
|
+
readonly name: "time";
|
|
26
|
+
private scheduler;
|
|
27
|
+
constructor(options: FlumeTimeSourceOptions);
|
|
28
|
+
protected connect(ctx: FlumeSourceStartContext): Promise<Error | null>;
|
|
29
|
+
protected disconnect(): void;
|
|
30
|
+
private handleTick;
|
|
31
|
+
private emitTick;
|
|
32
|
+
private runCatchup;
|
|
33
|
+
private loadLastFiredAt;
|
|
34
|
+
private saveLastFiredAt;
|
|
35
|
+
private safeMessage;
|
|
36
|
+
private normalizeMeta;
|
|
37
|
+
}
|
|
38
|
+
//#endregion
|
|
39
|
+
//#region lib/time/parse-cron.d.ts
|
|
40
|
+
type FlumeCron = {
|
|
41
|
+
source: string;
|
|
42
|
+
minutes: ReadonlySet<number>;
|
|
43
|
+
hours: ReadonlySet<number>;
|
|
44
|
+
daysOfMonth: ReadonlySet<number>;
|
|
45
|
+
months: ReadonlySet<number>;
|
|
46
|
+
daysOfWeek: ReadonlySet<number>; /** day-of-month フィールドが `*` 以外か。dow と両方制限時は OR マッチ (標準 cron 準拠) */
|
|
47
|
+
domRestricted: boolean;
|
|
48
|
+
dowRestricted: boolean;
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* 5 フィールド cron 式をパースする。dow は 0-7 を許可し 7 を 0 (日曜) に正規化する
|
|
52
|
+
*/
|
|
53
|
+
declare function parseCron(expression: string): FlumeCron | FlumeParseError;
|
|
54
|
+
//#endregion
|
|
55
|
+
export { FlumeParseError as i, parseCron as n, FlumeTimeSource as r, FlumeCron as t };
|
package/dist/slack.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { D as FlumeSourceStartContext, S as FlumeSlackEnvelope, t as FlumeSource, w as FlumeSlackSourceOptions } from "./flume-source.js";
|
|
2
2
|
|
|
3
3
|
//#region lib/slack/slack-source.d.ts
|
|
4
4
|
declare class FlumeSlackSource extends FlumeSource {
|
package/dist/slack.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
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
2
|
import { t as FlumeConnectionError } from "./connection-error.js";
|
|
3
3
|
import { t as FlumeHttpError } from "./http-error.js";
|
|
4
|
-
import { n as scheduleFlumeReconnect, r as FlumeReconnector, t as safeStringify } from "./safe-stringify.js";
|
|
5
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
6
|
import { t as safeJsonParse } from "./safe-json-parse.js";
|
|
7
7
|
import { t as safeReadText } from "./safe-read-text.js";
|
|
8
8
|
import { z } from "zod/v4";
|
|
@@ -0,0 +1,427 @@
|
|
|
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 };
|
package/dist/time.d.ts
CHANGED
|
@@ -1,40 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { t as FlumeParseError } from "./parse-error.js";
|
|
1
|
+
import { i as FlumeParseError, n as parseCron, r as FlumeTimeSource, t as FlumeCron } from "./parse-cron.js";
|
|
3
2
|
|
|
4
|
-
//#region lib/time/time-source.d.ts
|
|
5
|
-
/**
|
|
6
|
-
* cron スケジュールで tick を emit する Source。外部接続を持たないため
|
|
7
|
-
* 起動成功と同時に `connected` になり reconnect の対象外。
|
|
8
|
-
* `options.message` で tick ごとの type / data / meta を上書きできる
|
|
9
|
-
*/
|
|
10
|
-
declare class FlumeTimeSource extends FlumeSource {
|
|
11
|
-
private readonly options;
|
|
12
|
-
readonly name: "time";
|
|
13
|
-
private scheduler;
|
|
14
|
-
constructor(options: FlumeTimeSourceOptions);
|
|
15
|
-
protected connect(ctx: FlumeSourceStartContext): Promise<Error | null>;
|
|
16
|
-
protected disconnect(): void;
|
|
17
|
-
private handleTick;
|
|
18
|
-
private safeMessage;
|
|
19
|
-
private normalizeMeta;
|
|
20
|
-
}
|
|
21
|
-
//#endregion
|
|
22
|
-
//#region lib/time/parse-cron.d.ts
|
|
23
|
-
type FlumeCron = {
|
|
24
|
-
source: string;
|
|
25
|
-
minutes: ReadonlySet<number>;
|
|
26
|
-
hours: ReadonlySet<number>;
|
|
27
|
-
daysOfMonth: ReadonlySet<number>;
|
|
28
|
-
months: ReadonlySet<number>;
|
|
29
|
-
daysOfWeek: ReadonlySet<number>; /** day-of-month フィールドが `*` 以外か。dow と両方制限時は OR マッチ (標準 cron 準拠) */
|
|
30
|
-
domRestricted: boolean;
|
|
31
|
-
dowRestricted: boolean;
|
|
32
|
-
};
|
|
33
|
-
/**
|
|
34
|
-
* 5 フィールド cron 式をパースする。dow は 0-7 を許可し 7 を 0 (日曜) に正規化する
|
|
35
|
-
*/
|
|
36
|
-
declare function parseCron(expression: string): FlumeCron | FlumeParseError;
|
|
37
|
-
//#endregion
|
|
38
3
|
//#region lib/time/cron-next.d.ts
|
|
39
4
|
/**
|
|
40
5
|
* `afterMs` より後の最初の cron マッチ時刻 (epoch ms) を壁時計 (local time) で求める。
|
package/dist/time.js
CHANGED
|
@@ -1,319 +1,2 @@
|
|
|
1
|
-
import {
|
|
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-source.ts
|
|
229
|
-
/**
|
|
230
|
-
* cron スケジュールで tick を emit する Source。外部接続を持たないため
|
|
231
|
-
* 起動成功と同時に `connected` になり reconnect の対象外。
|
|
232
|
-
* `options.message` で tick ごとの type / data / meta を上書きできる
|
|
233
|
-
*/
|
|
234
|
-
var FlumeTimeSource = class extends FlumeSource {
|
|
235
|
-
options;
|
|
236
|
-
name = "time";
|
|
237
|
-
scheduler = null;
|
|
238
|
-
constructor(options) {
|
|
239
|
-
super();
|
|
240
|
-
this.options = options;
|
|
241
|
-
}
|
|
242
|
-
async connect(ctx) {
|
|
243
|
-
this.setStatus("connecting");
|
|
244
|
-
const cron = parseCron(this.options.cron);
|
|
245
|
-
if (cron instanceof FlumeParseError) {
|
|
246
|
-
const error = new FlumeStartError(`Time source: invalid cron "${this.options.cron}": ${cron.message}`);
|
|
247
|
-
ctx.log.error({
|
|
248
|
-
action: "source.start.failed",
|
|
249
|
-
message: safeErrorMessage({ error }),
|
|
250
|
-
error
|
|
251
|
-
});
|
|
252
|
-
this.setStatus("disconnected", error.message);
|
|
253
|
-
return error;
|
|
254
|
-
}
|
|
255
|
-
this.scheduler = new FlumeTimeScheduler({
|
|
256
|
-
cron,
|
|
257
|
-
onLog: ctx.log.handler,
|
|
258
|
-
deps: ctx.deps,
|
|
259
|
-
onTick: (firedAt) => this.handleTick(ctx, firedAt)
|
|
260
|
-
});
|
|
261
|
-
const result = this.scheduler.start();
|
|
262
|
-
if (result instanceof Error) {
|
|
263
|
-
const error = new FlumeStartError(`Time source: ${safeErrorMessage({ error: result })}`);
|
|
264
|
-
this.setStatus("disconnected", error.message);
|
|
265
|
-
return error;
|
|
266
|
-
}
|
|
267
|
-
this.setStatus("connected");
|
|
268
|
-
return null;
|
|
269
|
-
}
|
|
270
|
-
disconnect() {
|
|
271
|
-
this.scheduler?.stop();
|
|
272
|
-
this.scheduler = null;
|
|
273
|
-
}
|
|
274
|
-
handleTick(ctx, firedAt) {
|
|
275
|
-
const tick = {
|
|
276
|
-
firedAt,
|
|
277
|
-
cron: this.options.cron
|
|
278
|
-
};
|
|
279
|
-
const custom = this.safeMessage(ctx, tick);
|
|
280
|
-
this.emit({
|
|
281
|
-
source: "time",
|
|
282
|
-
type: typeof custom.type === "string" ? custom.type : "tick",
|
|
283
|
-
data: isRecord(custom.data) ? custom.data : {
|
|
284
|
-
firedAt,
|
|
285
|
-
cron: this.options.cron
|
|
286
|
-
},
|
|
287
|
-
meta: this.normalizeMeta(custom.meta, this.options.cron),
|
|
288
|
-
receivedAt: safeNow({ deps: ctx.deps })
|
|
289
|
-
});
|
|
290
|
-
}
|
|
291
|
-
safeMessage(ctx, tick) {
|
|
292
|
-
const message = this.options.message;
|
|
293
|
-
if (!message) return {};
|
|
294
|
-
const result = attempt(() => message(tick));
|
|
295
|
-
if (result instanceof Error) {
|
|
296
|
-
const error = safeNormalizeError({ value: result });
|
|
297
|
-
ctx.log.warn({
|
|
298
|
-
action: "message.error",
|
|
299
|
-
message: safeErrorMessage({ error }),
|
|
300
|
-
error,
|
|
301
|
-
detail: { firedAt: tick.firedAt }
|
|
302
|
-
});
|
|
303
|
-
return {};
|
|
304
|
-
}
|
|
305
|
-
return isRecord(result) ? result : {};
|
|
306
|
-
}
|
|
307
|
-
normalizeMeta(meta, cron) {
|
|
308
|
-
if (!isRecord(meta)) return { cron };
|
|
309
|
-
const normalized = {};
|
|
310
|
-
for (const key of Object.keys(meta)) {
|
|
311
|
-
const value = meta[key];
|
|
312
|
-
if (typeof value === "string") normalized[key] = value;
|
|
313
|
-
}
|
|
314
|
-
if (Object.keys(normalized).length === 0) return { cron };
|
|
315
|
-
return normalized;
|
|
316
|
-
}
|
|
317
|
-
};
|
|
318
|
-
//#endregion
|
|
1
|
+
import { i as parseCron, r as flumeCronNext, t as FlumeTimeSource } from "./time-source.js";
|
|
319
2
|
export { FlumeTimeSource, flumeCronNext, parseCron };
|
package/package.json
CHANGED
package/dist/parse-error.d.ts
DELETED