@stonyx/cron 0.2.1-beta.9 → 0.2.1-beta.90
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 +12 -0
- package/dist/cron-parser.d.ts +30 -0
- package/dist/cron-parser.js +200 -0
- package/dist/job.d.ts +72 -0
- package/dist/job.js +172 -0
- package/dist/locked.d.ts +13 -0
- package/dist/locked.js +27 -0
- package/dist/main.d.ts +79 -0
- package/dist/main.js +284 -0
- package/dist/min-heap.d.ts +13 -0
- package/dist/min-heap.js +67 -0
- package/dist/normalize.d.ts +49 -0
- package/dist/normalize.js +148 -0
- package/dist/run-log.d.ts +44 -0
- package/dist/run-log.js +60 -0
- package/dist/schedule.d.ts +23 -0
- package/dist/schedule.js +65 -0
- package/dist/service.d.ts +125 -0
- package/dist/service.js +513 -0
- package/package.json +53 -9
- package/.claude/architecture.md +0 -215
- package/.claude/extension-guide.md +0 -291
- package/.claude/improvements.md +0 -53
- package/.claude/project-structure.md +0 -139
- package/.claude/testing.md +0 -85
- package/.git/config +0 -18
- package/.github/workflows/ci.yml +0 -16
- package/.github/workflows/publish.yml +0 -51
- package/.gitignore +0 -16
- package/.npmignore +0 -5
- package/logs/error.log +0 -2
- package/pnpm-lock.yaml +0 -370
- package/src/main.js +0 -112
- package/src/min-heap.js +0 -73
package/dist/schedule.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schedule types and next-run computation.
|
|
3
|
+
*
|
|
4
|
+
* Three schedule kinds:
|
|
5
|
+
* - "at": One-shot at an absolute ISO-8601 timestamp
|
|
6
|
+
* - "every": Recurring interval in milliseconds
|
|
7
|
+
* - "cron": 5-field cron expression with optional timezone
|
|
8
|
+
*/
|
|
9
|
+
import { nextOccurrence, validateCronExpression } from './cron-parser.js';
|
|
10
|
+
/**
|
|
11
|
+
* Compute the next run time for a schedule.
|
|
12
|
+
*/
|
|
13
|
+
export function computeNextRunAtMs(schedule, nowMs) {
|
|
14
|
+
if (schedule.kind === 'at') {
|
|
15
|
+
const atMs = typeof schedule.at === 'number' ? schedule.at : Date.parse(schedule.at);
|
|
16
|
+
if (!Number.isFinite(atMs))
|
|
17
|
+
return undefined;
|
|
18
|
+
return atMs > nowMs ? atMs : undefined;
|
|
19
|
+
}
|
|
20
|
+
if (schedule.kind === 'every') {
|
|
21
|
+
const everyMs = Math.max(1, Math.floor(schedule.everyMs));
|
|
22
|
+
const anchor = Math.max(0, Math.floor(schedule.anchorMs ?? nowMs));
|
|
23
|
+
if (nowMs < anchor)
|
|
24
|
+
return anchor;
|
|
25
|
+
const elapsed = nowMs - anchor;
|
|
26
|
+
const steps = Math.max(1, Math.floor((elapsed + everyMs - 1) / everyMs));
|
|
27
|
+
return anchor + steps * everyMs;
|
|
28
|
+
}
|
|
29
|
+
if (schedule.kind === 'cron') {
|
|
30
|
+
const tz = schedule.tz?.trim() || undefined;
|
|
31
|
+
// Round nowMs down to the current second to avoid sub-second drift
|
|
32
|
+
const nowSecondMs = Math.floor(nowMs / 1000) * 1000;
|
|
33
|
+
return nextOccurrence(schedule.expr.trim(), nowSecondMs, tz);
|
|
34
|
+
}
|
|
35
|
+
throw new Error(`Unknown schedule kind: "${schedule.kind}"`);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Validate a schedule definition.
|
|
39
|
+
*/
|
|
40
|
+
export function validateSchedule(schedule) {
|
|
41
|
+
if (!schedule || typeof schedule !== 'object') {
|
|
42
|
+
throw new Error('Schedule must be an object');
|
|
43
|
+
}
|
|
44
|
+
if (schedule.kind === 'at') {
|
|
45
|
+
const atMs = typeof schedule.at === 'number' ? schedule.at : Date.parse(schedule.at);
|
|
46
|
+
if (!Number.isFinite(atMs)) {
|
|
47
|
+
throw new Error(`Invalid "at" timestamp: "${schedule.at}"`);
|
|
48
|
+
}
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (schedule.kind === 'every') {
|
|
52
|
+
if (typeof schedule.everyMs !== 'number' || schedule.everyMs < 1) {
|
|
53
|
+
throw new Error(`"every" schedule requires everyMs >= 1, got: ${schedule.everyMs}`);
|
|
54
|
+
}
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (schedule.kind === 'cron') {
|
|
58
|
+
if (typeof schedule.expr !== 'string' || !schedule.expr.trim()) {
|
|
59
|
+
throw new Error('"cron" schedule requires a non-empty expr string');
|
|
60
|
+
}
|
|
61
|
+
validateCronExpression(schedule.expr.trim());
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
throw new Error(`Unknown schedule kind: "${schedule.kind}"`);
|
|
65
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import MinHeap, { type HeapItem } from './min-heap.js';
|
|
2
|
+
import { type Job, type JobPatch } from './job.js';
|
|
3
|
+
import RunLog from './run-log.js';
|
|
4
|
+
interface HeapEntry extends HeapItem {
|
|
5
|
+
key: string;
|
|
6
|
+
}
|
|
7
|
+
interface JobDueResult {
|
|
8
|
+
status?: string;
|
|
9
|
+
error?: string;
|
|
10
|
+
summary?: string;
|
|
11
|
+
}
|
|
12
|
+
interface ExecuteResult {
|
|
13
|
+
status: string;
|
|
14
|
+
error?: string;
|
|
15
|
+
summary?: string;
|
|
16
|
+
durationMs?: number;
|
|
17
|
+
deleted?: boolean;
|
|
18
|
+
/** Only set when `status` is `'skipped'`. */
|
|
19
|
+
reason?: 'not due' | 'already running' | 'removed';
|
|
20
|
+
}
|
|
21
|
+
interface ServiceStatus {
|
|
22
|
+
started: boolean;
|
|
23
|
+
jobCount: number;
|
|
24
|
+
nextWakeAtMs: number | undefined;
|
|
25
|
+
}
|
|
26
|
+
interface ListOptions {
|
|
27
|
+
includeDisabled?: boolean;
|
|
28
|
+
}
|
|
29
|
+
type OnJobDueCallback = (job: Job) => Promise<JobDueResult | void> | JobDueResult | void;
|
|
30
|
+
export default class CronService {
|
|
31
|
+
#private;
|
|
32
|
+
jobs: Map<string, Job>;
|
|
33
|
+
heap: MinHeap<HeapEntry>;
|
|
34
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
35
|
+
running: boolean;
|
|
36
|
+
runLog: RunLog;
|
|
37
|
+
started: boolean;
|
|
38
|
+
onJobDue: OnJobDueCallback | null;
|
|
39
|
+
constructor();
|
|
40
|
+
/**
|
|
41
|
+
* Start the service. Loads jobs from store (if any), arms timer.
|
|
42
|
+
*/
|
|
43
|
+
start(initialJobs?: Job[]): Promise<void>;
|
|
44
|
+
/**
|
|
45
|
+
* Stop the service. Clears timer.
|
|
46
|
+
*/
|
|
47
|
+
stop(): void;
|
|
48
|
+
/**
|
|
49
|
+
* Get service status.
|
|
50
|
+
*/
|
|
51
|
+
status(): ServiceStatus;
|
|
52
|
+
/**
|
|
53
|
+
* List jobs, optionally including disabled ones.
|
|
54
|
+
*/
|
|
55
|
+
list(opts?: ListOptions): Job[];
|
|
56
|
+
/**
|
|
57
|
+
* Get a single job by ID.
|
|
58
|
+
*/
|
|
59
|
+
get(id: string): Job | null;
|
|
60
|
+
/**
|
|
61
|
+
* Add a new job. Input is normalized for AI compatibility.
|
|
62
|
+
*/
|
|
63
|
+
add(rawInput: Record<string, unknown>): Promise<Job>;
|
|
64
|
+
/**
|
|
65
|
+
* Update an existing job.
|
|
66
|
+
*/
|
|
67
|
+
update(id: string, patch: JobPatch): Promise<Job>;
|
|
68
|
+
/**
|
|
69
|
+
* Remove a job.
|
|
70
|
+
*/
|
|
71
|
+
remove(id: string): Promise<void>;
|
|
72
|
+
/**
|
|
73
|
+
* Manually trigger a job.
|
|
74
|
+
*
|
|
75
|
+
* Returns `{ status: 'skipped', reason }` without invoking the callback when
|
|
76
|
+
* the job is not due (`mode: 'due'`), is already in flight
|
|
77
|
+
* (`'already running'`), or was removed before the claim landed
|
|
78
|
+
* (`'removed'`). Before the phase split a forced run against an in-flight job
|
|
79
|
+
* launched a second concurrent invocation; refusing it is AC4 of #34.
|
|
80
|
+
*
|
|
81
|
+
* CONCURRENCY: the same job is bounded to one in-flight invocation on every
|
|
82
|
+
* path, and the timer path invokes due jobs one at a time. `run()` fan-out
|
|
83
|
+
* across DIFFERENT jobs is deliberately unbounded - N concurrent `run()`
|
|
84
|
+
* calls produce N concurrent consumer callbacks. Before the phase split
|
|
85
|
+
* these serialized behind the module-global lock; that serialization was the
|
|
86
|
+
* bug, not the feature (one hung callback wedged every other caller), so it
|
|
87
|
+
* is not being restored here. The fan-out is caller-driven: it is bounded by
|
|
88
|
+
* how many times the consumer chooses to call `run()`, exactly like any other
|
|
89
|
+
* async API, and the scheduler never produces it on its own. A consumer that
|
|
90
|
+
* exposes `run()` over HTTP or a CLI owns that bound the same way it owns
|
|
91
|
+
* request concurrency for every other handler. A per-invoke bound inside the
|
|
92
|
+
* service is tracked separately (#35).
|
|
93
|
+
*/
|
|
94
|
+
run(id: string, mode?: 'due' | 'force'): Promise<ExecuteResult>;
|
|
95
|
+
/**
|
|
96
|
+
* Get run history for a job.
|
|
97
|
+
*/
|
|
98
|
+
runs(id: string, limit?: number): ReturnType<RunLog['get']>;
|
|
99
|
+
armTimer(): void;
|
|
100
|
+
onTimer(): Promise<void>;
|
|
101
|
+
findDueJobs(nowMs: number): Job[];
|
|
102
|
+
/**
|
|
103
|
+
* Execute a job in three phases:
|
|
104
|
+
*
|
|
105
|
+
* 1. claim (locked) - take ownership of the job, detach it from the heap
|
|
106
|
+
* 2. invoke (UNLOCKED) - await the consumer callback
|
|
107
|
+
* 3. settle (locked) - apply the result, log it, re-insert into the heap
|
|
108
|
+
*
|
|
109
|
+
* The critical section deliberately excludes phase 2. `onJobDue` is
|
|
110
|
+
* arbitrary, unbounded consumer code; awaiting it under the module-global
|
|
111
|
+
* lock is what wedged every subsequent `locked()` call (add/update/remove)
|
|
112
|
+
* when a callback never settled.
|
|
113
|
+
*
|
|
114
|
+
* `onTimer` performs the batch claim (findDueJobs + markRunning) for all due
|
|
115
|
+
* jobs under a single lock, then enters at phase 2 via `#executeClaimed`.
|
|
116
|
+
* That entry point is a `#private` method rather than a parameter on this
|
|
117
|
+
* one: as a published `alreadyClaimed` boolean it was a supported way for a
|
|
118
|
+
* consumer to skip phase 1 entirely, which defeats the claim guard AC4 asks
|
|
119
|
+
* for and allows concurrent `onJobDue` invocations for the same job.
|
|
120
|
+
*/
|
|
121
|
+
executeJob(job: Job): Promise<ExecuteResult>;
|
|
122
|
+
removeFromHeap(id: string): void;
|
|
123
|
+
log(message: string): void;
|
|
124
|
+
}
|
|
125
|
+
export {};
|