@supabase/pg-delta 1.0.0-alpha.20 → 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 -4
- 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/publication/changes/publication.alter.d.ts +1 -1
- 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 +41 -9
- package/dist/core/objects/table/changes/table.alter.d.ts +16 -1
- package/dist/core/objects/table/changes/table.alter.js +39 -6
- package/dist/core/objects/table/table.diff.js +40 -17
- package/dist/core/objects/table/table.model.d.ts +6 -1
- package/dist/core/objects/table/table.model.js +50 -12
- 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-normalization.d.ts +36 -0
- package/dist/core/post-diff-normalization.js +202 -0
- package/dist/core/sort/cycle-breakers.d.ts +15 -0
- package/dist/core/sort/cycle-breakers.js +269 -0
- package/dist/core/sort/sort-changes.js +97 -43
- 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 -3
- package/src/core/catalog.model.ts +20 -8
- package/src/core/expand-replace-dependencies.test.ts +139 -5
- 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/publication/changes/publication.alter.ts +1 -1
- 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 +93 -1
- package/src/core/objects/sequence/sequence.diff.ts +43 -10
- package/src/core/objects/table/changes/table.alter.test.ts +26 -23
- package/src/core/objects/table/changes/table.alter.ts +66 -10
- package/src/core/objects/table/table.diff.test.ts +43 -0
- package/src/core/objects/table/table.diff.ts +52 -23
- package/src/core/objects/table/table.model.test.ts +209 -0
- package/src/core/objects/table/table.model.ts +62 -14
- 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 -160
- package/src/core/post-diff-normalization.ts +260 -0
- package/src/core/sort/cycle-breakers.test.ts +476 -0
- package/src/core/sort/cycle-breakers.ts +311 -0
- package/src/core/sort/sort-changes.ts +135 -50
- package/src/core/sort/utils.ts +38 -0
- package/dist/core/post-diff-cycle-breaking.d.ts +0 -29
- package/dist/core/post-diff-cycle-breaking.js +0 -209
- package/src/core/post-diff-cycle-breaking.ts +0 -317
|
@@ -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
|
|
|
6
10
|
const TableRelkindSchema = z.enum([
|
|
7
11
|
"r", // table (regular relation)
|
|
@@ -163,7 +167,11 @@ export class Index extends BasePgModel {
|
|
|
163
167
|
nulls_not_distinct: this.nulls_not_distinct,
|
|
164
168
|
immediate: this.immediate,
|
|
165
169
|
is_clustered: this.is_clustered,
|
|
166
|
-
is_replica_identity:
|
|
170
|
+
// is_replica_identity excluded: the table's `replica_identity` /
|
|
171
|
+
// `replica_identity_index` is the source of truth, set via
|
|
172
|
+
// ALTER TABLE ... REPLICA IDENTITY USING INDEX. Including this flag here
|
|
173
|
+
// would trigger spurious DROP+CREATE of the index whenever the table's
|
|
174
|
+
// replica identity changes.
|
|
167
175
|
// key_columns excluded: contains attribute numbers that can differ between databases
|
|
168
176
|
// even when indexes are logically identical. The definition field already captures
|
|
169
177
|
// the logical structure using column names, so we compare by definition instead.
|
|
@@ -215,8 +223,16 @@ export class Index extends BasePgModel {
|
|
|
215
223
|
}
|
|
216
224
|
}
|
|
217
225
|
|
|
218
|
-
export async function extractIndexes(
|
|
219
|
-
|
|
226
|
+
export async function extractIndexes(
|
|
227
|
+
pool: Pool,
|
|
228
|
+
options?: ExtractRetryOptions,
|
|
229
|
+
): Promise<Index[]> {
|
|
230
|
+
const indexRows = await extractWithDefinitionRetry({
|
|
231
|
+
label: "indexes",
|
|
232
|
+
options,
|
|
233
|
+
hasNullDefinition: (row) => row.definition === null,
|
|
234
|
+
query: async () => {
|
|
235
|
+
const result = await pool.query<IndexProps>(sql`
|
|
220
236
|
with extension_oids as (
|
|
221
237
|
select objid
|
|
222
238
|
from pg_depend d
|
|
@@ -372,8 +388,11 @@ export async function extractIndexes(pool: Pool): Promise<Index[]> {
|
|
|
372
388
|
|
|
373
389
|
order by 1, 2
|
|
374
390
|
`);
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
391
|
+
return result.rows.map((row: unknown) => indexRowSchema.parse(row));
|
|
392
|
+
},
|
|
393
|
+
});
|
|
394
|
+
const validatedRows = indexRows.filter(
|
|
395
|
+
(row): row is IndexProps => row.definition !== null,
|
|
396
|
+
);
|
|
378
397
|
return validatedRows.map((row: IndexProps) => new Index(row));
|
|
379
398
|
}
|
|
@@ -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
|
}
|
|
@@ -128,7 +128,7 @@ export class AlterPublicationAddTables extends AlterPublicationChange {
|
|
|
128
128
|
export class AlterPublicationDropTables extends AlterPublicationChange {
|
|
129
129
|
public readonly publication: Publication;
|
|
130
130
|
public readonly scope = "object" as const;
|
|
131
|
-
|
|
131
|
+
public readonly tables: PublicationTableProps[];
|
|
132
132
|
|
|
133
133
|
constructor(props: {
|
|
134
134
|
publication: Publication;
|
|
@@ -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));
|