@supabase/pg-delta 1.0.0-alpha.10 → 1.0.0-alpha.11

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.
Files changed (123) hide show
  1. package/dist/cli/commands/declarative-export.js +12 -17
  2. package/dist/cli/commands/plan.js +10 -13
  3. package/dist/cli/commands/sync.js +8 -12
  4. package/dist/cli/utils/integrations.d.ts +30 -6
  5. package/dist/cli/utils/integrations.js +98 -6
  6. package/dist/core/change-utils.d.ts +9 -0
  7. package/dist/core/change-utils.js +71 -0
  8. package/dist/core/change.types.d.ts +22 -0
  9. package/dist/core/change.types.js +37 -1
  10. package/dist/core/depend.js +25 -0
  11. package/dist/core/export/file-mapper.d.ts +2 -2
  12. package/dist/core/integrations/filter/dsl.d.ts +78 -74
  13. package/dist/core/integrations/filter/dsl.js +127 -79
  14. package/dist/core/integrations/filter/flatten.d.ts +51 -0
  15. package/dist/core/integrations/filter/flatten.js +116 -0
  16. package/dist/core/integrations/integration-dsl.d.ts +17 -1
  17. package/dist/core/integrations/merge.d.ts +20 -0
  18. package/dist/core/integrations/merge.js +60 -0
  19. package/dist/core/integrations/serialize/dsl.d.ts +7 -4
  20. package/dist/core/integrations/serialize/dsl.js +2 -2
  21. package/dist/core/integrations/supabase.js +23 -8
  22. package/dist/core/objects/aggregate/changes/aggregate.types.d.ts +1 -0
  23. package/dist/core/objects/base.change.d.ts +10 -0
  24. package/dist/core/objects/base.change.js +10 -0
  25. package/dist/core/objects/base.model.d.ts +4 -1
  26. package/dist/core/objects/base.model.js +5 -2
  27. package/dist/core/objects/collation/changes/collation.types.d.ts +1 -0
  28. package/dist/core/objects/domain/changes/domain.create.d.ts +1 -1
  29. package/dist/core/objects/domain/changes/domain.create.js +7 -1
  30. package/dist/core/objects/domain/changes/domain.types.d.ts +1 -0
  31. package/dist/core/objects/event-trigger/changes/event-trigger.types.d.ts +1 -0
  32. package/dist/core/objects/extension/changes/extension.types.d.ts +1 -0
  33. package/dist/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.types.d.ts +1 -0
  34. package/dist/core/objects/foreign-data-wrapper/foreign-data-wrapper.types.d.ts +1 -0
  35. package/dist/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.types.d.ts +1 -0
  36. package/dist/core/objects/foreign-data-wrapper/server/changes/server.types.d.ts +1 -0
  37. package/dist/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.types.d.ts +1 -0
  38. package/dist/core/objects/index/changes/index.types.d.ts +1 -0
  39. package/dist/core/objects/language/changes/language.types.d.ts +1 -0
  40. package/dist/core/objects/materialized-view/changes/materialized-view.types.d.ts +1 -0
  41. package/dist/core/objects/procedure/changes/procedure.types.d.ts +1 -0
  42. package/dist/core/objects/publication/changes/publication.types.d.ts +1 -0
  43. package/dist/core/objects/rls-policy/changes/rls-policy.types.d.ts +1 -0
  44. package/dist/core/objects/role/changes/role.types.d.ts +1 -0
  45. package/dist/core/objects/rule/changes/rule.types.d.ts +1 -0
  46. package/dist/core/objects/schema/changes/schema.types.d.ts +1 -0
  47. package/dist/core/objects/sequence/changes/sequence.types.d.ts +1 -0
  48. package/dist/core/objects/subscription/changes/subscription.types.d.ts +1 -0
  49. package/dist/core/objects/table/changes/table.types.d.ts +1 -0
  50. package/dist/core/objects/trigger/changes/trigger.types.d.ts +1 -0
  51. package/dist/core/objects/type/composite-type/changes/composite-type.types.d.ts +1 -0
  52. package/dist/core/objects/type/enum/changes/enum.types.d.ts +1 -0
  53. package/dist/core/objects/type/range/changes/range.types.d.ts +1 -0
  54. package/dist/core/objects/type/type.types.d.ts +1 -0
  55. package/dist/core/objects/view/changes/view.types.d.ts +1 -0
  56. package/dist/core/objects/view/view.diff.js +24 -13
  57. package/dist/core/postgres-config.d.ts +2 -2
  58. package/dist/core/sort/custom-constraints.js +1 -1
  59. package/dist/core/sort/logical-sort.js +3 -24
  60. package/package.json +5 -1
  61. package/src/cli/commands/declarative-export.ts +19 -27
  62. package/src/cli/commands/plan.ts +14 -20
  63. package/src/cli/commands/sync.ts +8 -15
  64. package/src/cli/utils/integrations.test.ts +210 -3
  65. package/src/cli/utils/integrations.ts +134 -6
  66. package/src/core/catalog.snapshot.test.ts +11 -2
  67. package/src/core/change-utils.test.ts +61 -0
  68. package/src/core/change-utils.ts +73 -0
  69. package/src/core/change.types.ts +50 -0
  70. package/src/core/depend.ts +25 -0
  71. package/src/core/export/file-mapper.ts +7 -2
  72. package/src/core/integrations/filter/dsl.test.ts +299 -60
  73. package/src/core/integrations/filter/dsl.ts +208 -169
  74. package/src/core/integrations/filter/flatten.test.ts +282 -0
  75. package/src/core/integrations/filter/flatten.ts +150 -0
  76. package/src/core/integrations/integration-dsl.ts +17 -1
  77. package/src/core/integrations/merge.test.ts +128 -0
  78. package/src/core/integrations/merge.ts +72 -0
  79. package/src/core/integrations/serialize/dsl.test.ts +6 -6
  80. package/src/core/integrations/serialize/dsl.ts +7 -4
  81. package/src/core/integrations/supabase.ts +23 -8
  82. package/src/core/objects/aggregate/changes/aggregate.types.ts +1 -0
  83. package/src/core/objects/base.change.ts +10 -0
  84. package/src/core/objects/base.model.test.ts +43 -0
  85. package/src/core/objects/base.model.ts +5 -2
  86. package/src/core/objects/collation/changes/collation.types.ts +1 -0
  87. package/src/core/objects/domain/changes/domain.create.ts +17 -1
  88. package/src/core/objects/domain/changes/domain.types.ts +1 -0
  89. package/src/core/objects/event-trigger/changes/event-trigger.types.ts +1 -0
  90. package/src/core/objects/extension/changes/extension.types.ts +1 -0
  91. package/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.types.ts +1 -0
  92. package/src/core/objects/foreign-data-wrapper/foreign-data-wrapper.types.ts +1 -0
  93. package/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.types.ts +1 -0
  94. package/src/core/objects/foreign-data-wrapper/server/changes/server.types.ts +1 -0
  95. package/src/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.types.ts +1 -0
  96. package/src/core/objects/index/changes/index.types.ts +1 -0
  97. package/src/core/objects/language/changes/language.types.ts +1 -0
  98. package/src/core/objects/materialized-view/changes/materialized-view.types.ts +1 -0
  99. package/src/core/objects/procedure/changes/procedure.types.ts +1 -0
  100. package/src/core/objects/publication/changes/publication.types.ts +1 -0
  101. package/src/core/objects/rls-policy/changes/rls-policy.types.ts +1 -0
  102. package/src/core/objects/role/changes/role.types.ts +1 -0
  103. package/src/core/objects/rule/changes/rule.types.ts +1 -0
  104. package/src/core/objects/schema/changes/schema.types.ts +1 -0
  105. package/src/core/objects/sequence/changes/sequence.types.ts +1 -0
  106. package/src/core/objects/subscription/changes/subscription.types.ts +1 -0
  107. package/src/core/objects/table/changes/table.types.ts +1 -0
  108. package/src/core/objects/trigger/changes/trigger.types.ts +1 -0
  109. package/src/core/objects/type/composite-type/changes/composite-type.types.ts +1 -0
  110. package/src/core/objects/type/enum/changes/enum.types.ts +1 -0
  111. package/src/core/objects/type/range/changes/range.types.ts +1 -0
  112. package/src/core/objects/type/type.types.ts +1 -0
  113. package/src/core/objects/view/changes/view.types.ts +1 -0
  114. package/src/core/objects/view/view.diff.test.ts +96 -0
  115. package/src/core/objects/view/view.diff.ts +30 -15
  116. package/src/core/postgres-config.ts +2 -2
  117. package/src/core/sort/custom-constraints.ts +1 -1
  118. package/src/core/sort/logical-sort.ts +3 -27
  119. package/src/typedoc.ts +248 -0
  120. package/dist/core/integrations/filter/extractors.d.ts +0 -12
  121. package/dist/core/integrations/filter/extractors.js +0 -178
  122. package/src/core/integrations/filter/extractors.test.ts +0 -244
  123. package/src/core/integrations/filter/extractors.ts +0 -187
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supabase/pg-delta",
3
- "version": "1.0.0-alpha.10",
3
+ "version": "1.0.0-alpha.11",
4
4
  "description": "PostgreSQL migrations made easy",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -68,6 +68,7 @@
