@smitejs/jobs 2.0.0-SNAPSHOT
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/.tsbuildinfo +1 -0
- package/dist/collector.d.ts +33 -0
- package/dist/collector.d.ts.map +1 -0
- package/dist/collector.js +29 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/job.d.ts +89 -0
- package/dist/job.d.ts.map +1 -0
- package/dist/job.js +47 -0
- package/dist/schedule.d.ts +67 -0
- package/dist/schedule.d.ts.map +1 -0
- package/dist/schedule.js +141 -0
- package/dist/scheduler.d.ts +53 -0
- package/dist/scheduler.d.ts.map +1 -0
- package/dist/scheduler.js +79 -0
- package/package.json +34 -0
- package/src/collector.ts +44 -0
- package/src/docs.test.ts +65 -0
- package/src/index.test.ts +176 -0
- package/src/index.ts +36 -0
- package/src/job.ts +108 -0
- package/src/schedule.ts +196 -0
- package/src/scheduler.ts +127 -0
- package/tsconfig.json +11 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { finalizeDescriptor } from "@smitejs/core";
|
|
2
|
+
import { fire } from "@smitejs/handlers";
|
|
3
|
+
import { jobsOf } from "./collector.js";
|
|
4
|
+
import { nextFire } from "./schedule.js";
|
|
5
|
+
const signalOf = (job) => fire(job.id);
|
|
6
|
+
/**
|
|
7
|
+
* Turns an app into a runtime job scheduler. Walks the app's `jobs.job` IR
|
|
8
|
+
* tree via child refs — never the global registry — so it keeps working in
|
|
9
|
+
* production bundles where collect mode is folded out. `start()` fires every
|
|
10
|
+
* job once; `tick()` arms the cron/interval timers and returns a `stop` for
|
|
11
|
+
* shutdown; `{ start: true }` arms automatically. `onRun`/`onError` observe
|
|
12
|
+
* each fire.
|
|
13
|
+
*
|
|
14
|
+
* @group Executor
|
|
15
|
+
* @example
|
|
16
|
+
* ```ts
|
|
17
|
+
* const handle = scheduler(app);
|
|
18
|
+
* const stop = handle.tick();
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export function scheduler(app, options = {}) {
|
|
22
|
+
finalizeDescriptor(app);
|
|
23
|
+
const jobs = jobsOf(app);
|
|
24
|
+
const timers = new Set();
|
|
25
|
+
const runJob = async (job) => {
|
|
26
|
+
options.onRun?.({ id: job.id, at: Date.now() });
|
|
27
|
+
try {
|
|
28
|
+
await Promise.resolve(job.run(signalOf(job)));
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
options.onError?.({ id: job.id, error });
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
const armJob = (job) => {
|
|
35
|
+
if (job.schedule.kind === "interval") {
|
|
36
|
+
const timer = setInterval(() => {
|
|
37
|
+
void runJob(job);
|
|
38
|
+
}, job.schedule.milliseconds);
|
|
39
|
+
timers.add(timer);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const armNext = () => {
|
|
43
|
+
const next = nextFire(job.schedule, new Date());
|
|
44
|
+
if (next === null)
|
|
45
|
+
return;
|
|
46
|
+
const timer = setTimeout(() => {
|
|
47
|
+
timers.delete(timer);
|
|
48
|
+
void runJob(job);
|
|
49
|
+
armNext();
|
|
50
|
+
}, Math.max(0, next.getTime() - Date.now()));
|
|
51
|
+
timers.add(timer);
|
|
52
|
+
};
|
|
53
|
+
armNext();
|
|
54
|
+
};
|
|
55
|
+
const stop = () => {
|
|
56
|
+
for (const timer of timers)
|
|
57
|
+
clearTimer(timer);
|
|
58
|
+
timers.clear();
|
|
59
|
+
};
|
|
60
|
+
return {
|
|
61
|
+
jobs,
|
|
62
|
+
start: async () => {
|
|
63
|
+
for (const job of jobs) {
|
|
64
|
+
await runJob(job);
|
|
65
|
+
}
|
|
66
|
+
if (options.start === true) {
|
|
67
|
+
for (const job of jobs)
|
|
68
|
+
armJob(job);
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
tick: () => {
|
|
72
|
+
for (const job of jobs)
|
|
73
|
+
armJob(job);
|
|
74
|
+
return stop;
|
|
75
|
+
},
|
|
76
|
+
stop,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const clearTimer = (timer) => clearInterval(timer);
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@smitejs/jobs",
|
|
3
|
+
"version": "2.0.0-SNAPSHOT",
|
|
4
|
+
"description": "Scheduled jobs for Smite: job descriptors with cron and interval schedules, a scheduler executor, and a collector.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/githiago-f/smite.git"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/githiago-f/smite#readme",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/githiago-f/smite/issues"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"default": "./dist/index.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": ["dist", "!dist/**/*.test.*", "!dist/.tsbuildinfo"],
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsc -b",
|
|
27
|
+
"test": "vitest run"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@smitejs/core": "^2.0.0-SNAPSHOT",
|
|
31
|
+
"@smitejs/handlers": "^2.0.0-SNAPSHOT"
|
|
32
|
+
},
|
|
33
|
+
"sideEffects": false
|
|
34
|
+
}
|
package/src/collector.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { childrenOf } from "@smitejs/core";
|
|
2
|
+
import type { AppDescriptor, Descriptor } from "@smitejs/core";
|
|
3
|
+
import type { EmptySignal } from "@smitejs/handlers";
|
|
4
|
+
import type { JobSchedule } from "./schedule.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A job as seen by artifact generators: its id, schedule, and the run function
|
|
8
|
+
* the scheduler invokes. `run` takes a zero-input signal.
|
|
9
|
+
*
|
|
10
|
+
* @group Collector
|
|
11
|
+
*/
|
|
12
|
+
export interface CollectedJob {
|
|
13
|
+
readonly id: string;
|
|
14
|
+
readonly schedule: JobSchedule;
|
|
15
|
+
readonly run: (signal: EmptySignal) => void | Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type JobNode = Descriptor<"jobs.job", { id: string; schedule: JobSchedule }>;
|
|
19
|
+
type JobHandlerNode = Descriptor<
|
|
20
|
+
"jobs.handler",
|
|
21
|
+
{ fn: (signal: EmptySignal) => void | Promise<void> }
|
|
22
|
+
>;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Walks an app's `jobs.job` children and returns the collected jobs with their
|
|
26
|
+
* schedules and run functions. Shared by artifact generators and the scheduler
|
|
27
|
+
* executor.
|
|
28
|
+
*
|
|
29
|
+
* @group Collector
|
|
30
|
+
* @example Collect an app's jobs
|
|
31
|
+
*/
|
|
32
|
+
export function jobsOf(app: AppDescriptor): readonly CollectedJob[] {
|
|
33
|
+
return childrenOf(app, "jobs.job").map((node) => {
|
|
34
|
+
const jobNode = node as JobNode;
|
|
35
|
+
const handler = childrenOf(node, "jobs.handler")[0] as
|
|
36
|
+
| JobHandlerNode
|
|
37
|
+
| undefined;
|
|
38
|
+
return {
|
|
39
|
+
id: jobNode.data.id,
|
|
40
|
+
schedule: jobNode.data.schedule,
|
|
41
|
+
run: handler?.data.fn ?? (() => undefined),
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
}
|
package/src/docs.test.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { expandExamples } from "../../../scripts/inject-jsdoc-examples.mjs";
|
|
5
|
+
import {
|
|
6
|
+
collectFiles,
|
|
7
|
+
collectTestSnippets,
|
|
8
|
+
} from "../../../scripts/snippets.mjs";
|
|
9
|
+
|
|
10
|
+
const packageName = "@smitejs/jobs";
|
|
11
|
+
const rootDir = process.cwd();
|
|
12
|
+
const srcDir = join(rootDir, "packages/jobs/src");
|
|
13
|
+
|
|
14
|
+
const exampleReferences = async () => {
|
|
15
|
+
const { snippetIndex } = await collectTestSnippets({
|
|
16
|
+
packageName,
|
|
17
|
+
rootDir,
|
|
18
|
+
srcDir,
|
|
19
|
+
});
|
|
20
|
+
const files = await collectFiles(srcDir, (filePath) =>
|
|
21
|
+
filePath.endsWith(".ts"),
|
|
22
|
+
);
|
|
23
|
+
const references: Array<{ filePath: string; title: string }> = [];
|
|
24
|
+
|
|
25
|
+
for (const filePath of files) {
|
|
26
|
+
const source = await readFile(filePath, "utf8");
|
|
27
|
+
const exampleTag = /^(\s*\*[^\S\r\n]*)@example[^\S\r\n]+([^\r\n]+?)\s*$/gmu;
|
|
28
|
+
for (const match of source.matchAll(exampleTag)) {
|
|
29
|
+
references.push({ filePath, title: match[2]?.trim() ?? "" });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return { snippetIndex, references };
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
describe("documentation integrity", () => {
|
|
37
|
+
it("every @example resolves to a tested #section snippet", async () => {
|
|
38
|
+
const { snippetIndex, references } = await exampleReferences();
|
|
39
|
+
const missing = references
|
|
40
|
+
.filter(({ title }) => !snippetIndex.has(title.toLowerCase()))
|
|
41
|
+
.map(
|
|
42
|
+
({ filePath, title }) =>
|
|
43
|
+
`- Missing tested snippet "${title}" in ${filePath}`,
|
|
44
|
+
);
|
|
45
|
+
expect(missing).toEqual([]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("renders each referenced snippet into a code block", async () => {
|
|
49
|
+
const { snippetIndex, snippets } = await collectTestSnippets({
|
|
50
|
+
packageName,
|
|
51
|
+
rootDir,
|
|
52
|
+
srcDir,
|
|
53
|
+
});
|
|
54
|
+
for (const snippet of snippets) {
|
|
55
|
+
const rendered = expandExamples(
|
|
56
|
+
` * @example ${snippet.title}\n`,
|
|
57
|
+
snippetIndex,
|
|
58
|
+
packageName,
|
|
59
|
+
"synthetic.d.ts",
|
|
60
|
+
);
|
|
61
|
+
expect(rendered).toContain("```ts");
|
|
62
|
+
expect(rendered).toContain(snippet.code.split("\n")[0]);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { childrenOf, clear, createApp, lookup } from "@smitejs/core";
|
|
2
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
cron,
|
|
5
|
+
interval,
|
|
6
|
+
job,
|
|
7
|
+
jobs,
|
|
8
|
+
jobsOf,
|
|
9
|
+
nextFire,
|
|
10
|
+
scheduler,
|
|
11
|
+
} from "./index.js";
|
|
12
|
+
|
|
13
|
+
afterEach(() => clear());
|
|
14
|
+
|
|
15
|
+
describe("schedules", () => {
|
|
16
|
+
it("computes a cron fire using a shared cron builder", () => {
|
|
17
|
+
// #section - Schedule a job on a cron expression
|
|
18
|
+
const everyFive = cron("5 * * * *");
|
|
19
|
+
// #endsection
|
|
20
|
+
|
|
21
|
+
expect(everyFive.kind).toBe("cron");
|
|
22
|
+
expect(
|
|
23
|
+
nextFire(everyFive, new Date("2026-01-01T00:00:00Z"))?.getTime(),
|
|
24
|
+
).toBe(Date.UTC(2026, 0, 1, 0, 5, 0));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("computes the first matching minute boundary", () => {
|
|
28
|
+
// #section - Compute the next cron fire
|
|
29
|
+
const workdayNine = cron("0 9-17 * * 1-5");
|
|
30
|
+
const monday = new Date("2026-01-05T00:00:00Z"); // 2026-01-05 is a Monday
|
|
31
|
+
const next = nextFire(workdayNine, monday);
|
|
32
|
+
// #endsection
|
|
33
|
+
|
|
34
|
+
expect(next?.getTime()).toBe(Date.UTC(2026, 0, 5, 9, 0, 0));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("fires an interval at the offset", () => {
|
|
38
|
+
const everyTenSeconds = interval(10_000);
|
|
39
|
+
const fromAt = new Date("2026-01-01T00:00:00Z");
|
|
40
|
+
expect(nextFire(everyTenSeconds, fromAt)?.getTime()).toBe(
|
|
41
|
+
Date.UTC(2026, 0, 1, 0, 0, 10),
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("rejects malformed cron expressions", () => {
|
|
46
|
+
expect(() => cron("12 30")).toThrow(/fields/);
|
|
47
|
+
expect(() => cron("0 25 * * *")).toThrow(/out of range/);
|
|
48
|
+
expect(() => cron("bad * * * *")).toThrow(/Invalid/);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("job builders", () => {
|
|
53
|
+
it("schedules a job on a cron expression", () => {
|
|
54
|
+
// #section - Define a job
|
|
55
|
+
const app = createApp();
|
|
56
|
+
const descriptor = job(app, "nightly")
|
|
57
|
+
.cron("0 0 * * *")
|
|
58
|
+
.run(() => undefined);
|
|
59
|
+
// #endsection
|
|
60
|
+
|
|
61
|
+
expect(descriptor.__kind).toBe("jobs.job");
|
|
62
|
+
expect(descriptor.data.id).toBe("nightly");
|
|
63
|
+
expect(descriptor.data.schedule.kind).toBe("cron");
|
|
64
|
+
expect(childrenOf(app, "jobs.job").length).toBe(1);
|
|
65
|
+
const handlerNode = childrenOf(descriptor, "jobs.handler")[0];
|
|
66
|
+
expect(handlerNode).toBeDefined();
|
|
67
|
+
expect(lookup(descriptor.__key)).toBeDefined();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("schedules a job on an interval", () => {
|
|
71
|
+
// #section - Schedule a job on an interval
|
|
72
|
+
const app = createApp();
|
|
73
|
+
const heartbeat = job(app, "heartbeat")
|
|
74
|
+
.every(5_000)
|
|
75
|
+
.run(() => undefined);
|
|
76
|
+
// #endsection
|
|
77
|
+
expect(heartbeat.data.schedule.kind).toBe("interval");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("collects an app's jobs", () => {
|
|
81
|
+
const app = createApp();
|
|
82
|
+
|
|
83
|
+
// #section - Collect an app's jobs
|
|
84
|
+
job(app, "cleanup")
|
|
85
|
+
.every(60_000)
|
|
86
|
+
.run(() => undefined);
|
|
87
|
+
job(app, "report")
|
|
88
|
+
.cron("0 2 * * 1")
|
|
89
|
+
.run(() => undefined);
|
|
90
|
+
const collected = jobsOf(app);
|
|
91
|
+
// #endsection
|
|
92
|
+
|
|
93
|
+
expect(collected.map((entry) => entry.id).sort()).toEqual([
|
|
94
|
+
"cleanup",
|
|
95
|
+
"report",
|
|
96
|
+
]);
|
|
97
|
+
expect(collected[0]?.schedule.kind).toBe("interval");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("exposes a namespace bundle", () => {
|
|
101
|
+
// #section - Declare a jobs bundle
|
|
102
|
+
const jobsBundle = jobs;
|
|
103
|
+
// #endsection
|
|
104
|
+
|
|
105
|
+
expect(typeof jobsBundle.cron).toBe("function");
|
|
106
|
+
expect(typeof jobsBundle.interval).toBe("function");
|
|
107
|
+
expect(typeof jobsBundle.job).toBe("function");
|
|
108
|
+
expect(typeof jobsBundle.scheduler).toBe("function");
|
|
109
|
+
expect(typeof jobsBundle.jobsOf).toBe("function");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("scheduler executor", () => {
|
|
114
|
+
it("fires every job once on start", async () => {
|
|
115
|
+
const app = createApp();
|
|
116
|
+
const runs: Record<string, number> = {};
|
|
117
|
+
job(app, "a")
|
|
118
|
+
.cron("* * * * *")
|
|
119
|
+
.run(() => {
|
|
120
|
+
runs.a = (runs.a ?? 0) + 1;
|
|
121
|
+
});
|
|
122
|
+
job(app, "b")
|
|
123
|
+
.every(10)
|
|
124
|
+
.run(() => {
|
|
125
|
+
runs.b = (runs.b ?? 0) + 1;
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const handle = scheduler(app);
|
|
129
|
+
expect(handle.jobs.map((entry) => entry.id).sort()).toEqual(["a", "b"]);
|
|
130
|
+
await handle.start();
|
|
131
|
+
|
|
132
|
+
expect(runs.a).toBe(1);
|
|
133
|
+
expect(runs.b).toBe(1);
|
|
134
|
+
handle.stop();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("arms timers on tick and stops them", async () => {
|
|
138
|
+
const app = createApp();
|
|
139
|
+
const runs: number[] = [];
|
|
140
|
+
job(app, "ticker")
|
|
141
|
+
.every(5)
|
|
142
|
+
.run(() => {
|
|
143
|
+
runs.push(runs.length);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// #section - Schedule an app's jobs
|
|
147
|
+
const handle = scheduler(app);
|
|
148
|
+
const stop = handle.tick();
|
|
149
|
+
// #endsection
|
|
150
|
+
|
|
151
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
152
|
+
stop();
|
|
153
|
+
|
|
154
|
+
expect(runs.length).toBeGreaterThan(0);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("surfaces onError for a failing job", async () => {
|
|
158
|
+
const app = createApp();
|
|
159
|
+
const errors: string[] = [];
|
|
160
|
+
job(app, "boom")
|
|
161
|
+
.every(10)
|
|
162
|
+
.run(() => {
|
|
163
|
+
throw new Error("bad");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const handle = scheduler(app, {
|
|
167
|
+
onError: ({ id, error }) => {
|
|
168
|
+
errors.push(`${id}:${String(error)}`);
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
await handle.start();
|
|
172
|
+
handle.stop();
|
|
173
|
+
|
|
174
|
+
expect(errors).toEqual(["boom:Error: bad"]);
|
|
175
|
+
});
|
|
176
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export { cron, interval, nextFire } from "./schedule.js";
|
|
2
|
+
export type { CompiledCron, FieldMatcher, JobSchedule } from "./schedule.js";
|
|
3
|
+
|
|
4
|
+
export { job, runJob } from "./job.js";
|
|
5
|
+
export type {
|
|
6
|
+
JobBuilder,
|
|
7
|
+
JobDescriptor,
|
|
8
|
+
JobHandler,
|
|
9
|
+
JobHandlerDescriptor,
|
|
10
|
+
JobRun,
|
|
11
|
+
} from "./job.js";
|
|
12
|
+
|
|
13
|
+
export { scheduler } from "./scheduler.js";
|
|
14
|
+
export type { JobScheduler, SchedulerOptions } from "./scheduler.js";
|
|
15
|
+
|
|
16
|
+
export { jobsOf } from "./collector.js";
|
|
17
|
+
export type { CollectedJob } from "./collector.js";
|
|
18
|
+
|
|
19
|
+
import { jobsOf } from "./collector.js";
|
|
20
|
+
import { job } from "./job.js";
|
|
21
|
+
import { cron, interval } from "./schedule.js";
|
|
22
|
+
import { scheduler } from "./scheduler.js";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The jobs namespace: one import for the whole scheduled-jobs app extensor.
|
|
26
|
+
*
|
|
27
|
+
* @group Surface
|
|
28
|
+
* @example Declare a jobs bundle
|
|
29
|
+
*/
|
|
30
|
+
export const jobs = {
|
|
31
|
+
cron,
|
|
32
|
+
interval,
|
|
33
|
+
job,
|
|
34
|
+
jobsOf,
|
|
35
|
+
scheduler,
|
|
36
|
+
};
|
package/src/job.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { defineDescriptor, relate } from "@smitejs/core";
|
|
2
|
+
import type { AppDescriptor, Descriptor } from "@smitejs/core";
|
|
3
|
+
import type { EmptyHandler, EmptySignal } from "@smitejs/handlers";
|
|
4
|
+
import { emptyHandler, fire } from "@smitejs/handlers";
|
|
5
|
+
import { cron, interval } from "./schedule.js";
|
|
6
|
+
import type { JobSchedule } from "./schedule.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The zero-input function a job runs each time it fires.
|
|
10
|
+
*
|
|
11
|
+
* @group Types
|
|
12
|
+
*/
|
|
13
|
+
export type JobHandler = EmptyHandler;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A `jobs.job` IR node: a named schedule and the handler edge that runs it.
|
|
17
|
+
*
|
|
18
|
+
* @group Internals
|
|
19
|
+
*/
|
|
20
|
+
export interface JobDescriptor
|
|
21
|
+
extends Descriptor<
|
|
22
|
+
"jobs.job",
|
|
23
|
+
{ readonly id: string; readonly schedule: JobSchedule }
|
|
24
|
+
> {}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A node wrapping the function a job runs.
|
|
28
|
+
*
|
|
29
|
+
* @group Internals
|
|
30
|
+
*/
|
|
31
|
+
export interface JobHandlerDescriptor
|
|
32
|
+
extends Descriptor<"jobs.handler", { readonly fn: JobHandler }> {}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The terminal step of a {@link JobBuilder}: binds the run function to the
|
|
36
|
+
* schedule chosen and registers the job node under the app.
|
|
37
|
+
*
|
|
38
|
+
* @group Builders
|
|
39
|
+
*/
|
|
40
|
+
export interface JobRun {
|
|
41
|
+
/** Attaches the run function, relates the IR nodes, and returns the job. */
|
|
42
|
+
readonly run: (fn: JobHandler) => JobDescriptor;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A job builder: choose a cron or interval schedule from one shared
|
|
47
|
+
* `job(app, id)` piece, then bind a run function. Always a builder; returns the
|
|
48
|
+
* `jobs.job` descriptor after `run()`.
|
|
49
|
+
*
|
|
50
|
+
* @group Builders
|
|
51
|
+
* @example Define a job
|
|
52
|
+
*/
|
|
53
|
+
export interface JobBuilder {
|
|
54
|
+
/** Cron schedule from a 5-field POSIX expression. */
|
|
55
|
+
readonly cron: (expression: string) => JobRun;
|
|
56
|
+
/** Fixed-interval schedule in milliseconds. */
|
|
57
|
+
readonly every: (milliseconds: number) => JobRun;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Creates a job builder for an app. The common piece — `(app, id)` — yields two
|
|
62
|
+
* schedule builders (`cron()` / `every()`); pick one and `.run(fn)` attaches the
|
|
63
|
+
* `jobs.handler` child and relates the `jobs.job` node under the app.
|
|
64
|
+
*
|
|
65
|
+
* @group Builders
|
|
66
|
+
* @example Schedule a job on a cron expression
|
|
67
|
+
* @example Schedule a job on an interval
|
|
68
|
+
*/
|
|
69
|
+
export function job(app: AppDescriptor, id: string): JobBuilder {
|
|
70
|
+
const declare = (schedule: JobSchedule): JobRun => ({
|
|
71
|
+
run: (fn: JobHandler): JobDescriptor => {
|
|
72
|
+
const descriptor = defineDescriptor(
|
|
73
|
+
"jobs.job",
|
|
74
|
+
`${app.__key}:job:${id}`,
|
|
75
|
+
{
|
|
76
|
+
id,
|
|
77
|
+
schedule,
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
const handlerDescriptor = defineDescriptor(
|
|
81
|
+
"jobs.handler",
|
|
82
|
+
`${descriptor.__key}:handler`,
|
|
83
|
+
{ fn: emptyHandler({ name: id }, fn) },
|
|
84
|
+
);
|
|
85
|
+
relate(descriptor, "jobs.handler", handlerDescriptor);
|
|
86
|
+
relate(app, "jobs.job", descriptor);
|
|
87
|
+
return descriptor;
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const builder: JobBuilder = {
|
|
92
|
+
cron: (expression: string) => declare(cron(expression)),
|
|
93
|
+
every: (milliseconds: number) => declare(interval(milliseconds)),
|
|
94
|
+
};
|
|
95
|
+
return builder;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Fires a job's run function with a zero-input signal at the given (or
|
|
100
|
+
* current) instant.
|
|
101
|
+
*
|
|
102
|
+
* @group Executor
|
|
103
|
+
*/
|
|
104
|
+
export const runJob = (
|
|
105
|
+
descriptor: { readonly data: { readonly id: string } },
|
|
106
|
+
fn: (signal: EmptySignal) => void | Promise<void>,
|
|
107
|
+
at: number | Date = Date.now(),
|
|
108
|
+
): Promise<void> => Promise.resolve(fn(fire(descriptor.data.id, at)));
|