@supabase/pg-delta 1.0.0-alpha.29 → 1.0.0-alpha.30

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 (65) hide show
  1. package/dist/cli/bin/cli.js +8 -1
  2. package/dist/cli/commands/plan.js +33 -1
  3. package/dist/cli/utils.d.ts +3 -0
  4. package/dist/cli/utils.js +6 -3
  5. package/dist/core/objects/base.change.d.ts +39 -2
  6. package/dist/core/objects/base.change.js +32 -3
  7. package/dist/core/objects/subscription/changes/subscription.alter.d.ts +1 -0
  8. package/dist/core/objects/subscription/changes/subscription.alter.js +5 -0
  9. package/dist/core/objects/subscription/changes/subscription.create.js +10 -1
  10. package/dist/core/objects/subscription/changes/subscription.drop.d.ts +1 -0
  11. package/dist/core/objects/subscription/changes/subscription.drop.js +5 -0
  12. package/dist/core/objects/type/enum/changes/enum.alter.d.ts +1 -0
  13. package/dist/core/objects/type/enum/changes/enum.alter.js +4 -0
  14. package/dist/core/plan/apply.d.ts +10 -1
  15. package/dist/core/plan/apply.js +64 -29
  16. package/dist/core/plan/create.js +5 -31
  17. package/dist/core/plan/execution.d.ts +21 -0
  18. package/dist/core/plan/execution.js +76 -0
  19. package/dist/core/plan/index.d.ts +1 -1
  20. package/dist/core/plan/index.js +1 -1
  21. package/dist/core/plan/io.d.ts +2 -1
  22. package/dist/core/plan/io.js +4 -2
  23. package/dist/core/plan/normalize.d.ts +11 -0
  24. package/dist/core/plan/normalize.js +33 -0
  25. package/dist/core/plan/render.d.ts +32 -0
  26. package/dist/core/plan/render.js +104 -0
  27. package/dist/core/plan/types.d.ts +47 -3
  28. package/dist/core/plan/types.js +18 -1
  29. package/dist/core/sort/sort-changes.js +5 -4
  30. package/dist/core/sort/unorderable-cycle-error.d.ts +18 -0
  31. package/dist/core/sort/unorderable-cycle-error.js +20 -0
  32. package/dist/index.d.ts +5 -1
  33. package/dist/index.js +2 -1
  34. package/package.json +1 -1
  35. package/src/cli/bin/cli.ts +9 -1
  36. package/src/cli/commands/plan.ts +47 -1
  37. package/src/cli/utils.ts +21 -5
  38. package/src/core/catalog.snapshot.test.ts +2 -1
  39. package/src/core/objects/base.change.ts +44 -3
  40. package/src/core/objects/subscription/changes/subscription.alter.ts +6 -0
  41. package/src/core/objects/subscription/changes/subscription.create.test.ts +4 -1
  42. package/src/core/objects/subscription/changes/subscription.create.ts +10 -1
  43. package/src/core/objects/subscription/changes/subscription.drop.ts +6 -0
  44. package/src/core/objects/subscription/changes/subscription.traits.test.ts +83 -0
  45. package/src/core/objects/type/enum/changes/enum.alter.ts +5 -0
  46. package/src/core/plan/apply.ts +84 -39
  47. package/src/core/plan/create.ts +5 -46
  48. package/src/core/plan/execution.test.ts +231 -0
  49. package/src/core/plan/execution.ts +115 -0
  50. package/src/core/plan/index.ts +1 -1
  51. package/src/core/plan/io.ts +4 -2
  52. package/src/core/plan/normalize.test.ts +69 -0
  53. package/src/core/plan/normalize.ts +40 -0
  54. package/src/core/plan/render.test.ts +134 -0
  55. package/src/core/plan/render.ts +153 -0
  56. package/src/core/plan/sql-format/format-off.test.ts +1 -1
  57. package/src/core/plan/sql-format/format-pretty-lower-leading.test.ts +1 -0
  58. package/src/core/plan/sql-format/format-pretty-narrow.test.ts +2 -1
  59. package/src/core/plan/sql-format/format-pretty-preserve.test.ts +2 -1
  60. package/src/core/plan/sql-format/format-pretty-upper.test.ts +2 -1
  61. package/src/core/plan/types.ts +63 -3
  62. package/src/core/sort/sort-changes.ts +9 -4
  63. package/src/core/sort/unorderable-cycle-error.test.ts +60 -0
  64. package/src/core/sort/unorderable-cycle-error.ts +23 -0
  65. package/src/index.ts +18 -1