68
68
  "scripts": {
69
69
  "build": "tsc --project tsconfig.build.json",
70
70
  "check-types": "tsc --noEmit",
71
+ "docs": "typedoc",
71
72
  "format-and-lint": "biome check . --error-on-warnings",
72
73
  "knip": "knip",
73
74
  "pgdelta": "bun src/cli/bin/cli.ts",
@@ -84,6 +85,7 @@
84
85
  "chalk": "^5.6.2",
85
86
  "debug": "^4.3.7",
86
87
  "pg": "^8.17.2",
88
+ "picomatch": "^4.0.3",
87
89
  "zod": "^4.2.1"
88
90
  },
89
91
  "devDependencies": {
@@ -94,9 +96,11 @@
94
96
  "@types/debug": "^4.1.12",
95
97
  "@types/node": "^24.10.4",
96
98
  "@types/pg": "^8.11.10",
99
+ "@types/picomatch": "^4.0.2",
97
100
  "dedent": "^1.7.1",
98
101
  "knip": "^5.75.2",
99
102
  "testcontainers": "^11.10.0",
103
+ "typedoc": "^0.28.17",
100
104
  "typescript": "^5.9.3"
101
105
  }
102
106
  }
@@ -6,13 +6,14 @@ import { mkdir, rm, writeFile } from "node:fs/promises";
6
6
  import path from "node:path";
