@openlup/platform 0.0.0-stage → 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.
Files changed (160) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/LICENSE +204 -0
  3. package/README.md +125 -2
  4. package/dist/bff/dispatch.d.ts +27 -0
  5. package/dist/bff/dispatch.js +47 -0
  6. package/dist/bff/envelope.d.ts +46 -0
  7. package/dist/bff/envelope.js +80 -0
  8. package/dist/bff/health.d.ts +24 -0
  9. package/dist/bff/health.js +19 -0
  10. package/dist/bff/index.d.ts +6 -0
  11. package/dist/bff/index.js +6 -0
  12. package/dist/bff/lazyRoute.d.ts +4 -0
  13. package/dist/bff/lazyRoute.js +13 -0
  14. package/dist/bff/requestContext.d.ts +17 -0
  15. package/dist/bff/requestContext.js +26 -0
  16. package/dist/bff/routerRewrite.d.ts +2 -0
  17. package/dist/bff/routerRewrite.js +31 -0
  18. package/dist/blob/binding.d.ts +16 -0
  19. package/dist/blob/binding.js +37 -0
  20. package/dist/blob/filesystem.d.ts +8 -0
  21. package/dist/blob/filesystem.js +71 -0
  22. package/dist/blob/index.d.ts +6 -0
  23. package/dist/blob/index.js +6 -0
  24. package/dist/blob/s3.d.ts +32 -0
  25. package/dist/blob/s3.js +56 -0
  26. package/dist/blob/s3Client.d.ts +25 -0
  27. package/dist/blob/s3Client.js +76 -0
  28. package/dist/blob/supabase.d.ts +40 -0
  29. package/dist/blob/supabase.js +59 -0
  30. package/dist/blob/vercel.d.ts +63 -0
  31. package/dist/blob/vercel.js +69 -0
  32. package/dist/composition/composeBundle.d.ts +47 -0
  33. package/dist/composition/composeBundle.js +45 -0
  34. package/dist/composition/httpBinding.d.ts +8 -0
  35. package/dist/composition/httpBinding.js +18 -0
  36. package/dist/composition/index.d.ts +3 -0
  37. package/dist/composition/index.js +3 -0
  38. package/dist/composition/nodeHost.d.ts +103 -0
  39. package/dist/composition/nodeHost.js +145 -0
  40. package/dist/data/binding.d.ts +34 -0
  41. package/dist/data/binding.js +92 -0
  42. package/dist/data/index.d.ts +5 -0
  43. package/dist/data/index.js +5 -0
  44. package/dist/data/postgresGateway.d.ts +42 -0
  45. package/dist/data/postgresGateway.js +135 -0
  46. package/dist/data/queryBuilder.d.ts +54 -0
  47. package/dist/data/queryBuilder.js +240 -0
  48. package/dist/data/queryBuilderSql.d.ts +76 -0
  49. package/dist/data/queryBuilderSql.js +145 -0
  50. package/dist/data/supabaseClientFactory.d.ts +34 -0
  51. package/dist/data/supabaseClientFactory.js +56 -0
  52. package/dist/data/supabaseGateway.d.ts +22 -0
  53. package/dist/data/supabaseGateway.js +49 -0
  54. package/dist/http/deadline.d.ts +20 -0
  55. package/dist/http/deadline.js +93 -0
  56. package/dist/http/index.d.ts +6 -0
  57. package/dist/http/index.js +5 -0
  58. package/dist/http/requestAdapter.d.ts +38 -0
  59. package/dist/http/requestAdapter.js +203 -0
  60. package/dist/http/runtime.d.ts +83 -0
  61. package/dist/http/runtime.js +199 -0
  62. package/dist/http/security.d.ts +44 -0
  63. package/dist/http/security.js +114 -0
  64. package/dist/http/staticAssets.d.ts +3 -0
  65. package/dist/http/staticAssets.js +42 -0
  66. package/dist/http/types.d.ts +23 -0
  67. package/dist/http/types.js +6 -0
  68. package/dist/job-ledger/index.d.ts +5 -0
  69. package/dist/job-ledger/index.js +3 -0
  70. package/dist/job-ledger/ports.d.ts +10 -0
  71. package/dist/job-ledger/ports.js +7 -0
  72. package/dist/job-ledger/postgres.d.ts +10 -0
  73. package/dist/job-ledger/postgres.js +101 -0
  74. package/dist/job-ledger/sql.d.ts +7 -0
  75. package/dist/job-ledger/sql.js +10 -0
  76. package/dist/job-ledger/supabase.d.ts +77 -0
  77. package/dist/job-ledger/supabase.js +202 -0
  78. package/dist/kernel/index.d.ts +1 -0
  79. package/dist/kernel/index.js +1 -0
  80. package/dist/kernel/platformKernel.d.ts +34 -0
  81. package/dist/kernel/platformKernel.js +115 -0
  82. package/dist/migrations/index.d.ts +1 -0
  83. package/dist/migrations/index.js +1 -0
  84. package/dist/migrations/postgresRunner.d.ts +50 -0
  85. package/dist/migrations/postgresRunner.js +176 -0
  86. package/dist/scheduler/advisoryLock.d.ts +44 -0
  87. package/dist/scheduler/advisoryLock.js +114 -0
  88. package/dist/scheduler/cronGuard.d.ts +10 -0
  89. package/dist/scheduler/cronGuard.js +19 -0
  90. package/dist/scheduler/cronSecret.d.ts +5 -0
  91. package/dist/scheduler/cronSecret.js +23 -0
  92. package/dist/scheduler/handlers.d.ts +9 -0
  93. package/dist/scheduler/handlers.js +10 -0
  94. package/dist/scheduler/httpEntry.d.ts +16 -0
  95. package/dist/scheduler/httpEntry.js +31 -0
  96. package/dist/scheduler/index.d.ts +9 -0
  97. package/dist/scheduler/index.js +9 -0
  98. package/dist/scheduler/jobRegistry.d.ts +21 -0
  99. package/dist/scheduler/jobRegistry.js +39 -0
  100. package/dist/scheduler/jobs.d.ts +48 -0
  101. package/dist/scheduler/jobs.js +92 -0
  102. package/dist/scheduler/nodeScheduler.d.ts +42 -0
  103. package/dist/scheduler/nodeScheduler.js +73 -0
  104. package/dist/scheduler/vercelScheduler.d.ts +29 -0
  105. package/dist/scheduler/vercelScheduler.js +51 -0
  106. package/package.json +108 -4
  107. package/release-gates.json +121 -0
  108. package/sql/20260809150000_job_run_ledger.sql +301 -0
  109. package/sql/20260809150001_job_run_ledger_v3.sql +70 -0
  110. package/src/bff/dispatch.ts +81 -0
  111. package/src/bff/envelope.ts +142 -0
  112. package/src/bff/health.ts +37 -0
  113. package/src/bff/index.ts +30 -0
  114. package/src/bff/lazyRoute.ts +28 -0
  115. package/src/bff/requestContext.ts +48 -0
  116. package/src/bff/routerRewrite.ts +38 -0
  117. package/src/blob/binding.ts +55 -0
  118. package/src/blob/filesystem.ts +86 -0
  119. package/src/blob/index.ts +17 -0
  120. package/src/blob/s3.ts +87 -0
  121. package/src/blob/s3Client.ts +91 -0
  122. package/src/blob/supabase.ts +88 -0
  123. package/src/blob/vercel.ts +127 -0
  124. package/src/composition/composeBundle.ts +109 -0
  125. package/src/composition/httpBinding.ts +30 -0
  126. package/src/composition/index.ts +17 -0
  127. package/src/composition/nodeHost.ts +289 -0
  128. package/src/data/binding.ts +157 -0
  129. package/src/data/index.ts +33 -0
  130. package/src/data/postgresGateway.ts +186 -0
  131. package/src/data/queryBuilder.ts +300 -0
  132. package/src/data/queryBuilderSql.ts +183 -0
  133. package/src/data/supabaseClientFactory.ts +92 -0
  134. package/src/data/supabaseGateway.ts +76 -0
  135. package/src/http/deadline.ts +117 -0
  136. package/src/http/index.ts +36 -0
  137. package/src/http/requestAdapter.ts +215 -0
  138. package/src/http/runtime.ts +319 -0
  139. package/src/http/security.ts +155 -0
  140. package/src/http/staticAssets.ts +42 -0
  141. package/src/http/types.ts +31 -0
  142. package/src/job-ledger/index.ts +25 -0
  143. package/src/job-ledger/ports.ts +27 -0
  144. package/src/job-ledger/postgres.ts +121 -0
  145. package/src/job-ledger/sql.ts +12 -0
  146. package/src/job-ledger/supabase.ts +294 -0
  147. package/src/kernel/index.ts +17 -0
  148. package/src/kernel/platformKernel.ts +153 -0
  149. package/src/migrations/index.ts +10 -0
  150. package/src/migrations/postgresRunner.ts +255 -0
  151. package/src/scheduler/advisoryLock.ts +145 -0
  152. package/src/scheduler/cronGuard.ts +30 -0
  153. package/src/scheduler/cronSecret.ts +26 -0
  154. package/src/scheduler/handlers.ts +24 -0
  155. package/src/scheduler/httpEntry.ts +49 -0
  156. package/src/scheduler/index.ts +47 -0
  157. package/src/scheduler/jobRegistry.ts +57 -0
  158. package/src/scheduler/jobs.ts +119 -0
  159. package/src/scheduler/nodeScheduler.ts +123 -0
  160. package/src/scheduler/vercelScheduler.ts +71 -0