package/src/cli/utils.ts CHANGED
@@ -9,9 +9,9 @@ import type { Change } from "../core/change.types.ts";
9
9
  import type { DiffContext } from "../core/context.ts";
10
10
  import { groupChangesHierarchically } from "../core/plan/hierarchy.ts";
11
11
  import { type Plan, serializePlan } from "../core/plan/index.ts";
12
+ import { renderPlanSql } from "../core/plan/render.ts";
12
13
  import { classifyChangesRisk } from "../core/plan/risk.ts";
13
14
  import type { SqlFormatOptions } from "../core/plan/sql-format.ts";
14
- import { formatSqlScript } from "../core/plan/statements.ts";
15
15
  import { formatTree } from "./formatters/index.ts";
16
16
 
17
17
  // Re-export ApplyPlanResult type for convenience
@@ -19,8 +19,19 @@ type ApplyPlanResult =
19
19
  | { status: "invalid_plan"; message: string }
20
20
  | { status: "fingerprint_mismatch"; current: string; expected: string }
21
21
  | { status: "already_applied" }
22
- | { status: "applied"; statements: number; warnings?: string[] }
23
- | { status: "failed"; error: unknown; script: string };
22
+ | {
23
+ status: "applied";
24
+ statements: number;
25
+ units: number;
26
+ warnings?: string[];
27
+ }
28
+ | {
29
+ status: "failed";
30
+ error: unknown;
31
+ script: string;
32
+ failedUnitIndex?: number;
33
+ completedUnits: number;
34
+ };
24
35
 
