@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,158 +0,0 @@
1
- /**
2
- * LC-1 topology-1 org-erasure ENDPOINT — the DRY adoption surface.
3
- *
4
- * A first-party biome that holds org data by-row opts in with ONE line:
5
- *
6
- * ```ts
7
- * imports: [OrgErasureModule.forRoot({ prismaServiceToken: PrismaService })]
8
- * ```
9
- *
10
- * That exposes the STANDARD internal route `POST /internal/org-erasure/:orgId`,
11
- * which erases every row belonging to the org across the service's org-scoped
12
- * models via {@link eraseOrgData} on the UNSCOPED client (`asSystem()`). The
13
- * org-erasure orchestrator saga fans out to this route on every participant.
14
- *
15
- * Service-only, and ENFORCED by `ServiceActorGuard` — not by the classification
16
- * decorator. `@XemaInternalRoute({ audience: Service })` is metadata the
17
- * fail-closed route scanner reads; it installs NO runtime guard (see
18
- * `.claude/rules/public-and-internal-route-guarding.md`). The global stack only
19
- * proves the JWT signature, and `RolesGuard` enforces nothing unless a route
20
- * declares `@RequireOrgRole`/`@RequireTokenClass`. Without the guard, ANY
21
- * authenticated user token reaching this service — trivially so for any
22
- * participant whose chart enables ingress — could irreversibly erase an
23
- * arbitrary org's data, since `:orgId` is taken verbatim from the path. This
24
- * module is adopted by 17 participants, so the guard belongs HERE, once.
25
- *
26
- * The org filter is applied inside {@link eraseOrgData}; the runner passes the
27
- * unscoped client so scoping is neither doubled nor left to ambient context.
28
- *
29
- * **The module refuses to boot a participant that can erase nothing.** Wiring
30
- * the runner asserts (via {@link assertOrgErasureMatchesModels}) that the
31
- * configured `orgField` matches at least one model of THIS service's schema. A
32
- * participant registered against a column its schema does not spell matches
33
- * zero models, deletes nothing, and returns `{totalDeleted: 0}` — which the
34
- * orchestrator records as a successful erasure. That must fail at deploy, not
35
- * silently succeed during a Data Subject Request.
36
- */
37
- import {
38
- Controller,
39
- Inject,
40
- Logger,
41
- Param,
42
- Post,
43
- UseGuards,
44
- type DynamicModule,
45
- type Type,
46
- } from '@nestjs/common';
47
- import { ApiOperation, ApiTags } from '@nestjs/swagger';
48
- import { ServiceActorGuard } from '@xemahq/platform-common';
49
- import { InternalRouteAudience, XemaInternalRoute } from '@xemahq/xema-decorators';
50
-
51
- import {
52
- assertOrgErasureMatchesModels,
53
- eraseOrgData,
54
- type EraseOrgDataOptions,
55
- type OrgErasureReport,
56
- } from '../tenant-isolation/org-erasure';
57
- import { DEFAULT_ORG_FIELD } from '../tenant-isolation/tenant-isolation.extension';
58
-
59
- import type { TenantScopedApi } from './prisma-service-factory';
60
-
61
- /** DI token for the erasure runner the controller delegates to. */
62
- export const ORG_ERASURE_RUNNER = Symbol.for(
63
- '@xemahq/biome-database-nest/org-erasure-runner',
64
- );
65
-
66
- /**
67
- * The capability a topology-1 participant declares in its service descriptor's
68
- * `exposesCapabilities` so the org-erasure orchestrator can DISCOVER it via the
69
- * service registry (`registry.list()` filtered on this string) and fan out to
70
- * its `POST /internal/org-erasure/:orgId`. Single source of truth shared by the
71
- * declaring service and the orchestrator — discovery is explicit-by-declaration,
72
- * never a probe-and-guess heuristic.
73
- */
74
- export const ORG_DATA_ERASURE_CAPABILITY = 'xema.org-data-erasure';
75
-
76
- /** The internal route path this module exposes (orchestrator fans out to it). */
77
- export const ORG_ERASURE_ROUTE_PATH = 'internal/org-erasure';
78
-
79
- export interface OrgErasureRunner {
80
- erase(orgId: string): Promise<OrgErasureReport>;
81
- }
82
-
83
- @ApiTags('org-erasure')
84
- @UseGuards(ServiceActorGuard)
85
- @Controller('internal/org-erasure')
86
- export class OrgErasureController {
87
- constructor(
88
- @Inject(ORG_ERASURE_RUNNER) private readonly runner: OrgErasureRunner,
89
- ) {}
90
-
91
- @XemaInternalRoute({ audience: InternalRouteAudience.Service })
92
- @Post(':orgId')
93
- @ApiOperation({
94
- summary:
95
- "Erase every row belonging to an org across this service's org-scoped models",
96
- })
97
- erase(@Param('orgId') orgId: string): Promise<OrgErasureReport> {
98
- return this.runner.erase(orgId);
99
- }
100
- }
101
-
102
- export interface OrgErasureModuleOptions extends EraseOrgDataOptions {
103
- /**
104
- * The service's PrismaService DI token — the class it provides as its Prisma
105
- * service (a {@link BiomePrismaServiceInstance}). The runner resolves it and
106
- * calls `.asSystem()` to get the unscoped client.
107
- */
108
- readonly prismaServiceToken: Type<unknown> | string | symbol;
109
- }
110
-
111
- /**
112
- * The subset of the biome Prisma service the runner needs: the unscoped
113
- * `asSystem()` client accessor.
114
- */
115
- type AsSystemProvider = TenantScopedApi<unknown>;
116
-
117
- export class OrgErasureModule {
118
- static forRoot(options: OrgErasureModuleOptions): DynamicModule {
119
- const { prismaServiceToken, ...eraseOptions } = options;
120
- return {
121
- module: OrgErasureModule,
122
- controllers: [OrgErasureController],
123
- providers: [
124
- {
125
- provide: ORG_ERASURE_RUNNER,
126
- useFactory: (prisma: AsSystemProvider): OrgErasureRunner => {
127
- const client = prisma.asSystem();
128
- // BOOT GATE. A participant whose orgField matches no model can
129
- // never delete a row, and `eraseOrgData` would have answered every
130
- // erasure `{totalDeleted: 0}` — which the orchestrator saga records
131
- // as a successful erasure. Discovering that during a live Data
132
- // Subject Request is discovering it too late, so the service
133
- // refuses to START rather than advertise `xema.org-data-erasure`
134
- // it cannot honour. Throwing here aborts Nest's bootstrap.
135
- //
136
- // This is the CONFIGURATION proof; `eraseOrgData` re-asserts per
137
- // request, which is the proof of the RUN being recorded as
138
- // evidence. The assertion is on MODELS matched — an org that
139
- // genuinely holds no rows here still erases successfully.
140
- const models = [
141
- ...assertOrgErasureMatchesModels(client, eraseOptions),
142
- ].sort();
143
- new Logger(OrgErasureModule.name).log(
144
- `org-erasure participant armed on "${
145
- eraseOptions.orgField ?? DEFAULT_ORG_FIELD
146
- }" over ${models.length} model(s): ${models.join(', ')}`,
147
- );
148
- return {
149
- erase: (orgId: string): Promise<OrgErasureReport> =>
150
- eraseOrgData(prisma.asSystem(), orgId, eraseOptions),
151
- };
152
- },
153
- inject: [prismaServiceToken],
154
- },
155
- ],
156
- };
157
- }
158
- }
@@ -1,377 +0,0 @@
1
- import { Injectable, Logger } from '@nestjs/common';
2
- import { getAmbientOrgId } from '@xemahq/platform-common';
3
-
4
- import { createBiomePrismaAdapterFromEnv } from '../adapter';
5
- import {
6
- TenantIsolationMode,
7
- resolveTenantIsolationMode,
8
- } from '../tenant-isolation/tenant-isolation-mode';
9
- import {
10
- DEFAULT_ORG_FIELD,
11
- collectOrgScopedModels,
12
- createTenantIsolationExtension,
13
- readRuntimeModels,
14
- type TenantIsolationLogger,
15
- } from '../tenant-isolation/tenant-isolation.extension';
16
- import {
17
- assertUnscopedModelsDeclared,
18
- type UnscopedModelDeclarations,
19
- } from '../tenant-isolation/unscoped-models';
20
-
21
- import type { OnModuleDestroy, OnModuleInit, Type } from '@nestjs/common';
22
-
23
- /**
24
- * Minimal structural shape of a generated Prisma `PrismaClient` as far as the
25
- * lifecycle wrapper cares: it is a constructor whose single (optional) argument
26
- * is the Prisma client options bag (we inject `{ adapter }`), and instances
27
- * expose the `$connect`/`$disconnect` lifecycle methods.
28
- *
29
- * It is deliberately structural and `any`-arg so that EVERY service's own
30
- * generated `PrismaClient` — each with a different model surface — satisfies it
31
- * without the SDK having to know any concrete schema.
32
- */
33
- export type BiomePrismaClientCtor<TClient extends BiomePrismaClient> = new (
34
- // The generated PrismaClient constructor's options arg is schema-specific;
35
- // we only ever pass `{ adapter }`, which every generated client accepts.
36
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
37
- ...args: any[]
38
- ) => TClient;
39
-
40
- /** The lifecycle surface every generated `PrismaClient` exposes. */
41
- export interface BiomePrismaClient {
42
- $connect(): Promise<void>;
43
- $disconnect(): Promise<void>;
44
- }
45
-
46
- /** Tenant-isolation knobs a service may set on its Prisma service. */
47
- export interface TenantIsolationServiceOptions {
48
- /**
49
- * Mode for the DEFAULT (ambient-scoped) client. Resolution: this option >
50
- * `XEMA_TENANT_ISOLATION_MODE` env var > `Observe`.
51
- */
52
- readonly mode?: TenantIsolationMode;
53
- /** Org column detected on models (default `orgId`). */
54
- readonly orgField?: string;
55
- /**
56
- * Models that carry the org column but are deliberately NOT tenant-scoped.
57
- * Names must match the Prisma schema model names; unknown names throw.
58
- */
59
- readonly exemptModels?: readonly string[];
60
- /**
61
- * Models that declare NO org column at all — the ones the isolation extension
62
- * is structurally unable to auto-scope — each with a written justification.
63
- *
64
- * This is REQUIRED to be exhaustive: in `Enforce` mode a model with no org
65
- * column and no declaration ABORTS BOOT
66
- * ({@link UndeclaredUnscopedModelsError}); in `Observe` it is a loud warning.
67
- * Previously such a model was simply invisible to enforcement, so `Enforce`
68
- * gave false assurance — see `../tenant-isolation/unscoped-models.ts` for the
69
- * incident that motivated this.
70
- *
71
- * A model that is tenant-scoped in reality (owned through a foreign key) MUST
72
- * also state `fencedBy` — the manual predicate its queries carry.
73
- *
74
- * @example
75
- * ```ts
76
- * unscopedModels: {
77
- * MigrationBookkeeping: 'internal bookkeeping; rows belong to no tenant',
78
- * ChangeRequest: {
79
- * reason: 'owned via projectRepositoryBindingId -> … -> OrgRepository.orgId',
80
- * fencedBy: 'projectRepositoryBinding: { orgRepository: { orgId } }',
81
- * },
82
- * }
83
- * ```
84
- */
85
- readonly unscopedModels?: UnscopedModelDeclarations;
86
- }
87
-
88
- /** Options bag for {@link createBiomePrismaService}. */
89
- export interface CreateBiomePrismaServiceOptions {
90
- /** Logger name (defaults to `'PrismaService'`). */
91
- readonly loggerContext?: string;
92
- readonly tenantIsolation?: TenantIsolationServiceOptions;
93
- }
94
-
95
- /**
96
- * The explicit-scoping surface every biome Prisma service now carries in
97
- * addition to its full generated-client surface.
98
- */
99
- export interface TenantScopedApi<TClient> {
100
- /**
101
- * A client hard-bound to `orgId` — for jobs, workers, Temporal activities,
102
- * contribution sync, and any other path with no ambient request context.
103
- * ALWAYS enforces (regardless of the global mode): explicit scoping is a
104
- * correctness request and is never silently degraded.
105
- */
106
- forOrg(orgId: string): TClient;
107
- /**
108
- * The raw, UNSCOPED client for legitimate cross-org system paths (boot
109
- * backfills, platform-level admin surfaces). Logged at debug level once per
110
- * call site so system access stays auditable without log floods.
111
- */
112
- asSystem(): TClient;
113
- }
114
-
115
- /** Instance type produced by {@link createBiomePrismaService}. */
116
- export type BiomePrismaServiceInstance<TClient> = TClient &
117
- TenantScopedApi<TClient> &
118
- OnModuleInit &
119
- OnModuleDestroy;
120
-
121
- interface ExtendableClient {
122
- $extends?: (extension: unknown) => unknown;
123
- }
124
-
125
- function lowerFirst(name: string): string {
126
- return name.length === 0
127
- ? name
128
- : name.charAt(0).toLowerCase() + name.slice(1);
129
- }
130
-
131
- function normalizeOptions(
132
- optionsOrLoggerContext: string | CreateBiomePrismaServiceOptions | undefined,
133
- ): CreateBiomePrismaServiceOptions {
134
- if (optionsOrLoggerContext === undefined) {
135
- return {};
136
- }
137
- if (typeof optionsOrLoggerContext === 'string') {
138
- return { loggerContext: optionsOrLoggerContext };
139
- }
140
- return optionsOrLoggerContext;
141
- }
142
-
143
- /**
144
- * Build the `@Injectable()` Prisma service class for a biome from ITS OWN
145
- * generated `PrismaClient` constructor.
146
- *
147
- * Eliminates the byte-identical `prisma.service.ts` every biome service
148
- * hand-writes: the ONLY per-service difference is which generated client is
149
- * extended. A service supplies that client and gets the schema-qualified
150
- * driver-adapter (via {@link createBiomePrismaAdapterFromEnv}, the package's
151
- * single correct adapter construction — REUSED, not reimplemented) plus the
152
- * Nest `OnModuleInit`/`OnModuleDestroy` `$connect`/`$disconnect` lifecycle for
153
- * free.
154
- *
155
- * **Tenant isolation (C3).** Org-scoped models (those declaring the org
156
- * column, default `orgId`) are detected from the client's runtime data model
157
- * at construction time, and the DEFAULT injected client routes their delegates
158
- * (plus `$transaction`) through a Prisma client extension that scopes every
159
- * operation to the AMBIENT org (`AmbientOrgContextMiddleware` /
160
- * `runWithAmbientOrg`) per {@link TenantIsolationMode}:
161
- *
162
- * - `Observe` (default): no query is rewritten; violations are warned.
163
- * - `Enforce`: org filters are injected; violations throw
164
- * `TenantIsolationError`. Outside any request context, org-model access
165
- * throws — use `forOrg(orgId)` / `asSystem()` (see {@link TenantScopedApi}).
166
- * - `Off`: passthrough kill switch (detection is deferred so `Off` can never
167
- * fail a boot; `forOrg` still enforces on first use).
168
- *
169
- * Fail-fast: a failed `$connect()` at `onModuleInit` is logged and rethrown so
170
- * Nest aborts startup. `$disconnect()` errors at shutdown are logged but not
171
- * rethrown (the process is already going down; masking shutdown noise must not
172
- * abort an otherwise-clean teardown).
173
- *
174
- * The returned class is a concrete subclass of the supplied client, so callers
175
- * keep the FULL typed `PrismaClient` surface (every model delegate, every
176
- * `$`-method) on the resulting service — plus `forOrg`/`asSystem`.
177
- *
178
- * @example Per-service usage — the entire `prisma.service.ts` becomes:
179
- * ```ts
180
- * import { createBiomePrismaService } from '@xemahq/biome-database-nest';
181
- * import { PrismaClient } from '../../generated/prisma-client'; // its own
182
- *
183
- * export class PrismaService extends createBiomePrismaService(PrismaClient) {}
184
- * ```
185
- *
186
- * The `provide: PrismaService` factory still gates on `BIOME_DATABASE_READY`
187
- * exactly as before — this factory changes only how the class body is written.
188
- *
189
- * @param PrismaClientCtor the service's OWN generated `PrismaClient` constructor.
190
- * @param optionsOrLoggerContext either the legacy logger-context string
191
- * (defaults to `'PrismaService'`) or a {@link CreateBiomePrismaServiceOptions}
192
- * bag carrying `loggerContext` + `tenantIsolation`.
193
- */
194
- export function createBiomePrismaService<TClient extends BiomePrismaClient>(
195
- PrismaClientCtor: BiomePrismaClientCtor<TClient>,
196
- optionsOrLoggerContext?: string | CreateBiomePrismaServiceOptions,
197
- ): Type<BiomePrismaServiceInstance<TClient>> {
198
- const options = normalizeOptions(optionsOrLoggerContext);
199
- const loggerContext = options.loggerContext ?? 'PrismaService';
200
- const tenantOptions = options.tenantIsolation ?? {};
201
- const orgField = tenantOptions.orgField ?? DEFAULT_ORG_FIELD;
202
- const exemptModels = tenantOptions.exemptModels ?? [];
203
- const unscopedModels = tenantOptions.unscopedModels ?? {};
204
-
205
- @Injectable()
206
- class BiomePrismaService
207
- extends (PrismaClientCtor as BiomePrismaClientCtor<BiomePrismaClient>)
208
- implements OnModuleInit, OnModuleDestroy
209
- {
210
- private readonly biomePrismaLogger = new Logger(loggerContext);
211
-
212
- constructor() {
213
- super({ adapter: createBiomePrismaAdapterFromEnv() });
214
-
215
- // Fail-fast at construction: an invalid XEMA_TENANT_ISOLATION_MODE (or
216
- // option) must abort boot, never fall back to a weaker mode.
217
- const mode = resolveTenantIsolationMode(tenantOptions.mode);
218
- const raw = this;
219
- const tenantLogger: TenantIsolationLogger = new Logger(
220
- `${loggerContext}:tenant-isolation`,
221
- );
222
-
223
- const requireExtends = (): ((extension: unknown) => unknown) => {
224
- const extendsFn = (raw as ExtendableClient).$extends;
225
- if (typeof extendsFn !== 'function') {
226
- throw new Error(
227
- '[tenant-isolation] the Prisma client has no $extends — tenant ' +
228
- 'isolation requires a generated @prisma/client >= 5 (this SDK peers on ^7).',
229
- );
230
- }
231
- return extendsFn.bind(raw);
232
- };
233
-
234
- // Org-scoped-model detection reads the client's runtime data model. In
235
- // Off mode it is DEFERRED (the kill switch must never be able to fail a
236
- // boot); forOrg() still resolves it lazily on first use.
237
- let scopedModelsCache: ReadonlySet<string> | undefined;
238
- const getScopedModels = (): ReadonlySet<string> => {
239
- scopedModelsCache ??= collectOrgScopedModels(
240
- raw,
241
- orgField,
242
- exemptModels,
243
- );
244
- return scopedModelsCache;
245
- };
246
-
247
- // Close the blind spot BEFORE any client is handed out: a model with no
248
- // org column can never be auto-scoped, so enforcement silently does not
249
- // apply to it. Every such model must be DECLARED with a justification.
250
- // Enforce → throws here and Nest aborts startup; Observe → one loud warn;
251
- // Off → skipped with the rest of the detection (kill switch).
252
- if (mode !== TenantIsolationMode.Off) {
253
- assertUnscopedModelsDeclared({
254
- models: readRuntimeModels(raw),
255
- orgField,
256
- mode,
257
- unscopedModels,
258
- exemptModels,
259
- logger: tenantLogger,
260
- });
261
- }
262
-
263
- const forOrg = (orgId: string): TClient => {
264
- if (typeof orgId !== 'string' || orgId === '') {
265
- throw new Error(
266
- '[tenant-isolation] forOrg requires a non-empty orgId. For a ' +
267
- 'legitimate cross-org system path use asSystem().',
268
- );
269
- }
270
- // Explicit scoping ALWAYS enforces, regardless of the global mode.
271
- const orgScopedModels = getScopedModels();
272
- return requireExtends()(
273
- createTenantIsolationExtension({
274
- orgScopedModels,
275
- orgField,
276
- mode: TenantIsolationMode.Enforce,
277
- orgIdSource: () => orgId,
278
- logger: tenantLogger,
279
- }),
280
- ) as TClient;
281
- };
282
-
283
- const seenSystemCallSites = new Set<string>();
284
- const asSystem = (): TClient => {
285
- // Debug-log each distinct call site ONCE (auditable, no log floods).
286
- const callSite = new Error().stack?.split('\n')[2]?.trim();
287
- if (callSite !== undefined && !seenSystemCallSites.has(callSite)) {
288
- seenSystemCallSites.add(callSite);
289
- tenantLogger.debug(
290
- `[tenant-isolation] asSystem() unscoped access from ${callSite}`,
291
- );
292
- }
293
- return raw as unknown as TClient;
294
- };
295
-
296
- if (mode === TenantIsolationMode.Off) {
297
- // Kill switch: the default client is the raw client; only the
298
- // explicit-scoping API is layered on.
299
- return new Proxy(raw, {
300
- get(target, prop, _receiver): unknown {
301
- if (prop === 'forOrg') {
302
- return forOrg;
303
- }
304
- if (prop === 'asSystem') {
305
- return asSystem;
306
- }
307
- const value = Reflect.get(target, prop, target);
308
- return typeof value === 'function' ? value.bind(target) : value;
309
- },
310
- });
311
- }
312
-
313
- const ambientClient = requireExtends()(
314
- createTenantIsolationExtension({
315
- orgScopedModels: getScopedModels(),
316
- orgField,
317
- mode,
318
- orgIdSource: getAmbientOrgId,
319
- logger: tenantLogger,
320
- }),
321
- ) as Record<string, unknown>;
322
-
323
- // Delegates of org-scoped models — and $transaction, so interactive
324
- // transactions get extension-applied tx clients — route through the
325
- // ambient-scoped extension; everything else hits the raw client.
326
- const routedProps = new Set<string>(['$transaction']);
327
- for (const modelName of getScopedModels()) {
328
- routedProps.add(lowerFirst(modelName));
329
- }
330
-
331
- return new Proxy(raw, {
332
- get(target, prop, _receiver): unknown {
333
- if (prop === 'forOrg') {
334
- return forOrg;
335
- }
336
- if (prop === 'asSystem') {
337
- return asSystem;
338
- }
339
- if (typeof prop === 'string' && routedProps.has(prop)) {
340
- const value = ambientClient[prop];
341
- return typeof value === 'function'
342
- ? (value as (...args: unknown[]) => unknown).bind(ambientClient)
343
- : value;
344
- }
345
- const value = Reflect.get(target, prop, target);
346
- return typeof value === 'function' ? value.bind(target) : value;
347
- },
348
- });
349
- }
350
-
351
- async onModuleInit(): Promise<void> {
352
- try {
353
- await this.$connect();
354
- this.biomePrismaLogger.log('Connected to database');
355
- } catch (error) {
356
- this.biomePrismaLogger.error('Failed to connect to database', error);
357
- throw error;
358
- }
359
- }
360
-
361
- async onModuleDestroy(): Promise<void> {
362
- try {
363
- await this.$disconnect();
364
- this.biomePrismaLogger.log('Disconnected from database');
365
- } catch (error) {
366
- this.biomePrismaLogger.error(
367
- 'Error disconnecting from database',
368
- error,
369
- );
370
- }
371
- }
372
- }
373
-
374
- return BiomePrismaService as unknown as Type<
375
- BiomePrismaServiceInstance<TClient>
376
- >;
377
- }
@@ -1,77 +0,0 @@
1
- import {
2
- fitIdentifier,
3
- POSTGRES_MAX_IDENTIFIER_LENGTH,
4
- sanitizeIdentifierChars,
5
- } from './identifier';
6
-
7
- /**
8
- * Per-schema Postgres access roles.
9
- *
10
- * The org database pool CREATES these roles and grants them on the schema; every
11
- * consumer that mints a connection asks for one of them BY NAME. Provisioner and
12
- * consumer therefore have to derive byte-identical names from the same schema
13
- * name — this module is the single place that derivation exists.
14
- */
15
- export enum SchemaRoleKind {
16
- ReadWrite = 'rw',
17
- ReadOnly = 'ro',
18
- }
19
-
20
- /** `_` + a two-character {@link SchemaRoleKind} value, e.g. `_rw`. */
21
- export const SCHEMA_ROLE_SUFFIX_LENGTH = 3;
22
-
23
- /**
24
- * Budget for the role BASE. Deliberately smaller than
25
- * {@link POSTGRES_MAX_IDENTIFIER_LENGTH}: the base gets a `_rw` / `_ro` suffix
26
- * appended, and the FULL role name is what has to fit in Postgres's 63 bytes.
27
- * Fitting the base to 63 would let `<63-char base>_rw` be truncated by the
28
- * server to the same 63 bytes as `<same base>_ro`, silently collapsing the
29
- * read-write and read-only roles into one.
30
- */
31
- export const SCHEMA_ROLE_BASE_MAX_LENGTH =
32
- POSTGRES_MAX_IDENTIFIER_LENGTH - SCHEMA_ROLE_SUFFIX_LENGTH;
33
-
34
- export interface SchemaRoleNames {
35
- readonly rwRoleName: string;
36
- readonly roRoleName: string;
37
- }
38
-
39
- /**
40
- * The shared stem of a schema's role names.
41
- *
42
- * The schema name is character-sanitized but NOT collapsed or trimmed: it is
43
- * already a valid identifier, and collapsing would map the distinct schemas
44
- * `app__x` and `app_x` onto one role. Distinct schema names whose first
45
- * {@link SCHEMA_ROLE_BASE_MAX_LENGTH} characters collide would derive the SAME
46
- * role under naive truncation — causing cross-schema privilege bleed and drop
47
- * collisions — so an over-budget name gets a hash of the FULL schema name
48
- * spliced in instead.
49
- */
50
- export function schemaRoleBase(schemaName: string): string {
51
- return fitIdentifier({
52
- candidate: sanitizeIdentifierChars(schemaName),
53
- maxLength: SCHEMA_ROLE_BASE_MAX_LENGTH,
54
- hashInput: schemaName,
55
- });
56
- }
57
-
58
- /** The name of one specific role for a schema. */
59
- export function schemaRoleName(
60
- schemaName: string,
61
- kind: SchemaRoleKind,
62
- ): string {
63
- return `${schemaRoleBase(schemaName)}_${kind}`;
64
- }
65
-
66
- /**
67
- * Read-write / read-only role names for a schema. The single source of truth for
68
- * role-name derivation across provisioning, migration, and every connection
69
- * consumer — keep all call sites routed through here so the names never drift.
70
- */
71
- export function rolesForSchema(schemaName: string): SchemaRoleNames {
72
- const base = schemaRoleBase(schemaName);
73
- return {
74
- rwRoleName: `${base}_${SchemaRoleKind.ReadWrite}`,
75
- roRoleName: `${base}_${SchemaRoleKind.ReadOnly}`,
76
- };
77
- }
@@ -1,62 +0,0 @@
1
- import {
2
- fitIdentifier,
3
- POSTGRES_MAX_IDENTIFIER_LENGTH,
4
- sanitizeIdentifierSegment,
5
- } from './identifier';
6
-
7
- /**
8
- * The default database key. A biome's primary database carries no `__<key>`
9
- * suffix on its schema name; only additional, non-primary databases do.
10
- */
11
- export const DEFAULT_BIOME_DB_KEY = 'primary';
12
-
13
- const SCHEMA_PREFIX = 'biome';
14
-
15
- /**
16
- * Derive the Postgres schema name a biome's tables live in inside the shared
17
- * `xema_biomes` database.
18
- *
19
- * Convention:
20
- * - primary DB (`dbKey` omitted or `'primary'`) → `biome_<biomeId>`
21
- * - additional DB → `biome_<biomeId>__<dbKey>`
22
- *
23
- * Each part is sanitised independently (see {@link sanitizeIdentifierSegment});
24
- * the `__` (double underscore) is the only place two underscores survive
25
- * collapsing, so it unambiguously separates the biome id from the db key.
26
- *
27
- * Over-budget names are fitted by {@link fitIdentifier}, hashing the RAW
28
- * (pre-sanitisation) parts joined by `:` so two different inputs can never
29
- * collide.
30
- */
31
- export function deriveBiomeSchemaName(biomeId: string, dbKey?: string): string {
32
- const sanitizedBiomeId = sanitizeIdentifierSegment(biomeId);
33
- if (sanitizedBiomeId.length === 0) {
34
- throw new Error(
35
- `deriveBiomeSchemaName: biomeId "${biomeId}" sanitises to an empty identifier`,
36
- );
37
- }
38
-
39
- const isDefaultKey =
40
- dbKey === undefined || dbKey === '' || dbKey === DEFAULT_BIOME_DB_KEY;
41
- const sanitizedDbKey = isDefaultKey ? '' : sanitizeIdentifierSegment(dbKey);
42
-
43
- if (!isDefaultKey && sanitizedDbKey.length === 0) {
44
- throw new Error(
45
- `deriveBiomeSchemaName: dbKey "${dbKey}" sanitises to an empty identifier`,
46
- );
47
- }
48
-
49
- const candidate = isDefaultKey
50
- ? `${SCHEMA_PREFIX}_${sanitizedBiomeId}`
51
- : `${SCHEMA_PREFIX}_${sanitizedBiomeId}__${sanitizedDbKey}`;
52
-
53
- // The hash covers the raw parts so the disambiguating suffix is stable across
54
- // calls and distinguishes inputs that sanitise to the same prefix.
55
- const hashParts = isDefaultKey ? [biomeId] : [biomeId, dbKey];
56
-
57
- return fitIdentifier({
58
- candidate,
59
- maxLength: POSTGRES_MAX_IDENTIFIER_LENGTH,
60
- hashInput: hashParts.join(':'),
61
- });
62
- }