@@ -0,0 +1,255 @@
1
+ // Public-platform MigrationRunnerPort for the experimental node-postgres bundle.
2
+ // The manifest and its exact SQL bytes are validated before a pool is created.
3
+ // Applied rows are an exact prefix of that catalog; path, digest or position
4
+ // drift is fatal. Only the caller's catalogue is applied; no other migration
5
+ // history is selected or reachable through a default.
6
+
7
+ import type { Pool, PoolClient } from "pg";
8
+ import type { MigrationRunnerPort } from "@openlup/core/platform-runtime";
9
+
10
+ export interface PostgresMigrationRunnerEnv {
11
+ connectionString: string;
12
+ }
13
+
14
+ /** One migration file of the deployment's platform catalogue. */
15
+ export type PlatformMigrationFile = { file: string; content: string };
16
+ export type PlatformMigrationManifestEntry = { file: string; sha256: string };
17
+ /** The validated manifest: a baseline, its ordered forwards and the expected object inventory. */
18
+ export type PlatformMigrationManifest = {
19
+ schemaVersion: 1;
20
+ baseline: PlatformMigrationManifestEntry;
21
+ forward: PlatformMigrationManifestEntry[];
22
+ objectInventorySha256: string;
23
+ };
24
+
25
+ /**
26
+ * The deployment's migration catalogue and its validator. `evaluate` returns every refusal for a
27
+ * manifest against the catalogue files and, when given, the live object-inventory payload.
28
+ */
29
+ export interface PlatformMigrationSource {
30
+ readMigrations(root: string): { migrations: PlatformMigrationFile[]; errors: string[] };
31
+ readManifest(root: string): unknown | null;
32
+ evaluate(input: {
33
+ manifest: unknown;
34
+ migrations: readonly PlatformMigrationFile[];
35
+ objectInventoryPayload?: string;
36
+ }): string[];
37
+ }
38
+
39
+ export interface PostgresMigrationRunnerOptions {
40
+ /** Name of the PostgreSQL advisory lock that serializes migration sessions. */
41
+ lockName: string;
42
+ /** The deployment's migration catalogue. */
43
+ source: PlatformMigrationSource;
44
+ /** Serializes the live object inventory that the manifest's `objectInventorySha256` pins. */
45
+ catalogProjectionPayload: (client: PoolClient) => Promise<string>;
46
+ /** Catalogue root; defaults to process.cwd(). */
47
+ root?: string;
48
+ poolFactory?: (env: PostgresMigrationRunnerEnv) => Pool | Promise<Pool>;
49
+ }
50
+
51
+ const LEDGER = "public.platform_schema_migrations";
52
+ type PlannedMigration = Readonly<{ catalogPath: string; sha256: string; position: number; sql: string }>;
53
+ type LoadedPlan = Readonly<{
54
+ manifest: PlatformMigrationManifest;
55
+ migrations: readonly PlatformMigrationFile[];
56
+ units: readonly PlannedMigration[];
57
+ }>;
58
+ type LedgerRow = { catalog_path: unknown; sha256: unknown; position: unknown };
59
+ type LedgerExistenceRow = { ledger_exists: unknown };
60
+ type AdvisoryUnlockRow = { unlocked: unknown };
61
+ const ACQUIRE_MIGRATION_LOCK = "SELECT pg_advisory_lock(hashtextextended($1, 0))";
62
+ const RELEASE_MIGRATION_LOCK = "SELECT pg_advisory_unlock(hashtextextended($1, 0)) AS unlocked";
63
+
64
+ export function createPostgresMigrationRunner(
65
+ env: PostgresMigrationRunnerEnv,
66
+ options: PostgresMigrationRunnerOptions,
67
+ ): MigrationRunnerPort {
68
+ const root = options.root ?? process.cwd();
69
+ let pool: Pool | null = null;
70
+
71
+ async function getPool(): Promise<Pool> {
72
+ if (pool) return pool;
73
+ const factory = options.poolFactory ?? defaultPoolFactory;
74
+ pool = await factory(env);
75
+ return pool;
76
+ }
77
+
78
+ return {
79
+ async status(): Promise<{ pending: string[]; applied: string[] }> {
80
+ assertConnectionString(env);
81
+ const plan = loadPlan(root, options.source);
82
+ return withMigrationSession(await getPool(), options.lockName, async (client) => {
83
+ const applied = await readAppliedPrefix(client, plan.units);
84
+ if (applied.length === plan.units.length) await assertObjectInventory(client, plan, options);
85
+ return {
86
+ pending: plan.units.slice(applied.length).map((unit) => unit.catalogPath),
87
+ applied: applied.map((unit) => unit.catalogPath),
88
+ };
89
+ });
90
+ },
91
+
92
+ async apply(opts?: { dryRun?: boolean }): Promise<{ applied: string[] }> {
93
+ assertConnectionString(env);
94
+ const plan = loadPlan(root, options.source);
95
+ return withMigrationSession(await getPool(), options.lockName, async (client) => {
96
+ const already = await readAppliedPrefix(client, plan.units);
97
+ const pending = plan.units.slice(already.length);
98
+ if (opts?.dryRun) {
99
+ if (pending.length === 0) await assertObjectInventory(client, plan, options);
100
+ return { applied: pending.map((unit) => unit.catalogPath) };
101
+ }
102
+ const appliedNow: string[] = [];
103
+ for (const unit of pending) {
104
+ await client.query("BEGIN");
105
+ try {
106
+ await client.query(unit.sql);
107
+ await client.query(
108
+ `INSERT INTO ${LEDGER} (catalog_path, sha256, position) VALUES ($1, $2, $3)`,
109
+ [unit.catalogPath, unit.sha256, unit.position],
110
+ );
111
+ await client.query("COMMIT");
112
+ appliedNow.push(unit.catalogPath);
113
+ } catch (error) {
114
+ await client.query("ROLLBACK").catch(() => {});
115
+ throw new Error(`platform migration ${unit.catalogPath} failed: ${errorMessage(error)}`);
116
+ }
117
+ }
118
+ const complete = await readAppliedPrefix(client, plan.units);
119
+ if (complete.length !== plan.units.length) throw new Error("platform migration ledger is incomplete after apply");
120
+ await assertObjectInventory(client, plan, options);
121
+ return { applied: appliedNow };
122
+ });
123
+ },
124
+ };
125
+ }
126
+
127
+ async function withMigrationSession<T>(
128
+ pool: Pool,
129
+ lockName: string,
130
+ operation: (client: PoolClient) => Promise<T>,
131
+ ): Promise<T> {
132
+ const client = await pool.connect();
133
+ let locked = false;
134
+ let failed = false;
135
+ let operationFailure: unknown;
136
+ let result!: T;
137
+ try {
138
+ await client.query(ACQUIRE_MIGRATION_LOCK, [lockName]);
139
+ locked = true;
140
+ result = await operation(client);
141
+ } catch (error) {
142
+ failed = true;
143
+ operationFailure = error;
144
+ }
145
+
146
+ let unlockFailure: unknown;
147
+ if (locked) {
148
+ try {
149
+ const unlocked = await client.query<AdvisoryUnlockRow>(RELEASE_MIGRATION_LOCK, [lockName]);
150
+ if (unlocked.rows[0]?.unlocked !== true) throw new Error("PostgreSQL did not release the advisory lock");
151
+ } catch (error) {
152
+ unlockFailure = error;
153
+ }
154
+ }
155
+ client.release(unlockFailure !== undefined || (failed && !locked));
156
+
157
+ if (failed) {
158
+ if (unlockFailure !== undefined) {
159
+ throw new Error(
160
+ `platform migration operation failed: ${errorMessage(operationFailure)}; advisory lock release failed: ${errorMessage(unlockFailure)}`,
161
+ );
162
+ }
163
+ throw operationFailure;
164
+ }
165
+ if (unlockFailure !== undefined) {
166
+ throw new Error(`platform migration advisory lock release failed: ${errorMessage(unlockFailure)}`);
167
+ }
168
+ return result;
169
+ }
170
+
171
+ function assertConnectionString(env: PostgresMigrationRunnerEnv): void {
172
+ if (env.connectionString.trim() === "") {
173
+ throw new Error("DATABASE_URL is required for node-postgres migrations");
174
+ }
175
+ }
176
+
177
+ function loadPlan(root: string, source: PlatformMigrationSource): LoadedPlan {
178
+ const catalog = source.readMigrations(root);
179
+ const rawManifest = source.readManifest(root);
180
+ const errors = [...catalog.errors];
181
+ if (rawManifest === null) errors.push("platform migration manifest is missing");
182
+ else errors.push(...source.evaluate({ manifest: rawManifest, migrations: catalog.migrations }));
183
+ if (errors.length > 0) throw new Error(`platform migration catalog rejected: ${errors.join("; ")}`);
184
+
185
+ const manifest = rawManifest as PlatformMigrationManifest;
186
+ const byPath = new Map(catalog.migrations.map((migration) => [migration.file, migration.content]));
187
+ const entries = [manifest.baseline, ...manifest.forward];
188
+ const units = entries.map((entry, position) => Object.freeze({
189
+ catalogPath: entry.file,
190
+ sha256: entry.sha256,
191
+ position,
192
+ sql: byPath.get(entry.file)!,
193
+ }));
194
+ return Object.freeze({ manifest, migrations: catalog.migrations, units: Object.freeze(units) });
195
+ }
196
+
197
+ async function readAppliedPrefix(client: PoolClient, units: readonly PlannedMigration[]): Promise<readonly PlannedMigration[]> {
198
+ const exists = await client.query<LedgerExistenceRow>(
199
+ "SELECT to_regclass($1) IS NOT NULL AS ledger_exists",
200
+ [LEDGER],
201
+ );
202
+ if (exists.rows[0]?.ledger_exists !== true) return [];
203
+ const result = await client.query<LedgerRow>(
204
+ `SELECT catalog_path, sha256, position FROM ${LEDGER} ORDER BY position`,
205
+ );
206
+ if (result.rows.length > units.length) throw new Error("platform migration ledger contains an extra row");
207
+ for (let index = 0; index < result.rows.length; index += 1) {
208
+ const row = result.rows[index]!;
209
+ const unit = units[index]!;
210
+ if (row.catalog_path !== unit.catalogPath || row.sha256 !== unit.sha256 || row.position !== unit.position) {
211
+ throw new Error(`platform migration ledger is not the exact manifest prefix at position ${index}`);
212
+ }
213
+ }
214
+ return units.slice(0, result.rows.length);
215
+ }
216
+
217
+ async function assertObjectInventory(
218
+ client: PoolClient,
219
+ plan: LoadedPlan,
220
+ options: PostgresMigrationRunnerOptions,
221
+ ): Promise<void> {
222
+ const payload = await options.catalogProjectionPayload(client);
223
+ const errors = options.source.evaluate({
224
+ manifest: plan.manifest,
225
+ migrations: plan.migrations,
226
+ objectInventoryPayload: payload,
227
+ });
228
+ if (errors.length > 0) throw new Error(`platform migration inventory rejected: ${errors.join("; ")}`);
229
+ }
230
+
231
+ function errorMessage(error: unknown): string {
232
+ return error instanceof Error ? error.message : String(error);
233
+ }
234
+
235
+ async function defaultPoolFactory(env: PostgresMigrationRunnerEnv): Promise<Pool> {
236
+ const pg = await import("pg").catch(peerMissing);
237
+ const PoolCtor = (pg.default?.Pool ?? pg.Pool) as typeof import("pg").Pool;
238
+ return new PoolCtor({ connectionString: env.connectionString });
239
+ }
240
+
241
+ export function resolvePostgresMigrationRunnerEnv(
242
+ env: Record<string, string | undefined>,
243
+ ): PostgresMigrationRunnerEnv {
244
+ return { connectionString: env.DATABASE_URL ?? "" };
245
+ }
246
+
247
+ /** Translate only absence of this optional peer; preserve broken SDKs and other failures. */
248
+ function peerMissing(cause: unknown): never {
249
+ if (cause instanceof Error && "code" in cause
250
+ && (cause.code === "ERR_MODULE_NOT_FOUND" || cause.code === "MODULE_NOT_FOUND")
251
+ && /^Cannot find (?:package|module) ['"]pg['"]/.test(cause.message)) {
252
+ throw new Error("OPENLUP_E_PEER_MISSING: pg is required by the Postgres migration runner; install it alongside @openlup/platform", { cause });
253
+ }
254
+ throw cause;
255
+ }
@@ -0,0 +1,145 @@
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
+ /** A cluster-wide, per-job mutex. Returns true iff THIS caller acquired the lock. */
15
+ export interface AdvisoryLock {
16
+ /** Explicit capability marker; a host that must not run a job twice requires a cluster lock. */
17
+ readonly durability?: "cluster" | "process";
18
+ /** Try to acquire the lock for `jobId` without blocking. */
19
+ tryAcquire(jobId: string): Promise<boolean>;
20
+ /** Release a previously-acquired lock for `jobId`. */
21
+ release(jobId: string): Promise<void>;
22
+ /** Release the dedicated session when the host stops; safe to call repeatedly. */
23
+ close(): Promise<void>;
24
+ }
25
+
26
+ /** Minimal pg-query surface (a `pg` Pool/Client satisfies this) — keeps `pg` out of the import graph. */
27
+ export interface AdvisoryLockQuerier {
28
+ query<T = unknown>(text: string, values?: unknown[]): Promise<{ rows: T[] }>;
29
+ end?(): Promise<void>;
30
+ }
31
+
32
+ /**
33
+ * Deterministic 64-bit-ish key from a jobId for pg_try_advisory_lock(bigint).
34
+ * Postgres advisory locks key on bigint; we hash the job name to a stable signed-32-bit int
35
+ * (the single-arg `pg_try_advisory_lock(key bigint)` accepts it). Stable across processes.
36
+ */
37
+ export function advisoryLockKey(jobId: string): number {
38
+ // FNV-1a 32-bit, then coerce to signed 32-bit so it fits a Postgres int safely.
39
+ let hash = 0x811c9dc5;
40
+ for (let i = 0; i < jobId.length; i += 1) {
41
+ hash ^= jobId.charCodeAt(i);
42
+ hash = Math.imul(hash, 0x01000193);
43
+ }
44
+ return hash | 0;
45
+ }
46
+
47
+ /**
48
+ * pg-backed advisory lock. Uses `pg_try_advisory_lock` (session-scoped, non-blocking) on acquire
49
+ * and `pg_advisory_unlock` on release. NOTE: session-scoped locks require the same connection for
50
+ * acquire+release, so the supplied querier should be a single dedicated client/connection, not a
51
+ * pool that hands out a different connection per query.
52
+ */
53
+ export function createPgAdvisoryLock(
54
+ client: AdvisoryLockQuerier,
55
+ closeClient: () => Promise<void> = () => client.end?.() ?? Promise.resolve(),
56
+ ): AdvisoryLock {
57
+ let closePromise: Promise<void> | null = null;
58
+ return {
59
+ durability: "cluster",
60
+ async tryAcquire(jobId) {
61
+ const key = advisoryLockKey(jobId);
62
+ const { rows } = await client.query<{ locked: boolean }>(
63
+ "SELECT pg_try_advisory_lock($1) AS locked",
64
+ [key],
65
+ );
66
+ return rows[0]?.locked === true;
67
+ },
68
+ async release(jobId) {
69
+ const key = advisoryLockKey(jobId);
70
+ await client.query("SELECT pg_advisory_unlock($1)", [key]);
71
+ },
72
+ close() {
73
+ closePromise ??= closeClient();
74
+ return closePromise;
75
+ },
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Build the lock a long-running host should actually use, from the connection string the
81
+ * environment declares. Returns null when no connection string is configured, which leaves the
82
+ * scheduler on its always-granting default — the exact behaviour of a host with a single instance.
83
+ *
84
+ * The connection is opened LAZILY, on the first acquire, and then reused: a session-scoped
85
+ * try-lock must acquire and release on the SAME connection, and a host that never fires a timer
86
+ * must never open one. `pg` stays out of the static import graph (dynamic import), so the default
87
+ * hosted path and typecheck are untouched.
88
+ */
89
+ export function createEnvAdvisoryLock(
90
+ env: Record<string, string | undefined>,
91
+ connect: (connectionString: string) => Promise<AdvisoryLockQuerier> = connectDedicatedClient,
92
+ ): AdvisoryLock | null {
93
+ const connectionString = env.DATABASE_URL?.trim() ?? "";
94
+ if (connectionString === "") return null;
95
+ let pending: Promise<AdvisoryLockQuerier> | null = null;
96
+ let closed = false;
97
+ let closePromise: Promise<void> | null = null;
98
+ const querier: AdvisoryLockQuerier = {
99
+ async query<T = unknown>(text: string, values?: unknown[]): Promise<{ rows: T[] }> {
100
+ if (closed) throw new Error("advisory_lock_closed");
101
+ pending ??= connect(connectionString);
102
+ const client = await pending;
103
+ return client.query<T>(text, values);
104
+ },
105
+ };
106
+ return createPgAdvisoryLock(querier, () => {
107
+ closed = true;
108
+ closePromise ??= pending?.then((client) => client.end?.() ?? undefined) ?? Promise.resolve();
109
+ return closePromise;
110
+ });
111
+ }
112
+
113
+ async function connectDedicatedClient(connectionString: string): Promise<AdvisoryLockQuerier> {
114
+ const pg = await import("pg").catch(peerMissing);
115
+ const ClientCtor = (pg.default?.Client ?? pg.Client) as typeof import("pg").Client;
116
+ const client = new ClientCtor({ connectionString });
117
+ await client.connect();
118
+ return client as unknown as AdvisoryLockQuerier;
119
+ }
120
+
121
+ /** Lock that always grants (single-instance / tests). Never use in multi-instance Node deploys. */
122
+ export function createNoopAdvisoryLock(): AdvisoryLock {
123
+ return {
124
+ durability: "process",
125
+ async tryAcquire() {
126
+ return true;
127
+ },
128
+ async release() {
129
+ /* no-op */
130
+ },
131
+ async close() {
132
+ /* no-op */
133
+ },
134
+ };
135
+ }
136
+
137
+ /** Translate only absence of this optional peer; preserve broken SDKs and other failures. */
138
+ function peerMissing(cause: unknown): never {
139
+ if (cause instanceof Error && "code" in cause
140
+ && (cause.code === "ERR_MODULE_NOT_FOUND" || cause.code === "MODULE_NOT_FOUND")
141
+ && /^Cannot find (?:package|module) ['"]pg['"]/.test(cause.message)) {
142
+ throw new Error("OPENLUP_E_PEER_MISSING: pg is required by the Postgres scheduler advisory lock; install it alongside @openlup/platform", { cause });
143
+ }
144
+ throw cause;
145
+ }
@@ -0,0 +1,30 @@
1
+ import type { HttpRequest } from "../http/index.js";
2
+ import { bearerToken, cronSecretMatches } from "./cronSecret.js";
3
+
4
+ export { bearerToken };
5
+
6
+ export interface CronAuthDenied {
7
+ status: number;
8
+ body: Record<string, unknown>;
9
+ }
10
+
11
+ // Shared cron entrypoint guard: method allowlist + CRON_SECRET presence + constant-time bearer
12
+ // match (see ./cronSecret.ts). Returns a response to return verbatim when denied, or null when the
13
+ // request is authorized. Per-job flag kill-switches and provider/env readiness
14
+ // checks intentionally stay in each job — they differ per job and must run after
15
+ // this guard but before any ledger write.
16
+ export function authorizeCron(
17
+ req: HttpRequest,
18
+ env: { CRON_SECRET?: string },
19
+ ): CronAuthDenied | null {
20
+ if (req.method !== "GET" && req.method !== "POST") {
21
+ return { status: 405, body: { ok: false, error: "method_not_allowed" } };
22
+ }
23
+ if (!env.CRON_SECRET) {
24
+ return { status: 503, body: { ok: false, error: "cron_secret_required" } };
25
+ }
26
+ if (!cronSecretMatches(bearerToken(req), env.CRON_SECRET)) {
27
+ return { status: 401, body: { ok: false, error: "unauthorized" } };
28
+ }
29
+ return null;
30
+ }
@@ -0,0 +1,26 @@
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
+
8
+ import { timingSafeEqual } from "node:crypto";
9
+
10
+ import type { PlatformHttpRequest } from "@openlup/core/platform-runtime";
11
+
12
+ /** Extract the Bearer token from a request Authorization header (case-insensitive). */
13
+ export function bearerToken(req: PlatformHttpRequest): string | null {
14
+ const auth = req.headers.authorization;
15
+ const header = Array.isArray(auth) ? auth[0] : auth;
16
+ return header?.match(/^Bearer\s+(.+)$/i)?.[1]?.trim() ?? null;
17
+ }
18
+
19
+ /** True when the presented token equals the configured secret, compared in constant time. */
20
+ export function cronSecretMatches(presented: string | null, expected: string): boolean {
21
+ if (presented === null) return false;
22
+ const presentedBytes = Buffer.from(presented, "utf8");
23
+ const expectedBytes = Buffer.from(expected, "utf8");
24
+ if (presentedBytes.length !== expectedBytes.length) return false;
25
+ return timingSafeEqual(presentedBytes, expectedBytes);
26
+ }
@@ -0,0 +1,24 @@
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
+
8
+ export type ScheduledInvocationSource = "node_cron" | "scheduler";
9
+
10
+ export interface ScheduledJobInvocation {
11
+ jobId: string;
12
+ invocationSource: ScheduledInvocationSource;
13
+ }
14
+
15
+ export type ScheduledJobHandler = (invocation: ScheduledJobInvocation) => Promise<void> | void;
16
+
17
+ export type ScheduledJobHandlerRegistry = Readonly<Partial<Record<string, ScheduledJobHandler>>>;
18
+
19
+ /** Merge contributed bindings in order; a later contribution replaces an earlier one for the same job. */
20
+ export function mergeScheduledJobHandlers(
21
+ ...registries: ReadonlyArray<ScheduledJobHandlerRegistry | undefined>
22
+ ): ScheduledJobHandlerRegistry {
23
+ return Object.assign({}, ...registries.filter((registry) => registry !== undefined));
24
+ }
@@ -0,0 +1,49 @@
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
+
8
+ import type { JobLeasePort } from "@openlup/core/platform-runtime";
9
+
10
+ import type { HttpRequest, HttpResponse } from "../http/index.js";
11
+ import { authorizeCron } from "./cronGuard.js";
12
+ import { runScheduledJob, type ScheduledJob } from "./jobs.js";
13
+
14
+ export interface ScheduledJobHttpEntryOptions {
15
+ /** Environment whose CRON_SECRET authenticates the caller, read on each request. */
16
+ env: () => { CRON_SECRET?: string };
17
+ /** Evidence recorded for HTTP invocations, for example the hosting platform's cron. */
18
+ invocationSource: string;
19
+ /** The job-lease port, required when the job declares a lease. */
20
+ lease?: JobLeasePort;
21
+ /** Error sink for a failed run (defaults to console.error). */
22
+ onError?: (jobId: string, error: unknown) => void;
23
+ }
24
+
25
+ export function createScheduledJobHttpEntry(
26
+ job: ScheduledJob,
27
+ options: ScheduledJobHttpEntryOptions,
28
+ ): (req: HttpRequest, res: HttpResponse) => Promise<void> {
29
+ const onError = options.onError ?? ((jobId, error) => console.error(`[scheduled-job] "${jobId}" failed`, error));
30
+ return async function scheduledJobEntry(req, res) {
31
+ const denied = authorizeCron(req, options.env());
32
+ if (denied) {
33
+ res.status(denied.status).json(denied.body);
34
+ return;
35
+ }
36
+ try {
37
+ const result = await runScheduledJob(job, {
38
+ invocation: { triggerKind: "scheduler", invocationSource: options.invocationSource },
39
+ lease: options.lease,
40
+ });
41
+ res.status(200).json(result.status === "skipped"
42
+ ? { ok: true, skipped: true, reason: result.reason }
43
+ : { ok: true, result: result.summary });
44
+ } catch (error) {
45
+ onError(job.id, error);
46
+ res.status(500).json({ ok: false, error: "scheduled_job_failed" });
47
+ }
48
+ };
49
+ }
@@ -0,0 +1,47 @@
1
+ export {
2
+ jobIdFromPath,
3
+ jobRegistryFromConfig,
4
+ loadJobRegistry,
5
+ type PlatformRuntimeConfig,
6
+ type ScheduledJobDeclaration,
7
+ } from "./jobRegistry.js";
8
+ export {
9
+ advisoryLockKey,
10
+ createEnvAdvisoryLock,
11
+ createNoopAdvisoryLock,
12
+ createPgAdvisoryLock,
13
+ type AdvisoryLock,
14
+ type AdvisoryLockQuerier,
15
+ } from "./advisoryLock.js";
16
+ export {
17
+ createNodeScheduler,
18
+ withAdvisoryLock,
19
+ type CreateNodeSchedulerOptions,
20
+ type CronScheduler,
21
+ type NodeSchedulerAdapter,
22
+ } from "./nodeScheduler.js";
23
+ export {
24
+ bearerToken,
25
+ createVercelScheduler,
26
+ verifyCronAuth,
27
+ type VercelSchedulerAdapter,
28
+ } from "./vercelScheduler.js";
29
+ export { authorizeCron, type CronAuthDenied } from "./cronGuard.js";
30
+ export { cronSecretMatches } from "./cronSecret.js";
31
+ export {
32
+ mergeScheduledJobHandlers,
33
+ type ScheduledInvocationSource,
34
+ type ScheduledJobHandler,
35
+ type ScheduledJobHandlerRegistry,
36
+ type ScheduledJobInvocation,
37
+ } from "./handlers.js";
38
+ export {
39
+ createScheduledJobRegistry,
40
+ cronFromCadenceSeconds,
41
+ runScheduledJob,
42
+ scheduledJobsFromContribution,
43
+ type ScheduledJob,
44
+ type ScheduledJobContext,
45
+ type ScheduledJobRunResult,
46
+ } from "./jobs.js";
47
+ export { createScheduledJobHttpEntry, type ScheduledJobHttpEntryOptions } from "./httpEntry.js";
@@ -0,0 +1,57 @@
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
+
12
+ import { readFileSync } from "node:fs";
13
+
14
+ /** A scheduled job declared by path in the platform-runtime configuration (HTTP-invoked). */
15
+ export interface ScheduledJobDeclaration {
16
+ /** Stable job id used for advisory locking + job-run accounting (derived from the cron path). */
17
+ jobId: string;
18
+ /** Crontab expression (UTC), exactly as authored in the platform-runtime configuration. */
19
+ schedule: string;
20
+ /** The app endpoint a self-hosted scheduler invokes (Vercel cron hits this directly). */
21
+ path: string;
22
+ }
23
+
24
+ export interface PlatformRuntimeConfig {
25
+ crons?: Array<{ path: string; schedule: string }>;
26
+ }
27
+
28
+ /** Derive the canonical jobId from a cron path: `/api/cron/nightly-report` -> `nightly-report`. */
29
+ export function jobIdFromPath(path: string): string {
30
+ const trimmed = path.replace(/\/+$/, "");
31
+ const last = trimmed.split("/").filter(Boolean).pop();
32
+ if (!last) {
33
+ throw new Error(`scheduler: cannot derive jobId from cron path "${path}"`);
34
+ }
35
+ return last;
36
+ }
37
+
38
+ /** Build the job registry from the deployment's platform-runtime configuration file. */
39
+ export function loadJobRegistry(configPath: string): ScheduledJobDeclaration[] {
40
+ const raw = readFileSync(configPath, "utf8");
41
+ const config = JSON.parse(raw) as PlatformRuntimeConfig;
42
+ const crons = config.crons ?? [];
43
+ return crons.map((cron) => ({
44
+ jobId: jobIdFromPath(cron.path),
45
+ schedule: cron.schedule,
46
+ path: cron.path,
47
+ }));
48
+ }
49
+
50
+ /** Same registry but built from an already-parsed config object (pure; no filesystem). */
51
+ export function jobRegistryFromConfig(config: PlatformRuntimeConfig): ScheduledJobDeclaration[] {
52
+ return (config.crons ?? []).map((cron) => ({
53
+ jobId: jobIdFromPath(cron.path),
54
+ schedule: cron.schedule,
55
+ path: cron.path,
56
+ }));
57
+ }