@rebasepro/server-core 0.0.1-canary.eae7889 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/frontend/node_modules/esbuild/LICENSE.md +21 -0
- package/app/frontend/node_modules/esbuild/README.md +3 -0
- package/app/frontend/node_modules/esbuild/bin/esbuild +220 -0
- package/app/frontend/node_modules/esbuild/install.js +285 -0
- package/app/frontend/node_modules/esbuild/lib/main.d.ts +705 -0
- package/app/frontend/node_modules/esbuild/lib/main.js +2239 -0
- package/app/frontend/node_modules/esbuild/package.json +46 -0
- package/dist/index.es.js +1186 -1673
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +1185 -1672
- package/dist/index.umd.js.map +1 -1
- package/dist/server-core/src/api/rest/api-generator.d.ts +15 -3
- package/dist/server-core/src/auth/admin-routes.d.ts +5 -0
- package/dist/server-core/src/auth/google-oauth.d.ts +36 -3
- package/dist/server-core/src/auth/index.d.ts +1 -0
- package/dist/server-core/src/cron/cron-scheduler.d.ts +45 -0
- package/dist/server-core/src/cron/index.d.ts +1 -1
- package/dist/server-core/src/init.d.ts +11 -1
- package/dist/types/src/controllers/auth.d.ts +8 -2
- package/dist/types/src/controllers/client.d.ts +13 -0
- package/dist/types/src/controllers/collection_registry.d.ts +2 -1
- package/dist/types/src/controllers/data_driver.d.ts +36 -1
- package/dist/types/src/controllers/navigation.d.ts +18 -6
- package/dist/types/src/controllers/registry.d.ts +9 -1
- package/dist/types/src/controllers/side_entity_controller.d.ts +7 -0
- package/dist/types/src/rebase_context.d.ts +17 -0
- package/dist/types/src/types/backend_hooks.d.ts +187 -0
- package/dist/types/src/types/collections.d.ts +31 -11
- package/dist/types/src/types/component_ref.d.ts +47 -0
- package/dist/types/src/types/cron.d.ts +1 -1
- package/dist/types/src/types/entity_views.d.ts +6 -7
- package/dist/types/src/types/formex.d.ts +40 -0
- package/dist/types/src/types/index.d.ts +3 -0
- package/dist/types/src/types/plugins.d.ts +6 -3
- package/dist/types/src/types/properties.d.ts +72 -88
- package/dist/types/src/types/slots.d.ts +20 -10
- package/dist/types/src/types/translations.d.ts +6 -0
- package/examples/firebase/node_modules/esbuild/LICENSE.md +21 -0
- package/examples/firebase/node_modules/esbuild/README.md +3 -0
- package/examples/firebase/node_modules/esbuild/bin/esbuild +220 -0
- package/examples/firebase/node_modules/esbuild/install.js +285 -0
- package/examples/firebase/node_modules/esbuild/lib/main.d.ts +705 -0
- package/examples/firebase/node_modules/esbuild/lib/main.js +2239 -0
- package/examples/firebase/node_modules/esbuild/package.json +46 -0
- package/examples/medmot-staging/frontend/node_modules/esbuild/LICENSE.md +21 -0
- package/examples/medmot-staging/frontend/node_modules/esbuild/README.md +3 -0
- package/examples/medmot-staging/frontend/node_modules/esbuild/bin/esbuild +220 -0
- package/examples/medmot-staging/frontend/node_modules/esbuild/install.js +285 -0
- package/examples/medmot-staging/frontend/node_modules/esbuild/lib/main.d.ts +705 -0
- package/examples/medmot-staging/frontend/node_modules/esbuild/lib/main.js +2239 -0
- package/examples/medmot-staging/frontend/node_modules/esbuild/package.json +46 -0
- package/examples/sdk-demo/node_modules/esbuild/LICENSE.md +21 -0
- package/examples/sdk-demo/node_modules/esbuild/README.md +3 -0
- package/examples/sdk-demo/node_modules/esbuild/bin/esbuild +223 -0
- package/examples/sdk-demo/node_modules/esbuild/install.js +289 -0
- package/examples/sdk-demo/node_modules/esbuild/lib/main.d.ts +716 -0
- package/examples/sdk-demo/node_modules/esbuild/lib/main.js +2242 -0
- package/examples/sdk-demo/node_modules/esbuild/package.json +49 -0
- package/package.json +9 -9
- package/packages/client/node_modules/esbuild/LICENSE.md +21 -0
- package/packages/client/node_modules/esbuild/README.md +3 -0
- package/packages/client/node_modules/esbuild/bin/esbuild +220 -0
- package/packages/client/node_modules/esbuild/install.js +285 -0
- package/packages/client/node_modules/esbuild/lib/main.d.ts +705 -0
- package/packages/client/node_modules/esbuild/lib/main.js +2239 -0
- package/packages/client/node_modules/esbuild/package.json +46 -0
- package/packages/client-postgresql/node_modules/esbuild/LICENSE.md +21 -0
- package/packages/client-postgresql/node_modules/esbuild/README.md +3 -0
- package/packages/client-postgresql/node_modules/esbuild/bin/esbuild +220 -0
- package/packages/client-postgresql/node_modules/esbuild/install.js +285 -0
- package/packages/client-postgresql/node_modules/esbuild/lib/main.d.ts +705 -0
- package/packages/client-postgresql/node_modules/esbuild/lib/main.js +2239 -0
- package/packages/client-postgresql/node_modules/esbuild/package.json +46 -0
- package/packages/common/node_modules/esbuild/LICENSE.md +21 -0
- package/packages/common/node_modules/esbuild/README.md +3 -0
- package/packages/common/node_modules/esbuild/bin/esbuild +220 -0
- package/packages/common/node_modules/esbuild/install.js +285 -0
- package/packages/common/node_modules/esbuild/lib/main.d.ts +705 -0
- package/packages/common/node_modules/esbuild/lib/main.js +2239 -0
- package/packages/common/node_modules/esbuild/package.json +46 -0
- package/packages/server-mongodb/node_modules/esbuild/LICENSE.md +21 -0
- package/packages/server-mongodb/node_modules/esbuild/README.md +3 -0
- package/packages/server-mongodb/node_modules/esbuild/bin/esbuild +220 -0
- package/packages/server-mongodb/node_modules/esbuild/install.js +285 -0
- package/packages/server-mongodb/node_modules/esbuild/lib/main.d.ts +705 -0
- package/packages/server-mongodb/node_modules/esbuild/lib/main.js +2239 -0
- package/packages/server-mongodb/node_modules/esbuild/package.json +46 -0
- package/packages/server-postgresql/node_modules/esbuild/LICENSE.md +21 -0
- package/packages/server-postgresql/node_modules/esbuild/README.md +3 -0
- package/packages/server-postgresql/node_modules/esbuild/bin/esbuild +220 -0
- package/packages/server-postgresql/node_modules/esbuild/install.js +285 -0
- package/packages/server-postgresql/node_modules/esbuild/lib/main.d.ts +705 -0
- package/packages/server-postgresql/node_modules/esbuild/lib/main.js +2239 -0
- package/packages/server-postgresql/node_modules/esbuild/package.json +46 -0
- package/packages/types/node_modules/esbuild/LICENSE.md +21 -0
- package/packages/types/node_modules/esbuild/README.md +3 -0
- package/packages/types/node_modules/esbuild/bin/esbuild +220 -0
- package/packages/types/node_modules/esbuild/install.js +285 -0
- package/packages/types/node_modules/esbuild/lib/main.d.ts +705 -0
- package/packages/types/node_modules/esbuild/lib/main.js +2239 -0
- package/packages/types/node_modules/esbuild/package.json +46 -0
- package/packages/utils/node_modules/esbuild/LICENSE.md +21 -0
- package/packages/utils/node_modules/esbuild/README.md +3 -0
- package/packages/utils/node_modules/esbuild/bin/esbuild +220 -0
- package/packages/utils/node_modules/esbuild/install.js +285 -0
- package/packages/utils/node_modules/esbuild/lib/main.d.ts +705 -0
- package/packages/utils/node_modules/esbuild/lib/main.js +2239 -0
- package/packages/utils/node_modules/esbuild/package.json +46 -0
- package/src/api/errors.ts +3 -2
- package/src/api/rest/api-generator-count.test.ts +113 -0
- package/src/api/rest/api-generator.ts +123 -22
- package/src/api/server.ts +8 -4
- package/src/auth/admin-routes.ts +133 -57
- package/src/auth/apple-oauth.ts +8 -18
- package/src/auth/google-oauth.ts +192 -22
- package/src/auth/index.ts +1 -0
- package/src/auth/rate-limiter.ts +9 -5
- package/src/auth/routes.ts +25 -5
- package/src/collections/loader.ts +3 -3
- package/src/cron/cron-scheduler.test.ts +301 -175
- package/src/cron/cron-scheduler.ts +220 -57
- package/src/cron/index.ts +1 -1
- package/src/init.ts +27 -5
- package/src/storage/LocalStorageController.ts +37 -13
- package/src/storage/S3StorageController.ts +4 -1
- package/src/storage/routes.ts +51 -5
- package/test/backend-hooks-admin.test.ts +394 -0
- package/test/backend-hooks-data.test.ts +408 -0
- package/history_diff.log +0 -385
- package/scratch.ts +0 -9
- package/test-ast.ts +0 -28
- package/test_output.txt +0 -1133
|
@@ -9,23 +9,95 @@ import type { RebaseClient } from "@rebasepro/client";
|
|
|
9
9
|
import type { LoadedCronJob } from "./cron-loader";
|
|
10
10
|
import type { CronStore } from "./cron-store";
|
|
11
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
|
+
|
|
12
16
|
/**
|
|
13
|
-
*
|
|
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.
|
|
14
19
|
*/
|
|
15
|
-
function
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
|
|
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);
|
|
20
56
|
}
|
|
21
57
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
+
}
|
|
25
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Calculate the next Date after `after` that matches the cron expression.
|
|
98
|
+
* Throws on invalid expressions.
|
|
99
|
+
*/
|
|
26
100
|
function parseCronExpression(expression: string, after: Date): Date {
|
|
27
|
-
// We implement a simple forward-search. For production-grade parsing
|
|
28
|
-
// one would use a library, but we avoid adding dependencies.
|
|
29
101
|
const parts = expression.trim().split(/\s+/);
|
|
30
102
|
if (parts.length < 5) {
|
|
31
103
|
throw new Error(`Invalid cron expression: "${expression}". Expected 5 fields.`);
|
|
@@ -33,41 +105,11 @@ function parseCronExpression(expression: string, after: Date): Date {
|
|
|
33
105
|
|
|
34
106
|
const [minField, hourField, domField, monField, dowField] = parts;
|
|
35
107
|
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
} else if (segment.includes("/")) {
|
|
42
|
-
const [rangeStr, stepStr] = segment.split("/");
|
|
43
|
-
const step = parseInt(stepStr, 10);
|
|
44
|
-
let start = min;
|
|
45
|
-
let end = max;
|
|
46
|
-
if (rangeStr !== "*") {
|
|
47
|
-
if (rangeStr.includes("-")) {
|
|
48
|
-
const [a, b] = rangeStr.split("-").map(Number);
|
|
49
|
-
start = a;
|
|
50
|
-
end = b;
|
|
51
|
-
} else {
|
|
52
|
-
start = parseInt(rangeStr, 10);
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
for (let i = start; i <= end; i += step) results.add(i);
|
|
56
|
-
} else if (segment.includes("-")) {
|
|
57
|
-
const [a, b] = segment.split("-").map(Number);
|
|
58
|
-
for (let i = a; i <= b; i++) results.add(i);
|
|
59
|
-
} else {
|
|
60
|
-
results.add(parseInt(segment, 10));
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
return [...results].sort((a, b) => a - b);
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
const minutes = expand(minField, 0, 59);
|
|
67
|
-
const hours = expand(hourField, 0, 23);
|
|
68
|
-
const doms = expand(domField, 1, 31);
|
|
69
|
-
const months = expand(monField, 1, 12);
|
|
70
|
-
const dows = expand(dowField, 0, 6); // 0=Sunday
|
|
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
|
|
71
113
|
|
|
72
114
|
// Forward-search from `after + 1 minute`
|
|
73
115
|
const candidate = new Date(after);
|
|
@@ -104,6 +146,12 @@ function parseCronExpression(expression: string, after: Date): Date {
|
|
|
104
146
|
|
|
105
147
|
const MAX_LOGS_PER_JOB = 50;
|
|
106
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
|
+
|
|
107
155
|
// ─── CronScheduler ───────────────────────────────────────────────────
|
|
108
156
|
|
|
109
157
|
interface RegisteredJob {
|
|
@@ -119,6 +167,8 @@ interface RegisteredJob {
|
|
|
119
167
|
totalFailures: number;
|
|
120
168
|
timerId?: ReturnType<typeof setTimeout>;
|
|
121
169
|
logs: CronJobLogEntry[];
|
|
170
|
+
/** True while a handler is actively executing (prevents concurrent runs). */
|
|
171
|
+
executing: boolean;
|
|
122
172
|
}
|
|
123
173
|
|
|
124
174
|
export class CronScheduler {
|
|
@@ -145,24 +195,47 @@ export class CronScheduler {
|
|
|
145
195
|
|
|
146
196
|
/**
|
|
147
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.
|
|
148
204
|
*/
|
|
149
205
|
registerJobs(loadedJobs: LoadedCronJob[]): void {
|
|
150
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
|
+
|
|
151
216
|
const existing = this.jobs.get(loaded.id);
|
|
152
217
|
if (existing) {
|
|
153
218
|
console.warn(`[cron] Duplicate cron job id: "${loaded.id}". Overwriting.`);
|
|
154
219
|
this.stopJob(loaded.id);
|
|
155
220
|
}
|
|
156
221
|
|
|
222
|
+
const enabled = loaded.definition.enabled !== false;
|
|
223
|
+
|
|
157
224
|
this.jobs.set(loaded.id, {
|
|
158
225
|
id: loaded.id,
|
|
159
226
|
definition: loaded.definition,
|
|
160
|
-
enabled
|
|
161
|
-
state:
|
|
227
|
+
enabled,
|
|
228
|
+
state: enabled ? "idle" : "disabled",
|
|
162
229
|
totalRuns: 0,
|
|
163
230
|
totalFailures: 0,
|
|
164
|
-
logs: []
|
|
231
|
+
logs: [],
|
|
232
|
+
executing: false
|
|
165
233
|
});
|
|
234
|
+
|
|
235
|
+
// If the scheduler is already running, auto-schedule new jobs
|
|
236
|
+
if (this.started && enabled) {
|
|
237
|
+
this.scheduleNext(loaded.id);
|
|
238
|
+
}
|
|
166
239
|
}
|
|
167
240
|
}
|
|
168
241
|
|
|
@@ -201,6 +274,9 @@ export class CronScheduler {
|
|
|
201
274
|
|
|
202
275
|
/**
|
|
203
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.
|
|
204
280
|
*/
|
|
205
281
|
stop(): void {
|
|
206
282
|
this.started = false;
|
|
@@ -269,32 +345,93 @@ export class CronScheduler {
|
|
|
269
345
|
|
|
270
346
|
/**
|
|
271
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.
|
|
272
352
|
*/
|
|
273
353
|
async triggerJob(id: string): Promise<CronJobLogEntry | undefined> {
|
|
274
354
|
const job = this.jobs.get(id);
|
|
275
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
|
+
|
|
276
375
|
return this.executeJob(job, true);
|
|
277
376
|
}
|
|
278
377
|
|
|
279
378
|
// ─── Internal ────────────────────────────────────────────────────
|
|
280
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
|
+
*/
|
|
281
390
|
private scheduleNext(id: string): void {
|
|
282
391
|
const job = this.jobs.get(id);
|
|
283
392
|
if (!job || !job.enabled || !this.started) return;
|
|
284
393
|
|
|
394
|
+
// Clear any previously scheduled timer to prevent double-firing
|
|
395
|
+
this.stopJob(id);
|
|
396
|
+
|
|
285
397
|
try {
|
|
286
398
|
const now = new Date();
|
|
287
399
|
const nextRun = parseCronExpression(job.definition.schedule, now);
|
|
288
400
|
job.nextRunAt = nextRun;
|
|
289
401
|
|
|
290
|
-
const
|
|
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);
|
|
291
406
|
|
|
292
|
-
|
|
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
|
|
293
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
|
+
|
|
294
420
|
await this.executeJob(job, false);
|
|
295
|
-
|
|
296
|
-
|
|
421
|
+
|
|
422
|
+
// Schedule the next tick (only if still started + enabled)
|
|
423
|
+
if (this.started && job.enabled) {
|
|
424
|
+
this.scheduleNext(id);
|
|
425
|
+
}
|
|
297
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;
|
|
298
435
|
} catch (err: unknown) {
|
|
299
436
|
console.error(`[cron] Failed to schedule "${id}":`, err);
|
|
300
437
|
job.state = "error";
|
|
@@ -302,6 +439,9 @@ export class CronScheduler {
|
|
|
302
439
|
}
|
|
303
440
|
}
|
|
304
441
|
|
|
442
|
+
/**
|
|
443
|
+
* Stop a single job's timer and clear its next run state.
|
|
444
|
+
*/
|
|
305
445
|
private stopJob(id: string): void {
|
|
306
446
|
const job = this.jobs.get(id);
|
|
307
447
|
if (job?.timerId) {
|
|
@@ -311,6 +451,15 @@ export class CronScheduler {
|
|
|
311
451
|
}
|
|
312
452
|
}
|
|
313
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
|
+
*/
|
|
314
463
|
private async executeJob(
|
|
315
464
|
job: RegisteredJob,
|
|
316
465
|
manual: boolean
|
|
@@ -318,6 +467,9 @@ export class CronScheduler {
|
|
|
318
467
|
const startedAt = new Date();
|
|
319
468
|
const capturedLogs: string[] = [];
|
|
320
469
|
|
|
470
|
+
// Set executing flag — prevents concurrent runs
|
|
471
|
+
job.executing = true;
|
|
472
|
+
|
|
321
473
|
const ctx: CronJobContext = {
|
|
322
474
|
jobId: job.id,
|
|
323
475
|
scheduledAt: startedAt,
|
|
@@ -342,15 +494,26 @@ export class CronScheduler {
|
|
|
342
494
|
// Race with timeout
|
|
343
495
|
const timeout = (job.definition.timeoutSeconds ?? 300) * 1000;
|
|
344
496
|
const handlerPromise = Promise.resolve(job.definition.handler(ctx));
|
|
497
|
+
let timeoutHandle: ReturnType<typeof setTimeout>;
|
|
345
498
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
346
|
-
|
|
499
|
+
timeoutHandle = setTimeout(
|
|
500
|
+
() => reject(new Error(`Cron job "${job.id}" timed out after ${timeout}ms`)),
|
|
501
|
+
timeout
|
|
502
|
+
);
|
|
347
503
|
});
|
|
348
504
|
|
|
349
|
-
|
|
505
|
+
try {
|
|
506
|
+
result = await Promise.race([handlerPromise, timeoutPromise]);
|
|
507
|
+
} finally {
|
|
508
|
+
clearTimeout(timeoutHandle!);
|
|
509
|
+
}
|
|
350
510
|
} catch (err: unknown) {
|
|
351
511
|
success = false;
|
|
352
512
|
error = err instanceof Error ? err.message : String(err);
|
|
353
513
|
job.totalFailures++;
|
|
514
|
+
} finally {
|
|
515
|
+
// Always clear executing flag — even on catastrophic errors
|
|
516
|
+
job.executing = false;
|
|
354
517
|
}
|
|
355
518
|
|
|
356
519
|
const finishedAt = new Date();
|
|
@@ -380,8 +543,8 @@ export class CronScheduler {
|
|
|
380
543
|
|
|
381
544
|
// Persist to database (non-blocking)
|
|
382
545
|
if (this.store) {
|
|
383
|
-
this.store.insertLog(logEntry).catch((
|
|
384
|
-
console.error(`[cron] Failed to persist log for "${job.id}":`,
|
|
546
|
+
this.store.insertLog(logEntry).catch((persistErr) => {
|
|
547
|
+
console.error(`[cron] Failed to persist log for "${job.id}":`, persistErr);
|
|
385
548
|
});
|
|
386
549
|
}
|
|
387
550
|
|
package/src/cron/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { loadCronJobsFromDirectory } from "./cron-loader";
|
|
2
2
|
export type { LoadedCronJob } from "./cron-loader";
|
|
3
|
-
export { CronScheduler } from "./cron-scheduler";
|
|
3
|
+
export { CronScheduler, validateCronExpression } from "./cron-scheduler";
|
|
4
4
|
export { createCronRoutes } from "./cron-routes";
|
|
5
5
|
export { createCronStore } from "./cron-store";
|
|
6
6
|
export type { CronStore } from "./cron-store";
|
package/src/init.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DataDriver, EntityCollection, BackendBootstrapper, BootstrappedAuth, RealtimeProvider, HealthCheckResult, InitializedDriver, isSQLAdmin } from "@rebasepro/types";
|
|
1
|
+
import { DataDriver, EntityCollection, BackendBootstrapper, BootstrappedAuth, RealtimeProvider, HealthCheckResult, InitializedDriver, isSQLAdmin, BackendHooks } from "@rebasepro/types";
|
|
2
2
|
import { BackendCollectionRegistry } from "./collections/BackendCollectionRegistry";
|
|
3
3
|
import { loadCollectionsFromDirectory } from "./collections/loader";
|
|
4
4
|
import { DriverRegistry, DEFAULT_DRIVER_ID, DefaultDriverRegistry } from "./services/driver-registry";
|
|
@@ -43,7 +43,7 @@ export interface RebaseAuthConfig {
|
|
|
43
43
|
*/
|
|
44
44
|
serviceKey?: string;
|
|
45
45
|
email?: EmailConfig;
|
|
46
|
-
google?: { clientId: string };
|
|
46
|
+
google?: { clientId: string; clientSecret?: string };
|
|
47
47
|
linkedin?: { clientId: string; clientSecret: string };
|
|
48
48
|
github?: { clientId: string; clientSecret: string };
|
|
49
49
|
microsoft?: { clientId: string; clientSecret: string; tenantId?: string };
|
|
@@ -107,6 +107,15 @@ export interface RebaseBackendConfig {
|
|
|
107
107
|
/** Allowed origins for CSRF validation. */
|
|
108
108
|
origin: string | string[] | ((origin: string) => boolean);
|
|
109
109
|
};
|
|
110
|
+
/**
|
|
111
|
+
* Backend-level hooks for intercepting admin data (users, roles)
|
|
112
|
+
* at the API boundary. These run server-side after database reads
|
|
113
|
+
* and before API responses are sent.
|
|
114
|
+
*
|
|
115
|
+
* Complement the per-collection `EntityCallbacks` system which
|
|
116
|
+
* handles collection CRUD operations.
|
|
117
|
+
*/
|
|
118
|
+
hooks?: BackendHooks;
|
|
110
119
|
}
|
|
111
120
|
|
|
112
121
|
export interface RebaseBackendInstance {
|
|
@@ -346,7 +355,7 @@ collectionRegistry });
|
|
|
346
355
|
|
|
347
356
|
if (config.auth.google?.clientId) {
|
|
348
357
|
const { createGoogleProvider } = await import("./auth");
|
|
349
|
-
oauthProviders.push(createGoogleProvider(config.auth.google
|
|
358
|
+
oauthProviders.push(createGoogleProvider(config.auth.google));
|
|
350
359
|
}
|
|
351
360
|
|
|
352
361
|
if (config.auth.linkedin?.clientId && config.auth.linkedin?.clientSecret) {
|
|
@@ -418,7 +427,8 @@ collectionRegistry });
|
|
|
418
427
|
authRepo: authConfigResult.authRepository as import("./auth/interfaces").AuthRepository ?? authConfigResult.userService as import("./auth/interfaces").AuthRepository,
|
|
419
428
|
emailService: authConfigResult.emailService as import("./email").EmailService,
|
|
420
429
|
emailConfig: config.auth.email,
|
|
421
|
-
serviceKey
|
|
430
|
+
serviceKey,
|
|
431
|
+
hooks: config.hooks
|
|
422
432
|
});
|
|
423
433
|
config.app.route(`${basePath}/admin`, adminRoutes);
|
|
424
434
|
}
|
|
@@ -502,7 +512,7 @@ collectionRegistry });
|
|
|
502
512
|
dataRouter.route("/", historyRoutes);
|
|
503
513
|
}
|
|
504
514
|
|
|
505
|
-
const restGenerator = new RestApiGenerator(activeCollections, defaultDriver);
|
|
515
|
+
const restGenerator = new RestApiGenerator(activeCollections, defaultDriver, config.hooks?.data);
|
|
506
516
|
dataRouter.route("/", restGenerator.generateRoutes());
|
|
507
517
|
|
|
508
518
|
config.app.route(`${basePath}/data`, dataRouter);
|
|
@@ -572,6 +582,18 @@ collectionRegistry });
|
|
|
572
582
|
_initRebase(serverClient);
|
|
573
583
|
logger.info("Rebase singleton initialized");
|
|
574
584
|
|
|
585
|
+
// Retroactively inject the server client into the driver so that
|
|
586
|
+
// entity callbacks receive `context.client` at runtime.
|
|
587
|
+
// The driver is created before the client (which depends on the mounted
|
|
588
|
+
// Hono app), so we set it here, mirroring the historyService injection above.
|
|
589
|
+
if (defaultDriverResult.internals) {
|
|
590
|
+
const internals = defaultDriverResult.internals as Record<string, unknown>;
|
|
591
|
+
const driver = internals.driver as Record<string, unknown> | undefined;
|
|
592
|
+
if (driver && "client" in driver) {
|
|
593
|
+
driver.client = serverClient;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
575
597
|
// 5. Mount Custom Functions
|
|
576
598
|
if (config.functionsDir) {
|
|
577
599
|
const { loadFunctionsFromDirectory } = await import("./functions/function-loader");
|
|
@@ -77,10 +77,10 @@ export class LocalStorageController implements StorageController {
|
|
|
77
77
|
* Includes a path traversal guard to prevent escaping the base directory.
|
|
78
78
|
*/
|
|
79
79
|
private getFullPath(storagePath: string, bucket?: string): string {
|
|
80
|
-
const
|
|
81
|
-
const resolved = path.resolve(path.join(
|
|
82
|
-
if (!resolved.startsWith(
|
|
83
|
-
throw new Error("Path traversal detected: resolved storage path is outside the
|
|
80
|
+
const bucketPath = bucket ? path.join(this.basePath, bucket) : this.basePath;
|
|
81
|
+
const resolved = path.resolve(path.join(bucketPath, storagePath));
|
|
82
|
+
if (!resolved.startsWith(bucketPath + path.sep) && resolved !== bucketPath) {
|
|
83
|
+
throw new Error("Path traversal detected: resolved storage path is outside the bucket directory.");
|
|
84
84
|
}
|
|
85
85
|
return resolved;
|
|
86
86
|
}
|
|
@@ -264,21 +264,45 @@ export class LocalStorageController implements StorageController {
|
|
|
264
264
|
|
|
265
265
|
// Normalize path to handle leading/trailing slashes
|
|
266
266
|
resolvedPath = normalizeStoragePath(resolvedPath);
|
|
267
|
+
|
|
268
|
+
if (!resolvedPath) {
|
|
269
|
+
// Safety: never delete the bucket root
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
267
273
|
const fullPath = this.getFullPath(resolvedPath, resolvedBucket);
|
|
268
274
|
|
|
275
|
+
// Check if path exists before attempting to delete
|
|
269
276
|
try {
|
|
270
|
-
await
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
277
|
+
await access(fullPath, fs.constants.F_OK);
|
|
278
|
+
} catch {
|
|
279
|
+
// File doesn't exist — nothing to delete
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
const stats = await stat(fullPath);
|
|
285
|
+
if (stats.isDirectory()) {
|
|
286
|
+
// Only remove if empty — client must delete contents first
|
|
287
|
+
await fs.promises.rmdir(fullPath);
|
|
288
|
+
} else {
|
|
289
|
+
await unlink(fullPath);
|
|
290
|
+
// Also delete metadata file if exists
|
|
291
|
+
try {
|
|
292
|
+
await unlink(`${fullPath}.metadata.json`);
|
|
293
|
+
} catch {
|
|
294
|
+
// Metadata file might not exist
|
|
295
|
+
}
|
|
276
296
|
}
|
|
277
297
|
} catch (error: unknown) {
|
|
278
|
-
if (error instanceof Error
|
|
279
|
-
|
|
298
|
+
if (error instanceof Error) {
|
|
299
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
300
|
+
if (code === "ENOENT" || code === "ENOTEMPTY") {
|
|
301
|
+
// File doesn't exist or directory not empty — ignore
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
280
304
|
}
|
|
281
|
-
|
|
305
|
+
throw error;
|
|
282
306
|
}
|
|
283
307
|
}
|
|
284
308
|
|
|
@@ -72,7 +72,10 @@ export class S3StorageController implements StorageController {
|
|
|
72
72
|
* Get the bucket name - either from parameter or config
|
|
73
73
|
*/
|
|
74
74
|
private getBucket(bucket?: string): string {
|
|
75
|
-
|
|
75
|
+
// "default" is a logical bucket name used by local storage;
|
|
76
|
+
// for S3 it should resolve to the configured bucket.
|
|
77
|
+
if (!bucket || bucket === "default") return this.config.bucket;
|
|
78
|
+
return bucket;
|
|
76
79
|
}
|
|
77
80
|
|
|
78
81
|
async putObject({
|
package/src/storage/routes.ts
CHANGED
|
@@ -177,13 +177,20 @@ export function createStorageRoutes(config: StorageRoutesConfig): Hono<HonoEnv>
|
|
|
177
177
|
return c.body(new Uint8Array(fileContent));
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
-
// For remote storage (S3, GCS, etc.),
|
|
181
|
-
|
|
182
|
-
|
|
180
|
+
// For remote storage (S3, GCS, etc.), proxy the file through the backend.
|
|
181
|
+
// We avoid redirecting to signed URLs because:
|
|
182
|
+
// 1. Mixed-content (HTTPS page → HTTP MinIO) is blocked by browsers
|
|
183
|
+
// 2. Internal IPs / VPC endpoints are unreachable from the browser
|
|
184
|
+
const { bucket: parsedBucket, resolvedPath: parsedPath } = parseBucketAndPath(filePath);
|
|
185
|
+
const fileObject = await controller.getObject(parsedPath, parsedBucket);
|
|
186
|
+
if (!fileObject) {
|
|
183
187
|
throw ApiError.notFound("File not found");
|
|
184
188
|
}
|
|
185
189
|
|
|
186
|
-
|
|
190
|
+
c.header("Content-Type", fileObject.type || "application/octet-stream");
|
|
191
|
+
c.header("Cache-Control", "public, max-age=3600, immutable");
|
|
192
|
+
const buf = await fileObject.arrayBuffer();
|
|
193
|
+
return c.body(new Uint8Array(buf));
|
|
187
194
|
});
|
|
188
195
|
|
|
189
196
|
/**
|
|
@@ -248,7 +255,7 @@ message: "No file to delete" });
|
|
|
248
255
|
const result = await controller.listObjects(
|
|
249
256
|
storagePrefix,
|
|
250
257
|
{
|
|
251
|
-
bucket,
|
|
258
|
+
bucket: bucket ?? (controller.getType() === "local" ? "default" : undefined),
|
|
252
259
|
maxResults: maxResults ? parseInt(maxResults, 10) : undefined,
|
|
253
260
|
pageToken
|
|
254
261
|
}
|
|
@@ -260,5 +267,44 @@ message: "No file to delete" });
|
|
|
260
267
|
});
|
|
261
268
|
});
|
|
262
269
|
|
|
270
|
+
/**
|
|
271
|
+
* POST /folder - Create a new folder
|
|
272
|
+
* Body: { path: string, bucket?: string }
|
|
273
|
+
*/
|
|
274
|
+
router.post("/folder", writeAuthMiddleware, async (c) => {
|
|
275
|
+
const body = await c.req.json();
|
|
276
|
+
const folderPath = body.path;
|
|
277
|
+
|
|
278
|
+
if (!folderPath || typeof folderPath !== "string") {
|
|
279
|
+
throw ApiError.badRequest("Folder path is required");
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const { bucket, resolvedPath } = parseBucketAndPath(folderPath);
|
|
283
|
+
|
|
284
|
+
if (!resolvedPath || resolvedPath.trim() === "") {
|
|
285
|
+
throw ApiError.badRequest("Invalid folder path");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (controller.getType() === "local") {
|
|
289
|
+
// For local storage, create the directory
|
|
290
|
+
const localController = controller as LocalStorageController;
|
|
291
|
+
const absolutePath = localController.getAbsolutePath(resolvedPath, bucket);
|
|
292
|
+
fs.mkdirSync(absolutePath, { recursive: true });
|
|
293
|
+
} else {
|
|
294
|
+
// For S3-compatible storage, create a zero-byte marker object with trailing slash
|
|
295
|
+
const key = resolvedPath.endsWith("/") ? resolvedPath : resolvedPath + "/";
|
|
296
|
+
const emptyFile = new File([], key, { type: "application/x-directory" });
|
|
297
|
+
await controller.putObject({
|
|
298
|
+
file: emptyFile,
|
|
299
|
+
key
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return c.json({
|
|
304
|
+
success: true,
|
|
305
|
+
message: "Folder created"
|
|
306
|
+
}, 201);
|
|
307
|
+
});
|
|
308
|
+
|
|
263
309
|
return router;
|
|
264
310
|
}
|