25
36
  type PlanResult = {
26
37
  plan: Plan;
@@ -50,7 +61,7 @@ export function formatPlanForDisplay(
50
61
  case "sql": {
51
62
  const content = [
52
63
  `-- Risk: ${risk.level === "data_loss" ? `data-loss (${risk.statements.length})` : "safe"}`,
53
- formatSqlScript(plan.statements, options.sqlFormatOptions),
64
+ renderPlanSql(plan, { sqlFormatOptions: options.sqlFormatOptions }),
54
65
  ].join("\n");
55
66
  return { content, label: "Migration script" };
56
67
  }
@@ -184,12 +195,17 @@ export function handleApplyResult(
184
195
  context.process.stderr.write(
185
196
  `Failed to apply changes: ${result.error instanceof Error ? result.error.message : String(result.error)}\n`,
186
197
  );
198
+ if (result.failedUnitIndex !== undefined) {
199
+ context.process.stderr.write(
200
+ `Failed in migration unit ${result.failedUnitIndex + 1} (${result.completedUnits} unit${result.completedUnits === 1 ? "" : "s"} already committed).\n`,
201
+ );
202
+ }
187
203
  context.process.stderr.write(`Migration script:\n${result.script}\n`);
188
204
  return { exitCode: 1 };
189
205
  }
190
206
  case "applied": {
191
207
  context.process.stdout.write(
192
- `Applying ${result.statements} changes to database...\n`,
208
+ `Applied ${result.statements} change${result.statements === 1 ? "" : "s"} across ${result.units} migration unit${result.units === 1 ? "" : "s"}.\n`,
193
209
  );
194
210
  context.process.stdout.write("Successfully applied all changes.\n");
195
211
  if (result.warnings?.length) {
@@ -12,6 +12,7 @@ import { Role } from "./objects/role/role.model.ts";
12
12
  import { Schema } from "./objects/schema/schema.model.ts";
13
13
  import { Sequence } from "./objects/sequence/sequence.model.ts";
14
14
  import { Table } from "./objects/table/table.model.ts";
15
+ import { flattenPlanStatements } from "./plan/render.ts";
15
16
 
16
17
  function emptyCatalogProps() {
17
18
  return {
@@ -330,7 +331,7 @@ describe("catalog snapshot serde", () => {
330
331
 
331
332
  const result = await createPlan(null, target);
332
333
  expect(result).not.toBeNull();
333
- expect(result?.plan.statements.length).toBeGreaterThan(0);
334
+ expect(flattenPlanStatements(result!.plan).length).toBeGreaterThan(0);
334
335
  });
335
336
 
336
337
  test("createPlan with filter DSL without cascade keeps dependents of excluded changes", async () => {
@@ -2,6 +2,17 @@ import type { SerializeOptions } from "../integrations/serialize/serialize.types
2
2
 
3
3
  type ChangeOperation = "create" | "alter" | "drop";
4
4
 
5
+ /**
6
+ * Kinds of commit-visibility boundaries a change can force.
7
+ *
8
+ * Each kind names a PostgreSQL behavior where a statement's effects only
9
+ * become usable by later statements after the enclosing transaction commits.
10
+ * The token becomes the `reason` of the migration unit that follows the
11
+ * producer run, so adding a kind requires a matching arm in the renderer's
12
+ * `unitName` switch (the exhaustive switch enforces this at compile time).
13
+ */
14
+ export type CommitBoundaryReason = "enum_value_visibility";
15
+
5
16
  /**
6
17
  * Abstract base class for all change objects.
7
18
  *
@@ -27,10 +38,40 @@ export abstract class BaseChange {
27
38
  abstract readonly scope: string;
28
39
 
29
40
  /**
30
- * A unique identifier for the change.
41
+ * True when the serialized statement cannot run inside a transaction block
42
+ * (PostgreSQL rejects it with SQLSTATE 25001, e.g. `CREATE INDEX
43
+ * CONCURRENTLY`, `CREATE SUBSCRIPTION` with `connect = true`).
44
+ *
45
+ * The planner emits such a change as its own single-statement migration
46
+ * unit with `transactionMode: "none"`, and `applyPlan` executes it without
47
+ * a `BEGIN`/`COMMIT` wrapper. Never derive this from the rendered SQL —
48
+ * declare it on the change class.
49
+ *
50
+ * Defaults to false. Override in subclasses whose statement PostgreSQL
51
+ * forbids inside a transaction block.
52
+ */
53
+ get nonTransactional(): boolean {
54
+ return false;
55
+ }
56
+
57
+ /**
58
+ * Non-null when this statement's effects only become usable by later
59
+ * statements after the enclosing transaction commits. The canonical case is
60
+ * `ALTER TYPE ... ADD VALUE`: using the new enum value in the same
61
+ * transaction fails with 55P04.
62
+ *
63
+ * This is a conservative boundary signal: the planner groups consecutive
64
+ * producers of the same kind into one unit, and pushes any other statement
65
+ * (different kind or non-producer) past a commit boundary, regardless of
66
+ * whether it references the produced effects. No consumer detection is
67
+ * attempted. Never derive this from the rendered SQL — declare it on the
68
+ * change class.
69
+ *
70
+ * Defaults to null. Override in subclasses whose effects PostgreSQL defers
71
+ * until commit.
31
72
  */
32
- get changeId(): string {
33
- return `${this.operation}:${this.scope}:${this.objectType}:${this.serialize()}`;
73
+ get commitBoundary(): CommitBoundaryReason | null {
74
+ return null;
34
75
  }
35
76
 
36
77
  /**
@@ -31,6 +31,12 @@ export class AlterSubscriptionSetPublication extends AlterSubscriptionChange {
31
31
  this.subscription = props.subscription;
32
32
  }
33
33
 
34
+ // When the subscription is enabled, serialize() keeps the refresh = true
35
+ // default, which PostgreSQL rejects inside a transaction block.
36
+ override get nonTransactional() {
37
+ return this.subscription.enabled;
38
+ }
39
+
34
40
  serialize(_options?: SerializeOptions): string {
35
41
  const base = `ALTER SUBSCRIPTION ${this.subscription.name} SET PUBLICATION ${this.subscription.publications.join(", ")}`;
36
42
  if (!this.subscription.enabled) {
@@ -45,8 +45,11 @@ describe("subscription.create", () => {
45
45
  expect(change.creates).toEqual([subscription.stableId]);
46
46
  expect(change.requires).toEqual([stableId.role(subscription.owner)]);
47
47
  await assertValidSql(change.serialize());
48
+ // The slot already exists on the publisher, so the statement must reuse
49
+ // it: create_slot defaults to true and would fail with "replication slot
50
+ // already exists" (and 25001 inside a transaction block).
48
51
  expect(change.serialize()).toBe(
49
- "CREATE SUBSCRIPTION sub_base CONNECTION 'host=example dbname=postgres' PUBLICATION pub_base",
52
+ "CREATE SUBSCRIPTION sub_base CONNECTION 'host=example dbname=postgres' PUBLICATION pub_base WITH (create_slot = false)",
50
53
  );
51
54
  });
52
55
 
@@ -23,6 +23,11 @@ export class CreateSubscription extends CreateSubscriptionChange {
23
23
  return [stableId.role(this.subscription.owner)];
24
24
  }
25
25
 
26
+ // No nonTransactional override: PostgreSQL's transaction-block gate for
27
+ // CREATE SUBSCRIPTION is on create_slot = true, and serialize() always
28
+ // emits create_slot = false (either reusing an existing slot or skipping
29
+ // the connect entirely).
30
+
26
31
  serialize(_options?: SerializeOptions): string {
27
32
  const parts: string[] = [
28
33
  "CREATE SUBSCRIPTION",
@@ -41,7 +46,11 @@ export class CreateSubscription extends CreateSubscriptionChange {
41
46
  optionEntries.map(({ key, value }) => [key, value]),
42
47
  );
43
48
 
44
- if (!this.subscription.replication_slot_created) {
49
+ if (this.subscription.replication_slot_created) {
50
+ // The slot already exists on the publisher: keep the connect = true
51
+ // default so it is looked up, but never recreated.
52
+ optionsMap.set("create_slot", "false");
53
+ } else {
45
54
  optionsMap.set("create_slot", "false");
46
55
  optionsMap.set("connect", "false");
47
56
 
@@ -15,6 +15,12 @@ export class DropSubscription extends DropSubscriptionChange {
15
15
  return [this.subscription.stableId];
16
16
  }
17
17
 
18
+ // PostgreSQL forbids DROP SUBSCRIPTION inside a transaction block when a
19
+ // replication slot is associated with the subscription.
20
+ override get nonTransactional() {
21
+ return !this.subscription.slot_is_none;
22
+ }
23
+
18
24
  serialize(_options?: SerializeOptions): string {
19
25
  return `DROP SUBSCRIPTION ${this.subscription.name}`;
20
26
  }
@@ -0,0 +1,83 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { Subscription, SubscriptionProps } from "../subscription.model.ts";
3
+ import { Subscription as SubscriptionModel } from "../subscription.model.ts";
4
+ import { AlterSubscriptionSetPublication } from "./subscription.alter.ts";
5
+ import { CreateSubscription } from "./subscription.create.ts";
6
+ import { DropSubscription } from "./subscription.drop.ts";
7
+
8
+ function makeSubscription(
9
+ overrides: Partial<SubscriptionProps> = {},
10
+ ): Subscription {
11
+ return new SubscriptionModel({
12
+ name: "sub_orders",
13
+ raw_name: "sub_orders",
14
+ owner: "postgres",
15
+ comment: null,
16
+ enabled: false,
17
+ binary: false,
18
+ streaming: "off",
19
+ two_phase: false,
20
+ disable_on_error: false,
21
+ password_required: true,
22
+ run_as_owner: false,
23
+ failover: false,
24
+ conninfo: "host=publisher dbname=app",
25
+ slot_name: "sub_orders",
26
+ slot_is_none: false,
27
+ replication_slot_created: false,
28
+ synchronous_commit: "off",
29
+ publications: ["pub_orders"],
30
+ origin: "any",
31
+ ...overrides,
32
+ });
33
+ }
34
+
35
+ describe("subscription transaction-block traits", () => {
36
+ test("CREATE SUBSCRIPTION is always transactional", () => {
37
+ // PostgreSQL's transaction-block gate is on create_slot = true, and
38
+ // serialize() always emits create_slot = false: connect stays true when
39
+ // the slot already exists (it is reused, never recreated), and connect
40
+ // is false otherwise.
41
+ const withSlot = new CreateSubscription({
42
+ subscription: makeSubscription({ replication_slot_created: true }),
43
+ });
44
+ expect(withSlot.nonTransactional).toBe(false);
45
+ expect(withSlot.serialize()).toContain("create_slot = false");
46
+
47
+ const withoutSlot = new CreateSubscription({
48
+ subscription: makeSubscription({ replication_slot_created: false }),
49
+ });
50
+ expect(withoutSlot.nonTransactional).toBe(false);
51
+ expect(withoutSlot.serialize()).toContain("create_slot = false");
52
+ });
53
+
54
+ test("ALTER SUBSCRIPTION SET PUBLICATION with implicit refresh cannot run in a transaction block", () => {
55
+ // serialize() omits WITH (refresh = false) when the subscription is
56
+ // enabled, and refresh = true is rejected inside a transaction block.
57
+ const change = new AlterSubscriptionSetPublication({
58
+ subscription: makeSubscription({ enabled: true }),
59
+ });
60
+ expect(change.nonTransactional).toBe(true);
61
+ });
62
+
63
+ test("ALTER SUBSCRIPTION SET PUBLICATION with refresh = false is transactional", () => {
64
+ const change = new AlterSubscriptionSetPublication({
65
+ subscription: makeSubscription({ enabled: false }),
66
+ });
67
+ expect(change.nonTransactional).toBe(false);
68
+ });
69
+
70
+ test("DROP SUBSCRIPTION with an associated slot cannot run in a transaction block", () => {
71
+ const change = new DropSubscription({
72
+ subscription: makeSubscription({ slot_is_none: false }),
73
+ });
74
+ expect(change.nonTransactional).toBe(true);
75
+ });
76
+
77
+ test("DROP SUBSCRIPTION with slot_name = NONE is transactional", () => {
78
+ const change = new DropSubscription({
79
+ subscription: makeSubscription({ slot_is_none: true, slot_name: null }),
80
+ });
81
+ expect(change.nonTransactional).toBe(false);
82
+ });
83
+ });
@@ -71,6 +71,11 @@ export class AlterEnumAddValue extends AlterEnumChange {
71
71
  return [this.enum.stableId];
72
72
  }
73
73
 
74
+ // New enum values are not usable until the transaction commits (55P04).
75
+ override get commitBoundary() {
76
+ return "enum_value_visibility" as const;
77
+ }
78
+
74
79
  serialize(_options?: SerializeOptions): string {
75
80
  const parts = [
76
81
  "ALTER TYPE",
@@ -2,7 +2,7 @@
2
2
  * Plan application - execute migration plans against target databases.
3
3
  */
4
4
 
5
- import type { Pool } from "pg";
5
+ import type { Pool, PoolClient } from "pg";
6
6
  import { diffCatalogs } from "../catalog.diff.ts";
7
7
  import { extractCatalog } from "../catalog.model.ts";
8
8
  import type { DiffContext } from "../context.ts";
@@ -10,14 +10,29 @@ import { buildPlanScopeFingerprint, hashStableIds } from "../fingerprint.ts";
10
10
  import { compileFilterDSL } from "../integrations/filter/dsl.ts";
11
11
  import { createManagedPool, endPool } from "../postgres-config.ts";
12
12
  import { sortChanges } from "../sort/sort-changes.ts";
13
- import type { Plan } from "./types.ts";
13
+ import { normalizePlan } from "./normalize.ts";
14
+ import { renderPlanSql } from "./render.ts";
15
+ import type { MigrationUnit, Plan } from "./types.ts";
14
16
 
15
17
  type ApplyPlanResult =
16
18
  | { status: "invalid_plan"; message: string }
17
19
  | { status: "fingerprint_mismatch"; current: string; expected: string }
18
20
  | { status: "already_applied" }
19
- | { status: "applied"; statements: number; warnings?: string[] }
20
- | { status: "failed"; error: unknown; script: string };
21
+ | {
22
+ status: "applied";
23
+ statements: number;
24
+ units: number;
25
+ warnings?: string[];
26
+ }
27
+ | {
28
+ status: "failed";
29
+ error: unknown;
30
+ script: string;
31
+ /** 0-based index of the unit that failed; undefined if a session statement failed. */
32
+ failedUnitIndex?: number;
33
+ /** Number of units fully committed before the failure. */
34
+ completedUnits: number;
35
+ };
21
36
 
22
37
  interface ApplyPlanOptions {
23
38
  verifyPostApply?: boolean;
@@ -26,25 +41,22 @@ interface ApplyPlanOptions {
26
41
  type ConnectionInput = string | Pool;
27
42
 
28
43
  /**
29
- * Check if a statement is a session configuration statement (standalone SET statements).
30
- * These statements should not be counted as changes.
31
- */
32
- function isSessionStatement(statement: string): boolean {
33
- return statement.trim().startsWith("SET ");
34
- }
35
-
36
- /**
37
- * Apply a plan's SQL statements to a target database with integrity checks.
44
+ * Apply a plan's migration units to a target database with integrity checks.
38
45
  * Validates fingerprints before and after application to ensure plan integrity.
46
+ *
47
+ * Units are applied in order on a single session: transactional units inside
48
+ * an explicit BEGIN/COMMIT, non-transactional units without a wrapper. A
49
+ * failure does not roll back units that already committed.
39
50
  */
40
-
41
51
  export async function applyPlan(
42
52
  plan: Plan,
43
53
  source: ConnectionInput,
44
54
  target: ConnectionInput,
45
55
  options: ApplyPlanOptions = {},
46
56
  ): Promise<ApplyPlanResult> {
47
- if (!plan.statements || plan.statements.length === 0) {
57
+ const normalizedPlan = normalizePlan(plan);
58
+ const units = normalizedPlan.units;
59
+ if (units.length === 0) {
48
60
  return {
49
61
  status: "invalid_plan",
50
62
  message: "Plan contains no SQL statements to execute.",
@@ -58,7 +70,7 @@ export async function applyPlan(
58
70
 
59
71
  if (typeof source === "string") {
60
72
  const managed = await createManagedPool(source, {
61
- role: plan.role,
73
+ role: normalizedPlan.role,
62
74
  label: "source",
63
75
  });
64
76
  currentPool = managed.pool;
@@ -69,7 +81,7 @@ export async function applyPlan(
69
81
 
70
82
  if (typeof target === "string") {
71
83
  const managed = await createManagedPool(target, {
72
- role: plan.role,
84
+ role: normalizedPlan.role,
73
85
  label: "target",
74
86
  });
75
87
  desiredPool = managed.pool;
@@ -93,8 +105,8 @@ export async function applyPlan(
93
105
 
94
106
  // Apply the same filter that was used to create the plan (if any)
95
107
  let filteredChanges = changes;
96
- if (plan.filter) {
97
- const filterFn = compileFilterDSL(plan.filter);
108
+ if (normalizedPlan.filter) {
109
+ const filterFn = compileFilterDSL(normalizedPlan.filter);
98
110
  filteredChanges = filteredChanges.filter((change) => filterFn(change));
99
111
  }
100
112
 
@@ -106,31 +118,44 @@ export async function applyPlan(
106
118
  // We intentionally recompute target fingerprint only after applying.
107
119
 
108
120
  // Pre-apply fingerprint validation
109
- if (fingerprintFrom === plan.target.fingerprint) {
121
+ if (fingerprintFrom === normalizedPlan.target.fingerprint) {
110
122
  return { status: "already_applied" };
111
123
  }
112
124
 
113
- if (fingerprintFrom !== plan.source.fingerprint) {
125
+ if (fingerprintFrom !== normalizedPlan.source.fingerprint) {
114
126
  return {
115
127
  status: "fingerprint_mismatch",
116
128
  current: fingerprintFrom,
117
- expected: plan.source.fingerprint,
129
+ expected: normalizedPlan.source.fingerprint,
118
130
  };
119
131
  }
120
132
 
121
- // Execute the SQL script
122
- // TODO: mark statements that can't be run within a transaction
123
- const statements = plan.statements;
124
-
125
- const script = (() => {
126
- const joined = statements.join(";\n");
127
- return joined.endsWith(";") ? joined : `${joined};`;
128
- })();
133
+ const script = renderPlanSql(normalizedPlan);
134
+ let completedUnits = 0;
135
+ let unitStarted = false;
129
136
 
137
+ // A single session for the whole plan: session statements (SET ROLE,
138
+ // SET check_function_bodies) must stay in effect across all units.
139
+ const client = await currentPool.connect();
130
140
  try {
131
- await currentPool.query(script);
141
+ for (const statement of normalizedPlan.sessionStatements) {
142
+ await client.query(statement);
143
+ }
144
+ for (const unit of units) {
145
+ unitStarted = true;
146
+ await applyUnit(client, unit);
147
+ completedUnits++;
148
+ }
132
149
  } catch (error) {
133
- return { status: "failed", error, script };
150
+ return {
151
+ status: "failed",
152
+ error,
153
+ script,
154
+ failedUnitIndex: unitStarted ? completedUnits : undefined,
155
+ completedUnits,
156
+ };
157
+ } finally {
158
+ client.release();
134
159
  }
135
160
 
136
161
  const warnings: string[] = [];
@@ -139,7 +164,7 @@ export async function applyPlan(
139
164
  try {
140
165
  const updatedCatalog = await extractCatalog(currentPool);
141
166
  const updatedFingerprint = hashStableIds(updatedCatalog, stableIds);
142
- if (updatedFingerprint !== plan.target.fingerprint) {
167
+ if (updatedFingerprint !== normalizedPlan.target.fingerprint) {
143
168
  warnings.push(
144
169
  "Post-apply fingerprint does not match the plan target fingerprint.",
145
170
  );
@@ -151,14 +176,11 @@ export async function applyPlan(
151
176
  }
152
177
  }
153
178
 
154
- // Count only actual changes, excluding session configuration statements
155
- const changeStatements = statements.filter(
156
- (stmt) => !isSessionStatement(stmt),
157
- );
158
-
159
179
  return {
160
180
  status: "applied",
161
- statements: changeStatements.length,
181
+ // Units contain only change statements; session statements are not counted.
182
+ statements: units.reduce((sum, unit) => sum + unit.statements.length, 0),
183
+ units: units.length,
162
184
  warnings: warnings.length ? warnings : undefined,
163
185
  };
164
186
  } finally {
@@ -170,3 +192,26 @@ export async function applyPlan(
170
192
  }
171
193
  }
172
194
  }
195
+
196
+ async function applyUnit(
197
+ client: PoolClient,
198
+ unit: MigrationUnit,
199
+ ): Promise<void> {
200
+ if (unit.transactionMode === "transactional") {
201
+ await client.query("BEGIN");
202
+ try {
203
+ for (const statement of unit.statements) {
204
+ await client.query(statement);
205
+ }
206
+ await client.query("COMMIT");
207
+ } catch (error) {
208
+ await client.query("ROLLBACK").catch(() => {});
209
+ throw error;
210
+ }
211
+ return;
212
+ }
213
+
214
+ for (const statement of unit.statements) {
215
+ await client.query(statement);
216
+ }
217
+ }
@@ -3,7 +3,6 @@
3
3
  */
4
4
 
5
5
  import type { Pool } from "pg";
6
- import { escapeIdentifier } from "pg";
7
6
  import { diffCatalogs } from "../catalog.diff.ts";
8
7
  import type { Catalog } from "../catalog.model.ts";
9
8
  import { createEmptyCatalog, extractCatalog } from "../catalog.model.ts";
@@ -19,6 +18,7 @@ import type { SerializeDSL } from "../integrations/serialize/dsl.ts";
19
18
  import { createManagedPool, endPool } from "../postgres-config.ts";
20
19
  import { sortChanges } from "../sort/sort-changes.ts";
21
20
  import type { PgDependRow } from "../sort/types.ts";
21
+ import { buildExecutionPlan } from "./execution.ts";
22
22
  import { classifyChangesRisk } from "./risk.ts";
23
23
  import type { CreatePlanOptions, Plan } from "./types.ts";
24
24
 
@@ -306,10 +306,7 @@ function buildPlan(
306
306
  integration?: ResolvedIntegration,
307
307
  ): Plan {
308
308
  const role = options?.role;
309
- const statements = generateStatements(changes, {
310
- integration,
311
- role,
312
- });
309
+ const execution = buildExecutionPlan(changes, { integration, role });
313
310
  const risk = classifyChangesRisk(changes);
314
311
 
315
312
  const { hash: fingerprintFrom, stableIds } = buildPlanScopeFingerprint(
@@ -319,52 +316,14 @@ function buildPlan(
319
316
  const fingerprintTo = hashStableIds(ctx.branchCatalog, stableIds);
320
317
 
321
318
  return {
322
- version: 1,
319
+ version: 2,
323
320
  source: { fingerprint: fingerprintFrom },
324
321
  target: { fingerprint: fingerprintTo },
325
- statements,
322
+ units: execution.units,
323
+ sessionStatements: execution.sessionStatements,
326
324
  role,
327
325
  filter: filterDSL,
328
326
  serialize: serializeDSL,
329
327
  risk,
330
328
  };
331
329
  }
332
-
333
- /**
334
- * Generate the individual SQL statements that make up the plan.
335
- */
336
- function generateStatements(
337
- changes: Change[],
338
- options?: {
339
- integration?: ResolvedIntegration;
340
- role?: string;
341
- },
342
- ): string[] {
343
- const statements: string[] = [];
344
-
345
- if (options?.role) {
346
- statements.push(`SET ROLE ${escapeIdentifier(options.role)}`);
347
- }
348
-
349
- if (hasRoutineChanges(changes)) {
350
- statements.push("SET check_function_bodies = false");
351
- }
352
-
353
- for (const change of changes) {
354
- const sql = options?.integration?.serialize?.(change) ?? change.serialize();
355
- statements.push(sql);
356
- }
357
-
358
- return statements;
359
- }
360
-
361
- /**
362
- * Check if any changes involve routines (procedures or aggregates).
363
- * Used to determine if we need to disable function body checking.
364
- */
365
- function hasRoutineChanges(changes: Change[]): boolean {
366
- return changes.some(
367
- (change) =>
368
- change.objectType === "procedure" || change.objectType === "aggregate",
369
- );
370
- }