@fabrika/platform-node 0.3.2 → 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.
- package/package.json +7 -2
- package/src/application-postgres-grants.ts +117 -0
- package/src/application-postgres-migrations.ts +152 -0
- package/src/application-postgres-roles.ts +239 -0
- package/src/application-schema.ts +21 -0
- package/src/blob-s3.ts +88 -2
- package/src/database-code.ts +164 -0
- package/src/file-table-ddl.ts +96 -0
- package/src/index.ts +21 -0
- package/src/migration-classify.ts +1044 -0
- package/src/migration-sql.ts +204 -0
- package/src/pg-catalog-names.ts +359 -0
- package/src/postgres-migration-error.ts +4 -0
- package/src/postgres-migrations.ts +270 -25
- package/src/postgres-test-provisioner.ts +96 -0
- package/src/sql-names.ts +740 -0
- package/src/sql-postgres.ts +56 -22
- package/src/sql.ts +52 -0
|
@@ -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
|
-
|
|
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
|
|
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
|
|
134
|
-
|
|
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
|
-
|
|
140
|
-
db.
|
|
141
|
-
|
|
142
|
-
.prepare(
|
|
143
|
-
.
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
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
|
|
175
|
-
if (names.has(migration.name)) throw new
|
|
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
|
|
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
|
|
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
|
|
283
|
+
if (!MIGRATION_NAME.test(value)) throw new PostgresMigrationError(`Invalid Postgres migration filename: ${value}`)
|
|
194
284
|
}
|
|
195
285
|
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
+
}
|