@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.
Files changed (132) hide show
  1. package/app/frontend/node_modules/esbuild/LICENSE.md +21 -0
  2. package/app/frontend/node_modules/esbuild/README.md +3 -0
  3. package/app/frontend/node_modules/esbuild/bin/esbuild +220 -0
  4. package/app/frontend/node_modules/esbuild/install.js +285 -0
  5. package/app/frontend/node_modules/esbuild/lib/main.d.ts +705 -0
  6. package/app/frontend/node_modules/esbuild/lib/main.js +2239 -0
  7. package/app/frontend/node_modules/esbuild/package.json +46 -0
  8. package/dist/index.es.js +1186 -1673
  9. package/dist/index.es.js.map +1 -1
  10. package/dist/index.umd.js +1185 -1672
  11. package/dist/index.umd.js.map +1 -1
  12. package/dist/server-core/src/api/rest/api-generator.d.ts +15 -3
  13. package/dist/server-core/src/auth/admin-routes.d.ts +5 -0
  14. package/dist/server-core/src/auth/google-oauth.d.ts +36 -3
  15. package/dist/server-core/src/auth/index.d.ts +1 -0
  16. package/dist/server-core/src/cron/cron-scheduler.d.ts +45 -0
  17. package/dist/server-core/src/cron/index.d.ts +1 -1
  18. package/dist/server-core/src/init.d.ts +11 -1
  19. package/dist/types/src/controllers/auth.d.ts +8 -2
  20. package/dist/types/src/controllers/client.d.ts +13 -0
  21. package/dist/types/src/controllers/collection_registry.d.ts +2 -1
  22. package/dist/types/src/controllers/data_driver.d.ts +36 -1
  23. package/dist/types/src/controllers/navigation.d.ts +18 -6
  24. package/dist/types/src/controllers/registry.d.ts +9 -1
  25. package/dist/types/src/controllers/side_entity_controller.d.ts +7 -0
  26. package/dist/types/src/rebase_context.d.ts +17 -0
  27. package/dist/types/src/types/backend_hooks.d.ts +187 -0
  28. package/dist/types/src/types/collections.d.ts +31 -11
  29. package/dist/types/src/types/component_ref.d.ts +47 -0
  30. package/dist/types/src/types/cron.d.ts +1 -1
  31. package/dist/types/src/types/entity_views.d.ts +6 -7
  32. package/dist/types/src/types/formex.d.ts +40 -0
  33. package/dist/types/src/types/index.d.ts +3 -0
  34. package/dist/types/src/types/plugins.d.ts +6 -3
  35. package/dist/types/src/types/properties.d.ts +72 -88
  36. package/dist/types/src/types/slots.d.ts +20 -10
  37. package/dist/types/src/types/translations.d.ts +6 -0
  38. package/examples/firebase/node_modules/esbuild/LICENSE.md +21 -0
  39. package/examples/firebase/node_modules/esbuild/README.md +3 -0
  40. package/examples/firebase/node_modules/esbuild/bin/esbuild +220 -0
  41. package/examples/firebase/node_modules/esbuild/install.js +285 -0
  42. package/examples/firebase/node_modules/esbuild/lib/main.d.ts +705 -0
  43. package/examples/firebase/node_modules/esbuild/lib/main.js +2239 -0
  44. package/examples/firebase/node_modules/esbuild/package.json +46 -0
  45. package/examples/medmot-staging/frontend/node_modules/esbuild/LICENSE.md +21 -0
  46. package/examples/medmot-staging/frontend/node_modules/esbuild/README.md +3 -0
  47. package/examples/medmot-staging/frontend/node_modules/esbuild/bin/esbuild +220 -0
  48. package/examples/medmot-staging/frontend/node_modules/esbuild/install.js +285 -0
  49. package/examples/medmot-staging/frontend/node_modules/esbuild/lib/main.d.ts +705 -0
  50. package/examples/medmot-staging/frontend/node_modules/esbuild/lib/main.js +2239 -0
  51. package/examples/medmot-staging/frontend/node_modules/esbuild/package.json +46 -0
  52. package/examples/sdk-demo/node_modules/esbuild/LICENSE.md +21 -0
  53. package/examples/sdk-demo/node_modules/esbuild/README.md +3 -0
  54. package/examples/sdk-demo/node_modules/esbuild/bin/esbuild +223 -0
  55. package/examples/sdk-demo/node_modules/esbuild/install.js +289 -0
  56. package/examples/sdk-demo/node_modules/esbuild/lib/main.d.ts +716 -0
  57. package/examples/sdk-demo/node_modules/esbuild/lib/main.js +2242 -0
  58. package/examples/sdk-demo/node_modules/esbuild/package.json +49 -0
  59. package/package.json +9 -9
  60. package/packages/client/node_modules/esbuild/LICENSE.md +21 -0
  61. package/packages/client/node_modules/esbuild/README.md +3 -0
  62. package/packages/client/node_modules/esbuild/bin/esbuild +220 -0
  63. package/packages/client/node_modules/esbuild/install.js +285 -0
  64. package/packages/client/node_modules/esbuild/lib/main.d.ts +705 -0
  65. package/packages/client/node_modules/esbuild/lib/main.js +2239 -0
  66. package/packages/client/node_modules/esbuild/package.json +46 -0
  67. package/packages/client-postgresql/node_modules/esbuild/LICENSE.md +21 -0
  68. package/packages/client-postgresql/node_modules/esbuild/README.md +3 -0
  69. package/packages/client-postgresql/node_modules/esbuild/bin/esbuild +220 -0
  70. package/packages/client-postgresql/node_modules/esbuild/install.js +285 -0
  71. package/packages/client-postgresql/node_modules/esbuild/lib/main.d.ts +705 -0
  72. package/packages/client-postgresql/node_modules/esbuild/lib/main.js +2239 -0
  73. package/packages/client-postgresql/node_modules/esbuild/package.json +46 -0
  74. package/packages/common/node_modules/esbuild/LICENSE.md +21 -0
  75. package/packages/common/node_modules/esbuild/README.md +3 -0
  76. package/packages/common/node_modules/esbuild/bin/esbuild +220 -0
  77. package/packages/common/node_modules/esbuild/install.js +285 -0
  78. package/packages/common/node_modules/esbuild/lib/main.d.ts +705 -0
  79. package/packages/common/node_modules/esbuild/lib/main.js +2239 -0
  80. package/packages/common/node_modules/esbuild/package.json +46 -0
  81. package/packages/server-mongodb/node_modules/esbuild/LICENSE.md +21 -0
  82. package/packages/server-mongodb/node_modules/esbuild/README.md +3 -0
  83. package/packages/server-mongodb/node_modules/esbuild/bin/esbuild +220 -0
  84. package/packages/server-mongodb/node_modules/esbuild/install.js +285 -0
  85. package/packages/server-mongodb/node_modules/esbuild/lib/main.d.ts +705 -0
  86. package/packages/server-mongodb/node_modules/esbuild/lib/main.js +2239 -0
  87. package/packages/server-mongodb/node_modules/esbuild/package.json +46 -0
  88. package/packages/server-postgresql/node_modules/esbuild/LICENSE.md +21 -0
  89. package/packages/server-postgresql/node_modules/esbuild/README.md +3 -0
  90. package/packages/server-postgresql/node_modules/esbuild/bin/esbuild +220 -0
  91. package/packages/server-postgresql/node_modules/esbuild/install.js +285 -0
  92. package/packages/server-postgresql/node_modules/esbuild/lib/main.d.ts +705 -0
  93. package/packages/server-postgresql/node_modules/esbuild/lib/main.js +2239 -0
  94. package/packages/server-postgresql/node_modules/esbuild/package.json +46 -0
  95. package/packages/types/node_modules/esbuild/LICENSE.md +21 -0
  96. package/packages/types/node_modules/esbuild/README.md +3 -0
  97. package/packages/types/node_modules/esbuild/bin/esbuild +220 -0
  98. package/packages/types/node_modules/esbuild/install.js +285 -0
  99. package/packages/types/node_modules/esbuild/lib/main.d.ts +705 -0
  100. package/packages/types/node_modules/esbuild/lib/main.js +2239 -0
  101. package/packages/types/node_modules/esbuild/package.json +46 -0
  102. package/packages/utils/node_modules/esbuild/LICENSE.md +21 -0
  103. package/packages/utils/node_modules/esbuild/README.md +3 -0
  104. package/packages/utils/node_modules/esbuild/bin/esbuild +220 -0
  105. package/packages/utils/node_modules/esbuild/install.js +285 -0
  106. package/packages/utils/node_modules/esbuild/lib/main.d.ts +705 -0
  107. package/packages/utils/node_modules/esbuild/lib/main.js +2239 -0
  108. package/packages/utils/node_modules/esbuild/package.json +46 -0
  109. package/src/api/errors.ts +3 -2
  110. package/src/api/rest/api-generator-count.test.ts +113 -0
  111. package/src/api/rest/api-generator.ts +123 -22
  112. package/src/api/server.ts +8 -4
  113. package/src/auth/admin-routes.ts +133 -57
  114. package/src/auth/apple-oauth.ts +8 -18
  115. package/src/auth/google-oauth.ts +192 -22
  116. package/src/auth/index.ts +1 -0
  117. package/src/auth/rate-limiter.ts +9 -5
  118. package/src/auth/routes.ts +25 -5
  119. package/src/collections/loader.ts +3 -3
  120. package/src/cron/cron-scheduler.test.ts +301 -175
  121. package/src/cron/cron-scheduler.ts +220 -57
  122. package/src/cron/index.ts +1 -1
  123. package/src/init.ts +27 -5
  124. package/src/storage/LocalStorageController.ts +37 -13
  125. package/src/storage/S3StorageController.ts +4 -1
  126. package/src/storage/routes.ts +51 -5
  127. package/test/backend-hooks-admin.test.ts +394 -0
  128. package/test/backend-hooks-data.test.ts +408 -0
  129. package/history_diff.log +0 -385
  130. package/scratch.ts +0 -9
  131. package/test-ast.ts +0 -28
  132. 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 * * * *", // every hour
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
- const jobs = scheduler.listJobs();
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
- const job = scheduler.getJob("enabled-job");
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
- const job = scheduler.getJob("disabled-job");
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?.totalRuns).toBe(0);
89
- expect(job?.totalFailures).toBe(0);
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
- const jobs = scheduler.listJobs();
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
- makeJob("meta", {
104
- name: "My Job",
105
- description: "Does stuff",
106
- schedule: "30 2 * * 1"
107
- })
108
- ]);
109
-
110
- const job = scheduler.getJob("meta");
111
- expect(job?.name).toBe("My Job");
112
- expect(job?.description).toBe("Does stuff");
113
- expect(job?.schedule).toBe("30 2 * * 1");
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
- const job = scheduler.getJob("b");
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
- const result = await scheduler.triggerJob("ghost");
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
- const status = scheduler.getJob("fail-me");
175
- expect(status?.totalFailures).toBe(1);
176
- expect(status?.state).toBe("error");
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
- makeJob("logger", {
183
- handler: async (ctx) => {
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 job = scheduler.getJob("timed");
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
- makeJob("sync-handler", {
239
- handler: (ctx) => {
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
- makeJob("slow", {
259
- timeoutSeconds: 1,
260
- handler: () =>
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
- // newest first means the last entry's startedAt >= first entry's startedAt
300
- const t0 = new Date(logs[0].startedAt).getTime();
301
- const t2 = new Date(logs[2].startedAt).getTime();
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(); // should not throw
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
- makeJob("shape", {
390
- name: "Shape Test",
391
- description: "Desc",
392
- schedule: "15 3 * * *"
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
- const before = scheduler.getJob("iso-check")!;
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
  });