@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,289 @@
|
|
|
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
|
+
|
|
11
|
+
import { resolve } from "node:path";
|
|
12
|
+
|
|
13
|
+
import type { HttpRuntimePort, JobLeasePort } from "@openlup/core/platform-runtime";
|
|
14
|
+
|
|
15
|
+
import { createDistStaticReader } from "../http/index.js";
|
|
16
|
+
import type { StaticAssetReader } from "../http/index.js";
|
|
17
|
+
import {
|
|
18
|
+
createEnvAdvisoryLock,
|
|
19
|
+
createNodeScheduler,
|
|
20
|
+
mergeScheduledJobHandlers,
|
|
21
|
+
runScheduledJob,
|
|
22
|
+
type AdvisoryLock,
|
|
23
|
+
type CronScheduler,
|
|
24
|
+
type NodeSchedulerAdapter,
|
|
25
|
+
type ScheduledJob,
|
|
26
|
+
type ScheduledJobDeclaration,
|
|
27
|
+
type ScheduledJobHandler,
|
|
28
|
+
type ScheduledJobHandlerRegistry,
|
|
29
|
+
} from "../scheduler/index.js";
|
|
30
|
+
import type { ComposeBundle, PlatformBundlePorts } from "./composeBundle.js";
|
|
31
|
+
|
|
32
|
+
type Env = Record<string, string | undefined>;
|
|
33
|
+
|
|
34
|
+
/** What the host hands its admission check before it serves or schedules anything. */
|
|
35
|
+
export interface NodeHostAdmission {
|
|
36
|
+
kind: "node";
|
|
37
|
+
nodeJobHandlers: ScheduledJobHandlerRegistry;
|
|
38
|
+
/** The path-declared jobs this process will time; empty when it does not run the scheduler. */
|
|
39
|
+
nodeJobs: readonly ScheduledJobDeclaration[];
|
|
40
|
+
/** The registry jobs this process will run directly; empty when it does not run the scheduler. */
|
|
41
|
+
scheduledJobs: readonly ScheduledJob[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface NodeHostDependencies {
|
|
45
|
+
/** The deployment's composition root (see `createBundleComposer`). */
|
|
46
|
+
composeBundle: ComposeBundle;
|
|
47
|
+
/**
|
|
48
|
+
* Build the HTTP runtime this host serves, for the declared jobs and its static reader. The
|
|
49
|
+
* bundle's `http` port only proves that the bundle is a Node bundle at readiness time.
|
|
50
|
+
*/
|
|
51
|
+
createHttpRuntime: (input: {
|
|
52
|
+
schedulerJobs: readonly ScheduledJobDeclaration[];
|
|
53
|
+
staticReader: StaticAssetReader;
|
|
54
|
+
/** The composed ports (blob storage, data gateway and the rest) for routes to use. */
|
|
55
|
+
ports: PlatformBundlePorts;
|
|
56
|
+
}) => HttpRuntimePort;
|
|
57
|
+
/** The deployment's declared jobs, used when the caller passes none. */
|
|
58
|
+
loadSchedulerJobs: () => readonly ScheduledJobDeclaration[];
|
|
59
|
+
/** Transport-neutral jobs contributed to the registry; run in-process with no HTTP secret. */
|
|
60
|
+
scheduledJobs?: (env: Env, ports: PlatformBundlePorts) => readonly ScheduledJob[];
|
|
61
|
+
/** The job-lease port for registry jobs that declare a lease. */
|
|
62
|
+
jobLease?: (env: Env) => JobLeasePort;
|
|
63
|
+
/** Direct bindings the deployment and its domains contribute; caller overrides win. */
|
|
64
|
+
scheduledJobHandlers?: (env: Env) => ScheduledJobHandlerRegistry;
|
|
65
|
+
/** Runs first, before readiness (for example, process-wide configuration checks). */
|
|
66
|
+
prepareHost?: (env: Env) => void;
|
|
67
|
+
/** Admits the host before it serves or schedules; a rejection stops the boot. */
|
|
68
|
+
admitHost?: (env: Env, admission: NodeHostAdmission) => Promise<void>;
|
|
69
|
+
/** Operational log sink (defaults to console.log). */
|
|
70
|
+
log?: (message: string) => void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A deployment's scheduler profile: a fenced projection of the declared jobs with its own
|
|
75
|
+
* validation, applied at the host's boot boundaries. Each hook may throw to stop the boot.
|
|
76
|
+
*/
|
|
77
|
+
export interface NodeSchedulerProfile {
|
|
78
|
+
/** Replaces the declared jobs with this profile's projection. Runs after any migration replay. */
|
|
79
|
+
projectJobs(env: Env, jobs: readonly ScheduledJobDeclaration[]): readonly ScheduledJobDeclaration[];
|
|
80
|
+
/** Direct bindings the profile adds; they replace the deployment's and the caller's for a job. */
|
|
81
|
+
handlers?(env: Env): ScheduledJobHandlerRegistry;
|
|
82
|
+
/** Runs once the advisory lock is resolved and before any job is registered. */
|
|
83
|
+
validateSchedule?(input: {
|
|
84
|
+
lock: AdvisoryLock | undefined;
|
|
85
|
+
jobs: readonly ScheduledJobDeclaration[];
|
|
86
|
+
overrides: ScheduledJobHandlerRegistry | undefined;
|
|
87
|
+
}): void;
|
|
88
|
+
/**
|
|
89
|
+
* Runs for each job, in order, immediately before it is registered, with its direct binding if
|
|
90
|
+
* any. A refusal stops the boot; jobs registered before it keep their timers, as the boot fails.
|
|
91
|
+
*/
|
|
92
|
+
validateJob?(job: ScheduledJobDeclaration, directHandler: ScheduledJobHandler | undefined): void;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface ServeNodeOptions {
|
|
96
|
+
/** The process environment of this host. */
|
|
97
|
+
env: Env;
|
|
98
|
+
/** dist/ root for static serving (defaults to <cwd>/dist). */
|
|
99
|
+
distDir?: string;
|
|
100
|
+
/** node-cron instance; injected so the dependency stays optional for typecheck and tests. */
|
|
101
|
+
cron?: CronScheduler;
|
|
102
|
+
/** Direct bindings added over the deployment's own for this boot. */
|
|
103
|
+
scheduledJobHandlers?: ScheduledJobHandlerRegistry;
|
|
104
|
+
/** Explicit scheduler projection; production uses the deployment's job registry. */
|
|
105
|
+
schedulerJobs?: readonly ScheduledJobDeclaration[];
|
|
106
|
+
/** Optional scheduler profile applied to the declared jobs (see `NodeSchedulerProfile`). */
|
|
107
|
+
schedulerProfile?: NodeSchedulerProfile;
|
|
108
|
+
/**
|
|
109
|
+
* Per-job cluster mutex. Defaults to the one the environment's connection string describes, so a
|
|
110
|
+
* host that runs more than one instance stops executing every timer once per instance.
|
|
111
|
+
*/
|
|
112
|
+
advisoryLock?: AdvisoryLock;
|
|
113
|
+
/** Skip binding the network listener (unit tests build the runtime without opening a socket). */
|
|
114
|
+
listen?: boolean;
|
|
115
|
+
port?: number;
|
|
116
|
+
host?: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface ServeNodeResult {
|
|
120
|
+
bundleId: string;
|
|
121
|
+
httpRuntime: HttpRuntimePort;
|
|
122
|
+
registeredJobs: string[];
|
|
123
|
+
/** Stop timers and the local listener after a host signal; safe to call repeatedly. */
|
|
124
|
+
stop(): Promise<void>;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface NodeHost {
|
|
128
|
+
/** Readiness gate: the active bundle's descriptor decides, and the bundle must bind a Node runtime. */
|
|
129
|
+
assertBundleReady(env: Env): { bundleId: string };
|
|
130
|
+
/** Build (and optionally start) the Node host. */
|
|
131
|
+
serveNode(options: ServeNodeOptions): Promise<ServeNodeResult>;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function createNodeHost(deps: NodeHostDependencies): NodeHost {
|
|
135
|
+
const log = deps.log ?? ((message: string) => console.log(message));
|
|
136
|
+
|
|
137
|
+
function assertBundleReady(env: Env): { bundleId: string } {
|
|
138
|
+
const bundle = deps.composeBundle(env);
|
|
139
|
+
const readiness = bundle.descriptor.readReadiness(env);
|
|
140
|
+
if (!readiness.ok) {
|
|
141
|
+
throw new Error(`[serve.node] bundle "${bundle.id}" not ready: ${readiness.error}`);
|
|
142
|
+
}
|
|
143
|
+
if (!bundle.ports.http) {
|
|
144
|
+
throw new Error(`[serve.node] bundle "${bundle.id}" has no http runtime (expected node bundle)`);
|
|
145
|
+
}
|
|
146
|
+
return { bundleId: bundle.id };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function serveNode(options: ServeNodeOptions): Promise<ServeNodeResult> {
|
|
150
|
+
const env = options.env;
|
|
151
|
+
deps.prepareHost?.(env);
|
|
152
|
+
const { bundleId } = assertBundleReady(env);
|
|
153
|
+
|
|
154
|
+
// Deploy parity (migrate -> health -> serve): the node-postgres bundle has no external migration
|
|
155
|
+
// step, so it applies the composed MigrationRunnerPort BEFORE serving. Gated on the data
|
|
156
|
+
// capability so managed-schema bundles are untouched. A listening node-postgres process has no
|
|
157
|
+
// bypass: unit tests that need no database use the non-listening seam.
|
|
158
|
+
const bundle = deps.composeBundle(env);
|
|
159
|
+
const shouldMigrate =
|
|
160
|
+
options.listen !== false &&
|
|
161
|
+
bundle.descriptor.capabilities.data === "postgres" &&
|
|
162
|
+
bundle.ports.migrations !== null;
|
|
163
|
+
if (shouldMigrate) {
|
|
164
|
+
const { applied } = await bundle.ports.migrations!.apply();
|
|
165
|
+
log(`[serve.node] bundle=${bundleId} applied ${applied.length} migration(s) before serve`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const distDir = options.distDir ?? resolve(process.cwd(), "dist");
|
|
169
|
+
const staticReader = createDistStaticReader(distDir);
|
|
170
|
+
const declaredSchedulerJobs = options.schedulerJobs ?? deps.loadSchedulerJobs();
|
|
171
|
+
const profile = options.schedulerProfile;
|
|
172
|
+
const schedulerJobs = profile ? profile.projectJobs(env, declaredSchedulerJobs) : declaredSchedulerJobs;
|
|
173
|
+
|
|
174
|
+
const handlers = mergeScheduledJobHandlers(
|
|
175
|
+
deps.scheduledJobHandlers?.(env),
|
|
176
|
+
options.scheduledJobHandlers,
|
|
177
|
+
profile?.handlers?.(env),
|
|
178
|
+
);
|
|
179
|
+
const registryJobs = deps.scheduledJobs?.(env, bundle.ports) ?? [];
|
|
180
|
+
for (const job of registryJobs) {
|
|
181
|
+
if (schedulerJobs.some((declared) => declared.jobId === job.id)) {
|
|
182
|
+
throw new Error(`scheduled_job_declared_twice:${job.id}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (deps.admitHost && (options.listen !== false || options.cron)) {
|
|
186
|
+
await deps.admitHost(env, {
|
|
187
|
+
kind: "node",
|
|
188
|
+
nodeJobHandlers: handlers,
|
|
189
|
+
nodeJobs: options.cron ? schedulerJobs : [],
|
|
190
|
+
scheduledJobs: options.cron ? registryJobs : [],
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const httpRuntime = deps.createHttpRuntime({ schedulerJobs, staticReader, ports: bundle.ports });
|
|
195
|
+
|
|
196
|
+
// Node cron invokes a direct binding where one exists. Every other job takes the HTTP
|
|
197
|
+
// compatibility bridge to its own entrypoint, which the runtime mounts from the same registry,
|
|
198
|
+
// so a routing miss cannot masquerade as a scheduler failure and a job without an entrypoint
|
|
199
|
+
// fails visibly. The scheduler holds the cluster mutex: without it every instance of a
|
|
200
|
+
// horizontally scaled host fires the same timer in the same minute.
|
|
201
|
+
const registeredJobs: string[] = [];
|
|
202
|
+
let scheduler: NodeSchedulerAdapter | null = null;
|
|
203
|
+
let schedulerLock: AdvisoryLock | undefined;
|
|
204
|
+
if (options.cron) {
|
|
205
|
+
schedulerLock = options.advisoryLock ?? createEnvAdvisoryLock(env) ?? undefined;
|
|
206
|
+
scheduler = createNodeScheduler({
|
|
207
|
+
cron: options.cron,
|
|
208
|
+
env,
|
|
209
|
+
jobs: schedulerJobs,
|
|
210
|
+
lock: schedulerLock,
|
|
211
|
+
});
|
|
212
|
+
profile?.validateSchedule?.({
|
|
213
|
+
lock: schedulerLock,
|
|
214
|
+
jobs: scheduler.jobs,
|
|
215
|
+
overrides: options.scheduledJobHandlers,
|
|
216
|
+
});
|
|
217
|
+
const baseUrl = env.APP_BASE_URL ?? "http://localhost:3000";
|
|
218
|
+
const cronSecret = env.CRON_SECRET ?? "";
|
|
219
|
+
for (const job of scheduler.jobs) {
|
|
220
|
+
const directHandler = handlers[job.jobId];
|
|
221
|
+
profile?.validateJob?.(job, directHandler);
|
|
222
|
+
scheduler.register({
|
|
223
|
+
jobId: job.jobId,
|
|
224
|
+
schedule: job.schedule,
|
|
225
|
+
handler: async () => {
|
|
226
|
+
if (directHandler) {
|
|
227
|
+
await directHandler({ jobId: job.jobId, invocationSource: "node_cron" });
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const response = await fetch(new URL(job.path, baseUrl).toString(), {
|
|
231
|
+
method: "GET",
|
|
232
|
+
headers: cronSecret ? { Authorization: `Bearer ${cronSecret}` } : {},
|
|
233
|
+
});
|
|
234
|
+
if (!response.ok) {
|
|
235
|
+
throw new Error(
|
|
236
|
+
`node scheduler compatibility bridge for "${job.jobId}" returned HTTP ${response.status}`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
registeredJobs.push(job.jobId);
|
|
242
|
+
}
|
|
243
|
+
const lease = registryJobs.some((job) => job.lease) ? deps.jobLease?.(env) : undefined;
|
|
244
|
+
for (const job of registryJobs) {
|
|
245
|
+
scheduler.register({
|
|
246
|
+
jobId: job.id,
|
|
247
|
+
schedule: job.schedule,
|
|
248
|
+
handler: async () => {
|
|
249
|
+
await runScheduledJob(job, { invocation: { triggerKind: "scheduler", invocationSource: "node_cron" }, lease });
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
registeredJobs.push(job.id);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (options.listen !== false) {
|
|
257
|
+
const port = options.port ?? Number(env.PORT ?? 3000);
|
|
258
|
+
await httpRuntime.listen?.({ port, host: options.host ?? "0.0.0.0" });
|
|
259
|
+
log(`[serve.node] bundle=${bundleId} listening on :${port} (${registeredJobs.length} cron jobs)`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
let stopPromise: Promise<void> | null = null;
|
|
263
|
+
return {
|
|
264
|
+
bundleId,
|
|
265
|
+
httpRuntime,
|
|
266
|
+
registeredJobs,
|
|
267
|
+
stop: () => (stopPromise ??= stopNodeHost(scheduler, schedulerLock, httpRuntime)),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return { assertBundleReady, serveNode };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function stopNodeHost(
|
|
275
|
+
scheduler: NodeSchedulerAdapter | null,
|
|
276
|
+
advisoryLock: AdvisoryLock | undefined,
|
|
277
|
+
httpRuntime: HttpRuntimePort,
|
|
278
|
+
): Promise<void> {
|
|
279
|
+
const timers = Promise.resolve().then(() => scheduler?.stopAll());
|
|
280
|
+
const server = (httpRuntime as HttpRuntimePort & {
|
|
281
|
+
server?: { close(callback: (error?: Error) => void): void } | null;
|
|
282
|
+
}).server;
|
|
283
|
+
const listener = server
|
|
284
|
+
? new Promise<void>((resolveClose, reject) => server.close((error) => error ? reject(error) : resolveClose()))
|
|
285
|
+
: Promise.resolve();
|
|
286
|
+
const results = await Promise.allSettled([timers, advisoryLock?.close(), listener]);
|
|
287
|
+
const failures = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
|
|
288
|
+
if (failures.length > 0) throw new AggregateError(failures, "node_host_shutdown_failed");
|
|
289
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
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
|
+
|
|
12
|
+
import {
|
|
13
|
+
getBundleDescriptor,
|
|
14
|
+
resolveBundleId,
|
|
15
|
+
} from "../kernel/index.js";
|
|
16
|
+
import { createSupabaseDataGateway } from "./supabaseGateway.js";
|
|
17
|
+
import {
|
|
18
|
+
createPostgresDataGateway,
|
|
19
|
+
resolvePostgresDataGatewayEnv,
|
|
20
|
+
type PostgresDataGatewayEnv,
|
|
21
|
+
} from "./postgresGateway.js";
|
|
22
|
+
import type { DataGatewayPort } from "@openlup/core/platform-runtime";
|
|
23
|
+
import type { SupabaseCreateClient } from "./supabaseClientFactory.js";
|
|
24
|
+
|
|
25
|
+
type Env = Record<string, string | undefined>;
|
|
26
|
+
type ActorClaims = { sub?: string; role?: string } | null;
|
|
27
|
+
/** Actor-only data access for one request. */
|
|
28
|
+
export type ActorOnlyDataPort = {
|
|
29
|
+
asActor: <T>(
|
|
30
|
+
claims: ActorClaims,
|
|
31
|
+
work: (gateway: unknown) => Promise<T>,
|
|
32
|
+
) => Promise<T>;
|
|
33
|
+
};
|
|
34
|
+
export interface DataPortBindingOptions {
|
|
35
|
+
/** Replaces the bindings' supabase-js `createClient` for this call (tests). */
|
|
36
|
+
createClientImpl?: SupabaseCreateClient;
|
|
37
|
+
resolveAccessToken?: (claims: ActorClaims) => string | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface CachedBundleDataPort {
|
|
41
|
+
cacheKey: string;
|
|
42
|
+
port: DataGatewayPort | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface DataBindingDependencies {
|
|
46
|
+
/** The supabase-js `createClient` used by every supabase gateway these bindings build. */
|
|
47
|
+
createSupabaseClient: SupabaseCreateClient;
|
|
48
|
+
/** Builds the direct-PostgreSQL gateway; defaults to `createPostgresDataGateway`. */
|
|
49
|
+
createPostgresGateway?: (env: PostgresDataGatewayEnv) => DataGatewayPort;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface DataBindings {
|
|
53
|
+
/** Resolve the DataGatewayPort for a bundle's declared data capability kind. */
|
|
54
|
+
bindDataPort(dataKind: string, env: Env, requestOptions?: DataPortBindingOptions): DataGatewayPort | null;
|
|
55
|
+
/** Bind one request to actor-only data access. */
|
|
56
|
+
bindRequestActorDataPort(
|
|
57
|
+
accessToken: string,
|
|
58
|
+
env: Env,
|
|
59
|
+
requestOptions?: DataPortBindingOptions,
|
|
60
|
+
): ActorOnlyDataPort | null;
|
|
61
|
+
/** Resolve and bind the active bundle's data gateway, cached per bundle and data configuration. */
|
|
62
|
+
bindBundleDataPort(env: Env): DataGatewayPort | null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Create one set of data bindings; the bundle data port cache belongs to this set. */
|
|
66
|
+
export function createDataBindings(deps: DataBindingDependencies): DataBindings {
|
|
67
|
+
let cachedBundleDataPort: CachedBundleDataPort | null = null;
|
|
68
|
+
const createPostgresGateway = deps.createPostgresGateway ?? ((env: PostgresDataGatewayEnv) => createPostgresDataGateway(env));
|
|
69
|
+
|
|
70
|
+
/** Resolve the DataGatewayPort for a bundle's declared data capability kind. */
|
|
71
|
+
function bindDataPort(
|
|
72
|
+
dataKind: string,
|
|
73
|
+
env: Env,
|
|
74
|
+
requestOptions: DataPortBindingOptions = {},
|
|
75
|
+
): DataGatewayPort | null {
|
|
76
|
+
switch (dataKind) {
|
|
77
|
+
case "supabase":
|
|
78
|
+
// Construct from env with empty-string fallbacks; the actual supabase client is created
|
|
79
|
+
// lazily when asActor/asService runs work, so the slot binds even before secrets are present
|
|
80
|
+
// (same as the blob/transactional/analytics slots — keeps composeBundle({}) non-null).
|
|
81
|
+
return createSupabaseDataGateway({
|
|
82
|
+
url: env.SUPABASE_URL ?? env.VITE_SUPABASE_URL ?? "",
|
|
83
|
+
anonKey:
|
|
84
|
+
env.VITE_SUPABASE_PUBLISHABLE_KEY ??
|
|
85
|
+
env.VITE_SUPABASE_ANON_KEY ??
|
|
86
|
+
env.SUPABASE_ANON_KEY ??
|
|
87
|
+
"",
|
|
88
|
+
serviceRoleKey: env.SUPABASE_SERVICE_ROLE_KEY ?? "",
|
|
89
|
+
}, { ...requestOptions, createClientImpl: requestOptions.createClientImpl ?? deps.createSupabaseClient });
|
|
90
|
+
case "postgres":
|
|
91
|
+
// Direct-PG gateway (pg.Pool, tx-per-request RLS via SET LOCAL). Pure factory — no pool
|
|
92
|
+
// until asActor/asService runs work, so the slot binds even before DATABASE_URL is present
|
|
93
|
+
// (matches the supabase case's lazy null-is-no-op convention; keeps composeBundle non-null).
|
|
94
|
+
return createPostgresGateway(resolvePostgresDataGatewayEnv(env));
|
|
95
|
+
default:
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Bind one request to actor-only data access.
|
|
102
|
+
*
|
|
103
|
+
* HTTP-backed bundles receive a fresh gateway so the raw bearer resolver cannot
|
|
104
|
+
* cross request boundaries. The direct database bundle reuses the existing
|
|
105
|
+
* cached port (and therefore its lazy pool); only this actor-only facade is new.
|
|
106
|
+
*/
|
|
107
|
+
function bindRequestActorDataPort(
|
|
108
|
+
accessToken: string,
|
|
109
|
+
env: Env,
|
|
110
|
+
requestOptions: DataPortBindingOptions = {},
|
|
111
|
+
): ActorOnlyDataPort | null {
|
|
112
|
+
const bundleId = resolveBundleId(env);
|
|
113
|
+
const dataKind = getBundleDescriptor(bundleId).capabilities.data;
|
|
114
|
+
const port = dataKind === "postgres"
|
|
115
|
+
? bindBundleDataPort(env)
|
|
116
|
+
: bindDataPort(dataKind, env, {
|
|
117
|
+
...requestOptions,
|
|
118
|
+
resolveAccessToken: () => accessToken,
|
|
119
|
+
});
|
|
120
|
+
if (!port) return null;
|
|
121
|
+
return {
|
|
122
|
+
asActor: (claims, work) => port.asActor(claims, work),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Resolve and bind the active bundle's data gateway without composing the whole runtime. */
|
|
127
|
+
function bindBundleDataPort(env: Env): DataGatewayPort | null {
|
|
128
|
+
const bundleId = resolveBundleId(env);
|
|
129
|
+
const descriptor = getBundleDescriptor(bundleId);
|
|
130
|
+
const cacheKey = bundleDataPortCacheKey(bundleId, descriptor.capabilities.data, env);
|
|
131
|
+
if (cachedBundleDataPort?.cacheKey === cacheKey) {
|
|
132
|
+
return cachedBundleDataPort.port;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const port = bindDataPort(descriptor.capabilities.data, env);
|
|
136
|
+
cachedBundleDataPort = { cacheKey, port };
|
|
137
|
+
return port;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return { bindDataPort, bindRequestActorDataPort, bindBundleDataPort };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function bundleDataPortCacheKey(bundleId: string, dataKind: string, env: Env): string {
|
|
144
|
+
const dataConfig = dataKind === "postgres"
|
|
145
|
+
? [env.DATABASE_URL ?? ""]
|
|
146
|
+
: dataKind === "supabase"
|
|
147
|
+
? [
|
|
148
|
+
env.SUPABASE_URL ?? env.VITE_SUPABASE_URL ?? "",
|
|
149
|
+
env.VITE_SUPABASE_PUBLISHABLE_KEY ??
|
|
150
|
+
env.VITE_SUPABASE_ANON_KEY ??
|
|
151
|
+
env.SUPABASE_ANON_KEY ??
|
|
152
|
+
"",
|
|
153
|
+
env.SUPABASE_SERVICE_ROLE_KEY ?? "",
|
|
154
|
+
]
|
|
155
|
+
: [];
|
|
156
|
+
return JSON.stringify([bundleId, dataKind, ...dataConfig]);
|
|
157
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export {
|
|
2
|
+
createPostgresDataGateway,
|
|
3
|
+
createPostgresGatewayTypeOverrides,
|
|
4
|
+
createPostgresRoleFreeTransactionLane,
|
|
5
|
+
postgresTimestampTzToRfc3339,
|
|
6
|
+
resolvePostgresDataGatewayEnv,
|
|
7
|
+
type PostgresDataGatewayEnv,
|
|
8
|
+
type PostgresDataGatewayOptions,
|
|
9
|
+
type PostgresRoleFreeTransactionLane,
|
|
10
|
+
} from "./postgresGateway.js";
|
|
11
|
+
export {
|
|
12
|
+
PgGatewayClient,
|
|
13
|
+
PgQueryBuilder,
|
|
14
|
+
type PgQueryExecutor,
|
|
15
|
+
type PgShimError,
|
|
16
|
+
type PgShimResult,
|
|
17
|
+
} from "./queryBuilder.js";
|
|
18
|
+
export {
|
|
19
|
+
createActorClient,
|
|
20
|
+
createServiceClient,
|
|
21
|
+
readSupabaseActorDataGatewayEnv,
|
|
22
|
+
readSupabaseDataGatewayEnv,
|
|
23
|
+
type SupabaseCreateClient,
|
|
24
|
+
type SupabaseDataGatewayEnv,
|
|
25
|
+
} from "./supabaseClientFactory.js";
|
|
26
|
+
export { createSupabaseDataGateway, type SupabaseDataGatewayOptions } from "./supabaseGateway.js";
|
|
27
|
+
export {
|
|
28
|
+
createDataBindings,
|
|
29
|
+
type ActorOnlyDataPort,
|
|
30
|
+
type DataBindingDependencies,
|
|
31
|
+
type DataBindings,
|
|
32
|
+
type DataPortBindingOptions,
|
|
33
|
+
} from "./binding.js";
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Vanilla-Postgres DataGatewayPort plus capability-local role-free transaction
|
|
2
|
+
// lanes. Actor/service role changes use SET LOCAL; every call owns one pooled
|
|
3
|
+
// BEGIN/COMMIT|ROLLBACK/release lifecycle, so claims never leak between calls.
|
|
4
|
+
import type { Pool, PoolClient } from "pg";
|
|
5
|
+
import type { DataGatewayPort } from "@openlup/core/platform-runtime";
|
|
6
|
+
import { PgGatewayClient } from "./queryBuilder.js";
|
|
7
|
+
|
|
8
|
+
type ActorClaims = { sub?: string; role?: string } | null;
|
|
9
|
+
|
|
10
|
+
export interface PostgresDataGatewayEnv {
|
|
11
|
+
/** Postgres connection string (DATABASE_URL). Empty until a node-postgres deploy supplies it. */
|
|
12
|
+
connectionString: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface PostgresDataGatewayOptions {
|
|
16
|
+
/** Injection seam: tests pass a stub pool; production lazily builds a real pg.Pool from env. */
|
|
17
|
+
poolFactory?: (env: PostgresDataGatewayEnv) => Pool | Promise<Pool>;
|
|
18
|
+
}
|
|
19
|
+
/** Convert node-postgres timestamptz text to lossless RFC 3339. */
|
|
20
|
+
export function postgresTimestampTzToRfc3339(value: string): string {
|
|
21
|
+
const parts = /^(\d{4,}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}(?:\.\d+)?)([+-])(\d{2})(?::?(\d{2}))?$/.exec(value);
|
|
22
|
+
if (!parts) return value;
|
|
23
|
+
const [, date, time, sign, offsetHours, offsetMinutes = "00"] = parts;
|
|
24
|
+
return `${date}T${time}${sign}${offsetHours}:${offsetMinutes}`;
|
|
25
|
+
}
|
|
26
|
+
/** Keep PostgREST-compatible, lossless timestamp strings at the gateway edge. */
|
|
27
|
+
export function createPostgresGatewayTypeOverrides(
|
|
28
|
+
TypeOverridesCtor: typeof import("pg").TypeOverrides,
|
|
29
|
+
): import("pg").TypeOverrides {
|
|
30
|
+
const overrides = new TypeOverridesCtor();
|
|
31
|
+
// node-postgres otherwise turns timestamptz into a millisecond Date and drops
|
|
32
|
+
// PostgreSQL microseconds, making an immediate schedule round-trip look stale.
|
|
33
|
+
overrides.setTypeParser(1184, "text", postgresTimestampTzToRfc3339);
|
|
34
|
+
return overrides;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Capability-local, role-free transaction lane.
|
|
39
|
+
*
|
|
40
|
+
* The platform manifest intentionally creates no service role. This is not an
|
|
41
|
+
* alternate DataGatewayPort access mode: a deployment hands one lane to exactly
|
|
42
|
+
* one adapter that calls its own rail RPCs, and names that lane after the
|
|
43
|
+
* capability, because the name is the access rule. Its lifecycle reuses the same
|
|
44
|
+
* pooled BEGIN/callback/COMMIT|ROLLBACK/release implementation as the public
|
|
45
|
+
* gateway below, but it never changes the connection role.
|
|
46
|
+
*/
|
|
47
|
+
export interface PostgresRoleFreeTransactionLane {
|
|
48
|
+
run<T>(work: (gateway: unknown) => Promise<T>): Promise<T>;
|
|
49
|
+
close(): Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Build a node-postgres-backed DataGatewayPort. Pure: NO pool/connection is created until a port
|
|
54
|
+
* method runs work (lazy, like the supabase adapter), so the slot binds even before DATABASE_URL is
|
|
55
|
+
* present (keeps composeBundle({}) non-null). The pool is created once and reused across calls.
|
|
56
|
+
*/
|
|
57
|
+
export function createPostgresDataGateway(
|
|
58
|
+
env: PostgresDataGatewayEnv,
|
|
59
|
+
options: PostgresDataGatewayOptions = {},
|
|
60
|
+
): DataGatewayPort {
|
|
61
|
+
const transactions = createPooledPostgresTransactions(env, options);
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
async asService<T>(work: (gateway: unknown) => Promise<T>): Promise<T> {
|
|
65
|
+
return transactions.run(async (client) => {
|
|
66
|
+
await client.query("SET LOCAL ROLE service_role");
|
|
67
|
+
}, work);
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
async asActor<T>(claims: ActorClaims, work: (gateway: unknown) => Promise<T>): Promise<T> {
|
|
71
|
+
return transactions.run(async (client) => {
|
|
72
|
+
const jwtClaims = buildJwtClaims(claims);
|
|
73
|
+
if (jwtClaims) {
|
|
74
|
+
// Parameterized: the claims JSON is bound as $1, never interpolated into the SET statement.
|
|
75
|
+
await client.query("SELECT set_config('request.jwt.claims', $1, true)", [jwtClaims]);
|
|
76
|
+
await client.query("SET LOCAL ROLE authenticated");
|
|
77
|
+
} else {
|
|
78
|
+
// No usable principal → drop to the `anon` role so RLS evaluates the request as
|
|
79
|
+
// unauthenticated. CRITICAL: the connection's own login role may be a superuser/owner
|
|
80
|
+
// (which BYPASSes RLS), so we MUST switch roles rather than leave the default — otherwise an
|
|
81
|
+
// unauthenticated actor would silently see everything. Fail-safe: anon is RLS-bound, never
|
|
82
|
+
// elevated. (No request.jwt.claims is set, so auth.uid() returns NULL.)
|
|
83
|
+
await client.query("SET LOCAL ROLE anon");
|
|
84
|
+
}
|
|
85
|
+
}, work);
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Build one role-free, per-operation lane. Do not use this for generic service
|
|
92
|
+
* work: DataGatewayPort.asService remains the mandatory service_role boundary.
|
|
93
|
+
* Only a named owner-authority capability of the deployment may hold a lane.
|
|
94
|
+
*/
|
|
95
|
+
export function createPostgresRoleFreeTransactionLane(
|
|
96
|
+
env: PostgresDataGatewayEnv,
|
|
97
|
+
options: PostgresDataGatewayOptions = {},
|
|
98
|
+
): PostgresRoleFreeTransactionLane {
|
|
99
|
+
const transactions = createPooledPostgresTransactions(env, options);
|
|
100
|
+
return { run: (work) => transactions.run(async () => {}, work), close: transactions.close };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function createPooledPostgresTransactions(
|
|
104
|
+
env: PostgresDataGatewayEnv,
|
|
105
|
+
options: PostgresDataGatewayOptions,
|
|
106
|
+
): {
|
|
107
|
+
run<T>(setup: (client: PoolClient) => Promise<void>, work: (gateway: unknown) => Promise<T>): Promise<T>;
|
|
108
|
+
close(): Promise<void>;
|
|
109
|
+
} {
|
|
110
|
+
let poolPromise: Promise<Pool> | null = null;
|
|
111
|
+
|
|
112
|
+
async function getPool(): Promise<Pool> {
|
|
113
|
+
// Cache the in-flight promise (not the resolved Pool) so concurrent first calls share ONE pool
|
|
114
|
+
// rather than racing to build two. Promise.resolve normalizes the factory's Pool | Promise<Pool>.
|
|
115
|
+
if (!poolPromise) {
|
|
116
|
+
const factory = options.poolFactory ?? defaultPoolFactory;
|
|
117
|
+
poolPromise = Promise.resolve(factory(env));
|
|
118
|
+
}
|
|
119
|
+
return poolPromise;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function withClient<T>(setup: (client: PoolClient) => Promise<void>, work: (gw: unknown) => Promise<T>): Promise<T> {
|
|
123
|
+
const activePool = await getPool();
|
|
124
|
+
const client = await activePool.connect();
|
|
125
|
+
try {
|
|
126
|
+
await client.query("BEGIN");
|
|
127
|
+
await setup(client);
|
|
128
|
+
const result = await work(new PgGatewayClient(client));
|
|
129
|
+
await client.query("COMMIT");
|
|
130
|
+
return result;
|
|
131
|
+
} catch (error) {
|
|
132
|
+
try {
|
|
133
|
+
await client.query("ROLLBACK");
|
|
134
|
+
} catch {
|
|
135
|
+
// ignore rollback failure; surface the original error
|
|
136
|
+
}
|
|
137
|
+
throw error;
|
|
138
|
+
} finally {
|
|
139
|
+
client.release();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
run: withClient,
|
|
145
|
+
async close(): Promise<void> {
|
|
146
|
+
if (poolPromise) await (await poolPromise).end();
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Build the request.jwt.claims JSON PostgREST would set. Returns null when there is no usable
|
|
153
|
+
* principal (no sub) so the caller stays anon rather than asserting an authenticated-but-empty role.
|
|
154
|
+
*/
|
|
155
|
+
function buildJwtClaims(claims: ActorClaims): string | null {
|
|
156
|
+
if (!claims || !claims.sub) return null;
|
|
157
|
+
const role = claims.role && claims.role.trim() !== "" ? claims.role : "authenticated";
|
|
158
|
+
return JSON.stringify({ sub: claims.sub, role });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Lazy default: only import `pg` and build the Pool when actually invoked, so typecheck/test paths
|
|
162
|
+
// that never run work do not load the driver. SET LOCAL keeps pooled-connection reuse safe.
|
|
163
|
+
async function defaultPoolFactory(env: PostgresDataGatewayEnv): Promise<Pool> {
|
|
164
|
+
const pg = await import("pg").catch(peerMissing);
|
|
165
|
+
const PoolCtor = (pg.default?.Pool ?? pg.Pool) as typeof import("pg").Pool;
|
|
166
|
+
const TypeOverridesCtor = (pg.default?.TypeOverrides ?? pg.TypeOverrides) as typeof import("pg").TypeOverrides;
|
|
167
|
+
return new PoolCtor({
|
|
168
|
+
connectionString: env.connectionString,
|
|
169
|
+
types: createPostgresGatewayTypeOverrides(TypeOverridesCtor),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Resolve the gateway env from process env (DATABASE_URL), mirroring the supabase binding shape. */
|
|
174
|
+
export function resolvePostgresDataGatewayEnv(env: Record<string, string | undefined>): PostgresDataGatewayEnv {
|
|
175
|
+
return { connectionString: env.DATABASE_URL ?? "" };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Translate only absence of this optional peer; preserve broken SDKs and other failures. */
|
|
179
|
+
function peerMissing(cause: unknown): never {
|
|
180
|
+
if (cause instanceof Error && "code" in cause
|
|
181
|
+
&& (cause.code === "ERR_MODULE_NOT_FOUND" || cause.code === "MODULE_NOT_FOUND")
|
|
182
|
+
&& /^Cannot find (?:package|module) ['"]pg['"]/.test(cause.message)) {
|
|
183
|
+
throw new Error("OPENLUP_E_PEER_MISSING: pg is required by the Postgres data gateway; install it alongside @openlup/platform", { cause });
|
|
184
|
+
}
|
|
185
|
+
throw cause;
|
|
186
|
+
}
|