@xemahq/biome-database-nest 0.12.0 → 0.12.2

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.
@@ -1,433 +0,0 @@
1
- import { createHash } from 'node:crypto';
2
- import { readFileSync } from 'node:fs';
3
- import { writeFile } from 'node:fs/promises';
4
- import { tmpdir } from 'node:os';
5
- import { join } from 'node:path';
6
-
7
- import { Logger } from '@nestjs/common';
8
- import { PrismaMigrationRunner } from '@xemahq/migration-runner-prisma';
9
- import {
10
- composeBiomeDatabaseUrl,
11
- ensureBiomeDbSearchPath,
12
- } from '@xemahq/platform-common';
13
- import { Client } from 'pg';
14
-
15
- import { resolveBiomeDatabaseConfigFromEnv } from './config';
16
- import { fetchSystemDatabaseConfig } from './control-plane-client';
17
- import {
18
- resolveControlPlaneBaseUrl,
19
- type ControlPlaneDiscoveryOptions,
20
- } from './control-plane-discovery';
21
- import {
22
- BiomeDatabaseMigrationMode,
23
- } from './migration-mode';
24
- import { DEFAULT_BIOME_DB_KEY } from './schema-name';
25
-
26
- import type { BiomeDatabaseConfig } from './config';
27
- import type { KernelState } from '@xemahq/kernel-contracts/kernel-state';
28
-
29
- /** One database whose schema and Prisma migrations the application owns. */
30
- export interface BiomeDatabaseDeclaration {
31
- /** Defaults to `'primary'`. */
32
- readonly key?: string;
33
- /** Directory containing `prisma/` (cwd for `prisma migrate deploy`). */
34
- readonly workspaceDir: string;
35
- }
36
-
37
-
38
- interface BootstrapBiomeDatabasesCommonOptions {
39
- readonly biomeId: string;
40
- /**
41
- * A service bearer, or a factory that mints one. Required whenever the
42
- * selected credential path is the control plane. Its address is discovered
43
- * from the service registry via {@link kernelState}.
44
- */
45
- readonly serviceToken?: string | (() => Promise<string>);
46
- /**
47
- * The app's `KernelState` backend (injected via `KERNEL_STATE_TOKEN`). Used to
48
- * discover the `org-database-pool-api` control-plane address from the service
49
- * registry. Required on the control-plane path; unused when every declared
50
- * database explicitly resolves from the environment.
51
- */
52
- readonly kernelState: KernelState;
53
- /** Optional bounded-backoff tuning for control-plane discovery. */
54
- readonly controlPlaneDiscovery?: ControlPlaneDiscoveryOptions;
55
- }
56
-
57
- export interface ApplicationManagedBootstrapOptions
58
- extends BootstrapBiomeDatabasesCommonOptions {
59
- readonly migrationMode: BiomeDatabaseMigrationMode.ApplicationManaged;
60
- readonly databases: readonly BiomeDatabaseDeclaration[];
61
- /**
62
- * Postgres extensions this biome needs, ensured in the SHARED `public` schema
63
- * BEFORE migrations run (e.g. `['vector']` for pgvector). A Postgres
64
- * extension can exist in only ONE schema per database, so installing it in
65
- * each biome's own schema would make whichever biome migrates first win and
66
- * every later biome fail to resolve the type. Installing in `public` (which
67
- * every biome's search_path includes) keeps it shared. `CREATE EXTENSION IF
68
- * NOT EXISTS` is idempotent; only declare extensions the biome actually uses
69
- * so non-Postgres-extension environments (local dev) are unaffected.
70
- */
71
- readonly requiredExtensions?: readonly string[];
72
- }
73
-
74
-
75
- export type BootstrapBiomeDatabasesOptions =
76
- ApplicationManagedBootstrapOptions;
77
-
78
- const LOG_PREFIX = '[biome-database]';
79
-
80
- /**
81
- * Boot-progress sink. A published SDK must not write to the host's raw
82
- * stdout: this bootstrap runs from a service's `main.ts`, so its output
83
- * belongs on the same Nest logger transport as the rest of that service's
84
- * boot. `Logger`'s static/instance API works before `NestFactory.create`,
85
- * which is exactly when this runs.
86
- */
87
- const bootstrapLogger = new Logger('BiomeDatabaseBootstrap');
88
-
89
- function upperKeySuffix(key: string): string {
90
- return key === DEFAULT_BIOME_DB_KEY
91
- ? ''
92
- : `_${key.toUpperCase().replace(/[^A-Z0-9]/g, '_')}`;
93
- }
94
-
95
- async function resolveServiceToken(
96
- serviceToken: string | (() => Promise<string>) | undefined,
97
- ): Promise<string> {
98
- if (serviceToken === undefined) {
99
- throw new Error(
100
- `${LOG_PREFIX} no local DB_* override and no serviceToken supplied — ` +
101
- 'cannot authenticate to the control plane.',
102
- );
103
- }
104
- return typeof serviceToken === 'function' ? serviceToken() : serviceToken;
105
- }
106
-
107
- /**
108
- * Resolve config for one database: local `DB_*` override FIRST, control plane
109
- * as the fallback. Throws if neither yields a config (no silent default). The
110
- * control-plane base URL is obtained via {@link getControlPlaneBaseUrl}, which
111
- * discovers `org-database-pool-api` from the service registry (memoized across
112
- * databases so discovery runs at most once per bootstrap).
113
- */
114
- async function resolveDatabaseConfig(
115
- opts: BootstrapBiomeDatabasesCommonOptions,
116
- key: string,
117
- getControlPlaneBaseUrl: () => Promise<string>,
118
- ): Promise<BiomeDatabaseConfig> {
119
- const local = resolveBiomeDatabaseConfigFromEnv(process.env, key);
120
- if (local !== null) {
121
- return local;
122
- }
123
- const baseUrl = await getControlPlaneBaseUrl();
124
- const token = await resolveServiceToken(opts.serviceToken);
125
- return fetchSystemDatabaseConfig(baseUrl, token, {
126
- biomeId: opts.biomeId,
127
- ...(key !== DEFAULT_BIOME_DB_KEY ? { dbKey: key } : {}),
128
- });
129
- }
130
-
131
-
132
- /**
133
- * If the config carries an inline CA PEM, write it to a deterministic temp file
134
- * and rewrite `sslParams` so `sslrootcert` points at it. Returns the config to
135
- * use downstream. When no inline CA is present, the config is returned as-is
136
- * (any already-mounted `sslrootcert` path in `sslParams` is preserved).
137
- */
138
- async function materializeInlineCaCert(
139
- biomeId: string,
140
- key: string,
141
- config: BiomeDatabaseConfig,
142
- ): Promise<BiomeDatabaseConfig> {
143
- if (config.caCertPem === undefined || config.caCertPem === '') {
144
- return config;
145
- }
146
- const hash = createHash('sha256')
147
- .update(`${biomeId}:${key}`)
148
- .digest('hex')
149
- .slice(0, 16);
150
- const certPath = join(tmpdir(), `xema-biome-db-ca-${hash}.crt`);
151
- await writeFile(certPath, config.caCertPem, { mode: 0o600 });
152
-
153
- const params = new URLSearchParams(config.sslParams ?? '');
154
- params.set('sslrootcert', certPath);
155
- if (!params.has('sslmode')) {
156
- params.set('sslmode', 'verify-full');
157
- }
158
- bootstrapLogger.log(
159
- `${LOG_PREFIX} biome=${biomeId} key=${key}: wrote inline CA cert to ${certPath}`,
160
- );
161
- return { ...config, sslParams: params.toString() };
162
- }
163
-
164
- /**
165
- * Ensure `config.schema` exists. No-op when the config has no schema (dedicated
166
- * DB). Uses a per-schema advisory lock inside a txn so concurrent replicas
167
- * serialize the `CREATE SCHEMA IF NOT EXISTS`.
168
- */
169
- /**
170
- * Build the node-postgres `ssl` option from the config's `sslParams` /
171
- * `caCertPem`. Managed Postgres (e.g. DigitalOcean) REQUIRES TLS — a raw
172
- * `Client` with no `ssl` is rejected at connect. We mirror libpq `sslmode`:
173
- * - absent / `disable` → no TLS
174
- * - `require` / `prefer` → encrypt, do NOT verify the chain/host
175
- * - `verify-ca` / `verify-full` → verify against the CA (the CA comes from
176
- * inline `caCertPem` or the `sslrootcert`
177
- * file path in `sslParams`)
178
- */
179
- function buildPgSsl(
180
- config: BiomeDatabaseConfig,
181
- ): false | { ca?: string; rejectUnauthorized: boolean } {
182
- const params = new URLSearchParams(config.sslParams ?? '');
183
- const sslmode = params.get('sslmode');
184
- if (sslmode === null || sslmode === 'disable') {
185
- return false;
186
- }
187
- const verify = sslmode === 'verify-ca' || sslmode === 'verify-full';
188
- const caPath = params.get('sslrootcert');
189
- const ca =
190
- config.caCertPem ?? (caPath ? readFileSync(caPath, 'utf8') : undefined);
191
- return {
192
- ...(ca !== undefined ? { ca } : {}),
193
- rejectUnauthorized: verify,
194
- };
195
- }
196
-
197
- function isValidExtensionName(name: string): boolean {
198
- return /^[a-z0-9_]+$/i.test(name);
199
- }
200
-
201
- async function ensureSchemaExists(
202
- biomeId: string,
203
- key: string,
204
- config: BiomeDatabaseConfig,
205
- requiredExtensions: readonly string[],
206
- ): Promise<void> {
207
- const schema = config.schema;
208
- const hasSchema = schema !== undefined && schema !== '';
209
- if (!hasSchema && requiredExtensions.length === 0) {
210
- return;
211
- }
212
- const client = new Client({
213
- host: config.host,
214
- port: config.port,
215
- database: config.database,
216
- user: config.username,
217
- password: config.password,
218
- ssl: buildPgSsl(config),
219
- });
220
- await client.connect();
221
- try {
222
- // Shared extensions FIRST, in `public` (a Postgres extension can live in
223
- // only ONE schema per DB; `public` is in every biome's search_path so the
224
- // type/ops resolve everywhere). Idempotent; runs before migrate so a
225
- // migration's own `CREATE EXTENSION IF NOT EXISTS` no-ops.
226
- for (const ext of requiredExtensions) {
227
- if (!isValidExtensionName(ext)) {
228
- throw new Error(
229
- `${LOG_PREFIX} biome=${biomeId}: invalid extension name "${ext}".`,
230
- );
231
- }
232
- await client.query(
233
- `CREATE EXTENSION IF NOT EXISTS "${ext}" SCHEMA public`,
234
- );
235
- bootstrapLogger.log(
236
- `${LOG_PREFIX} biome=${biomeId} key=${key}: ensured extension "${ext}" in public`,
237
- );
238
- }
239
- if (hasSchema) {
240
- await client.query('BEGIN');
241
- await client.query('SELECT pg_advisory_xact_lock(hashtext($1))', [
242
- schema,
243
- ]);
244
- await client.query(`CREATE SCHEMA IF NOT EXISTS "${schema}"`);
245
- await client.query('COMMIT');
246
- bootstrapLogger.log(
247
- `${LOG_PREFIX} biome=${biomeId} key=${key}: ensured schema "${schema}" exists`,
248
- );
249
- }
250
- } catch (err) {
251
- await client.query('ROLLBACK').catch(() => undefined);
252
- throw err;
253
- } finally {
254
- await client.end();
255
- }
256
- }
257
-
258
- /** Build the direct connection URL for a resolved database config. */
259
- function buildDirectConnectionUrl(
260
- biomeId: string,
261
- key: string,
262
- config: BiomeDatabaseConfig,
263
- ): string {
264
- const directUrl = composeBiomeDatabaseUrl({
265
- user: config.username,
266
- password: config.password,
267
- host: config.host,
268
- port: String(config.port),
269
- database: config.database,
270
- ...(config.schema !== undefined ? { schema: config.schema } : {}),
271
- ...(config.sslParams !== undefined ? { sslParams: config.sslParams } : {}),
272
- });
273
- if (directUrl === null) {
274
- throw new Error(
275
- `${LOG_PREFIX} biome=${biomeId} key=${key}: incomplete config — cannot form a database URL.`,
276
- );
277
- }
278
- return ensureBiomeDbSearchPath(directUrl);
279
- }
280
-
281
- async function runMigrations(
282
- biomeId: string,
283
- key: string,
284
- workspaceDir: string,
285
- migrateUrl: string,
286
- ): Promise<void> {
287
- bootstrapLogger.log(
288
- `${LOG_PREFIX} biome=${biomeId} key=${key}: running prisma migrate deploy (cwd=${workspaceDir})`,
289
- );
290
- // The migrate connection MUST carry `public` in its search_path (not just the
291
- // biome schema) so migrations resolve SHARED objects that live in `public` —
292
- // notably the pgvector `vector`/`halfvec` types and their operator classes.
293
- // pgvector can only be installed in ONE schema per database; the shared
294
- // xema_biomes DB keeps it in `public`. Without `,public` here, a biome whose
295
- // schema is the migrate target cannot see `public.vector` and CREATE TABLE
296
- // with a `vector` column fails (`type "vector" does not exist`). The runtime
297
- // URL already gets this via `ensureBiomeDbSearchPath`; the migrate URL needs
298
- // it too. `ensureBiomeDbSearchPath` is idempotent.
299
- const result = await new PrismaMigrationRunner().run({
300
- workspaceDir,
301
- connectionUrl: migrateUrl,
302
- env: {},
303
- });
304
- if (result.exitCode !== 0) {
305
- throw new Error(
306
- `${LOG_PREFIX} biome=${biomeId} key=${key}: migrations failed (exit ${result.exitCode}):\n${result.output}`,
307
- );
308
- }
309
- }
310
-
311
- /**
312
- * Build the STEADY-STATE runtime URL. When the config declares a transaction
313
- * pooler (`poolerHost`), the runtime connection is routed through it — the fix
314
- * for connection fan-out across many replicas × biomes — while `migrateUrl`
315
- * (direct) is what schema-create and `prisma migrate deploy` already used.
316
- *
317
- * The app→pooler leg is normally in-cluster plaintext (`sslmode=disable` by
318
- * default); the pooler terminates the managed-Postgres TLS on its own upstream
319
- * leg. Both URLs carry the libpq `search_path` option (via
320
- * `ensureBiomeDbSearchPath`) so RAW queries resolve to the biome schema — the
321
- * pooler must be configured to track `search_path` per-client (PgBouncer ≥1.23
322
- * `track_extra_parameters = search_path`) so transaction pooling stays correct.
323
- *
324
- * No pooler declared → returns the direct runtime URL (current behavior).
325
- */
326
- function buildRuntimeUrl(
327
- biomeId: string,
328
- key: string,
329
- config: BiomeDatabaseConfig,
330
- directUrl: string,
331
- ): string {
332
- if (config.poolerHost === undefined || config.poolerHost === '') {
333
- return ensureBiomeDbSearchPath(directUrl);
334
- }
335
- const pooledUrl = composeBiomeDatabaseUrl({
336
- user: config.username,
337
- password: config.password,
338
- host: config.poolerHost,
339
- port: String(config.poolerPort ?? config.port),
340
- database: config.database,
341
- ...(config.schema !== undefined ? { schema: config.schema } : {}),
342
- sslParams: config.poolerSslParams ?? 'sslmode=disable',
343
- });
344
- if (pooledUrl === null) {
345
- throw new Error(
346
- `${LOG_PREFIX} biome=${biomeId} key=${key}: incomplete config — cannot form a pooled runtime URL.`,
347
- );
348
- }
349
- bootstrapLogger.log(
350
- `${LOG_PREFIX} biome=${biomeId} key=${key}: runtime routed through pooler ${config.poolerHost}:${config.poolerPort ?? config.port}`,
351
- );
352
- return ensureBiomeDbSearchPath(pooledUrl);
353
- }
354
-
355
-
356
-
357
-
358
- /**
359
- * Export the resolved connection into the env the unchanged-shape
360
- * `PrismaService` reads. The primary DB lands on `DATABASE_URL`/`DB_SCHEMA`;
361
- * additional keys land on `DATABASE_URL_<KEY>`/`DB_SCHEMA_<KEY>`.
362
- */
363
- function exportResolvedEnv(
364
- key: string,
365
- config: BiomeDatabaseConfig,
366
- runtimeUrl: string,
367
- ): void {
368
- const suffix = upperKeySuffix(key);
369
- process.env[`DATABASE_URL${suffix}`] = runtimeUrl;
370
- if (config.schema !== undefined && config.schema !== '') {
371
- process.env[`DB_SCHEMA${suffix}`] = config.schema;
372
- }
373
- }
374
-
375
- /**
376
- * Bootstrap every declared biome database according to its explicit migration
377
- * ownership mode.
378
- *
379
- * Application-managed mode retains schema/extension creation and Prisma
380
- * migration execution. Externally-managed mode performs no DDL and never
381
- * invokes the migration runner: it resolves only the explicitly selected
382
- * runtime credential, proves its least-privilege contract and migration
383
- * compatibility, then exports the verified runtime URL.
384
- */
385
- export async function bootstrapBiomeDatabases(
386
- opts: BootstrapBiomeDatabasesOptions,
387
- ): Promise<void> {
388
- // Discover the control-plane address at most once, and only when a database
389
- // actually needs it (i.e. has no local DB_* override).
390
- let cachedBaseUrl: string | undefined;
391
- const getControlPlaneBaseUrl = async (): Promise<string> => {
392
- if (cachedBaseUrl === undefined) {
393
- cachedBaseUrl = await resolveControlPlaneBaseUrl(
394
- opts.kernelState,
395
- opts.controlPlaneDiscovery,
396
- );
397
- }
398
- return cachedBaseUrl;
399
- };
400
-
401
- if (opts.migrationMode === BiomeDatabaseMigrationMode.ApplicationManaged) {
402
- for (const decl of opts.databases) {
403
- const key = decl.key ?? DEFAULT_BIOME_DB_KEY;
404
- bootstrapLogger.log(
405
- `${LOG_PREFIX} biome=${opts.biomeId} key=${key}: resolving config for mode=${opts.migrationMode}`,
406
- );
407
- const rawConfig = await resolveDatabaseConfig(
408
- opts,
409
- key,
410
- getControlPlaneBaseUrl,
411
- );
412
- const config = await materializeInlineCaCert(
413
- opts.biomeId,
414
- key,
415
- rawConfig,
416
- );
417
- const directUrl = buildDirectConnectionUrl(opts.biomeId, key, config);
418
- await ensureSchemaExists(
419
- opts.biomeId,
420
- key,
421
- config,
422
- opts.requiredExtensions ?? [],
423
- );
424
- await runMigrations(opts.biomeId, key, decl.workspaceDir, directUrl);
425
- const runtimeUrl = buildRuntimeUrl(opts.biomeId, key, config, directUrl);
426
- exportResolvedEnv(key, config, runtimeUrl);
427
- bootstrapLogger.log(
428
- `${LOG_PREFIX} biome=${opts.biomeId} key=${key}: bootstrap complete`,
429
- );
430
- }
431
- return;
432
- }
433
- }
package/src/lib/config.ts DELETED
@@ -1,183 +0,0 @@
1
- import { DEFAULT_BIOME_DB_KEY } from './schema-name';
2
-
3
- /**
4
- * Fully-resolved connection facts for one biome database. Mirrors the standard
5
- * `DB_*` env contract but carries the inline CA option so a biome running on
6
- * its own runner needs no pre-mounted certificate file.
7
- */
8
- export interface BiomeDatabaseConfig {
9
- readonly host: string;
10
- readonly port: number;
11
- readonly database: string;
12
- /** Per-biome schema; `undefined` means a dedicated DB / the `public` schema. */
13
- readonly schema?: string;
14
- readonly username: string;
15
- readonly password: string;
16
- /** Monotonic credential generation returned by external provisioning. */
17
- /** Pre-formed SSL query params, e.g. `sslmode=verify-full&sslrootcert=/path`. */
18
- readonly sslParams?: string;
19
- /** Inline CA PEM; the bootstrap writes it to a temp file and points sslrootcert at it. */
20
- readonly caCertPem?: string;
21
- readonly poolMax?: number;
22
- /**
23
- * When set, the STEADY-STATE runtime connection is routed through this
24
- * transaction pooler (e.g. PgBouncer) at `poolerHost[:poolerPort]`, while
25
- * schema-create and `prisma migrate deploy` still go DIRECT to `host:port`.
26
- *
27
- * A transaction pooler multiplexes many short-lived app connections onto a
28
- * small set of server connections — the fix for connection-fan-out across
29
- * many replicas × many biomes. It MUST NOT carry migrations: `migrate deploy`
30
- * needs session-level state (advisory locks, DDL, prepared-statement lifetime)
31
- * that transaction pooling breaks. Hence the direct-vs-pooled split.
32
- *
33
- * Absent → the runtime uses the direct `host` (current behavior; local dev,
34
- * or any cluster without a pooler deployed). Opt-in, per-deployment.
35
- */
36
- readonly poolerHost?: string;
37
- /** Pooler port; defaults to `port` when a `poolerHost` is set without one. */
38
- readonly poolerPort?: number;
39
- /**
40
- * SSL params for the app→pooler leg. The pooler→Postgres leg terminates the
41
- * managed-TLS in the pooler's OWN config, so the in-cluster app→pooler hop is
42
- * normally plaintext. Defaults to `sslmode=disable` when a `poolerHost` is set.
43
- */
44
- readonly poolerSslParams?: string;
45
- }
46
-
47
- /** Per-dbKey env-var suffix, e.g. `analytics` → `_ANALYTICS`; primary → ''. */
48
- function envSuffixForKey(dbKey: string | undefined): string {
49
- if (dbKey === undefined || dbKey === '' || dbKey === DEFAULT_BIOME_DB_KEY) {
50
- return '';
51
- }
52
- return `_${dbKey.toUpperCase().replace(/[^A-Z0-9]/g, '_')}`;
53
- }
54
-
55
- /**
56
- * Read a `DB_<NAME>` var, preferring the dbKey-suffixed variant and falling
57
- * back to the unsuffixed base. `fallbackToBase=false` reads ONLY the suffixed
58
- * variant (used for fields that must not be shared across keys, e.g. DB_NAME).
59
- */
60
- function readDbVar(
61
- env: NodeJS.ProcessEnv,
62
- base: string,
63
- suffix: string,
64
- fallbackToBase: boolean,
65
- ): string | undefined {
66
- const suffixed = env[`${base}${suffix}`];
67
- if (suffixed !== undefined && suffixed !== '') {
68
- return suffixed;
69
- }
70
- if (!fallbackToBase || suffix === '') {
71
- return undefined;
72
- }
73
- const baseVal = env[base];
74
- return baseVal !== undefined && baseVal !== '' ? baseVal : undefined;
75
- }
76
-
77
- /**
78
- * Resolve a {@link BiomeDatabaseConfig} from the standard `DB_*` env contract.
79
- *
80
- * This is the LOCAL-DEV OVERRIDE path. For a non-default `dbKey`, identity
81
- * fields (`DB_NAME`, `DB_SCHEMA`) are read from the suffixed variant ONLY,
82
- * while shared connection fields (host/port/user/password/ssl/pool) fall back
83
- * to the base var when the suffixed one is absent.
84
- *
85
- * Returns `null` when `DB_HOST` is absent entirely — the caller treats that as
86
- * "no local override → use the control plane". If `DB_HOST` IS set but a
87
- * mandatory field (port/user/password/database) is missing, this THROWS: a
88
- * partial override is a misconfiguration that must be loud, never silently
89
- * ignored.
90
- */
91
- export function resolveBiomeDatabaseConfigFromEnv(
92
- env: NodeJS.ProcessEnv = process.env,
93
- dbKey?: string,
94
- ): BiomeDatabaseConfig | null {
95
- const suffix = envSuffixForKey(dbKey);
96
-
97
- const host = readDbVar(env, 'DB_HOST', suffix, true);
98
- if (host === undefined) {
99
- return null;
100
- }
101
-
102
- const portRaw = readDbVar(env, 'DB_PORT', suffix, true);
103
- const username = readDbVar(env, 'DB_USER', suffix, true);
104
- const password = readDbVar(env, 'DB_PASSWORD', suffix, true);
105
- // Identity fields are per-key — never inherit the base name/schema for a
106
- // non-default key, or two keys would alias the same database.
107
- const database = readDbVar(env, 'DB_NAME', suffix, suffix === '');
108
- const schema = readDbVar(env, 'DB_SCHEMA', suffix, suffix === '');
109
- const missing: string[] = [];
110
- if (portRaw === undefined) {
111
- missing.push(`DB_PORT${suffix}`);
112
- }
113
- if (username === undefined) {
114
- missing.push(`DB_USER${suffix}`);
115
- }
116
- if (password === undefined) {
117
- missing.push(`DB_PASSWORD${suffix}`);
118
- }
119
- if (database === undefined) {
120
- missing.push(`DB_NAME${suffix}`);
121
- }
122
- if (missing.length > 0) {
123
- throw new Error(
124
- `Partial biome database config for dbKey "${dbKey ?? DEFAULT_BIOME_DB_KEY}": ` +
125
- `DB_HOST${suffix} is set but the following are missing: ${missing.join(', ')}. ` +
126
- `Provide all of them or none.`,
127
- );
128
- }
129
-
130
- const port = Number.parseInt(portRaw as string, 10);
131
- if (!Number.isInteger(port) || port <= 0) {
132
- throw new Error(
133
- `Invalid DB_PORT${suffix} value "${portRaw}" — expected a positive integer.`,
134
- );
135
- }
136
-
137
- const sslParams = readDbVar(env, 'DB_SSL_PARAMS', suffix, true);
138
-
139
- // Optional transaction-pooler override for the RUNTIME leg (migrations stay
140
- // direct). All three are optional; a `DB_POOLER_HOST` with no port defaults
141
- // to the direct port at compose time.
142
- const poolerHost = readDbVar(env, 'DB_POOLER_HOST', suffix, true);
143
- const poolerPortRaw = readDbVar(env, 'DB_POOLER_PORT', suffix, true);
144
- const poolerPort =
145
- poolerPortRaw !== undefined
146
- ? Number.parseInt(poolerPortRaw, 10)
147
- : undefined;
148
- if (
149
- poolerPortRaw !== undefined &&
150
- (!Number.isInteger(poolerPort) || poolerPort! <= 0)
151
- ) {
152
- throw new Error(
153
- `Invalid DB_POOLER_PORT${suffix} value "${poolerPortRaw}" — expected a positive integer.`,
154
- );
155
- }
156
- const poolerSslParams = readDbVar(env, 'DB_POOLER_SSL_PARAMS', suffix, true);
157
-
158
- const poolMaxRaw = readDbVar(env, 'DB_POOL_MAX', suffix, true);
159
- const poolMax =
160
- poolMaxRaw !== undefined ? Number.parseInt(poolMaxRaw, 10) : undefined;
161
- if (
162
- poolMaxRaw !== undefined &&
163
- (!Number.isInteger(poolMax) || poolMax! <= 0)
164
- ) {
165
- throw new Error(
166
- `Invalid DB_POOL_MAX${suffix} value "${poolMaxRaw}" — expected a positive integer.`,
167
- );
168
- }
169
-
170
- return {
171
- host,
172
- port,
173
- database: database as string,
174
- username: username as string,
175
- password: password as string,
176
- ...(schema !== undefined ? { schema } : {}),
177
- ...(sslParams !== undefined ? { sslParams } : {}),
178
- ...(poolMax !== undefined ? { poolMax } : {}),
179
- ...(poolerHost !== undefined ? { poolerHost } : {}),
180
- ...(poolerPort !== undefined ? { poolerPort } : {}),
181
- ...(poolerSslParams !== undefined ? { poolerSslParams } : {}),
182
- };
183
- }