@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
@@ -1,9 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  import { run } from "@stricli/core";
3
+ import { UnorderableCycleError } from "../../core/sort/unorderable-cycle-error.js";
3
4
  import { app } from "../app.js";
4
5
  import { getCommandExitCode } from "../exit-code.js";
5
6
  await run(app, process.argv.slice(2), { process }).catch((error) => {
6
- console.error(error);
7
+ if (error instanceof UnorderableCycleError) {
8
+ console.error(error.message);
9
+ console.error("pg-delta could not find a valid execution order for these changes. Please report this plan at https://github.com/supabase/pg-toolbelt/issues.");
10
+ }
11
+ else {
12
+ console.error(error);
13
+ }
7
14
  process.exit(1);
8
15
  });
9
16
  const code = getCommandExitCode();
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * Plan command - compute schema diff and preview changes.
3
3
  */
4
- import { writeFile } from "node:fs/promises";
4
+ import { mkdir, readdir, writeFile } from "node:fs/promises";
5
+ import path from "node:path";
5
6
  import { buildCommand } from "@stricli/core";
6
7
  import { deserializeCatalog } from "../../core/catalog.snapshot.js";
7
8
  import { createPlan } from "../../core/plan/index.js";
9
+ import { renderPlanFiles } from "../../core/plan/render.js";
8
10
  import { setCommandExitCode } from "../exit-code.js";
9
11
  import { resolveIntegrationOptions } from "../utils/integrations.js";
10
12
  import { isPostgresUrl, loadCatalogFromFile } from "../utils/resolve-input.js";
@@ -35,6 +37,12 @@ export const planCommand = buildCommand({
35
37
  parse: String,
36
38
  optional: true,
37
39
  },
40
+ "output-dir": {
41
+ kind: "parsed",
42
+ brief: "Write numbered SQL migration files to a directory using transaction-aware plan units.",
43
+ parse: String,
44
+ optional: true,
45
+ },
38
46
  role: {
39
47
  kind: "parsed",
40
48
  brief: "Role to use when executing the migration (SET ROLE will be added to statements).",
@@ -131,6 +139,23 @@ json/sql outputs are available for artifacts or piping.
131
139
  this.process.stdout.write("No changes detected.\n");
132
140
  return;
133
141
  }
142
+ if (flags.output && flags["output-dir"]) {
143
+ throw new Error("Use either --output or --output-dir, not both.");
144
+ }
145
+ if (flags["output-dir"]) {
146
+ await prepareOutputDirectory(flags["output-dir"]);
147
+ const files = renderPlanFiles(planResult.plan, {
148
+ sqlFormatOptions: flags["sql-format"] || flags["sql-format-options"]
149
+ ? (flags["sql-format-options"] ?? {})
150
+ : undefined,
151
+ });
152
+ for (const file of files) {
153
+ await writeFile(path.join(flags["output-dir"], file.path), file.sql, "utf-8");
154
+ }
155
+ this.process.stdout.write(`${files.length} migration file${files.length === 1 ? "" : "s"} written to ${flags["output-dir"]}\n`);
156
+ setCommandExitCode(2);
157
+ return;
158
+ }
134
159
  const outputPath = flags.output;
135
160
  let effectiveFormat;
136
161
  if (flags.format) {
@@ -166,3 +191,10 @@ json/sql outputs are available for artifacts or piping.
166
191
  setCommandExitCode(2);
167
192
  },
168
193
  });
194
+ async function prepareOutputDirectory(outputDir) {
195
+ await mkdir(outputDir, { recursive: true });
196
+ const entries = await readdir(outputDir);
197
+ if (entries.length > 0) {
198
+ throw new Error(`Output directory is not empty: ${outputDir}. Choose an empty directory to avoid stale migration files.`);
199
+ }
200
+ }
@@ -18,11 +18,14 @@ type ApplyPlanResult = {
18
18
  } | {
19
19
  status: "applied";
20
20
  statements: number;
21
+ units: number;
21
22
  warnings?: string[];
22
23
  } | {
23
24
  status: "failed";
24
25
  error: unknown;
25
26
  script: string;
27
+ failedUnitIndex?: number;
28
+ completedUnits: number;
26
29
  };
