@supabase/pg-delta 1.0.0-alpha.12 → 1.0.0-alpha.14

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 (30) hide show
  1. package/dist/core/connection-url.d.ts +32 -0
  2. package/dist/core/connection-url.js +77 -0
  3. package/dist/core/export/index.d.ts +2 -2
  4. package/dist/core/export/index.js +4 -1
  5. package/dist/core/integrations/integration.types.d.ts +26 -1
  6. package/dist/core/integrations/integration.types.js +31 -1
  7. package/dist/core/integrations/supabase.js +1 -0
  8. package/dist/core/objects/procedure/procedure.diff.js +8 -0
  9. package/dist/core/objects/table/changes/table.alter.js +4 -1
  10. package/dist/core/objects/table/table.diff.js +7 -2
  11. package/dist/core/plan/create.js +5 -17
  12. package/dist/core/plan/types.d.ts +3 -6
  13. package/dist/core/postgres-config.d.ts +27 -0
  14. package/dist/core/postgres-config.js +99 -7
  15. package/package.json +2 -1
  16. package/src/core/connection-url.test.ts +142 -0
  17. package/src/core/connection-url.ts +82 -0
  18. package/src/core/export/index.ts +13 -4
  19. package/src/core/integrations/integration.types.ts +59 -1
  20. package/src/core/integrations/supabase.ts +1 -0
  21. package/src/core/objects/procedure/procedure.diff.test.ts +25 -0
  22. package/src/core/objects/procedure/procedure.diff.ts +12 -0
  23. package/src/core/objects/table/changes/table.alter.test.ts +14 -0
  24. package/src/core/objects/table/changes/table.alter.ts +4 -1
  25. package/src/core/objects/table/table.diff.test.ts +55 -0
  26. package/src/core/objects/table/table.diff.ts +10 -2
  27. package/src/core/plan/create.ts +11 -27
  28. package/src/core/plan/types.ts +3 -6
  29. package/src/core/postgres-config.test.ts +241 -0
  30. package/src/core/postgres-config.ts +127 -16
