@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,153 @@
1
+ /**
2
+ * Plan rendering - turn migration units into executable SQL scripts.
3
+ */
4
+
5
+ import { normalizePlan } from "./normalize.ts";
6
+ import type { SqlFormatOptions } from "./sql-format.ts";
7
+ import { formatSqlStatements } from "./sql-format.ts";
8
+ import type {
9
+ ExecutionBoundaryReason,
10
+ MigrationUnit,
11
+ Plan,
12
+ SerializedPlan,
13
+ } from "./types.ts";
14
+
15
+ const STATEMENT_DELIMITER = ";\n\n";
16
+
17
+ export interface RenderPlanSqlOptions {
18
+ sqlFormatOptions?: SqlFormatOptions;
19
+ includeTransactions?: boolean;
20
+ }
21
+
22
+ export interface RenderedPlanFile {
23
+ path: string;
24
+ sql: string;
25
+ unit: MigrationUnit;
26
+ }
27
+
28
+ /**
29
+ * Render the whole plan as a single SQL script. Each migration unit is
30
+ * delimited with header comments and, for transactional units, wrapped in
31
+ * explicit BEGIN/COMMIT.
32
+ */
33
+ export function renderPlanSql(
34
+ plan: SerializedPlan,
35
+ options: RenderPlanSqlOptions = {},
36
+ ): string {
37
+ const normalized = normalizePlan(plan);
38
+ return normalized.units
39
+ .map((unit, index) => renderUnitSql(normalized, unit, index, options))
40
+ .join("\n\n");
41
+ }
42
+
43
+ /**
44
+ * Render the plan as one numbered SQL file per migration unit. Session
45
+ * statements are repeated in every file because each file may be executed
46
+ * in its own session.
47
+ */
48
+ export function renderPlanFiles(
49
+ plan: SerializedPlan,
50
+ options: RenderPlanSqlOptions = {},
51
+ ): RenderedPlanFile[] {
52
+ const normalized = normalizePlan(plan);
53
+ return normalized.units.map((unit, index) => ({
54
+ path: `${String(index + 1).padStart(3, "0")}_${unitName(unit.reason)}.sql`,
55
+ sql: renderUnitSql(normalized, unit, index, options),
56
+ unit,
57
+ }));
58
+ }
59
+
60
+ /**
61
+ * Flatten a plan back into the ordered statement list, session statements
62
+ * included. Execution context (transaction boundaries) is lost — use
63
+ * `renderPlanSql`/`renderPlanFiles` or `plan.units` when it matters.
64
+ */
65
+ export function flattenPlanStatements(plan: SerializedPlan): string[] {
66
+ const normalized = normalizePlan(plan);
67
+ return [
68
+ ...normalized.sessionStatements,
69
+ ...normalized.units.flatMap((unit) => unit.statements),
70
+ ];
71
+ }
72
+
73
+ /** Display name for a migration unit, derived from its boundary reason. */
74
+ function unitName(reason: ExecutionBoundaryReason): string {
75
+ switch (reason) {
76
+ case "default":
77
+ return "schema_changes";
78
+ case "enum_value_visibility":
79
+ return "after_enum_values";
80
+ case "non_transactional":
81
+ return "non_transactional";
82
+ }
83
+ }
84
+
85
+ function renderUnitSql(
86
+ plan: Plan,
87
+ unit: MigrationUnit,
88
+ index: number,
89
+ options: RenderPlanSqlOptions,
90
+ ): string {
91
+ const includeTransactions = options.includeTransactions !== false;
92
+ const body =
93
+ options.sqlFormatOptions != null
94
+ ? formatSqlStatements(unit.statements, options.sqlFormatOptions)
95
+ : unit.statements;
96
+
97
+ const lines: string[] = [
98
+ `-- Migration unit ${index + 1}: ${unitName(unit.reason)}`,
99
+ `-- Transaction mode: ${unit.transactionMode}`,
100
+ `-- Boundary reason: ${unit.reason}`,
101
+ ];
102
+
103
+ if (unit.transactionMode === "none") {
104
+ // PostgreSQL runs every statement of a multi-command simple-query string
105
+ // in an implicit transaction block, so this unit can never execute as
106
+ // part of a single query string — no SET/COMMIT shuffling changes that.
107
+ lines.push(
108
+ "-- Run statement-by-statement (psql does this; do not use psql -1 or",
109
+ "-- send this script as a single multi-statement query string).",
110
+ );
111
+ }
112
+
113
+ lines.push("");
114
+
115
+ if (plan.sessionStatements.length > 0) {
116
+ lines.push(renderStatements(plan.sessionStatements));
117
+ lines.push("");
118
+ }
119
+
120
+ if (includeTransactions && unit.transactionMode === "transactional") {
121
+ lines.push("BEGIN;");
122
+ lines.push("");
123
+ }
124
+
125
+ lines.push(renderStatements(body));
126
+
127
+ if (includeTransactions && unit.transactionMode === "transactional") {
128
+ lines.push("");
129
+ lines.push("COMMIT;");
130
+ }
131
+
132
+ return lines.join("\n").trimEnd();
133
+ }
134
+
135
+ function renderStatements(statements: string[]): string {
136
+ if (statements.length === 0) return "";
137
+ return `${statements.map(trimTerminator).join(STATEMENT_DELIMITER)};`;
138
+ }
139
+
140
+ /**
141
+ * Strip trailing semicolons so every rendered statement ends with exactly
142
+ * one. pg-delta's own serializers emit no terminator, but plan JSON is a
143
+ * persisted artifact — legacy v1 files, hand-built units, or user-edited
144
+ * plans may already carry one, and joining those blindly would render ";;".
145
+ */
146
+ function trimTerminator(statement: string): string {
147
+ const trimmed = statement.trim();
148
+ let end = trimmed.length;
149
+ while (end > 0 && trimmed.charCodeAt(end - 1) === 59) {
150
+ end--;
151
+ }
152
+ return trimmed.slice(0, end);
153
+ }
@@ -659,7 +659,7 @@ describe("sql formatting snapshots", () => {
659
659
  ALTER DEFAULT PRIVILEGES FOR ROLE app_user IN SCHEMA public REVOKE SELECT ON TABLES FROM app_reader;
660
660
 
661
661
  -- subscription.create
662
- CREATE SUBSCRIPTION sub_replica CONNECTION 'host=primary.db port=5432 dbname=mydb' PUBLICATION pub_custom WITH (slot_name = 'sub_replica_slot', binary = true, streaming = 'parallel', synchronous_commit = 'remote_apply', disable_on_error = true, failover = true);
662
+ CREATE SUBSCRIPTION sub_replica CONNECTION 'host=primary.db port=5432 dbname=mydb' PUBLICATION pub_custom WITH (slot_name = 'sub_replica_slot', binary = true, streaming = 'parallel', synchronous_commit = 'remote_apply', disable_on_error = true, failover = true, create_slot = false);
663
663
 
664
664
  -- subscription.drop
665
665
  DROP SUBSCRIPTION sub_replica;
@@ -869,6 +869,7 @@ describe("sql formatting snapshots", () => {
869
869
  , synchronous_commit = 'remote_apply'
870
870
  , disable_on_error = true
871
871
  , failover = true
872
+ , create_slot = false
872
873
  );
873
874
 
874
875
  -- subscription.drop
@@ -1047,7 +1047,8 @@ describe("sql formatting snapshots", () => {
1047
1047
  streaming = 'parallel',
1048
1048
  synchronous_commit = 'remote_apply',
1049
1049
  disable_on_error = true,
1050
- failover = true
1050
+ failover = true,
1051
+ create_slot = false
1051
1052
  );
1052
1053
 
1053
1054
  -- subscription.drop
@@ -864,7 +864,8 @@ describe("sql formatting snapshots", () => {
864
864
  streaming = 'parallel',
865
865
  synchronous_commit = 'remote_apply',
866
866
  disable_on_error = true,
867
- failover = true
867
+ failover = true,
868
+ create_slot = false
868
869
  );
869
870
 
870
871
  -- subscription.drop
@@ -855,7 +855,8 @@ describe("sql formatting snapshots", () => {
855
855
  streaming = 'parallel',
856
856
  synchronous_commit = 'remote_apply',
857
857
  disable_on_error = true,
858
- failover = true
858
+ failover = true,
859
+ create_slot = false
859
860
  );
860
861
 
861
862
  -- subscription.drop
@@ -5,6 +5,7 @@
5
5
  import z from "zod";
6
6
  import type { Change } from "../change.types.ts";
7
7
  import type { Integration } from "../integrations/integration.types.ts";
8
+ import type { CommitBoundaryReason } from "../objects/base.change.ts";
8
9
 
9
10
  // ============================================================================
10
11
  // Core Types
@@ -14,6 +15,34 @@ export type PlanRisk =
14
15
  | { level: "safe" }
15
16
  | { level: "data_loss"; statements: string[] };
16
17
 
18
+ export type TransactionMode = "transactional" | "none";
19
+
20
+ /**
21
+ * Why a migration unit starts a new execution boundary.
22
+ *
23
+ * - `"default"` — the first (or only) unit of the plan.
24
+ * - `"non_transactional"` — the unit's statement cannot run inside a
25
+ * transaction block (see `BaseChange.nonTransactional`).
26
+ * - commit-visibility kinds (see `BaseChange.commitBoundary`) — the previous
27
+ * unit produced effects that are only usable after COMMIT.
28
+ */
29
+ export type ExecutionBoundaryReason =
30
+ | "default"
31
+ | "non_transactional"
32
+ | CommitBoundaryReason;
33
+
34
+ /**
35
+ * An ordered group of SQL statements that share one execution context.
36
+ *
37
+ * Transactional units are applied inside an explicit BEGIN/COMMIT;
38
+ * non-transactional units run their single statement without a wrapper.
39
+ */
40
+ export interface MigrationUnit {
41
+ transactionMode: TransactionMode;
42
+ reason: ExecutionBoundaryReason;
43
+ statements: string[];
44
+ }
45
+
17
46
  /**
18
47
  * All supported object types in the system.
19
48
  * Derived from the Change union type's objectType discriminant.
@@ -127,7 +156,26 @@ export const PlanSchema = z.object({
127
156
  target: z.object({
128
157
  fingerprint: z.string(),
129
158
  }),
130
- statements: z.array(z.string()),
159
+ units: z
160
+ .array(
161
+ z.object({
162
+ transactionMode: z.enum(["transactional", "none"]),
163
+ reason: z.enum([
164
+ "default",
165
+ "non_transactional",
166
+ "enum_value_visibility",
167
+ ]),
168
+ statements: z.array(z.string()),
169
+ }),
170
+ )
171
+ .optional(),
172
+ /** Session-level statements (SET ROLE, ...) applied once before the units. */
173
+ sessionStatements: z.array(z.string()).optional(),
174
+ /**
175
+ * Legacy v1 plans only: the flat statement list. Converted to units by
176
+ * `normalizePlan`; never emitted by `createPlan`/`serializePlan`.
177
+ */
178
+ statements: z.array(z.string()).optional(),
131
179
  role: z.string().optional(),
132
180
  filter: z.any().optional(), // FilterDSL - complex recursive type, validated at compile time
133
181
  serialize: z.any().optional(), // SerializeDSL - complex recursive type, validated at compile time
@@ -144,10 +192,22 @@ export const PlanSchema = z.object({
144
192
  .optional(),
145
193
  });
146
194
 
195
+ export type SerializedPlan = z.infer<typeof PlanSchema>;
196
+
147
197
  /**
148
- * A migration plan containing all changes to transform one database schema into another.
198
+ * A migration plan containing all changes to transform one database schema
199
+ * into another, as an ordered list of execution-aware migration units.
200
+ *
201
+ * `units` and `sessionStatements` are the single source of truth: render via
202
+ * `renderPlanSql`/`renderPlanFiles`, or flatten via `flattenPlanStatements`.
149
203
  */
150
- export type Plan = z.infer<typeof PlanSchema>;
204
+ export type Plan = Omit<
205
+ SerializedPlan,
206
+ "units" | "statements" | "sessionStatements"
207
+ > & {
208
+ units: MigrationUnit[];
209
+ sessionStatements: string[];
210
+ };
151
211
 
152
212
  /**
153
213
  * Options for creating a plan.
@@ -39,6 +39,7 @@ import {
39
39
  performStableTopologicalSort,
40
40
  } from "./topological-sort.ts";
41
41
  import type { PgDependRow, PhaseSortOptions } from "./types.ts";
42
+ import { UnorderableCycleError } from "./unorderable-cycle-error.ts";
42
43
  import { getExecutionPhase, type Phase } from "./utils.ts";
43
44
 
44
45
  // `sortPhaseChanges` caps the change-injection breaker at one round per
@@ -238,7 +239,9 @@ function attemptSortRound(
238
239
 
239
240
  if (!topologicalOrder || topologicalOrder.length !== phaseChanges.length) {
240
241
  // This should never happen if findCycle returned null, but guard anyway
241
- throw new Error("CycleError: dependency graph contains a cycle");
242
+ throw new UnorderableCycleError(
243
+ "CycleError: dependency graph contains a cycle",
244
+ );
242
245
  }
243
246
 
244
247
  return {
@@ -287,12 +290,13 @@ function sortPhaseChanges(
287
290
  phaseChanges,
288
291
  );
289
292
  if (broken === null) {
290
- throw new Error(
293
+ throw new UnorderableCycleError(
291
294
  formatCycleError(
292
295
  result.cycleNodeIndexes,
293
296
  phaseChanges,
294
297
  result.cycleEdges,
295
298
  ),
299
+ result.cycleNodeIndexes.map((index) => phaseChanges[index]),
296
300
  );
297
301
  }
298
302
 
@@ -300,12 +304,13 @@ function sortPhaseChanges(
300
304
  // the breaker isn't making progress. Throw with full context.
301
305
  const signature = normalizeCycle(result.cycleNodeIndexes);
302
306
  if (breakerRoundSignatures.has(signature)) {
303
- throw new Error(
307
+ throw new UnorderableCycleError(
304
308
  formatCycleError(
305
309
  result.cycleNodeIndexes,
306
310
  phaseChanges,
307
311
  result.cycleEdges,
308
312
  ),
313
+ result.cycleNodeIndexes.map((index) => phaseChanges[index]),
309
314
  );
310
315
  }
311
316
  breakerRoundSignatures.add(signature);
@@ -313,7 +318,7 @@ function sortPhaseChanges(
313
318
  phaseChanges = broken;
314
319
  }
315
320
 
316
- throw new Error(
321
+ throw new UnorderableCycleError(
317
322
  `CycleError: change-injection breaker exceeded ${maxRounds} rounds (one per node in the phase) — likely a buggy breaker rule`,
318
323
  );
319
324
  }
@@ -0,0 +1,60 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createEmptyCatalog } from "../catalog.model.ts";
3
+ import type { Change } from "../change.types.ts";
4
+ import { BaseChange } from "../objects/base.change.ts";
5
+ import { sortChanges } from "./sort-changes.ts";
6
+ import { UnorderableCycleError } from "./unorderable-cycle-error.ts";
7
+
8
+ class MutualCreateChange extends BaseChange {
9
+ readonly operation = "create";
10
+ readonly objectType = "table";
11
+ readonly scope = "object";
12
+ readonly table: { schema: string; name: string };
13
+ private readonly dependsOn: string;
14
+
15
+ constructor(name: string, dependsOn: string) {
16
+ super();
17
+ this.table = { schema: "public", name };
18
+ this.dependsOn = dependsOn;
19
+ }
20
+
21
+ override get creates() {
22
+ return [`table:public.${this.table.name}`];
23
+ }
24
+
25
+ override get requires() {
26
+ return [`table:public.${this.dependsOn}`];
27
+ }
28
+
29
+ serialize(): string {
30
+ return `CREATE TABLE public.${this.table.name} ()`;
31
+ }
32
+ }
33
+
34
+ describe("UnorderableCycleError", () => {
35
+ test("sortChanges throws a typed error carrying the offending cycle", async () => {
36
+ const a = new MutualCreateChange("a", "b");
37
+ const b = new MutualCreateChange("b", "a");
38
+ const catalog = await createEmptyCatalog(170000, "postgres");
39
+
40
+ let thrown: unknown;
41
+ try {
42
+ sortChanges({ mainCatalog: catalog, branchCatalog: catalog }, [
43
+ a,
44
+ b,
45
+ ] as unknown as Change[]);
46
+ } catch (error) {
47
+ thrown = error;
48
+ }
49
+
50
+ expect(thrown).toBeInstanceOf(UnorderableCycleError);
51
+ if (!(thrown instanceof UnorderableCycleError)) {
52
+ throw new Error("expected UnorderableCycleError");
53
+ }
54
+ expect(thrown.name).toBe("UnorderableCycleError");
55
+ expect(thrown.message).toContain("CycleError");
56
+ expect(new Set(thrown.cycle)).toEqual(
57
+ new Set<Change>([a, b] as unknown as Change[]),
58
+ );
59
+ });
60
+ });
@@ -0,0 +1,23 @@
1
+ import type { Change } from "../change.types.ts";
2
+
3
+ /**
4
+ * Thrown by `sortChanges` when the dependency graph contains a cycle that
5
+ * neither weak-edge filtering nor the change-injection cycle breakers could
6
+ * resolve.
7
+ *
8
+ * `message` is the human-readable `formatCycleError` output (it starts with
9
+ * "CycleError:" for backward compatibility with log greps).
10
+ */
11
+ export class UnorderableCycleError extends Error {
12
+ override readonly name = "UnorderableCycleError";
13
+ /**
14
+ * Changes participating in the cycle, in cycle order. Empty when the
15
+ * failure came from an internal guard rather than a concrete cycle.
16
+ */
17
+ readonly cycle: readonly Change[];
18
+
19
+ constructor(message: string, cycle: readonly Change[] = []) {
20
+ super(message);
21
+ this.cycle = cycle;
22
+ }
23
+ }
package/src/index.ts CHANGED
@@ -30,12 +30,29 @@ export type {
30
30
  export type { IntegrationDSL } from "./core/integrations/integration-dsl.ts";
31
31
 
32
32
  // Plan operations
33
+ export type { Change } from "./core/change.types.ts";
33
34
  export { applyPlan } from "./core/plan/apply.ts";
34
35
  export type { CatalogInput } from "./core/plan/create.ts";
35
36
  export { createPlan } from "./core/plan/create.ts";
37
+ export type {
38
+ RenderedPlanFile,
39
+ RenderPlanSqlOptions,
40
+ } from "./core/plan/render.ts";
41
+ export {
42
+ flattenPlanStatements,
43
+ renderPlanFiles,
44
+ renderPlanSql,
45
+ } from "./core/plan/render.ts";
36
46
  export type { SqlFormatOptions } from "./core/plan/sql-format.ts";
37
47
  export { formatSqlStatements } from "./core/plan/sql-format.ts";
38
- export type { CreatePlanOptions, Plan } from "./core/plan/types.ts";
48
+ export type {
49
+ CreatePlanOptions,
50
+ ExecutionBoundaryReason,
51
+ MigrationUnit,
52
+ Plan,
53
+ TransactionMode,
54
+ } from "./core/plan/types.ts";
55
+ export { UnorderableCycleError } from "./core/sort/unorderable-cycle-error.ts";
39
56
 
40
57
  // Postgres config
41
58
  export { createManagedPool } from "./core/postgres-config.ts";