@openlup/platform 0.0.0 → 0.15.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/CHANGELOG.md +19 -0
- package/LICENSE +204 -0
- package/README.md +124 -1
- package/dist/bff/dispatch.d.ts +27 -0
- package/dist/bff/dispatch.js +47 -0
- package/dist/bff/envelope.d.ts +46 -0
- package/dist/bff/envelope.js +80 -0
- package/dist/bff/health.d.ts +24 -0
- package/dist/bff/health.js +19 -0
- package/dist/bff/index.d.ts +6 -0
- package/dist/bff/index.js +6 -0
- package/dist/bff/lazyRoute.d.ts +4 -0
- package/dist/bff/lazyRoute.js +13 -0
- package/dist/bff/requestContext.d.ts +17 -0
- package/dist/bff/requestContext.js +26 -0
- package/dist/bff/routerRewrite.d.ts +2 -0
- package/dist/bff/routerRewrite.js +31 -0
- package/dist/blob/binding.d.ts +16 -0
- package/dist/blob/binding.js +37 -0
- package/dist/blob/filesystem.d.ts +8 -0
- package/dist/blob/filesystem.js +71 -0
- package/dist/blob/index.d.ts +6 -0
- package/dist/blob/index.js +6 -0
- package/dist/blob/s3.d.ts +32 -0
- package/dist/blob/s3.js +56 -0
- package/dist/blob/s3Client.d.ts +25 -0
- package/dist/blob/s3Client.js +76 -0
- package/dist/blob/supabase.d.ts +40 -0
- package/dist/blob/supabase.js +59 -0
- package/dist/blob/vercel.d.ts +63 -0
- package/dist/blob/vercel.js +69 -0
- package/dist/composition/composeBundle.d.ts +47 -0
- package/dist/composition/composeBundle.js +45 -0
- package/dist/composition/httpBinding.d.ts +8 -0
- package/dist/composition/httpBinding.js +18 -0
- package/dist/composition/index.d.ts +3 -0
- package/dist/composition/index.js +3 -0
- package/dist/composition/nodeHost.d.ts +103 -0
- package/dist/composition/nodeHost.js +145 -0
- package/dist/data/binding.d.ts +34 -0
- package/dist/data/binding.js +92 -0
- package/dist/data/index.d.ts +5 -0
- package/dist/data/index.js +5 -0
- package/dist/data/postgresGateway.d.ts +42 -0
- package/dist/data/postgresGateway.js +135 -0
- package/dist/data/queryBuilder.d.ts +54 -0
- package/dist/data/queryBuilder.js +240 -0
- package/dist/data/queryBuilderSql.d.ts +76 -0
- package/dist/data/queryBuilderSql.js +145 -0
- package/dist/data/supabaseClientFactory.d.ts +34 -0
- package/dist/data/supabaseClientFactory.js +56 -0
- package/dist/data/supabaseGateway.d.ts +22 -0
- package/dist/data/supabaseGateway.js +49 -0
- package/dist/http/deadline.d.ts +20 -0
- package/dist/http/deadline.js +93 -0
- package/dist/http/index.d.ts +6 -0
- package/dist/http/index.js +5 -0
- package/dist/http/requestAdapter.d.ts +38 -0
- package/dist/http/requestAdapter.js +203 -0
- package/dist/http/runtime.d.ts +83 -0
- package/dist/http/runtime.js +199 -0
- package/dist/http/security.d.ts +44 -0
- package/dist/http/security.js +114 -0
- package/dist/http/staticAssets.d.ts +3 -0
- package/dist/http/staticAssets.js +42 -0
- package/dist/http/types.d.ts +23 -0
- package/dist/http/types.js +6 -0
- package/dist/job-ledger/index.d.ts +5 -0
- package/dist/job-ledger/index.js +3 -0
- package/dist/job-ledger/ports.d.ts +10 -0
- package/dist/job-ledger/ports.js +7 -0
- package/dist/job-ledger/postgres.d.ts +10 -0
- package/dist/job-ledger/postgres.js +101 -0
- package/dist/job-ledger/sql.d.ts +7 -0
- package/dist/job-ledger/sql.js +10 -0
- package/dist/job-ledger/supabase.d.ts +77 -0
- package/dist/job-ledger/supabase.js +202 -0
- package/dist/kernel/index.d.ts +1 -0
- package/dist/kernel/index.js +1 -0
- package/dist/kernel/platformKernel.d.ts +34 -0
- package/dist/kernel/platformKernel.js +115 -0
- package/dist/migrations/index.d.ts +1 -0
- package/dist/migrations/index.js +1 -0
- package/dist/migrations/postgresRunner.d.ts +50 -0
- package/dist/migrations/postgresRunner.js +176 -0
- package/dist/scheduler/advisoryLock.d.ts +44 -0
- package/dist/scheduler/advisoryLock.js +114 -0
- package/dist/scheduler/cronGuard.d.ts +10 -0
- package/dist/scheduler/cronGuard.js +19 -0
- package/dist/scheduler/cronSecret.d.ts +5 -0
- package/dist/scheduler/cronSecret.js +23 -0
- package/dist/scheduler/handlers.d.ts +9 -0
- package/dist/scheduler/handlers.js +10 -0
- package/dist/scheduler/httpEntry.d.ts +16 -0
- package/dist/scheduler/httpEntry.js +31 -0
- package/dist/scheduler/index.d.ts +9 -0
- package/dist/scheduler/index.js +9 -0
- package/dist/scheduler/jobRegistry.d.ts +21 -0
- package/dist/scheduler/jobRegistry.js +39 -0
- package/dist/scheduler/jobs.d.ts +48 -0
- package/dist/scheduler/jobs.js +92 -0
- package/dist/scheduler/nodeScheduler.d.ts +42 -0
- package/dist/scheduler/nodeScheduler.js +73 -0
- package/dist/scheduler/vercelScheduler.d.ts +29 -0
- package/dist/scheduler/vercelScheduler.js +51 -0
- package/package.json +106 -3
- package/release-gates.json +121 -0
- package/sql/20260809150000_job_run_ledger.sql +301 -0
- package/sql/20260809150001_job_run_ledger_v3.sql +70 -0
- package/src/bff/dispatch.ts +81 -0
- package/src/bff/envelope.ts +142 -0
- package/src/bff/health.ts +37 -0
- package/src/bff/index.ts +30 -0
- package/src/bff/lazyRoute.ts +28 -0
- package/src/bff/requestContext.ts +48 -0
- package/src/bff/routerRewrite.ts +38 -0
- package/src/blob/binding.ts +55 -0
- package/src/blob/filesystem.ts +86 -0
- package/src/blob/index.ts +17 -0
- package/src/blob/s3.ts +87 -0
- package/src/blob/s3Client.ts +91 -0
- package/src/blob/supabase.ts +88 -0
- package/src/blob/vercel.ts +127 -0
- package/src/composition/composeBundle.ts +109 -0
- package/src/composition/httpBinding.ts +30 -0
- package/src/composition/index.ts +17 -0
- package/src/composition/nodeHost.ts +289 -0
- package/src/data/binding.ts +157 -0
- package/src/data/index.ts +33 -0
- package/src/data/postgresGateway.ts +186 -0
- package/src/data/queryBuilder.ts +300 -0
- package/src/data/queryBuilderSql.ts +183 -0
- package/src/data/supabaseClientFactory.ts +92 -0
- package/src/data/supabaseGateway.ts +76 -0
- package/src/http/deadline.ts +117 -0
- package/src/http/index.ts +36 -0
- package/src/http/requestAdapter.ts +215 -0
- package/src/http/runtime.ts +319 -0
- package/src/http/security.ts +155 -0
- package/src/http/staticAssets.ts +42 -0
- package/src/http/types.ts +31 -0
- package/src/job-ledger/index.ts +25 -0
- package/src/job-ledger/ports.ts +27 -0
- package/src/job-ledger/postgres.ts +121 -0
- package/src/job-ledger/sql.ts +12 -0
- package/src/job-ledger/supabase.ts +294 -0
- package/src/kernel/index.ts +17 -0
- package/src/kernel/platformKernel.ts +153 -0
- package/src/migrations/index.ts +10 -0
- package/src/migrations/postgresRunner.ts +255 -0
- package/src/scheduler/advisoryLock.ts +145 -0
- package/src/scheduler/cronGuard.ts +30 -0
- package/src/scheduler/cronSecret.ts +26 -0
- package/src/scheduler/handlers.ts +24 -0
- package/src/scheduler/httpEntry.ts +49 -0
- package/src/scheduler/index.ts +47 -0
- package/src/scheduler/jobRegistry.ts +57 -0
- package/src/scheduler/jobs.ts +119 -0
- package/src/scheduler/nodeScheduler.ts +123 -0
- package/src/scheduler/vercelScheduler.ts +71 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** A cluster-wide, per-job mutex. Returns true iff THIS caller acquired the lock. */
|
|
2
|
+
export interface AdvisoryLock {
|
|
3
|
+
/** Explicit capability marker; a host that must not run a job twice requires a cluster lock. */
|
|
4
|
+
readonly durability?: "cluster" | "process";
|
|
5
|
+
/** Try to acquire the lock for `jobId` without blocking. */
|
|
6
|
+
tryAcquire(jobId: string): Promise<boolean>;
|
|
7
|
+
/** Release a previously-acquired lock for `jobId`. */
|
|
8
|
+
release(jobId: string): Promise<void>;
|
|
9
|
+
/** Release the dedicated session when the host stops; safe to call repeatedly. */
|
|
10
|
+
close(): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
/** Minimal pg-query surface (a `pg` Pool/Client satisfies this) — keeps `pg` out of the import graph. */
|
|
13
|
+
export interface AdvisoryLockQuerier {
|
|
14
|
+
query<T = unknown>(text: string, values?: unknown[]): Promise<{
|
|
15
|
+
rows: T[];
|
|
16
|
+
}>;
|
|
17
|
+
end?(): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Deterministic 64-bit-ish key from a jobId for pg_try_advisory_lock(bigint).
|
|
21
|
+
* Postgres advisory locks key on bigint; we hash the job name to a stable signed-32-bit int
|
|
22
|
+
* (the single-arg `pg_try_advisory_lock(key bigint)` accepts it). Stable across processes.
|
|
23
|
+
*/
|
|
24
|
+
export declare function advisoryLockKey(jobId: string): number;
|
|
25
|
+
/**
|
|
26
|
+
* pg-backed advisory lock. Uses `pg_try_advisory_lock` (session-scoped, non-blocking) on acquire
|
|
27
|
+
* and `pg_advisory_unlock` on release. NOTE: session-scoped locks require the same connection for
|
|
28
|
+
* acquire+release, so the supplied querier should be a single dedicated client/connection, not a
|
|
29
|
+
* pool that hands out a different connection per query.
|
|
30
|
+
*/
|
|
31
|
+
export declare function createPgAdvisoryLock(client: AdvisoryLockQuerier, closeClient?: () => Promise<void>): AdvisoryLock;
|
|
32
|
+
/**
|
|
33
|
+
* Build the lock a long-running host should actually use, from the connection string the
|
|
34
|
+
* environment declares. Returns null when no connection string is configured, which leaves the
|
|
35
|
+
* scheduler on its always-granting default — the exact behaviour of a host with a single instance.
|
|
36
|
+
*
|
|
37
|
+
* The connection is opened LAZILY, on the first acquire, and then reused: a session-scoped
|
|
38
|
+
* try-lock must acquire and release on the SAME connection, and a host that never fires a timer
|
|
39
|
+
* must never open one. `pg` stays out of the static import graph (dynamic import), so the default
|
|
40
|
+
* hosted path and typecheck are untouched.
|
|
41
|
+
*/
|
|
42
|
+
export declare function createEnvAdvisoryLock(env: Record<string, string | undefined>, connect?: (connectionString: string) => Promise<AdvisoryLockQuerier>): AdvisoryLock | null;
|
|
43
|
+
/** Lock that always grants (single-instance / tests). Never use in multi-instance Node deploys. */
|
|
44
|
+
export declare function createNoopAdvisoryLock(): AdvisoryLock;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Per-job advisory lock for the Node scheduler.
|
|
2
|
+
//
|
|
3
|
+
// On Vercel exactly one cron timer fires per schedule. On a self-hosted Node bundle the SAME
|
|
4
|
+
// process image can run on MULTIPLE instances (horizontal scale, blue/green overlap, a stray
|
|
5
|
+
// second pod) — each with its own node-cron timer. Without coordination, every instance would
|
|
6
|
+
// fire the same job at the same minute => double-run (double emails, double polls, double
|
|
7
|
+
// dispatch). `pg_try_advisory_lock` gives us a cluster-wide, connection-scoped mutex keyed per job:
|
|
8
|
+
// only the instance that ACQUIRES the lock runs the job body; the rest skip and release nothing
|
|
9
|
+
// (try-lock returns false without blocking). The lock is released after the job body finishes.
|
|
10
|
+
//
|
|
11
|
+
// Injectable: the adapter depends on the AdvisoryLock interface, not on `pg` directly, so tests
|
|
12
|
+
// run without a database and a non-Supabase bundle can supply its own pg-backed implementation.
|
|
13
|
+
/**
|
|
14
|
+
* Deterministic 64-bit-ish key from a jobId for pg_try_advisory_lock(bigint).
|
|
15
|
+
* Postgres advisory locks key on bigint; we hash the job name to a stable signed-32-bit int
|
|
16
|
+
* (the single-arg `pg_try_advisory_lock(key bigint)` accepts it). Stable across processes.
|
|
17
|
+
*/
|
|
18
|
+
export function advisoryLockKey(jobId) {
|
|
19
|
+
// FNV-1a 32-bit, then coerce to signed 32-bit so it fits a Postgres int safely.
|
|
20
|
+
let hash = 0x811c9dc5;
|
|
21
|
+
for (let i = 0; i < jobId.length; i += 1) {
|
|
22
|
+
hash ^= jobId.charCodeAt(i);
|
|
23
|
+
hash = Math.imul(hash, 0x01000193);
|
|
24
|
+
}
|
|
25
|
+
return hash | 0;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* pg-backed advisory lock. Uses `pg_try_advisory_lock` (session-scoped, non-blocking) on acquire
|
|
29
|
+
* and `pg_advisory_unlock` on release. NOTE: session-scoped locks require the same connection for
|
|
30
|
+
* acquire+release, so the supplied querier should be a single dedicated client/connection, not a
|
|
31
|
+
* pool that hands out a different connection per query.
|
|
32
|
+
*/
|
|
33
|
+
export function createPgAdvisoryLock(client, closeClient = () => client.end?.() ?? Promise.resolve()) {
|
|
34
|
+
let closePromise = null;
|
|
35
|
+
return {
|
|
36
|
+
durability: "cluster",
|
|
37
|
+
async tryAcquire(jobId) {
|
|
38
|
+
const key = advisoryLockKey(jobId);
|
|
39
|
+
const { rows } = await client.query("SELECT pg_try_advisory_lock($1) AS locked", [key]);
|
|
40
|
+
return rows[0]?.locked === true;
|
|
41
|
+
},
|
|
42
|
+
async release(jobId) {
|
|
43
|
+
const key = advisoryLockKey(jobId);
|
|
44
|
+
await client.query("SELECT pg_advisory_unlock($1)", [key]);
|
|
45
|
+
},
|
|
46
|
+
close() {
|
|
47
|
+
closePromise ??= closeClient();
|
|
48
|
+
return closePromise;
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Build the lock a long-running host should actually use, from the connection string the
|
|
54
|
+
* environment declares. Returns null when no connection string is configured, which leaves the
|
|
55
|
+
* scheduler on its always-granting default — the exact behaviour of a host with a single instance.
|
|
56
|
+
*
|
|
57
|
+
* The connection is opened LAZILY, on the first acquire, and then reused: a session-scoped
|
|
58
|
+
* try-lock must acquire and release on the SAME connection, and a host that never fires a timer
|
|
59
|
+
* must never open one. `pg` stays out of the static import graph (dynamic import), so the default
|
|
60
|
+
* hosted path and typecheck are untouched.
|
|
61
|
+
*/
|
|
62
|
+
export function createEnvAdvisoryLock(env, connect = connectDedicatedClient) {
|
|
63
|
+
const connectionString = env.DATABASE_URL?.trim() ?? "";
|
|
64
|
+
if (connectionString === "")
|
|
65
|
+
return null;
|
|
66
|
+
let pending = null;
|
|
67
|
+
let closed = false;
|
|
68
|
+
let closePromise = null;
|
|
69
|
+
const querier = {
|
|
70
|
+
async query(text, values) {
|
|
71
|
+
if (closed)
|
|
72
|
+
throw new Error("advisory_lock_closed");
|
|
73
|
+
pending ??= connect(connectionString);
|
|
74
|
+
const client = await pending;
|
|
75
|
+
return client.query(text, values);
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
return createPgAdvisoryLock(querier, () => {
|
|
79
|
+
closed = true;
|
|
80
|
+
closePromise ??= pending?.then((client) => client.end?.() ?? undefined) ?? Promise.resolve();
|
|
81
|
+
return closePromise;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
async function connectDedicatedClient(connectionString) {
|
|
85
|
+
const pg = await import("pg").catch(peerMissing);
|
|
86
|
+
const ClientCtor = (pg.default?.Client ?? pg.Client);
|
|
87
|
+
const client = new ClientCtor({ connectionString });
|
|
88
|
+
await client.connect();
|
|
89
|
+
return client;
|
|
90
|
+
}
|
|
91
|
+
/** Lock that always grants (single-instance / tests). Never use in multi-instance Node deploys. */
|
|
92
|
+
export function createNoopAdvisoryLock() {
|
|
93
|
+
return {
|
|
94
|
+
durability: "process",
|
|
95
|
+
async tryAcquire() {
|
|
96
|
+
return true;
|
|
97
|
+
},
|
|
98
|
+
async release() {
|
|
99
|
+
/* no-op */
|
|
100
|
+
},
|
|
101
|
+
async close() {
|
|
102
|
+
/* no-op */
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/** Translate only absence of this optional peer; preserve broken SDKs and other failures. */
|
|
107
|
+
function peerMissing(cause) {
|
|
108
|
+
if (cause instanceof Error && "code" in cause
|
|
109
|
+
&& (cause.code === "ERR_MODULE_NOT_FOUND" || cause.code === "MODULE_NOT_FOUND")
|
|
110
|
+
&& /^Cannot find (?:package|module) ['"]pg['"]/.test(cause.message)) {
|
|
111
|
+
throw new Error("OPENLUP_E_PEER_MISSING: pg is required by the Postgres scheduler advisory lock; install it alongside @openlup/platform", { cause });
|
|
112
|
+
}
|
|
113
|
+
throw cause;
|
|
114
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { HttpRequest } from "../http/index.js";
|
|
2
|
+
import { bearerToken } from "./cronSecret.js";
|
|
3
|
+
export { bearerToken };
|
|
4
|
+
export interface CronAuthDenied {
|
|
5
|
+
status: number;
|
|
6
|
+
body: Record<string, unknown>;
|
|
7
|
+
}
|
|
8
|
+
export declare function authorizeCron(req: HttpRequest, env: {
|
|
9
|
+
CRON_SECRET?: string;
|
|
10
|
+
}): CronAuthDenied | null;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { bearerToken, cronSecretMatches } from "./cronSecret.js";
|
|
2
|
+
export { bearerToken };
|
|
3
|
+
// Shared cron entrypoint guard: method allowlist + CRON_SECRET presence + constant-time bearer
|
|
4
|
+
// match (see ./cronSecret.ts). Returns a response to return verbatim when denied, or null when the
|
|
5
|
+
// request is authorized. Per-job flag kill-switches and provider/env readiness
|
|
6
|
+
// checks intentionally stay in each job — they differ per job and must run after
|
|
7
|
+
// this guard but before any ledger write.
|
|
8
|
+
export function authorizeCron(req, env) {
|
|
9
|
+
if (req.method !== "GET" && req.method !== "POST") {
|
|
10
|
+
return { status: 405, body: { ok: false, error: "method_not_allowed" } };
|
|
11
|
+
}
|
|
12
|
+
if (!env.CRON_SECRET) {
|
|
13
|
+
return { status: 503, body: { ok: false, error: "cron_secret_required" } };
|
|
14
|
+
}
|
|
15
|
+
if (!cronSecretMatches(bearerToken(req), env.CRON_SECRET)) {
|
|
16
|
+
return { status: 401, body: { ok: false, error: "unauthorized" } };
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { PlatformHttpRequest } from "@openlup/core/platform-runtime";
|
|
2
|
+
/** Extract the Bearer token from a request Authorization header (case-insensitive). */
|
|
3
|
+
export declare function bearerToken(req: PlatformHttpRequest): string | null;
|
|
4
|
+
/** True when the presented token equals the configured secret, compared in constant time. */
|
|
5
|
+
export declare function cronSecretMatches(presented: string | null, expected: string): boolean;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// The one cron bearer check shared by the cron entrypoint guard and the scheduler verifier.
|
|
2
|
+
//
|
|
3
|
+
// Grammar: `Authorization: Bearer <token>`, scheme case-insensitive, the first value of a repeated
|
|
4
|
+
// header, the presented token trimmed and the configured secret compared as-is. The comparison takes
|
|
5
|
+
// the same time for every presented token of the secret's byte length, so a mismatch reveals no
|
|
6
|
+
// matching prefix; a token of another byte length (including any non-ASCII one) is refused first.
|
|
7
|
+
import { timingSafeEqual } from "node:crypto";
|
|
8
|
+
/** Extract the Bearer token from a request Authorization header (case-insensitive). */
|
|
9
|
+
export function bearerToken(req) {
|
|
10
|
+
const auth = req.headers.authorization;
|
|
11
|
+
const header = Array.isArray(auth) ? auth[0] : auth;
|
|
12
|
+
return header?.match(/^Bearer\s+(.+)$/i)?.[1]?.trim() ?? null;
|
|
13
|
+
}
|
|
14
|
+
/** True when the presented token equals the configured secret, compared in constant time. */
|
|
15
|
+
export function cronSecretMatches(presented, expected) {
|
|
16
|
+
if (presented === null)
|
|
17
|
+
return false;
|
|
18
|
+
const presentedBytes = Buffer.from(presented, "utf8");
|
|
19
|
+
const expectedBytes = Buffer.from(expected, "utf8");
|
|
20
|
+
if (presentedBytes.length !== expectedBytes.length)
|
|
21
|
+
return false;
|
|
22
|
+
return timingSafeEqual(presentedBytes, expectedBytes);
|
|
23
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type ScheduledInvocationSource = "node_cron" | "scheduler";
|
|
2
|
+
export interface ScheduledJobInvocation {
|
|
3
|
+
jobId: string;
|
|
4
|
+
invocationSource: ScheduledInvocationSource;
|
|
5
|
+
}
|
|
6
|
+
export type ScheduledJobHandler = (invocation: ScheduledJobInvocation) => Promise<void> | void;
|
|
7
|
+
export type ScheduledJobHandlerRegistry = Readonly<Partial<Record<string, ScheduledJobHandler>>>;
|
|
8
|
+
/** Merge contributed bindings in order; a later contribution replaces an earlier one for the same job. */
|
|
9
|
+
export declare function mergeScheduledJobHandlers(...registries: ReadonlyArray<ScheduledJobHandlerRegistry | undefined>): ScheduledJobHandlerRegistry;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Direct scheduled-job bindings for self-hosted runtimes.
|
|
2
|
+
//
|
|
3
|
+
// The Node host mounts every declared `/api/cron/<jobId>` route, so any job can be reached
|
|
4
|
+
// through the HTTP compatibility bridge. A direct binding skips that hop: it calls the
|
|
5
|
+
// application runtime in-process and records an honest invocation source instead of a
|
|
6
|
+
// hosted-cron one. Deployments and domains contribute bindings; this module only merges them.
|
|
7
|
+
/** Merge contributed bindings in order; a later contribution replaces an earlier one for the same job. */
|
|
8
|
+
export function mergeScheduledJobHandlers(...registries) {
|
|
9
|
+
return Object.assign({}, ...registries.filter((registry) => registry !== undefined));
|
|
10
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { JobLeasePort } from "@openlup/core/platform-runtime";
|
|
2
|
+
import type { HttpRequest, HttpResponse } from "../http/index.js";
|
|
3
|
+
import { type ScheduledJob } from "./jobs.js";
|
|
4
|
+
export interface ScheduledJobHttpEntryOptions {
|
|
5
|
+
/** Environment whose CRON_SECRET authenticates the caller, read on each request. */
|
|
6
|
+
env: () => {
|
|
7
|
+
CRON_SECRET?: string;
|
|
8
|
+
};
|
|
9
|
+
/** Evidence recorded for HTTP invocations, for example the hosting platform's cron. */
|
|
10
|
+
invocationSource: string;
|
|
11
|
+
/** The job-lease port, required when the job declares a lease. */
|
|
12
|
+
lease?: JobLeasePort;
|
|
13
|
+
/** Error sink for a failed run (defaults to console.error). */
|
|
14
|
+
onError?: (jobId: string, error: unknown) => void;
|
|
15
|
+
}
|
|
16
|
+
export declare function createScheduledJobHttpEntry(job: ScheduledJob, options: ScheduledJobHttpEntryOptions): (req: HttpRequest, res: HttpResponse) => Promise<void>;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Authenticated HTTP cron entry for a transport-neutral scheduled job.
|
|
2
|
+
//
|
|
3
|
+
// The entry is an adapter: it applies the cron guard (method, configured secret, bearer token) and
|
|
4
|
+
// only then runs the job with an HTTP invocation. A refusal answers the guard's own status and
|
|
5
|
+
// body. A run answers 200 with its result, a lease held elsewhere answers 200 as skipped, and a
|
|
6
|
+
// failed run answers 500 without error detail; the error goes to the host's error sink.
|
|
7
|
+
import { authorizeCron } from "./cronGuard.js";
|
|
8
|
+
import { runScheduledJob } from "./jobs.js";
|
|
9
|
+
export function createScheduledJobHttpEntry(job, options) {
|
|
10
|
+
const onError = options.onError ?? ((jobId, error) => console.error(`[scheduled-job] "${jobId}" failed`, error));
|
|
11
|
+
return async function scheduledJobEntry(req, res) {
|
|
12
|
+
const denied = authorizeCron(req, options.env());
|
|
13
|
+
if (denied) {
|
|
14
|
+
res.status(denied.status).json(denied.body);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
const result = await runScheduledJob(job, {
|
|
19
|
+
invocation: { triggerKind: "scheduler", invocationSource: options.invocationSource },
|
|
20
|
+
lease: options.lease,
|
|
21
|
+
});
|
|
22
|
+
res.status(200).json(result.status === "skipped"
|
|
23
|
+
? { ok: true, skipped: true, reason: result.reason }
|
|
24
|
+
: { ok: true, result: result.summary });
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
onError(job.id, error);
|
|
28
|
+
res.status(500).json({ ok: false, error: "scheduled_job_failed" });
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { jobIdFromPath, jobRegistryFromConfig, loadJobRegistry, type PlatformRuntimeConfig, type ScheduledJobDeclaration, } from "./jobRegistry.js";
|
|
2
|
+
export { advisoryLockKey, createEnvAdvisoryLock, createNoopAdvisoryLock, createPgAdvisoryLock, type AdvisoryLock, type AdvisoryLockQuerier, } from "./advisoryLock.js";
|
|
3
|
+
export { createNodeScheduler, withAdvisoryLock, type CreateNodeSchedulerOptions, type CronScheduler, type NodeSchedulerAdapter, } from "./nodeScheduler.js";
|
|
4
|
+
export { bearerToken, createVercelScheduler, verifyCronAuth, type VercelSchedulerAdapter, } from "./vercelScheduler.js";
|
|
5
|
+
export { authorizeCron, type CronAuthDenied } from "./cronGuard.js";
|
|
6
|
+
export { cronSecretMatches } from "./cronSecret.js";
|
|
7
|
+
export { mergeScheduledJobHandlers, type ScheduledInvocationSource, type ScheduledJobHandler, type ScheduledJobHandlerRegistry, type ScheduledJobInvocation, } from "./handlers.js";
|
|
8
|
+
export { createScheduledJobRegistry, cronFromCadenceSeconds, runScheduledJob, scheduledJobsFromContribution, type ScheduledJob, type ScheduledJobContext, type ScheduledJobRunResult, } from "./jobs.js";
|
|
9
|
+
export { createScheduledJobHttpEntry, type ScheduledJobHttpEntryOptions } from "./httpEntry.js";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { jobIdFromPath, jobRegistryFromConfig, loadJobRegistry, } from "./jobRegistry.js";
|
|
2
|
+
export { advisoryLockKey, createEnvAdvisoryLock, createNoopAdvisoryLock, createPgAdvisoryLock, } from "./advisoryLock.js";
|
|
3
|
+
export { createNodeScheduler, withAdvisoryLock, } from "./nodeScheduler.js";
|
|
4
|
+
export { bearerToken, createVercelScheduler, verifyCronAuth, } from "./vercelScheduler.js";
|
|
5
|
+
export { authorizeCron } from "./cronGuard.js";
|
|
6
|
+
export { cronSecretMatches } from "./cronSecret.js";
|
|
7
|
+
export { mergeScheduledJobHandlers, } from "./handlers.js";
|
|
8
|
+
export { createScheduledJobRegistry, cronFromCadenceSeconds, runScheduledJob, scheduledJobsFromContribution, } from "./jobs.js";
|
|
9
|
+
export { createScheduledJobHttpEntry } from "./httpEntry.js";
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** A scheduled job declared by path in the platform-runtime configuration (HTTP-invoked). */
|
|
2
|
+
export interface ScheduledJobDeclaration {
|
|
3
|
+
/** Stable job id used for advisory locking + job-run accounting (derived from the cron path). */
|
|
4
|
+
jobId: string;
|
|
5
|
+
/** Crontab expression (UTC), exactly as authored in the platform-runtime configuration. */
|
|
6
|
+
schedule: string;
|
|
7
|
+
/** The app endpoint a self-hosted scheduler invokes (Vercel cron hits this directly). */
|
|
8
|
+
path: string;
|
|
9
|
+
}
|
|
10
|
+
export interface PlatformRuntimeConfig {
|
|
11
|
+
crons?: Array<{
|
|
12
|
+
path: string;
|
|
13
|
+
schedule: string;
|
|
14
|
+
}>;
|
|
15
|
+
}
|
|
16
|
+
/** Derive the canonical jobId from a cron path: `/api/cron/nightly-report` -> `nightly-report`. */
|
|
17
|
+
export declare function jobIdFromPath(path: string): string;
|
|
18
|
+
/** Build the job registry from the deployment's platform-runtime configuration file. */
|
|
19
|
+
export declare function loadJobRegistry(configPath: string): ScheduledJobDeclaration[];
|
|
20
|
+
/** Same registry but built from an already-parsed config object (pure; no filesystem). */
|
|
21
|
+
export declare function jobRegistryFromConfig(config: PlatformRuntimeConfig): ScheduledJobDeclaration[];
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Scheduler job registry.
|
|
2
|
+
//
|
|
3
|
+
// SINGLE SOURCE: the cron job list is DERIVED from the deployment's platform-runtime
|
|
4
|
+
// configuration `crons` (the single source from which an adopter may generate a provider
|
|
5
|
+
// manifest), never a hand-copied list. Both scheduler adapters (hosted cron and node-cron) read
|
|
6
|
+
// this same registry so the set of jobs, their schedules, and their endpoint paths can never drift
|
|
7
|
+
// between providers.
|
|
8
|
+
//
|
|
9
|
+
// Pure: reads the configuration file the caller names, or an already-parsed object. No infra
|
|
10
|
+
// imports, no I/O against the network.
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
/** Derive the canonical jobId from a cron path: `/api/cron/nightly-report` -> `nightly-report`. */
|
|
13
|
+
export function jobIdFromPath(path) {
|
|
14
|
+
const trimmed = path.replace(/\/+$/, "");
|
|
15
|
+
const last = trimmed.split("/").filter(Boolean).pop();
|
|
16
|
+
if (!last) {
|
|
17
|
+
throw new Error(`scheduler: cannot derive jobId from cron path "${path}"`);
|
|
18
|
+
}
|
|
19
|
+
return last;
|
|
20
|
+
}
|
|
21
|
+
/** Build the job registry from the deployment's platform-runtime configuration file. */
|
|
22
|
+
export function loadJobRegistry(configPath) {
|
|
23
|
+
const raw = readFileSync(configPath, "utf8");
|
|
24
|
+
const config = JSON.parse(raw);
|
|
25
|
+
const crons = config.crons ?? [];
|
|
26
|
+
return crons.map((cron) => ({
|
|
27
|
+
jobId: jobIdFromPath(cron.path),
|
|
28
|
+
schedule: cron.schedule,
|
|
29
|
+
path: cron.path,
|
|
30
|
+
}));
|
|
31
|
+
}
|
|
32
|
+
/** Same registry but built from an already-parsed config object (pure; no filesystem). */
|
|
33
|
+
export function jobRegistryFromConfig(config) {
|
|
34
|
+
return (config.crons ?? []).map((cron) => ({
|
|
35
|
+
jobId: jobIdFromPath(cron.path),
|
|
36
|
+
schedule: cron.schedule,
|
|
37
|
+
path: cron.path,
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { JobLeasePort, PlatformJobFinishSummary, PlatformJobInvocation } from "@openlup/core/platform-runtime";
|
|
2
|
+
export interface ScheduledJobContext {
|
|
3
|
+
/** Who triggered this run; evidence only, never an authorization control. */
|
|
4
|
+
readonly invocation: PlatformJobInvocation;
|
|
5
|
+
/** The lease's run id when the job declares a lease. */
|
|
6
|
+
readonly runId?: string;
|
|
7
|
+
readonly signal?: AbortSignal;
|
|
8
|
+
}
|
|
9
|
+
export interface ScheduledJob {
|
|
10
|
+
readonly id: string;
|
|
11
|
+
/** Crontab expression (UTC). */
|
|
12
|
+
readonly schedule: string;
|
|
13
|
+
/** Claim a job-run lease of this many seconds before running. */
|
|
14
|
+
readonly lease?: {
|
|
15
|
+
readonly seconds: number;
|
|
16
|
+
};
|
|
17
|
+
run(context: ScheduledJobContext): Promise<PlatformJobFinishSummary | void> | PlatformJobFinishSummary | void;
|
|
18
|
+
}
|
|
19
|
+
export type ScheduledJobRunResult = {
|
|
20
|
+
readonly status: "completed";
|
|
21
|
+
readonly summary: PlatformJobFinishSummary | null;
|
|
22
|
+
} | {
|
|
23
|
+
readonly status: "skipped";
|
|
24
|
+
readonly reason: string;
|
|
25
|
+
};
|
|
26
|
+
/** Merge contributed jobs into one registry; refuses a duplicate id or an empty id or schedule. */
|
|
27
|
+
export declare function createScheduledJobRegistry(...contributions: ReadonlyArray<readonly ScheduledJob[]>): readonly ScheduledJob[];
|
|
28
|
+
/**
|
|
29
|
+
* The cron expression for a fixed cadence: a whole number of minutes that divides an hour, or a
|
|
30
|
+
* whole number of hours that divides a day. Any other cadence is refused rather than approximated.
|
|
31
|
+
*/
|
|
32
|
+
export declare function cronFromCadenceSeconds(cadenceSeconds: number): string;
|
|
33
|
+
/**
|
|
34
|
+
* Jobs for a package contribution's cadence schedules; each run passes the invocation through. A
|
|
35
|
+
* run that reports `{ outcome: "failed" }` instead of throwing fails the job, so neither transport
|
|
36
|
+
* reports it as completed.
|
|
37
|
+
*/
|
|
38
|
+
export declare function scheduledJobsFromContribution(schedules: ReadonlyArray<{
|
|
39
|
+
id: string;
|
|
40
|
+
cadenceSeconds: number;
|
|
41
|
+
run: (invocation: PlatformJobInvocation) => unknown;
|
|
42
|
+
}>): ScheduledJob[];
|
|
43
|
+
/** Run one job once, under its lease when it declares one. */
|
|
44
|
+
export declare function runScheduledJob(job: ScheduledJob, options: {
|
|
45
|
+
invocation: PlatformJobInvocation;
|
|
46
|
+
lease?: JobLeasePort;
|
|
47
|
+
signal?: AbortSignal;
|
|
48
|
+
}): Promise<ScheduledJobRunResult>;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Transport-neutral scheduled jobs.
|
|
2
|
+
//
|
|
3
|
+
// A job is `{ id, schedule, lease?, run(context) }`. Domains and packages contribute jobs to a
|
|
4
|
+
// registry; a host runs them on its own timer or through an authenticated HTTP cron entry, and both
|
|
5
|
+
// call the same `run`. The in-process path carries no HTTP secret. When a job declares a lease, the
|
|
6
|
+
// runner claims it through the deployment's job-lease port first and skips the run when another
|
|
7
|
+
// instance holds it; a job that fences its own work (for example the outbox) declares no lease.
|
|
8
|
+
const EMPTY_SUMMARY = { checked: 0, updated: 0, failures: 0, skipped: false };
|
|
9
|
+
/** Merge contributed jobs into one registry; refuses a duplicate id or an empty id or schedule. */
|
|
10
|
+
export function createScheduledJobRegistry(...contributions) {
|
|
11
|
+
const seen = new Set();
|
|
12
|
+
const jobs = [];
|
|
13
|
+
for (const job of contributions.flat()) {
|
|
14
|
+
if (!job.id?.trim())
|
|
15
|
+
throw new Error("scheduled_job_id_required");
|
|
16
|
+
if (!job.schedule?.trim())
|
|
17
|
+
throw new Error(`scheduled_job_schedule_required:${job.id}`);
|
|
18
|
+
if (seen.has(job.id))
|
|
19
|
+
throw new Error(`scheduled_job_duplicate:${job.id}`);
|
|
20
|
+
seen.add(job.id);
|
|
21
|
+
jobs.push(job);
|
|
22
|
+
}
|
|
23
|
+
return Object.freeze(jobs);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The cron expression for a fixed cadence: a whole number of minutes that divides an hour, or a
|
|
27
|
+
* whole number of hours that divides a day. Any other cadence is refused rather than approximated.
|
|
28
|
+
*/
|
|
29
|
+
export function cronFromCadenceSeconds(cadenceSeconds) {
|
|
30
|
+
if (Number.isInteger(cadenceSeconds) && cadenceSeconds > 0 && cadenceSeconds % 60 === 0) {
|
|
31
|
+
const minutes = cadenceSeconds / 60;
|
|
32
|
+
if (minutes < 60 && 60 % minutes === 0)
|
|
33
|
+
return minutes === 1 ? "* * * * *" : `*/${minutes} * * * *`;
|
|
34
|
+
if (minutes % 60 === 0) {
|
|
35
|
+
const hours = minutes / 60;
|
|
36
|
+
if (hours === 24)
|
|
37
|
+
return "0 0 * * *";
|
|
38
|
+
if (hours < 24 && 24 % hours === 0)
|
|
39
|
+
return hours === 1 ? "0 * * * *" : `0 */${hours} * * *`;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
throw new Error(`scheduled_job_cadence_unsupported:${cadenceSeconds}`);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Jobs for a package contribution's cadence schedules; each run passes the invocation through. A
|
|
46
|
+
* run that reports `{ outcome: "failed" }` instead of throwing fails the job, so neither transport
|
|
47
|
+
* reports it as completed.
|
|
48
|
+
*/
|
|
49
|
+
export function scheduledJobsFromContribution(schedules) {
|
|
50
|
+
return schedules.map((schedule) => ({
|
|
51
|
+
id: schedule.id,
|
|
52
|
+
schedule: cronFromCadenceSeconds(schedule.cadenceSeconds),
|
|
53
|
+
async run({ invocation }) {
|
|
54
|
+
const result = await schedule.run(invocation);
|
|
55
|
+
if (typeof result === "object" && result !== null && result.outcome === "failed") {
|
|
56
|
+
throw new Error(`scheduled_job_outcome_failed:${schedule.id}`);
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
61
|
+
/** Run one job once, under its lease when it declares one. */
|
|
62
|
+
export async function runScheduledJob(job, options) {
|
|
63
|
+
if (!job.lease) {
|
|
64
|
+
const summary = await job.run({ invocation: options.invocation, signal: options.signal });
|
|
65
|
+
return { status: "completed", summary: summary ?? null };
|
|
66
|
+
}
|
|
67
|
+
if (!options.lease)
|
|
68
|
+
throw new Error(`scheduled_job_lease_port_required:${job.id}`);
|
|
69
|
+
const claim = await options.lease.claimJobRun(job.id, options.invocation, job.lease.seconds);
|
|
70
|
+
if (!claim.acquired)
|
|
71
|
+
return { status: "skipped", reason: claim.reason };
|
|
72
|
+
if (!claim.runId)
|
|
73
|
+
throw new Error(`scheduled_job_lease_without_run_id:${job.id}`);
|
|
74
|
+
let summary;
|
|
75
|
+
try {
|
|
76
|
+
summary = await job.run({ invocation: options.invocation, runId: claim.runId, signal: options.signal });
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
try {
|
|
80
|
+
await options.lease.finishJobRun(job.id, claim.runId, options.invocation, "failed", { ...EMPTY_SUMMARY, failures: 1 });
|
|
81
|
+
}
|
|
82
|
+
catch (finishError) {
|
|
83
|
+
throw new AggregateError([error, finishError], `scheduled_job_failed_unfinished:${job.id}`);
|
|
84
|
+
}
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
const finished = await options.lease.finishJobRun(job.id, claim.runId, options.invocation, "success", summary ?? EMPTY_SUMMARY);
|
|
88
|
+
// A refused finish means the lease no longer belongs to this run; its result cannot be recorded.
|
|
89
|
+
if (!finished)
|
|
90
|
+
throw new Error(`scheduled_job_finish_refused:${job.id}`);
|
|
91
|
+
return { status: "completed", summary: summary ?? null };
|
|
92
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { SchedulerPort } from "@openlup/core/platform-runtime";
|
|
2
|
+
import type { ScheduledJobDeclaration } from "./jobRegistry.js";
|
|
3
|
+
import { type AdvisoryLock } from "./advisoryLock.js";
|
|
4
|
+
type CronEnv = Record<string, string | undefined> & {
|
|
5
|
+
CRON_SECRET?: string;
|
|
6
|
+
};
|
|
7
|
+
/** Minimal node-cron surface we depend on (keeps the package optional at type level). */
|
|
8
|
+
export interface CronScheduler {
|
|
9
|
+
schedule(expression: string, handler: () => void | Promise<void>, options?: {
|
|
10
|
+
timezone?: string;
|
|
11
|
+
}): {
|
|
12
|
+
stop: () => void;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export interface NodeSchedulerAdapter extends SchedulerPort {
|
|
16
|
+
readonly jobs: readonly ScheduledJobDeclaration[];
|
|
17
|
+
/** Stop all registered node-cron timers (graceful shutdown). */
|
|
18
|
+
stopAll(): void;
|
|
19
|
+
}
|
|
20
|
+
export interface CreateNodeSchedulerOptions {
|
|
21
|
+
/** node-cron instance (inject `import("node-cron")` at the host boot site). */
|
|
22
|
+
cron: CronScheduler;
|
|
23
|
+
/** Per-job advisory lock; defaults to a no-op (single-instance) — supply pg-backed in prod. */
|
|
24
|
+
lock?: AdvisoryLock;
|
|
25
|
+
/** Environment whose CRON_SECRET verifies an invocation. */
|
|
26
|
+
env: CronEnv;
|
|
27
|
+
/** The deployment's declared jobs (see `loadJobRegistry`). */
|
|
28
|
+
jobs: readonly ScheduledJobDeclaration[];
|
|
29
|
+
/** crontab timezone; jobs are authored in UTC to match Vercel. */
|
|
30
|
+
timezone?: string;
|
|
31
|
+
/** Structured error sink for failed job bodies (defaults to console.error). */
|
|
32
|
+
onError?: (jobId: string, error: unknown) => void;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Wrap a job body with the per-job advisory lock. Returns a guarded runner that:
|
|
36
|
+
* - tries to acquire the lock; if not acquired (another instance is running it) -> skip silently;
|
|
37
|
+
* - runs the body; always releases the lock in finally.
|
|
38
|
+
*/
|
|
39
|
+
export declare function withAdvisoryLock(jobId: string, lock: AdvisoryLock, body: () => Promise<void> | void, onError: (jobId: string, error: unknown) => void): () => Promise<void>;
|
|
40
|
+
/** Build the Node scheduler adapter backed by node-cron + per-job advisory locks. */
|
|
41
|
+
export declare function createNodeScheduler(options: CreateNodeSchedulerOptions): NodeSchedulerAdapter;
|
|
42
|
+
export {};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Node (self-hosted) SchedulerPort adapter.
|
|
2
|
+
//
|
|
3
|
+
// Unlike a managed host with an adopter-owned declarative cron manifest, a self-hosted Node bundle owns the timers itself.
|
|
4
|
+
// This adapter:
|
|
5
|
+
// 1. Takes its job set from the platform-runtime configuration `crons` (the SAME single source
|
|
6
|
+
// the managed-host adapter also reads), never a hand-copied list.
|
|
7
|
+
// 2. Registers each job with node-cron (injected lazily so `pg`/`node-cron` aren't required to
|
|
8
|
+
// typecheck or to run the default vercel-supabase path).
|
|
9
|
+
// 3. Wraps every job body in a PER-JOB advisory lock: multi-instance Node must not
|
|
10
|
+
// double-run a cron. Only the instance that acquires `pg_try_advisory_lock(<job>)` runs; others
|
|
11
|
+
// skip. The lock is released in a finally, even if the body throws.
|
|
12
|
+
import { verifyCronAuth } from "./vercelScheduler.js";
|
|
13
|
+
import { createNoopAdvisoryLock } from "./advisoryLock.js";
|
|
14
|
+
/**
|
|
15
|
+
* Wrap a job body with the per-job advisory lock. Returns a guarded runner that:
|
|
16
|
+
* - tries to acquire the lock; if not acquired (another instance is running it) -> skip silently;
|
|
17
|
+
* - runs the body; always releases the lock in finally.
|
|
18
|
+
*/
|
|
19
|
+
export function withAdvisoryLock(jobId, lock, body, onError) {
|
|
20
|
+
return async () => {
|
|
21
|
+
const acquired = await lock.tryAcquire(jobId);
|
|
22
|
+
if (!acquired) {
|
|
23
|
+
// Another instance holds the lock for this minute — skip (no double-run).
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
await body();
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
onError(jobId, error);
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
await lock.release(jobId);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/** Build the Node scheduler adapter backed by node-cron + per-job advisory locks. */
|
|
38
|
+
export function createNodeScheduler(options) {
|
|
39
|
+
const env = options.env;
|
|
40
|
+
const jobs = options.jobs;
|
|
41
|
+
const lock = options.lock ?? createNoopAdvisoryLock();
|
|
42
|
+
const onError = options.onError ??
|
|
43
|
+
((jobId, error) => {
|
|
44
|
+
console.error(`[node-scheduler] job "${jobId}" failed`, error);
|
|
45
|
+
});
|
|
46
|
+
const scheduleById = new Map(jobs.map((job) => [job.jobId, job.schedule]));
|
|
47
|
+
const timers = [];
|
|
48
|
+
return {
|
|
49
|
+
jobs,
|
|
50
|
+
register(input) {
|
|
51
|
+
// Prefer an explicit schedule; fall back to the single-source registry when the caller
|
|
52
|
+
// omits one (empty string or undefined both fall back).
|
|
53
|
+
const explicit = input.schedule?.trim();
|
|
54
|
+
const schedule = explicit && explicit.length > 0 ? explicit : scheduleById.get(input.jobId);
|
|
55
|
+
if (!schedule) {
|
|
56
|
+
throw new Error(`node-scheduler: job "${input.jobId}" is not declared in config/platform-runtime.json crons`);
|
|
57
|
+
}
|
|
58
|
+
const guarded = withAdvisoryLock(input.jobId, lock, input.handler, onError);
|
|
59
|
+
const timer = options.cron.schedule(schedule, guarded, { timezone: options.timezone });
|
|
60
|
+
timers.push({ jobId: input.jobId, stop: timer.stop });
|
|
61
|
+
},
|
|
62
|
+
verifyInvocation(req) {
|
|
63
|
+
// Self-hosted invocations still present the shared CRON_SECRET; reuse the canonical check.
|
|
64
|
+
return verifyCronAuth(req, env);
|
|
65
|
+
},
|
|
66
|
+
stopAll() {
|
|
67
|
+
for (const timer of timers) {
|
|
68
|
+
timer.stop();
|
|
69
|
+
}
|
|
70
|
+
timers.length = 0;
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|