@pgpmjs/transform 0.18.0 → 0.19.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/bundle-driver.d.ts +1 -1
- package/catalog-check.d.ts +47 -0
- package/catalog-check.js +130 -0
- package/esm/catalog-check.js +124 -0
- package/esm/fixture-closure.js +1 -1
- package/esm/index.js +11 -6
- package/esm/module-emit.js +65 -0
- package/esm/module-source.js +54 -0
- package/esm/partition.js +89 -0
- package/esm/restructure.js +62 -0
- package/esm/semantic-diff-driver.js +27 -13
- package/fixture-closure.d.ts +1 -1
- package/fixture-closure.js +1 -1
- package/index.d.ts +20 -10
- package/index.js +34 -16
- package/module-emit.d.ts +39 -0
- package/module-emit.js +104 -0
- package/module-source.d.ts +33 -0
- package/module-source.js +92 -0
- package/package.json +7 -6
- package/partition.d.ts +32 -0
- package/partition.js +127 -0
- package/restructure.d.ts +38 -0
- package/restructure.js +67 -0
- package/semantic-diff-driver.js +27 -13
package/bundle-driver.d.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* purpose — no dependency on `@pgpmjs/bundle`/`@pgpmjs/core` — so the driver
|
|
8
8
|
* stays a pure function factory over `transformSql` and `classifyStatements`.
|
|
9
9
|
*/
|
|
10
|
-
import { ExtensionDefinition,
|
|
10
|
+
import { ExtensionDefinition, ExtensionRouter, ExtensionRouteSpec, ExtensionTransformResult, RoleRouteSpec, RoleTransformResult, SchemaRouter, SchemaTransformResult, TransformSqlOptions } from '@pgsql/transform';
|
|
11
11
|
/** Identity of the script being transformed/validated (matches the bundle seams). */
|
|
12
12
|
export interface BundleScriptContext {
|
|
13
13
|
change: string;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural catalog snapshot + equivalence check, used by
|
|
3
|
+
* `pgpm transform --check` to prove a transform is lossless: deploy the
|
|
4
|
+
* original and the transformed module(s) into two scratch databases and
|
|
5
|
+
* assert the resulting catalogs describe the same schema.
|
|
6
|
+
*
|
|
7
|
+
* The snapshot is intentionally order-insensitive per object class (objects
|
|
8
|
+
* are keyed by identity and compared by definition), but column order within
|
|
9
|
+
* a table IS part of the comparison — the dials pipeline preserves it.
|
|
10
|
+
*/
|
|
11
|
+
/** Minimal query surface: satisfied by pg Pool/PoolClient and test clients. */
|
|
12
|
+
export interface CatalogQueryable {
|
|
13
|
+
query(text: string): Promise<{
|
|
14
|
+
rows: any[];
|
|
15
|
+
}>;
|
|
16
|
+
}
|
|
17
|
+
/** Catalog snapshot: object identity -> normalized definition. */
|
|
18
|
+
export interface CatalogSnapshot {
|
|
19
|
+
schemas: Record<string, string>;
|
|
20
|
+
tables: Record<string, string>;
|
|
21
|
+
columns: Record<string, string>;
|
|
22
|
+
constraints: Record<string, string>;
|
|
23
|
+
indexes: Record<string, string>;
|
|
24
|
+
functions: Record<string, string>;
|
|
25
|
+
triggers: Record<string, string>;
|
|
26
|
+
policies: Record<string, string>;
|
|
27
|
+
rls: Record<string, string>;
|
|
28
|
+
grants: Record<string, string>;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Snapshot the user-schema surface of a database: schemas, tables, columns,
|
|
32
|
+
* constraints, indexes, functions, triggers, policies, RLS flags, and table
|
|
33
|
+
* grants. System schemas, `public`, and the pgpm ledger are excluded.
|
|
34
|
+
*/
|
|
35
|
+
export declare const snapshotCatalog: (db: CatalogQueryable) => Promise<CatalogSnapshot>;
|
|
36
|
+
/**
|
|
37
|
+
* Column-order-insensitive view of a snapshot. Migrations that drop and add
|
|
38
|
+
* columns can never reproduce a fresh deploy's physical column order
|
|
39
|
+
* (Postgres appends), so migration-equivalence checks (`pgpm diff --verify`)
|
|
40
|
+
* compare columns by name and definition only.
|
|
41
|
+
*/
|
|
42
|
+
export declare const withoutColumnOrder: (snap: CatalogSnapshot) => CatalogSnapshot;
|
|
43
|
+
/**
|
|
44
|
+
* Compare two catalog snapshots. Returns a flat list of human-readable
|
|
45
|
+
* differences; an empty list means the catalogs are structurally equivalent.
|
|
46
|
+
*/
|
|
47
|
+
export declare const diffCatalogSnapshots: (a: CatalogSnapshot, b: CatalogSnapshot) => string[];
|
package/catalog-check.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.diffCatalogSnapshots = exports.withoutColumnOrder = exports.snapshotCatalog = void 0;
|
|
4
|
+
const SYSTEM_SCHEMAS = `('pg_catalog', 'information_schema', 'pg_toast', 'pgpm_migrate', 'public')`;
|
|
5
|
+
const record = (rows, key, value) => {
|
|
6
|
+
const out = {};
|
|
7
|
+
for (const row of rows)
|
|
8
|
+
out[key(row)] = value(row);
|
|
9
|
+
return out;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Snapshot the user-schema surface of a database: schemas, tables, columns,
|
|
13
|
+
* constraints, indexes, functions, triggers, policies, RLS flags, and table
|
|
14
|
+
* grants. System schemas, `public`, and the pgpm ledger are excluded.
|
|
15
|
+
*/
|
|
16
|
+
const snapshotCatalog = async (db) => {
|
|
17
|
+
const schemas = await db.query(`
|
|
18
|
+
SELECT nspname FROM pg_namespace
|
|
19
|
+
WHERE nspname NOT IN ${SYSTEM_SCHEMAS} AND nspname NOT LIKE 'pg_temp%' AND nspname NOT LIKE 'pg_toast%'
|
|
20
|
+
ORDER BY nspname
|
|
21
|
+
`);
|
|
22
|
+
const tables = await db.query(`
|
|
23
|
+
SELECT table_schema, table_name FROM information_schema.tables
|
|
24
|
+
WHERE table_schema NOT IN ${SYSTEM_SCHEMAS} AND table_type = 'BASE TABLE'
|
|
25
|
+
`);
|
|
26
|
+
const columns = await db.query(`
|
|
27
|
+
SELECT table_schema, table_name, column_name, ordinal_position,
|
|
28
|
+
data_type, is_nullable, column_default, character_maximum_length
|
|
29
|
+
FROM information_schema.columns
|
|
30
|
+
WHERE table_schema NOT IN ${SYSTEM_SCHEMAS}
|
|
31
|
+
`);
|
|
32
|
+
const constraints = await db.query(`
|
|
33
|
+
SELECT n.nspname AS schema, cl.relname AS table, c.conname,
|
|
34
|
+
pg_get_constraintdef(c.oid) AS def
|
|
35
|
+
FROM pg_constraint c
|
|
36
|
+
JOIN pg_class cl ON cl.oid = c.conrelid
|
|
37
|
+
JOIN pg_namespace n ON n.oid = cl.relnamespace
|
|
38
|
+
WHERE n.nspname NOT IN ${SYSTEM_SCHEMAS}
|
|
39
|
+
`);
|
|
40
|
+
const indexes = await db.query(`
|
|
41
|
+
SELECT schemaname, tablename, indexname, indexdef
|
|
42
|
+
FROM pg_indexes WHERE schemaname NOT IN ${SYSTEM_SCHEMAS}
|
|
43
|
+
`);
|
|
44
|
+
const functions = await db.query(`
|
|
45
|
+
SELECT n.nspname AS schema, p.proname,
|
|
46
|
+
pg_get_function_identity_arguments(p.oid) AS args,
|
|
47
|
+
pg_get_functiondef(p.oid) AS def
|
|
48
|
+
FROM pg_proc p
|
|
49
|
+
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
50
|
+
WHERE n.nspname NOT IN ${SYSTEM_SCHEMAS}
|
|
51
|
+
`);
|
|
52
|
+
const triggers = await db.query(`
|
|
53
|
+
SELECT n.nspname AS schema, cl.relname AS table, t.tgname,
|
|
54
|
+
pg_get_triggerdef(t.oid) AS def
|
|
55
|
+
FROM pg_trigger t
|
|
56
|
+
JOIN pg_class cl ON cl.oid = t.tgrelid
|
|
57
|
+
JOIN pg_namespace n ON n.oid = cl.relnamespace
|
|
58
|
+
WHERE NOT t.tgisinternal AND n.nspname NOT IN ${SYSTEM_SCHEMAS}
|
|
59
|
+
`);
|
|
60
|
+
const policies = await db.query(`
|
|
61
|
+
SELECT schemaname, tablename, policyname, permissive, roles, cmd,
|
|
62
|
+
qual, with_check
|
|
63
|
+
FROM pg_policies WHERE schemaname NOT IN ${SYSTEM_SCHEMAS}
|
|
64
|
+
`);
|
|
65
|
+
const rls = await db.query(`
|
|
66
|
+
SELECT n.nspname AS schema, cl.relname AS table,
|
|
67
|
+
cl.relrowsecurity, cl.relforcerowsecurity
|
|
68
|
+
FROM pg_class cl
|
|
69
|
+
JOIN pg_namespace n ON n.oid = cl.relnamespace
|
|
70
|
+
WHERE cl.relkind = 'r' AND n.nspname NOT IN ${SYSTEM_SCHEMAS}
|
|
71
|
+
`);
|
|
72
|
+
const grants = await db.query(`
|
|
73
|
+
SELECT table_schema, table_name, grantee, privilege_type
|
|
74
|
+
FROM information_schema.role_table_grants
|
|
75
|
+
WHERE table_schema NOT IN ${SYSTEM_SCHEMAS}
|
|
76
|
+
AND grantee <> (SELECT current_user)
|
|
77
|
+
`);
|
|
78
|
+
return {
|
|
79
|
+
schemas: record(schemas.rows, r => r.nspname, () => 'schema'),
|
|
80
|
+
tables: record(tables.rows, r => `${r.table_schema}.${r.table_name}`, () => 'table'),
|
|
81
|
+
columns: record(columns.rows, r => `${r.table_schema}.${r.table_name}.${r.column_name}`, r => `#${r.ordinal_position} ${r.data_type}${r.character_maximum_length ? `(${r.character_maximum_length})` : ''} nullable=${r.is_nullable} default=${r.column_default ?? 'none'}`),
|
|
82
|
+
constraints: record(constraints.rows, r => `${r.schema}.${r.table}.${r.conname}`, r => r.def),
|
|
83
|
+
indexes: record(indexes.rows, r => `${r.schemaname}.${r.tablename}.${r.indexname}`, r => r.indexdef),
|
|
84
|
+
functions: record(functions.rows, r => `${r.schema}.${r.proname}(${r.args})`, r => r.def),
|
|
85
|
+
triggers: record(triggers.rows, r => `${r.schema}.${r.table}.${r.tgname}`, r => r.def),
|
|
86
|
+
policies: record(policies.rows, r => `${r.schemaname}.${r.tablename}.${r.policyname}`, r => `permissive=${r.permissive} roles=${[...(r.roles ?? [])].sort().join(',')} cmd=${r.cmd} qual=${r.qual ?? 'none'} check=${r.with_check ?? 'none'}`),
|
|
87
|
+
rls: record(rls.rows, r => `${r.schema}.${r.table}`, r => `enabled=${r.relrowsecurity} forced=${r.relforcerowsecurity}`),
|
|
88
|
+
grants: record(grants.rows, r => `${r.table_schema}.${r.table_name}:${r.grantee}:${r.privilege_type}`, () => 'grant')
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
exports.snapshotCatalog = snapshotCatalog;
|
|
92
|
+
/**
|
|
93
|
+
* Column-order-insensitive view of a snapshot. Migrations that drop and add
|
|
94
|
+
* columns can never reproduce a fresh deploy's physical column order
|
|
95
|
+
* (Postgres appends), so migration-equivalence checks (`pgpm diff --verify`)
|
|
96
|
+
* compare columns by name and definition only.
|
|
97
|
+
*/
|
|
98
|
+
const withoutColumnOrder = (snap) => {
|
|
99
|
+
const columns = {};
|
|
100
|
+
for (const [key, value] of Object.entries(snap.columns)) {
|
|
101
|
+
columns[key] = value.replace(/^#\d+ /, '');
|
|
102
|
+
}
|
|
103
|
+
return { ...snap, columns };
|
|
104
|
+
};
|
|
105
|
+
exports.withoutColumnOrder = withoutColumnOrder;
|
|
106
|
+
/**
|
|
107
|
+
* Compare two catalog snapshots. Returns a flat list of human-readable
|
|
108
|
+
* differences; an empty list means the catalogs are structurally equivalent.
|
|
109
|
+
*/
|
|
110
|
+
const diffCatalogSnapshots = (a, b) => {
|
|
111
|
+
const diffs = [];
|
|
112
|
+
for (const section of Object.keys(a)) {
|
|
113
|
+
const left = a[section];
|
|
114
|
+
const right = b[section];
|
|
115
|
+
for (const key of Object.keys(left).sort()) {
|
|
116
|
+
if (!(key in right)) {
|
|
117
|
+
diffs.push(`${section}: ${key} only in original`);
|
|
118
|
+
}
|
|
119
|
+
else if (left[key] !== right[key]) {
|
|
120
|
+
diffs.push(`${section}: ${key} differs\n original: ${left[key]}\n transformed: ${right[key]}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
for (const key of Object.keys(right).sort()) {
|
|
124
|
+
if (!(key in left))
|
|
125
|
+
diffs.push(`${section}: ${key} only in transformed`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return diffs;
|
|
129
|
+
};
|
|
130
|
+
exports.diffCatalogSnapshots = diffCatalogSnapshots;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
const SYSTEM_SCHEMAS = `('pg_catalog', 'information_schema', 'pg_toast', 'pgpm_migrate', 'public')`;
|
|
2
|
+
const record = (rows, key, value) => {
|
|
3
|
+
const out = {};
|
|
4
|
+
for (const row of rows)
|
|
5
|
+
out[key(row)] = value(row);
|
|
6
|
+
return out;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Snapshot the user-schema surface of a database: schemas, tables, columns,
|
|
10
|
+
* constraints, indexes, functions, triggers, policies, RLS flags, and table
|
|
11
|
+
* grants. System schemas, `public`, and the pgpm ledger are excluded.
|
|
12
|
+
*/
|
|
13
|
+
export const snapshotCatalog = async (db) => {
|
|
14
|
+
const schemas = await db.query(`
|
|
15
|
+
SELECT nspname FROM pg_namespace
|
|
16
|
+
WHERE nspname NOT IN ${SYSTEM_SCHEMAS} AND nspname NOT LIKE 'pg_temp%' AND nspname NOT LIKE 'pg_toast%'
|
|
17
|
+
ORDER BY nspname
|
|
18
|
+
`);
|
|
19
|
+
const tables = await db.query(`
|
|
20
|
+
SELECT table_schema, table_name FROM information_schema.tables
|
|
21
|
+
WHERE table_schema NOT IN ${SYSTEM_SCHEMAS} AND table_type = 'BASE TABLE'
|
|
22
|
+
`);
|
|
23
|
+
const columns = await db.query(`
|
|
24
|
+
SELECT table_schema, table_name, column_name, ordinal_position,
|
|
25
|
+
data_type, is_nullable, column_default, character_maximum_length
|
|
26
|
+
FROM information_schema.columns
|
|
27
|
+
WHERE table_schema NOT IN ${SYSTEM_SCHEMAS}
|
|
28
|
+
`);
|
|
29
|
+
const constraints = await db.query(`
|
|
30
|
+
SELECT n.nspname AS schema, cl.relname AS table, c.conname,
|
|
31
|
+
pg_get_constraintdef(c.oid) AS def
|
|
32
|
+
FROM pg_constraint c
|
|
33
|
+
JOIN pg_class cl ON cl.oid = c.conrelid
|
|
34
|
+
JOIN pg_namespace n ON n.oid = cl.relnamespace
|
|
35
|
+
WHERE n.nspname NOT IN ${SYSTEM_SCHEMAS}
|
|
36
|
+
`);
|
|
37
|
+
const indexes = await db.query(`
|
|
38
|
+
SELECT schemaname, tablename, indexname, indexdef
|
|
39
|
+
FROM pg_indexes WHERE schemaname NOT IN ${SYSTEM_SCHEMAS}
|
|
40
|
+
`);
|
|
41
|
+
const functions = await db.query(`
|
|
42
|
+
SELECT n.nspname AS schema, p.proname,
|
|
43
|
+
pg_get_function_identity_arguments(p.oid) AS args,
|
|
44
|
+
pg_get_functiondef(p.oid) AS def
|
|
45
|
+
FROM pg_proc p
|
|
46
|
+
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
47
|
+
WHERE n.nspname NOT IN ${SYSTEM_SCHEMAS}
|
|
48
|
+
`);
|
|
49
|
+
const triggers = await db.query(`
|
|
50
|
+
SELECT n.nspname AS schema, cl.relname AS table, t.tgname,
|
|
51
|
+
pg_get_triggerdef(t.oid) AS def
|
|
52
|
+
FROM pg_trigger t
|
|
53
|
+
JOIN pg_class cl ON cl.oid = t.tgrelid
|
|
54
|
+
JOIN pg_namespace n ON n.oid = cl.relnamespace
|
|
55
|
+
WHERE NOT t.tgisinternal AND n.nspname NOT IN ${SYSTEM_SCHEMAS}
|
|
56
|
+
`);
|
|
57
|
+
const policies = await db.query(`
|
|
58
|
+
SELECT schemaname, tablename, policyname, permissive, roles, cmd,
|
|
59
|
+
qual, with_check
|
|
60
|
+
FROM pg_policies WHERE schemaname NOT IN ${SYSTEM_SCHEMAS}
|
|
61
|
+
`);
|
|
62
|
+
const rls = await db.query(`
|
|
63
|
+
SELECT n.nspname AS schema, cl.relname AS table,
|
|
64
|
+
cl.relrowsecurity, cl.relforcerowsecurity
|
|
65
|
+
FROM pg_class cl
|
|
66
|
+
JOIN pg_namespace n ON n.oid = cl.relnamespace
|
|
67
|
+
WHERE cl.relkind = 'r' AND n.nspname NOT IN ${SYSTEM_SCHEMAS}
|
|
68
|
+
`);
|
|
69
|
+
const grants = await db.query(`
|
|
70
|
+
SELECT table_schema, table_name, grantee, privilege_type
|
|
71
|
+
FROM information_schema.role_table_grants
|
|
72
|
+
WHERE table_schema NOT IN ${SYSTEM_SCHEMAS}
|
|
73
|
+
AND grantee <> (SELECT current_user)
|
|
74
|
+
`);
|
|
75
|
+
return {
|
|
76
|
+
schemas: record(schemas.rows, r => r.nspname, () => 'schema'),
|
|
77
|
+
tables: record(tables.rows, r => `${r.table_schema}.${r.table_name}`, () => 'table'),
|
|
78
|
+
columns: record(columns.rows, r => `${r.table_schema}.${r.table_name}.${r.column_name}`, r => `#${r.ordinal_position} ${r.data_type}${r.character_maximum_length ? `(${r.character_maximum_length})` : ''} nullable=${r.is_nullable} default=${r.column_default ?? 'none'}`),
|
|
79
|
+
constraints: record(constraints.rows, r => `${r.schema}.${r.table}.${r.conname}`, r => r.def),
|
|
80
|
+
indexes: record(indexes.rows, r => `${r.schemaname}.${r.tablename}.${r.indexname}`, r => r.indexdef),
|
|
81
|
+
functions: record(functions.rows, r => `${r.schema}.${r.proname}(${r.args})`, r => r.def),
|
|
82
|
+
triggers: record(triggers.rows, r => `${r.schema}.${r.table}.${r.tgname}`, r => r.def),
|
|
83
|
+
policies: record(policies.rows, r => `${r.schemaname}.${r.tablename}.${r.policyname}`, r => `permissive=${r.permissive} roles=${[...(r.roles ?? [])].sort().join(',')} cmd=${r.cmd} qual=${r.qual ?? 'none'} check=${r.with_check ?? 'none'}`),
|
|
84
|
+
rls: record(rls.rows, r => `${r.schema}.${r.table}`, r => `enabled=${r.relrowsecurity} forced=${r.relforcerowsecurity}`),
|
|
85
|
+
grants: record(grants.rows, r => `${r.table_schema}.${r.table_name}:${r.grantee}:${r.privilege_type}`, () => 'grant')
|
|
86
|
+
};
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* Column-order-insensitive view of a snapshot. Migrations that drop and add
|
|
90
|
+
* columns can never reproduce a fresh deploy's physical column order
|
|
91
|
+
* (Postgres appends), so migration-equivalence checks (`pgpm diff --verify`)
|
|
92
|
+
* compare columns by name and definition only.
|
|
93
|
+
*/
|
|
94
|
+
export const withoutColumnOrder = (snap) => {
|
|
95
|
+
const columns = {};
|
|
96
|
+
for (const [key, value] of Object.entries(snap.columns)) {
|
|
97
|
+
columns[key] = value.replace(/^#\d+ /, '');
|
|
98
|
+
}
|
|
99
|
+
return { ...snap, columns };
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* Compare two catalog snapshots. Returns a flat list of human-readable
|
|
103
|
+
* differences; an empty list means the catalogs are structurally equivalent.
|
|
104
|
+
*/
|
|
105
|
+
export const diffCatalogSnapshots = (a, b) => {
|
|
106
|
+
const diffs = [];
|
|
107
|
+
for (const section of Object.keys(a)) {
|
|
108
|
+
const left = a[section];
|
|
109
|
+
const right = b[section];
|
|
110
|
+
for (const key of Object.keys(left).sort()) {
|
|
111
|
+
if (!(key in right)) {
|
|
112
|
+
diffs.push(`${section}: ${key} only in original`);
|
|
113
|
+
}
|
|
114
|
+
else if (left[key] !== right[key]) {
|
|
115
|
+
diffs.push(`${section}: ${key} differs\n original: ${left[key]}\n transformed: ${right[key]}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
for (const key of Object.keys(right).sort()) {
|
|
119
|
+
if (!(key in left))
|
|
120
|
+
diffs.push(`${section}: ${key} only in transformed`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return diffs;
|
|
124
|
+
};
|
package/esm/fixture-closure.js
CHANGED
package/esm/index.js
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
export * from '@pgsql/transform';
|
|
2
|
-
export { loadModule } from 'plpgsql-parser';
|
|
3
1
|
export { buildExtensionRouter, buildSchemaRouter, makeNamespaceValidator, makeSchemaTranspiler, } from './bundle-driver';
|
|
4
|
-
export {
|
|
5
|
-
export { diffChangeSets, diffSchemas, } from './semantic-diff-driver';
|
|
6
|
-
export { PartitionCycleError, partitionUnits, RESIDUAL_UNIT_PATH, } from './partition-driver';
|
|
2
|
+
export { diffCatalogSnapshots, snapshotCatalog, withoutColumnOrder } from './catalog-check';
|
|
7
3
|
export { buildCategoryOf, categorizeChange, TIER_PROFILE, } from './categorize';
|
|
8
4
|
export { resolveFixtureClosure } from './fixture-closure';
|
|
9
|
-
export {
|
|
5
|
+
export { defaultChangeName, restructureChanges, } from './granularity-driver';
|
|
6
|
+
export { checkOverwrite, writeControlFile, writeModule } from './module-emit';
|
|
7
|
+
export { loadModuleSource, stripTransactionWrapper } from './module-source';
|
|
8
|
+
export { parsePartitionConfig, partitionExportRows } from './partition';
|
|
9
|
+
export { PartitionCycleError, partitionUnits, RESIDUAL_UNIT_PATH, } from './partition-driver';
|
|
10
10
|
export { emitSqlProgram, parseSqlProgram } from './program';
|
|
11
|
+
export { classifyScript, isStubScript, regenerateScripts, } from './regen';
|
|
12
|
+
export { EXPORT_GRANULARITIES, isExportGranularity, restructureExportRows } from './restructure';
|
|
13
|
+
export { diffChangeSets, diffSchemas, } from './semantic-diff-driver';
|
|
14
|
+
export * from '@pgsql/transform';
|
|
15
|
+
export { loadModule } from 'plpgsql-parser';
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module emission — the single seam every dials command renders through.
|
|
3
|
+
*
|
|
4
|
+
* The engine (semantic diff, granularity, partition) produces plan-ordered
|
|
5
|
+
* {@link PgpmRow}s; a {@link PgpmModuleModel} is just those rows plus the
|
|
6
|
+
* module's name and control-file requires. `writeModule` is the one writer
|
|
7
|
+
* that turns a model into an on-disk pgpm module (`.control` + `pgpm.plan` +
|
|
8
|
+
* deploy/revert/verify trees) — so `pgpm diff`, `pgpm transform`, and
|
|
9
|
+
* `pgpm import` all emit byte-identical module layouts instead of each
|
|
10
|
+
* carrying its own copy of the writer.
|
|
11
|
+
*/
|
|
12
|
+
import { writePgpmFiles, writePgpmPlan } from '@pgpmjs/ast';
|
|
13
|
+
import * as fs from 'fs';
|
|
14
|
+
import * as path from 'path';
|
|
15
|
+
/** Write a module's Postgres extension `.control` file. */
|
|
16
|
+
export const writeControlFile = (dir, name, requires) => {
|
|
17
|
+
const lines = [
|
|
18
|
+
`# ${name} extension`,
|
|
19
|
+
`comment = '${name} extension'`,
|
|
20
|
+
`default_version = '0.0.1'`,
|
|
21
|
+
`relocatable = false`,
|
|
22
|
+
`superuser = false`
|
|
23
|
+
];
|
|
24
|
+
if (requires.length) {
|
|
25
|
+
lines.push(`requires = '${requires.join(',')}'`);
|
|
26
|
+
}
|
|
27
|
+
fs.writeFileSync(path.join(dir, `${name}.control`), lines.join('\n') + '\n');
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Write a {@link PgpmModuleModel} to `<outBase>/<name>` as a full pgpm module:
|
|
31
|
+
* a `.control` file, `pgpm.plan`, and deploy/revert/verify script trees.
|
|
32
|
+
* The deploy/revert/verify directories are cleared first so a re-emit never
|
|
33
|
+
* leaves stale scripts behind. Returns the module directory.
|
|
34
|
+
*/
|
|
35
|
+
export const writeModule = (outBase, model, extraRequires = []) => {
|
|
36
|
+
const dir = path.join(outBase, model.name);
|
|
37
|
+
fs.rmSync(path.join(dir, 'deploy'), { recursive: true, force: true });
|
|
38
|
+
fs.rmSync(path.join(dir, 'revert'), { recursive: true, force: true });
|
|
39
|
+
fs.rmSync(path.join(dir, 'verify'), { recursive: true, force: true });
|
|
40
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
41
|
+
writeControlFile(dir, model.name, [...extraRequires, ...model.requires]);
|
|
42
|
+
const opts = {
|
|
43
|
+
outdir: outBase,
|
|
44
|
+
name: model.name,
|
|
45
|
+
replacer: (str) => str.split('constructive-extension-name').join(model.name)
|
|
46
|
+
};
|
|
47
|
+
writePgpmPlan(model.rows, opts);
|
|
48
|
+
writePgpmFiles(model.rows, opts);
|
|
49
|
+
return dir;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Guard against clobbering: writing into the source directory requires
|
|
53
|
+
* `--write`, as does overwriting any existing package directory.
|
|
54
|
+
*/
|
|
55
|
+
export const checkOverwrite = (targetDir, sourcePath, write) => {
|
|
56
|
+
const resolvedTarget = path.resolve(targetDir);
|
|
57
|
+
const resolvedSource = path.resolve(sourcePath);
|
|
58
|
+
if (resolvedTarget === resolvedSource && !write) {
|
|
59
|
+
return `Refusing to overwrite the source module at ${resolvedSource} (pass --write to allow).`;
|
|
60
|
+
}
|
|
61
|
+
if (resolvedTarget !== resolvedSource && fs.existsSync(resolvedTarget) && !write) {
|
|
62
|
+
return `Output directory ${resolvedTarget} already exists (pass --write to overwrite).`;
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module ingestion for the dials pipeline: load an on-disk pgpm module into
|
|
3
|
+
* the flattened, classified change list the transform/partition drivers
|
|
4
|
+
* consume. `pgpm transform` uses this today; a future `pgpm import` (dumps)
|
|
5
|
+
* and `pgpm diff` are expected to reuse the same seam.
|
|
6
|
+
*/
|
|
7
|
+
import { parsePgpmHeader, parsePlanFile, readScript } from '@pgpmjs/ast';
|
|
8
|
+
import * as path from 'path';
|
|
9
|
+
const TX_LINE = /^\s*(BEGIN|COMMIT|ROLLBACK)\s*;\s*$/i;
|
|
10
|
+
/**
|
|
11
|
+
* Strip the transaction wrapper some modules carry inside their scripts
|
|
12
|
+
* (standalone `BEGIN;` / `COMMIT;` / `ROLLBACK;` lines). The caller owns
|
|
13
|
+
* transaction control.
|
|
14
|
+
*/
|
|
15
|
+
export const stripTransactionWrapper = (sql) => sql
|
|
16
|
+
.split('\n')
|
|
17
|
+
.filter(line => !TX_LINE.test(line))
|
|
18
|
+
.join('\n')
|
|
19
|
+
.trim();
|
|
20
|
+
/**
|
|
21
|
+
* Load a pgpm module from disk: parse pgpm.plan and read each change's
|
|
22
|
+
* deploy script, stripping pgpm headers and transaction wrappers so the
|
|
23
|
+
* result can be flattened straight into the dials pipeline
|
|
24
|
+
* (`restructureChanges` / `partitionUnits`).
|
|
25
|
+
*/
|
|
26
|
+
export const loadModuleSource = (moduleDir) => {
|
|
27
|
+
const modulePath = path.resolve(moduleDir);
|
|
28
|
+
const planResult = parsePlanFile(path.join(modulePath, 'pgpm.plan'));
|
|
29
|
+
if (!planResult.data) {
|
|
30
|
+
const reasons = planResult.errors.map(e => e.message).join(', ');
|
|
31
|
+
throw new Error(`Failed to parse pgpm.plan in ${modulePath}: ${reasons}`);
|
|
32
|
+
}
|
|
33
|
+
const warnings = [];
|
|
34
|
+
const changes = [];
|
|
35
|
+
for (const change of planResult.data.changes) {
|
|
36
|
+
const raw = readScript(modulePath, 'deploy', change.name);
|
|
37
|
+
if (!raw) {
|
|
38
|
+
warnings.push(`${change.name}: no deploy script found, skipping`);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const { body } = parsePgpmHeader(raw);
|
|
42
|
+
changes.push({
|
|
43
|
+
name: change.name,
|
|
44
|
+
dependencies: change.dependencies ?? [],
|
|
45
|
+
deploy: stripTransactionWrapper(body)
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
name: planResult.data.package,
|
|
50
|
+
modulePath,
|
|
51
|
+
changes,
|
|
52
|
+
warnings
|
|
53
|
+
};
|
|
54
|
+
};
|
package/esm/partition.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import { loadModule } from 'plpgsql-parser';
|
|
3
|
+
import { partitionUnits } from './partition-driver';
|
|
4
|
+
import { regenerateScripts } from './regen';
|
|
5
|
+
const PATH_STYLES = ['directory', 'flat'];
|
|
6
|
+
/**
|
|
7
|
+
* Parse and validate a partition config file (JSON matching
|
|
8
|
+
* `PartitionConfig`: rules, defaultPackage, optional style/splitRiders).
|
|
9
|
+
* Throws with a message naming the offending field.
|
|
10
|
+
*/
|
|
11
|
+
export const parsePartitionConfig = (filePath) => {
|
|
12
|
+
let raw;
|
|
13
|
+
try {
|
|
14
|
+
raw = fs.readFileSync(filePath, 'utf-8');
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
throw new Error(`Partition config not found: ${filePath}`);
|
|
18
|
+
}
|
|
19
|
+
let parsed;
|
|
20
|
+
try {
|
|
21
|
+
parsed = JSON.parse(raw);
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
throw new Error(`Partition config is not valid JSON (${filePath}): ${err?.message ?? err}`);
|
|
25
|
+
}
|
|
26
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
27
|
+
throw new Error(`Partition config must be a JSON object (${filePath})`);
|
|
28
|
+
}
|
|
29
|
+
if (typeof parsed.defaultPackage !== 'string' || !parsed.defaultPackage) {
|
|
30
|
+
throw new Error('Partition config: "defaultPackage" must be a non-empty string');
|
|
31
|
+
}
|
|
32
|
+
if (!Array.isArray(parsed.rules)) {
|
|
33
|
+
throw new Error('Partition config: "rules" must be an array');
|
|
34
|
+
}
|
|
35
|
+
parsed.rules.forEach((rule, i) => {
|
|
36
|
+
if (typeof rule !== 'object' || rule === null) {
|
|
37
|
+
throw new Error(`Partition config: rules[${i}] must be an object`);
|
|
38
|
+
}
|
|
39
|
+
if (typeof rule.package !== 'string' || !rule.package) {
|
|
40
|
+
throw new Error(`Partition config: rules[${i}].package must be a non-empty string`);
|
|
41
|
+
}
|
|
42
|
+
if (!Array.isArray(rule.select) || rule.select.length === 0) {
|
|
43
|
+
throw new Error(`Partition config: rules[${i}].select must be a non-empty array of selectors`);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
if (parsed.style !== undefined && !PATH_STYLES.includes(parsed.style)) {
|
|
47
|
+
throw new Error(`Partition config: "style" must be one of: ${PATH_STYLES.join(', ')}`);
|
|
48
|
+
}
|
|
49
|
+
if (parsed.splitRiders !== undefined && !Array.isArray(parsed.splitRiders)) {
|
|
50
|
+
throw new Error('Partition config: "splitRiders" must be an array of statement kinds');
|
|
51
|
+
}
|
|
52
|
+
return parsed;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Partition changes into packages and generate revert/verify for every
|
|
56
|
+
* emitted change. Cross-package dependencies keep the `<pkg>:<path>`
|
|
57
|
+
* convention in `deps`. May throw `PartitionCycleError`.
|
|
58
|
+
*/
|
|
59
|
+
export const partitionExportRows = async (rows, config) => {
|
|
60
|
+
await loadModule();
|
|
61
|
+
const input = rows.map(row => ({
|
|
62
|
+
name: row.deploy,
|
|
63
|
+
dependencies: row.deps ?? [],
|
|
64
|
+
deploy: row.content
|
|
65
|
+
}));
|
|
66
|
+
const { packages, warnings } = partitionUnits(input, config);
|
|
67
|
+
const result = packages.map(pkg => ({
|
|
68
|
+
name: pkg.name,
|
|
69
|
+
requires: pkg.requires,
|
|
70
|
+
rows: pkg.changes.map(change => {
|
|
71
|
+
const scripts = regenerateScripts(change.deploy);
|
|
72
|
+
for (const warning of scripts.revert.warnings) {
|
|
73
|
+
warnings.push(`${pkg.name}:${change.name}: ${warning}`);
|
|
74
|
+
}
|
|
75
|
+
for (const warning of scripts.verify.warnings) {
|
|
76
|
+
warnings.push(`${pkg.name}:${change.name}: ${warning}`);
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
name: change.name,
|
|
80
|
+
deploy: change.name,
|
|
81
|
+
deps: change.dependencies,
|
|
82
|
+
content: change.deploy,
|
|
83
|
+
revert: scripts.revert.sql,
|
|
84
|
+
verify: scripts.verify.sql
|
|
85
|
+
};
|
|
86
|
+
})
|
|
87
|
+
}));
|
|
88
|
+
return { packages: result, warnings };
|
|
89
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { alterationPathFor, pathFor } from '@pgpmjs/naming-spec';
|
|
2
|
+
import { identityOf } from '@pgsql/transform';
|
|
3
|
+
import { loadModule } from 'plpgsql-parser';
|
|
4
|
+
import { restructureChanges } from './granularity-driver';
|
|
5
|
+
export const EXPORT_GRANULARITIES = ['atomic', 'object', 'consolidated'];
|
|
6
|
+
export const isExportGranularity = (value) => typeof value === 'string' && EXPORT_GRANULARITIES.includes(value);
|
|
7
|
+
/**
|
|
8
|
+
* Restructure exported migration rows to the target granularity.
|
|
9
|
+
*
|
|
10
|
+
* The rows' deploy SQL is flattened in export order, restructured, and
|
|
11
|
+
* re-sliced into per-object changes whose paths come from the naming spec and
|
|
12
|
+
* whose deps come from the statement graph. When two changes derive the same
|
|
13
|
+
* path (the same object altered again), later occurrences get the spec's
|
|
14
|
+
* alteration convention via `alterationPathFor` (monotonic per-parent
|
|
15
|
+
* counter), with the parent added to their deps.
|
|
16
|
+
*
|
|
17
|
+
* Revert/verify scripts are not carried through from the original rows (the
|
|
18
|
+
* restructured grouping no longer matches them one-to-one); instead each
|
|
19
|
+
* change gets scripts generated from its own statements via
|
|
20
|
+
* `revertFor`/`verifyFor` (drops in reverse topological order, one existence
|
|
21
|
+
* check per created object).
|
|
22
|
+
*/
|
|
23
|
+
export const restructureExportRows = async (rows, granularity, options = {}) => {
|
|
24
|
+
await loadModule();
|
|
25
|
+
const style = options.naming ?? 'directory';
|
|
26
|
+
const changeName = (facts) => {
|
|
27
|
+
const identity = identityOf(facts);
|
|
28
|
+
if (identity)
|
|
29
|
+
return pathFor(identity, { style });
|
|
30
|
+
return 'misc/statements';
|
|
31
|
+
};
|
|
32
|
+
const { changes, warnings } = restructureChanges(rows.map(row => ({
|
|
33
|
+
name: row.deploy,
|
|
34
|
+
dependencies: row.deps ?? [],
|
|
35
|
+
deploy: row.content
|
|
36
|
+
})), { granularity, changeName });
|
|
37
|
+
const counters = new Map();
|
|
38
|
+
const taken = new Set();
|
|
39
|
+
const restructured = changes.map(change => {
|
|
40
|
+
let deploy = change.name;
|
|
41
|
+
const deps = [...change.dependencies];
|
|
42
|
+
if (taken.has(deploy)) {
|
|
43
|
+
let n = (counters.get(change.name) ?? 0) + 1;
|
|
44
|
+
while (taken.has(alterationPathFor(change.name, n)))
|
|
45
|
+
n++;
|
|
46
|
+
counters.set(change.name, n);
|
|
47
|
+
deploy = alterationPathFor(change.name, n);
|
|
48
|
+
if (!deps.includes(change.name))
|
|
49
|
+
deps.push(change.name);
|
|
50
|
+
}
|
|
51
|
+
taken.add(deploy);
|
|
52
|
+
return {
|
|
53
|
+
name: deploy,
|
|
54
|
+
deploy,
|
|
55
|
+
deps,
|
|
56
|
+
content: change.deploy,
|
|
57
|
+
revert: change.revert,
|
|
58
|
+
verify: change.verify
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
return { rows: restructured, warnings };
|
|
62
|
+
};
|