@xemahq/biome-database-nest 0.12.1 → 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/package.json +4 -5
- package/src/index.ts +0 -115
- package/src/lib/adapter.ts +0 -117
- package/src/lib/bootstrap.ts +0 -428
- package/src/lib/config.ts +0 -183
- package/src/lib/control-plane-client.ts +0 -124
- 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 -174
- 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/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
|
-
}
|
|
@@ -1,124 +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: unknown = unwrapPlatformResponseEnvelope<unknown>(
|
|
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(
|
|
103
|
-
config: unknown,
|
|
104
|
-
url: string,
|
|
105
|
-
): asserts config is BiomeDatabaseConfig {
|
|
106
|
-
// A null / non-object body coerces to `{}`, so every required field reads as
|
|
107
|
-
// missing and the diagnostic below fires with "Present keys: []".
|
|
108
|
-
const record: Record<string, unknown> =
|
|
109
|
-
config !== null && typeof config === 'object'
|
|
110
|
-
? Object.fromEntries(Object.entries(config))
|
|
111
|
-
: {};
|
|
112
|
-
const missing = REQUIRED_CONFIG_FIELDS.filter((field) => {
|
|
113
|
-
const value = record[field];
|
|
114
|
-
return value === undefined || value === null || value === '';
|
|
115
|
-
});
|
|
116
|
-
if (missing.length > 0) {
|
|
117
|
-
throw new Error(
|
|
118
|
-
`fetchSystemDatabaseConfig: ${url} returned a config missing required ` +
|
|
119
|
-
`field(s): ${missing.join(', ')}. Present keys: ` +
|
|
120
|
-
`[${Object.keys(record).join(', ')}]. (If the keys look nested, the ` +
|
|
121
|
-
`{ data } response envelope was not unwrapped.)`,
|
|
122
|
-
);
|
|
123
|
-
}
|
|
124
|
-
}
|
|
@@ -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
|
-
}
|