7
7
  import { buildCommand, type CommandContext } from "@stricli/core";
8
8
  import chalk from "chalk";
9
- import type { CatalogSnapshot } from "../../core/catalog.snapshot.ts";
9
+ import { deserializeCatalog } from "../../core/catalog.snapshot.ts";
10
10
  import { exportDeclarativeSchema } from "../../core/export/index.ts";
11
11
  import type { Grouping, GroupingPattern } from "../../core/export/types.ts";
12
12
  import type { FilterDSL } from "../../core/integrations/filter/dsl.ts";
13
- import type { ChangeFilter } from "../../core/integrations/filter/filter.types.ts";
14
- import type { SerializeDSL } from "../../core/integrations/serialize/dsl.ts";
15
- import type { ChangeSerializer } from "../../core/integrations/serialize/serialize.types.ts";
13
+ import {
14
+ compileSerializeDSL,
15
+ type SerializeDSL,
16
+ } from "../../core/integrations/serialize/dsl.ts";
16
17
  import { createPlan } from "../../core/plan/index.ts";
17
18
  import type { SqlFormatOptions } from "../../core/plan/sql-format.ts";
18
19
  import {
@@ -21,7 +22,7 @@ import {
21
22
  computeFileDiff,
22
23
  formatExportSummary,
23
24
  } from "../utils/export-display.ts";
24
- import { loadIntegrationDSL } from "../utils/integrations.ts";
25
+ import { resolveIntegrationOptions } from "../utils/integrations.ts";
25
26
  import { isPostgresUrl, loadCatalogFromFile } from "../utils/resolve-input.ts";
26
27
 
27
28
  function parseJsonFlag<T>(label: string, value: string): T {
@@ -185,29 +186,22 @@ After export, a tip is printed with the command to apply the schema to an empty
185
186
  verbose?: boolean;
186
187
  },
187
188
  ) {
188
- const { compileSerializeDSL } = await import(
189
- "../../core/integrations/serialize/dsl.ts"
190
- );
191
-
192
- let filterOption: FilterDSL | ChangeFilter | undefined = flags.filter;
193
- let serializeOption: SerializeDSL | ChangeSerializer | undefined =
194
- flags.serialize;
195
- let integrationEmptyCatalog: CatalogSnapshot | undefined;
196
- if (flags.integration) {
197
- const integrationDSL = await loadIntegrationDSL(flags.integration);
198
- filterOption = filterOption ?? integrationDSL.filter;
199
- serializeOption = serializeOption ?? integrationDSL.serialize;
200
- integrationEmptyCatalog = integrationDSL.emptyCatalog;
201
- }
189
+ const {
190
+ filter,
191
+ serialize,
192
+ emptyCatalog: integrationEmptyCatalog,
193
+ } = await resolveIntegrationOptions({
194
+ filter: flags.filter,
195
+ serialize: flags.serialize,
196
+ integration: flags.integration,
197
+ });
202
198
 
203
199
  const resolvedSource = flags.source
204
200
  ? isPostgresUrl(flags.source)
205
201
  ? flags.source
206
202
  : await loadCatalogFromFile(flags.source)
207
203
  : integrationEmptyCatalog
208
- ? (await import("../../core/catalog.snapshot.ts")).deserializeCatalog(
209
- integrationEmptyCatalog,
210
- )
204
+ ? deserializeCatalog(integrationEmptyCatalog)
211
205
  : null;
212
206
 
213
207
  const resolvedTarget = isPostgresUrl(flags.target)
@@ -221,8 +215,8 @@ After export, a tip is printed with the command to apply the schema to an empty
221
215
  // changes that depend on filtered objects (e.g. RLS policies that
222
216
  // reference auth.uid() when the auth schema is filtered out).
223
217
  const planResult = await createPlan(resolvedSource, resolvedTarget, {
224
- filter: filterOption,
225
- serialize: serializeOption,
218
+ filter,
219
+ serialize,
226
220
  skipDefaultPrivilegeSubtraction: true,
227
221
  });
228
222
 
@@ -254,9 +248,7 @@ After export, a tip is printed with the command to apply the schema to an empty
254
248
  }
