@fabrika/platform-node 0.3.3 → 0.4.0

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,10 +1,19 @@
1
- import type { SqlDatabase } from '@fabrika/platform'
1
+ import type { SqlDatabase, SqlStatement } from '@fabrika/platform'
2
+ import { createHash } from 'node:crypto'
2
3
  import { readdirSync, readFileSync } from 'node:fs'
3
4
  import { join } from 'node:path'
5
+ import { acquireMigrationLock, grantRuntimeRole, normalizeDefaultPrivileges } from './application-postgres-grants'
6
+ import { DATABASE_CODE_FAILURE, databaseCodeCheck } from './database-code'
7
+ import { classifyMigrations, type DatabaseObject, migrationManifest } from './migration-classify'
8
+ import { refusedStatement } from './migration-sql'
9
+ import { PostgresMigrationError } from './postgres-migration-error'
4
10
 
5
11
  const IDENTIFIER = /^[a-z][a-z0-9_]*$/
6
12
  const BUNDLE_NAME = /^[a-z][a-z0-9-]*$/
7
13
  const MIGRATION_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]*\.sql$/
14
+ const CHECKSUM = /^[0-9a-f]{64}$/
15
+ /** Raised in the migration's own transaction when the file ended it or changed a runner-owned setting. */
16
+ const GUARD_FAILURE = 'fabrika-migration-guard'
8
17
  const PLATFORM_MIGRATIONS_DIR = join(import.meta.dir, '..', 'migrations')
9
18
 
