@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
@@ -0,0 +1,76 @@
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 { escapeIdentifier } from "pg";
10
+ export function buildExecutionPlan(changes, options = {}) {
11
+ return {
12
+ units: buildMigrationUnits(changes, options.integration),
13
+ sessionStatements: buildSessionStatements(changes, options),
14
+ };
15
+ }
16
+ function buildSessionStatements(changes, options) {
17
+ const statements = [];
18
+ if (options.role) {
19
+ statements.push(`SET ROLE ${escapeIdentifier(options.role)}`);
20
+ }
21
+ if (hasRoutineChanges(changes)) {
22
+ statements.push("SET check_function_bodies = false");
23
+ }
24
+ return statements;
25
+ }
26
+ /**
27
+ * Check if any changes involve routines (procedures or aggregates).
28
+ * Used to determine if we need to disable function body checking.
29
+ */
30
+ function hasRoutineChanges(changes) {
31
+ return changes.some((change) => change.objectType === "procedure" || change.objectType === "aggregate");
32
+ }
33
+ function buildMigrationUnits(changes, integration) {
34
+ const units = [];
35
+ let current = [];
36
+ let reason = "default";
37
+ let pendingBoundary = null;
38
+ function flush() {
39
+ if (current.length === 0)
40
+ return;
41
+ units.push({
42
+ transactionMode: "transactional",
43
+ reason,
44
+ statements: current,
45
+ });
46
+ current = [];
47
+ }
48
+ for (const change of changes) {
49
+ const sql = integration?.serialize?.(change) ?? change.serialize();
50
+ const boundary = change.commitBoundary;
51
+ if (change.nonTransactional) {
52
+ flush();
53
+ pendingBoundary = null;
54
+ reason = "default";
55
+ units.push({
56
+ transactionMode: "none",
57
+ reason: "non_transactional",
58
+ statements: [sql],
59
+ });
60
+ continue;
61
+ }
62
+ // Only producers of the same boundary kind share a unit; anything else
63
+ // (a different kind or a non-producer) runs after the producers' COMMIT.
64
+ if (pendingBoundary !== null && boundary !== pendingBoundary) {
65
+ flush();
66
+ reason = pendingBoundary;
67
+ pendingBoundary = null;
68
+ }
69
+ current.push(sql);
70
+ if (boundary !== null) {
71
+ pendingBoundary = boundary;
72
+ }
73
+ }
74
+ flush();
75
+ return units;
76
+ }
@@ -9,7 +9,7 @@
9
9
  * if (planResult) {
10
10
  * const { plan, sortedChanges, ctx } = planResult;
11
11
  * const hierarchy = groupChangesHierarchically(ctx, sortedChanges);
12
- * console.log(plan.statements);
12
+ * console.log(renderPlanSql(plan));
13
13
  * }
14
14
  * ```
15
15
  */
@@ -9,7 +9,7 @@
9
9
  * if (planResult) {
10
10
  * const { plan, sortedChanges, ctx } = planResult;
11
11
  * const hierarchy = groupChangesHierarchically(ctx, sortedChanges);
12
- * console.log(plan.statements);
12
+ * console.log(renderPlanSql(plan));
13
13
  * }
14
14
  * ```
15
15
  */
@@ -7,6 +7,7 @@ import { type Plan } from "./types.ts";
7
7
  */
8
8
  export declare function serializePlan(plan: Plan): string;
9
9
  /**
10
- * Deserialize a plan from JSON string.
10
+ * Deserialize a plan from JSON string. Legacy v1 plans (flat `statements`)
11
+ * are normalized into migration units.
11
12
  */
12
13
  export declare function deserializePlan(json: string): Plan;
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Plan I/O utilities for serializing and deserializing plans to/from JSON.
3
3
  */
4
+ import { normalizePlan } from "./normalize.js";
4
5
  import { PlanSchema } from "./types.js";
5
6
  /**
6
7
  * Serialize a plan to JSON string.
@@ -9,9 +10,10 @@ export function serializePlan(plan) {
9
10
  return JSON.stringify(plan, null, 2);
10
11
  }
11
12
  /**
12
- * Deserialize a plan from JSON string.
13
+ * Deserialize a plan from JSON string. Legacy v1 plans (flat `statements`)
14
+ * are normalized into migration units.
13
15
  */
