@supabase/pg-delta 1.0.0-alpha.25 → 1.0.0-alpha.27
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/dist/cli/commands/catalog-export.js +22 -1
- package/dist/core/catalog.filter.d.ts +17 -0
- package/dist/core/catalog.filter.js +75 -0
- package/dist/core/catalog.model.js +9 -1
- package/dist/core/expand-replace-dependencies.js +1 -7
- package/dist/core/integrations/supabase.js +102 -11
- package/dist/core/objects/foreign-data-wrapper/foreign-table/foreign-table.model.d.ts +4 -0
- package/dist/core/objects/foreign-data-wrapper/foreign-table/foreign-table.model.js +28 -2
- package/dist/core/objects/foreign-data-wrapper/server/server.model.d.ts +4 -0
- package/dist/core/objects/foreign-data-wrapper/server/server.model.js +18 -1
- package/dist/core/objects/foreign-data-wrapper/user-mapping/user-mapping.model.d.ts +4 -0
- package/dist/core/objects/foreign-data-wrapper/user-mapping/user-mapping.model.js +18 -1
- package/dist/core/objects/table/table.diff.js +53 -30
- package/dist/core/plan/hierarchy.js +4 -4
- package/dist/core/postgres-config.d.ts +7 -0
- package/dist/core/postgres-config.js +19 -5
- package/dist/core/sort/debug-visualization.js +1 -1
- package/dist/core/sort/topological-sort.js +2 -2
- package/package.json +34 -33
- package/src/cli/commands/catalog-export.ts +26 -1
- package/src/core/catalog.filter.ts +96 -0
- package/src/core/catalog.model.ts +10 -1
- package/src/core/catalog.snapshot.test.ts +1 -0
- package/src/core/expand-replace-dependencies.test.ts +12 -0
- package/src/core/expand-replace-dependencies.ts +1 -12
- package/src/core/integrations/supabase.test.ts +335 -0
- package/src/core/integrations/supabase.ts +102 -11
- package/src/core/objects/aggregate/changes/aggregate.base.ts +1 -1
- package/src/core/objects/collation/changes/collation.base.ts +1 -1
- package/src/core/objects/domain/changes/domain.base.ts +1 -1
- package/src/core/objects/extension/changes/extension.base.ts +1 -1
- package/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.base.ts +1 -1
- package/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.base.ts +1 -1
- package/src/core/objects/foreign-data-wrapper/foreign-table/foreign-table.model.ts +28 -2
- package/src/core/objects/foreign-data-wrapper/server/changes/server.base.ts +1 -1
- package/src/core/objects/foreign-data-wrapper/server/server.model.ts +18 -1
- package/src/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.base.ts +1 -1
- package/src/core/objects/foreign-data-wrapper/user-mapping/user-mapping.model.ts +18 -1
- package/src/core/objects/index/changes/index.base.ts +1 -1
- package/src/core/objects/language/changes/language.base.ts +1 -1
- package/src/core/objects/materialized-view/changes/materialized-view.base.ts +1 -1
- package/src/core/objects/procedure/changes/procedure.base.ts +1 -1
- package/src/core/objects/rls-policy/changes/rls-policy.base.ts +1 -1
- package/src/core/objects/role/changes/role.base.ts +1 -1
- package/src/core/objects/schema/changes/schema.base.ts +1 -1
- package/src/core/objects/sequence/changes/sequence.base.ts +1 -1
- package/src/core/objects/table/changes/table.base.ts +1 -1
- package/src/core/objects/table/changes/table.comment.ts +2 -8
- package/src/core/objects/table/table.diff.test.ts +198 -5
- package/src/core/objects/table/table.diff.ts +63 -34
- package/src/core/objects/trigger/changes/trigger.alter.ts +1 -4
- package/src/core/objects/trigger/changes/trigger.base.ts +1 -1
- package/src/core/objects/type/composite-type/changes/composite-type.base.ts +1 -1
- package/src/core/objects/type/enum/changes/enum.base.ts +1 -1
- package/src/core/objects/type/range/changes/range.base.ts +1 -1
- package/src/core/objects/view/changes/view.base.ts +1 -1
- package/src/core/plan/hierarchy.ts +4 -4
- package/src/core/postgres-config.test.ts +39 -1
- package/src/core/postgres-config.ts +32 -16
- package/src/core/sort/debug-visualization.ts +1 -1
- package/src/core/sort/sort-changes.test.ts +1 -0
- package/src/core/sort/topological-sort.ts +2 -2
|
@@ -28,12 +28,6 @@ function createAlterConstraintChange(mainTable, branchTable) {
|
|
|
28
28
|
table: branchTable,
|
|
29
29
|
constraint: c,
|
|
30
30
|
}));
|
|
31
|
-
if (!c.validated) {
|
|
32
|
-
changes.push(new AlterTableValidateConstraint({
|
|
33
|
-
table: branchTable,
|
|
34
|
-
constraint: c,
|
|
35
|
-
}));
|
|
36
|
-
}
|
|
37
31
|
// Add comment for newly created constraint
|
|
38
32
|
if (c.comment !== null) {
|
|
39
33
|
changes.push(new CreateCommentOnConstraint({
|
|
@@ -53,7 +47,7 @@ function createAlterConstraintChange(mainTable, branchTable) {
|
|
|
53
47
|
changes.push(new AlterTableDropConstraint({ table: mainTable, constraint: c }));
|
|
54
48
|
}
|
|
55
49
|
}
|
|
56
|
-
// Altered constraints -> drop + add
|
|
50
|
+
// Altered constraints -> drop + add (or VALIDATE-only shortcut)
|
|
57
51
|
for (const [name, mainC] of mainByName) {
|
|
58
52
|
const branchC = branchByName.get(name);
|
|
59
53
|
if (!branchC)
|
|
@@ -62,23 +56,57 @@ function createAlterConstraintChange(mainTable, branchTable) {
|
|
|
62
56
|
if (mainC.is_partition_clone || branchC.is_partition_clone) {
|
|
63
57
|
continue;
|
|
64
58
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
mainC.
|
|
69
|
-
mainC.
|
|
70
|
-
mainC.
|
|
71
|
-
mainC.
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
mainC.
|
|
77
|
-
mainC.
|
|
78
|
-
mainC.
|
|
79
|
-
mainC.
|
|
80
|
-
|
|
81
|
-
mainC.
|
|
59
|
+
// Cheap scalar `===` checks first; only fall through to JSON.stringify
|
|
60
|
+
// on the array fields when every scalar has already matched.
|
|
61
|
+
const fieldsEqualExceptValidated = mainC.constraint_type === branchC.constraint_type &&
|
|
62
|
+
mainC.deferrable === branchC.deferrable &&
|
|
63
|
+
mainC.initially_deferred === branchC.initially_deferred &&
|
|
64
|
+
mainC.is_local === branchC.is_local &&
|
|
65
|
+
mainC.no_inherit === branchC.no_inherit &&
|
|
66
|
+
mainC.is_temporal === branchC.is_temporal &&
|
|
67
|
+
mainC.foreign_key_table === branchC.foreign_key_table &&
|
|
68
|
+
mainC.foreign_key_schema === branchC.foreign_key_schema &&
|
|
69
|
+
mainC.on_update === branchC.on_update &&
|
|
70
|
+
mainC.on_delete === branchC.on_delete &&
|
|
71
|
+
mainC.match_type === branchC.match_type &&
|
|
72
|
+
mainC.check_expression === branchC.check_expression &&
|
|
73
|
+
JSON.stringify(mainC.key_columns) ===
|
|
74
|
+
JSON.stringify(branchC.key_columns) &&
|
|
75
|
+
JSON.stringify(mainC.foreign_key_columns) ===
|
|
76
|
+
JSON.stringify(branchC.foreign_key_columns);
|
|
77
|
+
// Safe-migration shortcut: when the only difference is `validated`
|
|
78
|
+
// flipping from false to true, emit a single `ALTER TABLE ... VALIDATE
|
|
79
|
+
// CONSTRAINT` instead of drop+add. VALIDATE CONSTRAINT only takes
|
|
80
|
+
// SHARE UPDATE EXCLUSIVE (concurrent reads/writes proceed), whereas
|
|
81
|
+
// dropping and re-adding takes ACCESS EXCLUSIVE for the entire scan.
|
|
82
|
+
// Postgres has no reverse command, so `true -> false` must still go
|
|
83
|
+
// through drop+add below.
|
|
84
|
+
if (fieldsEqualExceptValidated &&
|
|
85
|
+
mainC.validated === false &&
|
|
86
|
+
branchC.validated === true) {
|
|
87
|
+
changes.push(new AlterTableValidateConstraint({
|
|
88
|
+
table: branchTable,
|
|
89
|
+
constraint: branchC,
|
|
90
|
+
}));
|
|
91
|
+
// VALIDATE preserves the constraint OID, so its comment is preserved
|
|
92
|
+
// too. Only emit a comment change if it actually differs.
|
|
93
|
+
if (mainC.comment !== branchC.comment) {
|
|
94
|
+
if (branchC.comment === null) {
|
|
95
|
+
changes.push(new DropCommentOnConstraint({
|
|
96
|
+
table: mainTable,
|
|
97
|
+
constraint: mainC,
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
changes.push(new CreateCommentOnConstraint({
|
|
102
|
+
table: branchTable,
|
|
103
|
+
constraint: branchC,
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const changed = mainC.validated !== branchC.validated || !fieldsEqualExceptValidated;
|
|
82
110
|
if (changed) {
|
|
83
111
|
changes.push(new AlterTableDropConstraint({
|
|
84
112
|
table: mainTable,
|
|
@@ -88,12 +116,6 @@ function createAlterConstraintChange(mainTable, branchTable) {
|
|
|
88
116
|
table: branchTable,
|
|
89
117
|
constraint: branchC,
|
|
90
118
|
}));
|
|
91
|
-
if (!branchC.validated) {
|
|
92
|
-
changes.push(new AlterTableValidateConstraint({
|
|
93
|
-
table: branchTable,
|
|
94
|
-
constraint: branchC,
|
|
95
|
-
}));
|
|
96
|
-
}
|
|
97
119
|
// Ensure constraint comment is applied after re-creation
|
|
98
120
|
if (branchC.comment !== null) {
|
|
99
121
|
changes.push(new CreateCommentOnConstraint({
|
|
@@ -163,6 +185,7 @@ export function diffTables(ctx, main, branch) {
|
|
|
163
185
|
changes.push(...createAlterConstraintChange(
|
|
164
186
|
// Create a dummy table with no constraints do diff constraints against
|
|
165
187
|
new Table({
|
|
188
|
+
// oxlint-disable-next-line typescript/no-misused-spread
|
|
166
189
|
...branchTable,
|
|
167
190
|
constraints: [],
|
|
168
191
|
}), branchTable));
|
|
@@ -262,7 +262,7 @@ function addClusterChange(cluster, change) {
|
|
|
262
262
|
break;
|
|
263
263
|
default: {
|
|
264
264
|
const _exhaustive = objectType;
|
|
265
|
-
throw new Error(`Unhandled object type: ${_exhaustive}`);
|
|
265
|
+
throw new Error(`Unhandled object type: ${JSON.stringify(_exhaustive)}`);
|
|
266
266
|
}
|
|
267
267
|
}
|
|
268
268
|
}
|
|
@@ -303,7 +303,7 @@ function addChildChange(schema, change) {
|
|
|
303
303
|
break;
|
|
304
304
|
default: {
|
|
305
305
|
const _exhaustive = parentType;
|
|
306
|
-
throw new Error(`Unhandled parent type: ${_exhaustive}`);
|
|
306
|
+
throw new Error(`Unhandled parent type: ${JSON.stringify(_exhaustive)}`);
|
|
307
307
|
}
|
|
308
308
|
}
|
|
309
309
|
const objectType = change.objectType;
|
|
@@ -351,7 +351,7 @@ function addChildChange(schema, change) {
|
|
|
351
351
|
break;
|
|
352
352
|
default: {
|
|
353
353
|
const _exhaustive = objectType;
|
|
354
|
-
throw new Error(`Unhandled object type: ${_exhaustive}`);
|
|
354
|
+
throw new Error(`Unhandled object type: ${JSON.stringify(_exhaustive)}`);
|
|
355
355
|
}
|
|
356
356
|
}
|
|
357
357
|
}
|
|
@@ -482,7 +482,7 @@ function addSchemaLevelChange(schema, change, enrichment) {
|
|
|
482
482
|
break;
|
|
483
483
|
default: {
|
|
484
484
|
const _exhaustive = objectType;
|
|
485
|
-
throw new Error(`Unhandled object type: ${_exhaustive}`);
|
|
485
|
+
throw new Error(`Unhandled object type: ${JSON.stringify(_exhaustive)}`);
|
|
486
486
|
}
|
|
487
487
|
}
|
|
488
488
|
}
|
|
@@ -30,6 +30,13 @@ export declare function connectWithRetry<T>(opts: {
|
|
|
30
30
|
maxBackoffMs?: number;
|
|
31
31
|
sleep?: (ms: number) => Promise<void>;
|
|
32
32
|
}): Promise<T>;
|
|
33
|
+
/**
|
|
34
|
+
* Race `connect()` against a `timeoutMs` rejection and clear the timer when
|
|
35
|
+
* either side wins. If the timer is left running after a fast connect, the
|
|
36
|
+
* pending `setTimeout` keeps the event loop alive and the process hangs for
|
|
37
|
+
* the rest of `timeoutMs`.
|
|
38
|
+
*/
|
|
39
|
+
export declare function connectWithTimeout<T>(connect: () => Promise<T>, timeoutMs: number, label: "source" | "target"): Promise<T>;
|
|
33
40
|
/**
|
|
34
41
|
* Options for creating a Pool with event listeners.
|
|
35
42
|
*/
|
|
@@ -180,6 +180,24 @@ export async function connectWithRetry(opts) {
|
|
|
180
180
|
// Unreachable: loop either returns or throws.
|
|
181
181
|
throw lastError;
|
|
182
182
|
}
|
|
183
|
+
/**
|
|
184
|
+
* Race `connect()` against a `timeoutMs` rejection and clear the timer when
|
|
185
|
+
* either side wins. If the timer is left running after a fast connect, the
|
|
186
|
+
* pending `setTimeout` keeps the event loop alive and the process hangs for
|
|
187
|
+
* the rest of `timeoutMs`.
|
|
188
|
+
*/
|
|
189
|
+
export function connectWithTimeout(connect, timeoutMs, label) {
|
|
190
|
+
let timer;
|
|
191
|
+
return Promise.race([
|
|
192
|
+
connect(),
|
|
193
|
+
new Promise((_, reject) => {
|
|
194
|
+
timer = setTimeout(() => reject(new Error(`Connection to ${label} database timed out after ${timeoutMs}ms. ` +
|
|
195
|
+
`The server may require SSL, use an invalid certificate, or be unreachable.`)), timeoutMs);
|
|
196
|
+
}),
|
|
197
|
+
]).finally(() => {
|
|
198
|
+
clearTimeout(timer);
|
|
199
|
+
});
|
|
200
|
+
}
|
|
183
201
|
/**
|
|
184
202
|
* Create a Pool with custom type handlers and optional event listeners.
|
|
185
203
|
*
|
|
@@ -347,11 +365,7 @@ export async function createManagedPool(url, options) {
|
|
|
347
365
|
const timeoutMs = DEFAULT_CONNECT_TIMEOUT_MS;
|
|
348
366
|
try {
|
|
349
367
|
const client = await connectWithRetry({
|
|
350
|
-
connect: () =>
|
|
351
|
-
pool.connect(),
|
|
352
|
-
new Promise((_, reject) => setTimeout(() => reject(new Error(`Connection to ${label} database timed out after ${timeoutMs}ms. ` +
|
|
353
|
-
`The server may require SSL, use an invalid certificate, or be unreachable.`)), timeoutMs)),
|
|
354
|
-
]),
|
|
368
|
+
connect: () => connectWithTimeout(() => pool.connect(), timeoutMs, label),
|
|
355
369
|
});
|
|
356
370
|
client.release();
|
|
357
371
|
}
|
|
@@ -139,7 +139,7 @@ export function printDebugGraph(phaseChanges, graphData, edges, dependencyRows,
|
|
|
139
139
|
const mermaidDiagram = generateMermaidDiagram(phaseChanges, graphData, edges, requirementSets, dependenciesByReferencedId);
|
|
140
140
|
debugGraph("\n==== Mermaid (cycle detected) ====\n%s\n==== end ====", mermaidDiagram);
|
|
141
141
|
}
|
|
142
|
-
catch
|
|
142
|
+
catch {
|
|
143
143
|
// ignore debug printing errors
|
|
144
144
|
}
|
|
145
145
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
export function performStableTopologicalSort(nodeCount, edges) {
|
|
7
7
|
const adjacencyList = Array.from({ length: nodeCount }, () => new Set());
|
|
8
|
-
const inDegreeCounts =
|
|
8
|
+
const inDegreeCounts = Array.from({ length: nodeCount }, () => 0);
|
|
9
9
|
for (const [sourceIndex, targetIndex] of edges) {
|
|
10
10
|
if (!adjacencyList[sourceIndex].has(targetIndex)) {
|
|
11
11
|
adjacencyList[sourceIndex].add(targetIndex);
|
|
@@ -51,7 +51,7 @@ export function findCycle(nodeCount, edges) {
|
|
|
51
51
|
adjacencyList[sourceIndex].push(targetIndex);
|
|
52
52
|
}
|
|
53
53
|
// 0 = unvisited, 1 = visiting, 2 = completed
|
|
54
|
-
const visitState =
|
|
54
|
+
const visitState = Array.from({ length: nodeCount }, () => 0);
|
|
55
55
|
const pathStack = [];
|
|
56
56
|
let cycleNodeIndexes = null;
|
|
57
57
|
const depthFirstSearch = (nodeIndex) => {
|
package/package.json
CHANGED
|
@@ -1,7 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supabase/pg-delta",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.27",
|
|
4
4
|
"description": "PostgreSQL migrations made easy",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"diff",
|
|
7
|
+
"migrations",
|
|
8
|
+
"pg",
|
|
9
|
+
"pg-delta",
|
|
10
|
+
"pgdelta",
|
|
11
|
+
"postgres"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/supabase/pg-toolbelt",
|
|
14
|
+
"bugs": "https://github.com/supabase/pg-toolbelt/issues",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"author": "Supabase",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/supabase/pg-toolbelt.git",
|
|
20
|
+
"directory": "packages/pg-delta"
|
|
21
|
+
},
|
|
22
|
+
"bin": {
|
|
23
|
+
"pgdelta": "./dist/cli/bin/cli.js"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"src",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE"
|
|
30
|
+
],
|
|
5
31
|
"type": "module",
|
|
6
32
|
"sideEffects": false,
|
|
7
33
|
"main": "./dist/index.js",
|
|
@@ -36,40 +62,12 @@
|
|
|
36
62
|
"default": "./dist/core/catalog-export/index.js"
|
|
37
63
|
}
|
|
38
64
|
},
|
|
39
|
-
"bin": {
|
|
40
|
-
"pgdelta": "./dist/cli/bin/cli.js"
|
|
41
|
-
},
|
|
42
|
-
"files": [
|
|
43
|
-
"dist",
|
|
44
|
-
"src",
|
|
45
|
-
"README.md",
|
|
46
|
-
"LICENSE"
|
|
47
|
-
],
|
|
48
|
-
"keywords": [
|
|
49
|
-
"pg",
|
|
50
|
-
"postgres",
|
|
51
|
-
"migrations",
|
|
52
|
-
"diff",
|
|
53
|
-
"pg-delta",
|
|
54
|
-
"pgdelta"
|
|
55
|
-
],
|
|
56
|
-
"author": "Supabase",
|
|
57
|
-
"license": "MIT",
|
|
58
|
-
"homepage": "https://github.com/supabase/pg-toolbelt",
|
|
59
|
-
"repository": {
|
|
60
|
-
"type": "git",
|
|
61
|
-
"url": "https://github.com/supabase/pg-toolbelt.git",
|
|
62
|
-
"directory": "packages/pg-delta"
|
|
63
|
-
},
|
|
64
|
-
"bugs": "https://github.com/supabase/pg-toolbelt/issues",
|
|
65
|
-
"engines": {
|
|
66
|
-
"node": ">=20.0.0"
|
|
67
|
-
},
|
|
68
65
|
"scripts": {
|
|
69
66
|
"build": "tsc --project tsconfig.build.json",
|
|
70
67
|
"check-types": "tsc --noEmit",
|
|
71
68
|
"docs": "typedoc",
|
|
72
|
-
"format-and-lint": "
|
|
69
|
+
"format-and-lint": "oxfmt --check . && oxlint --deny-warnings",
|
|
70
|
+
"format-and-lint:fix": "oxfmt . && oxlint --fix",
|
|
73
71
|
"knip": "knip",
|
|
74
72
|
"pgdelta": "bun src/cli/bin/cli.ts",
|
|
75
73
|
"sync-base-images": "bun scripts/sync-supabase-base-images.ts",
|
|
@@ -77,12 +75,12 @@
|
|
|
77
75
|
"test:unit": "bun run test src/",
|
|
78
76
|
"test:integration": "bun run test tests/",
|
|
79
77
|
"update-empty-baseline": "bun scripts/update-empty-catalog-baseline.ts",
|
|
80
|
-
"version": "changeset version && bun install --no-frozen-lockfile && bun run format-and-lint
|
|
78
|
+
"version": "changeset version && bun install --no-frozen-lockfile && bun run format-and-lint:fix"
|
|
81
79
|
},
|
|
82
80
|
"dependencies": {
|
|
83
81
|
"@stricli/core": "^1.2.4",
|
|
84
|
-
"@ts-safeql/sql-tag": "^0.2.0",
|
|
85
82
|
"@supabase/pg-topo": "^1.0.0-alpha.1",
|
|
83
|
+
"@ts-safeql/sql-tag": "^0.2.0",
|
|
86
84
|
"chalk": "^5.6.2",
|
|
87
85
|
"debug": "^4.3.7",
|
|
88
86
|
"pg": "^8.17.2",
|
|
@@ -103,5 +101,8 @@
|
|
|
103
101
|
"testcontainers": "^11.10.0",
|
|
104
102
|
"typedoc": "^0.28.17",
|
|
105
103
|
"typescript": "^5.9.3"
|
|
104
|
+
},
|
|
105
|
+
"engines": {
|
|
106
|
+
"node": ">=20.0.0"
|
|
106
107
|
}
|
|
107
108
|
}
|
|
@@ -4,11 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
import { writeFile } from "node:fs/promises";
|
|
6
6
|
import { buildCommand, type CommandContext } from "@stricli/core";
|
|
7
|
+
import { filterCatalog } from "../../core/catalog.filter.ts";
|
|
7
8
|
import { extractCatalog } from "../../core/catalog.model.ts";
|
|
8
9
|
import {
|
|
9
10
|
serializeCatalog,
|
|
10
11
|
stringifyCatalogSnapshot,
|
|
11
12
|
} from "../../core/catalog.snapshot.ts";
|
|
13
|
+
import type { FilterDSL } from "../../core/integrations/filter/dsl.ts";
|
|
12
14
|
import { createManagedPool } from "../../core/postgres-config.ts";
|
|
13
15
|
|
|
14
16
|
export const catalogExportCommand = buildCommand({
|
|
@@ -30,6 +32,21 @@ export const catalogExportCommand = buildCommand({
|
|
|
30
32
|
parse: String,
|
|
31
33
|
optional: true,
|
|
32
34
|
},
|
|
35
|
+
filter: {
|
|
36
|
+
kind: "parsed",
|
|
37
|
+
brief:
|
|
38
|
+
'Filter DSL as inline JSON to filter changes (e.g., \'{"*/schema": "app"}\').',
|
|
39
|
+
parse: (value: string): FilterDSL => {
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(value) as FilterDSL;
|
|
42
|
+
} catch (error) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`Invalid filter JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
optional: true,
|
|
49
|
+
},
|
|
33
50
|
},
|
|
34
51
|
aliases: {
|
|
35
52
|
t: "target",
|
|
@@ -48,6 +65,10 @@ Use cases:
|
|
|
48
65
|
- Snapshot template1 for use as an empty-database baseline
|
|
49
66
|
- Snapshot a production database to generate revert migrations
|
|
50
67
|
- Snapshot any state for reproducible offline diffs
|
|
68
|
+
|
|
69
|
+
Pass --filter to scope the snapshot to a subset of the catalog (same
|
|
70
|
+
Filter DSL accepted by plan/sync). Useful when committing a baseline
|
|
71
|
+
snapshot to a repo and only one schema's drift is interesting.
|
|
51
72
|
`.trim(),
|
|
52
73
|
},
|
|
53
74
|
async func(
|
|
@@ -56,6 +77,7 @@ Use cases:
|
|
|
56
77
|
target: string;
|
|
57
78
|
output: string;
|
|
58
79
|
role?: string;
|
|
80
|
+
filter?: FilterDSL;
|
|
59
81
|
},
|
|
60
82
|
) {
|
|
61
83
|
const { pool, close } = await createManagedPool(flags.target, {
|
|
@@ -65,7 +87,10 @@ Use cases:
|
|
|
65
87
|
|
|
66
88
|
try {
|
|
67
89
|
const catalog = await extractCatalog(pool);
|
|
68
|
-
const
|
|
90
|
+
const scoped = flags.filter
|
|
91
|
+
? await filterCatalog(catalog, flags.filter)
|
|
92
|
+
: catalog;
|
|
93
|
+
const snapshot = serializeCatalog(scoped);
|
|
69
94
|
const json = stringifyCatalogSnapshot(snapshot);
|
|
70
95
|
await writeFile(flags.output, json, "utf-8");
|
|
71
96
|
this.process.stdout.write(
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prune a catalog to the objects that match a Filter DSL expression.
|
|
3
|
+
*
|
|
4
|
+
* The Filter DSL is defined over Change objects, so the catalog is
|
|
5
|
+
* diffed against an empty baseline first to materialize one CREATE
|
|
6
|
+
* change per object. The filter then evaluates against the same shape
|
|
7
|
+
* it would at plan time, and the surviving stableIds drive the prune.
|
|
8
|
+
*
|
|
9
|
+
* Dependency cascade is not applied. A scoped snapshot is partial by
|
|
10
|
+
* design: out-of-scope owners, roles, and types must exist on the
|
|
11
|
+
* target DB at apply time. Cascading would expand the filter beyond
|
|
12
|
+
* what the caller asked for and, in practice, collapse schema-scoped
|
|
13
|
+
* exports whose kept objects reference cluster-scoped owners.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { diffCatalogs } from "./catalog.diff.ts";
|
|
17
|
+
import { Catalog, createEmptyCatalog } from "./catalog.model.ts";
|
|
18
|
+
import { compileFilterDSL, type FilterDSL } from "./integrations/filter/dsl.ts";
|
|
19
|
+
|
|
20
|
+
export async function filterCatalog(
|
|
21
|
+
catalog: Catalog,
|
|
22
|
+
filter: FilterDSL,
|
|
23
|
+
): Promise<Catalog> {
|
|
24
|
+
if (
|
|
25
|
+
typeof filter === "object" &&
|
|
26
|
+
filter !== null &&
|
|
27
|
+
(filter as Record<string, unknown>).cascade === true
|
|
28
|
+
) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
"Filter DSL `cascade: true` is not supported by catalog-export: " +
|
|
31
|
+
"scoped snapshots are intentionally partial. Out-of-scope owners, " +
|
|
32
|
+
"roles, and types must exist on the target DB at apply time.",
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const empty = await createEmptyCatalog(catalog.version, catalog.currentUser);
|
|
37
|
+
const changes = diffCatalogs(empty, catalog);
|
|
38
|
+
const filterFn = compileFilterDSL(filter);
|
|
39
|
+
|
|
40
|
+
const keep = new Set<string>();
|
|
41
|
+
for (const change of changes) {
|
|
42
|
+
if (!filterFn(change)) continue;
|
|
43
|
+
for (const id of change.creates ?? []) keep.add(id);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return pruneCatalog(catalog, keep);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function filterRecord<T>(
|
|
50
|
+
record: Record<string, T>,
|
|
51
|
+
keep: ReadonlySet<string>,
|
|
52
|
+
): Record<string, T> {
|
|
53
|
+
return Object.fromEntries(
|
|
54
|
+
Object.entries(record).filter(([id]) => keep.has(id)),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function pruneCatalog(catalog: Catalog, keep: ReadonlySet<string>): Catalog {
|
|
59
|
+
const tables = filterRecord(catalog.tables, keep);
|
|
60
|
+
const materializedViews = filterRecord(catalog.materializedViews, keep);
|
|
61
|
+
|
|
62
|
+
return new Catalog({
|
|
63
|
+
aggregates: filterRecord(catalog.aggregates, keep),
|
|
64
|
+
collations: filterRecord(catalog.collations, keep),
|
|
65
|
+
compositeTypes: filterRecord(catalog.compositeTypes, keep),
|
|
66
|
+
domains: filterRecord(catalog.domains, keep),
|
|
67
|
+
enums: filterRecord(catalog.enums, keep),
|
|
68
|
+
extensions: filterRecord(catalog.extensions, keep),
|
|
69
|
+
procedures: filterRecord(catalog.procedures, keep),
|
|
70
|
+
indexes: filterRecord(catalog.indexes, keep),
|
|
71
|
+
materializedViews,
|
|
72
|
+
subscriptions: filterRecord(catalog.subscriptions, keep),
|
|
73
|
+
publications: filterRecord(catalog.publications, keep),
|
|
74
|
+
rlsPolicies: filterRecord(catalog.rlsPolicies, keep),
|
|
75
|
+
roles: filterRecord(catalog.roles, keep),
|
|
76
|
+
schemas: filterRecord(catalog.schemas, keep),
|
|
77
|
+
sequences: filterRecord(catalog.sequences, keep),
|
|
78
|
+
tables,
|
|
79
|
+
triggers: filterRecord(catalog.triggers, keep),
|
|
80
|
+
eventTriggers: filterRecord(catalog.eventTriggers, keep),
|
|
81
|
+
rules: filterRecord(catalog.rules, keep),
|
|
82
|
+
ranges: filterRecord(catalog.ranges, keep),
|
|
83
|
+
views: filterRecord(catalog.views, keep),
|
|
84
|
+
foreignDataWrappers: filterRecord(catalog.foreignDataWrappers, keep),
|
|
85
|
+
servers: filterRecord(catalog.servers, keep),
|
|
86
|
+
userMappings: filterRecord(catalog.userMappings, keep),
|
|
87
|
+
foreignTables: filterRecord(catalog.foreignTables, keep),
|
|
88
|
+
depends: catalog.depends.filter(
|
|
89
|
+
(d) =>
|
|
90
|
+
keep.has(d.dependent_stable_id) && keep.has(d.referenced_stable_id),
|
|
91
|
+
),
|
|
92
|
+
indexableObjects: { ...tables, ...materializedViews },
|
|
93
|
+
version: catalog.version,
|
|
94
|
+
currentUser: catalog.currentUser,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
@@ -183,7 +183,8 @@ let _pg17Baseline: Catalog | null = null;
|
|
|
183
183
|
|
|
184
184
|
async function loadBaselineJson(): Promise<Record<string, unknown>> {
|
|
185
185
|
const mod = await import(
|
|
186
|
-
"./fixtures/empty-catalogs/postgres-15-16-baseline.json"
|
|
186
|
+
"./fixtures/empty-catalogs/postgres-15-16-baseline.json",
|
|
187
|
+
{ with: { type: "json" } }
|
|
187
188
|
);
|
|
188
189
|
return mod.default as Record<string, unknown>;
|
|
189
190
|
}
|
|
@@ -256,10 +257,12 @@ export async function createEmptyCatalog(
|
|
|
256
257
|
): Promise<Catalog> {
|
|
257
258
|
if (version >= 170000) {
|
|
258
259
|
const baseline = await getPg17Baseline();
|
|
260
|
+
// oxlint-disable-next-line typescript/no-misused-spread
|
|
259
261
|
return new Catalog({ ...baseline, version, currentUser });
|
|
260
262
|
}
|
|
261
263
|
if (version >= 150000) {
|
|
262
264
|
const baseline = await getPg1516Baseline();
|
|
265
|
+
// oxlint-disable-next-line typescript/no-misused-spread
|
|
263
266
|
return new Catalog({ ...baseline, version, currentUser });
|
|
264
267
|
}
|
|
265
268
|
|
|
@@ -446,6 +449,8 @@ function normalizeCatalog(catalog: Catalog): Catalog {
|
|
|
446
449
|
options: redactSensitiveOptionPairs(server.options),
|
|
447
450
|
comment: server.comment,
|
|
448
451
|
privileges: server.privileges,
|
|
452
|
+
wrapper_handler: server.wrapper_handler,
|
|
453
|
+
wrapper_validator: server.wrapper_validator,
|
|
449
454
|
});
|
|
450
455
|
});
|
|
451
456
|
|
|
@@ -454,6 +459,8 @@ function normalizeCatalog(catalog: Catalog): Catalog {
|
|
|
454
459
|
user: mapping.user,
|
|
455
460
|
server: mapping.server,
|
|
456
461
|
options: redactSensitiveOptionPairs(mapping.options),
|
|
462
|
+
wrapper_handler: mapping.wrapper_handler,
|
|
463
|
+
wrapper_validator: mapping.wrapper_validator,
|
|
457
464
|
});
|
|
458
465
|
});
|
|
459
466
|
|
|
@@ -469,6 +476,8 @@ function normalizeCatalog(catalog: Catalog): Catalog {
|
|
|
469
476
|
options: redactSensitiveOptionPairs(foreignTable.options),
|
|
470
477
|
comment: foreignTable.comment,
|
|
471
478
|
privileges: foreignTable.privileges,
|
|
479
|
+
wrapper_handler: foreignTable.wrapper_handler,
|
|
480
|
+
wrapper_validator: foreignTable.wrapper_validator,
|
|
472
481
|
}),
|
|
473
482
|
);
|
|
474
483
|
|
|
@@ -294,6 +294,7 @@ describe("catalog snapshot serde", () => {
|
|
|
294
294
|
|
|
295
295
|
const sourceCatalog = await createEmptyCatalog(160000, "postgres");
|
|
296
296
|
const targetCatalog = await createEmptyCatalog(160000, "postgres");
|
|
297
|
+
// oxlint-disable-next-line typescript/no-misused-spread
|
|
297
298
|
const source = { ...sourceCatalog };
|
|
298
299
|
|
|
299
300
|
expect(source instanceof Catalog).toBe(false);
|
|
@@ -189,11 +189,13 @@ describe("expandReplaceDependencies", () => {
|
|
|
189
189
|
privileges: [],
|
|
190
190
|
});
|
|
191
191
|
const branchView = new View({
|
|
192
|
+
// oxlint-disable-next-line typescript/no-misused-spread
|
|
192
193
|
...mainView,
|
|
193
194
|
definition: " SELECT count(*) AS n FROM public.members;",
|
|
194
195
|
});
|
|
195
196
|
|
|
196
197
|
const mainCatalog = new Catalog({
|
|
198
|
+
// oxlint-disable-next-line typescript/no-misused-spread
|
|
197
199
|
...baseline,
|
|
198
200
|
tables: { [usersTable.stableId]: usersTable },
|
|
199
201
|
views: { [mainView.stableId]: mainView },
|
|
@@ -206,6 +208,7 @@ describe("expandReplaceDependencies", () => {
|
|
|
206
208
|
],
|
|
207
209
|
});
|
|
208
210
|
const branchCatalog = new Catalog({
|
|
211
|
+
// oxlint-disable-next-line typescript/no-misused-spread
|
|
209
212
|
...baseline,
|
|
210
213
|
views: { [branchView.stableId]: branchView },
|
|
211
214
|
});
|
|
@@ -253,6 +256,7 @@ describe("expandReplaceDependencies", () => {
|
|
|
253
256
|
owner: "postgres",
|
|
254
257
|
});
|
|
255
258
|
const branchSequence = new Sequence({
|
|
259
|
+
// oxlint-disable-next-line typescript/no-misused-spread
|
|
256
260
|
...mainSequence,
|
|
257
261
|
persistence: "p",
|
|
258
262
|
});
|
|
@@ -309,6 +313,7 @@ describe("expandReplaceDependencies", () => {
|
|
|
309
313
|
{ [usersTable.stableId]: usersTable },
|
|
310
314
|
);
|
|
311
315
|
const mainCatalog = new Catalog({
|
|
316
|
+
// oxlint-disable-next-line typescript/no-misused-spread
|
|
312
317
|
...baseline,
|
|
313
318
|
sequences: { [mainSequence.stableId]: mainSequence },
|
|
314
319
|
tables: { [usersTable.stableId]: usersTable },
|
|
@@ -326,6 +331,7 @@ describe("expandReplaceDependencies", () => {
|
|
|
326
331
|
],
|
|
327
332
|
});
|
|
328
333
|
const branchCatalog = new Catalog({
|
|
334
|
+
// oxlint-disable-next-line typescript/no-misused-spread
|
|
329
335
|
...baseline,
|
|
330
336
|
sequences: { [branchSequence.stableId]: branchSequence },
|
|
331
337
|
tables: { [usersTable.stableId]: usersTable },
|
|
@@ -369,6 +375,7 @@ describe("expandReplaceDependencies", () => {
|
|
|
369
375
|
privileges: [],
|
|
370
376
|
});
|
|
371
377
|
const branchEnum = new Enum({
|
|
378
|
+
// oxlint-disable-next-line typescript/no-misused-spread
|
|
372
379
|
...mainEnum,
|
|
373
380
|
labels: [
|
|
374
381
|
{ sort_order: 1, label: "draft" },
|
|
@@ -430,6 +437,7 @@ describe("expandReplaceDependencies", () => {
|
|
|
430
437
|
privileges: [],
|
|
431
438
|
});
|
|
432
439
|
const branchChildren = new Table({
|
|
440
|
+
// oxlint-disable-next-line typescript/no-misused-spread
|
|
433
441
|
...mainChildren,
|
|
434
442
|
columns: [
|
|
435
443
|
{ ...columnTemplate, name: "id", position: 1, not_null: true },
|
|
@@ -522,6 +530,7 @@ describe("expandReplaceDependencies", () => {
|
|
|
522
530
|
];
|
|
523
531
|
|
|
524
532
|
const mainCatalog = new Catalog({
|
|
533
|
+
// oxlint-disable-next-line typescript/no-misused-spread
|
|
525
534
|
...baseline,
|
|
526
535
|
enums: { [mainEnum.stableId]: mainEnum },
|
|
527
536
|
tables: { [mainChildren.stableId]: mainChildren },
|
|
@@ -535,6 +544,7 @@ describe("expandReplaceDependencies", () => {
|
|
|
535
544
|
],
|
|
536
545
|
});
|
|
537
546
|
const branchCatalog = new Catalog({
|
|
547
|
+
// oxlint-disable-next-line typescript/no-misused-spread
|
|
538
548
|
...baseline,
|
|
539
549
|
enums: { [branchEnum.stableId]: branchEnum },
|
|
540
550
|
tables: { [branchChildren.stableId]: branchChildren },
|
|
@@ -657,6 +667,7 @@ describe("expandReplaceDependencies", () => {
|
|
|
657
667
|
];
|
|
658
668
|
|
|
659
669
|
const mainCatalog = new Catalog({
|
|
670
|
+
// oxlint-disable-next-line typescript/no-misused-spread
|
|
660
671
|
...baseline,
|
|
661
672
|
procedures: { [mainProcedure.stableId]: mainProcedure },
|
|
662
673
|
views: { [mainView.stableId]: mainView },
|
|
@@ -669,6 +680,7 @@ describe("expandReplaceDependencies", () => {
|
|
|
669
680
|
],
|
|
670
681
|
});
|
|
671
682
|
const branchCatalog = new Catalog({
|
|
683
|
+
// oxlint-disable-next-line typescript/no-misused-spread
|
|
672
684
|
...baseline,
|
|
673
685
|
procedures: { [branchProcedure.stableId]: branchProcedure },
|
|
674
686
|
views: { [branchView.stableId]: branchView },
|
|
@@ -8,10 +8,7 @@ import { CreateMaterializedView } from "./objects/materialized-view/changes/mate
|
|
|
8
8
|
import { DropMaterializedView } from "./objects/materialized-view/changes/materialized-view.drop.ts";
|
|
9
9
|
import { CreateProcedure } from "./objects/procedure/changes/procedure.create.ts";
|
|
10
10
|
import { DropProcedure } from "./objects/procedure/changes/procedure.drop.ts";
|
|
11
|
-
import {
|
|
12
|
-
AlterTableAddConstraint,
|
|
13
|
-
AlterTableValidateConstraint,
|
|
14
|
-
} from "./objects/table/changes/table.alter.ts";
|
|
11
|
+
import { AlterTableAddConstraint } from "./objects/table/changes/table.alter.ts";
|
|
15
12
|
import { CreateCommentOnConstraint } from "./objects/table/changes/table.comment.ts";
|
|
16
13
|
import { CreateTable } from "./objects/table/changes/table.create.ts";
|
|
17
14
|
import { DropTable } from "./objects/table/changes/table.drop.ts";
|
|
@@ -455,14 +452,6 @@ function buildReplaceChanges(
|
|
|
455
452
|
constraint,
|
|
456
453
|
}),
|
|
457
454
|
];
|
|
458
|
-
if (!constraint.validated) {
|
|
459
|
-
items.push(
|
|
460
|
-
new AlterTableValidateConstraint({
|
|
461
|
-
table: resolved.branch,
|
|
462
|
-
constraint,
|
|
463
|
-
}),
|
|
464
|
-
);
|
|
465
|
-
}
|
|
466
455
|
if (
|
|
467
456
|
constraint.comment !== null &&
|
|
468
457
|
constraint.comment !== undefined
|