@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
package/dist/cli/bin/cli.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/dist/cli/utils.d.ts
CHANGED
|
@@ -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
|
-
|
|
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(`
|
|
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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
|
16
|
-
return
|
|
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 (
|
|
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;
|
|
@@ -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
|
}
|
|
@@ -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
|
|
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 {};
|
package/dist/core/plan/apply.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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 (
|
|
68
|
-
const filterFn = compileFilterDSL(
|
|
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 ===
|
|
76
|
+
if (fingerprintFrom === normalizedPlan.target.fingerprint) {
|
|
76
77
|
return { status: "already_applied" };
|
|
77
78
|
}
|
|
78
|
-
if (fingerprintFrom !==
|
|
79
|
+
if (fingerprintFrom !== normalizedPlan.source.fingerprint) {
|
|
79
80
|
return {
|
|
80
81
|
status: "fingerprint_mismatch",
|
|
81
82
|
current: fingerprintFrom,
|
|
82
|
-
expected:
|
|
83
|
+
expected: normalizedPlan.source.fingerprint,
|
|
83
84
|
};
|
|
84
85
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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 {
|
|
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 !==
|
|
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
|
|
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
|
+
}
|
package/dist/core/plan/create.js
CHANGED
|
@@ -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
|
|
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:
|
|
223
|
+
version: 2,
|
|
227
224
|
source: { fingerprint: fingerprintFrom },
|
|
228
225
|
target: { fingerprint: fingerprintTo },
|
|
229
|
-
|
|
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 {};
|