@@ -0,0 +1,142 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { isIPv6, normalizeConnectionUrl } from "./connection-url.ts";
3
+
4
+ describe("isIPv6", () => {
5
+ describe("accepted", () => {
6
+ const accepted = [
7
+ "::",
8
+ "::1",
9
+ "1::",
10
+ "1:2:3:4:5:6:7:8",
11
+ "2406:da18:243:740f:abda:9a5c:a92d:b3c9",
12
+ "::ffff:192.0.2.1",
13
+ "fe80::AbCd",
14
+ "fe80::1%eth0",
15
+ ];
16
+ for (const value of accepted) {
17
+ test(`accepts "${value}"`, () => {
18
+ expect(isIPv6(value)).toBe(true);
19
+ });
20
+ }
21
+ });
22
+
23
+ describe("rejected", () => {
24
+ const rejected = [
25
+ "",
26
+ "2406:da18:243:740f", // only 4 groups
27
+ "1:2:3:4:5:6:7:8:9", // 9 groups
28
+ "1::2::3", // double compression
29
+ "gggg::1", // invalid hex
30
+ "1.2.3.4", // pure IPv4
31
+ "[::1]", // bracketed
32
+ "localhost",
33
+ "example.com",
34
+ ":::", // malformed
35
+ ];
36
+ for (const value of rejected) {
37
+ test(`rejects ${JSON.stringify(value)}`, () => {
38
+ expect(isIPv6(value)).toBe(false);
39
+ });
40
+ }
41
+ });
42
+ });
43
+
44
+ describe("normalizeConnectionUrl", () => {
45
+ describe("normalizes percent-encoded IPv6 hosts", () => {
46
+ test("full 8-group IPv6 becomes bracketed", () => {
47
+ const input =
48
+ "postgresql://user:pass@2406%3Ada18%3A243%3A740f%3Aabda%3A9a5c%3Aa92d%3Ab3c9:5432/db";
49
+ expect(normalizeConnectionUrl(input)).toBe(
50
+ "postgresql://user:pass@[2406:da18:243:740f:abda:9a5c:a92d:b3c9]:5432/db",
51
+ );
52
+ });
53
+
54
+ test("compressed ::1 form", () => {
55
+ const input = "postgresql://user:pass@%3A%3A1:5432/db";
56
+ expect(normalizeConnectionUrl(input)).toBe(
57
+ "postgresql://user:pass@[::1]:5432/db",
58
+ );
59
+ });
60
+
61
+ test("IPv4-mapped ::ffff:192.0.2.1", () => {
62
+ const input = "postgresql://user:pass@%3A%3Affff%3A192.0.2.1:5432/db";
63
+ expect(normalizeConnectionUrl(input)).toBe(
64
+ "postgresql://user:pass@[::ffff:192.0.2.1]:5432/db",
65
+ );
66
+ });
67
+
68
+ test("mixed-case percent triples (%3a and %3A)", () => {
69
+ const input =
70
+ "postgresql://user:pass@2406%3ada18%3A243%3a740f%3Aabda%3A9a5c%3Aa92d%3Ab3c9:5432/db";
71
+ expect(normalizeConnectionUrl(input)).toBe(
72
+ "postgresql://user:pass@[2406:da18:243:740f:abda:9a5c:a92d:b3c9]:5432/db",
73
+ );
74
+ });
75
+
76
+ test("preserves URL-encoded password and query string", () => {
77
+ const input =
78
+ "postgresql://user:p%40ss%2Fword@%3A%3A1:5432/db?sslmode=require&application_name=pgdelta";
79
+ expect(normalizeConnectionUrl(input)).toBe(
80
+ "postgresql://user:p%40ss%2Fword@[::1]:5432/db?sslmode=require&application_name=pgdelta",
81
+ );
82
+ });
83
+
84
+ test("preserves fragment", () => {
85
+ const input = "postgresql://user:pass@%3A%3A1:5432/db#frag";
86
+ expect(normalizeConnectionUrl(input)).toBe(
87
+ "postgresql://user:pass@[::1]:5432/db#frag",
88
+ );
89
+ });
90
+
91
+ test("works without a port", () => {
92
+ const input = "postgresql://user:pass@%3A%3A1/db";
93
+ expect(normalizeConnectionUrl(input)).toBe(
94
+ "postgresql://user:pass@[::1]/db",
95
+ );
96
+ });
97
+
98
+ test("works without userinfo", () => {
99
+ const input = "postgresql://%3A%3A1:5432/db";
100
+ expect(normalizeConnectionUrl(input)).toBe("postgresql://[::1]:5432/db");
101
+ });
102
+
103
+ test("works with username only (no password)", () => {
104
+ const input = "postgresql://user@%3A%3A1:5432/db";
105
+ expect(normalizeConnectionUrl(input)).toBe(
106
+ "postgresql://user@[::1]:5432/db",
107
+ );
108
+ });
109
+ });
110
+
111
+ describe("leaves URL unchanged (guardrail)", () => {
112
+ test("already-bracketed IPv6", () => {
113
+ const input = "postgresql://user:pass@[::1]:5432/db";
114
+ expect(normalizeConnectionUrl(input)).toBe(input);
115
+ });
116
+
117
+ test("IPv4 host", () => {
118
+ const input = "postgresql://user:pass@127.0.0.1:5432/db";
119
+ expect(normalizeConnectionUrl(input)).toBe(input);
120
+ });
121
+
122
+ test("DNS hostname", () => {
123
+ const input = "postgresql://user:pass@db.example.com:5432/db";
124
+ expect(normalizeConnectionUrl(input)).toBe(input);
125
+ });
126
+
127
+ test("percent-encoded colons that do not decode to a valid IPv6 (4 groups only)", () => {
128
+ const input = "postgresql://user:pass@2406%3Ada18%3A243%3A740f:5432/db";
129
+ expect(normalizeConnectionUrl(input)).toBe(input);
130
+ });
131
+
132
+ test("non-colon percent-encoded character in hostname", () => {
133
+ const input = "postgresql://user:pass@host%2Dname:5432/db";
134
+ expect(normalizeConnectionUrl(input)).toBe(input);
135
+ });
136
+
137
+ test("garbage host `%3A%3Azzz` decodes to `::zzz`, not valid IPv6", () => {
138
+ const input = "postgresql://user:pass@%3A%3Azzz:5432/db";
139
+ expect(normalizeConnectionUrl(input)).toBe(input);
140
+ });
141
+ });
142
+ });
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Connection URL normalization for pg-delta.
3
+ *
4
+ * Auto-normalizes percent-encoded IPv6 hosts in PostgreSQL connection URLs.
5
+ * A URL like `postgresql://user:pass@2406%3Ada18%3A...%3Ab3c9:5432/db`
6
+ * becomes `postgresql://user:pass@[2406:da18:...:b3c9]:5432/db` before it
7
+ * reaches `pg-connection-string` / `pg.Pool`, so DNS resolution sees the
8
+ * address in its canonical bracketed form.
9
+ *
10
+ * Non-IPv6 hosts (IPv4, DNS names, already-bracketed IPv6, partial fragments
11
+ * that just happen to contain `%3A`) are returned verbatim.
12
+ */
13
+
14
+ // IPv6 detection regex vendored from ip-regex (Sindre Sorhus, MIT).
15
+ // https://github.com/sindresorhus/ip-regex
16
+ const v4 =
17
+ "(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}";
18
+ const v6seg = "[a-fA-F\\d]{1,4}";
19
+ const v6 = `
20
+ (?:
21
+ (?:${v6seg}:){7}(?:${v6seg}|:)|
22
+ (?:${v6seg}:){6}(?:${v4}|:${v6seg}|:)|
23
+ (?:${v6seg}:){5}(?::${v4}|(?::${v6seg}){1,2}|:)|
24
+ (?:${v6seg}:){4}(?:(?::${v6seg}){0,1}:${v4}|(?::${v6seg}){1,3}|:)|
25
+ (?:${v6seg}:){3}(?:(?::${v6seg}){0,2}:${v4}|(?::${v6seg}){1,4}|:)|
26
+ (?:${v6seg}:){2}(?:(?::${v6seg}){0,3}:${v4}|(?::${v6seg}){1,5}|:)|
27
+ (?:${v6seg}:){1}(?:(?::${v6seg}){0,4}:${v4}|(?::${v6seg}){1,6}|:)|
28
+ (?::(?:(?::${v6seg}){0,5}:${v4}|(?::${v6seg}){1,7}|:))
29
+ )(?:%[0-9a-zA-Z]{1,})?
30
+ `
31
+ .replace(/\s*\/\/.*$/gm, "")
32
+ .replace(/\n/g, "")
33
+ .trim();
34
+
35
+ const V6_EXACT = new RegExp(`^${v6}$`);
36
+
37
+ /**
38
+ * Return true if `value` is a valid IPv6 literal in any canonical form:
39
+ * full 8-group, `::` compression, or IPv4-mapped (`::ffff:1.2.3.4`).
40
+ * RFC 4007 zone identifiers (`fe80::1%eth0`) are accepted.
41
+ */
42
+ export function isIPv6(value: string): boolean {
43
+ return typeof value === "string" && V6_EXACT.test(value);
44
+ }
45
+
46
+ /**
47
+ * Normalize a PostgreSQL connection URL so IPv6 hosts reach pg in the
48
+ * canonical bracketed form.
49
+ *
50
+ * If the URL's hostname contains a percent-encoded colon AND the decoded
51
+ * hostname is a valid IPv6 literal, the hostname is decoded and wrapped in
52
+ * `[...]`. All other fields (scheme, userinfo, port, path, query, fragment)
53
+ * are preserved byte-for-byte from the input.
54
+ *
55
+ * Any URL whose decoded hostname does not validate as IPv6 is returned
56
+ * verbatim, so a malformed input will surface its usual downstream error
57
+ * instead of being silently rewritten.
58
+ */
59
+ export function normalizeConnectionUrl(url: string): string {
60
+ const urlObj = new URL(url);
61
+ // Cheap pre-filter: only look closer if the hostname contains a
62
+ // percent-encoded colon. Anything else is left entirely untouched.
63
+ if (!/%3[aA]/.test(urlObj.hostname)) return url;
64
+
65
+ const decodedHost = decodeURIComponent(urlObj.hostname);
66
+ // Authoritative validation: only normalize when the decoded string is a
67
+ // real IPv6 literal. Rejects partial fragments, random hostnames that
68
+ // happen to contain `%3A`, and any malformed input.
69
+ if (!isIPv6(decodedHost)) return url;
70
+
71
+ // Preserve username/password/port/path/search/hash exactly as they appear
72
+ // in the WHATWG URL model (these are returned already percent-encoded).
73
+ const scheme = `${urlObj.protocol}//`;
74
+ const auth = urlObj.username
75
+ ? urlObj.password
76
+ ? `${urlObj.username}:${urlObj.password}@`
77
+ : `${urlObj.username}@`
78
+ : "";
79
+ const port = urlObj.port ? `:${urlObj.port}` : "";
80
+ const tail = `${urlObj.pathname}${urlObj.search}${urlObj.hash}`;
81
+ return `${scheme}${auth}[${decodedHost}]${port}${tail}`;
82
+ }
@@ -4,7 +4,11 @@
4
4
 
