@fabrika/platform-node 0.3.3 → 0.4.1

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.
@@ -0,0 +1,164 @@
1
+ // The catalog half of ADR-0017's migration rules: the runtime witness. Postgres has already resolved every name the
2
+ // schema stores, so this reads the catalog instead of the SQL text: the runner runs it inside each migration's
3
+ // transaction, before its commit, and `@fabrika/db`'s codegen runs it at every start. It refuses what escapes the
4
+ // schema and any database object that no migration declares, such as one created at runtime.
5
+
6
+ import type { DatabaseObject } from './migration-classify'
7
+
8
+ const IDENTIFIER = /^[a-z_][a-z0-9_]*$/
9
+ const encoder = new TextEncoder()
10
+
11
+ /** A text literal with no quote and no backslash in it, whatever the session's `standard_conforming_strings`. */
12
+ function hexText(value: string): string {
13
+ const hex = Array.from(encoder.encode(value), (byte) => byte.toString(16).padStart(2, '0')).join('')
14
+ return `pg_catalog.convert_from(pg_catalog.decode('${hex}', 'hex'), 'UTF8')`
15
+ }
16
+
17
+ /** The manifest as a `text[]` of `<kind> <name>`, the names `objects` gives the schema's objects. */
18
+ function declaredArray(declared: readonly DatabaseObject[]): string {
19
+ if (declared.length === 0) return 'ARRAY[]::pg_catalog.text[]'
20
+ return `ARRAY[${declared.map((object) => hexText(`${object.kind} ${object.name}`)).join(', ')}]::pg_catalog.text[]`
21
+ }
22
+
23
+ /** A catalog's `regclass`, qualified, since the query may run with the application's schema first on the path. */
24
+ const catalog = (table: string): string => `'pg_catalog.${table}'::pg_catalog.regclass`
25
+
26
+ /**
27
+ * One row (`violation`) per escape and per undeclared object in `schema`. Escapes: a function that is `SECURITY
28
+ * DEFINER`, has a `SET` clause or a language other than `sql` and `plpgsql`; an event trigger on a function of the
29
+ * schema; and any dependency of an object of the schema on a type, function, relation, operator, operator class or
30
+ * collation outside the schema and `pg_catalog`; a routine that shadows a `pg_catalog` name or has more than one
31
+ * signature; a sequence other than an identity column's. Undeclared: a function, procedure or aggregate, operator,
32
+ * trigger (a foreign key's excepted), rule, view, policy or domain that `declared` does not name. Every name is
33
+ * qualified with `pg_catalog`, the query has no backslash, and the manifest is
34
+ * hex text, so the session's `search_path` and `standard_conforming_strings` cannot change what it finds.
35
+ */
36
+ export function databaseCodeQuery(schema: string, declared: readonly DatabaseObject[]): string {
37
+ if (!IDENTIFIER.test(schema)) throw new Error(`Invalid application schema: ${schema}`)
38
+ // A constant, not a subquery, so the planner sizes each scan of the schema and does not reach for JIT.
39
+ const namespace = `'${schema}'::pg_catalog.regnamespace::pg_catalog.oid`
40
+ const trusted = `(SELECT oid FROM pg_catalog.pg_namespace WHERE nspname IN ('${schema}', 'pg_catalog'))`
41
+ const outside = (kind: string, table: string, name: string, namespaceColumn: string): string =>
42
+ `SELECT d.object || ' depends on the ${kind} ' || s.nspname || '.' || r.${name}
43
+ FROM dependencies d
44
+ JOIN pg_catalog.${table} r ON d.refclassid = ${catalog(table)} AND r.oid = d.refobjid
45
+ JOIN pg_catalog.pg_namespace s ON s.oid = r.${namespaceColumn}
46
+ WHERE r.${namespaceColumn} NOT IN ${trusted}`
47
+ return `
48
+ WITH objects AS (
49
+ SELECT CASE p.prokind WHEN 'a' THEN 'aggregate ' WHEN 'p' THEN 'procedure ' ELSE 'function ' END || p.proname AS object
50
+ FROM pg_catalog.pg_proc p
51
+ WHERE p.pronamespace = ${namespace} AND NOT EXISTS (
52
+ SELECT 1 FROM pg_catalog.pg_depend d WHERE d.classid = ${catalog('pg_proc')} AND d.objid = p.oid AND d.deptype = 'i'
53
+ )
54
+ UNION ALL
55
+ SELECT 'operator ' || o.oprname FROM pg_catalog.pg_operator o WHERE o.oprnamespace = ${namespace}
56
+ UNION ALL
57
+ SELECT 'trigger ' || c.relname || '.' || t.tgname
58
+ FROM pg_catalog.pg_trigger t JOIN pg_catalog.pg_class c ON c.oid = t.tgrelid
59
+ WHERE c.relnamespace = ${namespace} AND NOT t.tgisinternal
60
+ UNION ALL
61
+ SELECT 'rule ' || c.relname || '.' || r.rulename
62
+ FROM pg_catalog.pg_rewrite r JOIN pg_catalog.pg_class c ON c.oid = r.ev_class
63
+ WHERE c.relnamespace = ${namespace} AND r.rulename <> '_RETURN'
64
+ UNION ALL
65
+ SELECT CASE c.relkind WHEN 'm' THEN 'materialized view ' ELSE 'view ' END || c.relname
66
+ FROM pg_catalog.pg_class c WHERE c.relnamespace = ${namespace} AND c.relkind IN ('v', 'm')
67
+ UNION ALL
68
+ SELECT 'policy ' || c.relname || '.' || p.polname
69
+ FROM pg_catalog.pg_policy p JOIN pg_catalog.pg_class c ON c.oid = p.polrelid
70
+ WHERE c.relnamespace = ${namespace}
71
+ UNION ALL
72
+ SELECT 'domain ' || t.typname FROM pg_catalog.pg_type t WHERE t.typnamespace = ${namespace} AND t.typtype = 'd'
73
+ ),
74
+ dependents AS (
75
+ SELECT ${catalog('pg_class')} AS classid, c.oid AS objid,
76
+ CASE c.relkind WHEN 'i' THEN 'index ' WHEN 'v' THEN 'view ' WHEN 'm' THEN 'materialized view ' WHEN 'S' THEN 'sequence ' ELSE 'table ' END
77
+ || c.relname AS object
78
+ FROM pg_catalog.pg_class c WHERE c.relnamespace = ${namespace}
79
+ UNION ALL
80
+ SELECT ${catalog('pg_proc')}, p.oid, 'function ' || p.proname FROM pg_catalog.pg_proc p WHERE p.pronamespace = ${namespace}
81
+ UNION ALL
82
+ SELECT ${catalog('pg_type')}, t.oid, 'type ' || t.typname FROM pg_catalog.pg_type t WHERE t.typnamespace = ${namespace}
83
+ UNION ALL
84
+ SELECT ${catalog('pg_operator')}, o.oid, 'operator ' || o.oprname FROM pg_catalog.pg_operator o WHERE o.oprnamespace = ${namespace}
85
+ UNION ALL
86
+ SELECT ${catalog('pg_constraint')}, k.oid, 'constraint ' || k.conname FROM pg_catalog.pg_constraint k WHERE k.connamespace = ${namespace}
87
+ UNION ALL
88
+ SELECT ${catalog('pg_attrdef')}, d.oid, 'default ' || c.relname || '.' || a.attname
89
+ FROM pg_catalog.pg_attrdef d
90
+ JOIN pg_catalog.pg_attribute a ON a.attrelid = d.adrelid AND a.attnum = d.adnum
91
+ JOIN pg_catalog.pg_class c ON c.oid = d.adrelid
92
+ WHERE c.relnamespace = ${namespace}
93
+ UNION ALL
94
+ SELECT ${catalog('pg_trigger')}, t.oid, 'trigger ' || c.relname || '.' || t.tgname
95
+ FROM pg_catalog.pg_trigger t JOIN pg_catalog.pg_class c ON c.oid = t.tgrelid WHERE c.relnamespace = ${namespace}
96
+ UNION ALL
97
+ SELECT ${catalog('pg_rewrite')}, r.oid, CASE WHEN r.rulename = '_RETURN' THEN 'view ' || c.relname ELSE 'rule ' || c.relname || '.' || r.rulename END
98
+ FROM pg_catalog.pg_rewrite r JOIN pg_catalog.pg_class c ON c.oid = r.ev_class WHERE c.relnamespace = ${namespace}
99
+ UNION ALL
100
+ SELECT ${catalog('pg_policy')}, p.oid, 'policy ' || c.relname || '.' || p.polname
101
+ FROM pg_catalog.pg_policy p JOIN pg_catalog.pg_class c ON c.oid = p.polrelid WHERE c.relnamespace = ${namespace}
102
+ ),
103
+ dependencies AS (
104
+ SELECT DISTINCT o.object, d.refclassid, d.refobjid
105
+ FROM pg_catalog.pg_depend d JOIN dependents o ON o.classid = d.classid AND o.objid = d.objid
106
+ ),
107
+ violations AS (
108
+ SELECT 'function ' || p.proname || ' is SECURITY DEFINER' AS violation
109
+ FROM pg_catalog.pg_proc p WHERE p.pronamespace = ${namespace} AND p.prosecdef
110
+ UNION ALL
111
+ SELECT 'function ' || p.proname || ' has a SET clause' FROM pg_catalog.pg_proc p WHERE p.pronamespace = ${namespace} AND p.proconfig IS NOT NULL
112
+ UNION ALL
113
+ SELECT 'function ' || p.proname || ' is in the language ' || l.lanname
114
+ FROM pg_catalog.pg_proc p JOIN pg_catalog.pg_language l ON l.oid = p.prolang
115
+ WHERE p.pronamespace = ${namespace} AND l.lanname NOT IN ('sql', 'plpgsql')
116
+ UNION ALL
117
+ SELECT DISTINCT 'function ' || p.proname || ' shadows a pg_catalog function'
118
+ FROM pg_catalog.pg_proc p
119
+ JOIN pg_catalog.pg_proc b ON b.proname = p.proname
120
+ JOIN pg_catalog.pg_namespace n ON n.oid = b.pronamespace AND n.nspname = 'pg_catalog'
121
+ WHERE p.pronamespace = ${namespace}
122
+ UNION ALL
123
+ SELECT 'routine ' || p.proname || ' has more than one signature'
124
+ FROM pg_catalog.pg_proc p WHERE p.pronamespace = ${namespace} GROUP BY p.proname HAVING pg_catalog.count(*) > 1
125
+ UNION ALL
126
+ SELECT 'sequence ' || c.relname || ' (only an identity column''s; use GENERATED ALWAYS AS IDENTITY)'
127
+ FROM pg_catalog.pg_class c
128
+ WHERE c.relnamespace = ${namespace} AND c.relkind = 'S' AND NOT EXISTS (
129
+ SELECT 1 FROM pg_catalog.pg_depend d
130
+ WHERE d.classid = ${catalog('pg_class')} AND d.objid = c.oid AND d.refclassid = ${catalog('pg_class')} AND d.refobjsubid > 0 AND d.deptype = 'i'
131
+ )
132
+ UNION ALL
133
+ SELECT 'event trigger ' || e.evtname FROM pg_catalog.pg_event_trigger e JOIN pg_catalog.pg_proc p ON p.oid = e.evtfoid
134
+ WHERE p.pronamespace = ${namespace}
135
+ UNION ALL
136
+ ${outside('type', 'pg_type', 'typname', 'typnamespace')}
137
+ UNION ALL
138
+ ${outside('function', 'pg_proc', 'proname', 'pronamespace')}
139
+ UNION ALL
140
+ ${outside('relation', 'pg_class', 'relname', 'relnamespace')}
141
+ UNION ALL
142
+ ${outside('operator', 'pg_operator', 'oprname', 'oprnamespace')}
143
+ UNION ALL
144
+ ${outside('operator class', 'pg_opclass', 'opcname', 'opcnamespace')}
145
+ UNION ALL
146
+ ${outside('collation', 'pg_collation', 'collname', 'collnamespace')}
147
+ UNION ALL
148
+ SELECT o.object || ' (no migration declares it)' FROM objects o WHERE o.object <> ALL (${declaredArray(declared)})
149
+ )
150
+ SELECT DISTINCT violation FROM violations ORDER BY violation`
151
+ }
152
+
153
+ /** The message a `databaseCodeCheck` failure carries before its list of violations. */
154
+ export const DATABASE_CODE_FAILURE = 'fabrika-migration-database-code: '
155
+
156
+ /** Fails the transaction it runs in when `schema` escapes or holds an object `declared` does not name. */
157
+ export function databaseCodeCheck(schema: string, declared: readonly DatabaseObject[]): string {
158
+ return `DO $check$
159
+ DECLARE found text;
160
+ BEGIN
161
+ SELECT pg_catalog.string_agg(violation, ', ' ORDER BY violation) INTO found FROM (${databaseCodeQuery(schema, declared)}) AS code;
162
+ IF found IS NOT NULL THEN RAISE EXCEPTION '${DATABASE_CODE_FAILURE}%', found; END IF;
163
+ END $check$`
164
+ }
@@ -0,0 +1,96 @@
1
+ // The SQL of a file collection's table that three readers must agree on: `@fabrika/db`'s `fileTableSql` and
2
+ // `fileScanSql` write it, the migration classifier admits exactly the scan migration, and `@fabrika/check` recognizes
3
+ // a table that can hold the scan states. Pure: tokens in, answers out.
4
+
5
+ import { type SqlToken, sqlTokens, topLevelStatements, upperWord } from './migration-sql'
6
+ import { nameText } from './sql-names'
7
+
8
+ /**
9
+ * A file row's states. `pending`: its object is still being written. `quarantined`: written to a scanning collection
10
+ * and not yet given a clean verdict. `live`: served. `rejected`: refused by the scan and its object is gone.
11
+ */
12
+ export const FILE_STATUSES = ['pending', 'live', 'quarantined', 'rejected'] as const
13
+
14
+ export const FILE_TABLE_PREFIX = 'fabrika_files_'
15
+
16
+ /** The columns the scan job keeps: the scanned object's version, and the retry count and time of a file without a verdict. */
17
+ export const FILE_SCAN_COLUMNS = ['object_version TEXT', 'scan_attempts INTEGER NOT NULL DEFAULT 0', 'scan_after TIMESTAMPTZ'] as const
18
+
19
+ const STATUS_CHECK = `CHECK (status IN (${FILE_STATUSES.map((status) => `'${status}'`).join(', ')}))`
20
+
21
+ /** The named status check of every state. */
22
+ export function fileStatusCheck(table: string): string {
23
+ return `CONSTRAINT ${table}_status ${STATUS_CHECK}`
24
+ }
25
+
26
+ /** The index the scan job selects its batch by. */
27
+ export function fileScanIndex(table: string): string {
28
+ return `CREATE INDEX ${table}_scan ON ${table} (scan_after) WHERE status = 'quarantined'`
29
+ }
30
+
31
+ /**
32
+ * The name Postgres gave the unnamed status check of a table an earlier `fileTableSql` created:
33
+ * `<table>_status_check`, the table's name cut to 50 characters first (`makeObjectName`).
34
+ */
35
+ export function legacyStatusCheckName(table: string): string {
36
+ return `${table.slice(0, 63 - '_status_check'.length)}_status_check`
37
+ }
38
+
39
+ /** The statement of `fileScanSql` that widens a table an earlier `fileTableSql` created: its check and its columns. */
40
+ export function fileScanAlter(table: string): string {
41
+ const columns = FILE_SCAN_COLUMNS.map((column) => `ADD COLUMN ${column}`).join(', ')
42
+ return `ALTER TABLE ${table} DROP CONSTRAINT IF EXISTS ${legacyStatusCheckName(table)}, ADD ${fileStatusCheck(table)}, ${columns}`
43
+ }
44
+
45
+ const tokenKey = (token: SqlToken): string =>
46
+ token.kind === 'word'
47
+ ? `w:${token.text.toLowerCase()}`
48
+ : token.kind === 'punct'
49
+ ? `p:${token.char}`
50
+ : token.kind === 'string'
51
+ ? `s:${token.text ?? ''}`
52
+ : `${token.kind}:`
53
+
54
+ const keys = (tokens: readonly SqlToken[]): string[] => tokens.map(tokenKey)
55
+
56
+ const equalKeys = (actual: readonly string[], expected: readonly string[]): boolean =>
57
+ actual.length === expected.length && expected.every((key, index) => actual[index] === key)
58
+
59
+ /** Whether `needle`'s tokens appear in `haystack`, in a row. */
60
+ function containsRun(haystack: readonly string[], needle: readonly string[]): boolean {
61
+ for (let start = 0; start + needle.length <= haystack.length; start++) {
62
+ if (needle.every((key, offset) => haystack[start + offset] === key)) return true
63
+ }
64
+ return false
65
+ }
66
+
67
+ /** The file table a statement is exactly `fileScanAlter` of, or `undefined`. */
68
+ export function fileScanAlterTable(tokens: readonly SqlToken[]): string | undefined {
69
+ const table = nameText(tokens[2])
70
+ if (upperWord(tokens[0]) !== 'ALTER' || table?.startsWith(FILE_TABLE_PREFIX) !== true || tokens[2]?.kind !== 'word') return undefined
71
+ return equalKeys(keys(tokens), keys(sqlTokens(fileScanAlter(table)))) ? table : undefined
72
+ }
73
+
74
+ /** Whether a `CREATE TABLE` of `table` declares the check of every state and the scan columns. */
75
+ function createsScanReady(tokens: readonly SqlToken[], table: string): boolean {
76
+ const statement = keys(tokens)
77
+ return containsRun(statement, keys(sqlTokens(fileStatusCheck(table))))
78
+ && FILE_SCAN_COLUMNS.every((column) => containsRun(statement, keys(sqlTokens(column))))
79
+ }
80
+
81
+ /**
82
+ * Whether the migrations, in order, leave `table` able to hold the scan states: created with the check of every state
83
+ * and the scan columns, or widened by `fileScanAlter`. `undefined` when none of them creates it.
84
+ */
85
+ export function fileTableScanReady(migrations: readonly string[], table: string): boolean | undefined {
86
+ let state: boolean | undefined
87
+ for (const sql of migrations) {
88
+ for (const { tokens } of topLevelStatements(sql)) {
89
+ const create = upperWord(tokens[0]) === 'CREATE' && upperWord(tokens[1]) === 'TABLE'
90
+ const named = upperWord(tokens[2]) === 'IF' ? tokens[5] : tokens[2]
91
+ if (create && nameText(named) === table) state = createsScanReady(tokens, table)
92
+ else if (state !== undefined && fileScanAlterTable(tokens) === table) state = true
93
+ }
94
+ }
95
+ return state
96
+ }
package/src/index.ts CHANGED
@@ -18,8 +18,24 @@
18
18
  // Migrations for what this package owns (the jobs table, the Postgres lock table) live in
