@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.
@@ -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
+ }
@@ -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
+ }
@@ -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)));