5
5
  import type { Change } from "../change.types.ts";
6
6
  import { buildPlanScopeFingerprint, hashStableIds } from "../fingerprint.ts";
7
- import type { Integration } from "../integrations/integration.types.ts";
7
+ import {
8
+ type Integration,
9
+ type ResolvedIntegration,
10
+ resolveIntegration,
11
+ } from "../integrations/integration.types.ts";
8
12
  import type { createPlan } from "../plan/create.ts";
9
13
  import { DEFAULT_OPTIONS } from "../plan/sql-format/constants.ts";
10
14
  import type { SqlFormatOptions } from "../plan/sql-format/types.ts";
@@ -29,7 +33,7 @@ type PlanResult = NonNullable<Awaited<ReturnType<typeof createPlan>>>;
29
33
 
30
34
  export interface ExportOptions {
31
35
  /** Integration for custom serialization */
32
- integration?: Integration;
36
+ integration?: Pick<Integration, "serialize">;
33
37
  /**
34
38
  * SQL formatter options to control the output style.
35
39
  * Merged on top of the default export options (maxWidth: 180, keywordCase: "upper").
@@ -64,7 +68,9 @@ export function exportDeclarativeSchema(
64
68
  options?: ExportOptions,
65
69
  ): DeclarativeSchemaOutput {
66
70
  const { ctx, sortedChanges } = planResult;
67
- const integration = options?.integration;
71
+ const integration = options?.integration
72
+ ? resolveIntegration(options?.integration)
73
+ : {};
68
74
  const formatOptions: SqlFormatOptions | undefined =
69
75
  options?.formatOptions === null
70
76
  ? undefined
@@ -108,7 +114,10 @@ export function exportDeclarativeSchema(
108
114
  };
109
115
  }
110
116
 
111
- function serializeChange(change: Change, integration?: Integration): string {
117
+ function serializeChange(
118
+ change: Change,
119
+ integration?: ResolvedIntegration,
120
+ ): string {
112
121
  return integration?.serialize?.(change) ?? change.serialize();
113
122
  }
114
123
 
@@ -1,7 +1,65 @@
1
+ import { compileFilterDSL, type FilterDSL } from "./filter/dsl.ts";
1
2
  import type { ChangeFilter } from "./filter/filter.types.ts";
3
+ import { compileSerializeDSL, type SerializeDSL } from "./serialize/dsl.ts";
2
4
  import type { ChangeSerializer } from "./serialize/serialize.types.ts";
3
5
 
4
- export type Integration = {
6
+ /**
7
+ * A resolved integration is an integration that has been compiled to a function.
8
+ */
9
+ export type ResolvedIntegration = {
5
10
  filter?: ChangeFilter;
6
11
  serialize?: ChangeSerializer;
7
12
  };
13
+
14
+ /**
15
+ * A raw integration is an integration that has not been compiled to a function.
16
+ */
17
+ export type IntegrationDSL = {
18
+ filter?: FilterDSL;
19
+ serialize?: SerializeDSL;
20
+ };
21
+
22
+ /**
23
+ * An integration is a raw integration that has not been compiled to a function.
24
+ */
25
+ export type Integration = {
26
+ filter?: ResolvedIntegration["filter"] | IntegrationDSL["filter"];
27
+ serialize?: ResolvedIntegration["serialize"] | IntegrationDSL["serialize"];
28
+ };
29
+
30
+ /**
31
+ * Resolve an integration either DSL or already resovled into a ResolvedIntegration.
32
+ * @param integration - The integration to resolve.
33
+ * @returns The resolved integration.
34
+ */
35
+ export function resolveIntegration(
36
+ integration: Integration,
37
+ ): ResolvedIntegration | undefined {
38
+ // Determine if filter/serialize are DSL or functions, and extract DSL for storage
39
+ const isFilterDSL =
40
+ integration.filter && typeof integration.filter !== "function";
41
+ const isSerializeDSL =
42
+ integration.serialize && typeof integration.serialize !== "function";
43
+ const filterDSL = isFilterDSL ? (integration.filter as FilterDSL) : undefined;
44
+ const serializeDSL = isSerializeDSL
45
+ ? (integration.serialize as SerializeDSL)
46
+ : undefined;
47
+
48
+ // Build final integration: compile DSL if needed, use functions directly otherwise
49
+ if (integration.filter || integration.serialize) {
50
+ return {
51
+ filter:
52
+ typeof integration.filter === "function"
53
+ ? integration.filter
54
+ : filterDSL
55
+ ? compileFilterDSL(filterDSL)
56
+ : undefined,
57
+ serialize:
58
+ typeof integration.serialize === "function"
59
+ ? integration.serialize
60
+ : serializeDSL
61
+ ? compileSerializeDSL(serializeDSL)
62
+ : undefined,
63
+ };
64
+ }
65
+ }
@@ -16,6 +16,7 @@ const SUPABASE_SYSTEM_SCHEMAS = [
16
16
  "_supavisor",
17
17
  "auth",
18
18
  "cron",
19
+ "etl",
19
20
  "extensions",
20
21
  "graphql",
21
22
  "graphql_public",
@@ -9,6 +9,7 @@ import {
9
9
  AlterProcedureSetStrictness,
10
10
  AlterProcedureSetVolatility,
11
11
  } from "./changes/procedure.alter.ts";
12
+ import { CreateCommentOnProcedure } from "./changes/procedure.comment.ts";
12
13
  import { CreateProcedure } from "./changes/procedure.create.ts";
13
14
  import { DropProcedure } from "./changes/procedure.drop.ts";
14
15
  import { diffProcedures } from "./procedure.diff.ts";
@@ -158,4 +159,28 @@ describe.concurrent("procedure.diff", () => {
158
159
  expect(changes).toHaveLength(1);
159
160
  expect(changes[0]).toBeInstanceOf(CreateProcedure);
160
161
  });
162
+
163
+ test("create or replace also emits a procedure comment when the comment changes", () => {
164
+ const main = new Procedure(base);
165
+ const branch = new Procedure({
166
+ ...base,
167
+ definition:
168
+ "CREATE FUNCTION public.fn1() RETURNS int4 LANGUAGE sql AS $$SELECT 42::int4$$",
169
+ source_code: "SELECT 42::int4",
170
+ comment: "updated comment",
171
+ });
172
+
173
+ const changes = diffProcedures(
174
+ testContext,
175
+ { [main.stableId]: main },
176
+ { [branch.stableId]: branch },
177
+ );
178
+
179
+ expect(changes.some((change) => change instanceof CreateProcedure)).toBe(
180
+ true,
181
+ );
182
+ expect(
183
+ changes.some((change) => change instanceof CreateCommentOnProcedure),
184
+ ).toBe(true);
185
+ });
161
186
  });