19
19
  // `migrations/` — DDL is per-dialect even when the statement over it is portable.
20
20
 
21
+ export {
22
+ APPLICATION_MIGRATION_BUNDLE,
23
+ APPLICATION_MIGRATION_LEDGER,
24
+ applicationDatabaseRoles,
25
+ applicationMigrationLockKey,
26
+ applicationRuntimeUrl,
27
+ DEFAULT_MIGRATION_LOCK_TIMEOUT_MS,
28
+ DEFAULT_MIGRATION_STATEMENT_TIMEOUT_MS,
29
+ definePostgresApplicationMigrations,
30
+ dropApplicationDatabase,
31
+ type PostgresApplicationMigrations,
32
+ type PostgresApplicationMigrationsConfig,
33
+ } from './application-postgres-migrations'
34
+ export type { ApplicationDatabaseRoles } from './application-postgres-roles'
35
+ export { applicationDatabaseSchema } from './application-schema'
21
36
  export { FileSystemAssetServer, type FileSystemAssetServerOptions } from './asset-server-fs'
22
37
  export { S3BlobStore, type S3BlobStoreOptions } from './blob-s3'
38
+ export { databaseCodeQuery } from './database-code'
23
39
  export {
24
40
  decodeJob,
25
41
  type Job,
@@ -28,19 +44,24 @@ export {
28
44
  PostgresJobQueue,
29
45
  type PostgresJobQueueOptions,
30
46
  } from './job-queue-postgres'
47
+ export type { DatabaseObject, DatabaseObjectKind } from './migration-classify'
31
48
  export { rewritePlaceholders, type RewrittenSql } from './placeholders'
32
49
  export {
33
50
  applyPostgresMigrations,
34
51
  definePostgresMigrationPlan,
52
+ migrationChecksum,
35
53
  platformNodePostgresMigrationBundle,
54
+ type PostgresApplicationMigrationSettings,
36
55
  type PostgresMigration,
37
56
  type PostgresMigrationBundle,
57
+ PostgresMigrationError,
38
58
  postgresMigrationFilenames,
39
59
  postgresMigrationIdentity,
40
60
  type PostgresMigrationPlan,
41
61
  readPostgresMigrationBundle,
42
62
  type ReadPostgresMigrationBundleOptions,
43
63
  } from './postgres-migrations'
64
+ export { createTestProvisioner, type TestProvisioner } from './postgres-test-provisioner'
44
65
  export {
45
66
  definePostgresServiceMigrations,
46
67
  postgresMigrationResultMessage,