@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,121 +0,0 @@
1
- /**
2
- * Control-plane contract for biome database provisioning config.
3
- *
4
- * The control plane is `org-database-pool-api`. The endpoint author MUST
5
- * implement this exact request/response shape:
6
- *
7
- * POST {base}/system-databases/config
8
- * Authorization: Bearer <serviceToken>
9
- * Content-Type: application/json
10
- * Body (SystemDatabaseConfigRequest):
11
- * { "biomeId": string, "dbKey"?: string }
12
- *
13
- * 200 Response (BiomeDatabaseConfig, JSON):
14
- * {
15
- * "host": string,
16
- * "port": number,
17
- * "database": string,
18
- * "schema"?: string,
19
- * "username": string,
20
- * "password": string,
21
- * "sslParams"?: string,
22
- * "caCertPem"?: string,
23
- * "poolMax"?: number
24
- * }
25
- *
26
- * Non-2xx → this client throws (fail-fast, no silent fallback).
27
- *
28
- * The control-plane ADDRESS is discovered from the Xema service registry — see
29
- * `resolveControlPlaneBaseUrl` in `./control-plane-discovery`. This module owns
30
- * only the request/response contract against a resolved base URL; it carries no
31
- * knowledge of how the address is obtained.
32
- */
33
- import { unwrapPlatformResponseEnvelope } from '@xemahq/platform-common';
34
-
35
- import type { BiomeDatabaseConfig } from './config';
36
-
37
- export interface SystemDatabaseConfigRequest {
38
- readonly biomeId: string;
39
- readonly dbKey?: string;
40
- }
41
-
42
- /**
43
- * Fetch a biome's fully-resolved database config from the control plane.
44
- *
45
- * POSTs `{ biomeId, dbKey }` to `{base}/system-databases/config` with a
46
- * service bearer. `base` is the control-plane address (see
47
- * {@link resolveControlPlaneBaseUrl}).
48
- *
49
- * @throws on a non-2xx response (carries status + body) — never returns a
50
- * partial or defaulted config.
51
- */
52
- export async function fetchSystemDatabaseConfig(
53
- baseUrl: string,
54
- serviceToken: string,
55
- req: SystemDatabaseConfigRequest,
56
- ): Promise<BiomeDatabaseConfig> {
57
- const url = `${baseUrl.replace(/\/$/, '')}/system-databases/config`;
58
-
59
- const response = await fetch(url, {
60
- method: 'POST',
61
- headers: {
62
- authorization: `Bearer ${serviceToken}`,
63
- 'content-type': 'application/json',
64
- },
65
- body: JSON.stringify({
66
- biomeId: req.biomeId,
67
- ...(req.dbKey !== undefined ? { dbKey: req.dbKey } : {}),
68
- }),
69
- });
70
-
71
- if (!response.ok) {
72
- const body = await response.text().catch(() => '<unreadable body>');
73
- throw new Error(
74
- `fetchSystemDatabaseConfig: ${url} responded ${response.status} ${response.statusText}: ${body}`,
75
- );
76
- }
77
-
78
- const payload = await response.json();
79
- const config = unwrapPlatformResponseEnvelope<BiomeDatabaseConfig>(
80
- payload,
81
- `fetchSystemDatabaseConfig: ${url}`,
82
- );
83
- assertCompleteConfig(config, url);
84
- return config;
85
- }
86
-
87
- const REQUIRED_CONFIG_FIELDS = [
88
- 'host',
89
- 'port',
90
- 'username',
91
- 'password',
92
- 'database',
93
- ] as const;
94
-
95
- /**
96
- * Fail-fast if the resolved config cannot form a connection — a missing/empty
97
- * connection field (or a malformed envelope) is a control-plane contract
98
- * violation, never a silently-defaulted partial. The error names the missing
99
- * fields and the keys actually present, to distinguish "control plane returned
100
- * a partial config" from "response envelope was not unwrapped".
101
- */
102
- function assertCompleteConfig(config: BiomeDatabaseConfig, url: string): void {
103
- // A null / non-object body coerces to `{}`, so every required field reads as
104
- // missing and the diagnostic below fires with "Present keys: []".
105
- const record =
106
- config !== null && typeof config === 'object'
107
- ? (config as unknown as Record<string, unknown>)
108
- : {};
109
- const missing = REQUIRED_CONFIG_FIELDS.filter((field) => {
110
- const value = record[field];
111
- return value === undefined || value === null || value === '';
112
- });
113
- if (missing.length > 0) {
114
- throw new Error(
115
- `fetchSystemDatabaseConfig: ${url} returned a config missing required ` +
116
- `field(s): ${missing.join(', ')}. Present keys: ` +
117
- `[${Object.keys(record).join(', ')}]. (If the keys look nested, the ` +
118
- `{ data } response envelope was not unwrapped.)`,
119
- );
120
- }
121
- }
@@ -1,42 +0,0 @@
1
- /**
2
- * Discovery of the `org-database-pool-api` control plane via the Xema service
3
- * registry (KernelState / etcd) — replacing the former `ORG_DATABASE_POOL_API_URL`
4
- * env var. `org-database-pool-api` self-registers into the registry at boot (it
5
- * uses `XemaServiceModule.forBiome`), so its address is authoritative there.
6
- *
7
- * The DB bootstrap resolves the pool INSIDE the Nest DI instantiation phase
8
- * (the `BIOME_DATABASE_READY` factory runs before any `onModuleInit`), so it uses
9
- * the shared {@link resolveHttpUrlEagerly} helper — which drives a private,
10
- * hydrated `KernelStateServiceRegistry` off the app's injected `KernelState`
11
- * (`KERNEL_STATE_TOKEN`) with bounded retry, because the app-wide registry is not
12
- * yet hydrated at factory time. See that helper for the full rationale.
13
- */
14
- import {
15
- resolveHttpUrlEagerly,
16
- type EagerResolveOptions,
17
- } from '@xemahq/service-registry-nest';
18
-
19
- import type { KernelState } from '@xemahq/kernel-contracts/kernel-state';
20
-
21
- /** Registry name under which `org-database-pool-api` self-registers. */
22
- export const ORG_DATABASE_POOL_SERVICE_NAME = 'org-database-pool-api';
23
-
24
- /** Bounded-backoff tuning for control-plane discovery (alias of the shared shape). */
25
- export type ControlPlaneDiscoveryOptions = EagerResolveOptions;
26
-
27
- /**
28
- * Resolve the `org-database-pool-api` control-plane base URL from the service
29
- * registry, retrying with bounded backoff while the pool is not yet registered
30
- * (a legitimate boot-order race). Fail-fast after the deadline — no silent
31
- * fallback, no unbounded wait.
32
- */
33
- export function resolveControlPlaneBaseUrl(
34
- kernelState: KernelState,
35
- options?: ControlPlaneDiscoveryOptions,
36
- ): Promise<string> {
37
- return resolveHttpUrlEagerly(
38
- kernelState,
39
- ORG_DATABASE_POOL_SERVICE_NAME,
40
- options,
41
- );
42
- }
@@ -1,177 +0,0 @@
1
- import { createHash } from 'node:crypto';
2
-
3
- /**
4
- * Postgres identifier derivation — the SINGLE implementation.
5
- *
6
- * Several independent producers derive Postgres identifiers from free-form
7
- * input: the org database pool derives access-role names from a schema name,
8
- * biome-host derives an org database name and a per-biome schema name, and the
9
- * webapp studio derives per-app database/schema names. They MUST agree, because
10
- * one side creates the object and another side connects to it. Historically each
11
- * re-implemented the same algorithm inline, and they drifted — which is exactly
12
- * how a grant gets created for one role while the connection asks for another.
13
- *
14
- * Everything below is a primitive; the naming CONVENTIONS that use them live
15
- * with their owners (`./role-names` for `_rw`/`_ro`, `./schema-name` for
16
- * `biome_<id>`). The primitives are deliberately parameterised rather than
17
- * flattened into one function, because the callers genuinely differ:
18
- *
19
- * - the length budget differs (a role base must leave room for its `_rw`
20
- * suffix; a schema name may use the full 63 bytes);
21
- * - the character mapping differs (deriving from an already-well-formed
22
- * identifier must NOT collapse `_` runs, or two distinct schemas would share
23
- * one role; composing from free-form segments must collapse, or a `--` in a
24
- * slug would collide with the `__` separator convention);
25
- * - the hash input differs (it is always taken over the RAW, pre-sanitisation
26
- * input, but "the raw input" is a single string for some callers and a
27
- * `:`-joined tuple for others).
28
- *
29
- * Each of those is an explicit argument, so no caller can silently derive an
30
- * identifier another caller disagrees with.
31
- */
32
-
33
- /**
34
- * PostgreSQL caps identifiers at `NAMEDATALEN - 1` = 63 bytes. Beyond that the
35
- * SERVER truncates silently, which is how two distinct logical names become one
36
- * physical object. Every identifier is therefore fitted to an explicit budget by
37
- * {@link fitIdentifier} rather than left to server-side truncation.
38
- */
39
- export const POSTGRES_MAX_IDENTIFIER_LENGTH = 63;
40
-
41
- /**
42
- * Hex characters of the SHA-256 digest spliced into an over-budget identifier.
43
- * 12 hex chars = 48 bits, so accidental collisions between distinct inputs are
44
- * negligible while most of the budget stays human-readable.
45
- */
46
- export const IDENTIFIER_HASH_LENGTH = 12;
47
-
48
- /** A composed prefix must already be a bare, lowercase identifier fragment. */
49
- const IDENTIFIER_PREFIX_PATTERN = /^[a-z][a-z0-9_]*$/;
50
-
51
- /**
52
- * Lowercase, and map every rune outside `[a-z0-9_]` to `_`.
53
- *
54
- * RAW character mapping only: runs of `_` are preserved and leading/trailing `_`
55
- * are kept. This is the correct mapping when deriving an identifier from an
56
- * ALREADY-well-formed identifier (e.g. a role name from a schema name) —
57
- * collapsing there would make the distinct schemas `a__b` and `a_b` share one
58
- * role.
59
- */
60
- export function sanitizeIdentifierChars(value: string): string {
61
- return value.toLowerCase().replace(/[^a-z0-9_]/g, '_');
62
- }
63
-
64
- /**
65
- * {@link sanitizeIdentifierChars}, then collapse runs of `_` and trim
66
- * leading/trailing `_`.
67
- *
68
- * This is the correct mapping for a single free-form SEGMENT (a biome id, an app
69
- * slug, an org id) that is about to be joined to other segments with `_`.
70
- * Returns `''` when nothing survives; the caller decides whether that is fatal.
71
- */
72
- export function sanitizeIdentifierSegment(value: string): string {
73
- return sanitizeIdentifierChars(value)
74
- .replace(/_+/g, '_')
75
- .replace(/^_+|_+$/g, '');
76
- }
77
-
78
- export interface FitIdentifierInput {
79
- /** The already-composed, already-sanitized identifier. */
80
- readonly candidate: string;
81
- /**
82
- * Maximum length `candidate` may occupy. A caller that appends its own suffix
83
- * afterwards (e.g. `_rw`) MUST pass a budget that already excludes it — see
84
- * `SCHEMA_ROLE_BASE_MAX_LENGTH` in `./role-names`.
85
- */
86
- readonly maxLength: number;
87
- /**
88
- * The string hashed to disambiguate over-budget identifiers. Deliberately a
89
- * SEPARATE input from `candidate`: it is hashed over the RAW
90
- * (pre-sanitisation) input, so two inputs whose sanitized forms share a
91
- * prefix still derive different identifiers.
92
- */
93
- readonly hashInput: string;
94
- /** Defaults to {@link IDENTIFIER_HASH_LENGTH}. */
95
- readonly hashLength?: number;
96
- }
97
-
98
- /**
99
- * Fit `candidate` into `maxLength`, splicing in a deterministic hash of
100
- * `hashInput` when it does not fit.
101
- *
102
- * Under the budget the candidate is returned VERBATIM — that is what keeps
103
- * every already-provisioned database, schema, and role name stable. Over the
104
- * budget the result is `<truncated-prefix>_<hash>`, where the prefix is
105
- * right-trimmed of `_` so the separator is never doubled.
106
- */
107
- export function fitIdentifier({
108
- candidate,
109
- maxLength,
110
- hashInput,
111
- hashLength = IDENTIFIER_HASH_LENGTH,
112
- }: FitIdentifierInput): string {
113
- if (!Number.isInteger(maxLength) || maxLength <= hashLength + 1) {
114
- throw new Error(
115
- `fitIdentifier: maxLength must be an integer greater than ${
116
- hashLength + 1
117
- } (hashLength + 1) to leave room for the disambiguating hash; received ${maxLength}`,
118
- );
119
- }
120
-
121
- if (candidate.length <= maxLength) {
122
- return candidate;
123
- }
124
-
125
- const hash = createHash('sha256')
126
- .update(hashInput)
127
- .digest('hex')
128
- .slice(0, hashLength);
129
- const prefix = candidate
130
- .slice(0, maxLength - hashLength - 1)
131
- .replace(/_+$/g, '');
132
- return `${prefix}_${hash}`;
133
- }
134
-
135
- export interface ComposeIdentifierInput {
136
- /**
137
- * Literal namespace prefix (e.g. `app`, `biome`). It is NOT sanitized — it
138
- * must already be a bare lowercase identifier fragment — and it is NOT part
139
- * of the disambiguating hash input, because it is a constant per producer.
140
- */
141
- readonly prefix: string;
142
- /** Free-form segments; each is sanitized, and empty results are dropped. */
143
- readonly parts: readonly string[];
144
- /** Defaults to {@link POSTGRES_MAX_IDENTIFIER_LENGTH}. */
145
- readonly maxLength?: number;
146
- }
147
-
148
- /**
149
- * Compose `<prefix>_<segment>_<segment>…`, fitted to the budget.
150
- *
151
- * The disambiguating hash is taken over the RAW `parts` joined by `:` — the
152
- * separator cannot occur in a Postgres identifier, so the tuple is unambiguous
153
- * and `['a_b']` can never hash the same as `['a', 'b']`.
154
- */
155
- export function composeIdentifier({
156
- prefix,
157
- parts,
158
- maxLength = POSTGRES_MAX_IDENTIFIER_LENGTH,
159
- }: ComposeIdentifierInput): string {
160
- if (!IDENTIFIER_PREFIX_PATTERN.test(prefix)) {
161
- throw new Error(
162
- `composeIdentifier: prefix "${prefix}" is not a bare lowercase identifier fragment (${String(
163
- IDENTIFIER_PREFIX_PATTERN,
164
- )})`,
165
- );
166
- }
167
-
168
- const segments = parts
169
- .map((part) => sanitizeIdentifierSegment(part))
170
- .filter((part) => part.length > 0);
171
-
172
- return fitIdentifier({
173
- candidate: [prefix, ...segments].join('_'),
174
- maxLength,
175
- hashInput: parts.join(':'),
176
- });
177
- }
@@ -1,25 +0,0 @@
1
- /**
2
- * Who owns schema creation, extension installation, and Prisma migrations.
3
- *
4
- * This is deliberately required at every bootstrap call. Database ownership
5
- * must never change because an option was omitted or an environment variable
6
- * was misspelled.
7
- *
8
- * There is exactly ONE mode. `ExternallyManaged` was removed with the plane it
9
- * named: a runtime-only credential, per-schema `xema_runtime_*` roles, and a
10
- * verifier that proved a service's role could not alter its own schema. None of
11
- * it was ever provisioned — the roles were never created, and the workflows
12
- * that would have created them require a runner label no runner carries and a
13
- * privileged environment this org's GitHub plan cannot express. Every one of
14
- * the 31 shared-biome consumers is `application-managed`.
15
- *
16
- * The VALUE is gone rather than merely unimplemented, deliberately. Leaving it
17
- * in the enum with nothing behind it would let a service declare a mode that
18
- * silently did nothing — a degradation path disguised as configuration. Now
19
- * declaring it is a compile error, which is the honest answer while the plane
20
- * does not exist. If externally-managed migrations are ever built for real,
21
- * this member returns with the implementation, not before it.
22
- */
23
- export enum BiomeDatabaseMigrationMode {
24
- ApplicationManaged = 'application-managed',
25
- }
@@ -1,175 +0,0 @@
1
- import { Module } from '@nestjs/common';
2
- import { IdentityBootstrapService } from '@xemahq/identity-client';
3
- import {
4
- isOpenApiExtraction,
5
- KERNEL_STATE_TOKEN,
6
- } from '@xemahq/service-registry-nest';
7
-
8
- import { bootstrapBiomeDatabases } from '../bootstrap';
9
- import { BiomeDatabaseMigrationMode } from '../migration-mode';
10
-
11
- import type {
12
- BiomeDatabaseDeclaration,
13
- } from '../bootstrap';
14
- import type { DynamicModule } from '@nestjs/common';
15
- import type { KernelState } from '@xemahq/kernel-contracts/kernel-state';
16
-
17
- /**
18
- * DI-resolved sentinel that resolves to `true` once every declared biome
19
- * database has been schema-created, migrated, and exported into the env that
20
- * `createBiomePrismaAdapterFromEnv` reads.
21
- *
22
- * A consuming service MUST list this token in the `inject:[]` of its
23
- * `PrismaService` provider so Nest constructs the database connection ONLY
24
- * after the bootstrap has completed.
25
- */
26
- export const BIOME_DATABASE_READY = Symbol.for(
27
- '@xemahq/biome-database-nest:ready',
28
- );
29
-
30
- interface BiomeDatabaseModuleCommonOptions {
31
- /** The biome whose databases are being bootstrapped. */
32
- readonly biomeId: string;
33
- }
34
-
35
- export interface ApplicationManagedBiomeDatabaseModuleOptions
36
- extends BiomeDatabaseModuleCommonOptions {
37
- readonly migrationMode: BiomeDatabaseMigrationMode.ApplicationManaged;
38
- /**
39
- * Explicit databases to bootstrap. Each declaration pins the credential
40
- * generation independently of migration ownership.
41
- */
42
- readonly databases: readonly BiomeDatabaseDeclaration[];
43
- /**
44
- * Postgres extensions this biome needs (e.g. `['vector']`), ensured in the
45
- * shared `public` schema BEFORE migrations run. Only declare extensions the
46
- * biome actually uses. See `BootstrapBiomeDatabasesOptions.requiredExtensions`.
47
- */
48
- readonly requiredExtensions?: readonly string[];
49
- }
50
-
51
-
52
- /** Options for {@link BiomeDatabaseModule.forRoot}. */
53
- export type BiomeDatabaseModuleOptions =
54
- ApplicationManagedBiomeDatabaseModuleOptions;
55
-
56
- /**
57
- * Global Nest dynamic module that bootstraps a biome's database using an
58
- * explicitly selected migration ownership mode.
59
- *
60
- * Application-managed mode retains env-first config resolution. Externally
61
- * managed declarations select environment or control plane explicitly, so a
62
- * stale env override can never silently replace the intended runtime
63
- * credential.
64
- *
65
- * `IdentityBootstrapService` (from `@xemahq/identity-client`) is provided
66
- * globally by the app's `XemaServiceModule.forBiome(...)` bootstrap, so it is
67
- * injectable at DI time and mints the service bearer for the control-plane
68
- * call. The control-plane ADDRESS is discovered from the Xema service registry:
69
- * this factory injects the app's `KernelState` backend via `KERNEL_STATE_TOKEN`
70
- * (exposed globally by `ServiceRegistryModule`, wired by
71
- * `XemaServiceModule.forBiome(...)`) and drives a private, short-lived
72
- * `KernelStateServiceRegistry` to `resolve('org-database-pool-api')` with
73
- * bounded retry — see `resolveControlPlaneBaseUrl` in `control-plane-discovery`.
74
- * The app-wide registry is NOT injected: it hydrates only in `onModuleInit`,
75
- * which cannot run until this instantiation-phase factory returns.
76
- *
77
- * REQUIRES the consuming app to provide `KERNEL_STATE_TOKEN` globally — every
78
- * biome service does via `XemaServiceModule.forBiome(...)` /
79
- * `ServiceRegistryModule`. No extra import in this module is needed because that
80
- * provider is global; if it is absent, Nest fails fast at DI resolution.
81
- *
82
- * Fail-fast: an absent/unknown mode or any boot error aborts Nest startup.
83
- *
84
- * @example Consumer wiring
85
- * ```ts
86
- * // app.module.ts (or prisma.module.ts):
87
- * imports: [BiomeDatabaseModule.forRoot({
88
- * biomeId: '<biome-id>',
89
- * })],
90
- *
91
- * // prisma.module.ts — the PrismaService provider waits on the boot:
92
- * {
93
- * provide: PrismaService,
94
- * useFactory: () => new PrismaService(),
95
- * inject: [BIOME_DATABASE_READY],
96
- * }
97
- * ```
98
- */
99
- @Module({})
100
- export class BiomeDatabaseModule {
101
- static forRoot(opts: BiomeDatabaseModuleOptions): DynamicModule {
102
- if (
103
- opts.migrationMode !== BiomeDatabaseMigrationMode.ApplicationManaged
104
- ) {
105
- throw new Error(
106
- '[biome-database] migrationMode is required and must be a BiomeDatabaseMigrationMode.',
107
- );
108
- }
109
- if (opts.databases.length === 0) {
110
- throw new Error(
111
- '[biome-database] every migration mode requires at least one explicit database declaration.',
112
- );
113
- }
114
- const databases:
115
- | readonly BiomeDatabaseDeclaration[]
116
- = opts.databases;
117
-
118
- // Build-time OpenAPI extraction compiles the DI graph for decorator
119
- // metadata only: it never calls `app.init()`, never issues a query, and
120
- // stubs `PrismaService` outright. Running the real bootstrap there would
121
- // connect to — and MIGRATE — a throwaway scratch database, which is both
122
- // pointless and (under parallel extraction) mutually destructive. Resolve
123
- // the sentinel to `true` without injecting anything.
124
- if (isOpenApiExtraction()) {
125
- return {
126
- module: BiomeDatabaseModule,
127
- global: true,
128
- providers: [{ provide: BIOME_DATABASE_READY, useValue: true }],
129
- exports: [BIOME_DATABASE_READY],
130
- };
131
- }
132
-
133
- return {
134
- module: BiomeDatabaseModule,
135
- global: true,
136
- providers: [
137
- {
138
- provide: BIOME_DATABASE_READY,
139
- useFactory: async (
140
- identityBootstrap: IdentityBootstrapService,
141
- kernelState: KernelState,
142
- ): Promise<true> => {
143
- if (
144
- opts.migrationMode ===
145
- BiomeDatabaseMigrationMode.ApplicationManaged
146
- ) {
147
- await bootstrapBiomeDatabases({
148
- biomeId: opts.biomeId,
149
- migrationMode: opts.migrationMode,
150
- databases: databases as readonly BiomeDatabaseDeclaration[],
151
- ...(opts.requiredExtensions !== undefined
152
- ? { requiredExtensions: opts.requiredExtensions }
153
- : {}),
154
- serviceToken: () => identityBootstrap.getAccessToken(),
155
- kernelState,
156
- });
157
- } else {
158
- await bootstrapBiomeDatabases({
159
- biomeId: opts.biomeId,
160
- migrationMode: opts.migrationMode,
161
- databases:
162
- databases,
163
- serviceToken: () => identityBootstrap.getAccessToken(),
164
- kernelState,
165
- });
166
- }
167
- return true;
168
- },
169
- inject: [IdentityBootstrapService, KERNEL_STATE_TOKEN],
170
- },
171
- ],
172
- exports: [BIOME_DATABASE_READY],
173
- };
174
- }
175
- }