@rebasepro/server-core 0.0.1-canary.000dc36

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 (305) hide show
  1. package/LICENSE +6 -0
  2. package/README.md +40 -0
  3. package/build-errors.txt +52 -0
  4. package/coverage/clover.xml +3739 -0
  5. package/coverage/coverage-final.json +31 -0
  6. package/coverage/lcov-report/base.css +224 -0
  7. package/coverage/lcov-report/block-navigation.js +87 -0
  8. package/coverage/lcov-report/favicon.png +0 -0
  9. package/coverage/lcov-report/index.html +266 -0
  10. package/coverage/lcov-report/prettify.css +1 -0
  11. package/coverage/lcov-report/prettify.js +2 -0
  12. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  13. package/coverage/lcov-report/sorter.js +210 -0
  14. package/coverage/lcov-report/src/api/ast-schema-editor.ts.html +952 -0
  15. package/coverage/lcov-report/src/api/errors.ts.html +472 -0
  16. package/coverage/lcov-report/src/api/graphql/graphql-schema-generator.ts.html +1069 -0
  17. package/coverage/lcov-report/src/api/graphql/index.html +116 -0
  18. package/coverage/lcov-report/src/api/index.html +176 -0
  19. package/coverage/lcov-report/src/api/openapi-generator.ts.html +565 -0
  20. package/coverage/lcov-report/src/api/rest/api-generator.ts.html +994 -0
  21. package/coverage/lcov-report/src/api/rest/index.html +131 -0
  22. package/coverage/lcov-report/src/api/rest/query-parser.ts.html +550 -0
  23. package/coverage/lcov-report/src/api/schema-editor-routes.ts.html +202 -0
  24. package/coverage/lcov-report/src/api/server.ts.html +823 -0
  25. package/coverage/lcov-report/src/auth/admin-routes.ts.html +973 -0
  26. package/coverage/lcov-report/src/auth/index.html +176 -0
  27. package/coverage/lcov-report/src/auth/jwt.ts.html +574 -0
  28. package/coverage/lcov-report/src/auth/middleware.ts.html +745 -0
  29. package/coverage/lcov-report/src/auth/password.ts.html +310 -0
  30. package/coverage/lcov-report/src/auth/services.ts.html +2074 -0
  31. package/coverage/lcov-report/src/collections/index.html +116 -0
  32. package/coverage/lcov-report/src/collections/loader.ts.html +232 -0
  33. package/coverage/lcov-report/src/db/auth-schema.ts.html +523 -0
  34. package/coverage/lcov-report/src/db/data-transformer.ts.html +1753 -0
  35. package/coverage/lcov-report/src/db/entityService.ts.html +700 -0
  36. package/coverage/lcov-report/src/db/index.html +146 -0
  37. package/coverage/lcov-report/src/db/services/EntityFetchService.ts.html +4048 -0
  38. package/coverage/lcov-report/src/db/services/EntityPersistService.ts.html +883 -0
  39. package/coverage/lcov-report/src/db/services/RelationService.ts.html +3121 -0
  40. package/coverage/lcov-report/src/db/services/entity-helpers.ts.html +442 -0
  41. package/coverage/lcov-report/src/db/services/index.html +176 -0
  42. package/coverage/lcov-report/src/db/services/index.ts.html +124 -0
  43. package/coverage/lcov-report/src/generate-drizzle-schema-logic.ts.html +1960 -0
  44. package/coverage/lcov-report/src/index.html +116 -0
  45. package/coverage/lcov-report/src/services/driver-registry.ts.html +631 -0
  46. package/coverage/lcov-report/src/services/index.html +131 -0
  47. package/coverage/lcov-report/src/services/postgresDataDriver.ts.html +3025 -0
  48. package/coverage/lcov-report/src/storage/LocalStorageController.ts.html +1189 -0
  49. package/coverage/lcov-report/src/storage/S3StorageController.ts.html +970 -0
  50. package/coverage/lcov-report/src/storage/index.html +161 -0
  51. package/coverage/lcov-report/src/storage/storage-registry.ts.html +646 -0
  52. package/coverage/lcov-report/src/storage/types.ts.html +451 -0
  53. package/coverage/lcov-report/src/utils/drizzle-conditions.ts.html +3082 -0
  54. package/coverage/lcov-report/src/utils/index.html +116 -0
  55. package/coverage/lcov.info +7179 -0
  56. package/dist/common/src/collections/CollectionRegistry.d.ts +56 -0
  57. package/dist/common/src/collections/index.d.ts +1 -0
  58. package/dist/common/src/data/buildRebaseData.d.ts +14 -0
  59. package/dist/common/src/index.d.ts +3 -0
  60. package/dist/common/src/util/builders.d.ts +57 -0
  61. package/dist/common/src/util/callbacks.d.ts +6 -0
  62. package/dist/common/src/util/collections.d.ts +11 -0
  63. package/dist/common/src/util/common.d.ts +2 -0
  64. package/dist/common/src/util/conditions.d.ts +26 -0
  65. package/dist/common/src/util/entities.d.ts +58 -0
  66. package/dist/common/src/util/enums.d.ts +3 -0
  67. package/dist/common/src/util/index.d.ts +16 -0
  68. package/dist/common/src/util/navigation_from_path.d.ts +34 -0
  69. package/dist/common/src/util/navigation_utils.d.ts +20 -0
  70. package/dist/common/src/util/parent_references_from_path.d.ts +6 -0
  71. package/dist/common/src/util/paths.d.ts +14 -0
  72. package/dist/common/src/util/permissions.d.ts +5 -0
  73. package/dist/common/src/util/references.d.ts +2 -0
  74. package/dist/common/src/util/relations.d.ts +22 -0
  75. package/dist/common/src/util/resolutions.d.ts +72 -0
  76. package/dist/common/src/util/storage.d.ts +24 -0
  77. package/dist/index-DXVBFp5V.js +37 -0
  78. package/dist/index-DXVBFp5V.js.map +1 -0
  79. package/dist/index.es.js +49249 -0
  80. package/dist/index.es.js.map +1 -0
  81. package/dist/index.umd.js +49283 -0
  82. package/dist/index.umd.js.map +1 -0
  83. package/dist/server-core/src/api/ast-schema-editor.d.ts +21 -0
  84. package/dist/server-core/src/api/collections_for_test/callbacks_test_collection.d.ts +2 -0
  85. package/dist/server-core/src/api/errors.d.ts +35 -0
  86. package/dist/server-core/src/api/graphql/graphql-schema-generator.d.ts +35 -0
  87. package/dist/server-core/src/api/graphql/index.d.ts +1 -0
  88. package/dist/server-core/src/api/index.d.ts +9 -0
  89. package/dist/server-core/src/api/openapi-generator.d.ts +16 -0
  90. package/dist/server-core/src/api/rest/api-generator.d.ts +76 -0
  91. package/dist/server-core/src/api/rest/index.d.ts +1 -0
  92. package/dist/server-core/src/api/rest/query-parser.d.ts +9 -0
  93. package/dist/server-core/src/api/schema-editor-routes.d.ts +3 -0
  94. package/dist/server-core/src/api/server.d.ts +40 -0
  95. package/dist/server-core/src/api/types.d.ts +90 -0
  96. package/dist/server-core/src/auth/admin-routes.d.ts +21 -0
  97. package/dist/server-core/src/auth/apple-oauth.d.ts +30 -0
  98. package/dist/server-core/src/auth/bitbucket-oauth.d.ts +11 -0
  99. package/dist/server-core/src/auth/discord-oauth.d.ts +14 -0
  100. package/dist/server-core/src/auth/facebook-oauth.d.ts +14 -0
  101. package/dist/server-core/src/auth/github-oauth.d.ts +15 -0
  102. package/dist/server-core/src/auth/gitlab-oauth.d.ts +13 -0
  103. package/dist/server-core/src/auth/google-oauth.d.ts +14 -0
  104. package/dist/server-core/src/auth/index.d.ts +23 -0
  105. package/dist/server-core/src/auth/interfaces.d.ts +309 -0
  106. package/dist/server-core/src/auth/jwt.d.ts +43 -0
  107. package/dist/server-core/src/auth/linkedin-oauth.d.ts +18 -0
  108. package/dist/server-core/src/auth/microsoft-oauth.d.ts +16 -0
  109. package/dist/server-core/src/auth/middleware.d.ts +81 -0
  110. package/dist/server-core/src/auth/password.d.ts +22 -0
  111. package/dist/server-core/src/auth/rate-limiter.d.ts +31 -0
  112. package/dist/server-core/src/auth/routes.d.ts +27 -0
  113. package/dist/server-core/src/auth/slack-oauth.d.ts +12 -0
  114. package/dist/server-core/src/auth/spotify-oauth.d.ts +12 -0
  115. package/dist/server-core/src/auth/twitter-oauth.d.ts +18 -0
  116. package/dist/server-core/src/bootstrappers/index.d.ts +0 -0
  117. package/dist/server-core/src/collections/BackendCollectionRegistry.d.ts +13 -0
  118. package/dist/server-core/src/collections/loader.d.ts +5 -0
  119. package/dist/server-core/src/cron/cron-loader.d.ts +17 -0
  120. package/dist/server-core/src/cron/cron-routes.d.ts +14 -0
  121. package/dist/server-core/src/cron/cron-scheduler.d.ts +106 -0
  122. package/dist/server-core/src/cron/cron-store.d.ts +32 -0
  123. package/dist/server-core/src/cron/index.d.ts +6 -0
  124. package/dist/server-core/src/db/interfaces.d.ts +18 -0
  125. package/dist/server-core/src/email/index.d.ts +6 -0
  126. package/dist/server-core/src/email/smtp-email-service.d.ts +25 -0
  127. package/dist/server-core/src/email/templates.d.ts +42 -0
  128. package/dist/server-core/src/email/types.d.ts +107 -0
  129. package/dist/server-core/src/functions/function-loader.d.ts +17 -0
  130. package/dist/server-core/src/functions/function-routes.d.ts +10 -0
  131. package/dist/server-core/src/functions/index.d.ts +3 -0
  132. package/dist/server-core/src/history/history-routes.d.ts +23 -0
  133. package/dist/server-core/src/history/index.d.ts +1 -0
  134. package/dist/server-core/src/index.d.ts +29 -0
  135. package/dist/server-core/src/init.d.ts +168 -0
  136. package/dist/server-core/src/serve-spa.d.ts +30 -0
  137. package/dist/server-core/src/services/driver-registry.d.ts +78 -0
  138. package/dist/server-core/src/singleton.d.ts +35 -0
  139. package/dist/server-core/src/storage/LocalStorageController.d.ts +46 -0
  140. package/dist/server-core/src/storage/S3StorageController.d.ts +36 -0
  141. package/dist/server-core/src/storage/index.d.ts +25 -0
  142. package/dist/server-core/src/storage/routes.d.ts +38 -0
  143. package/dist/server-core/src/storage/storage-registry.d.ts +78 -0
  144. package/dist/server-core/src/storage/types.d.ts +103 -0
  145. package/dist/server-core/src/types/index.d.ts +11 -0
  146. package/dist/server-core/src/utils/dev-port.d.ts +35 -0
  147. package/dist/server-core/src/utils/logger.d.ts +31 -0
  148. package/dist/server-core/src/utils/logging.d.ts +9 -0
  149. package/dist/server-core/src/utils/request-logger.d.ts +19 -0
  150. package/dist/server-core/src/utils/sql.d.ts +27 -0
  151. package/dist/types/src/controllers/analytics_controller.d.ts +7 -0
  152. package/dist/types/src/controllers/auth.d.ts +119 -0
  153. package/dist/types/src/controllers/client.d.ts +170 -0
  154. package/dist/types/src/controllers/collection_registry.d.ts +46 -0
  155. package/dist/types/src/controllers/customization_controller.d.ts +60 -0
  156. package/dist/types/src/controllers/data.d.ts +168 -0
  157. package/dist/types/src/controllers/data_driver.d.ts +195 -0
  158. package/dist/types/src/controllers/database_admin.d.ts +11 -0
  159. package/dist/types/src/controllers/dialogs_controller.d.ts +36 -0
  160. package/dist/types/src/controllers/effective_role.d.ts +4 -0
  161. package/dist/types/src/controllers/email.d.ts +34 -0
  162. package/dist/types/src/controllers/index.d.ts +18 -0
  163. package/dist/types/src/controllers/local_config_persistence.d.ts +20 -0
  164. package/dist/types/src/controllers/navigation.d.ts +213 -0
  165. package/dist/types/src/controllers/registry.d.ts +54 -0
  166. package/dist/types/src/controllers/side_dialogs_controller.d.ts +67 -0
  167. package/dist/types/src/controllers/side_entity_controller.d.ts +90 -0
  168. package/dist/types/src/controllers/snackbar.d.ts +24 -0
  169. package/dist/types/src/controllers/storage.d.ts +171 -0
  170. package/dist/types/src/index.d.ts +4 -0
  171. package/dist/types/src/rebase_context.d.ts +105 -0
  172. package/dist/types/src/types/backend.d.ts +536 -0
  173. package/dist/types/src/types/backend_hooks.d.ts +187 -0
  174. package/dist/types/src/types/builders.d.ts +15 -0
  175. package/dist/types/src/types/chips.d.ts +5 -0
  176. package/dist/types/src/types/collections.d.ts +857 -0
  177. package/dist/types/src/types/cron.d.ts +102 -0
  178. package/dist/types/src/types/data_source.d.ts +64 -0
  179. package/dist/types/src/types/entities.d.ts +145 -0
  180. package/dist/types/src/types/entity_actions.d.ts +98 -0
  181. package/dist/types/src/types/entity_callbacks.d.ts +173 -0
  182. package/dist/types/src/types/entity_link_builder.d.ts +7 -0
  183. package/dist/types/src/types/entity_overrides.d.ts +10 -0
  184. package/dist/types/src/types/entity_views.d.ts +59 -0
  185. package/dist/types/src/types/export_import.d.ts +21 -0
  186. package/dist/types/src/types/formex.d.ts +40 -0
  187. package/dist/types/src/types/index.d.ts +25 -0
  188. package/dist/types/src/types/locales.d.ts +4 -0
  189. package/dist/types/src/types/modify_collections.d.ts +5 -0
  190. package/dist/types/src/types/plugins.d.ts +282 -0
  191. package/dist/types/src/types/properties.d.ts +1148 -0
  192. package/dist/types/src/types/property_config.d.ts +70 -0
  193. package/dist/types/src/types/relations.d.ts +336 -0
  194. package/dist/types/src/types/slots.d.ts +262 -0
  195. package/dist/types/src/types/translations.d.ts +874 -0
  196. package/dist/types/src/types/user_management_delegate.d.ts +121 -0
  197. package/dist/types/src/types/websockets.d.ts +78 -0
  198. package/dist/types/src/users/index.d.ts +2 -0
  199. package/dist/types/src/users/roles.d.ts +22 -0
  200. package/dist/types/src/users/user.d.ts +46 -0
  201. package/history_diff.log +385 -0
  202. package/jest.config.cjs +16 -0
  203. package/package.json +86 -0
  204. package/scratch.ts +9 -0
  205. package/src/api/ast-schema-editor.ts +289 -0
  206. package/src/api/collections_for_test/callbacks_test_collection.ts +60 -0
  207. package/src/api/errors.ts +179 -0
  208. package/src/api/graphql/graphql-schema-generator.ts +336 -0
  209. package/src/api/graphql/index.ts +2 -0
  210. package/src/api/index.ts +11 -0
  211. package/src/api/openapi-generator.ts +715 -0
  212. package/src/api/rest/api-generator-count.test.ts +113 -0
  213. package/src/api/rest/api-generator.ts +573 -0
  214. package/src/api/rest/index.ts +2 -0
  215. package/src/api/rest/query-parser.ts +155 -0
  216. package/src/api/schema-editor-routes.ts +41 -0
  217. package/src/api/server.ts +249 -0
  218. package/src/api/types.ts +90 -0
  219. package/src/auth/admin-routes.ts +605 -0
  220. package/src/auth/apple-oauth.ts +120 -0
  221. package/src/auth/bitbucket-oauth.ts +82 -0
  222. package/src/auth/discord-oauth.ts +83 -0
  223. package/src/auth/facebook-oauth.ts +72 -0
  224. package/src/auth/github-oauth.ts +110 -0
  225. package/src/auth/gitlab-oauth.ts +70 -0
  226. package/src/auth/google-oauth.ts +48 -0
  227. package/src/auth/index.ts +34 -0
  228. package/src/auth/interfaces.ts +363 -0
  229. package/src/auth/jwt.ts +181 -0
  230. package/src/auth/linkedin-oauth.ts +81 -0
  231. package/src/auth/microsoft-oauth.ts +88 -0
  232. package/src/auth/middleware.ts +384 -0
  233. package/src/auth/password.ts +77 -0
  234. package/src/auth/rate-limiter.ts +133 -0
  235. package/src/auth/routes.ts +788 -0
  236. package/src/auth/slack-oauth.ts +71 -0
  237. package/src/auth/spotify-oauth.ts +67 -0
  238. package/src/auth/twitter-oauth.ts +120 -0
  239. package/src/bootstrappers/index.ts +1 -0
  240. package/src/collections/BackendCollectionRegistry.ts +20 -0
  241. package/src/collections/loader.ts +49 -0
  242. package/src/cron/cron-loader.ts +89 -0
  243. package/src/cron/cron-routes.test.ts +265 -0
  244. package/src/cron/cron-routes.ts +85 -0
  245. package/src/cron/cron-scheduler.test.ts +547 -0
  246. package/src/cron/cron-scheduler.ts +576 -0
  247. package/src/cron/cron-store.ts +163 -0
  248. package/src/cron/index.ts +6 -0
  249. package/src/db/interfaces.ts +60 -0
  250. package/src/email/index.ts +18 -0
  251. package/src/email/smtp-email-service.ts +91 -0
  252. package/src/email/templates.ts +388 -0
  253. package/src/email/types.ts +105 -0
  254. package/src/functions/function-loader.ts +119 -0
  255. package/src/functions/function-routes.ts +31 -0
  256. package/src/functions/index.ts +3 -0
  257. package/src/history/history-routes.ts +129 -0
  258. package/src/history/index.ts +2 -0
  259. package/src/index.ts +66 -0
  260. package/src/init.ts +737 -0
  261. package/src/serve-spa.ts +81 -0
  262. package/src/services/driver-registry.ts +182 -0
  263. package/src/singleton.test.ts +28 -0
  264. package/src/singleton.ts +70 -0
  265. package/src/storage/LocalStorageController.ts +365 -0
  266. package/src/storage/S3StorageController.ts +298 -0
  267. package/src/storage/index.ts +43 -0
  268. package/src/storage/routes.ts +264 -0
  269. package/src/storage/storage-registry.ts +187 -0
  270. package/src/storage/types.ts +134 -0
  271. package/src/types/index.ts +27 -0
  272. package/src/utils/dev-port.ts +176 -0
  273. package/src/utils/logger.ts +143 -0
  274. package/src/utils/logging.ts +38 -0
  275. package/src/utils/request-logger.ts +66 -0
  276. package/src/utils/sql.ts +38 -0
  277. package/test/admin-routes.test.ts +640 -0
  278. package/test/api-generator.test.ts +501 -0
  279. package/test/ast-schema-editor.test.ts +63 -0
  280. package/test/auth-middleware-hono.test.ts +556 -0
  281. package/test/auth-routes.test.ts +1047 -0
  282. package/test/backend-hooks-admin.test.ts +394 -0
  283. package/test/backend-hooks-data.test.ts +408 -0
  284. package/test/driver-registry.test.ts +282 -0
  285. package/test/error-propagation.test.ts +226 -0
  286. package/test/errors-hono.test.ts +133 -0
  287. package/test/errors.test.ts +155 -0
  288. package/test/jwt-security.test.ts +182 -0
  289. package/test/jwt.test.ts +324 -0
  290. package/test/middleware.test.ts +300 -0
  291. package/test/password.test.ts +165 -0
  292. package/test/query-parser.test.ts +263 -0
  293. package/test/rate-limiter.test.ts +102 -0
  294. package/test/safe-compare.test.ts +66 -0
  295. package/test/singleton.test.ts +59 -0
  296. package/test/storage-local.test.ts +271 -0
  297. package/test/storage-registry.test.ts +282 -0
  298. package/test/storage-routes.test.ts +222 -0
  299. package/test/storage-s3.test.ts +304 -0
  300. package/test-ast.ts +28 -0
  301. package/test.ts +6 -0
  302. package/test_output.txt +1133 -0
  303. package/tsconfig.json +49 -0
  304. package/tsconfig.prod.json +20 -0
  305. package/vite.config.ts +80 -0