14
16
  export function deserializePlan(json) {
15
17
  const parsed = JSON.parse(json);
16
- return PlanSchema.parse(parsed);
18
+ return normalizePlan(PlanSchema.parse(parsed));
17
19
  }
@@ -0,0 +1,11 @@
1
+ import type { Plan, SerializedPlan } from "./types.ts";
2
+ /**
3
+ * Normalize a plan into the v2 shape: `units` + `sessionStatements`.
4
+ *
5
+ * Legacy v1 plans carry a flat `statements` array instead of units. Their
6
+ * leading SET statements become session statements, and the remaining
7
+ * statements become a single transactional unit — faithful to how the v1
8
+ * applier executed them (one multi-statement query, i.e. one implicit
9
+ * transaction).
10
+ */
11
+ export declare function normalizePlan(plan: SerializedPlan): Plan;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Normalize a plan into the v2 shape: `units` + `sessionStatements`.
3
+ *
4
+ * Legacy v1 plans carry a flat `statements` array instead of units. Their
5
+ * leading SET statements become session statements, and the remaining
6
+ * statements become a single transactional unit — faithful to how the v1
7
+ * applier executed them (one multi-statement query, i.e. one implicit
8
+ * transaction).
9
+ */
10
+ export function normalizePlan(plan) {
11
+ const { statements, ...rest } = plan;
12
+ return {
13
+ ...rest,
14
+ units: plan.units ?? legacyUnits(statements ?? []),
15
+ sessionStatements: plan.sessionStatements ??
16
+ (statements ?? []).filter((statement) => isSessionStatement(statement)),
17
+ };
18
+ }
19
+ function isSessionStatement(statement) {
20
+ return /^SET\s+/i.test(statement.trim());
21
+ }
22
+ function legacyUnits(statements) {
23
+ const schemaStatements = statements.filter((statement) => !isSessionStatement(statement));
24
+ if (schemaStatements.length === 0)
25
+ return [];
26
+ return [
27
+ {
28
+ transactionMode: "transactional",
29
+ reason: "default",
30
+ statements: schemaStatements,
31
+ },
32
+ ];
33
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Plan rendering - turn migration units into executable SQL scripts.
3
+ */
4
+ import type { SqlFormatOptions } from "./sql-format.ts";
5
+ import type { MigrationUnit, SerializedPlan } from "./types.ts";
6
+ export interface RenderPlanSqlOptions {
7
+ sqlFormatOptions?: SqlFormatOptions;
8
+ includeTransactions?: boolean;
9
+ }
10
+ export interface RenderedPlanFile {
11
+ path: string;
12
+ sql: string;
13
+ unit: MigrationUnit;
14
+ }
15
+ /**
16
+ * Render the whole plan as a single SQL script. Each migration unit is
17
+ * delimited with header comments and, for transactional units, wrapped in
18
+ * explicit BEGIN/COMMIT.
19
+ */
20
+ export declare function renderPlanSql(plan: SerializedPlan, options?: RenderPlanSqlOptions): string;
21
+ /**
22
+ * Render the plan as one numbered SQL file per migration unit. Session
23
+ * statements are repeated in every file because each file may be executed
24
+ * in its own session.
25
+ */
26
+ export declare function renderPlanFiles(plan: SerializedPlan, options?: RenderPlanSqlOptions): RenderedPlanFile[];
27
+ /**
28
+ * Flatten a plan back into the ordered statement list, session statements
29
+ * included. Execution context (transaction boundaries) is lost — use
30
+ * `renderPlanSql`/`renderPlanFiles` or `plan.units` when it matters.
31
+ */
32
+ export declare function flattenPlanStatements(plan: SerializedPlan): string[];
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Plan rendering - turn migration units into executable SQL scripts.
3
+ */
4
+ import { normalizePlan } from "./normalize.js";
5
+ import { formatSqlStatements } from "./sql-format.js";
6
+ const STATEMENT_DELIMITER = ";\n\n";
7
+ /**
8
+ * Render the whole plan as a single SQL script. Each migration unit is
9
+ * delimited with header comments and, for transactional units, wrapped in
10
+ * explicit BEGIN/COMMIT.
11
+ */
12
+ export function renderPlanSql(plan, options = {}) {
13
+ const normalized = normalizePlan(plan);
14
+ return normalized.units
15
+ .map((unit, index) => renderUnitSql(normalized, unit, index, options))
16
+ .join("\n\n");
17
+ }
18
+ /**
19
+ * Render the plan as one numbered SQL file per migration unit. Session
20
+ * statements are repeated in every file because each file may be executed
21
+ * in its own session.
22
+ */
23
+ export function renderPlanFiles(plan, options = {}) {
24
+ const normalized = normalizePlan(plan);
25
+ return normalized.units.map((unit, index) => ({
26
+ path: `${String(index + 1).padStart(3, "0")}_${unitName(unit.reason)}.sql`,
27
+ sql: renderUnitSql(normalized, unit, index, options),
28
+ unit,
29
+ }));
30
+ }
31
+ /**
32
+ * Flatten a plan back into the ordered statement list, session statements
33
+ * included. Execution context (transaction boundaries) is lost — use
34
+ * `renderPlanSql`/`renderPlanFiles` or `plan.units` when it matters.
35
+ */
36
+ export function flattenPlanStatements(plan) {
37
+ const normalized = normalizePlan(plan);
38
+ return [
39
+ ...normalized.sessionStatements,
40
+ ...normalized.units.flatMap((unit) => unit.statements),
41
+ ];
42
+ }
43
+ /** Display name for a migration unit, derived from its boundary reason. */
44
+ function unitName(reason) {
45
+ switch (reason) {
46
+ case "default":
47
+ return "schema_changes";
48
+ case "enum_value_visibility":
49
+ return "after_enum_values";
50
+ case "non_transactional":
51
+ return "non_transactional";
52
+ }
53
+ }
54
+ function renderUnitSql(plan, unit, index, options) {
55
+ const includeTransactions = options.includeTransactions !== false;
56
+ const body = options.sqlFormatOptions != null
57
+ ? formatSqlStatements(unit.statements, options.sqlFormatOptions)
58
+ : unit.statements;
59
+ const lines = [
60
+ `-- Migration unit ${index + 1}: ${unitName(unit.reason)}`,
61
+ `-- Transaction mode: ${unit.transactionMode}`,
62
+ `-- Boundary reason: ${unit.reason}`,
63
+ ];
64
+ if (unit.transactionMode === "none") {
65
+ // PostgreSQL runs every statement of a multi-command simple-query string
66
+ // in an implicit transaction block, so this unit can never execute as
67
+ // part of a single query string — no SET/COMMIT shuffling changes that.
68
+ lines.push("-- Run statement-by-statement (psql does this; do not use psql -1 or", "-- send this script as a single multi-statement query string).");
69
+ }
70
+ lines.push("");
71
+ if (plan.sessionStatements.length > 0) {
72
+ lines.push(renderStatements(plan.sessionStatements));
73
+ lines.push("");
74
+ }
75
+ if (includeTransactions && unit.transactionMode === "transactional") {
76
+ lines.push("BEGIN;");
77
+ lines.push("");
78
+ }
79
+ lines.push(renderStatements(body));
80
+ if (includeTransactions && unit.transactionMode === "transactional") {
81
+ lines.push("");
82
+ lines.push("COMMIT;");
83
+ }
84
+ return lines.join("\n").trimEnd();
85
+ }
86
+ function renderStatements(statements) {
87
+ if (statements.length === 0)
88
+ return "";
89
+ return `${statements.map(trimTerminator).join(STATEMENT_DELIMITER)};`;
90
+ }
91
+ /**
92
+ * Strip trailing semicolons so every rendered statement ends with exactly
93
+ * one. pg-delta's own serializers emit no terminator, but plan JSON is a
94
+ * persisted artifact — legacy v1 files, hand-built units, or user-edited
95
+ * plans may already carry one, and joining those blindly would render ";;".
96
+ */
97
+ function trimTerminator(statement) {
98
+ const trimmed = statement.trim();
99
+ let end = trimmed.length;
100
+ while (end > 0 && trimmed.charCodeAt(end - 1) === 59) {
101
+ end--;
102
+ }
103
+ return trimmed.slice(0, end);
104
+ }
@@ -4,12 +4,35 @@
4
4
  import z from "zod";
5
5
  import type { Change } from "../change.types.ts";
6
6
  import type { Integration } from "../integrations/integration.types.ts";
7
+ import type { CommitBoundaryReason } from "../objects/base.change.ts";
7
8
  export type PlanRisk = {
8
9
  level: "safe";
9
10
  } | {
10
11
  level: "data_loss";
11
12
  statements: string[];
12
13
  };
14
+ export type TransactionMode = "transactional" | "none";
15
+ /**
16
+ * Why a migration unit starts a new execution boundary.
17
+ *
18
+ * - `"default"` — the first (or only) unit of the plan.
19
+ * - `"non_transactional"` — the unit's statement cannot run inside a
20
+ * transaction block (see `BaseChange.nonTransactional`).
21
+ * - commit-visibility kinds (see `BaseChange.commitBoundary`) — the previous
22
+ * unit produced effects that are only usable after COMMIT.
23
+ */
24
+ export type ExecutionBoundaryReason = "default" | "non_transactional" | CommitBoundaryReason;
25
+ /**
26
+ * An ordered group of SQL statements that share one execution context.
27
+ *
28
+ * Transactional units are applied inside an explicit BEGIN/COMMIT;
29
+ * non-transactional units run their single statement without a wrapper.
30
+ */
31
+ export interface MigrationUnit {
32
+ transactionMode: TransactionMode;
33
+ reason: ExecutionBoundaryReason;
34
+ statements: string[];
35
+ }
13
36
  /**
14
37
  * All supported object types in the system.
15
38
  * Derived from the Change union type's objectType discriminant.
@@ -110,7 +133,20 @@ export declare const PlanSchema: z.ZodObject<{
110
133
  target: z.ZodObject<{
111
134
  fingerprint: z.ZodString;
112
135
  }, z.z.core.$strip>;
113
- statements: z.ZodArray<z.ZodString>;
136
+ units: z.ZodOptional<z.ZodArray<z.ZodObject<{
137
+ transactionMode: z.ZodEnum<{
138
+ none: "none";
139
+ transactional: "transactional";
140
+ }>;
141
+ reason: z.ZodEnum<{
142
+ default: "default";
143
+ enum_value_visibility: "enum_value_visibility";
144
+ non_transactional: "non_transactional";
145
+ }>;
146
+ statements: z.ZodArray<z.ZodString>;
147
+ }, z.z.core.$strip>>>;
148
+ sessionStatements: z.ZodOptional<z.ZodArray<z.ZodString>>;
149
+ statements: z.ZodOptional<z.ZodArray<z.ZodString>>;
114
150
  role: z.ZodOptional<z.ZodString>;
115
151
  filter: z.ZodOptional<z.ZodAny>;
116
152
  serialize: z.ZodOptional<z.ZodAny>;
@@ -121,10 +157,18 @@ export declare const PlanSchema: z.ZodObject<{
121
157
  statements: z.ZodArray<z.ZodString>;
122
158
  }, z.z.core.$strip>], "level">>;
123
159
  }, z.z.core.$strip>;
160
+ export type SerializedPlan = z.infer<typeof PlanSchema>;
124
161
  /**
125
- * A migration plan containing all changes to transform one database schema into another.
162
+ * A migration plan containing all changes to transform one database schema
163
+ * into another, as an ordered list of execution-aware migration units.
164
+ *
165
+ * `units` and `sessionStatements` are the single source of truth: render via
166
+ * `renderPlanSql`/`renderPlanFiles`, or flatten via `flattenPlanStatements`.
126
167
  */
127
- export type Plan = z.infer<typeof PlanSchema>;
168
+ export type Plan = Omit<SerializedPlan, "units" | "statements" | "sessionStatements"> & {
169
+ units: MigrationUnit[];
170
+ sessionStatements: string[];
171
+ };
128
172
  /**
129
173
  * Options for creating a plan.
130
174
  */
@@ -14,7 +14,24 @@ export const PlanSchema = z.object({
14
14
  target: z.object({
15
15
  fingerprint: z.string(),
16
16
  }),
17
- statements: z.array(z.string()),
17
+ units: z
18
+ .array(z.object({
19
+ transactionMode: z.enum(["transactional", "none"]),
20
+ reason: z.enum([
21
+ "default",
22
+ "non_transactional",
23
+ "enum_value_visibility",
24
+ ]),
25
+ statements: z.array(z.string()),
26
+ }))
27
+ .optional(),
28
+ /** Session-level statements (SET ROLE, ...) applied once before the units. */
29
+ sessionStatements: z.array(z.string()).optional(),
30
+ /**
31
+ * Legacy v1 plans only: the flat statement list. Converted to units by
32
+ * `normalizePlan`; never emitted by `createPlan`/`serializePlan`.
33
+ */
34
+ statements: z.array(z.string()).optional(),
18
35
  role: z.string().optional(),
19
36
  filter: z.any().optional(), // FilterDSL - complex recursive type, validated at compile time
20
37
  serialize: z.any().optional(), // SerializeDSL - complex recursive type, validated at compile time
@@ -20,6 +20,7 @@ import { buildGraphData, convertCatalogDependenciesToConstraints, convertConstra
20
20
  import { dedupeEdges } from "./graph-utils.js";
21
21
  import { logicalSort } from "./logical-sort.js";
22
22
  import { findCycle, formatCycleError, performStableTopologicalSort, } from "./topological-sort.js";
23
+ import { UnorderableCycleError } from "./unorderable-cycle-error.js";
23
24
  import { getExecutionPhase } from "./utils.js";
24
25
  // `sortPhaseChanges` caps the change-injection breaker at one round per
25
26
  // node in the initial phase: there can never be more disjoint unbreakable
@@ -148,7 +149,7 @@ function attemptSortRound(phaseChanges, dependencyRows, options) {
148
149
  const topologicalOrder = performStableTopologicalSort(phaseChanges.length, finalEdgePairs);
149
150
  if (!topologicalOrder || topologicalOrder.length !== phaseChanges.length) {
150
151
  // This should never happen if findCycle returned null, but guard anyway
151
- throw new Error("CycleError: dependency graph contains a cycle");
152
+ throw new UnorderableCycleError("CycleError: dependency graph contains a cycle");
152
153
  }
153
154
  return {
154
155
  kind: "sorted",
@@ -186,16 +187,16 @@ function sortPhaseChanges(initialPhaseChanges, dependencyRows, options = {}) {
186
187
  // throw with the same diagnostic the original code emitted.
187
188
  const broken = tryBreakCycleByChangeInjection(result.cycleNodeIndexes, phaseChanges);
188
189
  if (broken === null) {
189
- throw new Error(formatCycleError(result.cycleNodeIndexes, phaseChanges, result.cycleEdges));
190
+ throw new UnorderableCycleError(formatCycleError(result.cycleNodeIndexes, phaseChanges, result.cycleEdges), result.cycleNodeIndexes.map((index) => phaseChanges[index]));
190
191
  }
191
192
  // Loop guard: if the same cycle node-set re-appears after a break,
192
193
  // the breaker isn't making progress. Throw with full context.
193
194
  const signature = normalizeCycle(result.cycleNodeIndexes);
194
195
  if (breakerRoundSignatures.has(signature)) {
195
- throw new Error(formatCycleError(result.cycleNodeIndexes, phaseChanges, result.cycleEdges));
196
+ throw new UnorderableCycleError(formatCycleError(result.cycleNodeIndexes, phaseChanges, result.cycleEdges), result.cycleNodeIndexes.map((index) => phaseChanges[index]));
196
197
  }
197
198
  breakerRoundSignatures.add(signature);
198
199
  phaseChanges = broken;
199
200
  }
200
- throw new Error(`CycleError: change-injection breaker exceeded ${maxRounds} rounds (one per node in the phase) — likely a buggy breaker rule`);
201
+ throw new UnorderableCycleError(`CycleError: change-injection breaker exceeded ${maxRounds} rounds (one per node in the phase) — likely a buggy breaker rule`);
201
202
  }
@@ -0,0 +1,18 @@
1
+ import type { Change } from "../change.types.ts";
2
+ /**
3
+ * Thrown by `sortChanges` when the dependency graph contains a cycle that
4
+ * neither weak-edge filtering nor the change-injection cycle breakers could
5
+ * resolve.
6
+ *
7
+ * `message` is the human-readable `formatCycleError` output (it starts with
8
+ * "CycleError:" for backward compatibility with log greps).
9
+ */
10
+ export declare class UnorderableCycleError extends Error {
11
+ readonly name = "UnorderableCycleError";
12
+ /**
13
+ * Changes participating in the cycle, in cycle order. Empty when the
14
+ * failure came from an internal guard rather than a concrete cycle.
15
+ */
16
+ readonly cycle: readonly Change[];
17
+ constructor(message: string, cycle?: readonly Change[]);
18
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Thrown by `sortChanges` when the dependency graph contains a cycle that
3
+ * neither weak-edge filtering nor the change-injection cycle breakers could
4
+ * resolve.
5
+ *
6
+ * `message` is the human-readable `formatCycleError` output (it starts with
7
+ * "CycleError:" for backward compatibility with log greps).
8
+ */
9
+ export class UnorderableCycleError extends Error {
10
+ name = "UnorderableCycleError";
11
+ /**
12
+ * Changes participating in the cycle, in cycle order. Empty when the
13
+ * failure came from an internal guard rather than a concrete cycle.
14
+ */
15
+ cycle;
16
+ constructor(message, cycle = []) {
17
+ super(message);
18
+ this.cycle = cycle;
19
+ }
20
+ }
package/dist/index.d.ts CHANGED
@@ -9,10 +9,14 @@ export { deserializeCatalog, serializeCatalog, stringifyCatalogSnapshot, } from
9
9
  export { exportDeclarativeSchema } from "./core/export/index.ts";
10
10
  export type { DeclarativeSchemaOutput, FileCategory, FileEntry, FileMetadata, } from "./core/export/types.ts";
11
11
  export type { IntegrationDSL } from "./core/integrations/integration-dsl.ts";
12
+ export type { Change } from "./core/change.types.ts";
12
13
  export { applyPlan } from "./core/plan/apply.ts";
13
14
  export type { CatalogInput } from "./core/plan/create.ts";
14
15
  export { createPlan } from "./core/plan/create.ts";
16
+ export type { RenderedPlanFile, RenderPlanSqlOptions, } from "./core/plan/render.ts";
17
+ export { flattenPlanStatements, renderPlanFiles, renderPlanSql, } from "./core/plan/render.ts";
15
18
  export type { SqlFormatOptions } from "./core/plan/sql-format.ts";
16
19
  export { formatSqlStatements } from "./core/plan/sql-format.ts";
17
- export type { CreatePlanOptions, Plan } from "./core/plan/types.ts";
20
+ export type { CreatePlanOptions, ExecutionBoundaryReason, MigrationUnit, Plan, TransactionMode, } from "./core/plan/types.ts";
21
+ export { UnorderableCycleError } from "./core/sort/unorderable-cycle-error.ts";
18
22
  export { createManagedPool } from "./core/postgres-config.ts";
package/dist/index.js CHANGED
@@ -8,9 +8,10 @@ export { Catalog, createEmptyCatalog, extractCatalog, } from "./core/catalog.mod
8
8
  export { deserializeCatalog, serializeCatalog, stringifyCatalogSnapshot, } from "./core/catalog.snapshot.js";
9
9
  // Declarative schema export
10
10
  export { exportDeclarativeSchema } from "./core/export/index.js";
11
- // Plan operations
12
11
  export { applyPlan } from "./core/plan/apply.js";
13
12
  export { createPlan } from "./core/plan/create.js";
13
+ export { flattenPlanStatements, renderPlanFiles, renderPlanSql, } from "./core/plan/render.js";
14
14
  export { formatSqlStatements } from "./core/plan/sql-format.js";
15
+ export { UnorderableCycleError } from "./core/sort/unorderable-cycle-error.js";
15
16
  // Postgres config
16
17
  export { createManagedPool } from "./core/postgres-config.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supabase/pg-delta",
3
- "version": "1.0.0-alpha.29",
3
+ "version": "1.0.0-alpha.30",
4
4
  "description": "PostgreSQL migrations made easy",
5
5
  "keywords": [
6
6
  "diff",
@@ -1,11 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { run } from "@stricli/core";
4
+ import { UnorderableCycleError } from "../../core/sort/unorderable-cycle-error.ts";
4
5
  import { app } from "../app.ts";
5
6
  import { getCommandExitCode } from "../exit-code.ts";
6
7
 
7
8
  await run(app, process.argv.slice(2), { process }).catch((error) => {
8
- console.error(error);
9
+ if (error instanceof UnorderableCycleError) {
10
+ console.error(error.message);
11
+ console.error(
12
+ "pg-delta could not find a valid execution order for these changes. Please report this plan at https://github.com/supabase/pg-toolbelt/issues.",
13
+ );
14
+ } else {
15
+ console.error(error);
16
+ }
9
17
  process.exit(1);
10
18
  });
11
19
 
@@ -2,12 +2,14 @@
2
2
  * Plan command - compute schema diff and preview changes.
3
3
  */
4
4
 
5
- import { writeFile } from "node:fs/promises";
5
+ import { mkdir, readdir, writeFile } from "node:fs/promises";
6
+ import path from "node:path";
6
7
  import { buildCommand, type CommandContext } from "@stricli/core";
7
8
  import { deserializeCatalog } from "../../core/catalog.snapshot.ts";
8
9
  import type { FilterDSL } from "../../core/integrations/filter/dsl.ts";
9
10
  import type { SerializeDSL } from "../../core/integrations/serialize/dsl.ts";
10
11
  import { createPlan } from "../../core/plan/index.ts";
12
+ import { renderPlanFiles } from "../../core/plan/render.ts";
11
13
  import type { SqlFormatOptions } from "../../core/plan/sql-format.ts";
12
14
  import { setCommandExitCode } from "../exit-code.ts";
13
15
  import { resolveIntegrationOptions } from "../utils/integrations.ts";
@@ -43,6 +45,13 @@ export const planCommand = buildCommand({
43
45
  parse: String,
44
46
  optional: true,
45
47
  },
48
+ "output-dir": {
49
+ kind: "parsed",
50
+ brief:
51
+ "Write numbered SQL migration files to a directory using transaction-aware plan units.",
52
+ parse: String,
53
+ optional: true,
54
+ },
46
55
  role: {
47
56
  kind: "parsed",
48
57
  brief:
@@ -129,6 +138,7 @@ json/sql outputs are available for artifacts or piping.
129
138
  target: string;
130
139
  format?: "json" | "sql";
131
140
  output?: string;
141
+ "output-dir"?: string;
132
142
  role?: string;
133
143
  filter?: FilterDSL;
134
144
  serialize?: SerializeDSL;
@@ -169,6 +179,32 @@ json/sql outputs are available for artifacts or piping.
169
179
  return;
170
180
  }
171
181
 
182
+ if (flags.output && flags["output-dir"]) {
183
+ throw new Error("Use either --output or --output-dir, not both.");
184
+ }
185
+
186
+ if (flags["output-dir"]) {
187
+ await prepareOutputDirectory(flags["output-dir"]);
188
+ const files = renderPlanFiles(planResult.plan, {
189
+ sqlFormatOptions:
190
+ flags["sql-format"] || flags["sql-format-options"]
191
+ ? (flags["sql-format-options"] ?? {})
192
+ : undefined,
193
+ });
194
+ for (const file of files) {
195
+ await writeFile(
196
+ path.join(flags["output-dir"], file.path),
197
+ file.sql,
198
+ "utf-8",
199
+ );
200
+ }
201
+ this.process.stdout.write(
202
+ `${files.length} migration file${files.length === 1 ? "" : "s"} written to ${flags["output-dir"]}\n`,
203
+ );
204
+ setCommandExitCode(2);
205
+ return;
206
+ }
207
+
172
208
  const outputPath = flags.output;
173
209
  let effectiveFormat: "tree" | "json" | "sql";
174
210
  if (flags.format) {
@@ -208,3 +244,13 @@ json/sql outputs are available for artifacts or piping.
208
244
  setCommandExitCode(2);
209
245
  },
210
246
  });
247
+
248
+ async function prepareOutputDirectory(outputDir: string): Promise<void> {
249
+ await mkdir(outputDir, { recursive: true });
250
+ const entries = await readdir(outputDir);
251
+ if (entries.length > 0) {
252
+ throw new Error(
253
+ `Output directory is not empty: ${outputDir}. Choose an empty directory to avoid stale migration files.`,
254
+ );
255
+ }
256
+ }