10
19
  export interface PostgresMigration {
@@ -18,13 +27,35 @@ export interface PostgresMigrationBundle {
18
27
  }
19
28
 
20
29
  export interface PostgresMigrationPlan {
21
- /** Service-scoped ledger table, such as `iam_schema_migrations`. */
30
+ /** Service-scoped ledger table, such as `iam_schema_migrations`; an application's is qualified by its schema. */
22
31
  ledgerTable: string
23
32
  /** Service-specific session advisory lock. Keep this stable across runner upgrades. */
24
33
  lockKey: number
25
34
  bundles: readonly PostgresMigrationBundle[]
35
+ /** Present only in an application's plan (ADR-0017): its schema, a checksum per migration, and timeouts. */
36
+ application?: PostgresApplicationMigrationSettings
26
37
  }
27
38
 
39
+ export interface PostgresApplicationMigrationSettings {
40
+ /** Created when absent. Each migration runs with it as its only `search_path`. */
41
+ schema: string
42
+ /** `lock_timeout` of each migration, so a blocked `ALTER` fails the deploy instead of stalling the live version. */
43
+ lockTimeoutMs: number
44
+ statementTimeoutMs: number
45
+ /** The framework's own bundle (`fabrika`), which alone may name the framework's `fabrika_*` tables. */
46
+ frameworkBundle?: string
47
+ /**
48
+ * The application's runtime role. Before the plan runs, the migration role's default privileges are cleared of
49
+ * PUBLIC; after it ran, the runtime role's and PUBLIC's privileges in the schema are reset to the runtime role's DML
50
+ * on every table but the ledger and verified, in one transaction under the migration lock; it never gets DDL.
51
+ */
52
+ runtimeRole?: string
53
+ /** How long the runner waits for the migration lock; any session may take an advisory lock, so the wait is bounded. */
54
+ advisoryLockTimeoutMs?: number
55
+ }
56
+
57
+ export { PostgresMigrationError }
58
+
28
59
  export interface ReadPostgresMigrationBundleOptions {
29
60
  name: string
30
61
  directory: string
@@ -34,6 +65,7 @@ export interface ReadPostgresMigrationBundleOptions {
34
65
  interface LedgerRow {
35
66
  bundle: string
36
67
  name: string
68
+ checksum?: string | null
37
69
  }
38
70
 
39
71
  /** Read one ordered migration bundle from disk. An explicit file list fails closed when a file is absent. */
@@ -72,7 +104,7 @@ export function platformNodePostgresMigrationBundle(files?: readonly string[]):
72
104
 
73
105
  /** Validate a complete plan at composition time, before any SQL is executed. */
74
106
  export function definePostgresMigrationPlan(plan: PostgresMigrationPlan): PostgresMigrationPlan {
75
- validateIdentifier(plan.ledgerTable, 'migration ledger table')
107
+ validateLedgerTable(plan)
76
108
  if (!Number.isSafeInteger(plan.lockKey) || plan.lockKey < 1) {
77
109
  throw new Error('Postgres migration lock key must be a positive safe integer')
78
110
  }
@@ -125,27 +157,54 @@ export async function applyPostgresMigrations(
125
157
  options: { now?: () => number } = {},
126
158
  ): Promise<string[]> {
127
159
  const plan = definePostgresMigrationPlan(input)
128
- await db.prepare(`SELECT pg_advisory_lock(${plan.lockKey})`).run()
160
+ await acquireMigrationLock(db, plan.lockKey, plan.application?.advisoryLockTimeoutMs, plan.application?.schema ?? plan.ledgerTable)
129
161
  let migrationFailed = false
130
162
  let migrationError: unknown
131
163
  const fresh: string[] = []
132
164
  try {
133
- await ensureLedger(db, plan.ledgerTable)
134
- const applied = await appliedIdentities(db, plan.ledgerTable)
165
+ const applied = await appliedChecksums(db, plan)
166
+ let manifests: ReadonlyMap<string, readonly DatabaseObject[]> = new Map()
167
+ if (plan.application !== undefined) {
168
+ verifyChecksums(plan.bundles, applied)
169
+ manifests = classifyApplicationPlan(plan.bundles, applied, plan.application)
170
+ }
171
+ await ensureLedger(db, plan)
172
+ if (plan.application?.runtimeRole !== undefined) await normalizeDefaultPrivileges(db, plan.application.schema)
135
173
  for (const bundle of plan.bundles) {
136
174
  for (const migration of bundle.migrations) {
137
175
  const identity = postgresMigrationIdentity(bundle.name, migration.name)
138
176
  if (applied.has(identity)) continue
139
- await db.batch([
140
- db.prepare(migration.sql),
141
- db
142
- .prepare(`INSERT INTO ${plan.ledgerTable} (bundle, name, applied_at) VALUES (?, ?, ?)`)
143
- .bind(bundle.name, migration.name, (options.now ?? unixNow)()),
144
- ])
145
- applied.add(identity)
177
+ try {
178
+ await db.batch([
179
+ ...applicationSettings(db, plan.application),
180
+ db.prepare(GUARD_MARK),
181
+ db.prepare(migration.sql),
182
+ db.prepare(guardCheck(plan.application)),
183
+ ...databaseCodeStatements(db, plan.application, manifests.get(identity) ?? []),
184
+ ledgerInsert(db, plan, bundle.name, migration, (options.now ?? unixNow)()),
185
+ ])
186
+ } catch (error) {
187
+ if (error instanceof Error && error.message === GUARD_FAILURE) {
188
+ throw new PostgresMigrationError(
189
+ `Postgres migration ${identity} ended the runner's transaction or changed its lock_timeout, statement_timeout or search_path`,
190
+ )
191
+ }
192
+ if (error instanceof Error && error.message.startsWith(DATABASE_CODE_FAILURE)) {
193
+ throw new PostgresMigrationError(
194
+ `Postgres migration ${identity} was rolled back: the schema would hold database code that escapes it or that no migration declares: ${
195
+ error.message.slice(DATABASE_CODE_FAILURE.length)
196
+ }`,
197
+ )
198
+ }
199
+ throw new PostgresMigrationError(`Postgres migration ${identity} failed`, { cause: error })
200
+ }
201
+ applied.set(identity, undefined)
146
202
  fresh.push(identity)
147
203
  }
148
204
  }
205
+ if (plan.application?.runtimeRole !== undefined) {
206
+ await grantRuntimeRole(db, plan.application.schema, plan.ledgerTable, plan.application.runtimeRole)
207
+ }
149
208
  } catch (error) {
150
209
  migrationFailed = true
151
210
  migrationError = error
@@ -153,7 +212,7 @@ export async function applyPostgresMigrations(
153
212
  let unlockFailed = false
154
213
  try {
155
214
  const unlocked = await db
156
- .prepare(`SELECT pg_advisory_unlock(${plan.lockKey}) AS unlocked`)
215
+ .prepare(`SELECT pg_catalog.pg_advisory_unlock(${plan.lockKey}) AS unlocked`)
157
216
  .first<{ unlocked: boolean }>()
158
217
  unlockFailed = unlocked?.unlocked !== true
159
218
  } catch {
@@ -166,15 +225,21 @@ export async function applyPostgresMigrations(
166
225
 
167
226
  function validateBundle(bundle: PostgresMigrationBundle): void {
168
227
  validateBundleName(bundle.name)
169
- if (bundle.migrations.length === 0) throw new Error(`Postgres migration bundle ${bundle.name} is empty`)
228
+ if (bundle.migrations.length === 0) throw new PostgresMigrationError(`Postgres migration bundle ${bundle.name} is empty`)
170
229
  const names = new Set<string>()
171
230
  let previous = ''
172
231
  for (const migration of bundle.migrations) {
173
232
  validateMigrationName(migration.name)
174
- if (migration.sql.trim() === '') throw new Error(`Postgres migration ${bundle.name}/${migration.name} is empty`)
175
- if (names.has(migration.name)) throw new Error(`Duplicate Postgres migration ${bundle.name}/${migration.name}`)
233
+ if (migration.sql.trim() === '') throw new PostgresMigrationError(`Postgres migration ${bundle.name}/${migration.name} is empty`)
234
+ if (names.has(migration.name)) throw new PostgresMigrationError(`Duplicate Postgres migration ${bundle.name}/${migration.name}`)
235
+ const refused = refusedStatement(migration.sql)
236
+ if (refused !== undefined) {
237
+ throw new PostgresMigrationError(
238
+ `Postgres migration ${bundle.name}/${migration.name} line ${refused.line}: ${refused.statement} is not allowed; the runner owns the transaction and its settings`,
239
+ )
240
+ }
176
241
  if (previous !== '' && migration.name.localeCompare(previous) < 0) {
177
- throw new Error(`Postgres migration bundle ${bundle.name} is not ordered by filename`)
242
+ throw new PostgresMigrationError(`Postgres migration bundle ${bundle.name} is not ordered by filename`)
178
243
  }
179
244
  names.add(migration.name)
180
245
  previous = migration.name
@@ -185,26 +250,206 @@ function validateIdentifier(value: string, label: string): void {
185
250
  if (!IDENTIFIER.test(value)) throw new Error(`Invalid ${label}: ${value}`)
186
251
  }
187
252
 
253
+ /** Only an application's ledger is schema-qualified, and only by the application's own schema. */
254
+ function validateLedgerTable(plan: PostgresMigrationPlan): void {
255
+ const { application } = plan
256
+ if (application === undefined) {
257
+ validateIdentifier(plan.ledgerTable, 'migration ledger table')
258
+ return
259
+ }
260
+ validateIdentifier(application.schema, 'application schema')
261
+ const prefix = `${application.schema}.`
262
+ if (!plan.ledgerTable.startsWith(prefix)) throw new Error(`Invalid migration ledger table: ${plan.ledgerTable} is not in ${application.schema}`)
263
+ validateIdentifier(plan.ledgerTable.slice(prefix.length), 'migration ledger table')
264
+ if (application.runtimeRole !== undefined) validateIdentifier(application.runtimeRole, 'runtime role')
265
+ if (application.advisoryLockTimeoutMs !== undefined) validateTimeout(application.advisoryLockTimeoutMs, 'advisory lock timeout')
266
+ validateTimeout(application.lockTimeoutMs, 'lock timeout')
267
+ validateTimeout(application.statementTimeoutMs, 'statement timeout')
268
+ // Only the framework's bundle may name `fabrika_*`; it runs first, before an application's own.
269
+ if (application.frameworkBundle !== undefined && (plan.bundles.length < 2 || plan.bundles[0]?.name !== application.frameworkBundle)) {
270
+ throw new Error(`Invalid framework bundle: ${application.frameworkBundle} is not the first of several bundles`)
271
+ }
272
+ }
273
+
274
+ function validateTimeout(value: number, label: string): void {
275
+ if (!Number.isSafeInteger(value) || value < 1) throw new Error(`Postgres migration ${label} must be a positive number of milliseconds`)
276
+ }
277
+
188
278
  function validateBundleName(value: string): void {
189
- if (!BUNDLE_NAME.test(value)) throw new Error(`Invalid Postgres migration bundle name: ${value}`)
279
+ if (!BUNDLE_NAME.test(value)) throw new PostgresMigrationError(`Invalid Postgres migration bundle name: ${value}`)
190
280
  }
191
281
 
192
282
  function validateMigrationName(value: string): void {
193
- if (!MIGRATION_NAME.test(value)) throw new Error(`Invalid Postgres migration filename: ${value}`)
283
+ if (!MIGRATION_NAME.test(value)) throw new PostgresMigrationError(`Invalid Postgres migration filename: ${value}`)
194
284
  }
195
285
 
196
- async function ensureLedger(db: SqlDatabase, ledgerTable: string): Promise<void> {
197
- await db.prepare(`CREATE TABLE IF NOT EXISTS ${ledgerTable} (
286
+ /** The service ledgers keep their original shape; an application's ledger also records each file's checksum. */
287
+ async function ensureLedger(db: SqlDatabase, plan: PostgresMigrationPlan): Promise<void> {
288
+ if (plan.application === undefined) {
289
+ await db.prepare(`CREATE TABLE IF NOT EXISTS ${plan.ledgerTable} (
290
+ bundle TEXT NOT NULL,
291
+ name TEXT NOT NULL,
292
+ applied_at INTEGER NOT NULL,
293
+ PRIMARY KEY (bundle, name)
294
+ )`).run()
295
+ return
296
+ }
297
+ // `CREATE SCHEMA IF NOT EXISTS` needs CREATE on the database even when the schema exists, which a migration role lacks.
298
+ const schema = await db.prepare('SELECT pg_catalog.to_regnamespace(?) IS NOT NULL AS present').bind(plan.application.schema).first<
299
+ { present: boolean }
300
+ >()
301
+ if (schema?.present !== true) await db.prepare(`CREATE SCHEMA ${plan.application.schema}`).run()
302
+ await db.prepare(`CREATE TABLE IF NOT EXISTS ${plan.ledgerTable} (
198
303
  bundle TEXT NOT NULL,
199
304
  name TEXT NOT NULL,
305
+ checksum TEXT NOT NULL,
200
306
  applied_at INTEGER NOT NULL,
201
307
  PRIMARY KEY (bundle, name)
202
308
  )`).run()
203
309
  }
204
310
 
205
- async function appliedIdentities(db: SqlDatabase, ledgerTable: string): Promise<Set<string>> {
206
- const { results } = await db.prepare(`SELECT bundle, name FROM ${ledgerTable}`).all<LedgerRow>()
207
- return new Set(results.map((row) => postgresMigrationIdentity(row.bundle, row.name)))
311
+ /**
312
+ * Applied identities, each with its recorded checksum; `undefined` for a service ledger, which records none. Empty
313
+ * while the ledger does not exist yet, so a fresh schema is checked before the runner creates anything.
314
+ */
315
+ async function appliedChecksums(db: SqlDatabase, plan: PostgresMigrationPlan): Promise<Map<string, string | undefined>> {
316
+ const ledger = await db.prepare('SELECT pg_catalog.to_regclass(?) IS NOT NULL AS present').bind(plan.ledgerTable).first<{ present: boolean }>()
317
+ if (ledger?.present !== true) return new Map()
318
+ const columns = plan.application === undefined ? 'bundle, name' : 'bundle, name, checksum'
319
+ const { results } = await db.prepare(`SELECT ${columns} FROM ${plan.ledgerTable}`).all<LedgerRow>()
320
+ const applied = new Map<string, string | undefined>()
321
+ for (const row of results) {
322
+ const identity = postgresMigrationIdentity(row.bundle, row.name)
323
+ if (plan.application === undefined) {
324
+ applied.set(identity, undefined)
325
+ continue
326
+ }
327
+ if (typeof row.checksum !== 'string' || !CHECKSUM.test(row.checksum)) {
328
+ throw new PostgresMigrationError(`Postgres migration ledger row ${identity} has no valid checksum`)
329
+ }
330
+ applied.set(identity, row.checksum)
331
+ }
332
+ return applied
333
+ }
334
+
335
+ /**
336
+ * Every applied file must be byte-for-byte the file that ran. Checked before anything is applied, so an edited
337
+ * migration fails the run without a partial apply. A ledger row without a file is the database being ahead of the
338
+ * code after a rollback, which is tolerated.
339
+ */
340
+ function verifyChecksums(bundles: readonly PostgresMigrationBundle[], applied: ReadonlyMap<string, string | undefined>): void {
341
+ for (const bundle of bundles) {
342
+ for (const migration of bundle.migrations) {
343
+ const identity = postgresMigrationIdentity(bundle.name, migration.name)
344
+ const recorded = applied.get(identity)
345
+ if (recorded !== undefined && recorded !== migrationChecksum(migration.sql)) {
346
+ throw new PostgresMigrationError(
347
+ `Postgres migration ${identity} changed after it was applied; an applied migration is immutable, so write the change as a new migration`,
348
+ )
349
+ }
350
+ }
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Classifies every file of an application plan and refuses one that is not applied yet and holds a refused statement,
356
+ * before any SQL runs. An applied file is already checksum-locked, so a statement refused today cannot lock an
357
+ * installation out of its next deploy. Returns, per migration identity, the manifest folded through that file: what
358
+ * the catalog check admits right after it ran.
359
+ */
360
+ function classifyApplicationPlan(
361
+ bundles: readonly PostgresMigrationBundle[],
362
+ applied: ReadonlyMap<string, string | undefined>,
363
+ application: PostgresApplicationMigrationSettings,
364
+ ): ReadonlyMap<string, readonly DatabaseObject[]> {
365
+ const files = bundles.flatMap((bundle) =>
366
+ bundle.migrations.map((migration) => ({
367
+ name: migration.name,
368
+ sql: migration.sql,
369
+ framework: bundle.name === application.frameworkBundle,
370
+ identity: postgresMigrationIdentity(bundle.name, migration.name),
371
+ }))
372
+ )
373
+ const classified = classifyMigrations(files, application.schema)
374
+ for (const [index, file] of files.entries()) {
375
+ if (applied.has(file.identity)) continue
376
+ const refused = classified.files[index]?.refused[0]
377
+ if (refused === undefined) continue
378
+ throw new PostgresMigrationError(
379
+ `Application migration ${file.name} line ${refused.line}: ${refused.reason} is not allowed; an application migration may not leave its schema, its transaction or the platform (ADR-0017)`,
380
+ )
381
+ }
382
+ return new Map(files.map((file, index) => [file.identity, migrationManifest(classified.files.slice(0, index + 1))]))
383
+ }
384
+
385
+ /** SHA-256 of the file's text, hex. */
386
+ export function migrationChecksum(sql: string): string {
387
+ return createHash('sha256').update(sql, 'utf8').digest('hex')
388
+ }
389
+
390
+ /** Remembers the transaction in a transaction-local setting, which a COMMIT or ROLLBACK in the file discards. */
391
+ const GUARD_MARK = "SELECT pg_catalog.set_config('fabrika.migration_xact', pg_catalog.txid_current()::pg_catalog.text, true)"
392
+
393
+ /**
394
+ * Fails the transaction when the file ended it or loosened a runner-owned setting. The lexical refusal runs first;
395
+ * this also catches `set_config()` and anything the tokenizer cannot see. `pg_settings.setting` is in milliseconds.
396
+ * Every function, operator and type is `pg_catalog`'s by name: the application's schema comes first on the path.
397
+ */
398
+ function guardCheck(application: PostgresApplicationMigrationSettings | undefined): string {
399
+ const differs = (left: string, right: string): string => `${left} OPERATOR(pg_catalog.<>) ${right}`
400
+ const setting = (name: string): string => `(SELECT setting FROM pg_catalog.pg_settings WHERE name OPERATOR(pg_catalog.=) '${name}')`
401
+ const settings = application === undefined ? [] : [
402
+ differs(setting('lock_timeout'), `'${application.lockTimeoutMs}'`),
403
+ differs(setting('statement_timeout'), `'${application.statementTimeoutMs}'`),
404
+ differs("pg_catalog.current_setting('search_path')", `'${applicationSearchPath(application)}'`),
405
+ ]
406
+ const conditions = [
407
+ differs("COALESCE(pg_catalog.current_setting('fabrika.migration_xact', true), '')", 'pg_catalog.txid_current()::pg_catalog.text'),
408
+ ...settings,
409
+ ]
410
+ return `DO $guard$ BEGIN IF ${conditions.join(' OR ')} THEN RAISE EXCEPTION '${GUARD_FAILURE}'; END IF; END $guard$`
411
+ }
412
+
413
+ /** The application's own schema first, so an unqualified name is its own; `pg_catalog` then, so a built-in resolves. */
414
+ function applicationSearchPath(application: PostgresApplicationMigrationSettings): string {
415
+ return `${application.schema}, pg_catalog`
416
+ }
417
+
418
+ function applicationSettings(db: SqlDatabase, application: PostgresApplicationMigrationSettings | undefined): SqlStatement[] {
419
+ if (application === undefined) return []
420
+ return [
421
+ db.prepare(`SET LOCAL search_path TO ${applicationSearchPath(application)}`),
422
+ db.prepare(`SET LOCAL lock_timeout = ${application.lockTimeoutMs}`),
423
+ db.prepare(`SET LOCAL statement_timeout = ${application.statementTimeoutMs}`),
424
+ // The tokenizer reads '…' without backslash escapes, as Postgres does with this setting on.
425
+ db.prepare('SET LOCAL standard_conforming_strings = on'),
426
+ ]
427
+ }
428
+
429
+ /**
430
+ * The catalog check, after the file and before the commit, on settings it pins itself: the file may have changed
431
+ * `standard_conforming_strings`, and nothing the application installed may resolve before `pg_catalog`.
432
+ */
433
+ function databaseCodeStatements(
434
+ db: SqlDatabase,
435
+ application: PostgresApplicationMigrationSettings | undefined,
436
+ manifest: readonly DatabaseObject[],
437
+ ): SqlStatement[] {
438
+ if (application === undefined) return []
439
+ return [
440
+ db.prepare('SET LOCAL search_path TO pg_catalog, pg_temp'),
441
+ db.prepare('SET LOCAL standard_conforming_strings = on'),
442
+ db.prepare(databaseCodeCheck(application.schema, manifest)),
443
+ ]
444
+ }
445
+
446
+ function ledgerInsert(db: SqlDatabase, plan: PostgresMigrationPlan, bundle: string, migration: PostgresMigration, appliedAt: number): SqlStatement {
447
+ if (plan.application === undefined) {
448
+ return db.prepare(`INSERT INTO ${plan.ledgerTable} (bundle, name, applied_at) VALUES (?, ?, ?)`).bind(bundle, migration.name, appliedAt)
449
+ }
450
+ return db
451
+ .prepare(`INSERT INTO ${plan.ledgerTable} (bundle, name, checksum, applied_at) VALUES (?, ?, ?, ?)`)
452
+ .bind(bundle, migration.name, migrationChecksum(migration.sql), appliedAt)
208
453
  }
209
454
 
210
455
  function unixNow(): number {
@@ -0,0 +1,96 @@
1
+ import type { SqlDatabase } from '@fabrika/platform'
2
+ import { createHash, randomBytes } from 'node:crypto'
3
+ import { provisioningCredential, quotedIdentifier, scramVerifier } from './application-postgres-roles'
4
+ import { PostgresMigrationError } from './postgres-migration-error'
5
+ import { PostgresDatabase } from './sql-postgres'
6
+
7
+ /** A provisioning credential that exists for one test run only, and what it created. */
8
+ export interface TestProvisioner {
9
+ /** Connects as `fabrika_tests_<hex>`: CREATEROLE and CREATE on the database, nothing of any application. */
10
+ readonly url: string
11
+ /** Drops every role the provisioner created, with everything those roles own, then the provisioner itself. */
12
+ drop(): Promise<void>
13
+ }
14
+
15
+ /**
16
+ * Every grant or revoke on a database changes its one `pg_database` row, and two concurrent changes of that row fail with
17
+ * `tuple concurrently updated`, so each change waits for this transaction-level lock first.
18
+ */
19
+ export const DATABASE_GRANT_LOCK_KEY: number = createHash('sha256').update('fabrika-db:database-grants', 'utf8').digest().readUIntBE(0, 6) + 1
20
+
21
+ /** Grants (or revokes) CREATE on `database` to `role`, serialized with every other run that does so on the same server. */
22
+ export async function changeCreateOnDatabase(admin: SqlDatabase, change: 'grant' | 'revoke', role: string, database: string): Promise<void> {
23
+ const statement = change === 'grant'
24
+ ? `GRANT CREATE ON DATABASE ${quotedIdentifier(database)} TO ${quotedIdentifier(role)}`
25
+ : `REVOKE CREATE ON DATABASE ${quotedIdentifier(database)} FROM ${quotedIdentifier(role)}`
26
+ await admin.batch([admin.prepare(`SELECT pg_catalog.pg_advisory_xact_lock(${DATABASE_GRANT_LOCK_KEY})`), admin.prepare(statement)])
27
+ }
28
+
29
+ /** Before Postgres 16, CREATEROLE may grant membership in any role that is not a superuser, `pg_execute_server_program` included. */
30
+ const MINIMUM_SERVER_VERSION = 160000
31
+
32
+ /**
33
+ * Creates a throwaway provisioning credential for code the platform runs but did not write, such as an application's
34
+ * generated tests: it can create the test kit's role families and schemas, and reaches no application's data, because
35
+ * a CREATEROLE role administers only the roles it created. The provisioning credential itself never leaves this process.
36
+ */
37
+ export async function createTestProvisioner(provisioningUrl: string): Promise<TestProvisioner> {
38
+ const { url } = provisioningCredential(provisioningUrl)
39
+ const name = `fabrika_tests_${randomBytes(8).toString('hex')}`
40
+ const password = randomBytes(32).toString('base64url')
41
+ const admin = PostgresDatabase.connect(provisioningUrl, { max: 1 })
42
+ try {
43
+ const self = await admin.prepare(`SELECT rolsuper, rolcreaterole, pg_catalog.current_setting('server_version_num')::int AS version,
44
+ pg_catalog.current_database() AS database FROM pg_catalog.pg_roles WHERE rolname = current_user`).first<{
45
+ rolsuper: boolean
46
+ rolcreaterole: boolean
47
+ version: number
48
+ database: string
49
+ }>()
50
+ if (self === null || (!self.rolsuper && !self.rolcreaterole)) {
51
+ throw new PostgresMigrationError(
52
+ 'the provisioning credential is neither a superuser nor holds CREATEROLE, so it cannot create the test run its own',
53
+ )
54
+ }
55
+ if (self.version < MINIMUM_SERVER_VERSION) {
56
+ throw new PostgresMigrationError(
57
+ 'the test run needs Postgres 16 or later: before 16, a CREATEROLE role may grant itself any role that is not a superuser',
58
+ )
59
+ }
60
+ await admin.prepare(`CREATE ROLE ${name} LOGIN CREATEROLE NOINHERIT PASSWORD '${scramVerifier(password)}'`).run()
61
+ await changeCreateOnDatabase(admin, 'grant', name, self.database)
62
+ const database = self.database
63
+ url.username = name
64
+ url.password = password
65
+ return { url: url.toString(), drop: () => dropTestProvisioner(provisioningUrl, name, database) }
66
+ } finally {
67
+ await admin.close()
68
+ }
69
+ }
70
+
71
+ async function dropTestProvisioner(provisioningUrl: string, name: string, database: string): Promise<void> {
72
+ const admin = PostgresDatabase.connect(provisioningUrl, { max: 1 })
73
+ try {
74
+ await admin.prepare(`GRANT ${name} TO CURRENT_USER`).run()
75
+ await dropCreatedRoles(admin, name)
76
+ await changeCreateOnDatabase(admin, 'revoke', name, database)
77
+ await admin.prepare(`DROP ROLE ${name}`).run()
78
+ } finally {
79
+ await admin.close()
80
+ }
81
+ }
82
+
83
+ /** As the provisioner: each role it administers, made its own for a moment so that its objects can be dropped, then dropped. */
84
+ async function dropCreatedRoles(admin: SqlDatabase, name: string): Promise<void> {
85
+ const { results } = await admin.prepare(`SELECT pg_catalog.pg_get_userbyid(m.roleid) AS role FROM pg_catalog.pg_auth_members m
86
+ WHERE m.member = pg_catalog.to_regrole(?) AND m.admin_option ORDER BY 1`).bind(name).all<{ role: string }>()
87
+ for (const { role } of results) {
88
+ const quoted = quotedIdentifier(role)
89
+ await admin.batch([
90
+ admin.prepare(`SET LOCAL ROLE ${name}`),
91
+ admin.prepare(`GRANT ${quoted} TO ${name}`),
92
+ ])
93
+ await admin.batch([admin.prepare(`SET LOCAL ROLE ${quoted}`), admin.prepare(`DROP OWNED BY ${quoted} CASCADE`)])
94
+ await admin.batch([admin.prepare(`SET LOCAL ROLE ${name}`), admin.prepare(`DROP ROLE ${quoted}`)])
95
+ }
96
+ }