@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.
Files changed (160) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/LICENSE +204 -0
  3. package/README.md +124 -1
  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 +106 -3
  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,47 @@
1
+ import type { AnalyticsPort, BlobStoragePort, DataGatewayPort, HttpRuntimePort, MigrationRunnerPort, SchedulerPort, TransactionalRuntimePort } from "@openlup/core/platform-runtime";
2
+ import { type PlatformBundleDescriptor, type PlatformBundleId } from "../kernel/index.js";
3
+ type Env = Record<string, string | undefined>;
4
+ export interface PlatformBundlePorts {
5
+ http: HttpRuntimePort | null;
6
+ scheduler: SchedulerPort | null;
7
+ blob: BlobStoragePort | null;
8
+ data: DataGatewayPort | null;
9
+ migrations: MigrationRunnerPort | null;
10
+ analytics: AnalyticsPort | null;
11
+ transactional: TransactionalRuntimePort | null;
12
+ }
13
+ export interface PlatformBundle {
14
+ id: PlatformBundleId;
15
+ descriptor: PlatformBundleDescriptor;
16
+ ports: PlatformBundlePorts;
17
+ }
18
+ /**
19
+ * One binder per port slot. Each receives the slot's capability and the environment; the blob
20
+ * binder receives the selected blob capability (`PLATFORM_BLOB`, else the bundle's own), and the
21
+ * transactional binder also receives the bundle's data capability.
22
+ */
23
+ export interface PlatformBundleBinders {
24
+ /**
25
+ * The supabase-js `createClient` used by the default Supabase Storage blob binder. Declared
26
+ * structurally here so the composition entry's declarations reference no data-module type.
27
+ */
28
+ createSupabaseClient?: (url: string, key: string, options?: {
29
+ auth?: {
30
+ persistSession?: boolean;
31
+ autoRefreshToken?: boolean;
32
+ };
33
+ }) => unknown;
34
+ http?: (capability: string, env: Env) => HttpRuntimePort | null;
35
+ scheduler?: (capability: string, env: Env) => SchedulerPort | null;
36
+ /** Defaults to `bindBlobPort`. */
37
+ blob?: (capability: string, env: Env) => BlobStoragePort | null;
38
+ /** For example `createDataBindings(...).bindDataPort`. */
39
+ data?: (capability: string, env: Env) => DataGatewayPort | null;
40
+ migrations?: (capability: string, env: Env) => MigrationRunnerPort | null;
41
+ analytics?: (capability: string, env: Env) => AnalyticsPort | null;
42
+ transactional?: (capability: string, dataCapability: string, env: Env) => TransactionalRuntimePort | null;
43
+ }
44
+ export type ComposeBundle = (env: Env) => PlatformBundle;
45
+ /** Build the deployment's composition root from its port binders. */
46
+ export declare function createBundleComposer(binders?: PlatformBundleBinders): ComposeBundle;
47
+ export {};
@@ -0,0 +1,45 @@
1
+ // Platform-runtime bundle composition.
2
+ //
3
+ // Resolves the active bundle from the environment and returns its descriptor and port table. Each
4
+ // port slot is filled by one binder, keyed by the bundle's declared capability for that slot. A
5
+ // slot without a binder, or whose binder declines the capability, stays null: callers treat an
6
+ // unbound port as "use the deployment's direct path", never as a silent substitute.
7
+ //
8
+ // The package supplies a default binder for the blob slot. The HTTP, scheduler, data, migrations,
9
+ // analytics and transactional slots carry deployment values (route tables, jobs, the database
10
+ // client factory, migration catalogue, analytics provider, delivery runtime), so the deployment
11
+ // supplies them; `createDataBindings` builds the data binder.
12
+ import { bindBlobPort } from "../blob/index.js";
13
+ import { createServiceClient, readSupabaseDataGatewayEnv } from "../data/index.js";
14
+ import { getBundleDescriptor, resolveBlobCapability, resolveBundleId, } from "../kernel/index.js";
15
+ /** Build the deployment's composition root from its port binders. */
16
+ export function createBundleComposer(binders = {}) {
17
+ const bindBlob = binders.blob ?? ((capability, env) => {
18
+ const settings = capability === "supabase" ? readSupabaseDataGatewayEnv({
19
+ ...env,
20
+ SUPABASE_URL: env.SUPABASE_URL?.trim() || env.VITE_SUPABASE_URL?.trim(),
21
+ }) : null;
22
+ const createSupabaseClient = binders.createSupabaseClient;
23
+ return bindBlobPort(capability, env, {
24
+ supabaseStorageClient: settings && createSupabaseClient
25
+ ? () => createServiceClient(settings, createSupabaseClient)
26
+ : undefined,
27
+ });
28
+ });
29
+ return function composeBundle(env) {
30
+ const id = resolveBundleId(env);
31
+ const descriptor = getBundleDescriptor(id);
32
+ const { capabilities } = descriptor;
33
+ const ports = {
34
+ http: binders.http?.(capabilities.http, env) ?? null,
35
+ scheduler: null,
36
+ blob: bindBlob(resolveBlobCapability(descriptor, env), env),
37
+ data: binders.data?.(capabilities.data, env) ?? null,
38
+ migrations: binders.migrations?.(capabilities.migrations, env) ?? null,
39
+ analytics: binders.analytics?.(capabilities.analytics, env) ?? null,
40
+ transactional: binders.transactional?.(capabilities.transactional, capabilities.data, env) ?? null,
41
+ };
42
+ ports.scheduler = binders.scheduler?.(capabilities.scheduler, env) ?? null;
43
+ return { id, descriptor, ports };
44
+ };
45
+ }
@@ -0,0 +1,8 @@
1
+ import type { HttpRuntimePort } from "@openlup/core/platform-runtime";
2
+ import type { EntrypointLoader } from "../http/index.js";
3
+ import { type NodeHttpRuntimeOptions } from "../http/index.js";
4
+ import type { ScheduledJobDeclaration } from "../scheduler/index.js";
5
+ /** Mount only the scheduled routes declared by the job registry. */
6
+ export declare function createScheduledEntrypointResolver(jobs: readonly ScheduledJobDeclaration[], load: (jobId: string) => ReturnType<EntrypointLoader>): (jobId: string) => EntrypointLoader | null;
7
+ /** Build the http port for a bundle capability ("node" => Node runtime; else null). */
8
+ export declare function bindNodeHttpPort(capability: string, options: () => NodeHttpRuntimeOptions): HttpRuntimePort | null;
@@ -0,0 +1,18 @@
1
+ // HTTP slot binding for the Node runtime.
2
+ //
3
+ // Only a bundle whose `http` capability is "node" receives a Node runtime; a hosted bundle keeps
4
+ // `http: null` and serves its route table through its own function entrypoint. The scheduled-route
5
+ // resolver mounts exactly the jobs the deployment declared, so an undeclared `/api/cron/<id>`
6
+ // path stays a 404 and a declared job without an entrypoint fails visibly when called.
7
+ import { createNodeHttpRuntime } from "../http/index.js";
8
+ /** Mount only the scheduled routes declared by the job registry. */
9
+ export function createScheduledEntrypointResolver(jobs, load) {
10
+ const declared = new Set(jobs.map((job) => job.jobId));
11
+ return (jobId) => declared.has(jobId) ? () => load(jobId) : null;
12
+ }
13
+ /** Build the http port for a bundle capability ("node" => Node runtime; else null). */
14
+ export function bindNodeHttpPort(capability, options) {
15
+ if (capability !== "node")
16
+ return null;
17
+ return createNodeHttpRuntime(options());
18
+ }
@@ -0,0 +1,3 @@
1
+ export { createBundleComposer, type ComposeBundle, type PlatformBundle, type PlatformBundleBinders, type PlatformBundlePorts, } from "./composeBundle.js";
2
+ export { bindNodeHttpPort, createScheduledEntrypointResolver } from "./httpBinding.js";
3
+ export { createNodeHost, type NodeHost, type NodeHostAdmission, type NodeHostDependencies, type NodeSchedulerProfile, type ServeNodeOptions, type ServeNodeResult, } from "./nodeHost.js";
@@ -0,0 +1,3 @@
1
+ export { createBundleComposer, } from "./composeBundle.js";
2
+ export { bindNodeHttpPort, createScheduledEntrypointResolver } from "./httpBinding.js";
3
+ export { createNodeHost, } from "./nodeHost.js";
@@ -0,0 +1,103 @@
1
+ import type { HttpRuntimePort, JobLeasePort } from "@openlup/core/platform-runtime";
2
+ import type { StaticAssetReader } from "../http/index.js";
3
+ import { type AdvisoryLock, type CronScheduler, type ScheduledJob, type ScheduledJobDeclaration, type ScheduledJobHandler, type ScheduledJobHandlerRegistry } from "../scheduler/index.js";
4
+ import type { ComposeBundle, PlatformBundlePorts } from "./composeBundle.js";
5
+ type Env = Record<string, string | undefined>;
6
+ /** What the host hands its admission check before it serves or schedules anything. */
7
+ export interface NodeHostAdmission {
8
+ kind: "node";
9
+ nodeJobHandlers: ScheduledJobHandlerRegistry;
10
+ /** The path-declared jobs this process will time; empty when it does not run the scheduler. */
11
+ nodeJobs: readonly ScheduledJobDeclaration[];
12
+ /** The registry jobs this process will run directly; empty when it does not run the scheduler. */
13
+ scheduledJobs: readonly ScheduledJob[];
14
+ }
15
+ export interface NodeHostDependencies {
16
+ /** The deployment's composition root (see `createBundleComposer`). */
17
+ composeBundle: ComposeBundle;
18
+ /**
19
+ * Build the HTTP runtime this host serves, for the declared jobs and its static reader. The
20
+ * bundle's `http` port only proves that the bundle is a Node bundle at readiness time.
21
+ */
22
+ createHttpRuntime: (input: {
23
+ schedulerJobs: readonly ScheduledJobDeclaration[];
24
+ staticReader: StaticAssetReader;
25
+ /** The composed ports (blob storage, data gateway and the rest) for routes to use. */
26
+ ports: PlatformBundlePorts;
27
+ }) => HttpRuntimePort;
28
+ /** The deployment's declared jobs, used when the caller passes none. */
29
+ loadSchedulerJobs: () => readonly ScheduledJobDeclaration[];
30
+ /** Transport-neutral jobs contributed to the registry; run in-process with no HTTP secret. */
31
+ scheduledJobs?: (env: Env, ports: PlatformBundlePorts) => readonly ScheduledJob[];
32
+ /** The job-lease port for registry jobs that declare a lease. */
33
+ jobLease?: (env: Env) => JobLeasePort;
34
+ /** Direct bindings the deployment and its domains contribute; caller overrides win. */
35
+ scheduledJobHandlers?: (env: Env) => ScheduledJobHandlerRegistry;
36
+ /** Runs first, before readiness (for example, process-wide configuration checks). */
37
+ prepareHost?: (env: Env) => void;
38
+ /** Admits the host before it serves or schedules; a rejection stops the boot. */
39
+ admitHost?: (env: Env, admission: NodeHostAdmission) => Promise<void>;
40
+ /** Operational log sink (defaults to console.log). */
41
+ log?: (message: string) => void;
42
+ }
43
+ /**
44
+ * A deployment's scheduler profile: a fenced projection of the declared jobs with its own
45
+ * validation, applied at the host's boot boundaries. Each hook may throw to stop the boot.
46
+ */
47
+ export interface NodeSchedulerProfile {
48
+ /** Replaces the declared jobs with this profile's projection. Runs after any migration replay. */
49
+ projectJobs(env: Env, jobs: readonly ScheduledJobDeclaration[]): readonly ScheduledJobDeclaration[];
50
+ /** Direct bindings the profile adds; they replace the deployment's and the caller's for a job. */
51
+ handlers?(env: Env): ScheduledJobHandlerRegistry;
52
+ /** Runs once the advisory lock is resolved and before any job is registered. */
53
+ validateSchedule?(input: {
54
+ lock: AdvisoryLock | undefined;
55
+ jobs: readonly ScheduledJobDeclaration[];
56
+ overrides: ScheduledJobHandlerRegistry | undefined;
57
+ }): void;
58
+ /**
59
+ * Runs for each job, in order, immediately before it is registered, with its direct binding if
60
+ * any. A refusal stops the boot; jobs registered before it keep their timers, as the boot fails.
61
+ */
62
+ validateJob?(job: ScheduledJobDeclaration, directHandler: ScheduledJobHandler | undefined): void;
63
+ }
64
+ export interface ServeNodeOptions {
65
+ /** The process environment of this host. */
66
+ env: Env;
67
+ /** dist/ root for static serving (defaults to <cwd>/dist). */
68
+ distDir?: string;
69
+ /** node-cron instance; injected so the dependency stays optional for typecheck and tests. */
70
+ cron?: CronScheduler;
71
+ /** Direct bindings added over the deployment's own for this boot. */
72
+ scheduledJobHandlers?: ScheduledJobHandlerRegistry;
73
+ /** Explicit scheduler projection; production uses the deployment's job registry. */
74
+ schedulerJobs?: readonly ScheduledJobDeclaration[];
75
+ /** Optional scheduler profile applied to the declared jobs (see `NodeSchedulerProfile`). */
76
+ schedulerProfile?: NodeSchedulerProfile;
77
+ /**
78
+ * Per-job cluster mutex. Defaults to the one the environment's connection string describes, so a
79
+ * host that runs more than one instance stops executing every timer once per instance.
80
+ */
81
+ advisoryLock?: AdvisoryLock;
82
+ /** Skip binding the network listener (unit tests build the runtime without opening a socket). */
83
+ listen?: boolean;
84
+ port?: number;
85
+ host?: string;
86
+ }
87
+ export interface ServeNodeResult {
88
+ bundleId: string;
89
+ httpRuntime: HttpRuntimePort;
90
+ registeredJobs: string[];
91
+ /** Stop timers and the local listener after a host signal; safe to call repeatedly. */
92
+ stop(): Promise<void>;
93
+ }
94
+ export interface NodeHost {
95
+ /** Readiness gate: the active bundle's descriptor decides, and the bundle must bind a Node runtime. */
96
+ assertBundleReady(env: Env): {
97
+ bundleId: string;
98
+ };
99
+ /** Build (and optionally start) the Node host. */
100
+ serveNode(options: ServeNodeOptions): Promise<ServeNodeResult>;
101
+ }
102
+ export declare function createNodeHost(deps: NodeHostDependencies): NodeHost;
103
+ export {};
@@ -0,0 +1,145 @@
1
+ // Node (self-hosted) host bootstrap.
2
+ //
3
+ // Boots a Node platform bundle: prepare the host, check the bundle's readiness, apply the
4
+ // node-postgres migration replay before serving, admit the host, build the HTTP runtime, register
5
+ // the declared jobs with node-cron, and listen. A bundle whose migrations are managed outside the
6
+ // process (node-supabase) is expected to be migrated before the container starts.
7
+ //
8
+ // The deployment supplies its composition root, HTTP runtime options, job registry, direct job
9
+ // bindings and host admission; this module owns the order and the failure modes.
10
+ import { resolve } from "node:path";
11
+ import { createDistStaticReader } from "../http/index.js";
12
+ import { createEnvAdvisoryLock, createNodeScheduler, mergeScheduledJobHandlers, runScheduledJob, } from "../scheduler/index.js";
13
+ export function createNodeHost(deps) {
14
+ const log = deps.log ?? ((message) => console.log(message));
15
+ function assertBundleReady(env) {
16
+ const bundle = deps.composeBundle(env);
17
+ const readiness = bundle.descriptor.readReadiness(env);
18
+ if (!readiness.ok) {
19
+ throw new Error(`[serve.node] bundle "${bundle.id}" not ready: ${readiness.error}`);
20
+ }
21
+ if (!bundle.ports.http) {
22
+ throw new Error(`[serve.node] bundle "${bundle.id}" has no http runtime (expected node bundle)`);
23
+ }
24
+ return { bundleId: bundle.id };
25
+ }
26
+ async function serveNode(options) {
27
+ const env = options.env;
28
+ deps.prepareHost?.(env);
29
+ const { bundleId } = assertBundleReady(env);
30
+ // Deploy parity (migrate -> health -> serve): the node-postgres bundle has no external migration
31
+ // step, so it applies the composed MigrationRunnerPort BEFORE serving. Gated on the data
32
+ // capability so managed-schema bundles are untouched. A listening node-postgres process has no
33
+ // bypass: unit tests that need no database use the non-listening seam.
34
+ const bundle = deps.composeBundle(env);
35
+ const shouldMigrate = options.listen !== false &&
36
+ bundle.descriptor.capabilities.data === "postgres" &&
37
+ bundle.ports.migrations !== null;
38
+ if (shouldMigrate) {
39
+ const { applied } = await bundle.ports.migrations.apply();
40
+ log(`[serve.node] bundle=${bundleId} applied ${applied.length} migration(s) before serve`);
41
+ }
42
+ const distDir = options.distDir ?? resolve(process.cwd(), "dist");
43
+ const staticReader = createDistStaticReader(distDir);
44
+ const declaredSchedulerJobs = options.schedulerJobs ?? deps.loadSchedulerJobs();
45
+ const profile = options.schedulerProfile;
46
+ const schedulerJobs = profile ? profile.projectJobs(env, declaredSchedulerJobs) : declaredSchedulerJobs;
47
+ const handlers = mergeScheduledJobHandlers(deps.scheduledJobHandlers?.(env), options.scheduledJobHandlers, profile?.handlers?.(env));
48
+ const registryJobs = deps.scheduledJobs?.(env, bundle.ports) ?? [];
49
+ for (const job of registryJobs) {
50
+ if (schedulerJobs.some((declared) => declared.jobId === job.id)) {
51
+ throw new Error(`scheduled_job_declared_twice:${job.id}`);
52
+ }
53
+ }
54
+ if (deps.admitHost && (options.listen !== false || options.cron)) {
55
+ await deps.admitHost(env, {
56
+ kind: "node",
57
+ nodeJobHandlers: handlers,
58
+ nodeJobs: options.cron ? schedulerJobs : [],
59
+ scheduledJobs: options.cron ? registryJobs : [],
60
+ });
61
+ }
62
+ const httpRuntime = deps.createHttpRuntime({ schedulerJobs, staticReader, ports: bundle.ports });
63
+ // Node cron invokes a direct binding where one exists. Every other job takes the HTTP
64
+ // compatibility bridge to its own entrypoint, which the runtime mounts from the same registry,
65
+ // so a routing miss cannot masquerade as a scheduler failure and a job without an entrypoint
66
+ // fails visibly. The scheduler holds the cluster mutex: without it every instance of a
67
+ // horizontally scaled host fires the same timer in the same minute.
68
+ const registeredJobs = [];
69
+ let scheduler = null;
70
+ let schedulerLock;
71
+ if (options.cron) {
72
+ schedulerLock = options.advisoryLock ?? createEnvAdvisoryLock(env) ?? undefined;
73
+ scheduler = createNodeScheduler({
74
+ cron: options.cron,
75
+ env,
76
+ jobs: schedulerJobs,
77
+ lock: schedulerLock,
78
+ });
79
+ profile?.validateSchedule?.({
80
+ lock: schedulerLock,
81
+ jobs: scheduler.jobs,
82
+ overrides: options.scheduledJobHandlers,
83
+ });
84
+ const baseUrl = env.APP_BASE_URL ?? "http://localhost:3000";
85
+ const cronSecret = env.CRON_SECRET ?? "";
86
+ for (const job of scheduler.jobs) {
87
+ const directHandler = handlers[job.jobId];
88
+ profile?.validateJob?.(job, directHandler);
89
+ scheduler.register({
90
+ jobId: job.jobId,
91
+ schedule: job.schedule,
92
+ handler: async () => {
93
+ if (directHandler) {
94
+ await directHandler({ jobId: job.jobId, invocationSource: "node_cron" });
95
+ return;
96
+ }
97
+ const response = await fetch(new URL(job.path, baseUrl).toString(), {
98
+ method: "GET",
99
+ headers: cronSecret ? { Authorization: `Bearer ${cronSecret}` } : {},
100
+ });
101
+ if (!response.ok) {
102
+ throw new Error(`node scheduler compatibility bridge for "${job.jobId}" returned HTTP ${response.status}`);
103
+ }
104
+ },
105
+ });
106
+ registeredJobs.push(job.jobId);
107
+ }
108
+ const lease = registryJobs.some((job) => job.lease) ? deps.jobLease?.(env) : undefined;
109
+ for (const job of registryJobs) {
110
+ scheduler.register({
111
+ jobId: job.id,
112
+ schedule: job.schedule,
113
+ handler: async () => {
114
+ await runScheduledJob(job, { invocation: { triggerKind: "scheduler", invocationSource: "node_cron" }, lease });
115
+ },
116
+ });
117
+ registeredJobs.push(job.id);
118
+ }
119
+ }
120
+ if (options.listen !== false) {
121
+ const port = options.port ?? Number(env.PORT ?? 3000);
122
+ await httpRuntime.listen?.({ port, host: options.host ?? "0.0.0.0" });
123
+ log(`[serve.node] bundle=${bundleId} listening on :${port} (${registeredJobs.length} cron jobs)`);
124
+ }
125
+ let stopPromise = null;
126
+ return {
127
+ bundleId,
128
+ httpRuntime,
129
+ registeredJobs,
130
+ stop: () => (stopPromise ??= stopNodeHost(scheduler, schedulerLock, httpRuntime)),
131
+ };
132
+ }
133
+ return { assertBundleReady, serveNode };
134
+ }
135
+ async function stopNodeHost(scheduler, advisoryLock, httpRuntime) {
136
+ const timers = Promise.resolve().then(() => scheduler?.stopAll());
137
+ const server = httpRuntime.server;
138
+ const listener = server
139
+ ? new Promise((resolveClose, reject) => server.close((error) => error ? reject(error) : resolveClose()))
140
+ : Promise.resolve();
141
+ const results = await Promise.allSettled([timers, advisoryLock?.close(), listener]);
142
+ const failures = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
143
+ if (failures.length > 0)
144
+ throw new AggregateError(failures, "node_host_shutdown_failed");
145
+ }
@@ -0,0 +1,34 @@
1
+ import { type PostgresDataGatewayEnv } from "./postgresGateway.js";
2
+ import type { DataGatewayPort } from "@openlup/core/platform-runtime";
3
+ import type { SupabaseCreateClient } from "./supabaseClientFactory.js";
4
+ type Env = Record<string, string | undefined>;
5
+ type ActorClaims = {
6
+ sub?: string;
7
+ role?: string;
8
+ } | null;
9
+ /** Actor-only data access for one request. */
10
+ export type ActorOnlyDataPort = {
11
+ asActor: <T>(claims: ActorClaims, work: (gateway: unknown) => Promise<T>) => Promise<T>;
12
+ };
13
+ export interface DataPortBindingOptions {
14
+ /** Replaces the bindings' supabase-js `createClient` for this call (tests). */
15
+ createClientImpl?: SupabaseCreateClient;
16
+ resolveAccessToken?: (claims: ActorClaims) => string | null;
17
+ }
18
+ export interface DataBindingDependencies {
19
+ /** The supabase-js `createClient` used by every supabase gateway these bindings build. */
20
+ createSupabaseClient: SupabaseCreateClient;
21
+ /** Builds the direct-PostgreSQL gateway; defaults to `createPostgresDataGateway`. */
22
+ createPostgresGateway?: (env: PostgresDataGatewayEnv) => DataGatewayPort;
23
+ }
24
+ export interface DataBindings {
25
+ /** Resolve the DataGatewayPort for a bundle's declared data capability kind. */
26
+ bindDataPort(dataKind: string, env: Env, requestOptions?: DataPortBindingOptions): DataGatewayPort | null;
27
+ /** Bind one request to actor-only data access. */
28
+ bindRequestActorDataPort(accessToken: string, env: Env, requestOptions?: DataPortBindingOptions): ActorOnlyDataPort | null;
29
+ /** Resolve and bind the active bundle's data gateway, cached per bundle and data configuration. */
30
+ bindBundleDataPort(env: Env): DataGatewayPort | null;
31
+ }
32
+ /** Create one set of data bindings; the bundle data port cache belongs to this set. */
33
+ export declare function createDataBindings(deps: DataBindingDependencies): DataBindings;
34
+ export {};
@@ -0,0 +1,92 @@
1
+ // DataGatewayPort bindings for bundle composition.
2
+ //
3
+ // Maps a bundle's declared `data` capability to a concrete DataGatewayPort. The supabase gateway is
4
+ // constructed eagerly (pure factory; no client/network until a port method runs work), so the
5
+ // default vercel-supabase path binds the slot with no secrets needed: env is read with
6
+ // empty-string fallbacks and validated lazily inside asActor/asService. The application supplies
7
+ // the supabase-js `createClient` once, when it creates its bindings.
8
+ //
9
+ // The `postgres` capability (node-postgres) uses the existing direct-PG DataGatewayPort. Its
10
+ // PgGatewayClient exposes the same narrow RPC surface used by Supabase-backed domain ports.
11
+ import { getBundleDescriptor, resolveBundleId, } from "../kernel/index.js";
12
+ import { createSupabaseDataGateway } from "./supabaseGateway.js";
13
+ import { createPostgresDataGateway, resolvePostgresDataGatewayEnv, } from "./postgresGateway.js";
14
+ /** Create one set of data bindings; the bundle data port cache belongs to this set. */
15
+ export function createDataBindings(deps) {
16
+ let cachedBundleDataPort = null;
17
+ const createPostgresGateway = deps.createPostgresGateway ?? ((env) => createPostgresDataGateway(env));
18
+ /** Resolve the DataGatewayPort for a bundle's declared data capability kind. */
19
+ function bindDataPort(dataKind, env, requestOptions = {}) {
20
+ switch (dataKind) {
21
+ case "supabase":
22
+ // Construct from env with empty-string fallbacks; the actual supabase client is created
23
+ // lazily when asActor/asService runs work, so the slot binds even before secrets are present
24
+ // (same as the blob/transactional/analytics slots — keeps composeBundle({}) non-null).
25
+ return createSupabaseDataGateway({
26
+ url: env.SUPABASE_URL ?? env.VITE_SUPABASE_URL ?? "",
27
+ anonKey: env.VITE_SUPABASE_PUBLISHABLE_KEY ??
28
+ env.VITE_SUPABASE_ANON_KEY ??
29
+ env.SUPABASE_ANON_KEY ??
30
+ "",
31
+ serviceRoleKey: env.SUPABASE_SERVICE_ROLE_KEY ?? "",
32
+ }, { ...requestOptions, createClientImpl: requestOptions.createClientImpl ?? deps.createSupabaseClient });
33
+ case "postgres":
34
+ // Direct-PG gateway (pg.Pool, tx-per-request RLS via SET LOCAL). Pure factory — no pool
35
+ // until asActor/asService runs work, so the slot binds even before DATABASE_URL is present
36
+ // (matches the supabase case's lazy null-is-no-op convention; keeps composeBundle non-null).
37
+ return createPostgresGateway(resolvePostgresDataGatewayEnv(env));
38
+ default:
39
+ return null;
40
+ }
41
+ }
42
+ /**
43
+ * Bind one request to actor-only data access.
44
+ *
45
+ * HTTP-backed bundles receive a fresh gateway so the raw bearer resolver cannot
46
+ * cross request boundaries. The direct database bundle reuses the existing
47
+ * cached port (and therefore its lazy pool); only this actor-only facade is new.
48
+ */
49
+ function bindRequestActorDataPort(accessToken, env, requestOptions = {}) {
50
+ const bundleId = resolveBundleId(env);
51
+ const dataKind = getBundleDescriptor(bundleId).capabilities.data;
52
+ const port = dataKind === "postgres"
53
+ ? bindBundleDataPort(env)
54
+ : bindDataPort(dataKind, env, {
55
+ ...requestOptions,
56
+ resolveAccessToken: () => accessToken,
57
+ });
58
+ if (!port)
59
+ return null;
60
+ return {
61
+ asActor: (claims, work) => port.asActor(claims, work),
62
+ };
63
+ }
64
+ /** Resolve and bind the active bundle's data gateway without composing the whole runtime. */
65
+ function bindBundleDataPort(env) {
66
+ const bundleId = resolveBundleId(env);
67
+ const descriptor = getBundleDescriptor(bundleId);
68
+ const cacheKey = bundleDataPortCacheKey(bundleId, descriptor.capabilities.data, env);
69
+ if (cachedBundleDataPort?.cacheKey === cacheKey) {
70
+ return cachedBundleDataPort.port;
71
+ }
72
+ const port = bindDataPort(descriptor.capabilities.data, env);
73
+ cachedBundleDataPort = { cacheKey, port };
74
+ return port;
75
+ }
76
+ return { bindDataPort, bindRequestActorDataPort, bindBundleDataPort };
77
+ }
78
+ function bundleDataPortCacheKey(bundleId, dataKind, env) {
79
+ const dataConfig = dataKind === "postgres"
80
+ ? [env.DATABASE_URL ?? ""]
81
+ : dataKind === "supabase"
82
+ ? [
83
+ env.SUPABASE_URL ?? env.VITE_SUPABASE_URL ?? "",
84
+ env.VITE_SUPABASE_PUBLISHABLE_KEY ??
85
+ env.VITE_SUPABASE_ANON_KEY ??
86
+ env.SUPABASE_ANON_KEY ??
87
+ "",
88
+ env.SUPABASE_SERVICE_ROLE_KEY ?? "",
89
+ ]
90
+ : [];
91
+ return JSON.stringify([bundleId, dataKind, ...dataConfig]);
92
+ }
@@ -0,0 +1,5 @@
1
+ export { createPostgresDataGateway, createPostgresGatewayTypeOverrides, createPostgresRoleFreeTransactionLane, postgresTimestampTzToRfc3339, resolvePostgresDataGatewayEnv, type PostgresDataGatewayEnv, type PostgresDataGatewayOptions, type PostgresRoleFreeTransactionLane, } from "./postgresGateway.js";
2
+ export { PgGatewayClient, PgQueryBuilder, type PgQueryExecutor, type PgShimError, type PgShimResult, } from "./queryBuilder.js";
3
+ export { createActorClient, createServiceClient, readSupabaseActorDataGatewayEnv, readSupabaseDataGatewayEnv, type SupabaseCreateClient, type SupabaseDataGatewayEnv, } from "./supabaseClientFactory.js";
4
+ export { createSupabaseDataGateway, type SupabaseDataGatewayOptions } from "./supabaseGateway.js";
5
+ export { createDataBindings, type ActorOnlyDataPort, type DataBindingDependencies, type DataBindings, type DataPortBindingOptions, } from "./binding.js";
@@ -0,0 +1,5 @@
1
+ export { createPostgresDataGateway, createPostgresGatewayTypeOverrides, createPostgresRoleFreeTransactionLane, postgresTimestampTzToRfc3339, resolvePostgresDataGatewayEnv, } from "./postgresGateway.js";
2
+ export { PgGatewayClient, PgQueryBuilder, } from "./queryBuilder.js";
3
+ export { createActorClient, createServiceClient, readSupabaseActorDataGatewayEnv, readSupabaseDataGatewayEnv, } from "./supabaseClientFactory.js";
4
+ export { createSupabaseDataGateway } from "./supabaseGateway.js";
5
+ export { createDataBindings, } from "./binding.js";
@@ -0,0 +1,42 @@
1
+ import type { Pool } from "pg";
2
+ import type { DataGatewayPort } from "@openlup/core/platform-runtime";
3
+ export interface PostgresDataGatewayEnv {
4
+ /** Postgres connection string (DATABASE_URL). Empty until a node-postgres deploy supplies it. */
5
+ connectionString: string;
6
+ }
7
+ export interface PostgresDataGatewayOptions {
8
+ /** Injection seam: tests pass a stub pool; production lazily builds a real pg.Pool from env. */
9
+ poolFactory?: (env: PostgresDataGatewayEnv) => Pool | Promise<Pool>;
10
+ }
11
+ /** Convert node-postgres timestamptz text to lossless RFC 3339. */
12
+ export declare function postgresTimestampTzToRfc3339(value: string): string;
13
+ /** Keep PostgREST-compatible, lossless timestamp strings at the gateway edge. */
14
+ export declare function createPostgresGatewayTypeOverrides(TypeOverridesCtor: typeof import("pg").TypeOverrides): import("pg").TypeOverrides;
15
+ /**
16
+ * Capability-local, role-free transaction lane.
17
+ *
18
+ * The platform manifest intentionally creates no service role. This is not an
19
+ * alternate DataGatewayPort access mode: a deployment hands one lane to exactly
20
+ * one adapter that calls its own rail RPCs, and names that lane after the
21
+ * capability, because the name is the access rule. Its lifecycle reuses the same
22
+ * pooled BEGIN/callback/COMMIT|ROLLBACK/release implementation as the public
23
+ * gateway below, but it never changes the connection role.
24
+ */
25
+ export interface PostgresRoleFreeTransactionLane {
26
+ run<T>(work: (gateway: unknown) => Promise<T>): Promise<T>;
27
+ close(): Promise<void>;
28
+ }
29
+ /**
30
+ * Build a node-postgres-backed DataGatewayPort. Pure: NO pool/connection is created until a port
31
+ * method runs work (lazy, like the supabase adapter), so the slot binds even before DATABASE_URL is
32
+ * present (keeps composeBundle({}) non-null). The pool is created once and reused across calls.
33
+ */
34
+ export declare function createPostgresDataGateway(env: PostgresDataGatewayEnv, options?: PostgresDataGatewayOptions): DataGatewayPort;
35
+ /**
36
+ * Build one role-free, per-operation lane. Do not use this for generic service
37
+ * work: DataGatewayPort.asService remains the mandatory service_role boundary.
38
+ * Only a named owner-authority capability of the deployment may hold a lane.
39
+ */
40
+ export declare function createPostgresRoleFreeTransactionLane(env: PostgresDataGatewayEnv, options?: PostgresDataGatewayOptions): PostgresRoleFreeTransactionLane;
41
+ /** Resolve the gateway env from process env (DATABASE_URL), mirroring the supabase binding shape. */
42
+ export declare function resolvePostgresDataGatewayEnv(env: Record<string, string | undefined>): PostgresDataGatewayEnv;