@rebasepro/server-core 0.0.1-canary.eae7889 → 0.1.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/app/frontend/node_modules/esbuild/LICENSE.md +21 -0
- package/app/frontend/node_modules/esbuild/README.md +3 -0
- package/app/frontend/node_modules/esbuild/bin/esbuild +220 -0
- package/app/frontend/node_modules/esbuild/install.js +285 -0
- package/app/frontend/node_modules/esbuild/lib/main.d.ts +705 -0
- package/app/frontend/node_modules/esbuild/lib/main.js +2239 -0
- package/app/frontend/node_modules/esbuild/package.json +46 -0
- package/dist/index.es.js +1186 -1673
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +1185 -1672
- package/dist/index.umd.js.map +1 -1
- package/dist/server-core/src/api/rest/api-generator.d.ts +15 -3
- package/dist/server-core/src/auth/admin-routes.d.ts +5 -0
- package/dist/server-core/src/auth/google-oauth.d.ts +36 -3
- package/dist/server-core/src/auth/index.d.ts +1 -0
- package/dist/server-core/src/cron/cron-scheduler.d.ts +45 -0
- package/dist/server-core/src/cron/index.d.ts +1 -1
- package/dist/server-core/src/init.d.ts +11 -1
- package/dist/types/src/controllers/auth.d.ts +8 -2
- package/dist/types/src/controllers/client.d.ts +13 -0
- package/dist/types/src/controllers/collection_registry.d.ts +2 -1
- package/dist/types/src/controllers/data_driver.d.ts +36 -1
- package/dist/types/src/controllers/navigation.d.ts +18 -6
- package/dist/types/src/controllers/registry.d.ts +9 -1
- package/dist/types/src/controllers/side_entity_controller.d.ts +7 -0
- package/dist/types/src/rebase_context.d.ts +17 -0
- package/dist/types/src/types/backend_hooks.d.ts +187 -0
- package/dist/types/src/types/collections.d.ts +31 -11
- package/dist/types/src/types/component_ref.d.ts +47 -0
- package/dist/types/src/types/cron.d.ts +1 -1
- package/dist/types/src/types/entity_views.d.ts +6 -7
- package/dist/types/src/types/formex.d.ts +40 -0
- package/dist/types/src/types/index.d.ts +3 -0
- package/dist/types/src/types/plugins.d.ts +6 -3
- package/dist/types/src/types/properties.d.ts +72 -88
- package/dist/types/src/types/slots.d.ts +20 -10
- package/dist/types/src/types/translations.d.ts +6 -0
- package/examples/firebase/node_modules/esbuild/LICENSE.md +21 -0
- package/examples/firebase/node_modules/esbuild/README.md +3 -0
- package/examples/firebase/node_modules/esbuild/bin/esbuild +220 -0
- package/examples/firebase/node_modules/esbuild/install.js +285 -0
- package/examples/firebase/node_modules/esbuild/lib/main.d.ts +705 -0
- package/examples/firebase/node_modules/esbuild/lib/main.js +2239 -0
- package/examples/firebase/node_modules/esbuild/package.json +46 -0
- package/examples/medmot-staging/frontend/node_modules/esbuild/LICENSE.md +21 -0
- package/examples/medmot-staging/frontend/node_modules/esbuild/README.md +3 -0
- package/examples/medmot-staging/frontend/node_modules/esbuild/bin/esbuild +220 -0
- package/examples/medmot-staging/frontend/node_modules/esbuild/install.js +285 -0
- package/examples/medmot-staging/frontend/node_modules/esbuild/lib/main.d.ts +705 -0
- package/examples/medmot-staging/frontend/node_modules/esbuild/lib/main.js +2239 -0
- package/examples/medmot-staging/frontend/node_modules/esbuild/package.json +46 -0
- package/examples/sdk-demo/node_modules/esbuild/LICENSE.md +21 -0
- package/examples/sdk-demo/node_modules/esbuild/README.md +3 -0
- package/examples/sdk-demo/node_modules/esbuild/bin/esbuild +223 -0
- package/examples/sdk-demo/node_modules/esbuild/install.js +289 -0
- package/examples/sdk-demo/node_modules/esbuild/lib/main.d.ts +716 -0
- package/examples/sdk-demo/node_modules/esbuild/lib/main.js +2242 -0
- package/examples/sdk-demo/node_modules/esbuild/package.json +49 -0
- package/package.json +9 -9
- package/packages/client/node_modules/esbuild/LICENSE.md +21 -0
- package/packages/client/node_modules/esbuild/README.md +3 -0
- package/packages/client/node_modules/esbuild/bin/esbuild +220 -0
- package/packages/client/node_modules/esbuild/install.js +285 -0
- package/packages/client/node_modules/esbuild/lib/main.d.ts +705 -0
- package/packages/client/node_modules/esbuild/lib/main.js +2239 -0
- package/packages/client/node_modules/esbuild/package.json +46 -0
- package/packages/client-postgresql/node_modules/esbuild/LICENSE.md +21 -0
- package/packages/client-postgresql/node_modules/esbuild/README.md +3 -0
- package/packages/client-postgresql/node_modules/esbuild/bin/esbuild +220 -0
- package/packages/client-postgresql/node_modules/esbuild/install.js +285 -0
- package/packages/client-postgresql/node_modules/esbuild/lib/main.d.ts +705 -0
- package/packages/client-postgresql/node_modules/esbuild/lib/main.js +2239 -0
- package/packages/client-postgresql/node_modules/esbuild/package.json +46 -0
- package/packages/common/node_modules/esbuild/LICENSE.md +21 -0
- package/packages/common/node_modules/esbuild/README.md +3 -0
- package/packages/common/node_modules/esbuild/bin/esbuild +220 -0
- package/packages/common/node_modules/esbuild/install.js +285 -0
- package/packages/common/node_modules/esbuild/lib/main.d.ts +705 -0
- package/packages/common/node_modules/esbuild/lib/main.js +2239 -0
- package/packages/common/node_modules/esbuild/package.json +46 -0
- package/packages/server-mongodb/node_modules/esbuild/LICENSE.md +21 -0
- package/packages/server-mongodb/node_modules/esbuild/README.md +3 -0
- package/packages/server-mongodb/node_modules/esbuild/bin/esbuild +220 -0
- package/packages/server-mongodb/node_modules/esbuild/install.js +285 -0
- package/packages/server-mongodb/node_modules/esbuild/lib/main.d.ts +705 -0
- package/packages/server-mongodb/node_modules/esbuild/lib/main.js +2239 -0
- package/packages/server-mongodb/node_modules/esbuild/package.json +46 -0
- package/packages/server-postgresql/node_modules/esbuild/LICENSE.md +21 -0
- package/packages/server-postgresql/node_modules/esbuild/README.md +3 -0
- package/packages/server-postgresql/node_modules/esbuild/bin/esbuild +220 -0
- package/packages/server-postgresql/node_modules/esbuild/install.js +285 -0
- package/packages/server-postgresql/node_modules/esbuild/lib/main.d.ts +705 -0
- package/packages/server-postgresql/node_modules/esbuild/lib/main.js +2239 -0
- package/packages/server-postgresql/node_modules/esbuild/package.json +46 -0
- package/packages/types/node_modules/esbuild/LICENSE.md +21 -0
- package/packages/types/node_modules/esbuild/README.md +3 -0
- package/packages/types/node_modules/esbuild/bin/esbuild +220 -0
- package/packages/types/node_modules/esbuild/install.js +285 -0
- package/packages/types/node_modules/esbuild/lib/main.d.ts +705 -0
- package/packages/types/node_modules/esbuild/lib/main.js +2239 -0
- package/packages/types/node_modules/esbuild/package.json +46 -0
- package/packages/utils/node_modules/esbuild/LICENSE.md +21 -0
- package/packages/utils/node_modules/esbuild/README.md +3 -0
- package/packages/utils/node_modules/esbuild/bin/esbuild +220 -0
- package/packages/utils/node_modules/esbuild/install.js +285 -0
- package/packages/utils/node_modules/esbuild/lib/main.d.ts +705 -0
- package/packages/utils/node_modules/esbuild/lib/main.js +2239 -0
- package/packages/utils/node_modules/esbuild/package.json +46 -0
- package/src/api/errors.ts +3 -2
- package/src/api/rest/api-generator-count.test.ts +113 -0
- package/src/api/rest/api-generator.ts +123 -22
- package/src/api/server.ts +8 -4
- package/src/auth/admin-routes.ts +133 -57
- package/src/auth/apple-oauth.ts +8 -18
- package/src/auth/google-oauth.ts +192 -22
- package/src/auth/index.ts +1 -0
- package/src/auth/rate-limiter.ts +9 -5
- package/src/auth/routes.ts +25 -5
- package/src/collections/loader.ts +3 -3
- package/src/cron/cron-scheduler.test.ts +301 -175
- package/src/cron/cron-scheduler.ts +220 -57
- package/src/cron/index.ts +1 -1
- package/src/init.ts +27 -5
- package/src/storage/LocalStorageController.ts +37 -13
- package/src/storage/S3StorageController.ts +4 -1
- package/src/storage/routes.ts +51 -5
- package/test/backend-hooks-admin.test.ts +394 -0
- package/test/backend-hooks-data.test.ts +408 -0
- package/history_diff.log +0 -385
- package/scratch.ts +0 -9
- package/test-ast.ts +0 -28
- package/test_output.txt +0 -1133
|
@@ -1,45 +1,27 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach, jest } from "@jest/globals";
|
|
2
|
-
import { CronScheduler } from "./cron-scheduler";
|
|
2
|
+
import { CronScheduler, validateCronExpression } from "./cron-scheduler";
|
|
3
3
|
import type { CronJobDefinition } from "@rebasepro/types";
|
|
4
4
|
import type { LoadedCronJob } from "./cron-loader";
|
|
5
5
|
|
|
6
6
|
// ─── Helpers ────────────────────────────────────────────────────────
|
|
7
7
|
|
|
8
|
-
function makeJob(
|
|
9
|
-
id: string,
|
|
10
|
-
overrides: Partial<CronJobDefinition> = {}
|
|
11
|
-
): LoadedCronJob {
|
|
8
|
+
function makeJob(id: string, overrides: Partial<CronJobDefinition> = {}): LoadedCronJob {
|
|
12
9
|
return {
|
|
13
10
|
id,
|
|
14
11
|
definition: {
|
|
15
|
-
schedule: "0 * * * *",
|
|
12
|
+
schedule: "0 * * * *",
|
|
16
13
|
name: `Job ${id}`,
|
|
17
14
|
description: `Description for ${id}`,
|
|
18
15
|
enabled: true,
|
|
19
16
|
timeoutSeconds: 5,
|
|
20
|
-
handler: async (ctx) => {
|
|
21
|
-
ctx.log("hello from", id);
|
|
22
|
-
return { ok: true };
|
|
23
|
-
},
|
|
17
|
+
handler: async (ctx) => { ctx.log("hello from", id); return { ok: true }; },
|
|
24
18
|
...overrides
|
|
25
19
|
}
|
|
26
20
|
};
|
|
27
21
|
}
|
|
28
22
|
|
|
29
23
|
function makeFailingJob(id: string, errorMsg = "boom"): LoadedCronJob {
|
|
30
|
-
return makeJob(id, {
|
|
31
|
-
handler: async () => {
|
|
32
|
-
throw new Error(errorMsg);
|
|
33
|
-
}
|
|
34
|
-
});
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function makeSlowJob(id: string, delayMs: number): LoadedCronJob {
|
|
38
|
-
return makeJob(id, {
|
|
39
|
-
timeoutSeconds: 1, // 1s timeout
|
|
40
|
-
handler: () =>
|
|
41
|
-
new Promise((resolve) => setTimeout(() => resolve({ done: true }), delayMs))
|
|
42
|
-
});
|
|
24
|
+
return makeJob(id, { handler: async () => { throw new Error(errorMsg); } });
|
|
43
25
|
}
|
|
44
26
|
|
|
45
27
|
// ─── Tests ──────────────────────────────────────────────────────────
|
|
@@ -57,60 +39,103 @@ describe("CronScheduler", () => {
|
|
|
57
39
|
jest.useRealTimers();
|
|
58
40
|
});
|
|
59
41
|
|
|
42
|
+
// ── validateCronExpression ───────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
describe("validateCronExpression", () => {
|
|
45
|
+
it.each([
|
|
46
|
+
"0 * * * *", "*/5 * * * *", "0 0 1 * *", "30 2 * * 1",
|
|
47
|
+
"0 0 * * 0", "0,15,30,45 * * * *", "0 0 1-15 * *",
|
|
48
|
+
])("accepts valid expression: %s", (expr) => {
|
|
49
|
+
expect(validateCronExpression(expr)).toEqual({ valid: true });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("rejects empty string", () => {
|
|
53
|
+
const r = validateCronExpression("");
|
|
54
|
+
expect(r.valid).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("rejects wrong field count", () => {
|
|
58
|
+
expect(validateCronExpression("* * *").valid).toBe(false);
|
|
59
|
+
expect(validateCronExpression("* * * * * *").valid).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("rejects out-of-range values", () => {
|
|
63
|
+
expect(validateCronExpression("60 * * * *").valid).toBe(false);
|
|
64
|
+
expect(validateCronExpression("* 25 * * *").valid).toBe(false);
|
|
65
|
+
expect(validateCronExpression("* * 32 * *").valid).toBe(false);
|
|
66
|
+
expect(validateCronExpression("* * * 13 *").valid).toBe(false);
|
|
67
|
+
expect(validateCronExpression("* * * * 7").valid).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("rejects non-numeric garbage", () => {
|
|
71
|
+
expect(validateCronExpression("abc * * * *").valid).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
60
75
|
// ── Registration ────────────────────────────────────────────────
|
|
61
76
|
|
|
62
77
|
describe("registerJobs", () => {
|
|
63
78
|
it("registers jobs and they appear in listJobs", () => {
|
|
64
79
|
scheduler.registerJobs([makeJob("alpha"), makeJob("beta")]);
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
expect(jobs).toHaveLength(2);
|
|
68
|
-
expect(jobs.map((j) => j.id).sort()).toEqual(["alpha", "beta"]);
|
|
80
|
+
expect(scheduler.listJobs()).toHaveLength(2);
|
|
81
|
+
expect(scheduler.listJobs().map((j) => j.id).sort()).toEqual(["alpha", "beta"]);
|
|
69
82
|
});
|
|
70
83
|
|
|
71
84
|
it("sets initial state to idle for enabled jobs", () => {
|
|
72
85
|
scheduler.registerJobs([makeJob("enabled-job")]);
|
|
73
|
-
|
|
74
|
-
expect(job?.state).toBe("idle");
|
|
75
|
-
expect(job?.enabled).toBe(true);
|
|
86
|
+
expect(scheduler.getJob("enabled-job")?.state).toBe("idle");
|
|
76
87
|
});
|
|
77
88
|
|
|
78
89
|
it("sets initial state to disabled for disabled jobs", () => {
|
|
79
90
|
scheduler.registerJobs([makeJob("disabled-job", { enabled: false })]);
|
|
80
|
-
|
|
81
|
-
expect(job?.state).toBe("disabled");
|
|
82
|
-
expect(job?.enabled).toBe(false);
|
|
91
|
+
expect(scheduler.getJob("disabled-job")?.state).toBe("disabled");
|
|
83
92
|
});
|
|
84
93
|
|
|
85
94
|
it("initializes counters to zero", () => {
|
|
86
95
|
scheduler.registerJobs([makeJob("fresh")]);
|
|
87
|
-
const job = scheduler.getJob("fresh")
|
|
88
|
-
expect(job
|
|
89
|
-
expect(job
|
|
96
|
+
const job = scheduler.getJob("fresh")!;
|
|
97
|
+
expect(job.totalRuns).toBe(0);
|
|
98
|
+
expect(job.totalFailures).toBe(0);
|
|
90
99
|
});
|
|
91
100
|
|
|
92
101
|
it("overwrites duplicate job IDs", () => {
|
|
93
102
|
scheduler.registerJobs([makeJob("dup", { name: "First" })]);
|
|
94
103
|
scheduler.registerJobs([makeJob("dup", { name: "Second" })]);
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
expect(jobs).toHaveLength(1);
|
|
98
|
-
expect(jobs[0].name).toBe("Second");
|
|
104
|
+
expect(scheduler.listJobs()).toHaveLength(1);
|
|
105
|
+
expect(scheduler.listJobs()[0].name).toBe("Second");
|
|
99
106
|
});
|
|
100
107
|
|
|
101
108
|
it("preserves definition metadata", () => {
|
|
102
|
-
scheduler.registerJobs([
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
expect(
|
|
112
|
-
expect(
|
|
113
|
-
|
|
109
|
+
scheduler.registerJobs([makeJob("meta", { name: "My Job", description: "Desc", schedule: "30 2 * * 1" })]);
|
|
110
|
+
const job = scheduler.getJob("meta")!;
|
|
111
|
+
expect(job.name).toBe("My Job");
|
|
112
|
+
expect(job.description).toBe("Desc");
|
|
113
|
+
expect(job.schedule).toBe("30 2 * * 1");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("rejects jobs with invalid cron schedules", () => {
|
|
117
|
+
scheduler.registerJobs([makeJob("bad", { schedule: "99 99 * * *" })]);
|
|
118
|
+
expect(scheduler.getJob("bad")).toBeUndefined();
|
|
119
|
+
expect(scheduler.listJobs()).toHaveLength(0);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("rejects jobs with too few fields", () => {
|
|
123
|
+
scheduler.registerJobs([makeJob("short", { schedule: "* *" })]);
|
|
124
|
+
expect(scheduler.getJob("short")).toBeUndefined();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("auto-schedules newly registered jobs if already started", () => {
|
|
128
|
+
scheduler.registerJobs([makeJob("early")]);
|
|
129
|
+
scheduler.start();
|
|
130
|
+
// Register after start
|
|
131
|
+
scheduler.registerJobs([makeJob("late")]);
|
|
132
|
+
expect(scheduler.getJob("late")?.nextRunAt).toBeDefined();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("does NOT auto-schedule disabled jobs registered after start", () => {
|
|
136
|
+
scheduler.start();
|
|
137
|
+
scheduler.registerJobs([makeJob("off", { enabled: false })]);
|
|
138
|
+
expect(scheduler.getJob("off")?.nextRunAt).toBeUndefined();
|
|
114
139
|
});
|
|
115
140
|
});
|
|
116
141
|
|
|
@@ -123,27 +148,22 @@ describe("CronScheduler", () => {
|
|
|
123
148
|
|
|
124
149
|
it("returns the correct job by ID", () => {
|
|
125
150
|
scheduler.registerJobs([makeJob("a"), makeJob("b")]);
|
|
126
|
-
|
|
127
|
-
expect(job?.id).toBe("b");
|
|
151
|
+
expect(scheduler.getJob("b")?.id).toBe("b");
|
|
128
152
|
});
|
|
129
153
|
});
|
|
130
154
|
|
|
131
155
|
// ── triggerJob (manual execution) ───────────────────────────────
|
|
132
156
|
|
|
133
157
|
describe("triggerJob", () => {
|
|
134
|
-
beforeEach(() => {
|
|
135
|
-
jest.useRealTimers(); // triggerJob uses real async
|
|
136
|
-
});
|
|
158
|
+
beforeEach(() => { jest.useRealTimers(); });
|
|
137
159
|
|
|
138
160
|
it("returns undefined for nonexistent job", async () => {
|
|
139
|
-
|
|
140
|
-
expect(result).toBeUndefined();
|
|
161
|
+
expect(await scheduler.triggerJob("ghost")).toBeUndefined();
|
|
141
162
|
});
|
|
142
163
|
|
|
143
164
|
it("executes the handler and returns a log entry", async () => {
|
|
144
165
|
scheduler.registerJobs([makeJob("trigger-me")]);
|
|
145
166
|
const log = await scheduler.triggerJob("trigger-me");
|
|
146
|
-
|
|
147
167
|
expect(log).toBeDefined();
|
|
148
168
|
expect(log!.jobId).toBe("trigger-me");
|
|
149
169
|
expect(log!.success).toBe(true);
|
|
@@ -155,57 +175,38 @@ describe("CronScheduler", () => {
|
|
|
155
175
|
|
|
156
176
|
it("increments totalRuns after trigger", async () => {
|
|
157
177
|
scheduler.registerJobs([makeJob("count-me")]);
|
|
158
|
-
|
|
159
178
|
await scheduler.triggerJob("count-me");
|
|
160
179
|
expect(scheduler.getJob("count-me")?.totalRuns).toBe(1);
|
|
161
|
-
|
|
162
180
|
await scheduler.triggerJob("count-me");
|
|
163
181
|
expect(scheduler.getJob("count-me")?.totalRuns).toBe(2);
|
|
164
182
|
});
|
|
165
183
|
|
|
166
184
|
it("records failure and increments totalFailures", async () => {
|
|
167
185
|
scheduler.registerJobs([makeFailingJob("fail-me", "something broke")]);
|
|
168
|
-
|
|
169
186
|
const log = await scheduler.triggerJob("fail-me");
|
|
170
|
-
|
|
171
187
|
expect(log!.success).toBe(false);
|
|
172
188
|
expect(log!.error).toBe("something broke");
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
expect(status
|
|
176
|
-
expect(status
|
|
177
|
-
expect(status?.lastError).toBe("something broke");
|
|
189
|
+
const status = scheduler.getJob("fail-me")!;
|
|
190
|
+
expect(status.totalFailures).toBe(1);
|
|
191
|
+
expect(status.state).toBe("error");
|
|
192
|
+
expect(status.lastError).toBe("something broke");
|
|
178
193
|
});
|
|
179
194
|
|
|
180
195
|
it("captures ctx.log output", async () => {
|
|
181
|
-
scheduler.registerJobs([
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
ctx.log("line 1");
|
|
185
|
-
ctx.log("line 2", { nested: true });
|
|
186
|
-
ctx.log(42);
|
|
187
|
-
}
|
|
188
|
-
})
|
|
189
|
-
]);
|
|
190
|
-
|
|
196
|
+
scheduler.registerJobs([makeJob("logger", {
|
|
197
|
+
handler: async (ctx) => { ctx.log("line 1"); ctx.log("line 2", { nested: true }); ctx.log(42); }
|
|
198
|
+
})]);
|
|
191
199
|
const log = await scheduler.triggerJob("logger");
|
|
192
|
-
expect(log!.logs).toEqual([
|
|
193
|
-
"line 1",
|
|
194
|
-
'line 2 {"nested":true}',
|
|
195
|
-
"42"
|
|
196
|
-
]);
|
|
200
|
+
expect(log!.logs).toEqual(["line 1", 'line 2 {"nested":true}', "42"]);
|
|
197
201
|
});
|
|
198
202
|
|
|
199
203
|
it("sets lastRunAt after execution", async () => {
|
|
200
204
|
scheduler.registerJobs([makeJob("timed")]);
|
|
201
|
-
|
|
202
205
|
const before = new Date();
|
|
203
206
|
await scheduler.triggerJob("timed");
|
|
204
207
|
const after = new Date();
|
|
205
|
-
|
|
206
|
-
const
|
|
207
|
-
expect(job?.lastRunAt).toBeDefined();
|
|
208
|
-
const lastRun = new Date(job!.lastRunAt!);
|
|
208
|
+
const job = scheduler.getJob("timed")!;
|
|
209
|
+
const lastRun = new Date(job.lastRunAt!);
|
|
209
210
|
expect(lastRun.getTime()).toBeGreaterThanOrEqual(before.getTime());
|
|
210
211
|
expect(lastRun.getTime()).toBeLessThanOrEqual(after.getTime());
|
|
211
212
|
});
|
|
@@ -213,40 +214,80 @@ describe("CronScheduler", () => {
|
|
|
213
214
|
it("sets lastDurationMs after execution", async () => {
|
|
214
215
|
scheduler.registerJobs([makeJob("duration-check")]);
|
|
215
216
|
await scheduler.triggerJob("duration-check");
|
|
216
|
-
|
|
217
|
-
const job = scheduler.getJob("duration-check");
|
|
218
|
-
expect(job?.lastDurationMs).toBeDefined();
|
|
219
|
-
expect(job!.lastDurationMs!).toBeGreaterThanOrEqual(0);
|
|
217
|
+
expect(scheduler.getJob("duration-check")!.lastDurationMs!).toBeGreaterThanOrEqual(0);
|
|
220
218
|
});
|
|
221
219
|
|
|
222
220
|
it("handles handler that returns undefined (void)", async () => {
|
|
223
|
-
scheduler.registerJobs([
|
|
224
|
-
makeJob("void-handler", {
|
|
225
|
-
handler: async () => {
|
|
226
|
-
// returns void
|
|
227
|
-
}
|
|
228
|
-
})
|
|
229
|
-
]);
|
|
230
|
-
|
|
221
|
+
scheduler.registerJobs([makeJob("void-handler", { handler: async () => {} })]);
|
|
231
222
|
const log = await scheduler.triggerJob("void-handler");
|
|
232
223
|
expect(log!.success).toBe(true);
|
|
233
224
|
expect(log!.result).toBeUndefined();
|
|
234
225
|
});
|
|
235
226
|
|
|
236
227
|
it("handles synchronous handler", async () => {
|
|
237
|
-
scheduler.registerJobs([
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
ctx.log("sync");
|
|
241
|
-
return { sync: true };
|
|
242
|
-
}
|
|
243
|
-
})
|
|
244
|
-
]);
|
|
245
|
-
|
|
228
|
+
scheduler.registerJobs([makeJob("sync-handler", {
|
|
229
|
+
handler: (ctx) => { ctx.log("sync"); return { sync: true }; }
|
|
230
|
+
})]);
|
|
246
231
|
const log = await scheduler.triggerJob("sync-handler");
|
|
247
232
|
expect(log!.success).toBe(true);
|
|
248
233
|
expect(log!.result).toEqual({ sync: true });
|
|
249
234
|
});
|
|
235
|
+
|
|
236
|
+
it("handles non-Error thrown values", async () => {
|
|
237
|
+
scheduler.registerJobs([makeJob("string-throw", {
|
|
238
|
+
handler: async () => { throw "plain string error"; }
|
|
239
|
+
})]);
|
|
240
|
+
const log = await scheduler.triggerJob("string-throw");
|
|
241
|
+
expect(log!.success).toBe(false);
|
|
242
|
+
expect(log!.error).toBe("plain string error");
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// ── Concurrency guard ───────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
describe("concurrency guard", () => {
|
|
249
|
+
beforeEach(() => { jest.useRealTimers(); });
|
|
250
|
+
|
|
251
|
+
it("prevents overlapping manual triggers", async () => {
|
|
252
|
+
let resolve: () => void;
|
|
253
|
+
const blocker = new Promise<void>((r) => { resolve = r; });
|
|
254
|
+
|
|
255
|
+
scheduler.registerJobs([makeJob("slow", {
|
|
256
|
+
handler: async () => { await blocker; return { done: true }; }
|
|
257
|
+
})]);
|
|
258
|
+
|
|
259
|
+
// Start first trigger (will block)
|
|
260
|
+
const first = scheduler.triggerJob("slow");
|
|
261
|
+
|
|
262
|
+
// Try second trigger while first is running
|
|
263
|
+
const second = await scheduler.triggerJob("slow");
|
|
264
|
+
|
|
265
|
+
expect(second!.result).toEqual({ skipped: true, reason: "already_executing" });
|
|
266
|
+
expect(second!.logs).toContain("Skipped: job is already running");
|
|
267
|
+
|
|
268
|
+
// Let the first one finish
|
|
269
|
+
resolve!();
|
|
270
|
+
const firstResult = await first;
|
|
271
|
+
expect(firstResult!.success).toBe(true);
|
|
272
|
+
expect(firstResult!.result).toEqual({ done: true });
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("allows trigger after previous completes", async () => {
|
|
276
|
+
scheduler.registerJobs([makeJob("sequential")]);
|
|
277
|
+
const log1 = await scheduler.triggerJob("sequential");
|
|
278
|
+
const log2 = await scheduler.triggerJob("sequential");
|
|
279
|
+
expect(log1!.success).toBe(true);
|
|
280
|
+
expect(log2!.success).toBe(true);
|
|
281
|
+
expect(scheduler.getJob("sequential")!.totalRuns).toBe(2);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("resets executing flag even after handler throws", async () => {
|
|
285
|
+
scheduler.registerJobs([makeFailingJob("crasher")]);
|
|
286
|
+
await scheduler.triggerJob("crasher");
|
|
287
|
+
// Should NOT be skipped — executing flag was reset
|
|
288
|
+
const log = await scheduler.triggerJob("crasher");
|
|
289
|
+
expect(log!.error).toBe("boom"); // ran again, not skipped
|
|
290
|
+
});
|
|
250
291
|
});
|
|
251
292
|
|
|
252
293
|
// ── Timeout ─────────────────────────────────────────────────────
|
|
@@ -254,61 +295,51 @@ describe("CronScheduler", () => {
|
|
|
254
295
|
describe("timeout", () => {
|
|
255
296
|
it("times out a slow handler", async () => {
|
|
256
297
|
jest.useRealTimers();
|
|
257
|
-
scheduler.registerJobs([
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
new Promise((resolve) =>
|
|
262
|
-
setTimeout(() => resolve("late"), 3000)
|
|
263
|
-
)
|
|
264
|
-
})
|
|
265
|
-
]);
|
|
266
|
-
|
|
298
|
+
scheduler.registerJobs([makeJob("slow", {
|
|
299
|
+
timeoutSeconds: 1,
|
|
300
|
+
handler: () => new Promise((resolve) => setTimeout(() => resolve("late"), 3000))
|
|
301
|
+
})]);
|
|
267
302
|
const log = await scheduler.triggerJob("slow");
|
|
268
303
|
expect(log!.success).toBe(false);
|
|
269
304
|
expect(log!.error).toContain("timed out");
|
|
270
305
|
}, 10000);
|
|
306
|
+
|
|
307
|
+
it("clears timeout timer after success (no timer leak)", async () => {
|
|
308
|
+
jest.useRealTimers();
|
|
309
|
+
scheduler.registerJobs([makeJob("fast", {
|
|
310
|
+
timeoutSeconds: 60,
|
|
311
|
+
handler: async () => "quick"
|
|
312
|
+
})]);
|
|
313
|
+
const log = await scheduler.triggerJob("fast");
|
|
314
|
+
expect(log!.success).toBe(true);
|
|
315
|
+
// If timeout wasn't cleared, Jest would hang
|
|
316
|
+
});
|
|
271
317
|
});
|
|
272
318
|
|
|
273
319
|
// ── Logs ring buffer ────────────────────────────────────────────
|
|
274
320
|
|
|
275
321
|
describe("getJobLogs", () => {
|
|
276
|
-
beforeEach(() => {
|
|
277
|
-
jest.useRealTimers();
|
|
278
|
-
});
|
|
322
|
+
beforeEach(() => { jest.useRealTimers(); });
|
|
279
323
|
|
|
280
324
|
it("returns empty array for nonexistent job", () => {
|
|
281
325
|
expect(scheduler.getJobLogs("nope")).toEqual([]);
|
|
282
326
|
});
|
|
283
327
|
|
|
284
328
|
it("returns logs in reverse order (newest first)", async () => {
|
|
285
|
-
scheduler.registerJobs([
|
|
286
|
-
makeJob("ordered", {
|
|
287
|
-
handler: async (ctx) => {
|
|
288
|
-
return { run: ctx.jobId };
|
|
289
|
-
}
|
|
290
|
-
})
|
|
291
|
-
]);
|
|
292
|
-
|
|
329
|
+
scheduler.registerJobs([makeJob("ordered")]);
|
|
293
330
|
await scheduler.triggerJob("ordered");
|
|
294
331
|
await scheduler.triggerJob("ordered");
|
|
295
332
|
await scheduler.triggerJob("ordered");
|
|
296
|
-
|
|
297
333
|
const logs = scheduler.getJobLogs("ordered");
|
|
298
334
|
expect(logs).toHaveLength(3);
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
expect(t0).toBeGreaterThanOrEqual(t2);
|
|
335
|
+
expect(new Date(logs[0].startedAt).getTime()).toBeGreaterThanOrEqual(
|
|
336
|
+
new Date(logs[2].startedAt).getTime()
|
|
337
|
+
);
|
|
303
338
|
});
|
|
304
339
|
|
|
305
340
|
it("respects limit parameter", async () => {
|
|
306
341
|
scheduler.registerJobs([makeJob("limited")]);
|
|
307
|
-
|
|
308
|
-
for (let i = 0; i < 5; i++) {
|
|
309
|
-
await scheduler.triggerJob("limited");
|
|
310
|
-
}
|
|
311
|
-
|
|
342
|
+
for (let i = 0; i < 5; i++) await scheduler.triggerJob("limited");
|
|
312
343
|
expect(scheduler.getJobLogs("limited", 2)).toHaveLength(2);
|
|
313
344
|
expect(scheduler.getJobLogs("limited", 10)).toHaveLength(5);
|
|
314
345
|
expect(scheduler.getJobLogs("limited")).toHaveLength(5);
|
|
@@ -316,11 +347,7 @@ describe("CronScheduler", () => {
|
|
|
316
347
|
|
|
317
348
|
it("caps at 50 entries (ring buffer)", async () => {
|
|
318
349
|
scheduler.registerJobs([makeJob("ring")]);
|
|
319
|
-
|
|
320
|
-
for (let i = 0; i < 60; i++) {
|
|
321
|
-
await scheduler.triggerJob("ring");
|
|
322
|
-
}
|
|
323
|
-
|
|
350
|
+
for (let i = 0; i < 60; i++) await scheduler.triggerJob("ring");
|
|
324
351
|
expect(scheduler.getJobLogs("ring")).toHaveLength(50);
|
|
325
352
|
expect(scheduler.getJob("ring")?.totalRuns).toBe(60);
|
|
326
353
|
});
|
|
@@ -336,7 +363,6 @@ describe("CronScheduler", () => {
|
|
|
336
363
|
it("disables a job", () => {
|
|
337
364
|
scheduler.registerJobs([makeJob("togglable")]);
|
|
338
365
|
const result = scheduler.setJobEnabled("togglable", false);
|
|
339
|
-
|
|
340
366
|
expect(result?.enabled).toBe(false);
|
|
341
367
|
expect(result?.state).toBe("disabled");
|
|
342
368
|
});
|
|
@@ -345,11 +371,26 @@ describe("CronScheduler", () => {
|
|
|
345
371
|
scheduler.registerJobs([makeJob("togglable")]);
|
|
346
372
|
scheduler.setJobEnabled("togglable", false);
|
|
347
373
|
scheduler.start();
|
|
348
|
-
|
|
349
374
|
const result = scheduler.setJobEnabled("togglable", true);
|
|
350
375
|
expect(result?.enabled).toBe(true);
|
|
351
376
|
expect(result?.state).toBe("idle");
|
|
352
377
|
});
|
|
378
|
+
|
|
379
|
+
it("clears nextRunAt when disabling", () => {
|
|
380
|
+
scheduler.registerJobs([makeJob("dis")]);
|
|
381
|
+
scheduler.start();
|
|
382
|
+
expect(scheduler.getJob("dis")?.nextRunAt).toBeDefined();
|
|
383
|
+
scheduler.setJobEnabled("dis", false);
|
|
384
|
+
expect(scheduler.getJob("dis")?.nextRunAt).toBeUndefined();
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("sets nextRunAt when re-enabling", () => {
|
|
388
|
+
scheduler.registerJobs([makeJob("reenable")]);
|
|
389
|
+
scheduler.start();
|
|
390
|
+
scheduler.setJobEnabled("reenable", false);
|
|
391
|
+
scheduler.setJobEnabled("reenable", true);
|
|
392
|
+
expect(scheduler.getJob("reenable")?.nextRunAt).toBeDefined();
|
|
393
|
+
});
|
|
353
394
|
});
|
|
354
395
|
|
|
355
396
|
// ── start / stop ────────────────────────────────────────────────
|
|
@@ -358,17 +399,14 @@ describe("CronScheduler", () => {
|
|
|
358
399
|
it("start is idempotent", () => {
|
|
359
400
|
scheduler.registerJobs([makeJob("idem")]);
|
|
360
401
|
scheduler.start();
|
|
361
|
-
scheduler.start();
|
|
402
|
+
scheduler.start();
|
|
362
403
|
expect(scheduler.listJobs()).toHaveLength(1);
|
|
363
404
|
});
|
|
364
405
|
|
|
365
406
|
it("stop clears nextRunAt", () => {
|
|
366
407
|
scheduler.registerJobs([makeJob("stoppable")]);
|
|
367
408
|
scheduler.start();
|
|
368
|
-
|
|
369
|
-
// After start, nextRunAt should be set
|
|
370
409
|
expect(scheduler.getJob("stoppable")?.nextRunAt).toBeDefined();
|
|
371
|
-
|
|
372
410
|
scheduler.stop();
|
|
373
411
|
expect(scheduler.getJob("stoppable")?.nextRunAt).toBeUndefined();
|
|
374
412
|
});
|
|
@@ -376,44 +414,132 @@ describe("CronScheduler", () => {
|
|
|
376
414
|
it("does not schedule disabled jobs on start", () => {
|
|
377
415
|
scheduler.registerJobs([makeJob("off", { enabled: false })]);
|
|
378
416
|
scheduler.start();
|
|
379
|
-
|
|
380
417
|
expect(scheduler.getJob("off")?.nextRunAt).toBeUndefined();
|
|
381
418
|
});
|
|
419
|
+
|
|
420
|
+
it("stop then start re-schedules jobs", () => {
|
|
421
|
+
scheduler.registerJobs([makeJob("restart")]);
|
|
422
|
+
scheduler.start();
|
|
423
|
+
scheduler.stop();
|
|
424
|
+
expect(scheduler.getJob("restart")?.nextRunAt).toBeUndefined();
|
|
425
|
+
// Reset started flag by creating a new scheduler with same jobs
|
|
426
|
+
const s2 = new CronScheduler();
|
|
427
|
+
s2.registerJobs([makeJob("restart")]);
|
|
428
|
+
s2.start();
|
|
429
|
+
expect(s2.getJob("restart")?.nextRunAt).toBeDefined();
|
|
430
|
+
s2.stop();
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
// ── Schedule-driven execution ───────────────────────────────────
|
|
435
|
+
|
|
436
|
+
describe("scheduled execution", () => {
|
|
437
|
+
it("fires job when timer elapses", async () => {
|
|
438
|
+
let executed = false;
|
|
439
|
+
scheduler.registerJobs([makeJob("timer-test", {
|
|
440
|
+
handler: async () => { executed = true; }
|
|
441
|
+
})]);
|
|
442
|
+
scheduler.start();
|
|
443
|
+
// Advance past next scheduled time
|
|
444
|
+
await jest.advanceTimersByTimeAsync(61 * 60 * 1000);
|
|
445
|
+
expect(executed).toBe(true);
|
|
446
|
+
expect(scheduler.getJob("timer-test")?.totalRuns).toBeGreaterThanOrEqual(1);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it("does not fire disabled job when timer elapses", async () => {
|
|
450
|
+
let executed = false;
|
|
451
|
+
scheduler.registerJobs([makeJob("disabled-timer", {
|
|
452
|
+
handler: async () => { executed = true; }
|
|
453
|
+
})]);
|
|
454
|
+
scheduler.start();
|
|
455
|
+
scheduler.setJobEnabled("disabled-timer", false);
|
|
456
|
+
await jest.advanceTimersByTimeAsync(61 * 60 * 1000);
|
|
457
|
+
expect(executed).toBe(false);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("does not fire after stop()", async () => {
|
|
461
|
+
let executed = false;
|
|
462
|
+
scheduler.registerJobs([makeJob("stopped-timer", {
|
|
463
|
+
handler: async () => { executed = true; }
|
|
464
|
+
})]);
|
|
465
|
+
scheduler.start();
|
|
466
|
+
scheduler.stop();
|
|
467
|
+
await jest.advanceTimersByTimeAsync(61 * 60 * 1000);
|
|
468
|
+
expect(executed).toBe(false);
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
// ── CronStore integration ───────────────────────────────────────
|
|
473
|
+
|
|
474
|
+
describe("store integration", () => {
|
|
475
|
+
beforeEach(() => { jest.useRealTimers(); });
|
|
476
|
+
|
|
477
|
+
it("persists logs to store after execution", async () => {
|
|
478
|
+
const insertLog = jest.fn<(entry: any) => Promise<void>>().mockResolvedValue(undefined);
|
|
479
|
+
const mockStore = {
|
|
480
|
+
ensureTable: jest.fn<() => Promise<void>>().mockResolvedValue(undefined),
|
|
481
|
+
insertLog,
|
|
482
|
+
fetchLogs: jest.fn<() => Promise<any[]>>().mockResolvedValue([]),
|
|
483
|
+
fetchJobStats: jest.fn<() => Promise<Map<string, any>>>().mockResolvedValue(new Map()),
|
|
484
|
+
};
|
|
485
|
+
scheduler.setStore(mockStore);
|
|
486
|
+
scheduler.registerJobs([makeJob("persisted")]);
|
|
487
|
+
await scheduler.triggerJob("persisted");
|
|
488
|
+
expect(insertLog).toHaveBeenCalledTimes(1);
|
|
489
|
+
expect(insertLog.mock.calls[0]![0].jobId).toBe("persisted");
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it("does not crash if store.insertLog fails", async () => {
|
|
493
|
+
const mockStore = {
|
|
494
|
+
ensureTable: jest.fn<() => Promise<void>>().mockResolvedValue(undefined),
|
|
495
|
+
insertLog: jest.fn<() => Promise<void>>().mockRejectedValue(new Error("DB down")),
|
|
496
|
+
fetchLogs: jest.fn<() => Promise<any[]>>().mockResolvedValue([]),
|
|
497
|
+
fetchJobStats: jest.fn<() => Promise<Map<string, any>>>().mockResolvedValue(new Map()),
|
|
498
|
+
};
|
|
499
|
+
scheduler.setStore(mockStore);
|
|
500
|
+
scheduler.registerJobs([makeJob("resilient")]);
|
|
501
|
+
// Should not throw
|
|
502
|
+
const log = await scheduler.triggerJob("resilient");
|
|
503
|
+
expect(log!.success).toBe(true);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it("seeds counters from store on start", async () => {
|
|
507
|
+
const stats = new Map<string, any>();
|
|
508
|
+
stats.set("seeded", { totalRuns: 42, totalFailures: 3, lastRunAt: "2026-01-01T00:00:00Z" });
|
|
509
|
+
const mockStore = {
|
|
510
|
+
ensureTable: jest.fn<() => Promise<void>>().mockResolvedValue(undefined),
|
|
511
|
+
insertLog: jest.fn<() => Promise<void>>().mockResolvedValue(undefined),
|
|
512
|
+
fetchLogs: jest.fn<() => Promise<any[]>>().mockResolvedValue([]),
|
|
513
|
+
fetchJobStats: jest.fn<() => Promise<Map<string, any>>>().mockResolvedValue(stats),
|
|
514
|
+
};
|
|
515
|
+
scheduler.setStore(mockStore);
|
|
516
|
+
scheduler.registerJobs([makeJob("seeded")]);
|
|
517
|
+
scheduler.start();
|
|
518
|
+
// Wait for async seed
|
|
519
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
520
|
+
const job = scheduler.getJob("seeded")!;
|
|
521
|
+
expect(job.totalRuns).toBe(42);
|
|
522
|
+
expect(job.totalFailures).toBe(3);
|
|
523
|
+
});
|
|
382
524
|
});
|
|
383
525
|
|
|
384
526
|
// ── toStatus shape ──────────────────────────────────────────────
|
|
385
527
|
|
|
386
528
|
describe("status shape", () => {
|
|
387
529
|
it("returns all expected fields", () => {
|
|
388
|
-
scheduler.registerJobs([
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
})
|
|
394
|
-
]);
|
|
395
|
-
|
|
396
|
-
const job = scheduler.getJob("shape")!;
|
|
397
|
-
expect(job).toMatchObject({
|
|
398
|
-
id: "shape",
|
|
399
|
-
name: "Shape Test",
|
|
400
|
-
description: "Desc",
|
|
401
|
-
schedule: "15 3 * * *",
|
|
402
|
-
enabled: true,
|
|
403
|
-
state: "idle",
|
|
404
|
-
totalRuns: 0,
|
|
405
|
-
totalFailures: 0
|
|
530
|
+
scheduler.registerJobs([makeJob("shape", { name: "Shape Test", description: "Desc", schedule: "15 3 * * *" })]);
|
|
531
|
+
expect(scheduler.getJob("shape")).toMatchObject({
|
|
532
|
+
id: "shape", name: "Shape Test", description: "Desc",
|
|
533
|
+
schedule: "15 3 * * *", enabled: true, state: "idle",
|
|
534
|
+
totalRuns: 0, totalFailures: 0
|
|
406
535
|
});
|
|
407
536
|
});
|
|
408
537
|
|
|
409
538
|
it("lastRunAt and nextRunAt are ISO strings or undefined", () => {
|
|
410
539
|
scheduler.registerJobs([makeJob("iso-check")]);
|
|
411
|
-
|
|
412
|
-
expect(before.lastRunAt).toBeUndefined();
|
|
413
|
-
|
|
540
|
+
expect(scheduler.getJob("iso-check")!.lastRunAt).toBeUndefined();
|
|
414
541
|
scheduler.start();
|
|
415
542
|
const after = scheduler.getJob("iso-check")!;
|
|
416
|
-
// nextRunAt should be set as an ISO string after start
|
|
417
543
|
expect(after.nextRunAt).toBeDefined();
|
|
418
544
|
expect(() => new Date(after.nextRunAt!)).not.toThrow();
|
|
419
545
|
});
|