@@ -169,6 +169,18 @@ export function diffProcedures(
169
169
  changes.push(
170
170
  new CreateProcedure({ procedure: branchProcedure, orReplace: true }),
171
171
  );
172
+
173
+ if (mainProcedure.comment !== branchProcedure.comment) {
174
+ if (branchProcedure.comment === null) {
175
+ changes.push(
176
+ new DropCommentOnProcedure({ procedure: mainProcedure }),
177
+ );
178
+ } else {
179
+ changes.push(
180
+ new CreateCommentOnProcedure({ procedure: branchProcedure }),
181
+ );
182
+ }
183
+ }
172
184
  } else {
173
185
  // Only alterable properties changed - check each one
174
186
 
@@ -492,6 +492,20 @@ describe.concurrent("table", () => {
492
492
  "ALTER TABLE public.test_table ALTER COLUMN a SET DEFAULT 0",
493
493
  );
494
494
 
495
+ const changeSetGeneratedExpression = new AlterTableAlterColumnSetDefault({
496
+ table: withCols,
497
+ column: {
498
+ ...colText,
499
+ name: "computed_name",
500
+ is_generated: true,
501
+ default: "lower((b))",
502
+ },
503
+ });
504
+ await assertValidSql(changeSetGeneratedExpression.serialize());
505
+ expect(changeSetGeneratedExpression.serialize()).toBe(
506
+ "ALTER TABLE public.test_table ALTER COLUMN computed_name SET EXPRESSION AS (lower((b)))",
507
+ );
508
+
495
509
  const changeDropDefault = new AlterTableAlterColumnDropDefault({
496
510
  table: withCols,
497
511
  column: { ...colInt, default: null },
@@ -644,6 +644,9 @@ export class AlterTableAlterColumnSetDefault extends AlterTableChange {
644
644
 
645
645
  serialize(_options?: SerializeOptions): string {
646
646
  const set = this.column.is_generated ? "SET EXPRESSION AS" : "SET DEFAULT";
647
+ const value = this.column.is_generated
648
+ ? `(${this.column.default ?? "NULL"})`
649
+ : (this.column.default ?? "NULL");
647
650
 
648
651
  return [
649
652
  "ALTER TABLE",
@@ -651,7 +654,7 @@ export class AlterTableAlterColumnSetDefault extends AlterTableChange {
651
654
  "ALTER COLUMN",
652
655
  this.column.name,
653
656
  set,
654
- this.column.default ?? "NULL",
657
+ value,
655
658
  ].join(" ");
656
659
  }
657
660
  }
@@ -835,6 +835,61 @@ describe.concurrent("table.diff", () => {
835
835
  ).toBe(true);
836
836
  });
837
837
 
838
+ test("postgres 17+ recreates a column when switching from regular to generated", () => {
839
+ const pg17Context = {
840
+ ...testContext,
841
+ version: 170000,
842
+ };
843
+
844
+ const regularColumn = {
845
+ name: "confirmed_at",
846
+ position: 1,
847
+ data_type: "timestamp with time zone",
848
+ data_type_str: "timestamp with time zone",
849
+ is_custom_type: false,
850
+ custom_type_type: null,
851
+ custom_type_category: null,
852
+ custom_type_schema: null,
853
+ custom_type_name: null,
854
+ not_null: false,
855
+ is_identity: false,
856
+ is_identity_always: false,
857
+ is_generated: false,
858
+ collation: null,
859
+ default: null,
860
+ comment: null,
861
+ };
862
+
863
+ const generatedColumn = {
864
+ ...regularColumn,
865
+ is_generated: true,
866
+ default: "LEAST(email_confirmed_at, phone_confirmed_at)",
867
+ };
868
+
869
+ const mainTable = new Table({
870
+ ...base,
871
+ name: "auth_users_like",
872
+ columns: [regularColumn],
873
+ });
874
+ const branchTable = new Table({
875
+ ...base,
876
+ name: "auth_users_like",
877
+ columns: [generatedColumn],
878
+ });
879
+
880
+ const changes = diffTables(
881
+ pg17Context,
882
+ { [mainTable.stableId]: mainTable },
883
+ { [branchTable.stableId]: branchTable },
884
+ );
885
+
886
+ expect(changes.some((c) => c instanceof AlterTableDropColumn)).toBe(true);
887
+ expect(changes.some((c) => c instanceof AlterTableAddColumn)).toBe(true);
888
+ expect(
889
+ changes.some((c) => c instanceof AlterTableAlterColumnSetDefault),
890
+ ).toBe(false);
891
+ });
892
+
838
893
  test("created table with privileges emits grant changes", () => {
839
894
  const t = new Table({
840
895
  ...base,
@@ -745,10 +745,18 @@ export function diffTables(
745
745
  // Set new default value
746
746
  const isGeneratedColumn = branchCol.is_generated;
747
747
  const isPostgresLowerThan17 = ctx.version < 170000;
748
+ const generatedStatusChanged =
749
+ mainCol.is_generated !== branchCol.is_generated;
748
750
 
749
- if (isGeneratedColumn && isPostgresLowerThan17) {
751
+ if (
752
+ isGeneratedColumn &&
753
+ (isPostgresLowerThan17 || generatedStatusChanged)
754
+ ) {
750
755
  // For generated columns in < PostgreSQL 17, we need to drop and recreate
751
- // instead of using SET EXPRESSION AS for computed columns
756
+ // instead of using SET EXPRESSION AS for computed columns. We also
757
+ // need to recreate the column when switching between regular and
758
+ // generated states because SET EXPRESSION only applies to existing
759
+ // generated columns.
752
760
  // cf: https://git.postgresql.org/gitweb/?p=postgresql.git;a=commitdiff;h=5d06e99a3
753
761
  // cf: https://www.postgresql.org/docs/release/17.0/
754
762
  // > Allow ALTER TABLE to change a column's generation expression
@@ -10,15 +10,12 @@ import { createEmptyCatalog, extractCatalog } from "../catalog.model.ts";
10
10
  import type { Change } from "../change.types.ts";
11
11
  import type { DiffContext } from "../context.ts";
12
12
  import { buildPlanScopeFingerprint, hashStableIds } from "../fingerprint.ts";
13
+ import type { FilterDSL } from "../integrations/filter/dsl.ts";
13
14
  import {
14
- compileFilterDSL,
15
- type FilterDSL,
16
- } from "../integrations/filter/dsl.ts";
17
- import type { Integration } from "../integrations/integration.types.ts";
18
- import {
19
- compileSerializeDSL,
20
- type SerializeDSL,
21
- } from "../integrations/serialize/dsl.ts";
15
+ type ResolvedIntegration,
16
+ resolveIntegration,
17
+ } from "../integrations/integration.types.ts";
18
+ import type { SerializeDSL } from "../integrations/serialize/dsl.ts";
22
19
  import { createManagedPool, endPool } from "../postgres-config.ts";
23
20
  import { sortChanges } from "../sort/sort-changes.ts";
24
21
  import type { PgDependRow } from "../sort/types.ts";
@@ -155,23 +152,10 @@ function buildPlanForCatalogs(
155
152
  : undefined;
156
153
 
157
154
  // Build final integration: compile DSL if needed, use functions directly otherwise
158
- let finalIntegration: Integration | undefined;
159
- if (filterOption || serializeOption) {
160
- finalIntegration = {
161
- filter:
162
- typeof filterOption === "function"
163
- ? filterOption
164
- : filterDSL
165
- ? compileFilterDSL(filterDSL)
166
- : undefined,
167
- serialize:
168
- typeof serializeOption === "function"
169
- ? serializeOption
170
- : serializeDSL
171
- ? compileSerializeDSL(serializeDSL)
172
- : undefined,
173
- };
174
- }
155
+ const finalIntegration = resolveIntegration({
156
+ filter: filterOption,
157
+ serialize: serializeOption,
158
+ });
175
159
 
176
160
  // Use filter from final integration
177
161
  const filterFn = finalIntegration?.filter;
@@ -317,7 +301,7 @@ function buildPlan(
317
301
  options?: CreatePlanOptions,
318
302
  filterDSL?: FilterDSL,
319
303
  serializeDSL?: SerializeDSL,
320
- integration?: Integration,
304
+ integration?: ResolvedIntegration,
321
305
  ): Plan {
322
306
  const role = options?.role;
323
307
  const statements = generateStatements(changes, {
@@ -350,7 +334,7 @@ function buildPlan(
350
334
  function generateStatements(
351
335
  changes: Change[],
352
336
  options?: {
353
- integration?: Integration;
337
+ integration?: ResolvedIntegration;
354
338
  role?: string;
355
339
  },
356
340
  ): string[] {
@@ -4,10 +4,7 @@
4
4
 
5
5
  import z from "zod";
6
6
  import type { Change } from "../change.types.ts";
7
- import type { FilterDSL } from "../integrations/filter/dsl.ts";
8
- import type { ChangeFilter } from "../integrations/filter/filter.types.ts";
9
- import type { SerializeDSL } from "../integrations/serialize/dsl.ts";
10
- import type { ChangeSerializer } from "../integrations/serialize/serialize.types.ts";
7
+ import type { Integration } from "../integrations/integration.types.ts";
11
8
 
12
9
  // ============================================================================
13
10
  // Core Types
@@ -157,9 +154,9 @@ export type Plan = z.infer<typeof PlanSchema>;
157
154
  */
158
155
  export interface CreatePlanOptions {
159
156
  /** Filter - either FilterDSL (stored in plan) or ChangeFilter function (not stored) */
160
- filter?: FilterDSL | ChangeFilter;
157
+ filter?: Integration["filter"];
161
158
  /** Serialize - either SerializeDSL (stored in plan) or ChangeSerializer function (not stored) */
162
- serialize?: SerializeDSL | ChangeSerializer;
159
+ serialize?: Integration["serialize"];
163
160
  /** Role to use when executing the migration (SET ROLE will be added to statements) */
164
161
  role?: string;
165
162
  /**