@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.
- package/dist/lib/bootstrap.d.ts +1 -1
- package/dist/lib/bootstrap.d.ts.map +1 -1
- package/dist/lib/bootstrap.js +11 -15
- package/dist/lib/bootstrap.js.map +1 -1
- package/dist/lib/control-plane-client.js +1 -1
- package/dist/lib/control-plane-client.js.map +1 -1
- package/dist/lib/nest/biome-database.module.d.ts.map +1 -1
- package/dist/lib/nest/biome-database.module.js +24 -28
- package/dist/lib/nest/biome-database.module.js.map +1 -1
- package/package.json +7 -8
- package/src/index.ts +0 -115
- package/src/lib/adapter.ts +0 -117
- package/src/lib/bootstrap.ts +0 -433
- package/src/lib/config.ts +0 -183
- package/src/lib/control-plane-client.ts +0 -121
- package/src/lib/control-plane-discovery.ts +0 -42
- package/src/lib/identifier.ts +0 -177
- package/src/lib/migration-mode.ts +0 -25
- package/src/lib/nest/biome-database.module.ts +0 -175
- package/src/lib/nest/org-erasure.module.ts +0 -158
- package/src/lib/nest/prisma-service-factory.ts +0 -377
- package/src/lib/role-names.ts +0 -77
- package/src/lib/schema-name.ts +0 -62
- package/src/lib/tenant-isolation/org-erasure.ts +0 -235
- package/src/lib/tenant-isolation/org-scope.ts +0 -305
- package/src/lib/tenant-isolation/tenant-isolation-error.ts +0 -79
- package/src/lib/tenant-isolation/tenant-isolation-mode.ts +0 -55
- package/src/lib/tenant-isolation/tenant-isolation.extension.ts +0 -203
- package/src/lib/tenant-isolation/unscoped-models.ts +0 -272
package/src/lib/bootstrap.ts
DELETED
|
@@ -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
|
-
}
|