@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.
- package/LICENSE +6 -0
- package/README.md +40 -0
- package/build-errors.txt +52 -0
- package/coverage/clover.xml +3739 -0
- package/coverage/coverage-final.json +31 -0
- package/coverage/lcov-report/base.css +224 -0
- package/coverage/lcov-report/block-navigation.js +87 -0
- package/coverage/lcov-report/favicon.png +0 -0
- package/coverage/lcov-report/index.html +266 -0
- package/coverage/lcov-report/prettify.css +1 -0
- package/coverage/lcov-report/prettify.js +2 -0
- package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
- package/coverage/lcov-report/sorter.js +210 -0
- package/coverage/lcov-report/src/api/ast-schema-editor.ts.html +952 -0
- package/coverage/lcov-report/src/api/errors.ts.html +472 -0
- package/coverage/lcov-report/src/api/graphql/graphql-schema-generator.ts.html +1069 -0
- package/coverage/lcov-report/src/api/graphql/index.html +116 -0
- package/coverage/lcov-report/src/api/index.html +176 -0
- package/coverage/lcov-report/src/api/openapi-generator.ts.html +565 -0
- package/coverage/lcov-report/src/api/rest/api-generator.ts.html +994 -0
- package/coverage/lcov-report/src/api/rest/index.html +131 -0
- package/coverage/lcov-report/src/api/rest/query-parser.ts.html +550 -0
- package/coverage/lcov-report/src/api/schema-editor-routes.ts.html +202 -0
- package/coverage/lcov-report/src/api/server.ts.html +823 -0
- package/coverage/lcov-report/src/auth/admin-routes.ts.html +973 -0
- package/coverage/lcov-report/src/auth/index.html +176 -0
- package/coverage/lcov-report/src/auth/jwt.ts.html +574 -0
- package/coverage/lcov-report/src/auth/middleware.ts.html +745 -0
- package/coverage/lcov-report/src/auth/password.ts.html +310 -0
- package/coverage/lcov-report/src/auth/services.ts.html +2074 -0
- package/coverage/lcov-report/src/collections/index.html +116 -0
- package/coverage/lcov-report/src/collections/loader.ts.html +232 -0
- package/coverage/lcov-report/src/db/auth-schema.ts.html +523 -0
- package/coverage/lcov-report/src/db/data-transformer.ts.html +1753 -0
- package/coverage/lcov-report/src/db/entityService.ts.html +700 -0
- package/coverage/lcov-report/src/db/index.html +146 -0
- package/coverage/lcov-report/src/db/services/EntityFetchService.ts.html +4048 -0
- package/coverage/lcov-report/src/db/services/EntityPersistService.ts.html +883 -0
- package/coverage/lcov-report/src/db/services/RelationService.ts.html +3121 -0
- package/coverage/lcov-report/src/db/services/entity-helpers.ts.html +442 -0
- package/coverage/lcov-report/src/db/services/index.html +176 -0
- package/coverage/lcov-report/src/db/services/index.ts.html +124 -0
- package/coverage/lcov-report/src/generate-drizzle-schema-logic.ts.html +1960 -0
- package/coverage/lcov-report/src/index.html +116 -0
- package/coverage/lcov-report/src/services/driver-registry.ts.html +631 -0
- package/coverage/lcov-report/src/services/index.html +131 -0
- package/coverage/lcov-report/src/services/postgresDataDriver.ts.html +3025 -0
- package/coverage/lcov-report/src/storage/LocalStorageController.ts.html +1189 -0
- package/coverage/lcov-report/src/storage/S3StorageController.ts.html +970 -0
- package/coverage/lcov-report/src/storage/index.html +161 -0
- package/coverage/lcov-report/src/storage/storage-registry.ts.html +646 -0
- package/coverage/lcov-report/src/storage/types.ts.html +451 -0
- package/coverage/lcov-report/src/utils/drizzle-conditions.ts.html +3082 -0
- package/coverage/lcov-report/src/utils/index.html +116 -0
- package/coverage/lcov.info +7179 -0
- package/dist/common/src/collections/CollectionRegistry.d.ts +56 -0
- package/dist/common/src/collections/index.d.ts +1 -0
- package/dist/common/src/data/buildRebaseData.d.ts +14 -0
- package/dist/common/src/index.d.ts +3 -0
- package/dist/common/src/util/builders.d.ts +57 -0
- package/dist/common/src/util/callbacks.d.ts +6 -0
- package/dist/common/src/util/collections.d.ts +11 -0
- package/dist/common/src/util/common.d.ts +2 -0
- package/dist/common/src/util/conditions.d.ts +26 -0
- package/dist/common/src/util/entities.d.ts +58 -0
- package/dist/common/src/util/enums.d.ts +3 -0
- package/dist/common/src/util/index.d.ts +16 -0
- package/dist/common/src/util/navigation_from_path.d.ts +34 -0
- package/dist/common/src/util/navigation_utils.d.ts +20 -0
- package/dist/common/src/util/parent_references_from_path.d.ts +6 -0
- package/dist/common/src/util/paths.d.ts +14 -0
- package/dist/common/src/util/permissions.d.ts +5 -0
- package/dist/common/src/util/references.d.ts +2 -0
- package/dist/common/src/util/relations.d.ts +22 -0
- package/dist/common/src/util/resolutions.d.ts +72 -0
- package/dist/common/src/util/storage.d.ts +24 -0
- package/dist/index-DXVBFp5V.js +37 -0
- package/dist/index-DXVBFp5V.js.map +1 -0
- package/dist/index.es.js +49249 -0
- package/dist/index.es.js.map +1 -0
- package/dist/index.umd.js +49283 -0
- package/dist/index.umd.js.map +1 -0
- package/dist/server-core/src/api/ast-schema-editor.d.ts +21 -0
- package/dist/server-core/src/api/collections_for_test/callbacks_test_collection.d.ts +2 -0
- package/dist/server-core/src/api/errors.d.ts +35 -0
- package/dist/server-core/src/api/graphql/graphql-schema-generator.d.ts +35 -0
- package/dist/server-core/src/api/graphql/index.d.ts +1 -0
- package/dist/server-core/src/api/index.d.ts +9 -0
- package/dist/server-core/src/api/openapi-generator.d.ts +16 -0
- package/dist/server-core/src/api/rest/api-generator.d.ts +76 -0
- package/dist/server-core/src/api/rest/index.d.ts +1 -0
- package/dist/server-core/src/api/rest/query-parser.d.ts +9 -0
- package/dist/server-core/src/api/schema-editor-routes.d.ts +3 -0
- package/dist/server-core/src/api/server.d.ts +40 -0
- package/dist/server-core/src/api/types.d.ts +90 -0
- package/dist/server-core/src/auth/admin-routes.d.ts +21 -0
- package/dist/server-core/src/auth/apple-oauth.d.ts +30 -0
- package/dist/server-core/src/auth/bitbucket-oauth.d.ts +11 -0
- package/dist/server-core/src/auth/discord-oauth.d.ts +14 -0
- package/dist/server-core/src/auth/facebook-oauth.d.ts +14 -0
- package/dist/server-core/src/auth/github-oauth.d.ts +15 -0
- package/dist/server-core/src/auth/gitlab-oauth.d.ts +13 -0
- package/dist/server-core/src/auth/google-oauth.d.ts +14 -0
- package/dist/server-core/src/auth/index.d.ts +23 -0
- package/dist/server-core/src/auth/interfaces.d.ts +309 -0
- package/dist/server-core/src/auth/jwt.d.ts +43 -0
- package/dist/server-core/src/auth/linkedin-oauth.d.ts +18 -0
- package/dist/server-core/src/auth/microsoft-oauth.d.ts +16 -0
- package/dist/server-core/src/auth/middleware.d.ts +81 -0
- package/dist/server-core/src/auth/password.d.ts +22 -0
- package/dist/server-core/src/auth/rate-limiter.d.ts +31 -0
- package/dist/server-core/src/auth/routes.d.ts +27 -0
- package/dist/server-core/src/auth/slack-oauth.d.ts +12 -0
- package/dist/server-core/src/auth/spotify-oauth.d.ts +12 -0
- package/dist/server-core/src/auth/twitter-oauth.d.ts +18 -0
- package/dist/server-core/src/bootstrappers/index.d.ts +0 -0
- package/dist/server-core/src/collections/BackendCollectionRegistry.d.ts +13 -0
- package/dist/server-core/src/collections/loader.d.ts +5 -0
- package/dist/server-core/src/cron/cron-loader.d.ts +17 -0
- package/dist/server-core/src/cron/cron-routes.d.ts +14 -0
- package/dist/server-core/src/cron/cron-scheduler.d.ts +106 -0
- package/dist/server-core/src/cron/cron-store.d.ts +32 -0
- package/dist/server-core/src/cron/index.d.ts +6 -0
- package/dist/server-core/src/db/interfaces.d.ts +18 -0
- package/dist/server-core/src/email/index.d.ts +6 -0
- package/dist/server-core/src/email/smtp-email-service.d.ts +25 -0
- package/dist/server-core/src/email/templates.d.ts +42 -0
- package/dist/server-core/src/email/types.d.ts +107 -0
- package/dist/server-core/src/functions/function-loader.d.ts +17 -0
- package/dist/server-core/src/functions/function-routes.d.ts +10 -0
- package/dist/server-core/src/functions/index.d.ts +3 -0
- package/dist/server-core/src/history/history-routes.d.ts +23 -0
- package/dist/server-core/src/history/index.d.ts +1 -0
- package/dist/server-core/src/index.d.ts +29 -0
- package/dist/server-core/src/init.d.ts +168 -0
- package/dist/server-core/src/serve-spa.d.ts +30 -0
- package/dist/server-core/src/services/driver-registry.d.ts +78 -0
- package/dist/server-core/src/singleton.d.ts +35 -0
- package/dist/server-core/src/storage/LocalStorageController.d.ts +46 -0
- package/dist/server-core/src/storage/S3StorageController.d.ts +36 -0
- package/dist/server-core/src/storage/index.d.ts +25 -0
- package/dist/server-core/src/storage/routes.d.ts +38 -0
- package/dist/server-core/src/storage/storage-registry.d.ts +78 -0
- package/dist/server-core/src/storage/types.d.ts +103 -0
- package/dist/server-core/src/types/index.d.ts +11 -0
- package/dist/server-core/src/utils/dev-port.d.ts +35 -0
- package/dist/server-core/src/utils/logger.d.ts +31 -0
- package/dist/server-core/src/utils/logging.d.ts +9 -0
- package/dist/server-core/src/utils/request-logger.d.ts +19 -0
- package/dist/server-core/src/utils/sql.d.ts +27 -0
- package/dist/types/src/controllers/analytics_controller.d.ts +7 -0
- package/dist/types/src/controllers/auth.d.ts +119 -0
- package/dist/types/src/controllers/client.d.ts +170 -0
- package/dist/types/src/controllers/collection_registry.d.ts +46 -0
- package/dist/types/src/controllers/customization_controller.d.ts +60 -0
- package/dist/types/src/controllers/data.d.ts +168 -0
- package/dist/types/src/controllers/data_driver.d.ts +195 -0
- package/dist/types/src/controllers/database_admin.d.ts +11 -0
- package/dist/types/src/controllers/dialogs_controller.d.ts +36 -0
- package/dist/types/src/controllers/effective_role.d.ts +4 -0
- package/dist/types/src/controllers/email.d.ts +34 -0
- package/dist/types/src/controllers/index.d.ts +18 -0
- package/dist/types/src/controllers/local_config_persistence.d.ts +20 -0
- package/dist/types/src/controllers/navigation.d.ts +213 -0
- package/dist/types/src/controllers/registry.d.ts +54 -0
- package/dist/types/src/controllers/side_dialogs_controller.d.ts +67 -0
- package/dist/types/src/controllers/side_entity_controller.d.ts +90 -0
- package/dist/types/src/controllers/snackbar.d.ts +24 -0
- package/dist/types/src/controllers/storage.d.ts +171 -0
- package/dist/types/src/index.d.ts +4 -0
- package/dist/types/src/rebase_context.d.ts +105 -0
- package/dist/types/src/types/backend.d.ts +536 -0
- package/dist/types/src/types/backend_hooks.d.ts +187 -0
- package/dist/types/src/types/builders.d.ts +15 -0
- package/dist/types/src/types/chips.d.ts +5 -0
- package/dist/types/src/types/collections.d.ts +857 -0
- package/dist/types/src/types/cron.d.ts +102 -0
- package/dist/types/src/types/data_source.d.ts +64 -0
- package/dist/types/src/types/entities.d.ts +145 -0
- package/dist/types/src/types/entity_actions.d.ts +98 -0
- package/dist/types/src/types/entity_callbacks.d.ts +173 -0
- package/dist/types/src/types/entity_link_builder.d.ts +7 -0
- package/dist/types/src/types/entity_overrides.d.ts +10 -0
- package/dist/types/src/types/entity_views.d.ts +59 -0
- package/dist/types/src/types/export_import.d.ts +21 -0
- package/dist/types/src/types/formex.d.ts +40 -0
- package/dist/types/src/types/index.d.ts +25 -0
- package/dist/types/src/types/locales.d.ts +4 -0
- package/dist/types/src/types/modify_collections.d.ts +5 -0
- package/dist/types/src/types/plugins.d.ts +282 -0
- package/dist/types/src/types/properties.d.ts +1148 -0
- package/dist/types/src/types/property_config.d.ts +70 -0
- package/dist/types/src/types/relations.d.ts +336 -0
- package/dist/types/src/types/slots.d.ts +262 -0
- package/dist/types/src/types/translations.d.ts +874 -0
- package/dist/types/src/types/user_management_delegate.d.ts +121 -0
- package/dist/types/src/types/websockets.d.ts +78 -0
- package/dist/types/src/users/index.d.ts +2 -0
- package/dist/types/src/users/roles.d.ts +22 -0
- package/dist/types/src/users/user.d.ts +46 -0
- package/history_diff.log +385 -0
- package/jest.config.cjs +16 -0
- package/package.json +86 -0
- package/scratch.ts +9 -0
- package/src/api/ast-schema-editor.ts +289 -0
- package/src/api/collections_for_test/callbacks_test_collection.ts +60 -0
- package/src/api/errors.ts +179 -0
- package/src/api/graphql/graphql-schema-generator.ts +336 -0
- package/src/api/graphql/index.ts +2 -0
- package/src/api/index.ts +11 -0
- package/src/api/openapi-generator.ts +715 -0
- package/src/api/rest/api-generator-count.test.ts +113 -0
- package/src/api/rest/api-generator.ts +573 -0
- package/src/api/rest/index.ts +2 -0
- package/src/api/rest/query-parser.ts +155 -0
- package/src/api/schema-editor-routes.ts +41 -0
- package/src/api/server.ts +249 -0
- package/src/api/types.ts +90 -0
- package/src/auth/admin-routes.ts +605 -0
- package/src/auth/apple-oauth.ts +120 -0
- package/src/auth/bitbucket-oauth.ts +82 -0
- package/src/auth/discord-oauth.ts +83 -0
- package/src/auth/facebook-oauth.ts +72 -0
- package/src/auth/github-oauth.ts +110 -0
- package/src/auth/gitlab-oauth.ts +70 -0
- package/src/auth/google-oauth.ts +48 -0
- package/src/auth/index.ts +34 -0
- package/src/auth/interfaces.ts +363 -0
- package/src/auth/jwt.ts +181 -0
- package/src/auth/linkedin-oauth.ts +81 -0
- package/src/auth/microsoft-oauth.ts +88 -0
- package/src/auth/middleware.ts +384 -0
- package/src/auth/password.ts +77 -0
- package/src/auth/rate-limiter.ts +133 -0
- package/src/auth/routes.ts +788 -0
- package/src/auth/slack-oauth.ts +71 -0
- package/src/auth/spotify-oauth.ts +67 -0
- package/src/auth/twitter-oauth.ts +120 -0
- package/src/bootstrappers/index.ts +1 -0
- package/src/collections/BackendCollectionRegistry.ts +20 -0
- package/src/collections/loader.ts +49 -0
- package/src/cron/cron-loader.ts +89 -0
- package/src/cron/cron-routes.test.ts +265 -0
- package/src/cron/cron-routes.ts +85 -0
- package/src/cron/cron-scheduler.test.ts +547 -0
- package/src/cron/cron-scheduler.ts +576 -0
- package/src/cron/cron-store.ts +163 -0
- package/src/cron/index.ts +6 -0
- package/src/db/interfaces.ts +60 -0
- package/src/email/index.ts +18 -0
- package/src/email/smtp-email-service.ts +91 -0
- package/src/email/templates.ts +388 -0
- package/src/email/types.ts +105 -0
- package/src/functions/function-loader.ts +119 -0
- package/src/functions/function-routes.ts +31 -0
- package/src/functions/index.ts +3 -0
- package/src/history/history-routes.ts +129 -0
- package/src/history/index.ts +2 -0
- package/src/index.ts +66 -0
- package/src/init.ts +737 -0
- package/src/serve-spa.ts +81 -0
- package/src/services/driver-registry.ts +182 -0
- package/src/singleton.test.ts +28 -0
- package/src/singleton.ts +70 -0
- package/src/storage/LocalStorageController.ts +365 -0
- package/src/storage/S3StorageController.ts +298 -0
- package/src/storage/index.ts +43 -0
- package/src/storage/routes.ts +264 -0
- package/src/storage/storage-registry.ts +187 -0
- package/src/storage/types.ts +134 -0
- package/src/types/index.ts +27 -0
- package/src/utils/dev-port.ts +176 -0
- package/src/utils/logger.ts +143 -0
- package/src/utils/logging.ts +38 -0
- package/src/utils/request-logger.ts +66 -0
- package/src/utils/sql.ts +38 -0
- package/test/admin-routes.test.ts +640 -0
- package/test/api-generator.test.ts +501 -0
- package/test/ast-schema-editor.test.ts +63 -0
- package/test/auth-middleware-hono.test.ts +556 -0
- package/test/auth-routes.test.ts +1047 -0
- package/test/backend-hooks-admin.test.ts +394 -0
- package/test/backend-hooks-data.test.ts +408 -0
- package/test/driver-registry.test.ts +282 -0
- package/test/error-propagation.test.ts +226 -0
- package/test/errors-hono.test.ts +133 -0
- package/test/errors.test.ts +155 -0
- package/test/jwt-security.test.ts +182 -0
- package/test/jwt.test.ts +324 -0
- package/test/middleware.test.ts +300 -0
- package/test/password.test.ts +165 -0
- package/test/query-parser.test.ts +263 -0
- package/test/rate-limiter.test.ts +102 -0
- package/test/safe-compare.test.ts +66 -0
- package/test/singleton.test.ts +59 -0
- package/test/storage-local.test.ts +271 -0
- package/test/storage-registry.test.ts +282 -0
- package/test/storage-routes.test.ts +222 -0
- package/test/storage-s3.test.ts +304 -0
- package/test-ast.ts +28 -0
- package/test.ts +6 -0
- package/test_output.txt +1133 -0
- package/tsconfig.json +49 -0
- package/tsconfig.prod.json +20 -0
- 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
|
+
}
|