@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.
- package/dist/cli/bin/cli.js +8 -1
- package/dist/cli/commands/plan.js +33 -1
- package/dist/cli/utils.d.ts +3 -0
- package/dist/cli/utils.js +6 -3
- package/dist/core/objects/base.change.d.ts +39 -2
- package/dist/core/objects/base.change.js +32 -3
- package/dist/core/objects/subscription/changes/subscription.alter.d.ts +1 -0
- package/dist/core/objects/subscription/changes/subscription.alter.js +5 -0
- package/dist/core/objects/subscription/changes/subscription.create.js +10 -1
- package/dist/core/objects/subscription/changes/subscription.drop.d.ts +1 -0
- package/dist/core/objects/subscription/changes/subscription.drop.js +5 -0
- package/dist/core/objects/type/enum/changes/enum.alter.d.ts +1 -0
- package/dist/core/objects/type/enum/changes/enum.alter.js +4 -0
- package/dist/core/plan/apply.d.ts +10 -1
- package/dist/core/plan/apply.js +64 -29
- package/dist/core/plan/create.js +5 -31
- package/dist/core/plan/execution.d.ts +21 -0
- package/dist/core/plan/execution.js +76 -0
- package/dist/core/plan/index.d.ts +1 -1
- package/dist/core/plan/index.js +1 -1
- package/dist/core/plan/io.d.ts +2 -1
- package/dist/core/plan/io.js +4 -2
- package/dist/core/plan/normalize.d.ts +11 -0
- package/dist/core/plan/normalize.js +33 -0
- package/dist/core/plan/render.d.ts +32 -0
- package/dist/core/plan/render.js +104 -0
- package/dist/core/plan/types.d.ts +47 -3
- package/dist/core/plan/types.js +18 -1
- package/dist/core/sort/sort-changes.js +5 -4
- package/dist/core/sort/unorderable-cycle-error.d.ts +18 -0
- package/dist/core/sort/unorderable-cycle-error.js +20 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +2 -1
- package/package.json +1 -1
- package/src/cli/bin/cli.ts +9 -1
- package/src/cli/commands/plan.ts +47 -1
- package/src/cli/utils.ts +21 -5
- package/src/core/catalog.snapshot.test.ts +2 -1
- package/src/core/objects/base.change.ts +44 -3
- package/src/core/objects/subscription/changes/subscription.alter.ts +6 -0
- package/src/core/objects/subscription/changes/subscription.create.test.ts +4 -1
- package/src/core/objects/subscription/changes/subscription.create.ts +10 -1
- package/src/core/objects/subscription/changes/subscription.drop.ts +6 -0
- package/src/core/objects/subscription/changes/subscription.traits.test.ts +83 -0
- package/src/core/objects/type/enum/changes/enum.alter.ts +5 -0
- package/src/core/plan/apply.ts +84 -39
- package/src/core/plan/create.ts +5 -46
- package/src/core/plan/execution.test.ts +231 -0
- package/src/core/plan/execution.ts +115 -0
- package/src/core/plan/index.ts +1 -1
- package/src/core/plan/io.ts +4 -2
- package/src/core/plan/normalize.test.ts +69 -0
- package/src/core/plan/normalize.ts +40 -0
- package/src/core/plan/render.test.ts +134 -0
- package/src/core/plan/render.ts +153 -0
- package/src/core/plan/sql-format/format-off.test.ts +1 -1
- package/src/core/plan/sql-format/format-pretty-lower-leading.test.ts +1 -0
- package/src/core/plan/sql-format/format-pretty-narrow.test.ts +2 -1
- package/src/core/plan/sql-format/format-pretty-preserve.test.ts +2 -1
- package/src/core/plan/sql-format/format-pretty-upper.test.ts +2 -1
- package/src/core/plan/types.ts +63 -3
- package/src/core/sort/sort-changes.ts +9 -4
- package/src/core/sort/unorderable-cycle-error.test.ts +60 -0
- package/src/core/sort/unorderable-cycle-error.ts +23 -0
- 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
|
+
}
|
package/dist/core/plan/index.js
CHANGED
package/dist/core/plan/io.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/plan/io.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 =
|
|
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
|
*/
|
package/dist/core/plan/types.js
CHANGED
|
@@ -14,7 +14,24 @@ export const PlanSchema = z.object({
|
|
|
14
14
|
target: z.object({
|
|
15
15
|
fingerprint: z.string(),
|
|
16
16
|
}),
|
|
17
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
package/src/cli/bin/cli.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/cli/commands/plan.ts
CHANGED
|
@@ -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
|
+
}
|