@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
|
@@ -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
|
-
}
|
package/src/lib/identifier.ts
DELETED
|
@@ -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
|
-
}
|