@supabase/pg-delta 1.0.0-alpha.21 → 1.0.0-alpha.22
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/core/catalog.diff.js +4 -3
- package/dist/core/catalog.model.d.ts +8 -1
- package/dist/core/catalog.model.js +9 -8
- package/dist/core/expand-replace-dependencies.js +23 -0
- package/dist/core/objects/extract-with-retry.d.ts +36 -0
- package/dist/core/objects/extract-with-retry.js +51 -0
- package/dist/core/objects/index/index.diff.js +0 -1
- package/dist/core/objects/index/index.model.d.ts +2 -3
- package/dist/core/objects/index/index.model.js +17 -6
- package/dist/core/objects/materialized-view/materialized-view.model.d.ts +2 -1
- package/dist/core/objects/materialized-view/materialized-view.model.js +20 -4
- package/dist/core/objects/procedure/procedure.model.d.ts +2 -1
- package/dist/core/objects/procedure/procedure.model.js +20 -4
- package/dist/core/objects/rls-policy/rls-policy.diff.js +13 -1
- package/dist/core/objects/rule/rule.model.d.ts +2 -1
- package/dist/core/objects/rule/rule.model.js +20 -3
- package/dist/core/objects/sequence/sequence.diff.d.ts +2 -1
- package/dist/core/objects/sequence/sequence.diff.js +28 -4
- package/dist/core/objects/table/changes/table.alter.d.ts +12 -1
- package/dist/core/objects/table/changes/table.alter.js +20 -2
- package/dist/core/objects/table/table.diff.js +19 -15
- package/dist/core/objects/table/table.model.d.ts +6 -1
- package/dist/core/objects/table/table.model.js +40 -5
- package/dist/core/objects/trigger/trigger.model.d.ts +2 -1
- package/dist/core/objects/trigger/trigger.model.js +20 -4
- package/dist/core/objects/utils.d.ts +1 -0
- package/dist/core/objects/utils.js +3 -0
- package/dist/core/objects/view/view.model.d.ts +2 -1
- package/dist/core/objects/view/view.model.js +20 -4
- package/dist/core/plan/create.js +3 -1
- package/dist/core/plan/types.d.ts +8 -0
- package/dist/core/{post-diff-cycle-breaking.d.ts → post-diff-normalization.d.ts} +8 -1
- package/dist/core/post-diff-normalization.js +202 -0
- package/dist/core/sort/cycle-breakers.js +1 -1
- package/dist/core/sort/utils.d.ts +10 -0
- package/dist/core/sort/utils.js +28 -0
- package/package.json +1 -1
- package/src/core/catalog.diff.ts +4 -2
- package/src/core/catalog.model.ts +20 -8
- package/src/core/expand-replace-dependencies.test.ts +131 -0
- package/src/core/expand-replace-dependencies.ts +24 -0
- package/src/core/objects/extract-with-retry.test.ts +143 -0
- package/src/core/objects/extract-with-retry.ts +87 -0
- package/src/core/objects/index/index.diff.ts +0 -1
- package/src/core/objects/index/index.model.test.ts +37 -1
- package/src/core/objects/index/index.model.ts +25 -6
- package/src/core/objects/materialized-view/materialized-view.model.test.ts +93 -0
- package/src/core/objects/materialized-view/materialized-view.model.ts +27 -4
- package/src/core/objects/procedure/procedure.model.test.ts +117 -0
- package/src/core/objects/procedure/procedure.model.ts +28 -5
- package/src/core/objects/rls-policy/rls-policy.diff.ts +19 -1
- package/src/core/objects/rule/rule.model.test.ts +99 -0
- package/src/core/objects/rule/rule.model.ts +28 -4
- package/src/core/objects/sequence/sequence.diff.test.ts +87 -0
- package/src/core/objects/sequence/sequence.diff.ts +31 -6
- package/src/core/objects/table/changes/table.alter.test.ts +13 -21
- package/src/core/objects/table/changes/table.alter.ts +30 -3
- package/src/core/objects/table/table.diff.ts +24 -19
- package/src/core/objects/table/table.model.test.ts +209 -0
- package/src/core/objects/table/table.model.ts +52 -7
- package/src/core/objects/trigger/trigger.model.test.ts +113 -0
- package/src/core/objects/trigger/trigger.model.ts +28 -5
- package/src/core/objects/utils.ts +3 -0
- package/src/core/objects/view/view.model.test.ts +90 -0
- package/src/core/objects/view/view.model.ts +28 -5
- package/src/core/plan/create.ts +3 -1
- package/src/core/plan/types.ts +8 -0
- package/src/core/{post-diff-cycle-breaking.test.ts → post-diff-normalization.test.ts} +168 -4
- package/src/core/post-diff-normalization.ts +260 -0
- package/src/core/sort/cycle-breakers.ts +1 -1
- package/src/core/sort/utils.ts +38 -0
- package/dist/core/post-diff-cycle-breaking.js +0 -100
- package/src/core/post-diff-cycle-breaking.ts +0 -138
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { Pool } from "pg";
|
|
3
|
+
import {
|
|
4
|
+
extractMaterializedViews,
|
|
5
|
+
MaterializedView,
|
|
6
|
+
} from "./materialized-view.model.ts";
|
|
7
|
+
|
|
8
|
+
const baseRow = {
|
|
9
|
+
schema: "public",
|
|
10
|
+
row_security: false,
|
|
11
|
+
force_row_security: false,
|
|
12
|
+
has_indexes: false,
|
|
13
|
+
has_rules: false,
|
|
14
|
+
has_triggers: false,
|
|
15
|
+
has_subclasses: false,
|
|
16
|
+
is_populated: true,
|
|
17
|
+
replica_identity: "d" as const,
|
|
18
|
+
is_partition: false,
|
|
19
|
+
options: null,
|
|
20
|
+
partition_bound: null,
|
|
21
|
+
owner: "postgres",
|
|
22
|
+
comment: null,
|
|
23
|
+
columns: [],
|
|
24
|
+
privileges: [],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const mockPool = (rows: unknown[]): Pool =>
|
|
28
|
+
({ query: async () => ({ rows }) }) as unknown as Pool;
|
|
29
|
+
|
|
30
|
+
const mockPoolSequence = (...attempts: unknown[][]): Pool => {
|
|
31
|
+
let i = 0;
|
|
32
|
+
return {
|
|
33
|
+
query: async () => ({
|
|
34
|
+
rows: attempts[Math.min(i++, attempts.length - 1)],
|
|
35
|
+
}),
|
|
36
|
+
} as unknown as Pool;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const NO_BACKOFF = { backoffMs: 0 } as const;
|
|
40
|
+
|
|
41
|
+
describe("extractMaterializedViews", () => {
|
|
42
|
+
test("skips rows where pg_get_viewdef returned NULL after exhausting retries", async () => {
|
|
43
|
+
const mvs = await extractMaterializedViews(
|
|
44
|
+
mockPool([
|
|
45
|
+
{
|
|
46
|
+
...baseRow,
|
|
47
|
+
name: '"good_mv"',
|
|
48
|
+
definition: "SELECT 1",
|
|
49
|
+
},
|
|
50
|
+
{ ...baseRow, name: '"orphan_mv"', definition: null },
|
|
51
|
+
]),
|
|
52
|
+
NO_BACKOFF,
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
expect(mvs).toHaveLength(1);
|
|
56
|
+
expect(mvs[0]).toBeInstanceOf(MaterializedView);
|
|
57
|
+
expect(mvs[0]?.name).toBe('"good_mv"');
|
|
58
|
+
expect(mvs[0]?.definition).toBe("SELECT 1");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("does not throw ZodError when the only row has a null definition", async () => {
|
|
62
|
+
await expect(
|
|
63
|
+
extractMaterializedViews(
|
|
64
|
+
mockPool([{ ...baseRow, name: '"orphan"', definition: null }]),
|
|
65
|
+
NO_BACKOFF,
|
|
66
|
+
),
|
|
67
|
+
).resolves.toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("returns all materialized views when every row has a valid definition", async () => {
|
|
71
|
+
const mvs = await extractMaterializedViews(
|
|
72
|
+
mockPool([
|
|
73
|
+
{ ...baseRow, name: '"a"', definition: "SELECT 1" },
|
|
74
|
+
{ ...baseRow, name: '"b"', definition: "SELECT 2" },
|
|
75
|
+
]),
|
|
76
|
+
NO_BACKOFF,
|
|
77
|
+
);
|
|
78
|
+
expect(mvs.map((m) => m.name)).toEqual(['"a"', '"b"']);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("recovers when pg_get_viewdef is NULL on first attempt but resolved on retry", async () => {
|
|
82
|
+
const mvs = await extractMaterializedViews(
|
|
83
|
+
mockPoolSequence(
|
|
84
|
+
[{ ...baseRow, name: '"racy_mv"', definition: null }],
|
|
85
|
+
[{ ...baseRow, name: '"racy_mv"', definition: "SELECT 42" }],
|
|
86
|
+
),
|
|
87
|
+
{ retries: 2, backoffMs: 0 },
|
|
88
|
+
);
|
|
89
|
+
expect(mvs).toHaveLength(1);
|
|
90
|
+
expect(mvs[0]?.name).toBe('"racy_mv"');
|
|
91
|
+
expect(mvs[0]?.definition).toBe("SELECT 42");
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -10,6 +10,10 @@ import {
|
|
|
10
10
|
type PrivilegeProps,
|
|
11
11
|
privilegePropsSchema,
|
|
12
12
|
} from "../base.privilege-diff.ts";
|
|
13
|
+
import {
|
|
14
|
+
type ExtractRetryOptions,
|
|
15
|
+
extractWithDefinitionRetry,
|
|
16
|
+
} from "../extract-with-retry.ts";
|
|
13
17
|
import { ReplicaIdentitySchema } from "../table/table.model.ts";
|
|
14
18
|
|
|
15
19
|
const materializedViewPropsSchema = z.object({
|
|
@@ -33,6 +37,15 @@ const materializedViewPropsSchema = z.object({
|
|
|
33
37
|
privileges: z.array(privilegePropsSchema),
|
|
34
38
|
});
|
|
35
39
|
|
|
40
|
+
// pg_get_viewdef(oid) can return NULL when the underlying matview (or its
|
|
41
|
+
// pg_rewrite row) is dropped between catalog scan and resolution, or under
|
|
42
|
+
// transient catalog state during recovery. An unreadable matview cannot be
|
|
43
|
+
// diffed, so we accept NULL here and filter the row out at extraction time
|
|
44
|
+
// rather than crashing the whole catalog parse with a ZodError.
|
|
45
|
+
const materializedViewRowSchema = materializedViewPropsSchema.extend({
|
|
46
|
+
definition: z.string().nullable(),
|
|
47
|
+
});
|
|
48
|
+
|
|
36
49
|
type MaterializedViewPrivilegeProps = PrivilegeProps;
|
|
37
50
|
export type MaterializedViewProps = z.infer<typeof materializedViewPropsSchema>;
|
|
38
51
|
|
|
@@ -142,8 +155,14 @@ export class MaterializedView extends BasePgModel implements TableLikeObject {
|
|
|
142
155
|
|
|
143
156
|
export async function extractMaterializedViews(
|
|
144
157
|
pool: Pool,
|
|
158
|
+
options?: ExtractRetryOptions,
|
|
145
159
|
): Promise<MaterializedView[]> {
|
|
146
|
-
const
|
|
160
|
+
const mvRows = await extractWithDefinitionRetry({
|
|
161
|
+
label: "materialized views",
|
|
162
|
+
options,
|
|
163
|
+
hasNullDefinition: (row) => row.definition === null,
|
|
164
|
+
query: async () => {
|
|
165
|
+
const result = await pool.query<MaterializedViewProps>(sql`
|
|
147
166
|
with extension_oids as (
|
|
148
167
|
select
|
|
149
168
|
objid
|
|
@@ -248,9 +267,13 @@ group by
|
|
|
248
267
|
order by
|
|
249
268
|
c.relnamespace::regnamespace, c.relname
|
|
250
269
|
`);
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
270
|
+
return result.rows.map((row: unknown) =>
|
|
271
|
+
materializedViewRowSchema.parse(row),
|
|
272
|
+
);
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
const validatedRows = mvRows.filter(
|
|
276
|
+
(row): row is MaterializedViewProps => row.definition !== null,
|
|
254
277
|
);
|
|
255
278
|
return validatedRows.map(
|
|
256
279
|
(row: MaterializedViewProps) => new MaterializedView(row),
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { Pool } from "pg";
|
|
3
|
+
import { extractProcedures, Procedure } from "./procedure.model.ts";
|
|
4
|
+
|
|
5
|
+
const baseRow = {
|
|
6
|
+
schema: "public",
|
|
7
|
+
kind: "f" as const,
|
|
8
|
+
return_type: "integer",
|
|
9
|
+
return_type_schema: "pg_catalog",
|
|
10
|
+
language: "sql",
|
|
11
|
+
security_definer: false,
|
|
12
|
+
volatility: "v" as const,
|
|
13
|
+
parallel_safety: "u" as const,
|
|
14
|
+
execution_cost: 100,
|
|
15
|
+
result_rows: 0,
|
|
16
|
+
is_strict: false,
|
|
17
|
+
leakproof: false,
|
|
18
|
+
returns_set: false,
|
|
19
|
+
argument_count: 0,
|
|
20
|
+
argument_default_count: 0,
|
|
21
|
+
argument_names: null,
|
|
22
|
+
argument_types: null,
|
|
23
|
+
all_argument_types: null,
|
|
24
|
+
argument_modes: null,
|
|
25
|
+
argument_defaults: null,
|
|
26
|
+
source_code: "select 1",
|
|
27
|
+
binary_path: null,
|
|
28
|
+
sql_body: null,
|
|
29
|
+
config: null,
|
|
30
|
+
owner: "postgres",
|
|
31
|
+
comment: null,
|
|
32
|
+
privileges: [],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const mockPool = (rows: unknown[]): Pool =>
|
|
36
|
+
({ query: async () => ({ rows }) }) as unknown as Pool;
|
|
37
|
+
|
|
38
|
+
const mockPoolSequence = (...attempts: unknown[][]): Pool => {
|
|
39
|
+
let i = 0;
|
|
40
|
+
return {
|
|
41
|
+
query: async () => ({
|
|
42
|
+
rows: attempts[Math.min(i++, attempts.length - 1)],
|
|
43
|
+
}),
|
|
44
|
+
} as unknown as Pool;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const NO_BACKOFF = { backoffMs: 0 } as const;
|
|
48
|
+
|
|
49
|
+
describe("extractProcedures", () => {
|
|
50
|
+
test("skips rows where pg_get_functiondef returned NULL after exhausting retries", async () => {
|
|
51
|
+
const procs = await extractProcedures(
|
|
52
|
+
mockPool([
|
|
53
|
+
{
|
|
54
|
+
...baseRow,
|
|
55
|
+
name: '"good_fn"',
|
|
56
|
+
definition:
|
|
57
|
+
"CREATE OR REPLACE FUNCTION good_fn() RETURNS integer AS $$ select 1 $$ LANGUAGE sql;",
|
|
58
|
+
},
|
|
59
|
+
{ ...baseRow, name: '"orphan_fn"', definition: null },
|
|
60
|
+
]),
|
|
61
|
+
NO_BACKOFF,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
expect(procs).toHaveLength(1);
|
|
65
|
+
expect(procs[0]).toBeInstanceOf(Procedure);
|
|
66
|
+
expect(procs[0]?.name).toBe('"good_fn"');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("does not throw ZodError when the only row has a null definition", async () => {
|
|
70
|
+
await expect(
|
|
71
|
+
extractProcedures(
|
|
72
|
+
mockPool([{ ...baseRow, name: '"orphan"', definition: null }]),
|
|
73
|
+
NO_BACKOFF,
|
|
74
|
+
),
|
|
75
|
+
).resolves.toEqual([]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("returns all procedures when every row has a valid definition", async () => {
|
|
79
|
+
const procs = await extractProcedures(
|
|
80
|
+
mockPool([
|
|
81
|
+
{
|
|
82
|
+
...baseRow,
|
|
83
|
+
name: '"a"',
|
|
84
|
+
definition:
|
|
85
|
+
"CREATE OR REPLACE FUNCTION a() RETURNS integer AS $$ select 1 $$ LANGUAGE sql;",
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
...baseRow,
|
|
89
|
+
name: '"b"',
|
|
90
|
+
definition:
|
|
91
|
+
"CREATE OR REPLACE FUNCTION b() RETURNS integer AS $$ select 2 $$ LANGUAGE sql;",
|
|
92
|
+
},
|
|
93
|
+
]),
|
|
94
|
+
NO_BACKOFF,
|
|
95
|
+
);
|
|
96
|
+
expect(procs.map((p) => p.name)).toEqual(['"a"', '"b"']);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("recovers when pg_get_functiondef is NULL on first attempt but resolved on retry", async () => {
|
|
100
|
+
const procs = await extractProcedures(
|
|
101
|
+
mockPoolSequence(
|
|
102
|
+
[{ ...baseRow, name: '"racy_fn"', definition: null }],
|
|
103
|
+
[
|
|
104
|
+
{
|
|
105
|
+
...baseRow,
|
|
106
|
+
name: '"racy_fn"',
|
|
107
|
+
definition:
|
|
108
|
+
"CREATE OR REPLACE FUNCTION racy_fn() RETURNS integer AS $$ select 1 $$ LANGUAGE sql;",
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
),
|
|
112
|
+
{ retries: 2, backoffMs: 0 },
|
|
113
|
+
);
|
|
114
|
+
expect(procs).toHaveLength(1);
|
|
115
|
+
expect(procs[0]?.name).toBe('"racy_fn"');
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -6,6 +6,10 @@ import {
|
|
|
6
6
|
type PrivilegeProps,
|
|
7
7
|
privilegePropsSchema,
|
|
8
8
|
} from "../base.privilege-diff.ts";
|
|
9
|
+
import {
|
|
10
|
+
type ExtractRetryOptions,
|
|
11
|
+
extractWithDefinitionRetry,
|
|
12
|
+
} from "../extract-with-retry.ts";
|
|
9
13
|
|
|
10
14
|
const FunctionKindSchema = z.enum([
|
|
11
15
|
"f", // function
|
|
@@ -66,6 +70,15 @@ const procedurePropsSchema = z.object({
|
|
|
66
70
|
privileges: z.array(privilegePropsSchema),
|
|
67
71
|
});
|
|
68
72
|
|
|
73
|
+
// pg_get_functiondef(oid) can return NULL when the function (its pg_proc
|
|
74
|
+
// row) is dropped between catalog scan and resolution, or under transient
|
|
75
|
+
// catalog state. An unreadable function cannot be diffed, so we accept NULL
|
|
76
|
+
// here and filter the row out at extraction time rather than crashing the
|
|
77
|
+
// whole catalog parse with a ZodError.
|
|
78
|
+
const procedureRowSchema = procedurePropsSchema.extend({
|
|
79
|
+
definition: z.string().nullable(),
|
|
80
|
+
});
|
|
81
|
+
|
|
69
82
|
type ProcedurePrivilegeProps = PrivilegeProps;
|
|
70
83
|
export type ProcedureProps = z.infer<typeof procedurePropsSchema>;
|
|
71
84
|
|
|
@@ -183,8 +196,16 @@ export class Procedure extends BasePgModel {
|
|
|
183
196
|
}
|
|
184
197
|
}
|
|
185
198
|
|
|
186
|
-
export async function extractProcedures(
|
|
187
|
-
|
|
199
|
+
export async function extractProcedures(
|
|
200
|
+
pool: Pool,
|
|
201
|
+
options?: ExtractRetryOptions,
|
|
202
|
+
): Promise<Procedure[]> {
|
|
203
|
+
const procedureRows = await extractWithDefinitionRetry({
|
|
204
|
+
label: "procedures",
|
|
205
|
+
options,
|
|
206
|
+
hasNullDefinition: (row) => row.definition === null,
|
|
207
|
+
query: async () => {
|
|
208
|
+
const result = await pool.query<ProcedureProps>(sql`
|
|
188
209
|
with extension_oids as (
|
|
189
210
|
select
|
|
190
211
|
objid
|
|
@@ -256,9 +277,11 @@ from
|
|
|
256
277
|
order by
|
|
257
278
|
1, 2
|
|
258
279
|
`);
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
280
|
+
return result.rows.map((row: unknown) => procedureRowSchema.parse(row));
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
const validatedRows = procedureRows.filter(
|
|
284
|
+
(row): row is ProcedureProps => row.definition !== null,
|
|
262
285
|
);
|
|
263
286
|
return validatedRows.map((row: ProcedureProps) => new Procedure(row));
|
|
264
287
|
}
|
|
@@ -59,7 +59,25 @@ export function diffRlsPolicies(
|
|
|
59
59
|
{},
|
|
60
60
|
);
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
// The set of relations and procedures that the policy's USING / WITH
|
|
63
|
+
// CHECK expressions reference is recorded by PostgreSQL in pg_depend
|
|
64
|
+
// (recordDependencyOnExpr at policy creation). When that set changes
|
|
65
|
+
// it is unsafe to ALTER POLICY in place: the old reference target may
|
|
66
|
+
// be dropped in the same plan, and the new reference target may only
|
|
67
|
+
// exist after the create phase. Drop+create lets the sort phase order
|
|
68
|
+
// the policy's drop before the referenced object's drop and the
|
|
69
|
+
// policy's recreate after the referenced object's create.
|
|
70
|
+
const referencedDependenciesChanged = hasNonAlterableChanges(
|
|
71
|
+
mainRlsPolicy,
|
|
72
|
+
branchRlsPolicy,
|
|
73
|
+
["referenced_procedures", "referenced_relations"] as const,
|
|
74
|
+
{
|
|
75
|
+
referenced_procedures: deepEqual,
|
|
76
|
+
referenced_relations: deepEqual,
|
|
77
|
+
},
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
if (nonAlterablePropsChanged || referencedDependenciesChanged) {
|
|
63
81
|
// Replace the entire RLS policy (drop + create)
|
|
64
82
|
changes.push(
|
|
65
83
|
new DropRlsPolicy({ policy: mainRlsPolicy }),
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { Pool } from "pg";
|
|
3
|
+
import { extractRules, Rule } from "./rule.model.ts";
|
|
4
|
+
|
|
5
|
+
const baseRow = {
|
|
6
|
+
schema: "public",
|
|
7
|
+
table_name: '"events"',
|
|
8
|
+
relation_kind: "r" as const,
|
|
9
|
+
event: "INSERT" as const,
|
|
10
|
+
enabled: "O" as const,
|
|
11
|
+
is_instead: false,
|
|
12
|
+
owner: "postgres",
|
|
13
|
+
comment: null,
|
|
14
|
+
columns: [] as string[],
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const mockPool = (rows: unknown[]): Pool =>
|
|
18
|
+
({ query: async () => ({ rows }) }) as unknown as Pool;
|
|
19
|
+
|
|
20
|
+
const mockPoolSequence = (...attempts: unknown[][]): Pool => {
|
|
21
|
+
let i = 0;
|
|
22
|
+
return {
|
|
23
|
+
query: async () => ({
|
|
24
|
+
rows: attempts[Math.min(i++, attempts.length - 1)],
|
|
25
|
+
}),
|
|
26
|
+
} as unknown as Pool;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const NO_BACKOFF = { backoffMs: 0 } as const;
|
|
30
|
+
|
|
31
|
+
describe("extractRules", () => {
|
|
32
|
+
test("skips rows where pg_get_ruledef returned NULL after exhausting retries", async () => {
|
|
33
|
+
const rules = await extractRules(
|
|
34
|
+
mockPool([
|
|
35
|
+
{
|
|
36
|
+
...baseRow,
|
|
37
|
+
name: '"good_rule"',
|
|
38
|
+
definition:
|
|
39
|
+
"CREATE RULE good_rule AS ON INSERT TO events DO INSTEAD NOTHING;",
|
|
40
|
+
},
|
|
41
|
+
{ ...baseRow, name: '"orphan_rule"', definition: null },
|
|
42
|
+
]),
|
|
43
|
+
NO_BACKOFF,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
expect(rules).toHaveLength(1);
|
|
47
|
+
expect(rules[0]).toBeInstanceOf(Rule);
|
|
48
|
+
expect(rules[0]?.name).toBe('"good_rule"');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("does not throw ZodError when the only row has a null definition", async () => {
|
|
52
|
+
await expect(
|
|
53
|
+
extractRules(
|
|
54
|
+
mockPool([{ ...baseRow, name: '"orphan"', definition: null }]),
|
|
55
|
+
NO_BACKOFF,
|
|
56
|
+
),
|
|
57
|
+
).resolves.toEqual([]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("returns all rules when every row has a valid definition", async () => {
|
|
61
|
+
const rules = await extractRules(
|
|
62
|
+
mockPool([
|
|
63
|
+
{
|
|
64
|
+
...baseRow,
|
|
65
|
+
name: '"a"',
|
|
66
|
+
definition:
|
|
67
|
+
"CREATE RULE a AS ON INSERT TO events DO INSTEAD NOTHING;",
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
...baseRow,
|
|
71
|
+
name: '"b"',
|
|
72
|
+
definition:
|
|
73
|
+
"CREATE RULE b AS ON UPDATE TO events DO INSTEAD NOTHING;",
|
|
74
|
+
},
|
|
75
|
+
]),
|
|
76
|
+
NO_BACKOFF,
|
|
77
|
+
);
|
|
78
|
+
expect(rules.map((r) => r.name)).toEqual(['"a"', '"b"']);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("recovers when pg_get_ruledef is NULL on first attempt but resolved on retry", async () => {
|
|
82
|
+
const rules = await extractRules(
|
|
83
|
+
mockPoolSequence(
|
|
84
|
+
[{ ...baseRow, name: '"racy_rule"', definition: null }],
|
|
85
|
+
[
|
|
86
|
+
{
|
|
87
|
+
...baseRow,
|
|
88
|
+
name: '"racy_rule"',
|
|
89
|
+
definition:
|
|
90
|
+
"CREATE RULE racy_rule AS ON INSERT TO events DO INSTEAD NOTHING;",
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
),
|
|
94
|
+
{ retries: 2, backoffMs: 0 },
|
|
95
|
+
);
|
|
96
|
+
expect(rules).toHaveLength(1);
|
|
97
|
+
expect(rules[0]?.name).toBe('"racy_rule"');
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -2,6 +2,10 @@ import { sql } from "@ts-safeql/sql-tag";
|
|
|
2
2
|
import type { Pool } from "pg";
|
|
3
3
|
import z from "zod";
|
|
4
4
|
import { BasePgModel } from "../base.model.ts";
|
|
5
|
+
import {
|
|
6
|
+
type ExtractRetryOptions,
|
|
7
|
+
extractWithDefinitionRetry,
|
|
8
|
+
} from "../extract-with-retry.ts";
|
|
5
9
|
import { stableId } from "../utils.ts";
|
|
6
10
|
|
|
7
11
|
const RuleEventSchema = z.enum(["SELECT", "INSERT", "UPDATE", "DELETE"]);
|
|
@@ -29,6 +33,15 @@ const rulePropsSchema = z.object({
|
|
|
29
33
|
columns: z.array(z.string()),
|
|
30
34
|
});
|
|
31
35
|
|
|
36
|
+
// pg_get_ruledef(oid, pretty) can return NULL when the rule (its pg_rewrite
|
|
37
|
+
// row) is dropped between catalog scan and resolution, or under transient
|
|
38
|
+
// catalog state. An unreadable rule cannot be diffed, so we accept NULL here
|
|
39
|
+
// and filter the row out at extraction time rather than crashing the whole
|
|
40
|
+
// catalog parse with a ZodError.
|
|
41
|
+
const ruleRowSchema = rulePropsSchema.extend({
|
|
42
|
+
definition: z.string().nullable(),
|
|
43
|
+
});
|
|
44
|
+
|
|
32
45
|
export type RuleEnabledState = z.infer<typeof RuleEnabledStateSchema>;
|
|
33
46
|
export type RuleProps = z.infer<typeof rulePropsSchema>;
|
|
34
47
|
|
|
@@ -97,8 +110,16 @@ export class Rule extends BasePgModel {
|
|
|
97
110
|
}
|
|
98
111
|
}
|
|
99
112
|
|
|
100
|
-
export async function extractRules(
|
|
101
|
-
|
|
113
|
+
export async function extractRules(
|
|
114
|
+
pool: Pool,
|
|
115
|
+
options?: ExtractRetryOptions,
|
|
116
|
+
): Promise<Rule[]> {
|
|
117
|
+
const ruleRows = await extractWithDefinitionRetry({
|
|
118
|
+
label: "rules",
|
|
119
|
+
options,
|
|
120
|
+
hasNullDefinition: (row) => row.definition === null,
|
|
121
|
+
query: async () => {
|
|
122
|
+
const result = await pool.query<RuleProps>(sql`
|
|
102
123
|
WITH extension_rule_oids AS (
|
|
103
124
|
SELECT
|
|
104
125
|
objid
|
|
@@ -164,9 +185,12 @@ export async function extractRules(pool: Pool): Promise<Rule[]> {
|
|
|
164
185
|
ORDER BY
|
|
165
186
|
1, 3, 2
|
|
166
187
|
`);
|
|
188
|
+
return result.rows.map((row: unknown) => ruleRowSchema.parse(row));
|
|
189
|
+
},
|
|
190
|
+
});
|
|
167
191
|
|
|
168
|
-
const validatedRows = ruleRows.
|
|
169
|
-
|
|
192
|
+
const validatedRows = ruleRows.filter(
|
|
193
|
+
(row): row is RuleProps => row.definition !== null,
|
|
170
194
|
);
|
|
171
195
|
|
|
172
196
|
return validatedRows.map((row) => new Rule(row));
|
|
@@ -327,6 +327,93 @@ describe.concurrent("sequence.diff", () => {
|
|
|
327
327
|
expect(changes).toHaveLength(0);
|
|
328
328
|
});
|
|
329
329
|
|
|
330
|
+
test("recreate same-name sequence when owning table is renamed away", () => {
|
|
331
|
+
// Reproduces issue #228 case 1: a SERIAL column's table is renamed
|
|
332
|
+
// (`old_table` → `new_table`). The sequence keeps the same name
|
|
333
|
+
// (`old_table_id_seq`) but its OWNED BY now points at `new_table.id`.
|
|
334
|
+
// PostgreSQL cascade-drops the sequence with the old table, so a later
|
|
335
|
+
// CREATE TABLE that references `old_table_id_seq` fails. The diff must
|
|
336
|
+
// emit CreateSequence (and skip the explicit DropSequence to avoid an
|
|
337
|
+
// unbreakable cycle with the DropTable).
|
|
338
|
+
const tableColumn = {
|
|
339
|
+
name: "id",
|
|
340
|
+
position: 1,
|
|
341
|
+
data_type: "integer",
|
|
342
|
+
data_type_str: "integer",
|
|
343
|
+
is_custom_type: false,
|
|
344
|
+
custom_type_type: null,
|
|
345
|
+
custom_type_category: null,
|
|
346
|
+
custom_type_schema: null,
|
|
347
|
+
custom_type_name: null,
|
|
348
|
+
not_null: true,
|
|
349
|
+
is_identity: false,
|
|
350
|
+
is_identity_always: false,
|
|
351
|
+
is_generated: false,
|
|
352
|
+
collation: null,
|
|
353
|
+
default: "nextval('public.old_table_id_seq'::regclass)",
|
|
354
|
+
comment: null,
|
|
355
|
+
};
|
|
356
|
+
const tableBaseProps = {
|
|
357
|
+
schema: "public",
|
|
358
|
+
persistence: "p" as const,
|
|
359
|
+
row_security: false,
|
|
360
|
+
force_row_security: false,
|
|
361
|
+
has_indexes: false,
|
|
362
|
+
has_rules: false,
|
|
363
|
+
has_triggers: false,
|
|
364
|
+
has_subclasses: false,
|
|
365
|
+
is_populated: true,
|
|
366
|
+
replica_identity: "d" as const,
|
|
367
|
+
is_partition: false,
|
|
368
|
+
options: null,
|
|
369
|
+
partition_bound: null,
|
|
370
|
+
partition_by: null,
|
|
371
|
+
owner: "test",
|
|
372
|
+
comment: null,
|
|
373
|
+
parent_schema: null,
|
|
374
|
+
parent_name: null,
|
|
375
|
+
privileges: [],
|
|
376
|
+
};
|
|
377
|
+
const oldTable = new Table({
|
|
378
|
+
...tableBaseProps,
|
|
379
|
+
name: "old_table",
|
|
380
|
+
columns: [tableColumn],
|
|
381
|
+
});
|
|
382
|
+
const newTable = new Table({
|
|
383
|
+
...tableBaseProps,
|
|
384
|
+
name: "new_table",
|
|
385
|
+
columns: [tableColumn],
|
|
386
|
+
});
|
|
387
|
+
const mainSequence = new Sequence({
|
|
388
|
+
...base,
|
|
389
|
+
name: "old_table_id_seq",
|
|
390
|
+
owned_by_schema: "public",
|
|
391
|
+
owned_by_table: "old_table",
|
|
392
|
+
owned_by_column: "id",
|
|
393
|
+
});
|
|
394
|
+
const branchSequence = new Sequence({
|
|
395
|
+
...base,
|
|
396
|
+
name: "old_table_id_seq",
|
|
397
|
+
owned_by_schema: "public",
|
|
398
|
+
owned_by_table: "new_table",
|
|
399
|
+
owned_by_column: "id",
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
const changes = diffSequences(
|
|
403
|
+
testContext,
|
|
404
|
+
{ [mainSequence.stableId]: mainSequence },
|
|
405
|
+
{ [branchSequence.stableId]: branchSequence },
|
|
406
|
+
{ [newTable.stableId]: newTable },
|
|
407
|
+
{ [oldTable.stableId]: oldTable },
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
expect(changes.some((c) => c instanceof DropSequence)).toBe(false);
|
|
411
|
+
expect(changes.some((c) => c instanceof CreateSequence)).toBe(true);
|
|
412
|
+
expect(changes.some((c) => c instanceof AlterSequenceSetOwnedBy)).toBe(
|
|
413
|
+
true,
|
|
414
|
+
);
|
|
415
|
+
});
|
|
416
|
+
|
|
330
417
|
test("create with comment emits CreateCommentOnSequence", () => {
|
|
331
418
|
const s = new Sequence({ ...base, comment: "my seq" });
|
|
332
419
|
const changes = diffSequences(testContext, {}, { [s.stableId]: s });
|
|
@@ -36,6 +36,7 @@ type SequenceOrColumnSetDefaultChange =
|
|
|
36
36
|
* @param main - The sequences in the main catalog.
|
|
37
37
|
* @param branch - The sequences in the branch catalog.
|
|
38
38
|
* @param branchTables - The tables in the branch catalog (used to check if owning tables are being dropped).
|
|
39
|
+
* @param mainTables - The tables in the main catalog (used to detect when a same-name sequence will be cascade-dropped because its main-side owning table is going away).
|
|
39
40
|
* @returns A list of changes to apply to main to make it match branch.
|
|
40
41
|
*/
|
|
41
42
|
export function diffSequences(
|
|
@@ -46,6 +47,7 @@ export function diffSequences(
|
|
|
46
47
|
main: Record<string, Sequence>,
|
|
47
48
|
branch: Record<string, Sequence>,
|
|
48
49
|
branchTables: Record<string, Table> = {},
|
|
50
|
+
mainTables: Record<string, Table> = {},
|
|
49
51
|
): SequenceOrColumnSetDefaultChange[] {
|
|
50
52
|
const { created, dropped, altered } = diffObjects(main, branch);
|
|
51
53
|
|
|
@@ -157,12 +159,35 @@ export function diffSequences(
|
|
|
157
159
|
NON_ALTERABLE_FIELDS,
|
|
158
160
|
);
|
|
159
161
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
162
|
+
// A sequence kept the same name (so it's "altered" in catalog terms),
|
|
163
|
+
// but its main-side owning table is going away from the plan (renamed
|
|
164
|
+
// away or simply dropped). PostgreSQL will cascade-drop the sequence
|
|
165
|
+
// alongside the table, leaving any later CREATE TABLE / column-default
|
|
166
|
+
// that depends on the sequence name pointing at nothing. Treat this
|
|
167
|
+
// like a non-alterable change so we recreate the sequence after the
|
|
168
|
+
// owning table is dropped.
|
|
169
|
+
const mainOwnedByTableId =
|
|
170
|
+
mainSequence.owned_by_schema && mainSequence.owned_by_table
|
|
171
|
+
? `table:${mainSequence.owned_by_schema}.${mainSequence.owned_by_table}`
|
|
172
|
+
: null;
|
|
173
|
+
const cascadeOrphanedByOwningTable =
|
|
174
|
+
mainOwnedByTableId !== null &&
|
|
175
|
+
mainTables[mainOwnedByTableId] !== undefined &&
|
|
176
|
+
branchTables[mainOwnedByTableId] === undefined;
|
|
177
|
+
|
|
178
|
+
if (nonAlterablePropsChanged || cascadeOrphanedByOwningTable) {
|
|
179
|
+
// When the owning table is going away in this plan, PostgreSQL will
|
|
180
|
+
// cascade-drop the sequence as part of the DROP TABLE. Emitting an
|
|
181
|
+
// explicit DROP SEQUENCE here would (a) introduce an unbreakable
|
|
182
|
+
// DropSequence ↔ DropTable cycle on the catalog edges between the
|
|
183
|
+
// sequence and the dropped column, and (b) be redundant with the
|
|
184
|
+
// cascade. The CreateSequence below restores the sequence under its
|
|
185
|
+
// original name so any same-name reference in a later CREATE TABLE
|
|
186
|
+
// resolves correctly.
|
|
187
|
+
if (!cascadeOrphanedByOwningTable) {
|
|
188
|
+
changes.push(new DropSequence({ sequence: mainSequence }));
|
|
189
|
+
}
|
|
190
|
+
changes.push(new CreateSequence({ sequence: branchSequence }));
|
|
166
191
|
// Re-apply OWNED BY if present on branch
|
|
167
192
|
if (
|
|
168
193
|
branchSequence.owned_by_schema !== null &&
|