@basaltkit/scheduler 1.2.0 → 1.3.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/README.md +15 -0
- package/dist/cron.d.ts +25 -0
- package/dist/cron.js +118 -0
- package/dist/index.d.ts +55 -35
- package/dist/index.js +304 -322
- package/package.json +11 -12
package/README.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<a href="https://basaltkit-docs.pages.dev">
|
|
3
|
+
<img src="https://basaltkit-docs.pages.dev/social-card.png" alt="Basalt" width="440">
|
|
4
|
+
</a>
|
|
5
|
+
</p>
|
|
6
|
+
|
|
1
7
|
# @basaltkit/scheduler
|
|
2
8
|
|
|
3
9
|
Task scheduler for Basalt applications: define, with a fluent, readable API (`daily().at('03:00')`), tasks that run automatically at set times — backups, reports, cleanups, billing.
|
|
@@ -251,6 +257,15 @@ Exported for tooling and tests; you don't usually need them:
|
|
|
251
257
|
|
|
252
258
|
- `SCHEDULER: Token<Scheduler>` — to get the Scheduler from the container: `app.container.get(SCHEDULER)`.
|
|
253
259
|
|
|
260
|
+
## Multiple replicas — `.onOneServer()`
|
|
261
|
+
|
|
262
|
+
`withoutOverlapping()` guards one process only. On N replicas, mark an entry
|
|
263
|
+
`.onOneServer()` and pass `schedulerPlugin({ lock })` an atomic cross-replica
|
|
264
|
+
lock (`ScheduleLock`: `acquire(key, ttlMs)` — e.g. Redis `SET key v PX ttl NX`).
|
|
265
|
+
Exactly one replica runs the entry per tick; using `.onOneServer()` without a
|
|
266
|
+
`lock` fails loud at boot. Cron expressions are validated at definition time
|
|
267
|
+
(`CronParseError` on unsupported syntax like `MON` or out-of-range values).
|
|
268
|
+
|
|
254
269
|
## Common errors and solutions (FAQ)
|
|
255
270
|
|
|
256
271
|
**My `daily().at('03:00')` task runs at the wrong time.**
|
package/dist/cron.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { BasaltError } from '@basaltkit/core';
|
|
2
|
+
export declare class CronParseError extends BasaltError {
|
|
3
|
+
constructor(expression: string, detail: string);
|
|
4
|
+
}
|
|
5
|
+
export interface CronFields {
|
|
6
|
+
minute: string;
|
|
7
|
+
hour: string;
|
|
8
|
+
dayOfMonth: string;
|
|
9
|
+
month: string;
|
|
10
|
+
dayOfWeek: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function parseCron(expression: string): CronFields;
|
|
13
|
+
export declare function cronToString(fields: CronFields): string;
|
|
14
|
+
/** Supports: asterisk, steps (asterisk/n), single value, a-b ranges and a,b,c lists. */
|
|
15
|
+
export declare function fieldMatches(field: string, value: number): boolean;
|
|
16
|
+
export interface ZonedParts {
|
|
17
|
+
minute: number;
|
|
18
|
+
hour: number;
|
|
19
|
+
dayOfMonth: number;
|
|
20
|
+
month: number;
|
|
21
|
+
dayOfWeek: number;
|
|
22
|
+
}
|
|
23
|
+
/** Decomposes an instant into the cron fields, in the requested time zone (default UTC). */
|
|
24
|
+
export declare function zonedParts(date: Date, timeZone?: string): ZonedParts;
|
|
25
|
+
export declare function cronMatches(fields: CronFields, date: Date, timeZone?: string): boolean;
|
package/dist/cron.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { BasaltError } from '@basaltkit/core';
|
|
2
|
+
export class CronParseError extends BasaltError {
|
|
3
|
+
constructor(expression, detail) {
|
|
4
|
+
super('CRON_INVALID', `Invalid cron expression "${expression}": ${detail}`);
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
const FIELD_BOUNDS = [
|
|
8
|
+
['minute', 0, 59],
|
|
9
|
+
['hour', 0, 23],
|
|
10
|
+
['day-of-month', 1, 31],
|
|
11
|
+
['month', 1, 12],
|
|
12
|
+
['day-of-week', 0, 6],
|
|
13
|
+
];
|
|
14
|
+
/**
|
|
15
|
+
* Validates one cron field against the syntax {@link fieldMatches} actually
|
|
16
|
+
* supports: asterisk, asterisk-slash-n steps, single values, `a-b` ranges and comma lists.
|
|
17
|
+
* Anything else (names like MON, out-of-range values, `5-1`) previously became
|
|
18
|
+
* NaN comparisons — a job that silently NEVER fires. Fail at parse time instead.
|
|
19
|
+
*/
|
|
20
|
+
function assertField(expression, field, name, min, max) {
|
|
21
|
+
const invalid = (detail) => {
|
|
22
|
+
throw new CronParseError(expression, `${name} field "${field}": ${detail}`);
|
|
23
|
+
};
|
|
24
|
+
if (field === '*')
|
|
25
|
+
return;
|
|
26
|
+
for (const part of field.split(',')) {
|
|
27
|
+
const step = /^\*\/(\d+)$/.exec(part);
|
|
28
|
+
if (step) {
|
|
29
|
+
if (Number(step[1]) < 1)
|
|
30
|
+
invalid('step must be >= 1');
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const range = /^(\d+)-(\d+)$/.exec(part);
|
|
34
|
+
if (range) {
|
|
35
|
+
const [from, to] = [Number(range[1]), Number(range[2])];
|
|
36
|
+
if (from > to)
|
|
37
|
+
invalid(`range ${from}-${to} is reversed`);
|
|
38
|
+
if (from < min || to > max)
|
|
39
|
+
invalid(`range ${from}-${to} outside ${min}-${max}`);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (!/^\d+$/.test(part))
|
|
43
|
+
invalid(`"${part}" is not supported (use *, */n, n, a-b or comma lists; names like MON are not)`);
|
|
44
|
+
const value = Number(part);
|
|
45
|
+
if (value < min || value > max)
|
|
46
|
+
invalid(`${value} outside ${min}-${max}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export function parseCron(expression) {
|
|
50
|
+
const parts = expression.trim().split(/\s+/);
|
|
51
|
+
if (parts.length !== 5) {
|
|
52
|
+
throw new CronParseError(expression, `expected 5 fields, received ${parts.length}`);
|
|
53
|
+
}
|
|
54
|
+
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
|
|
55
|
+
parts.forEach((field, i) => {
|
|
56
|
+
const [name, min, max] = FIELD_BOUNDS[i];
|
|
57
|
+
assertField(expression, field, name, min, max);
|
|
58
|
+
});
|
|
59
|
+
return { minute, hour, dayOfMonth, month, dayOfWeek };
|
|
60
|
+
}
|
|
61
|
+
export function cronToString(fields) {
|
|
62
|
+
return [fields.minute, fields.hour, fields.dayOfMonth, fields.month, fields.dayOfWeek].join(' ');
|
|
63
|
+
}
|
|
64
|
+
/** Supports: asterisk, steps (asterisk/n), single value, a-b ranges and a,b,c lists. */
|
|
65
|
+
export function fieldMatches(field, value) {
|
|
66
|
+
if (field === '*')
|
|
67
|
+
return true;
|
|
68
|
+
return field.split(',').some((part) => {
|
|
69
|
+
const step = /^\*\/(\d+)$/.exec(part);
|
|
70
|
+
if (step)
|
|
71
|
+
return value % Number(step[1]) === 0;
|
|
72
|
+
const range = /^(\d+)-(\d+)$/.exec(part);
|
|
73
|
+
if (range)
|
|
74
|
+
return value >= Number(range[1]) && value <= Number(range[2]);
|
|
75
|
+
return Number(part) === value;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
const WEEKDAYS = {
|
|
79
|
+
Sun: 0,
|
|
80
|
+
Mon: 1,
|
|
81
|
+
Tue: 2,
|
|
82
|
+
Wed: 3,
|
|
83
|
+
Thu: 4,
|
|
84
|
+
Fri: 5,
|
|
85
|
+
Sat: 6,
|
|
86
|
+
};
|
|
87
|
+
/** Decomposes an instant into the cron fields, in the requested time zone (default UTC). */
|
|
88
|
+
export function zonedParts(date, timeZone = 'UTC') {
|
|
89
|
+
const formatter = new Intl.DateTimeFormat('en-US', {
|
|
90
|
+
timeZone,
|
|
91
|
+
minute: 'numeric',
|
|
92
|
+
hour: 'numeric',
|
|
93
|
+
day: 'numeric',
|
|
94
|
+
month: 'numeric',
|
|
95
|
+
weekday: 'short',
|
|
96
|
+
hour12: false,
|
|
97
|
+
});
|
|
98
|
+
const parts = {};
|
|
99
|
+
for (const part of formatter.formatToParts(date)) {
|
|
100
|
+
parts[part.type] = part.value;
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
minute: Number(parts['minute']),
|
|
104
|
+
// Intl with hour12:false may emit 24 for midnight
|
|
105
|
+
hour: Number(parts['hour']) % 24,
|
|
106
|
+
dayOfMonth: Number(parts['day']),
|
|
107
|
+
month: Number(parts['month']),
|
|
108
|
+
dayOfWeek: WEEKDAYS[parts['weekday']],
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
export function cronMatches(fields, date, timeZone) {
|
|
112
|
+
const parts = zonedParts(date, timeZone);
|
|
113
|
+
return (fieldMatches(fields.minute, parts.minute) &&
|
|
114
|
+
fieldMatches(fields.hour, parts.hour) &&
|
|
115
|
+
fieldMatches(fields.dayOfMonth, parts.dayOfMonth) &&
|
|
116
|
+
fieldMatches(fields.month, parts.month) &&
|
|
117
|
+
fieldMatches(fields.dayOfWeek, parts.dayOfWeek));
|
|
118
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,44 +1,38 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
declare class CronParseError extends BasaltError {
|
|
6
|
-
constructor(expression: string, detail: string);
|
|
7
|
-
}
|
|
8
|
-
interface CronFields {
|
|
9
|
-
minute: string;
|
|
10
|
-
hour: string;
|
|
11
|
-
dayOfMonth: string;
|
|
12
|
-
month: string;
|
|
13
|
-
dayOfWeek: string;
|
|
14
|
-
}
|
|
15
|
-
declare function parseCron(expression: string): CronFields;
|
|
16
|
-
/** Supports: asterisk, steps (asterisk/n), single value, a-b ranges and a,b,c lists. */
|
|
17
|
-
declare function fieldMatches(field: string, value: number): boolean;
|
|
18
|
-
interface ZonedParts {
|
|
19
|
-
minute: number;
|
|
20
|
-
hour: number;
|
|
21
|
-
dayOfMonth: number;
|
|
22
|
-
month: number;
|
|
23
|
-
dayOfWeek: number;
|
|
24
|
-
}
|
|
25
|
-
/** Decomposes an instant into the cron fields, in the requested time zone (default UTC). */
|
|
26
|
-
declare function zonedParts(date: Date, timeZone?: string): ZonedParts;
|
|
27
|
-
declare function cronMatches(fields: CronFields, date: Date, timeZone?: string): boolean;
|
|
28
|
-
|
|
1
|
+
import type { JobDefinition } from '@basaltkit/queue';
|
|
2
|
+
export { CronParseError, cronMatches, parseCron, fieldMatches, zonedParts } from './cron.js';
|
|
3
|
+
export type { CronFields, ZonedParts } from './cron.js';
|
|
29
4
|
type Task = () => void | Promise<void>;
|
|
5
|
+
/**
|
|
6
|
+
* Cross-replica mutex for `.onOneServer()` entries. `acquire` must be ATOMIC
|
|
7
|
+
* across processes (e.g. Redis `SET key value PX ttl NX`): it returns true for
|
|
8
|
+
* exactly one caller per key until the TTL expires. There is deliberately no
|
|
9
|
+
* `release` — the key covers the tick window, so a fast first run cannot be
|
|
10
|
+
* followed by a late replica re-acquiring and running the same minute again.
|
|
11
|
+
*
|
|
12
|
+
* ioredis example:
|
|
13
|
+
*
|
|
14
|
+
* const lock: ScheduleLock = {
|
|
15
|
+
* async acquire(key, ttlMs) {
|
|
16
|
+
* return (await redis.set(key, '1', 'PX', ttlMs, 'NX')) === 'OK'
|
|
17
|
+
* },
|
|
18
|
+
* }
|
|
19
|
+
*/
|
|
20
|
+
export interface ScheduleLock {
|
|
21
|
+
acquire(key: string, ttlMs: number): Promise<boolean>;
|
|
22
|
+
}
|
|
30
23
|
/**
|
|
31
24
|
* A scheduled entry, built fluently:
|
|
32
25
|
*
|
|
33
26
|
* schedule.job(ReconcileBilling).daily().at('03:00').timezone('UTC')
|
|
34
27
|
* schedule.call('purge-cache', () => cache.flush()).everyMinute().withoutOverlapping()
|
|
35
28
|
*/
|
|
36
|
-
declare class ScheduleEntry {
|
|
29
|
+
export declare class ScheduleEntry {
|
|
37
30
|
readonly name: string;
|
|
38
31
|
private readonly task;
|
|
39
32
|
private fields;
|
|
40
33
|
private tz;
|
|
41
34
|
private noOverlap;
|
|
35
|
+
private oneServer;
|
|
42
36
|
private failureHandler;
|
|
43
37
|
private running;
|
|
44
38
|
/** count of executions skipped due to overlap — visible for observability/tests */
|
|
@@ -64,6 +58,16 @@ declare class ScheduleEntry {
|
|
|
64
58
|
timezone(tz: string): this;
|
|
65
59
|
/** If the previous execution is still running, the new one is skipped. */
|
|
66
60
|
withoutOverlapping(): this;
|
|
61
|
+
/**
|
|
62
|
+
* On a horizontally-scaled deployment, run this entry on ONE replica per tick
|
|
63
|
+
* instead of on every pod. Requires a `lock` on the Scheduler (see
|
|
64
|
+
* {@link ScheduleLock}) — without one, boot fails loud rather than silently
|
|
65
|
+
* running the job N times. `runNow()`/`schedule:run` bypass the lock (a manual
|
|
66
|
+
* trigger is deliberate).
|
|
67
|
+
*/
|
|
68
|
+
onOneServer(): this;
|
|
69
|
+
/** @internal whether this entry asked for cross-replica locking. */
|
|
70
|
+
get wantsOneServer(): boolean;
|
|
67
71
|
onFailure(handler: (error: unknown) => void): this;
|
|
68
72
|
/** Entry description — consumed by `basalt schedule list`. */
|
|
69
73
|
describe(): {
|
|
@@ -76,10 +80,28 @@ declare class ScheduleEntry {
|
|
|
76
80
|
run(): Promise<void>;
|
|
77
81
|
private onDayOfWeek;
|
|
78
82
|
}
|
|
79
|
-
|
|
83
|
+
export interface SchedulerOptions {
|
|
84
|
+
/** Cross-replica lock for `.onOneServer()` entries. */
|
|
85
|
+
lock?: ScheduleLock;
|
|
86
|
+
/**
|
|
87
|
+
* TTL for each per-entry, per-tick lock key. Default 60_000 (one tick window
|
|
88
|
+
* — the key embeds the minute, so it only needs to outlive clock skew).
|
|
89
|
+
*/
|
|
90
|
+
lockTtlMs?: number;
|
|
91
|
+
}
|
|
92
|
+
export declare class Scheduler {
|
|
80
93
|
private readonly entries;
|
|
81
94
|
private timer;
|
|
82
95
|
private interval;
|
|
96
|
+
private readonly lock;
|
|
97
|
+
private readonly lockTtlMs;
|
|
98
|
+
/** ticks skipped because another replica held the lock — for observability/tests */
|
|
99
|
+
skippedByLock: number;
|
|
100
|
+
constructor(options?: SchedulerOptions);
|
|
101
|
+
/** @internal true when any entry requested `.onOneServer()`. */
|
|
102
|
+
get needsLock(): boolean;
|
|
103
|
+
/** @internal whether a lock was configured. */
|
|
104
|
+
get hasLock(): boolean;
|
|
83
105
|
/** Schedules the dispatch of a @basaltkit/queue job. */
|
|
84
106
|
job<T>(job: JobDefinition<T>, ...payload: T extends void ? [] : [T]): ScheduleEntry;
|
|
85
107
|
/** Schedules a named function. */
|
|
@@ -109,13 +131,11 @@ declare class Scheduler {
|
|
|
109
131
|
private safeTick;
|
|
110
132
|
private add;
|
|
111
133
|
}
|
|
112
|
-
declare const SCHEDULER:
|
|
113
|
-
interface SchedulerPluginOptions {
|
|
134
|
+
export declare const SCHEDULER: import("@basaltkit/core").Token<Scheduler>;
|
|
135
|
+
export interface SchedulerPluginOptions extends SchedulerOptions {
|
|
114
136
|
/** Callback that defines the schedules — receives the Scheduler at boot. */
|
|
115
137
|
define?: (schedule: Scheduler) => void;
|
|
116
138
|
/** Starts the timer at boot. Default: true (turn off in tests). */
|
|
117
139
|
autostart?: boolean;
|
|
118
140
|
}
|
|
119
|
-
declare function schedulerPlugin(options?: SchedulerPluginOptions):
|
|
120
|
-
|
|
121
|
-
export { type CronFields, CronParseError, SCHEDULER, ScheduleEntry, Scheduler, type SchedulerPluginOptions, type ZonedParts, cronMatches, fieldMatches, parseCron, schedulerPlugin, zonedParts };
|
|
141
|
+
export declare function schedulerPlugin(options?: SchedulerPluginOptions): import("@basaltkit/core").BasaltPlugin<unknown>;
|
package/dist/index.js
CHANGED
|
@@ -1,332 +1,314 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
dayOfMonth: Number(parts["day"]),
|
|
60
|
-
month: Number(parts["month"]),
|
|
61
|
-
dayOfWeek: WEEKDAYS[parts["weekday"]]
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
function cronMatches(fields, date, timeZone) {
|
|
65
|
-
const parts = zonedParts(date, timeZone);
|
|
66
|
-
return fieldMatches(fields.minute, parts.minute) && fieldMatches(fields.hour, parts.hour) && fieldMatches(fields.dayOfMonth, parts.dayOfMonth) && fieldMatches(fields.month, parts.month) && fieldMatches(fields.dayOfWeek, parts.dayOfWeek);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// src/index.ts
|
|
70
|
-
var ScheduleEntry = class {
|
|
71
|
-
constructor(name, task) {
|
|
72
|
-
this.name = name;
|
|
73
|
-
this.task = task;
|
|
74
|
-
}
|
|
75
|
-
name;
|
|
76
|
-
task;
|
|
77
|
-
fields = {
|
|
78
|
-
minute: "*",
|
|
79
|
-
hour: "*",
|
|
80
|
-
dayOfMonth: "*",
|
|
81
|
-
month: "*",
|
|
82
|
-
dayOfWeek: "*"
|
|
83
|
-
};
|
|
84
|
-
tz = "UTC";
|
|
85
|
-
noOverlap = false;
|
|
86
|
-
failureHandler;
|
|
87
|
-
running = false;
|
|
88
|
-
/** count of executions skipped due to overlap — visible for observability/tests */
|
|
89
|
-
skippedOverlaps = 0;
|
|
90
|
-
everyMinute() {
|
|
91
|
-
this.fields = { minute: "*", hour: "*", dayOfMonth: "*", month: "*", dayOfWeek: "*" };
|
|
92
|
-
return this;
|
|
93
|
-
}
|
|
94
|
-
everyMinutes(n) {
|
|
95
|
-
this.everyMinute();
|
|
96
|
-
this.fields.minute = `*/${n}`;
|
|
97
|
-
return this;
|
|
98
|
-
}
|
|
99
|
-
hourly() {
|
|
100
|
-
this.everyMinute();
|
|
101
|
-
this.fields.minute = "0";
|
|
102
|
-
return this;
|
|
103
|
-
}
|
|
104
|
-
daily() {
|
|
105
|
-
this.hourly();
|
|
106
|
-
this.fields.hour = "0";
|
|
107
|
-
return this;
|
|
108
|
-
}
|
|
109
|
-
weekly() {
|
|
110
|
-
this.daily();
|
|
111
|
-
this.fields.dayOfWeek = "0";
|
|
112
|
-
return this;
|
|
113
|
-
}
|
|
114
|
-
monthly() {
|
|
115
|
-
this.daily();
|
|
116
|
-
this.fields.dayOfMonth = "1";
|
|
117
|
-
return this;
|
|
118
|
-
}
|
|
119
|
-
/** 'HH:mm' time — combines with daily/weekly/monthly. */
|
|
120
|
-
at(time) {
|
|
121
|
-
const [hour, minute] = time.split(":");
|
|
122
|
-
this.fields.hour = String(Number(hour));
|
|
123
|
-
this.fields.minute = String(Number(minute ?? 0));
|
|
124
|
-
return this;
|
|
125
|
-
}
|
|
126
|
-
/** Raw cron expression (5 fields) — escape hatch. */
|
|
127
|
-
cron(expression) {
|
|
128
|
-
this.fields = parseCron(expression);
|
|
129
|
-
return this;
|
|
130
|
-
}
|
|
131
|
-
sundays() {
|
|
132
|
-
return this.onDayOfWeek(0);
|
|
133
|
-
}
|
|
134
|
-
mondays() {
|
|
135
|
-
return this.onDayOfWeek(1);
|
|
136
|
-
}
|
|
137
|
-
tuesdays() {
|
|
138
|
-
return this.onDayOfWeek(2);
|
|
139
|
-
}
|
|
140
|
-
wednesdays() {
|
|
141
|
-
return this.onDayOfWeek(3);
|
|
142
|
-
}
|
|
143
|
-
thursdays() {
|
|
144
|
-
return this.onDayOfWeek(4);
|
|
145
|
-
}
|
|
146
|
-
fridays() {
|
|
147
|
-
return this.onDayOfWeek(5);
|
|
148
|
-
}
|
|
149
|
-
saturdays() {
|
|
150
|
-
return this.onDayOfWeek(6);
|
|
151
|
-
}
|
|
152
|
-
timezone(tz) {
|
|
153
|
-
this.tz = tz;
|
|
154
|
-
return this;
|
|
155
|
-
}
|
|
156
|
-
/** If the previous execution is still running, the new one is skipped. */
|
|
157
|
-
withoutOverlapping() {
|
|
158
|
-
this.noOverlap = true;
|
|
159
|
-
return this;
|
|
160
|
-
}
|
|
161
|
-
onFailure(handler) {
|
|
162
|
-
this.failureHandler = handler;
|
|
163
|
-
return this;
|
|
164
|
-
}
|
|
165
|
-
/** Entry description — consumed by `basalt schedule list`. */
|
|
166
|
-
describe() {
|
|
167
|
-
return { name: this.name, cron: cronToString(this.fields), timezone: this.tz };
|
|
168
|
-
}
|
|
169
|
-
isDue(date) {
|
|
170
|
-
return cronMatches(this.fields, date, this.tz);
|
|
171
|
-
}
|
|
172
|
-
/** @internal runs the task with the overlap guard and failure handling. */
|
|
173
|
-
async run() {
|
|
174
|
-
if (this.noOverlap && this.running) {
|
|
175
|
-
this.skippedOverlaps++;
|
|
176
|
-
return;
|
|
1
|
+
import { createToken, definePlugin, ensureMetadata } from '@basaltkit/core';
|
|
2
|
+
import { cronMatches, cronToString, parseCron } from './cron.js';
|
|
3
|
+
export { CronParseError, cronMatches, parseCron, fieldMatches, zonedParts } from './cron.js';
|
|
4
|
+
/**
|
|
5
|
+
* A scheduled entry, built fluently:
|
|
6
|
+
*
|
|
7
|
+
* schedule.job(ReconcileBilling).daily().at('03:00').timezone('UTC')
|
|
8
|
+
* schedule.call('purge-cache', () => cache.flush()).everyMinute().withoutOverlapping()
|
|
9
|
+
*/
|
|
10
|
+
export class ScheduleEntry {
|
|
11
|
+
name;
|
|
12
|
+
task;
|
|
13
|
+
fields = {
|
|
14
|
+
minute: '*',
|
|
15
|
+
hour: '*',
|
|
16
|
+
dayOfMonth: '*',
|
|
17
|
+
month: '*',
|
|
18
|
+
dayOfWeek: '*',
|
|
19
|
+
};
|
|
20
|
+
tz = 'UTC';
|
|
21
|
+
noOverlap = false;
|
|
22
|
+
oneServer = false;
|
|
23
|
+
failureHandler;
|
|
24
|
+
running = false;
|
|
25
|
+
/** count of executions skipped due to overlap — visible for observability/tests */
|
|
26
|
+
skippedOverlaps = 0;
|
|
27
|
+
constructor(name, task) {
|
|
28
|
+
this.name = name;
|
|
29
|
+
this.task = task;
|
|
30
|
+
}
|
|
31
|
+
everyMinute() {
|
|
32
|
+
this.fields = { minute: '*', hour: '*', dayOfMonth: '*', month: '*', dayOfWeek: '*' };
|
|
33
|
+
return this;
|
|
34
|
+
}
|
|
35
|
+
everyMinutes(n) {
|
|
36
|
+
this.everyMinute();
|
|
37
|
+
this.fields.minute = `*/${n}`;
|
|
38
|
+
return this;
|
|
39
|
+
}
|
|
40
|
+
hourly() {
|
|
41
|
+
this.everyMinute();
|
|
42
|
+
this.fields.minute = '0';
|
|
43
|
+
return this;
|
|
44
|
+
}
|
|
45
|
+
daily() {
|
|
46
|
+
this.hourly();
|
|
47
|
+
this.fields.hour = '0';
|
|
48
|
+
return this;
|
|
49
|
+
}
|
|
50
|
+
weekly() {
|
|
51
|
+
this.daily();
|
|
52
|
+
this.fields.dayOfWeek = '0';
|
|
53
|
+
return this;
|
|
54
|
+
}
|
|
55
|
+
monthly() {
|
|
56
|
+
this.daily();
|
|
57
|
+
this.fields.dayOfMonth = '1';
|
|
58
|
+
return this;
|
|
177
59
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
} finally {
|
|
185
|
-
this.running = false;
|
|
60
|
+
/** 'HH:mm' time — combines with daily/weekly/monthly. */
|
|
61
|
+
at(time) {
|
|
62
|
+
const [hour, minute] = time.split(':');
|
|
63
|
+
this.fields.hour = String(Number(hour));
|
|
64
|
+
this.fields.minute = String(Number(minute ?? 0));
|
|
65
|
+
return this;
|
|
186
66
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
67
|
+
/** Raw cron expression (5 fields) — escape hatch. */
|
|
68
|
+
cron(expression) {
|
|
69
|
+
this.fields = parseCron(expression);
|
|
70
|
+
return this;
|
|
71
|
+
}
|
|
72
|
+
sundays() { return this.onDayOfWeek(0); }
|
|
73
|
+
mondays() { return this.onDayOfWeek(1); }
|
|
74
|
+
tuesdays() { return this.onDayOfWeek(2); }
|
|
75
|
+
wednesdays() { return this.onDayOfWeek(3); }
|
|
76
|
+
thursdays() { return this.onDayOfWeek(4); }
|
|
77
|
+
fridays() { return this.onDayOfWeek(5); }
|
|
78
|
+
saturdays() { return this.onDayOfWeek(6); }
|
|
79
|
+
timezone(tz) {
|
|
80
|
+
this.tz = tz;
|
|
81
|
+
return this;
|
|
82
|
+
}
|
|
83
|
+
/** If the previous execution is still running, the new one is skipped. */
|
|
84
|
+
withoutOverlapping() {
|
|
85
|
+
this.noOverlap = true;
|
|
86
|
+
return this;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* On a horizontally-scaled deployment, run this entry on ONE replica per tick
|
|
90
|
+
* instead of on every pod. Requires a `lock` on the Scheduler (see
|
|
91
|
+
* {@link ScheduleLock}) — without one, boot fails loud rather than silently
|
|
92
|
+
* running the job N times. `runNow()`/`schedule:run` bypass the lock (a manual
|
|
93
|
+
* trigger is deliberate).
|
|
94
|
+
*/
|
|
95
|
+
onOneServer() {
|
|
96
|
+
this.oneServer = true;
|
|
97
|
+
return this;
|
|
98
|
+
}
|
|
99
|
+
/** @internal whether this entry asked for cross-replica locking. */
|
|
100
|
+
get wantsOneServer() {
|
|
101
|
+
return this.oneServer;
|
|
102
|
+
}
|
|
103
|
+
onFailure(handler) {
|
|
104
|
+
this.failureHandler = handler;
|
|
105
|
+
return this;
|
|
106
|
+
}
|
|
107
|
+
/** Entry description — consumed by `basalt schedule list`. */
|
|
108
|
+
describe() {
|
|
109
|
+
return { name: this.name, cron: cronToString(this.fields), timezone: this.tz };
|
|
110
|
+
}
|
|
111
|
+
isDue(date) {
|
|
112
|
+
return cronMatches(this.fields, date, this.tz);
|
|
113
|
+
}
|
|
114
|
+
/** @internal runs the task with the overlap guard and failure handling. */
|
|
115
|
+
async run() {
|
|
116
|
+
if (this.noOverlap && this.running) {
|
|
117
|
+
this.skippedOverlaps++;
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
this.running = true;
|
|
233
121
|
try {
|
|
234
|
-
|
|
235
|
-
}
|
|
236
|
-
|
|
122
|
+
await this.task();
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
if (!this.failureHandler)
|
|
126
|
+
throw error;
|
|
127
|
+
this.failureHandler(error);
|
|
237
128
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
129
|
+
finally {
|
|
130
|
+
this.running = false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
onDayOfWeek(day) {
|
|
134
|
+
this.fields.dayOfWeek = String(day);
|
|
135
|
+
return this;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
export class Scheduler {
|
|
139
|
+
entries = [];
|
|
140
|
+
timer;
|
|
141
|
+
interval;
|
|
142
|
+
lock;
|
|
143
|
+
lockTtlMs;
|
|
144
|
+
/** ticks skipped because another replica held the lock — for observability/tests */
|
|
145
|
+
skippedByLock = 0;
|
|
146
|
+
constructor(options = {}) {
|
|
147
|
+
this.lock = options.lock;
|
|
148
|
+
this.lockTtlMs = options.lockTtlMs ?? 60_000;
|
|
149
|
+
}
|
|
150
|
+
/** @internal true when any entry requested `.onOneServer()`. */
|
|
151
|
+
get needsLock() {
|
|
152
|
+
return this.entries.some((entry) => entry.wantsOneServer);
|
|
153
|
+
}
|
|
154
|
+
/** @internal whether a lock was configured. */
|
|
155
|
+
get hasLock() {
|
|
156
|
+
return this.lock !== undefined;
|
|
157
|
+
}
|
|
158
|
+
/** Schedules the dispatch of a @basaltkit/queue job. */
|
|
159
|
+
job(job, ...payload) {
|
|
160
|
+
return this.add(new ScheduleEntry(job.name, () => job.dispatch(payload[0])));
|
|
161
|
+
}
|
|
162
|
+
/** Schedules a named function. */
|
|
163
|
+
call(name, task) {
|
|
164
|
+
return this.add(new ScheduleEntry(name, task));
|
|
165
|
+
}
|
|
166
|
+
list() {
|
|
167
|
+
return this.entries.map((entry) => entry.describe());
|
|
168
|
+
}
|
|
169
|
+
/** Names of every scheduled entry — for CLI validation/listing. */
|
|
170
|
+
names() {
|
|
171
|
+
return this.entries.map((entry) => entry.name);
|
|
242
172
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
}
|
|
255
|
-
stop() {
|
|
256
|
-
if (this.timer) clearTimeout(this.timer);
|
|
257
|
-
if (this.interval) clearInterval(this.interval);
|
|
258
|
-
this.timer = void 0;
|
|
259
|
-
this.interval = void 0;
|
|
260
|
-
}
|
|
261
|
-
async safeTick() {
|
|
262
|
-
try {
|
|
263
|
-
await this.tick();
|
|
264
|
-
} catch {
|
|
173
|
+
/**
|
|
174
|
+
* Runs a single entry by name on demand, ignoring its cron (for `schedule:run`
|
|
175
|
+
* and manual triggers). Returns false if no entry has that name. The entry's
|
|
176
|
+
* own overlap guard and failure handler still apply.
|
|
177
|
+
*/
|
|
178
|
+
async runNow(name) {
|
|
179
|
+
const entry = this.entries.find((candidate) => candidate.name === name);
|
|
180
|
+
if (!entry)
|
|
181
|
+
return false;
|
|
182
|
+
await entry.run();
|
|
183
|
+
return true;
|
|
265
184
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
185
|
+
/**
|
|
186
|
+
* Runs the entries due at the given instant. Deterministic — this is what
|
|
187
|
+
* the tests call directly and what the timer calls every minute.
|
|
188
|
+
* Failures (without onFailure) are aggregated; all due entries run.
|
|
189
|
+
*/
|
|
190
|
+
async tick(date = new Date()) {
|
|
191
|
+
const due = this.entries.filter((entry) => entry.isDue(date));
|
|
192
|
+
const errors = [];
|
|
193
|
+
await Promise.all(due.map(async (entry) => {
|
|
194
|
+
try {
|
|
195
|
+
if (entry.wantsOneServer && this.lock) {
|
|
196
|
+
// One key per entry per tick window: exactly one replica acquires
|
|
197
|
+
// it; the others skip this minute's run. A lock-store failure is
|
|
198
|
+
// treated as a task failure (visible), not as permission to run on
|
|
199
|
+
// every replica at once.
|
|
200
|
+
const minute = new Date(date);
|
|
201
|
+
minute.setSeconds(0, 0);
|
|
202
|
+
const key = `basalt:schedule:${entry.name}:${minute.toISOString()}`;
|
|
203
|
+
if (!(await this.lock.acquire(key, this.lockTtlMs))) {
|
|
204
|
+
this.skippedByLock++;
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
await entry.run();
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
errors.push(error);
|
|
212
|
+
}
|
|
213
|
+
}));
|
|
214
|
+
if (errors.length > 0) {
|
|
215
|
+
throw new AggregateError(errors, `Failure in ${errors.length} scheduled task(s)`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/** Aligns to the next minute and then runs tick() every 60s. */
|
|
219
|
+
start() {
|
|
220
|
+
if (this.timer || this.interval)
|
|
221
|
+
return;
|
|
222
|
+
const msToNextMinute = 60_000 - (Date.now() % 60_000);
|
|
223
|
+
this.timer = setTimeout(() => {
|
|
224
|
+
void this.safeTick();
|
|
225
|
+
this.interval = setInterval(() => void this.safeTick(), 60_000);
|
|
226
|
+
this.interval.unref?.();
|
|
227
|
+
}, msToNextMinute);
|
|
228
|
+
this.timer.unref?.();
|
|
229
|
+
}
|
|
230
|
+
stop() {
|
|
231
|
+
if (this.timer)
|
|
232
|
+
clearTimeout(this.timer);
|
|
233
|
+
if (this.interval)
|
|
234
|
+
clearInterval(this.interval);
|
|
235
|
+
this.timer = undefined;
|
|
236
|
+
this.interval = undefined;
|
|
237
|
+
}
|
|
238
|
+
async safeTick() {
|
|
239
|
+
try {
|
|
240
|
+
await this.tick();
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
// failures without onFailure were already aggregated; here we only avoid
|
|
244
|
+
// bringing down the process — each entry must handle its own failure
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
add(entry) {
|
|
248
|
+
this.entries.push(entry);
|
|
249
|
+
return entry;
|
|
289
250
|
}
|
|
290
|
-
});
|
|
291
251
|
}
|
|
252
|
+
export const SCHEDULER = createToken('scheduler');
|
|
253
|
+
export function schedulerPlugin(options = {}) {
|
|
254
|
+
return definePlugin({
|
|
255
|
+
name: 'basalt:scheduler',
|
|
256
|
+
register({ container }) {
|
|
257
|
+
container.singleton(SCHEDULER, () => new Scheduler({
|
|
258
|
+
...(options.lock ? { lock: options.lock } : {}),
|
|
259
|
+
...(options.lockTtlMs !== undefined ? { lockTtlMs: options.lockTtlMs } : {}),
|
|
260
|
+
}));
|
|
261
|
+
registerScheduleRunCommand(container);
|
|
262
|
+
},
|
|
263
|
+
boot({ container }) {
|
|
264
|
+
const scheduler = container.get(SCHEDULER);
|
|
265
|
+
options.define?.(scheduler);
|
|
266
|
+
if (scheduler.needsLock && !scheduler.hasLock) {
|
|
267
|
+
// Fail closed at boot: silently running the entry on every replica is
|
|
268
|
+
// exactly the failure mode .onOneServer() exists to prevent.
|
|
269
|
+
throw new Error('schedulerPlugin: an entry uses .onOneServer() but no `lock` was configured. ' +
|
|
270
|
+
'Pass `schedulerPlugin({ lock })` with an atomic cross-replica lock (e.g. Redis SET NX PX) — see ScheduleLock.');
|
|
271
|
+
}
|
|
272
|
+
// Expose entries to tooling (CLI `basalt schedule:list`).
|
|
273
|
+
const metadata = ensureMetadata(container);
|
|
274
|
+
for (const entry of scheduler.list())
|
|
275
|
+
metadata.add('schedule:entries', entry);
|
|
276
|
+
if (options.autostart !== false)
|
|
277
|
+
scheduler.start();
|
|
278
|
+
},
|
|
279
|
+
shutdown({ container }) {
|
|
280
|
+
container.get(SCHEDULER).stop();
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Registers `schedule:run` into the CLI command bucket. Runs a scheduled task on
|
|
286
|
+
* demand by name (ignoring its cron), or `--due` to run everything due right now.
|
|
287
|
+
* Registered structurally to avoid a hard @basaltkit/cli dependency.
|
|
288
|
+
*/
|
|
292
289
|
function registerScheduleRunCommand(container) {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
}
|
|
318
|
-
io.log(`Ran scheduled task "${name}".`);
|
|
319
|
-
}
|
|
320
|
-
});
|
|
290
|
+
ensureMetadata(container).add('commands', {
|
|
291
|
+
name: 'schedule:run',
|
|
292
|
+
description: 'Run a scheduled task on demand (by name), or --due for all due now',
|
|
293
|
+
async handle({ io, args, flags, }) {
|
|
294
|
+
const scheduler = container.get(SCHEDULER);
|
|
295
|
+
if (flags['due'] === true) {
|
|
296
|
+
await scheduler.tick();
|
|
297
|
+
io.log('Ran all due scheduled tasks.');
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const name = args[0];
|
|
301
|
+
if (!name) {
|
|
302
|
+
io.error('Usage: basalt schedule:run <name> | --due');
|
|
303
|
+
return 1;
|
|
304
|
+
}
|
|
305
|
+
const ran = await scheduler.runNow(name);
|
|
306
|
+
if (!ran) {
|
|
307
|
+
const available = scheduler.names().join(', ') || '(none)';
|
|
308
|
+
io.error(`Unknown scheduled task "${name}". Available: ${available}.`);
|
|
309
|
+
return 1;
|
|
310
|
+
}
|
|
311
|
+
io.log(`Ran scheduled task "${name}".`);
|
|
312
|
+
},
|
|
313
|
+
});
|
|
321
314
|
}
|
|
322
|
-
export {
|
|
323
|
-
CronParseError,
|
|
324
|
-
SCHEDULER,
|
|
325
|
-
ScheduleEntry,
|
|
326
|
-
Scheduler,
|
|
327
|
-
cronMatches,
|
|
328
|
-
fieldMatches,
|
|
329
|
-
parseCron,
|
|
330
|
-
schedulerPlugin,
|
|
331
|
-
zonedParts
|
|
332
|
-
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@basaltkit/scheduler",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Fluent Basalt scheduler: schedule.job(X).daily().at('03:00'), timezones, withoutOverlapping and @basaltkit/queue integration.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -14,15 +14,14 @@
|
|
|
14
14
|
"dist"
|
|
15
15
|
],
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@basaltkit/core": "^1.
|
|
18
|
-
"@basaltkit/queue": "^1.
|
|
17
|
+
"@basaltkit/core": "^1.3.0",
|
|
18
|
+
"@basaltkit/queue": "^1.3.1"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
|
-
"@types/node": "^
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"zod": "^3.24.0",
|
|
21
|
+
"@types/node": "^26.3.0",
|
|
22
|
+
"typescript": "^7.0.2",
|
|
23
|
+
"vitest": "^4.1.11",
|
|
24
|
+
"zod": "^3.24.0 || ^4.0.0",
|
|
26
25
|
"@basaltkit/tsconfig": "^0.24.0"
|
|
27
26
|
},
|
|
28
27
|
"publishConfig": {
|
|
@@ -30,11 +29,11 @@
|
|
|
30
29
|
},
|
|
31
30
|
"repository": {
|
|
32
31
|
"type": "git",
|
|
33
|
-
"url": "git+https://github.com/
|
|
32
|
+
"url": "git+https://github.com/basaltkit/basalt.git",
|
|
34
33
|
"directory": "packages/scheduler"
|
|
35
34
|
},
|
|
36
|
-
"homepage": "https://github.com/
|
|
37
|
-
"bugs": "https://github.com/
|
|
35
|
+
"homepage": "https://github.com/basaltkit/basalt/tree/main/packages/scheduler#readme",
|
|
36
|
+
"bugs": "https://github.com/basaltkit/basalt/issues",
|
|
38
37
|
"keywords": [
|
|
39
38
|
"basalt",
|
|
40
39
|
"typescript",
|
|
@@ -42,7 +41,7 @@
|
|
|
42
41
|
"cron"
|
|
43
42
|
],
|
|
44
43
|
"scripts": {
|
|
45
|
-
"build": "
|
|
44
|
+
"build": "tsc -p tsconfig.build.json",
|
|
46
45
|
"test": "vitest run",
|
|
47
46
|
"typecheck": "tsc --noEmit"
|
|
48
47
|
}
|