255
249
 
256
250
  const serializeFn =
257
- serializeOption !== undefined
258
- ? compileSerializeDSL(serializeOption)
259
- : undefined;
251
+ serialize !== undefined ? compileSerializeDSL(serialize) : undefined;
260
252
 
261
253
  const output = exportDeclarativeSchema(planResult, {
262
254
  integration:
@@ -4,14 +4,13 @@
4
4
 
5
5
  import { writeFile } from "node:fs/promises";
6
6
  import { buildCommand, type CommandContext } from "@stricli/core";
7
+ import { deserializeCatalog } from "../../core/catalog.snapshot.ts";
7
8
  import type { FilterDSL } from "../../core/integrations/filter/dsl.ts";
8
- import type { ChangeFilter } from "../../core/integrations/filter/filter.types.ts";
9
9
  import type { SerializeDSL } from "../../core/integrations/serialize/dsl.ts";
10
- import type { ChangeSerializer } from "../../core/integrations/serialize/serialize.types.ts";
11
10
  import { createPlan } from "../../core/plan/index.ts";
12
11
  import type { SqlFormatOptions } from "../../core/plan/sql-format.ts";
13
12
  import { setCommandExitCode } from "../exit-code.ts";
14
- import { loadIntegrationDSL } from "../utils/integrations.ts";
13
+ import { resolveIntegrationOptions } from "../utils/integrations.ts";
15
14
  import { isPostgresUrl, loadCatalogFromFile } from "../utils/resolve-input.ts";
16
15
  import { formatPlanForDisplay } from "../utils.ts";
17
16
 
@@ -138,27 +137,22 @@ json/sql outputs are available for artifacts or piping.
138
137
  "sql-format-options"?: SqlFormatOptions;
139
138
  },
140
139
  ) {
141
- let filterOption: FilterDSL | ChangeFilter | undefined = flags.filter;
142
- let serializeOption: SerializeDSL | ChangeSerializer | undefined =
143
- flags.serialize;
144
- let integrationEmptyCatalog:
145
- | import("../../core/catalog.snapshot.ts").CatalogSnapshot
146
- | undefined;
147
- if (flags.integration) {
148
- const integrationDSL = await loadIntegrationDSL(flags.integration);
149
- filterOption = filterOption ?? integrationDSL.filter;
150
- serializeOption = serializeOption ?? integrationDSL.serialize;
151
- integrationEmptyCatalog = integrationDSL.emptyCatalog;
152
- }
140
+ const {
141
+ filter,
142
+ serialize,
143
+ emptyCatalog: integrationEmptyCatalog,
144
+ } = await resolveIntegrationOptions({
145
+ filter: flags.filter,
146
+ serialize: flags.serialize,
147
+ integration: flags.integration,
148
+ });
153
149
 
154
150
  const resolvedSource = flags.source
155
151
  ? isPostgresUrl(flags.source)
156
152
  ? flags.source
157
153
  : await loadCatalogFromFile(flags.source)
158
154
  : integrationEmptyCatalog
159
- ? (await import("../../core/catalog.snapshot.ts")).deserializeCatalog(
160
- integrationEmptyCatalog,
161
- )
155
+ ? deserializeCatalog(integrationEmptyCatalog)
162
156
  : null;
163
157
 
164
158
  const resolvedTarget = isPostgresUrl(flags.target)
@@ -167,8 +161,8 @@ json/sql outputs are available for artifacts or piping.
167
161
 
168
162
  const planResult = await createPlan(resolvedSource, resolvedTarget, {
169
163
  role: flags.role,
170
- filter: filterOption,
171
- serialize: serializeOption,
164
+ filter,
165
+ serialize,
172
166
  });
173
167
  if (!planResult) {
174
168
  this.process.stdout.write("No changes detected.\n");
@@ -4,12 +4,10 @@
4
4
 
5
5
  import { buildCommand, type CommandContext } from "@stricli/core";
6
6
  import type { FilterDSL } from "../../core/integrations/filter/dsl.ts";
7
- import type { ChangeFilter } from "../../core/integrations/filter/filter.types.ts";
8
7
  import type { SerializeDSL } from "../../core/integrations/serialize/dsl.ts";
9
- import type { ChangeSerializer } from "../../core/integrations/serialize/serialize.types.ts";
10
8
  import { applyPlan } from "../../core/plan/apply.ts";
11
9
  import { createPlan } from "../../core/plan/index.ts";
12
- import { loadIntegrationDSL } from "../utils/integrations.ts";
10
+ import { resolveIntegrationOptions } from "../utils/integrations.ts";
13
11
  import {
14
12
  formatPlanForDisplay,
15
13
  handleApplyResult,
@@ -120,22 +118,17 @@ Exit codes:
120
118
  integration?: string;
121
119
  },
122
120
  ) {
123
- // Load integration if provided and extract filter/serialize DSL
124
- let filterOption: FilterDSL | ChangeFilter | undefined = flags.filter;
125
- let serializeOption: SerializeDSL | ChangeSerializer | undefined =
126
- flags.serialize;
127
- if (flags.integration) {
128
- const integrationDSL = await loadIntegrationDSL(flags.integration);
129
- // Use integration DSL if explicit flags not provided
130
- filterOption = filterOption ?? integrationDSL.filter;
131
- serializeOption = serializeOption ?? integrationDSL.serialize;
132
- }
121
+ const { filter, serialize } = await resolveIntegrationOptions({
122
+ filter: flags.filter,
123
+ serialize: flags.serialize,
124
+ integration: flags.integration,
125
+ });
133
126
 
134
127
  // 1. Create the plan
135
128
  const planResult = await createPlan(flags.source, flags.target, {
136
129
  role: flags.role,
137
- filter: filterOption,
138
- serialize: serializeOption,
130
+ filter,
131
+ serialize,
139
132
  });
140
133
  if (!planResult) {
141
134
  this.process.stdout.write("No changes detected.\n");
@@ -2,7 +2,12 @@ import { describe, expect, test } from "bun:test";
2
2
  import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import path from "node:path";
5
- import { loadIntegrationDSL } from "./integrations.ts";
5
+ import type { FilterDSL } from "../../core/integrations/filter/dsl.ts";
6
+ import type { SerializeDSL } from "../../core/integrations/serialize/dsl.ts";
7
+ import {
8
+ loadIntegrationDSL,
9
+ resolveIntegrationOptions,
10
+ } from "./integrations.ts";
6
11
 
7
12
  describe("loadIntegrationDSL", () => {
8
13
  test("loads from .json file path", async () => {
@@ -12,12 +17,12 @@ describe("loadIntegrationDSL", () => {
12
17
  await writeFile(
13
18
  jsonPath,
14
19
  JSON.stringify({
15
- filter: { schema: "app" },
20
+ filter: { "*/schema": "app" },
16
21
  }),
17
22
  );
18
23
  const dsl = await loadIntegrationDSL(jsonPath);
19
24
  expect(dsl).toBeDefined();
20
- expect(dsl.filter).toEqual({ schema: "app" });
25
+ expect(dsl.filter).toEqual({ "*/schema": "app" });
21
26
  } finally {
22
27
  await rm(dir, { recursive: true, force: true });
23
28
  }
@@ -42,3 +47,205 @@ describe("loadIntegrationDSL", () => {
42
47
  }
43
48
  });
44
49
  });
50
+
51
+ describe("extends resolution", () => {
52
+ test('extends: "supabase" → resolves and merges successfully', async () => {
53
+ const dir = await mkdtemp(path.join(tmpdir(), "pgd-extends-"));
54
+ const jsonPath = path.join(dir, "custom.json");
55
+ try {
56
+ await writeFile(
57
+ jsonPath,
58
+ JSON.stringify({
59
+ extends: "supabase",
60
+ serialize: [
61
+ {
62
+ when: { objectType: "table" },
63
+ options: { skipAuthorization: true },
64
+ },
65
+ ],
66
+ }),
67
+ );
68
+ const dsl = await loadIntegrationDSL(jsonPath);
69
+ expect(dsl).toBeDefined();
70
+ // Should have the supabase filter merged in
71
+ expect(dsl.filter).toBeDefined();
72
+ // Should have both supabase serialize rules and our custom one
73
+ expect(dsl.serialize).toBeDefined();
74
+ expect(dsl.serialize?.length).toBeGreaterThan(1);
75
+ } finally {
76
+ await rm(dir, { recursive: true, force: true });
77
+ }
78
+ });
79
+
80
+ test('extends: "./some-file.json" → throws error about core integrations only', async () => {
81
+ const dir = await mkdtemp(path.join(tmpdir(), "pgd-extends-"));
82
+ const filePath = path.join(dir, "child.json");
83
+ const parentPath = path.join(dir, "parent.json");
84
+ try {
85
+ await writeFile(
86
+ parentPath,
87
+ JSON.stringify({ filter: { "*/schema": "app" } }),
88
+ );
89
+ await writeFile(
90
+ filePath,
91
+ JSON.stringify({
92
+ extends: parentPath,
93
+ filter: { "*/schema": "public" },
94
+ }),
95
+ );
96
+ expect(loadIntegrationDSL(filePath)).rejects.toThrow(
97
+ /extends only supports core integration names/,
98
+ );
99
+ } finally {
100
+ await rm(dir, { recursive: true, force: true });
101
+ }
102
+ });
103
+
104
+ test('extends: "nonexistent" → throws error about unknown core integration', async () => {
105
+ const dir = await mkdtemp(path.join(tmpdir(), "pgd-extends-"));
106
+ const jsonPath = path.join(dir, "custom.json");
107
+ try {
108
+ await writeFile(jsonPath, JSON.stringify({ extends: "nonexistent" }));
109
+ expect(loadIntegrationDSL(jsonPath)).rejects.toThrow(
110
+ /Unknown core integration: "nonexistent"/,
111
+ );
112
+ } finally {
113
+ await rm(dir, { recursive: true, force: true });
114
+ }
115
+ });
116
+ });
117
+
118
+ describe("resolveIntegrationOptions", () => {
119
+ test("no integration, no CLI flags → all undefined", async () => {
120
+ const result = await resolveIntegrationOptions({});
121
+ expect(result).toEqual({
122
+ filter: undefined,
123
+ serialize: undefined,
124
+ });
125
+ });
126
+
127
+ test("CLI flags only → passed through unchanged", async () => {
128
+ const filter: FilterDSL = { "*/schema": "public" };
129
+ const serialize: SerializeDSL = [
130
+ { when: { objectType: "schema" }, options: { skipAuthorization: true } },
131
+ ];
132
+ const result = await resolveIntegrationOptions({ filter, serialize });
133
+ expect(result.filter).toEqual(filter);
134
+ expect(result.serialize).toEqual(serialize);
135
+ expect(result.emptyCatalog).toBeUndefined();
136
+ });
137
+
138
+ test("integration only → integration values returned", async () => {
139
+ const dir = await mkdtemp(path.join(tmpdir(), "pgd-resolve-"));
140
+ const jsonPath = path.join(dir, "int.json");
141
+ try {
142
+ await writeFile(
143
+ jsonPath,
144
+ JSON.stringify({
145
+ filter: { "*/schema": "app" },
146
+ serialize: [{ when: { objectType: "table" }, options: {} }],
147
+ }),
148
+ );
149
+ const result = await resolveIntegrationOptions({
150
+ integration: jsonPath,
151
+ });
152
+ expect(result.filter).toEqual({ "*/schema": "app" });
153
+ expect(result.serialize).toEqual([
154
+ { when: { objectType: "table" }, options: {} },
155
+ ]);
156
+ } finally {
157
+ await rm(dir, { recursive: true, force: true });
158
+ }
159
+ });
160
+
161
+ test("both filter + integration filter → AND-combined", async () => {
162
+ const dir = await mkdtemp(path.join(tmpdir(), "pgd-resolve-"));
163
+ const jsonPath = path.join(dir, "int.json");
164
+ try {
165
+ await writeFile(
166
+ jsonPath,
167
+ JSON.stringify({
168
+ filter: { "*/schema": "app" },
169
+ }),
170
+ );
171
+ const cliFilter: FilterDSL = { objectType: "table" };
172
+ const result = await resolveIntegrationOptions({
173
+ filter: cliFilter,
174
+ integration: jsonPath,
175
+ });
176
+ expect(result.filter).toEqual({
177
+ and: [{ "*/schema": "app" }, { objectType: "table" }],
178
+ });
179
+ } finally {
180
+ await rm(dir, { recursive: true, force: true });
181
+ }
182
+ });
183
+
184
+ test("both serialize + integration serialize → concatenated (integration first)", async () => {
185
+ const dir = await mkdtemp(path.join(tmpdir(), "pgd-resolve-"));
186
+ const jsonPath = path.join(dir, "int.json");
187
+ try {
188
+ const intSerialize: SerializeDSL = [
189
+ {
190
+ when: { objectType: "schema" },
191
+ options: { skipAuthorization: true },
192
+ },
193
+ ];
194
+ const cliSerialize: SerializeDSL = [
195
+ { when: { objectType: "table" }, options: {} },
196
+ ];
197
+ await writeFile(jsonPath, JSON.stringify({ serialize: intSerialize }));
198
+ const result = await resolveIntegrationOptions({
199
+ serialize: cliSerialize,
200
+ integration: jsonPath,
201
+ });
202
+ expect(result.serialize).toEqual([...intSerialize, ...cliSerialize]);
203
+ } finally {
204
+ await rm(dir, { recursive: true, force: true });
205
+ }
206
+ });
207
+
208
+ test("emptyCatalog returned from integration", async () => {
209
+ const dir = await mkdtemp(path.join(tmpdir(), "pgd-resolve-"));
210
+ const jsonPath = path.join(dir, "int.json");
211
+ try {
212
+ const emptyCatalog = {
213
+ version: 1,
214
+ currentUser: "postgres",
215
+ aggregates: {},
216
+ collations: {},
217
+ compositeTypes: {},
218
+ domains: {},
219
+ enums: {},
220
+ extensions: {},
221
+ procedures: {},
222
+ indexes: {},
223
+ materializedViews: {},
224
+ subscriptions: {},
225
+ publications: {},
226
+ rlsPolicies: {},
227
+ roles: {},
228
+ schemas: {},
229
+ sequences: {},
230
+ tables: {},
231
+ triggers: {},
232
+ eventTriggers: {},
233
+ rules: {},
234
+ ranges: {},
235
+ views: {},
236
+ foreignDataWrappers: {},
237
+ servers: {},
238
+ userMappings: {},
239
+ foreignTables: {},
240
+ depends: [],
241
+ };
242
+ await writeFile(jsonPath, JSON.stringify({ emptyCatalog }));
243
+ const result = await resolveIntegrationOptions({
244
+ integration: jsonPath,
245
+ });
246
+ expect(result.emptyCatalog).toEqual(emptyCatalog);
247
+ } finally {
248
+ await rm(dir, { recursive: true, force: true });
249
+ }
250
+ });
251
+ });
@@ -3,18 +3,19 @@
3
3
  */
4
4
 
5
5
  import { readFile } from "node:fs/promises";
6
+ import type { CatalogSnapshot } from "../../core/catalog.snapshot.ts";
7
+ import type { FilterDSL } from "../../core/integrations/filter/dsl.ts";
6
8
  import type { IntegrationDSL } from "../../core/integrations/integration-dsl.ts";
9
+ import { mergeIntegrations } from "../../core/integrations/merge.ts";
10
+ import type { SerializeDSL } from "../../core/integrations/serialize/dsl.ts";
7
11
 
8
12
  /**
9
- * Load an integration DSL from a file or core integration.
10
- * If the path ends with .json, treats it as a JSON file path directly.
11
- * Otherwise, tries to load from core integrations (TypeScript) first,
12
- * then falls back to treating as a JSON file path.
13
+ * Load a raw integration DSL from a file or core integration (without resolving extends).
13
14
  *
14
15
  * @param nameOrPath - Integration name (e.g., "supabase") or file path (e.g., "./my-integration.json")
15
- * @returns The loaded IntegrationDSL
16
+ * @returns The loaded IntegrationDSL (unresolved)
16
17
  */
17
- export async function loadIntegrationDSL(
18
+ async function loadRawIntegrationDSL(
18
19
  nameOrPath: string,
19
20
  ): Promise<IntegrationDSL> {
20
21
  // If path ends with .json, treat it as a JSON file path directly
@@ -40,3 +41,130 @@ export async function loadIntegrationDSL(
40
41
  const content = await readFile(nameOrPath, "utf-8");
41
42
  return JSON.parse(content) as IntegrationDSL;
42
43
  }
44
+
45
+ /**
46
+ * Load a core integration DSL by name only (no file path fallback).
47
+ *
48
+ * Used for resolving `extends` chains, which only support core integration names
49
+ * (e.g., "supabase"), not file paths.
50
+ *
51
+ * @param name - Core integration name (e.g., "supabase")
52
+ * @returns The loaded IntegrationDSL (unresolved)
53
+ */
54
+ async function loadCoreIntegrationDSL(name: string): Promise<IntegrationDSL> {
55
+ try {
56
+ const module = await import(`../../core/integrations/${name}.ts`);
57
+ if (name in module) {
58
+ return module[name] as IntegrationDSL;
59
+ }
60
+ } catch {
61
+ // Module not found
62
+ }
63
+ throw new Error(
64
+ `Unknown core integration: "${name}". extends only supports core integration names (e.g., "supabase").`,
65
+ );
66
+ }
67
+
68
+ /**
69
+ * Load an integration DSL, recursively resolving `extends` chains.
70
+ *
71
+ * When an integration has `extends`, the referenced integration(s) are loaded
72
+ * and merged: filters are AND-combined, serialize rules concatenated (base first),
73
+ * and emptyCatalog uses the most-specific value.
74
+ *
75
+ * Circular extends are detected and rejected with a descriptive error.
76
+ *
77
+ * @param nameOrPath - Integration name (e.g., "supabase") or file path
78
+ * @returns The fully resolved IntegrationDSL
79
+ */
80
+ export async function loadIntegrationDSL(
81
+ nameOrPath: string,
82
+ ): Promise<IntegrationDSL> {
83
+ return resolveIntegration(nameOrPath, new Set());
84
+ }
85
+
86
+ async function resolveIntegration(
87
+ nameOrPath: string,
88
+ visited: Set<string>,
89
+ preloadedRaw?: IntegrationDSL,
90
+ ): Promise<IntegrationDSL> {
91
+ if (visited.has(nameOrPath)) {
92
+ throw new Error(
93
+ `Circular extends detected: ${[...visited, nameOrPath].join(" → ")}`,
94
+ );
95
+ }
96
+ visited.add(nameOrPath);
97
+
98
+ const raw = preloadedRaw ?? (await loadRawIntegrationDSL(nameOrPath));
99
+
100
+ if (!raw.extends) {
101
+ return raw;
102
+ }
103
+
104
+ // Resolve base integrations (extends only supports core integration names)
105
+ const extendsArray = Array.isArray(raw.extends) ? raw.extends : [raw.extends];
106
+
107
+ const baseIntegrations: IntegrationDSL[] = [];
108
+ for (const baseName of extendsArray) {
109
+ const baseRaw = await loadCoreIntegrationDSL(baseName);
110
+ baseIntegrations.push(
111
+ await resolveIntegration(baseName, new Set(visited), baseRaw),
112
+ );
113
+ }
114
+
115
+ // Remove extends from the current integration before merging
116
+ const { extends: _, ...current } = raw;
117
+
118
+ // Merge: bases first (higher priority serialize), then current (most-specific)
119
+ return mergeIntegrations([...baseIntegrations, current]);
120
+ }
121
+
122
+ interface ResolvedIntegrationOptions {
123
+ filter?: FilterDSL;
124
+ serialize?: SerializeDSL;
125
+ emptyCatalog?: CatalogSnapshot;
126
+ }
127
+
128
+ /**
129
+ * Load an integration (if provided) and merge its filter/serialize with CLI flags.
130
+ *
131
+ * - Filters are AND-combined (integration ∧ CLI flag)
132
+ * - Serialize rules are concatenated (integration first = higher priority)
133
+ * - emptyCatalog is extracted from the integration
134
+ */
135
+ export async function resolveIntegrationOptions(options: {
136
+ filter?: FilterDSL;
137
+ serialize?: SerializeDSL;
138
+ integration?: string;
139
+ }): Promise<ResolvedIntegrationOptions> {
140
+ if (!options.integration) {
141
+ return {
142
+ filter: options.filter,
143
+ serialize: options.serialize,
144
+ };
145
+ }
146
+
147
+ const integrationDSL = await loadIntegrationDSL(options.integration);
148
+
149
+ // AND-combine integration filter with CLI --filter
150
+ let filter: FilterDSL | undefined;
151
+ if (integrationDSL.filter && options.filter) {
152
+ filter = { and: [integrationDSL.filter, options.filter] };
153
+ } else {
154
+ filter = options.filter ?? integrationDSL.filter;
155
+ }
156
+
157
+ // Concatenate serialize rules (integration first = higher priority)
158
+ let serialize: SerializeDSL | undefined;
159
+ if (integrationDSL.serialize && options.serialize) {
160
+ serialize = [...integrationDSL.serialize, ...options.serialize];
161
+ } else {
162
+ serialize = options.serialize ?? integrationDSL.serialize;
163
+ }
164
+
165
+ return {
166
+ filter,
167
+ serialize,
168
+ emptyCatalog: integrationDSL.emptyCatalog,
169
+ };
170
+ }
@@ -452,7 +452,11 @@ describe("catalog snapshot serde", () => {
452
452
 
453
453
  // Filter out auth schema (no cascade): procedure is excluded but policy stays
454
454
  const resultNoCascade = await createPlan(null, target, {
455
- filter: { not: { schema: ["auth"] } },
455
+ filter: {
456
+ not: {
457
+ or: [{ "*/schema": ["auth"] }, { "schema/name": ["auth"] }],
458
+ },
459
+ },
456
460
  });
457
461
  expect(resultNoCascade).not.toBeNull();
458
462
  if (resultNoCascade) {
@@ -464,7 +468,12 @@ describe("catalog snapshot serde", () => {
464
468
 
465
469
  // Filter out auth schema with cascade: true: policy is also excluded (depends on auth.uid())
466
470
  const resultCascade = await createPlan(null, target, {
467
- filter: { not: { schema: ["auth"] }, cascade: true },
471
+ filter: {
472
+ not: {
473
+ or: [{ "*/schema": ["auth"] }, { "schema/name": ["auth"] }],
474
+ },
475
+ cascade: true,
476
+ },
468
477
  });
469
478
  expect(resultCascade).not.toBeNull();
470
479
  if (resultCascade) {