27
30
  type PlanResult = {
28
31
  plan: Plan;
package/dist/cli/utils.js CHANGED
@@ -5,8 +5,8 @@ import { createInterface } from "node:readline";
5
5
  import chalk from "chalk";
6
6
  import { groupChangesHierarchically } from "../core/plan/hierarchy.js";
7
7
  import { serializePlan } from "../core/plan/index.js";
8
+ import { renderPlanSql } from "../core/plan/render.js";
8
9
  import { classifyChangesRisk } from "../core/plan/risk.js";
9
- import { formatSqlScript } from "../core/plan/statements.js";
10
10
  import { formatTree } from "./formatters/index.js";
11
11
  /**
12
12
  * Formats a plan result for display in various formats.
@@ -19,7 +19,7 @@ export function formatPlanForDisplay(planResult, format, options = {}) {
19
19
  case "sql": {
20
20
  const content = [
21
21
  `-- Risk: ${risk.level === "data_loss" ? `data-loss (${risk.statements.length})` : "safe"}`,
22
- formatSqlScript(plan.statements, options.sqlFormatOptions),
22
+ renderPlanSql(plan, { sqlFormatOptions: options.sqlFormatOptions }),
23
23
  ].join("\n");
24
24
  return { content, label: "Migration script" };
25
25
  }
@@ -123,11 +123,14 @@ export function handleApplyResult(result, context) {
123
123
  return { exitCode: 0 };
124
124
  case "failed": {
125
125
  context.process.stderr.write(`Failed to apply changes: ${result.error instanceof Error ? result.error.message : String(result.error)}\n`);
126
+ if (result.failedUnitIndex !== undefined) {
127
+ context.process.stderr.write(`Failed in migration unit ${result.failedUnitIndex + 1} (${result.completedUnits} unit${result.completedUnits === 1 ? "" : "s"} already committed).\n`);
128
+ }
126
129
  context.process.stderr.write(`Migration script:\n${result.script}\n`);
127
130
  return { exitCode: 1 };
128
131
  }
129
132
  case "applied": {
130
- context.process.stdout.write(`Applying ${result.statements} changes to database...\n`);
133
+ context.process.stdout.write(`Applied ${result.statements} change${result.statements === 1 ? "" : "s"} across ${result.units} migration unit${result.units === 1 ? "" : "s"}.\n`);
131
134
  context.process.stdout.write("Successfully applied all changes.\n");
132
135
  if (result.warnings?.length) {
133
136
  for (const warning of result.warnings) {
@@ -1,5 +1,15 @@
1
1
  import type { SerializeOptions } from "../integrations/serialize/serialize.types.ts";
2
2
  type ChangeOperation = "create" | "alter" | "drop";
3
+ /**
4
+ * Kinds of commit-visibility boundaries a change can force.
5
+ *
6
+ * Each kind names a PostgreSQL behavior where a statement's effects only
7
+ * become usable by later statements after the enclosing transaction commits.
8
+ * The token becomes the `reason` of the migration unit that follows the
9
+ * producer run, so adding a kind requires a matching arm in the renderer's
10
+ * `unitName` switch (the exhaustive switch enforces this at compile time).
11
+ */
12
+ export type CommitBoundaryReason = "enum_value_visibility";
3
13
  /**
4
14
  * Abstract base class for all change objects.
5
15
  *
@@ -24,9 +34,36 @@ export declare abstract class BaseChange {
24
34
  */
25
35
  abstract readonly scope: string;
26
36
  /**
27
- * A unique identifier for the change.
37
+ * True when the serialized statement cannot run inside a transaction block
38
+ * (PostgreSQL rejects it with SQLSTATE 25001, e.g. `CREATE INDEX
39
+ * CONCURRENTLY`, `CREATE SUBSCRIPTION` with `connect = true`).
40
+ *
41
+ * The planner emits such a change as its own single-statement migration
42
+ * unit with `transactionMode: "none"`, and `applyPlan` executes it without
43
+ * a `BEGIN`/`COMMIT` wrapper. Never derive this from the rendered SQL —
44
+ * declare it on the change class.
45
+ *
46
+ * Defaults to false. Override in subclasses whose statement PostgreSQL
47
+ * forbids inside a transaction block.
48
+ */
49
+ get nonTransactional(): boolean;
50
+ /**
51
+ * Non-null when this statement's effects only become usable by later
52
+ * statements after the enclosing transaction commits. The canonical case is
53
+ * `ALTER TYPE ... ADD VALUE`: using the new enum value in the same
54
+ * transaction fails with 55P04.
55
+ *
56
+ * This is a conservative boundary signal: the planner groups consecutive
57
+ * producers of the same kind into one unit, and pushes any other statement
58
+ * (different kind or non-producer) past a commit boundary, regardless of
59
+ * whether it references the produced effects. No consumer detection is
60
+ * attempted. Never derive this from the rendered SQL — declare it on the
61
+ * change class.
62
+ *
63
+ * Defaults to null. Override in subclasses whose effects PostgreSQL defers
64
+ * until commit.
28
65
  */
29
- get changeId(): string;
66
+ get commitBoundary(): CommitBoundaryReason | null;
30
67
  /**
31
68
  * Stable identifiers this change creates.
32
69
  *
@@ -10,10 +10,39 @@
10
10
  */
11
11
  export class BaseChange {
12
12
  /**
13
- * A unique identifier for the change.
13
+ * True when the serialized statement cannot run inside a transaction block
14
+ * (PostgreSQL rejects it with SQLSTATE 25001, e.g. `CREATE INDEX
15
+ * CONCURRENTLY`, `CREATE SUBSCRIPTION` with `connect = true`).
16
+ *
17
+ * The planner emits such a change as its own single-statement migration
18
+ * unit with `transactionMode: "none"`, and `applyPlan` executes it without
19
+ * a `BEGIN`/`COMMIT` wrapper. Never derive this from the rendered SQL —
20
+ * declare it on the change class.
21
+ *
22
+ * Defaults to false. Override in subclasses whose statement PostgreSQL
23
+ * forbids inside a transaction block.
24
+ */
25
+ get nonTransactional() {
26
+ return false;
27
+ }
28
+ /**
29
+ * Non-null when this statement's effects only become usable by later
30
+ * statements after the enclosing transaction commits. The canonical case is
31
+ * `ALTER TYPE ... ADD VALUE`: using the new enum value in the same
32
+ * transaction fails with 55P04.
33
+ *
34
+ * This is a conservative boundary signal: the planner groups consecutive
35
+ * producers of the same kind into one unit, and pushes any other statement
36
+ * (different kind or non-producer) past a commit boundary, regardless of
37
+ * whether it references the produced effects. No consumer detection is
38
+ * attempted. Never derive this from the rendered SQL — declare it on the
39
+ * change class.
40
+ *
41
+ * Defaults to null. Override in subclasses whose effects PostgreSQL defers
42
+ * until commit.
14
43
  */
15
- get changeId() {
16
- return `${this.operation}:${this.scope}:${this.objectType}:${this.serialize()}`;
44
+ get commitBoundary() {
45
+ return null;
17
46
  }
18
47
  /**
19
48
  * Stable identifiers this change creates.
@@ -16,6 +16,7 @@ export declare class AlterSubscriptionSetPublication extends AlterSubscriptionCh
16
16
  constructor(props: {
17
17
  subscription: Subscription;
18
18
  });
19
+ get nonTransactional(): boolean;
19
20
  serialize(_options?: SerializeOptions): string;
20
21
  }
21
22
  export declare class AlterSubscriptionEnable extends AlterSubscriptionChange {
@@ -20,6 +20,11 @@ export class AlterSubscriptionSetPublication extends AlterSubscriptionChange {
20
20
  super();
21
21
  this.subscription = props.subscription;
22
22
  }
23
+ // When the subscription is enabled, serialize() keeps the refresh = true
24
+ // default, which PostgreSQL rejects inside a transaction block.
25
+ get nonTransactional() {
26
+ return this.subscription.enabled;
27
+ }
23
28
  serialize(_options) {
24
29
  const base = `ALTER SUBSCRIPTION ${this.subscription.name} SET PUBLICATION ${this.subscription.publications.join(", ")}`;
25
30
  if (!this.subscription.enabled) {
@@ -16,6 +16,10 @@ export class CreateSubscription extends CreateSubscriptionChange {
16
16
  get requires() {
17
17
  return [stableId.role(this.subscription.owner)];
18
18
  }
19
+ // No nonTransactional override: PostgreSQL's transaction-block gate for
20
+ // CREATE SUBSCRIPTION is on create_slot = true, and serialize() always
21
+ // emits create_slot = false (either reusing an existing slot or skipping
22
+ // the connect entirely).
19
23
  serialize(_options) {
20
24
  const parts = [
21
25
  "CREATE SUBSCRIPTION",
@@ -30,7 +34,12 @@ export class CreateSubscription extends CreateSubscriptionChange {
30
34
  includeEnabled: true,
31
35
  });
32
36
  const optionsMap = new Map(optionEntries.map(({ key, value }) => [key, value]));
33
- if (!this.subscription.replication_slot_created) {
37
+ if (this.subscription.replication_slot_created) {
38
+ // The slot already exists on the publisher: keep the connect = true
39
+ // default so it is looked up, but never recreated.
40
+ optionsMap.set("create_slot", "false");
41
+ }
42
+ else {
34
43
  optionsMap.set("create_slot", "false");
35
44
  optionsMap.set("connect", "false");
36
45
  const defaultSlotName = this.subscription.raw_name;
@@ -8,5 +8,6 @@ export declare class DropSubscription extends DropSubscriptionChange {
8
8
  subscription: Subscription;
9
9
  });
10
10
  get drops(): `subscription:${string}`[];
11
+ get nonTransactional(): boolean;
11
12
  serialize(_options?: SerializeOptions): string;
12
13
  }
@@ -9,6 +9,11 @@ export class DropSubscription extends DropSubscriptionChange {
9
9
  get drops() {
10
10
  return [this.subscription.stableId];
11
11
  }
12
+ // PostgreSQL forbids DROP SUBSCRIPTION inside a transaction block when a
13
+ // replication slot is associated with the subscription.
14
+ get nonTransactional() {
15
+ return !this.subscription.slot_is_none;
16
+ }
12
17
  serialize(_options) {
13
18
  return `DROP SUBSCRIPTION ${this.subscription.name}`;
14
19
  }
@@ -49,5 +49,6 @@ export declare class AlterEnumAddValue extends AlterEnumChange {
49
49
  };
50
50
  });
51
51
  get requires(): `type:${string}`[];
52
+ get commitBoundary(): "enum_value_visibility";
52
53
  serialize(_options?: SerializeOptions): string;
53
54
  }
@@ -41,6 +41,10 @@ export class AlterEnumAddValue extends AlterEnumChange {
41
41
  get requires() {
42
42
  return [this.enum.stableId];
43
43
  }
44
+ // New enum values are not usable until the transaction commits (55P04).
45
+ get commitBoundary() {
46
+ return "enum_value_visibility";
47
+ }
44
48
  serialize(_options) {
45
49
  const parts = [
46
50
  "ALTER TYPE",
@@ -15,19 +15,28 @@ type ApplyPlanResult = {
15
15
  } | {
16
16
  status: "applied";
17
17
  statements: number;
18
+ units: number;
18
19
  warnings?: string[];
19
20
  } | {
20
21
  status: "failed";
21
22
  error: unknown;
22
23
  script: string;
24
+ /** 0-based index of the unit that failed; undefined if a session statement failed. */
25
+ failedUnitIndex?: number;
26
+ /** Number of units fully committed before the failure. */
27
+ completedUnits: number;
23
28
  };
24
29
  interface ApplyPlanOptions {
25
30
  verifyPostApply?: boolean;
26
31
  }
27
32
  type ConnectionInput = string | Pool;
28
33
  /**
29
- * Apply a plan's SQL statements to a target database with integrity checks.
34
+ * Apply a plan's migration units to a target database with integrity checks.
30
35
  * Validates fingerprints before and after application to ensure plan integrity.
36
+ *
37
+ * Units are applied in order on a single session: transactional units inside
38
+ * an explicit BEGIN/COMMIT, non-transactional units without a wrapper. A
39
+ * failure does not roll back units that already committed.
31
40
  */
32
41
  export declare function applyPlan(plan: Plan, source: ConnectionInput, target: ConnectionInput, options?: ApplyPlanOptions): Promise<ApplyPlanResult>;
33
42
  export {};
@@ -7,19 +7,20 @@ import { buildPlanScopeFingerprint, hashStableIds } from "../fingerprint.js";
7
7
  import { compileFilterDSL } from "../integrations/filter/dsl.js";
8
8
  import { createManagedPool, endPool } from "../postgres-config.js";
9
9
  import { sortChanges } from "../sort/sort-changes.js";
10
+ import { normalizePlan } from "./normalize.js";
11
+ import { renderPlanSql } from "./render.js";
10
12
  /**
11
- * Check if a statement is a session configuration statement (standalone SET statements).
12
- * These statements should not be counted as changes.
13
- */
14
- function isSessionStatement(statement) {
15
- return statement.trim().startsWith("SET ");
16
- }
17
- /**
18
- * Apply a plan's SQL statements to a target database with integrity checks.
13
+ * Apply a plan's migration units to a target database with integrity checks.
19
14
  * Validates fingerprints before and after application to ensure plan integrity.
15
+ *
16
+ * Units are applied in order on a single session: transactional units inside
17
+ * an explicit BEGIN/COMMIT, non-transactional units without a wrapper. A
18
+ * failure does not roll back units that already committed.
20
19
  */
21
20
  export async function applyPlan(plan, source, target, options = {}) {
22
- if (!plan.statements || plan.statements.length === 0) {
21
+ const normalizedPlan = normalizePlan(plan);
22
+ const units = normalizedPlan.units;
23
+ if (units.length === 0) {
23
24
  return {
24
25
  status: "invalid_plan",
25
26
  message: "Plan contains no SQL statements to execute.",
@@ -31,7 +32,7 @@ export async function applyPlan(plan, source, target, options = {}) {
31
32
  let shouldCloseDesired = false;
32
33
  if (typeof source === "string") {
33
34
  const managed = await createManagedPool(source, {
34
- role: plan.role,
35
+ role: normalizedPlan.role,
35
36
  label: "source",
36
37
  });
37
38
  currentPool = managed.pool;
@@ -42,7 +43,7 @@ export async function applyPlan(plan, source, target, options = {}) {
42
43
  }
43
44
  if (typeof target === "string") {
44
45
  const managed = await createManagedPool(target, {
45
- role: plan.role,
46
+ role: normalizedPlan.role,
46
47
  label: "target",
47
48
  });
48
49
  desiredPool = managed.pool;
@@ -64,43 +65,58 @@ export async function applyPlan(plan, source, target, options = {}) {
64
65
  };
65
66
  // Apply the same filter that was used to create the plan (if any)
66
67
  let filteredChanges = changes;
67
- if (plan.filter) {
68
- const filterFn = compileFilterDSL(plan.filter);
68
+ if (normalizedPlan.filter) {
69
+ const filterFn = compileFilterDSL(normalizedPlan.filter);
69
70
  filteredChanges = filteredChanges.filter((change) => filterFn(change));
70
71
  }
71
72
  const sortedChanges = sortChanges(ctx, filteredChanges);
72
73
  const { hash: fingerprintFrom, stableIds } = buildPlanScopeFingerprint(ctx.mainCatalog, sortedChanges);
73
74
  // We intentionally recompute target fingerprint only after applying.
74
75
  // Pre-apply fingerprint validation
75
- if (fingerprintFrom === plan.target.fingerprint) {
76
+ if (fingerprintFrom === normalizedPlan.target.fingerprint) {
76
77
  return { status: "already_applied" };
77
78
  }
78
- if (fingerprintFrom !== plan.source.fingerprint) {
79
+ if (fingerprintFrom !== normalizedPlan.source.fingerprint) {
79
80
  return {
80
81
  status: "fingerprint_mismatch",
81
82
  current: fingerprintFrom,
82
- expected: plan.source.fingerprint,
83
+ expected: normalizedPlan.source.fingerprint,
83
84
  };
84
85
  }
85
- // Execute the SQL script
86
- // TODO: mark statements that can't be run within a transaction
87
- const statements = plan.statements;
88
- const script = (() => {
89
- const joined = statements.join(";\n");
90
- return joined.endsWith(";") ? joined : `${joined};`;
91
- })();
86
+ const script = renderPlanSql(normalizedPlan);
87
+ let completedUnits = 0;
88
+ let unitStarted = false;
89
+ // A single session for the whole plan: session statements (SET ROLE,
90
+ // SET check_function_bodies) must stay in effect across all units.
91
+ const client = await currentPool.connect();
92
92
  try {
93
- await currentPool.query(script);
93
+ for (const statement of normalizedPlan.sessionStatements) {
94
+ await client.query(statement);
95
+ }
96
+ for (const unit of units) {
97
+ unitStarted = true;
98
+ await applyUnit(client, unit);
99
+ completedUnits++;
100
+ }
94
101
  }
95
102
  catch (error) {
96
- return { status: "failed", error, script };
103
+ return {
104
+ status: "failed",
105
+ error,
106
+ script,
107
+ failedUnitIndex: unitStarted ? completedUnits : undefined,
108
+ completedUnits,
109
+ };
110
+ }
111
+ finally {
112
+ client.release();
97
113
  }
98
114
  const warnings = [];
99
115
  if (options.verifyPostApply !== false) {
100
116
  try {
101
117
  const updatedCatalog = await extractCatalog(currentPool);
102
118
  const updatedFingerprint = hashStableIds(updatedCatalog, stableIds);
103
- if (updatedFingerprint !== plan.target.fingerprint) {
119
+ if (updatedFingerprint !== normalizedPlan.target.fingerprint) {
104
120
  warnings.push("Post-apply fingerprint does not match the plan target fingerprint.");
105
121
  }
106
122
  }
@@ -108,11 +124,11 @@ export async function applyPlan(plan, source, target, options = {}) {
108
124
  warnings.push(`Could not verify post-apply fingerprint: ${error instanceof Error ? error.message : String(error)}`);
109
125
  }
110
126
  }
111
- // Count only actual changes, excluding session configuration statements
112
- const changeStatements = statements.filter((stmt) => !isSessionStatement(stmt));
113
127
  return {
114
128
  status: "applied",
115
- statements: changeStatements.length,
129
+ // Units contain only change statements; session statements are not counted.
130
+ statements: units.reduce((sum, unit) => sum + unit.statements.length, 0),
131
+ units: units.length,
116
132
  warnings: warnings.length ? warnings : undefined,
117
133
  };
118
134
  }
@@ -127,3 +143,22 @@ export async function applyPlan(plan, source, target, options = {}) {
127
143
  }
128
144
  }
129
145
  }
146
+ async function applyUnit(client, unit) {
147
+ if (unit.transactionMode === "transactional") {
148
+ await client.query("BEGIN");
149
+ try {
150
+ for (const statement of unit.statements) {
151
+ await client.query(statement);
152
+ }
153
+ await client.query("COMMIT");
154
+ }
155
+ catch (error) {
156
+ await client.query("ROLLBACK").catch(() => { });
157
+ throw error;
158
+ }
159
+ return;
160
+ }
161
+ for (const statement of unit.statements) {
162
+ await client.query(statement);
163
+ }
164
+ }
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * Plan creation - the main entry point for creating migration plans.
3
3
  */
4
- import { escapeIdentifier } from "pg";
5
4
  import { diffCatalogs } from "../catalog.diff.js";
6
5
  import { createEmptyCatalog, extractCatalog } from "../catalog.model.js";
7
6
  import { buildPlanScopeFingerprint, hashStableIds } from "../fingerprint.js";
8
7
  import { resolveIntegration, } from "../integrations/integration.types.js";
9
8
  import { createManagedPool, endPool } from "../postgres-config.js";
10
9
  import { sortChanges } from "../sort/sort-changes.js";
10
+ import { buildExecutionPlan } from "./execution.js";
11
11
  import { classifyChangesRisk } from "./risk.js";
12
12
  /**
13
13
  * Bundle-safe catalog detection: treat input as a resolved Catalog when it has
@@ -215,45 +215,19 @@ function cascadeExclusions(filteredChanges, allChanges, catalogDepends) {
215
215
  */
216
216
  function buildPlan(ctx, changes, options, filterDSL, serializeDSL, integration) {
217
217
  const role = options?.role;
218
- const statements = generateStatements(changes, {
219
- integration,
220
- role,
221
- });
218
+ const execution = buildExecutionPlan(changes, { integration, role });
222
219
  const risk = classifyChangesRisk(changes);
223
220
  const { hash: fingerprintFrom, stableIds } = buildPlanScopeFingerprint(ctx.mainCatalog, changes);
224
221
  const fingerprintTo = hashStableIds(ctx.branchCatalog, stableIds);
225
222
  return {
226
- version: 1,
223
+ version: 2,
227
224
  source: { fingerprint: fingerprintFrom },
228
225
  target: { fingerprint: fingerprintTo },
229
- statements,
226
+ units: execution.units,
227
+ sessionStatements: execution.sessionStatements,
230
228
  role,
231
229
  filter: filterDSL,
232
230
  serialize: serializeDSL,
233
231
  risk,
234
232
  };
235
233
  }
236
- /**
237
- * Generate the individual SQL statements that make up the plan.
238
- */
239
- function generateStatements(changes, options) {
240
- const statements = [];
241
- if (options?.role) {
242
- statements.push(`SET ROLE ${escapeIdentifier(options.role)}`);
243
- }
244
- if (hasRoutineChanges(changes)) {
245
- statements.push("SET check_function_bodies = false");
246
- }
247
- for (const change of changes) {
248
- const sql = options?.integration?.serialize?.(change) ?? change.serialize();
249
- statements.push(sql);
250
- }
251
- return statements;
252
- }
253
- /**
254
- * Check if any changes involve routines (procedures or aggregates).
255
- * Used to determine if we need to disable function body checking.
256
- */
257
- function hasRoutineChanges(changes) {
258
- return changes.some((change) => change.objectType === "procedure" || change.objectType === "aggregate");
259
- }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Execution planning - group sorted changes into transaction-aware
3
+ * migration units.
4
+ *
5
+ * Execution semantics come from the `nonTransactional` and `commitBoundary`
6
+ * traits declared on the change classes (see `base.change.ts`), never from
7
+ * inspecting rendered SQL.
8
+ */
9
+ import type { Change } from "../change.types.ts";
10
+ import type { ResolvedIntegration } from "../integrations/integration.types.ts";
11
+ import type { MigrationUnit } from "./types.ts";
12
+ interface BuildExecutionPlanOptions {
13
+ integration?: ResolvedIntegration;
14
+ role?: string;
15
+ }
16
+ interface ExecutionPlan {
17
+ units: MigrationUnit[];
18
+ sessionStatements: string[];
19
+ }
20
+ export declare function buildExecutionPlan(changes: Change[], options?: BuildExecutionPlanOptions): ExecutionPlan;
21
+ export {};