@@ -0,0 +1,576 @@
1
+ import type {
2
+ CronJobDefinition,
3
+ CronJobStatus,
4
+ CronJobLogEntry,
5
+ CronJobRunState,
6
+ CronJobContext
7
+ } from "@rebasepro/types";
8
+ import type { RebaseClient } from "@rebasepro/client";
9
+ import type { LoadedCronJob } from "./cron-loader";
10
+ import type { CronStore } from "./cron-store";
11
+
12
+ // ─── Cron expression parser (minimal, no external dependency) ────────
13
+ // Supports standard 5-field cron (minute hour dom month dow).
14
+ // Returns the next Date after `after` that matches the expression.
15
+
16
+ /**
17
+ * Expand a single cron field into an ordered array of allowed values.
18
+ * Supports: `*`, `N`, `N-M`, `N/S`, `N-M/S`, `*​/S`, and comma-separated combinations.
19
+ */
20
+ function expandCronField(field: string, min: number, max: number): number[] {
21
+ const results = new Set<number>();
22
+ for (const segment of field.split(",")) {
23
+ const trimmed = segment.trim();
24
+ if (trimmed === "*") {
25
+ for (let i = min; i <= max; i++) results.add(i);
26
+ } else if (trimmed.includes("/")) {
27
+ const [rangeStr, stepStr] = trimmed.split("/");
28
+ const step = parseInt(stepStr, 10);
29
+ if (isNaN(step) || step <= 0) {
30
+ throw new Error(`Invalid step value "${stepStr}" in cron field "${field}"`);
31
+ }
32
+ let start = min;
33
+ let end = max;
34
+ if (rangeStr !== "*") {
35
+ if (rangeStr.includes("-")) {
36
+ const [a, b] = rangeStr.split("-").map(Number);
37
+ start = a;
38
+ end = b;
39
+ } else {
40
+ start = parseInt(rangeStr, 10);
41
+ }
42
+ }
43
+ for (let i = start; i <= end; i += step) results.add(i);
44
+ } else if (trimmed.includes("-")) {
45
+ const [a, b] = trimmed.split("-").map(Number);
46
+ for (let i = a; i <= b; i++) results.add(i);
47
+ } else {
48
+ const val = parseInt(trimmed, 10);
49
+ if (isNaN(val)) {
50
+ throw new Error(`Invalid value "${trimmed}" in cron field "${field}"`);
51
+ }
52
+ results.add(val);
53
+ }
54
+ }
55
+ return [...results].sort((a, b) => a - b);
56
+ }
57
+
58
+ /**
59
+ * Validates a standard 5-field cron expression structurally and semantically.
60
+ * Returns `{ valid: true }` or `{ valid: false, reason: string }`.
61
+ */
62
+ export function validateCronExpression(schedule: string): { valid: true } | { valid: false; reason: string } {
63
+ if (!schedule || typeof schedule !== "string") {
64
+ return { valid: false, reason: "Schedule must be a non-empty string" };
65
+ }
66
+ const parts = schedule.trim().split(/\s+/);
67
+ if (parts.length !== 5) {
68
+ return { valid: false, reason: `Expected 5 fields, got ${parts.length}` };
69
+ }
70
+ const fieldRanges: [string, number, number][] = [
71
+ ["minute", 0, 59],
72
+ ["hour", 0, 23],
73
+ ["day of month", 1, 31],
74
+ ["month", 1, 12],
75
+ ["day of week", 0, 6],
76
+ ];
77
+ for (let i = 0; i < 5; i++) {
78
+ const [name, min, max] = fieldRanges[i];
79
+ try {
80
+ const values = expandCronField(parts[i], min, max);
81
+ if (values.length === 0) {
82
+ return { valid: false, reason: `${name} field "${parts[i]}" produces no values` };
83
+ }
84
+ for (const v of values) {
85
+ if (v < min || v > max) {
86
+ return { valid: false, reason: `${name} field value ${v} out of range [${min}–${max}]` };
87
+ }
88
+ }
89
+ } catch (err) {
90
+ return { valid: false, reason: `${name} field: ${err instanceof Error ? err.message : String(err)}` };
91
+ }
92
+ }
93
+ return { valid: true };
94
+ }
95
+
96
+ /**
97
+ * Calculate the next Date after `after` that matches the cron expression.
98
+ * Throws on invalid expressions.
99
+ */
100
+ function parseCronExpression(expression: string, after: Date): Date {
101
+ const parts = expression.trim().split(/\s+/);
102
+ if (parts.length < 5) {
103
+ throw new Error(`Invalid cron expression: "${expression}". Expected 5 fields.`);
104
+ }
105
+
106
+ const [minField, hourField, domField, monField, dowField] = parts;
107
+
108
+ const minutes = expandCronField(minField, 0, 59);
109
+ const hours = expandCronField(hourField, 0, 23);
110
+ const doms = expandCronField(domField, 1, 31);
111
+ const months = expandCronField(monField, 1, 12);
112
+ const dows = expandCronField(dowField, 0, 6); // 0=Sunday
113
+
114
+ // Forward-search from `after + 1 minute`
115
+ const candidate = new Date(after);
116
+ candidate.setSeconds(0, 0);
117
+ candidate.setMinutes(candidate.getMinutes() + 1);
118
+
119
+ const maxIterations = 525960; // ~1 year in minutes
120
+ for (let i = 0; i < maxIterations; i++) {
121
+ const month = candidate.getMonth() + 1; // 1-12
122
+ const dom = candidate.getDate();
123
+ const dow = candidate.getDay(); // 0=Sunday
124
+ const hour = candidate.getHours();
125
+ const minute = candidate.getMinutes();
126
+
127
+ if (
128
+ months.includes(month) &&
129
+ doms.includes(dom) &&
130
+ dows.includes(dow) &&
131
+ hours.includes(hour) &&
132
+ minutes.includes(minute)
133
+ ) {
134
+ return candidate;
135
+ }
136
+ candidate.setMinutes(candidate.getMinutes() + 1);
137
+ }
138
+
139
+ // Fallback — should not happen with valid expressions
140
+ const fallback = new Date(after);
141
+ fallback.setMinutes(fallback.getMinutes() + 1);
142
+ return fallback;
143
+ }
144
+
145
+ // ─── In-memory ring buffer for logs ──────────────────────────────────
146
+
147
+ const MAX_LOGS_PER_JOB = 50;
148
+
149
+ /**
150
+ * Minimum milliseconds between scheduled executions of the same job.
151
+ * Prevents tight re-execution loops caused by jitter or clock drift.
152
+ */
153
+ const MIN_SCHEDULE_INTERVAL_MS = 5_000; // 5 seconds
154
+
155
+ // ─── CronScheduler ───────────────────────────────────────────────────
156
+
157
+ interface RegisteredJob {
158
+ id: string;
159
+ definition: CronJobDefinition;
160
+ enabled: boolean;
161
+ state: CronJobRunState;
162
+ lastRunAt?: Date;
163
+ nextRunAt?: Date;
164
+ lastDurationMs?: number;
165
+ lastError?: string;
166
+ totalRuns: number;
167
+ totalFailures: number;
168
+ timerId?: ReturnType<typeof setTimeout>;
169
+ logs: CronJobLogEntry[];
170
+ /** True while a handler is actively executing (prevents concurrent runs). */
171
+ executing: boolean;
172
+ }
173
+
174
+ export class CronScheduler {
175
+ private jobs = new Map<string, RegisteredJob>();
176
+ private started = false;
177
+ private store?: CronStore;
178
+ private client?: RebaseClient;
179
+
180
+ /**
181
+ * Set the RebaseClient instance to make it available to cron job handlers.
182
+ */
183
+ setClient(client: RebaseClient): void {
184
+ this.client = client;
185
+ }
186
+
187
+ /**
188
+ * Attach a persistence store for cron logs.
189
+ * When set, execution logs are written to the database after each run,
190
+ * and counters are seeded from the database on start.
191
+ */
192
+ setStore(store: CronStore): void {
193
+ this.store = store;
194
+ }
195
+
196
+ /**
197
+ * Register a batch of loaded cron jobs.
198
+ *
199
+ * If the scheduler is already started, newly registered jobs are
200
+ * automatically scheduled (so late-registered jobs don't sit idle).
201
+ *
202
+ * Validates the cron schedule on registration — invalid schedules
203
+ * are rejected with a warning and the job is NOT registered.
204
+ */
205
+ registerJobs(loadedJobs: LoadedCronJob[]): void {
206
+ for (const loaded of loadedJobs) {
207
+ // Validate schedule up-front — reject invalid schedules
208
+ const validation = validateCronExpression(loaded.definition.schedule);
209
+ if (!validation.valid) {
210
+ console.error(
211
+ `[cron] Rejecting job "${loaded.id}": invalid schedule "${loaded.definition.schedule}" — ${validation.reason}`
212
+ );
213
+ continue;
214
+ }
215
+
216
+ const existing = this.jobs.get(loaded.id);
217
+ if (existing) {
218
+ console.warn(`[cron] Duplicate cron job id: "${loaded.id}". Overwriting.`);
219
+ this.stopJob(loaded.id);
220
+ }
221
+
222
+ const enabled = loaded.definition.enabled !== false;
223
+
224
+ this.jobs.set(loaded.id, {
225
+ id: loaded.id,
226
+ definition: loaded.definition,
227
+ enabled,
228
+ state: enabled ? "idle" : "disabled",
229
+ totalRuns: 0,
230
+ totalFailures: 0,
231
+ logs: [],
232
+ executing: false
233
+ });
234
+
235
+ // If the scheduler is already running, auto-schedule new jobs
236
+ if (this.started && enabled) {
237
+ this.scheduleNext(loaded.id);
238
+ }
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Start the scheduler — begins ticking all enabled jobs.
244
+ */
245
+ start(): void {
246
+ if (this.started) return;
247
+ this.started = true;
248
+
249
+ // Seed counters from DB (non-blocking — scheduler starts immediately)
250
+ if (this.store) {
251
+ this.store.fetchJobStats().then((stats) => {
252
+ for (const [jobId, data] of stats) {
253
+ const job = this.jobs.get(jobId);
254
+ if (job) {
255
+ job.totalRuns = data.totalRuns;
256
+ job.totalFailures = data.totalFailures;
257
+ if (data.lastRunAt) {
258
+ job.lastRunAt = new Date(data.lastRunAt);
259
+ }
260
+ }
261
+ }
262
+ }).catch((err) => {
263
+ console.warn("[cron] Failed to seed job stats from database:", err);
264
+ });
265
+ }
266
+
267
+ for (const [id, job] of this.jobs) {
268
+ if (job.enabled) {
269
+ this.scheduleNext(id);
270
+ }
271
+ }
272
+ console.log(`⏰ Cron scheduler started with ${this.jobs.size} job(s)`);
273
+ }
274
+
275
+ /**
276
+ * Stop the scheduler and clear all timers.
277
+ *
278
+ * Currently-executing handlers run to completion (they are async),
279
+ * but no further scheduling occurs after stop.
280
+ */
281
+ stop(): void {
282
+ this.started = false;
283
+ for (const [id] of this.jobs) {
284
+ this.stopJob(id);
285
+ }
286
+ }
287
+
288
+ /**
289
+ * List all registered jobs with their current status.
290
+ */
291
+ listJobs(): CronJobStatus[] {
292
+ return [...this.jobs.values()].map((job) => this.toStatus(job));
293
+ }
294
+
295
+ /**
296
+ * Get a single job status by ID.
297
+ */
298
+ getJob(id: string): CronJobStatus | undefined {
299
+ const job = this.jobs.get(id);
300
+ return job ? this.toStatus(job) : undefined;
301
+ }
302
+
303
+ /**
304
+ * Get log entries for a job.
305
+ */
306
+ getJobLogs(id: string, limit?: number): CronJobLogEntry[] {
307
+ const job = this.jobs.get(id);
308
+ if (!job) return [];
309
+ const logs = [...job.logs].reverse(); // newest first
310
+ return limit ? logs.slice(0, limit) : logs;
311
+ }
312
+
313
+ /**
314
+ * Get log entries for a job from the database (if store is available).
315
+ * Falls back to in-memory logs if no store is configured.
316
+ */
317
+ async getJobLogsFromDb(id: string, limit?: number): Promise<CronJobLogEntry[]> {
318
+ if (this.store) {
319
+ const dbLogs = await this.store.fetchLogs(id, limit);
320
+ if (dbLogs.length > 0) return dbLogs;
321
+ }
322
+ // Fallback to in-memory
323
+ return this.getJobLogs(id, limit);
324
+ }
325
+
326
+ /**
327
+ * Enable or disable a job at runtime.
328
+ */
329
+ setJobEnabled(id: string, enabled: boolean): CronJobStatus | undefined {
330
+ const job = this.jobs.get(id);
331
+ if (!job) return undefined;
332
+
333
+ job.enabled = enabled;
334
+
335
+ if (enabled && this.started) {
336
+ job.state = "idle";
337
+ this.scheduleNext(id);
338
+ } else if (!enabled) {
339
+ this.stopJob(id);
340
+ job.state = "disabled";
341
+ }
342
+
343
+ return this.toStatus(job);
344
+ }
345
+
346
+ /**
347
+ * Manually trigger a job execution immediately.
348
+ *
349
+ * Returns `undefined` if the job doesn't exist.
350
+ * If the job is currently executing, returns the log entry with
351
+ * a `skipped: true` result rather than running concurrently.
352
+ */
353
+ async triggerJob(id: string): Promise<CronJobLogEntry | undefined> {
354
+ const job = this.jobs.get(id);
355
+ if (!job) return undefined;
356
+
357
+ // Concurrency guard — don't run two instances simultaneously
358
+ if (job.executing) {
359
+ console.warn(`[cron] Skipping manual trigger of "${id}" — already executing`);
360
+ const logEntry: CronJobLogEntry = {
361
+ jobId: id,
362
+ startedAt: new Date().toISOString(),
363
+ finishedAt: new Date().toISOString(),
364
+ durationMs: 0,
365
+ success: true,
366
+ result: { skipped: true, reason: "already_executing" },
367
+ logs: ["Skipped: job is already running"],
368
+ manual: true
369
+ };
370
+ job.logs.push(logEntry);
371
+ if (job.logs.length > MAX_LOGS_PER_JOB) job.logs.shift();
372
+ return logEntry;
373
+ }
374
+
375
+ return this.executeJob(job, true);
376
+ }
377
+
378
+ // ─── Internal ────────────────────────────────────────────────────
379
+
380
+ /**
381
+ * Schedule the next execution for a job.
382
+ *
383
+ * Safety guarantees:
384
+ * 1. Clears any existing timer first (prevents leaked/duplicate timers)
385
+ * 2. Enforces a minimum delay to prevent tight loops from jitter
386
+ * 3. Unref's the timer so it doesn't prevent process exit
387
+ * 4. Re-checks enabled & started state before executing
388
+ * 5. Concurrency guard prevents overlapping handler executions
389
+ */
390
+ private scheduleNext(id: string): void {
391
+ const job = this.jobs.get(id);
392
+ if (!job || !job.enabled || !this.started) return;
393
+
394
+ // Clear any previously scheduled timer to prevent double-firing
395
+ this.stopJob(id);
396
+
397
+ try {
398
+ const now = new Date();
399
+ const nextRun = parseCronExpression(job.definition.schedule, now);
400
+ job.nextRunAt = nextRun;
401
+
402
+ const rawDelay = nextRun.getTime() - now.getTime();
403
+ // Enforce a minimum delay to prevent tight re-execution loops
404
+ // from event loop jitter or near-zero setTimeout drift
405
+ const delay = Math.max(rawDelay, MIN_SCHEDULE_INTERVAL_MS);
406
+
407
+ const timer = setTimeout(async () => {
408
+ // Re-check state: scheduler may have been stopped or job disabled
409
+ // between when we scheduled and when we fire
410
+ if (!job.enabled || !this.started) return;
411
+
412
+ // Concurrency guard: if somehow we're already executing, skip
413
+ if (job.executing) {
414
+ console.warn(`[cron] Skipping scheduled run of "${id}" — still executing from previous run`);
415
+ // Re-schedule to try again later
416
+ this.scheduleNext(id);
417
+ return;
418
+ }
419
+
420
+ await this.executeJob(job, false);
421
+
422
+ // Schedule the next tick (only if still started + enabled)
423
+ if (this.started && job.enabled) {
424
+ this.scheduleNext(id);
425
+ }
426
+ }, delay);
427
+
428
+ // Unref the timer so it doesn't prevent Node.js from exiting
429
+ // during graceful shutdown
430
+ if (timer && typeof timer === "object" && "unref" in timer) {
431
+ timer.unref();
432
+ }
433
+
434
+ job.timerId = timer;
435
+ } catch (err: unknown) {
436
+ console.error(`[cron] Failed to schedule "${id}":`, err);
437
+ job.state = "error";
438
+ job.lastError = err instanceof Error ? err.message : String(err);
439
+ }
440
+ }
441
+
442
+ /**
443
+ * Stop a single job's timer and clear its next run state.
444
+ */
445
+ private stopJob(id: string): void {
446
+ const job = this.jobs.get(id);
447
+ if (job?.timerId) {
448
+ clearTimeout(job.timerId);
449
+ job.timerId = undefined;
450
+ job.nextRunAt = undefined;
451
+ }
452
+ }
453
+
454
+ /**
455
+ * Execute a job's handler with full isolation and safety.
456
+ *
457
+ * - Sets a concurrency flag to prevent overlapping runs
458
+ * - Wraps handler in a timeout race
459
+ * - Captures all logs, errors, and results
460
+ * - Persists to store (non-blocking) if available
461
+ * - Always restores state even on catastrophic errors
462
+ */
463
+ private async executeJob(
464
+ job: RegisteredJob,
465
+ manual: boolean
466
+ ): Promise<CronJobLogEntry> {
467
+ const startedAt = new Date();
468
+ const capturedLogs: string[] = [];
469
+
470
+ // Set executing flag — prevents concurrent runs
471
+ job.executing = true;
472
+
473
+ const ctx: CronJobContext = {
474
+ jobId: job.id,
475
+ scheduledAt: startedAt,
476
+ log: (...args: unknown[]) => {
477
+ const line = args.map((a) =>
478
+ typeof a === "string" ? a : JSON.stringify(a)
479
+ ).join(" ");
480
+ capturedLogs.push(line);
481
+ },
482
+ client: this.client!
483
+ };
484
+
485
+ job.state = "running";
486
+ job.lastRunAt = startedAt;
487
+ job.totalRuns++;
488
+
489
+ let success = true;
490
+ let error: string | undefined;
491
+ let result: unknown;
492
+
493
+ try {
494
+ // Race with timeout
495
+ const timeout = (job.definition.timeoutSeconds ?? 300) * 1000;
496
+ const handlerPromise = Promise.resolve(job.definition.handler(ctx));
497
+ let timeoutHandle: ReturnType<typeof setTimeout>;
498
+ const timeoutPromise = new Promise<never>((_, reject) => {
499
+ timeoutHandle = setTimeout(
500
+ () => reject(new Error(`Cron job "${job.id}" timed out after ${timeout}ms`)),
501
+ timeout
502
+ );
503
+ });
504
+
505
+ try {
506
+ result = await Promise.race([handlerPromise, timeoutPromise]);
507
+ } finally {
508
+ clearTimeout(timeoutHandle!);
509
+ }
510
+ } catch (err: unknown) {
511
+ success = false;
512
+ error = err instanceof Error ? err.message : String(err);
513
+ job.totalFailures++;
514
+ } finally {
515
+ // Always clear executing flag — even on catastrophic errors
516
+ job.executing = false;
517
+ }
518
+
519
+ const finishedAt = new Date();
520
+ const durationMs = finishedAt.getTime() - startedAt.getTime();
521
+
522
+ job.state = success ? (job.enabled ? "idle" : "disabled") : "error";
523
+ job.lastDurationMs = durationMs;
524
+ job.lastError = error;
525
+
526
+ const logEntry: CronJobLogEntry = {
527
+ jobId: job.id,
528
+ startedAt: startedAt.toISOString(),
529
+ finishedAt: finishedAt.toISOString(),
530
+ durationMs,
531
+ success,
532
+ error,
533
+ result: result !== undefined ? result : undefined,
534
+ logs: capturedLogs,
535
+ manual
536
+ };
537
+
538
+ // Push to ring buffer
539
+ job.logs.push(logEntry);
540
+ if (job.logs.length > MAX_LOGS_PER_JOB) {
541
+ job.logs.shift();
542
+ }
543
+
544
+ // Persist to database (non-blocking)
545
+ if (this.store) {
546
+ this.store.insertLog(logEntry).catch((persistErr) => {
547
+ console.error(`[cron] Failed to persist log for "${job.id}":`, persistErr);
548
+ });
549
+ }
550
+
551
+ if (success) {
552
+ console.log(`✅ [cron] "${job.id}" completed in ${durationMs}ms`);
553
+ } else {
554
+ console.error(`❌ [cron] "${job.id}" failed in ${durationMs}ms: ${error}`);
555
+ }
556
+
557
+ return logEntry;
558
+ }
559
+
560
+ private toStatus(job: RegisteredJob): CronJobStatus {
561
+ return {
562
+ id: job.id,
563
+ name: job.definition.name,
564
+ description: job.definition.description,
565
+ schedule: job.definition.schedule,
566
+ enabled: job.enabled,
567
+ state: job.state,
568
+ lastRunAt: job.lastRunAt?.toISOString(),
569
+ nextRunAt: job.nextRunAt?.toISOString(),
570
+ lastDurationMs: job.lastDurationMs,
571
+ lastError: job.lastError,
572
+ totalRuns: job.totalRuns,
573
+ totalFailures: job.totalFailures
574
+ };
575
+ }
576
+ }