@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/src/cli/utils.ts
CHANGED
|
@@ -9,9 +9,9 @@ import type { Change } from "../core/change.types.ts";
|
|
|
9
9
|
import type { DiffContext } from "../core/context.ts";
|
|
10
10
|
import { groupChangesHierarchically } from "../core/plan/hierarchy.ts";
|
|
11
11
|
import { type Plan, serializePlan } from "../core/plan/index.ts";
|
|
12
|
+
import { renderPlanSql } from "../core/plan/render.ts";
|
|
12
13
|
import { classifyChangesRisk } from "../core/plan/risk.ts";
|
|
13
14
|
import type { SqlFormatOptions } from "../core/plan/sql-format.ts";
|
|
14
|
-
import { formatSqlScript } from "../core/plan/statements.ts";
|
|
15
15
|
import { formatTree } from "./formatters/index.ts";
|
|
16
16
|
|
|
17
17
|
// Re-export ApplyPlanResult type for convenience
|
|
@@ -19,8 +19,19 @@ type ApplyPlanResult =
|
|
|
19
19
|
| { status: "invalid_plan"; message: string }
|
|
20
20
|
| { status: "fingerprint_mismatch"; current: string; expected: string }
|
|
21
21
|
| { status: "already_applied" }
|
|
22
|
-
| {
|
|
23
|
-
|
|
22
|
+
| {
|
|
23
|
+
status: "applied";
|
|
24
|
+
statements: number;
|
|
25
|
+
units: number;
|
|
26
|
+
warnings?: string[];
|
|
27
|
+
}
|
|
28
|
+
| {
|
|
29
|
+
status: "failed";
|
|
30
|
+
error: unknown;
|
|
31
|
+
script: string;
|
|
32
|
+
failedUnitIndex?: number;
|
|
33
|
+
completedUnits: number;
|
|
34
|
+
};
|
|
24
35
|
|
|
25
36
|
type PlanResult = {
|
|
26
37
|
plan: Plan;
|
|
@@ -50,7 +61,7 @@ export function formatPlanForDisplay(
|
|
|
50
61
|
case "sql": {
|
|
51
62
|
const content = [
|
|
52
63
|
`-- Risk: ${risk.level === "data_loss" ? `data-loss (${risk.statements.length})` : "safe"}`,
|
|
53
|
-
|
|
64
|
+
renderPlanSql(plan, { sqlFormatOptions: options.sqlFormatOptions }),
|
|
54
65
|
].join("\n");
|
|
55
66
|
return { content, label: "Migration script" };
|
|
56
67
|
}
|
|
@@ -184,12 +195,17 @@ export function handleApplyResult(
|
|
|
184
195
|
context.process.stderr.write(
|
|
185
196
|
`Failed to apply changes: ${result.error instanceof Error ? result.error.message : String(result.error)}\n`,
|
|
186
197
|
);
|
|
198
|
+
if (result.failedUnitIndex !== undefined) {
|
|
199
|
+
context.process.stderr.write(
|
|
200
|
+
`Failed in migration unit ${result.failedUnitIndex + 1} (${result.completedUnits} unit${result.completedUnits === 1 ? "" : "s"} already committed).\n`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
187
203
|
context.process.stderr.write(`Migration script:\n${result.script}\n`);
|
|
188
204
|
return { exitCode: 1 };
|
|
189
205
|
}
|
|
190
206
|
case "applied": {
|
|
191
207
|
context.process.stdout.write(
|
|
192
|
-
`
|
|
208
|
+
`Applied ${result.statements} change${result.statements === 1 ? "" : "s"} across ${result.units} migration unit${result.units === 1 ? "" : "s"}.\n`,
|
|
193
209
|
);
|
|
194
210
|
context.process.stdout.write("Successfully applied all changes.\n");
|
|
195
211
|
if (result.warnings?.length) {
|
|
@@ -12,6 +12,7 @@ import { Role } from "./objects/role/role.model.ts";
|
|
|
12
12
|
import { Schema } from "./objects/schema/schema.model.ts";
|
|
13
13
|
import { Sequence } from "./objects/sequence/sequence.model.ts";
|
|
14
14
|
import { Table } from "./objects/table/table.model.ts";
|
|
15
|
+
import { flattenPlanStatements } from "./plan/render.ts";
|
|
15
16
|
|
|
16
17
|
function emptyCatalogProps() {
|
|
17
18
|
return {
|
|
@@ -330,7 +331,7 @@ describe("catalog snapshot serde", () => {
|
|
|
330
331
|
|
|
331
332
|
const result = await createPlan(null, target);
|
|
332
333
|
expect(result).not.toBeNull();
|
|
333
|
-
expect(result
|
|
334
|
+
expect(flattenPlanStatements(result!.plan).length).toBeGreaterThan(0);
|
|
334
335
|
});
|
|
335
336
|
|
|
336
337
|
test("createPlan with filter DSL without cascade keeps dependents of excluded changes", async () => {
|
|
@@ -2,6 +2,17 @@ import type { SerializeOptions } from "../integrations/serialize/serialize.types
|
|
|
2
2
|
|
|
3
3
|
type ChangeOperation = "create" | "alter" | "drop";
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Kinds of commit-visibility boundaries a change can force.
|
|
7
|
+
*
|
|
8
|
+
* Each kind names a PostgreSQL behavior where a statement's effects only
|
|
9
|
+
* become usable by later statements after the enclosing transaction commits.
|
|
10
|
+
* The token becomes the `reason` of the migration unit that follows the
|
|
11
|
+
* producer run, so adding a kind requires a matching arm in the renderer's
|
|
12
|
+
* `unitName` switch (the exhaustive switch enforces this at compile time).
|
|
13
|
+
*/
|
|
14
|
+
export type CommitBoundaryReason = "enum_value_visibility";
|
|
15
|
+
|
|
5
16
|
/**
|
|
6
17
|
* Abstract base class for all change objects.
|
|
7
18
|
*
|
|
@@ -27,10 +38,40 @@ export abstract class BaseChange {
|
|
|
27
38
|
abstract readonly scope: string;
|
|
28
39
|
|
|
29
40
|
/**
|
|
30
|
-
*
|
|
41
|
+
* True when the serialized statement cannot run inside a transaction block
|
|
42
|
+
* (PostgreSQL rejects it with SQLSTATE 25001, e.g. `CREATE INDEX
|
|
43
|
+
* CONCURRENTLY`, `CREATE SUBSCRIPTION` with `connect = true`).
|
|
44
|
+
*
|
|
45
|
+
* The planner emits such a change as its own single-statement migration
|
|
46
|
+
* unit with `transactionMode: "none"`, and `applyPlan` executes it without
|
|
47
|
+
* a `BEGIN`/`COMMIT` wrapper. Never derive this from the rendered SQL —
|
|
48
|
+
* declare it on the change class.
|
|
49
|
+
*
|
|
50
|
+
* Defaults to false. Override in subclasses whose statement PostgreSQL
|
|
51
|
+
* forbids inside a transaction block.
|
|
52
|
+
*/
|
|
53
|
+
get nonTransactional(): boolean {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Non-null when this statement's effects only become usable by later
|
|
59
|
+
* statements after the enclosing transaction commits. The canonical case is
|
|
60
|
+
* `ALTER TYPE ... ADD VALUE`: using the new enum value in the same
|
|
61
|
+
* transaction fails with 55P04.
|
|
62
|
+
*
|
|
63
|
+
* This is a conservative boundary signal: the planner groups consecutive
|
|
64
|
+
* producers of the same kind into one unit, and pushes any other statement
|
|
65
|
+
* (different kind or non-producer) past a commit boundary, regardless of
|
|
66
|
+
* whether it references the produced effects. No consumer detection is
|
|
67
|
+
* attempted. Never derive this from the rendered SQL — declare it on the
|
|
68
|
+
* change class.
|
|
69
|
+
*
|
|
70
|
+
* Defaults to null. Override in subclasses whose effects PostgreSQL defers
|
|
71
|
+
* until commit.
|
|
31
72
|
*/
|
|
32
|
-
get
|
|
33
|
-
return
|
|
73
|
+
get commitBoundary(): CommitBoundaryReason | null {
|
|
74
|
+
return null;
|
|
34
75
|
}
|
|
35
76
|
|
|
36
77
|
/**
|
|
@@ -31,6 +31,12 @@ export class AlterSubscriptionSetPublication extends AlterSubscriptionChange {
|
|
|
31
31
|
this.subscription = props.subscription;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// When the subscription is enabled, serialize() keeps the refresh = true
|
|
35
|
+
// default, which PostgreSQL rejects inside a transaction block.
|
|
36
|
+
override get nonTransactional() {
|
|
37
|
+
return this.subscription.enabled;
|
|
38
|
+
}
|
|
39
|
+
|
|
34
40
|
serialize(_options?: SerializeOptions): string {
|
|
35
41
|
const base = `ALTER SUBSCRIPTION ${this.subscription.name} SET PUBLICATION ${this.subscription.publications.join(", ")}`;
|
|
36
42
|
if (!this.subscription.enabled) {
|
|
@@ -45,8 +45,11 @@ describe("subscription.create", () => {
|
|
|
45
45
|
expect(change.creates).toEqual([subscription.stableId]);
|
|
46
46
|
expect(change.requires).toEqual([stableId.role(subscription.owner)]);
|
|
47
47
|
await assertValidSql(change.serialize());
|
|
48
|
+
// The slot already exists on the publisher, so the statement must reuse
|
|
49
|
+
// it: create_slot defaults to true and would fail with "replication slot
|
|
50
|
+
// already exists" (and 25001 inside a transaction block).
|
|
48
51
|
expect(change.serialize()).toBe(
|
|
49
|
-
"CREATE SUBSCRIPTION sub_base CONNECTION 'host=example dbname=postgres' PUBLICATION pub_base",
|
|
52
|
+
"CREATE SUBSCRIPTION sub_base CONNECTION 'host=example dbname=postgres' PUBLICATION pub_base WITH (create_slot = false)",
|
|
50
53
|
);
|
|
51
54
|
});
|
|
52
55
|
|
|
@@ -23,6 +23,11 @@ export class CreateSubscription extends CreateSubscriptionChange {
|
|
|
23
23
|
return [stableId.role(this.subscription.owner)];
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// No nonTransactional override: PostgreSQL's transaction-block gate for
|
|
27
|
+
// CREATE SUBSCRIPTION is on create_slot = true, and serialize() always
|
|
28
|
+
// emits create_slot = false (either reusing an existing slot or skipping
|
|
29
|
+
// the connect entirely).
|
|
30
|
+
|
|
26
31
|
serialize(_options?: SerializeOptions): string {
|
|
27
32
|
const parts: string[] = [
|
|
28
33
|
"CREATE SUBSCRIPTION",
|
|
@@ -41,7 +46,11 @@ export class CreateSubscription extends CreateSubscriptionChange {
|
|
|
41
46
|
optionEntries.map(({ key, value }) => [key, value]),
|
|
42
47
|
);
|
|
43
48
|
|
|
44
|
-
if (
|
|
49
|
+
if (this.subscription.replication_slot_created) {
|
|
50
|
+
// The slot already exists on the publisher: keep the connect = true
|
|
51
|
+
// default so it is looked up, but never recreated.
|
|
52
|
+
optionsMap.set("create_slot", "false");
|
|
53
|
+
} else {
|
|
45
54
|
optionsMap.set("create_slot", "false");
|
|
46
55
|
optionsMap.set("connect", "false");
|
|
47
56
|
|
|
@@ -15,6 +15,12 @@ export class DropSubscription extends DropSubscriptionChange {
|
|
|
15
15
|
return [this.subscription.stableId];
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
// PostgreSQL forbids DROP SUBSCRIPTION inside a transaction block when a
|
|
19
|
+
// replication slot is associated with the subscription.
|
|
20
|
+
override get nonTransactional() {
|
|
21
|
+
return !this.subscription.slot_is_none;
|
|
22
|
+
}
|
|
23
|
+
|
|
18
24
|
serialize(_options?: SerializeOptions): string {
|
|
19
25
|
return `DROP SUBSCRIPTION ${this.subscription.name}`;
|
|
20
26
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { Subscription, SubscriptionProps } from "../subscription.model.ts";
|
|
3
|
+
import { Subscription as SubscriptionModel } from "../subscription.model.ts";
|
|
4
|
+
import { AlterSubscriptionSetPublication } from "./subscription.alter.ts";
|
|
5
|
+
import { CreateSubscription } from "./subscription.create.ts";
|
|
6
|
+
import { DropSubscription } from "./subscription.drop.ts";
|
|
7
|
+
|
|
8
|
+
function makeSubscription(
|
|
9
|
+
overrides: Partial<SubscriptionProps> = {},
|
|
10
|
+
): Subscription {
|
|
11
|
+
return new SubscriptionModel({
|
|
12
|
+
name: "sub_orders",
|
|
13
|
+
raw_name: "sub_orders",
|
|
14
|
+
owner: "postgres",
|
|
15
|
+
comment: null,
|
|
16
|
+
enabled: false,
|
|
17
|
+
binary: false,
|
|
18
|
+
streaming: "off",
|
|
19
|
+
two_phase: false,
|
|
20
|
+
disable_on_error: false,
|
|
21
|
+
password_required: true,
|
|
22
|
+
run_as_owner: false,
|
|
23
|
+
failover: false,
|
|
24
|
+
conninfo: "host=publisher dbname=app",
|
|
25
|
+
slot_name: "sub_orders",
|
|
26
|
+
slot_is_none: false,
|
|
27
|
+
replication_slot_created: false,
|
|
28
|
+
synchronous_commit: "off",
|
|
29
|
+
publications: ["pub_orders"],
|
|
30
|
+
origin: "any",
|
|
31
|
+
...overrides,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("subscription transaction-block traits", () => {
|
|
36
|
+
test("CREATE SUBSCRIPTION is always transactional", () => {
|
|
37
|
+
// PostgreSQL's transaction-block gate is on create_slot = true, and
|
|
38
|
+
// serialize() always emits create_slot = false: connect stays true when
|
|
39
|
+
// the slot already exists (it is reused, never recreated), and connect
|
|
40
|
+
// is false otherwise.
|
|
41
|
+
const withSlot = new CreateSubscription({
|
|
42
|
+
subscription: makeSubscription({ replication_slot_created: true }),
|
|
43
|
+
});
|
|
44
|
+
expect(withSlot.nonTransactional).toBe(false);
|
|
45
|
+
expect(withSlot.serialize()).toContain("create_slot = false");
|
|
46
|
+
|
|
47
|
+
const withoutSlot = new CreateSubscription({
|
|
48
|
+
subscription: makeSubscription({ replication_slot_created: false }),
|
|
49
|
+
});
|
|
50
|
+
expect(withoutSlot.nonTransactional).toBe(false);
|
|
51
|
+
expect(withoutSlot.serialize()).toContain("create_slot = false");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("ALTER SUBSCRIPTION SET PUBLICATION with implicit refresh cannot run in a transaction block", () => {
|
|
55
|
+
// serialize() omits WITH (refresh = false) when the subscription is
|
|
56
|
+
// enabled, and refresh = true is rejected inside a transaction block.
|
|
57
|
+
const change = new AlterSubscriptionSetPublication({
|
|
58
|
+
subscription: makeSubscription({ enabled: true }),
|
|
59
|
+
});
|
|
60
|
+
expect(change.nonTransactional).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("ALTER SUBSCRIPTION SET PUBLICATION with refresh = false is transactional", () => {
|
|
64
|
+
const change = new AlterSubscriptionSetPublication({
|
|
65
|
+
subscription: makeSubscription({ enabled: false }),
|
|
66
|
+
});
|
|
67
|
+
expect(change.nonTransactional).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("DROP SUBSCRIPTION with an associated slot cannot run in a transaction block", () => {
|
|
71
|
+
const change = new DropSubscription({
|
|
72
|
+
subscription: makeSubscription({ slot_is_none: false }),
|
|
73
|
+
});
|
|
74
|
+
expect(change.nonTransactional).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("DROP SUBSCRIPTION with slot_name = NONE is transactional", () => {
|
|
78
|
+
const change = new DropSubscription({
|
|
79
|
+
subscription: makeSubscription({ slot_is_none: true, slot_name: null }),
|
|
80
|
+
});
|
|
81
|
+
expect(change.nonTransactional).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -71,6 +71,11 @@ export class AlterEnumAddValue extends AlterEnumChange {
|
|
|
71
71
|
return [this.enum.stableId];
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
// New enum values are not usable until the transaction commits (55P04).
|
|
75
|
+
override get commitBoundary() {
|
|
76
|
+
return "enum_value_visibility" as const;
|
|
77
|
+
}
|
|
78
|
+
|
|
74
79
|
serialize(_options?: SerializeOptions): string {
|
|
75
80
|
const parts = [
|
|
76
81
|
"ALTER TYPE",
|
package/src/core/plan/apply.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Plan application - execute migration plans against target databases.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import type { Pool } from "pg";
|
|
5
|
+
import type { Pool, PoolClient } from "pg";
|
|
6
6
|
import { diffCatalogs } from "../catalog.diff.ts";
|
|
7
7
|
import { extractCatalog } from "../catalog.model.ts";
|
|
8
8
|
import type { DiffContext } from "../context.ts";
|
|
@@ -10,14 +10,29 @@ import { buildPlanScopeFingerprint, hashStableIds } from "../fingerprint.ts";
|
|
|
10
10
|
import { compileFilterDSL } from "../integrations/filter/dsl.ts";
|
|
11
11
|
import { createManagedPool, endPool } from "../postgres-config.ts";
|
|
12
12
|
import { sortChanges } from "../sort/sort-changes.ts";
|
|
13
|
-
import
|
|
13
|
+
import { normalizePlan } from "./normalize.ts";
|
|
14
|
+
import { renderPlanSql } from "./render.ts";
|
|
15
|
+
import type { MigrationUnit, Plan } from "./types.ts";
|
|
14
16
|
|
|
15
17
|
type ApplyPlanResult =
|
|
16
18
|
| { status: "invalid_plan"; message: string }
|
|
17
19
|
| { status: "fingerprint_mismatch"; current: string; expected: string }
|
|
18
20
|
| { status: "already_applied" }
|
|
19
|
-
| {
|
|
20
|
-
|
|
21
|
+
| {
|
|
22
|
+
status: "applied";
|
|
23
|
+
statements: number;
|
|
24
|
+
units: number;
|
|
25
|
+
warnings?: string[];
|
|
26
|
+
}
|
|
27
|
+
| {
|
|
28
|
+
status: "failed";
|
|
29
|
+
error: unknown;
|
|
30
|
+
script: string;
|
|
31
|
+
/** 0-based index of the unit that failed; undefined if a session statement failed. */
|
|
32
|
+
failedUnitIndex?: number;
|
|
33
|
+
/** Number of units fully committed before the failure. */
|
|
34
|
+
completedUnits: number;
|
|
35
|
+
};
|
|
21
36
|
|
|
22
37
|
interface ApplyPlanOptions {
|
|
23
38
|
verifyPostApply?: boolean;
|
|
@@ -26,25 +41,22 @@ interface ApplyPlanOptions {
|
|
|
26
41
|
type ConnectionInput = string | Pool;
|
|
27
42
|
|
|
28
43
|
/**
|
|
29
|
-
*
|
|
30
|
-
* These statements should not be counted as changes.
|
|
31
|
-
*/
|
|
32
|
-
function isSessionStatement(statement: string): boolean {
|
|
33
|
-
return statement.trim().startsWith("SET ");
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Apply a plan's SQL statements to a target database with integrity checks.
|
|
44
|
+
* Apply a plan's migration units to a target database with integrity checks.
|
|
38
45
|
* Validates fingerprints before and after application to ensure plan integrity.
|
|
46
|
+
*
|
|
47
|
+
* Units are applied in order on a single session: transactional units inside
|
|
48
|
+
* an explicit BEGIN/COMMIT, non-transactional units without a wrapper. A
|
|
49
|
+
* failure does not roll back units that already committed.
|
|
39
50
|
*/
|
|
40
|
-
|
|
41
51
|
export async function applyPlan(
|
|
42
52
|
plan: Plan,
|
|
43
53
|
source: ConnectionInput,
|
|
44
54
|
target: ConnectionInput,
|
|
45
55
|
options: ApplyPlanOptions = {},
|
|
46
56
|
): Promise<ApplyPlanResult> {
|
|
47
|
-
|
|
57
|
+
const normalizedPlan = normalizePlan(plan);
|
|
58
|
+
const units = normalizedPlan.units;
|
|
59
|
+
if (units.length === 0) {
|
|
48
60
|
return {
|
|
49
61
|
status: "invalid_plan",
|
|
50
62
|
message: "Plan contains no SQL statements to execute.",
|
|
@@ -58,7 +70,7 @@ export async function applyPlan(
|
|
|
58
70
|
|
|
59
71
|
if (typeof source === "string") {
|
|
60
72
|
const managed = await createManagedPool(source, {
|
|
61
|
-
role:
|
|
73
|
+
role: normalizedPlan.role,
|
|
62
74
|
label: "source",
|
|
63
75
|
});
|
|
64
76
|
currentPool = managed.pool;
|
|
@@ -69,7 +81,7 @@ export async function applyPlan(
|
|
|
69
81
|
|
|
70
82
|
if (typeof target === "string") {
|
|
71
83
|
const managed = await createManagedPool(target, {
|
|
72
|
-
role:
|
|
84
|
+
role: normalizedPlan.role,
|
|
73
85
|
label: "target",
|
|
74
86
|
});
|
|
75
87
|
desiredPool = managed.pool;
|
|
@@ -93,8 +105,8 @@ export async function applyPlan(
|
|
|
93
105
|
|
|
94
106
|
// Apply the same filter that was used to create the plan (if any)
|
|
95
107
|
let filteredChanges = changes;
|
|
96
|
-
if (
|
|
97
|
-
const filterFn = compileFilterDSL(
|
|
108
|
+
if (normalizedPlan.filter) {
|
|
109
|
+
const filterFn = compileFilterDSL(normalizedPlan.filter);
|
|
98
110
|
filteredChanges = filteredChanges.filter((change) => filterFn(change));
|
|
99
111
|
}
|
|
100
112
|
|
|
@@ -106,31 +118,44 @@ export async function applyPlan(
|
|
|
106
118
|
// We intentionally recompute target fingerprint only after applying.
|
|
107
119
|
|
|
108
120
|
// Pre-apply fingerprint validation
|
|
109
|
-
if (fingerprintFrom ===
|
|
121
|
+
if (fingerprintFrom === normalizedPlan.target.fingerprint) {
|
|
110
122
|
return { status: "already_applied" };
|
|
111
123
|
}
|
|
112
124
|
|
|
113
|
-
if (fingerprintFrom !==
|
|
125
|
+
if (fingerprintFrom !== normalizedPlan.source.fingerprint) {
|
|
114
126
|
return {
|
|
115
127
|
status: "fingerprint_mismatch",
|
|
116
128
|
current: fingerprintFrom,
|
|
117
|
-
expected:
|
|
129
|
+
expected: normalizedPlan.source.fingerprint,
|
|
118
130
|
};
|
|
119
131
|
}
|
|
120
132
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const script = (() => {
|
|
126
|
-
const joined = statements.join(";\n");
|
|
127
|
-
return joined.endsWith(";") ? joined : `${joined};`;
|
|
128
|
-
})();
|
|
133
|
+
const script = renderPlanSql(normalizedPlan);
|
|
134
|
+
let completedUnits = 0;
|
|
135
|
+
let unitStarted = false;
|
|
129
136
|
|
|
137
|
+
// A single session for the whole plan: session statements (SET ROLE,
|
|
138
|
+
// SET check_function_bodies) must stay in effect across all units.
|
|
139
|
+
const client = await currentPool.connect();
|
|
130
140
|
try {
|
|
131
|
-
|
|
141
|
+
for (const statement of normalizedPlan.sessionStatements) {
|
|
142
|
+
await client.query(statement);
|
|
143
|
+
}
|
|
144
|
+
for (const unit of units) {
|
|
145
|
+
unitStarted = true;
|
|
146
|
+
await applyUnit(client, unit);
|
|
147
|
+
completedUnits++;
|
|
148
|
+
}
|
|
132
149
|
} catch (error) {
|
|
133
|
-
return {
|
|
150
|
+
return {
|
|
151
|
+
status: "failed",
|
|
152
|
+
error,
|
|
153
|
+
script,
|
|
154
|
+
failedUnitIndex: unitStarted ? completedUnits : undefined,
|
|
155
|
+
completedUnits,
|
|
156
|
+
};
|
|
157
|
+
} finally {
|
|
158
|
+
client.release();
|
|
134
159
|
}
|
|
135
160
|
|
|
136
161
|
const warnings: string[] = [];
|
|
@@ -139,7 +164,7 @@ export async function applyPlan(
|
|
|
139
164
|
try {
|
|
140
165
|
const updatedCatalog = await extractCatalog(currentPool);
|
|
141
166
|
const updatedFingerprint = hashStableIds(updatedCatalog, stableIds);
|
|
142
|
-
if (updatedFingerprint !==
|
|
167
|
+
if (updatedFingerprint !== normalizedPlan.target.fingerprint) {
|
|
143
168
|
warnings.push(
|
|
144
169
|
"Post-apply fingerprint does not match the plan target fingerprint.",
|
|
145
170
|
);
|
|
@@ -151,14 +176,11 @@ export async function applyPlan(
|
|
|
151
176
|
}
|
|
152
177
|
}
|
|
153
178
|
|
|
154
|
-
// Count only actual changes, excluding session configuration statements
|
|
155
|
-
const changeStatements = statements.filter(
|
|
156
|
-
(stmt) => !isSessionStatement(stmt),
|
|
157
|
-
);
|
|
158
|
-
|
|
159
179
|
return {
|
|
160
180
|
status: "applied",
|
|
161
|
-
statements
|
|
181
|
+
// Units contain only change statements; session statements are not counted.
|
|
182
|
+
statements: units.reduce((sum, unit) => sum + unit.statements.length, 0),
|
|
183
|
+
units: units.length,
|
|
162
184
|
warnings: warnings.length ? warnings : undefined,
|
|
163
185
|
};
|
|
164
186
|
} finally {
|
|
@@ -170,3 +192,26 @@ export async function applyPlan(
|
|
|
170
192
|
}
|
|
171
193
|
}
|
|
172
194
|
}
|
|
195
|
+
|
|
196
|
+
async function applyUnit(
|
|
197
|
+
client: PoolClient,
|
|
198
|
+
unit: MigrationUnit,
|
|
199
|
+
): Promise<void> {
|
|
200
|
+
if (unit.transactionMode === "transactional") {
|
|
201
|
+
await client.query("BEGIN");
|
|
202
|
+
try {
|
|
203
|
+
for (const statement of unit.statements) {
|
|
204
|
+
await client.query(statement);
|
|
205
|
+
}
|
|
206
|
+
await client.query("COMMIT");
|
|
207
|
+
} catch (error) {
|
|
208
|
+
await client.query("ROLLBACK").catch(() => {});
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
for (const statement of unit.statements) {
|
|
215
|
+
await client.query(statement);
|
|
216
|
+
}
|
|
217
|
+
}
|
package/src/core/plan/create.ts
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { Pool } from "pg";
|
|
6
|
-
import { escapeIdentifier } from "pg";
|
|
7
6
|
import { diffCatalogs } from "../catalog.diff.ts";
|
|
8
7
|
import type { Catalog } from "../catalog.model.ts";
|
|
9
8
|
import { createEmptyCatalog, extractCatalog } from "../catalog.model.ts";
|
|
@@ -19,6 +18,7 @@ import type { SerializeDSL } from "../integrations/serialize/dsl.ts";
|
|
|
19
18
|
import { createManagedPool, endPool } from "../postgres-config.ts";
|
|
20
19
|
import { sortChanges } from "../sort/sort-changes.ts";
|
|
21
20
|
import type { PgDependRow } from "../sort/types.ts";
|
|
21
|
+
import { buildExecutionPlan } from "./execution.ts";
|
|
22
22
|
import { classifyChangesRisk } from "./risk.ts";
|
|
23
23
|
import type { CreatePlanOptions, Plan } from "./types.ts";
|
|
24
24
|
|
|
@@ -306,10 +306,7 @@ function buildPlan(
|
|
|
306
306
|
integration?: ResolvedIntegration,
|
|
307
307
|
): Plan {
|
|
308
308
|
const role = options?.role;
|
|
309
|
-
const
|
|
310
|
-
integration,
|
|
311
|
-
role,
|
|
312
|
-
});
|
|
309
|
+
const execution = buildExecutionPlan(changes, { integration, role });
|
|
313
310
|
const risk = classifyChangesRisk(changes);
|
|
314
311
|
|
|
315
312
|
const { hash: fingerprintFrom, stableIds } = buildPlanScopeFingerprint(
|
|
@@ -319,52 +316,14 @@ function buildPlan(
|
|
|
319
316
|
const fingerprintTo = hashStableIds(ctx.branchCatalog, stableIds);
|
|
320
317
|
|
|
321
318
|
return {
|
|
322
|
-
version:
|
|
319
|
+
version: 2,
|
|
323
320
|
source: { fingerprint: fingerprintFrom },
|
|
324
321
|
target: { fingerprint: fingerprintTo },
|
|
325
|
-
|
|
322
|
+
units: execution.units,
|
|
323
|
+
sessionStatements: execution.sessionStatements,
|
|
326
324
|
role,
|
|
327
325
|
filter: filterDSL,
|
|
328
326
|
serialize: serializeDSL,
|
|
329
327
|
risk,
|
|
330
328
|
};
|
|
331
329
|
}
|
|
332
|
-
|
|
333
|
-
/**
|
|
334
|
-
* Generate the individual SQL statements that make up the plan.
|
|
335
|
-
*/
|
|
336
|
-
function generateStatements(
|
|
337
|
-
changes: Change[],
|
|
338
|
-
options?: {
|
|
339
|
-
integration?: ResolvedIntegration;
|
|
340
|
-
role?: string;
|
|
341
|
-
},
|
|
342
|
-
): string[] {
|
|
343
|
-
const statements: string[] = [];
|
|
344
|
-
|
|
345
|
-
if (options?.role) {
|
|
346
|
-
statements.push(`SET ROLE ${escapeIdentifier(options.role)}`);
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
if (hasRoutineChanges(changes)) {
|
|
350
|
-
statements.push("SET check_function_bodies = false");
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
for (const change of changes) {
|
|
354
|
-
const sql = options?.integration?.serialize?.(change) ?? change.serialize();
|
|
355
|
-
statements.push(sql);
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
return statements;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
/**
|
|
362
|
-
* Check if any changes involve routines (procedures or aggregates).
|
|
363
|
-
* Used to determine if we need to disable function body checking.
|
|
364
|
-
*/
|
|
365
|
-
function hasRoutineChanges(changes: Change[]): boolean {
|
|
366
|
-
return changes.some(
|
|
367
|
-
(change) =>
|
|
368
|
-
change.objectType === "procedure" || change.objectType === "aggregate",
|
|
369
|
-
);
|
|
370
|
-
}
|