@rebasepro/server-postgres 0.17.3-canary.gdd23447 → 0.18.0
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/LICENSE +0 -1
- package/README.md +31 -0
- package/dist/backup-argv.d.ts +19 -0
- package/dist/branch-argv.d.ts +28 -0
- package/dist/branch-prune.d.ts +77 -0
- package/dist/cli-collections-path.d.ts +31 -0
- package/dist/cli-errors.d.ts +123 -0
- package/dist/cli-flags.d.ts +101 -0
- package/dist/cli-helpers.d.ts +94 -10
- package/dist/collection-index-C7LopMyX.js +247 -0
- package/dist/collection-index-C7LopMyX.js.map +1 -0
- package/dist/databasePoolManager.d.ts +27 -0
- package/dist/{ensure-collection-policies-UCqgv_8c.js → ensure-collection-policies-CagOzEPS.js} +6 -6
- package/dist/{ensure-collection-policies-UCqgv_8c.js.map → ensure-collection-policies-CagOzEPS.js.map} +1 -1
- package/dist/{ensure-collection-tables-DgVixhX3.js → ensure-collection-tables-DbAhrjnG.js} +614 -52
- package/dist/ensure-collection-tables-DbAhrjnG.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.es.js +19307 -3059
- package/dist/index.es.js.map +1 -1
- package/dist/{rls-enforcement-DvAbL9YJ.js → rls-enforcement-ClJoTboT.js} +53 -189
- package/dist/rls-enforcement-ClJoTboT.js.map +1 -0
- package/dist/schema/column-type-drift.d.ts +79 -0
- package/dist/schema/doctor.d.ts +42 -0
- package/dist/schema/ensure-collection-tables.d.ts +90 -0
- package/dist/schema/generate-next-step.d.ts +30 -0
- package/dist/schema/generate-postgres-ddl-logic.d.ts +28 -0
- package/dist/schema/generated-schema-staleness.d.ts +37 -0
- package/dist/schema/introspect-db-queries.d.ts +34 -0
- package/dist/security/rls-enforcement.d.ts +63 -2
- package/dist/services/BranchService.d.ts +86 -1
- package/dist/services/collection-helpers.d.ts +1 -1
- package/dist/services/realtimeService.d.ts +30 -2
- package/dist/services/row-pipeline.d.ts +12 -1
- package/dist/utils/pg-error-utils.d.ts +3 -1
- package/dist/utils/pg-numeric-number-patch.d.ts +31 -0
- package/package.json +32 -27
- package/dist/backup-service-DCk7KhhL.js +0 -9044
- package/dist/backup-service-DCk7KhhL.js.map +0 -1
- package/dist/collection-index-BRUg10H5.js +0 -5690
- package/dist/collection-index-BRUg10H5.js.map +0 -1
- package/dist/connection-GOKU3Hu5.js +0 -277
- package/dist/connection-GOKU3Hu5.js.map +0 -1
- package/dist/ensure-collection-tables-DgVixhX3.js.map +0 -1
- package/dist/rls-bootstrap-sql-B5C9LoJ6.js +0 -244
- package/dist/rls-bootstrap-sql-B5C9LoJ6.js.map +0 -1
- package/dist/rls-enforcement-DvAbL9YJ.js.map +0 -1
- package/dist/src-DiB5RP2Z.js +0 -541
- package/dist/src-DiB5RP2Z.js.map +0 -1
- package/dist/websocket-BZ4H5wUz.js +0 -8198
- package/dist/websocket-BZ4H5wUz.js.map +0 -1
- package/src/PostgresAdapter.ts +0 -91
- package/src/PostgresBackendDriver.ts +0 -1857
- package/src/PostgresBootstrapper.ts +0 -1157
- package/src/auth/ensure-tables.ts +0 -896
- package/src/auth/schema-version.ts +0 -260
- package/src/auth/services.ts +0 -1610
- package/src/backup/backup-cli.ts +0 -520
- package/src/backup/backup-cron.ts +0 -212
- package/src/backup/backup-logic.ts +0 -93
- package/src/backup/backup-service.ts +0 -485
- package/src/backup/index.ts +0 -12
- package/src/backup/pg-tools.ts +0 -403
- package/src/backup/retention.ts +0 -86
- package/src/cli-errors.ts +0 -265
- package/src/cli-helpers.ts +0 -569
- package/src/cli-output.ts +0 -43
- package/src/cli.ts +0 -1339
- package/src/collections/PostgresCollectionRegistry.ts +0 -108
- package/src/collections/buildRegistry.ts +0 -68
- package/src/collections/validate-relations.ts +0 -402
- package/src/connection.ts +0 -333
- package/src/data-transformer.ts +0 -845
- package/src/databasePoolManager.ts +0 -95
- package/src/history/HistoryService.ts +0 -254
- package/src/history/ensure-history-table.ts +0 -53
- package/src/index.ts +0 -19
- package/src/interfaces.ts +0 -60
- package/src/module-dir.ts +0 -7
- package/src/schema/atlas-argv.ts +0 -94
- package/src/schema/auth-schema.ts +0 -298
- package/src/schema/auth-users-columns.ts +0 -131
- package/src/schema/carved-out-migration.ts +0 -404
- package/src/schema/classify-change.ts +0 -436
- package/src/schema/collection-index.ts +0 -427
- package/src/schema/collections-schema-version.ts +0 -103
- package/src/schema/destructive-sql.ts +0 -94
- package/src/schema/doctor-cli.ts +0 -66
- package/src/schema/doctor-policy-checks.ts +0 -105
- package/src/schema/doctor.ts +0 -837
- package/src/schema/drizzle-ddl.ts +0 -33
- package/src/schema/dynamic-tables.test.ts +0 -302
- package/src/schema/dynamic-tables.ts +0 -293
- package/src/schema/ensure-collection-policies.ts +0 -198
- package/src/schema/ensure-collection-tables.test.ts +0 -518
- package/src/schema/ensure-collection-tables.ts +0 -1267
- package/src/schema/generate-drizzle-schema-logic.ts +0 -957
- package/src/schema/generate-drizzle-schema.ts +0 -143
- package/src/schema/generate-postgres-ddl-logic.ts +0 -1339
- package/src/schema/generate-postgres-ddl.ts +0 -180
- package/src/schema/generate-schema-commit.ts +0 -267
- package/src/schema/generated-schema-staleness.ts +0 -284
- package/src/schema/introspect-db-constraints.ts +0 -385
- package/src/schema/introspect-db-inference.ts +0 -255
- package/src/schema/introspect-db-logic.ts +0 -1387
- package/src/schema/introspect-db-naming.ts +0 -15
- package/src/schema/introspect-db-project.ts +0 -78
- package/src/schema/introspect-db-queries.ts +0 -326
- package/src/schema/introspect-db-structure.ts +0 -670
- package/src/schema/introspect-db-types.ts +0 -56
- package/src/schema/introspect-db.ts +0 -257
- package/src/schema/introspect-runtime.test.ts +0 -260
- package/src/schema/introspect-runtime.ts +0 -327
- package/src/schema/non-sql-collections.test.ts +0 -131
- package/src/schema/rls-bootstrap-sql.ts +0 -288
- package/src/schema/search-column.ts +0 -643
- package/src/schema/test-schema.ts +0 -11
- package/src/schema/vector-index.ts +0 -491
- package/src/security/anonymous-grants.test.ts +0 -73
- package/src/security/policy-drift.test.ts +0 -435
- package/src/security/policy-drift.ts +0 -473
- package/src/security/rls-enforcement.ts +0 -505
- package/src/services/BranchService.ts +0 -326
- package/src/services/FetchService.ts +0 -2115
- package/src/services/PersistService.ts +0 -557
- package/src/services/RelationService.ts +0 -978
- package/src/services/RelationWriteService.ts +0 -653
- package/src/services/cdc/CdcListener.ts +0 -103
- package/src/services/cdc/junction-tables.ts +0 -91
- package/src/services/cdc/trigger-cdc.ts +0 -173
- package/src/services/channel-bus/ChannelBus.ts +0 -44
- package/src/services/channel-bus/PostgresChannelBus.ts +0 -299
- package/src/services/channel-bus/index.ts +0 -114
- package/src/services/channel-history.ts +0 -424
- package/src/services/channel-presence.ts +0 -185
- package/src/services/collection-helpers.ts +0 -420
- package/src/services/dataService.ts +0 -256
- package/src/services/index.ts +0 -14
- package/src/services/junction-writes.ts +0 -295
- package/src/services/nested-path.ts +0 -145
- package/src/services/pg-notify-listener.ts +0 -151
- package/src/services/realtimeService.ts +0 -2545
- package/src/services/row-pipeline.ts +0 -240
- package/src/services/write-denial.ts +0 -55
- package/src/types.ts +0 -4
- package/src/utils/connection-string.ts +0 -58
- package/src/utils/drizzle-conditions.ts +0 -2743
- package/src/utils/pg-array-null-patch.ts +0 -42
- package/src/utils/pg-error-utils.ts +0 -391
- package/src/websocket.ts +0 -855
|
@@ -1,518 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "@jest/globals";
|
|
2
|
-
import type { CollectionConfig } from "@rebasepro/types";
|
|
3
|
-
import {
|
|
4
|
-
planCollectionSchemaEnsure,
|
|
5
|
-
ensureCollectionTables,
|
|
6
|
-
type ExistingSchema,
|
|
7
|
-
type Queryable
|
|
8
|
-
} from "./ensure-collection-tables";
|
|
9
|
-
|
|
10
|
-
const posts = {
|
|
11
|
-
name: "Posts",
|
|
12
|
-
slug: "posts",
|
|
13
|
-
properties: {
|
|
14
|
-
id: { name: "ID", type: "string", isId: "uuid" },
|
|
15
|
-
title: { name: "Title", type: "string" },
|
|
16
|
-
views: { name: "Views", type: "number" },
|
|
17
|
-
status: {
|
|
18
|
-
name: "Status",
|
|
19
|
-
type: "string",
|
|
20
|
-
enum: [
|
|
21
|
-
{ id: "draft", label: "Draft" },
|
|
22
|
-
{ id: "published", label: "Published" }
|
|
23
|
-
]
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
} as unknown as CollectionConfig;
|
|
27
|
-
|
|
28
|
-
const empty = (): ExistingSchema => ({ tables: new Map(), enums: new Set() });
|
|
29
|
-
|
|
30
|
-
const withTable = (key: string, columns: string[]): ExistingSchema => ({
|
|
31
|
-
tables: new Map([[key, new Set(columns)]]),
|
|
32
|
-
enums: new Set()
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
describe("planning an additive schema ensure", () => {
|
|
36
|
-
it("creates a missing table, its enum type, and its columns in that order", () => {
|
|
37
|
-
const plan = planCollectionSchemaEnsure([posts], empty());
|
|
38
|
-
const kinds = plan.actions.map(a => a.kind);
|
|
39
|
-
|
|
40
|
-
// The enum must exist before the column that references it, and the
|
|
41
|
-
// table before its own columns.
|
|
42
|
-
expect(kinds.indexOf("create-enum")).toBeLessThan(kinds.indexOf("create-table"));
|
|
43
|
-
expect(kinds.indexOf("create-table")).toBeLessThan(kinds.indexOf("add-column"));
|
|
44
|
-
expect(plan.statements.join("\n")).toMatch(/CREATE TABLE IF NOT EXISTS "public"\."posts"/);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it("adds only the columns an existing table is missing", () => {
|
|
48
|
-
const plan = planCollectionSchemaEnsure([posts], withTable("public.posts", ["id", "title"]));
|
|
49
|
-
const added = plan.actions.filter(a => a.kind === "add-column").map(a => a.target);
|
|
50
|
-
|
|
51
|
-
expect(added).toContain("public.posts.views");
|
|
52
|
-
expect(added).toContain("public.posts.status");
|
|
53
|
-
expect(added).not.toContain("public.posts.title");
|
|
54
|
-
expect(plan.actions.some(a => a.kind === "create-table")).toBe(false);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it("is a no-op against a database that is already current", () => {
|
|
58
|
-
const existing: ExistingSchema = {
|
|
59
|
-
tables: new Map([["public.posts", new Set(["id", "title", "views", "status"])]]),
|
|
60
|
-
enums: new Set(["public.posts_status"])
|
|
61
|
-
};
|
|
62
|
-
expect(planCollectionSchemaEnsure([posts], existing).actions).toEqual([]);
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
it("skips an enum type that already exists, since CREATE TYPE has no IF NOT EXISTS", () => {
|
|
66
|
-
const existing: ExistingSchema = { tables: new Map(), enums: new Set(["public.posts_status"]) };
|
|
67
|
-
const plan = planCollectionSchemaEnsure([posts], existing);
|
|
68
|
-
expect(plan.actions.some(a => a.kind === "create-enum")).toBe(false);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it("NEVER emits a destructive statement, whatever the database contains", () => {
|
|
72
|
-
// The core safety property. This runs unattended against customer data
|
|
73
|
-
// with nobody reading a diff, so a column the collections no longer
|
|
74
|
-
// mention must be left alone, not dropped.
|
|
75
|
-
const existing = withTable("public.posts", ["id", "title", "legacy_column", "another_old_one"]);
|
|
76
|
-
const sql = planCollectionSchemaEnsure([posts], existing).statements.join("\n");
|
|
77
|
-
|
|
78
|
-
expect(sql).not.toMatch(/\bDROP\b/i);
|
|
79
|
-
expect(sql).not.toMatch(/\bTRUNCATE\b/i);
|
|
80
|
-
expect(sql).not.toMatch(/ALTER COLUMN/i);
|
|
81
|
-
expect(sql).not.toMatch(/legacy_column/);
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it("never adds a NOT NULL column, which an existing table with rows could not take", () => {
|
|
85
|
-
const sql = planCollectionSchemaEnsure([posts], withTable("public.posts", ["id"]))
|
|
86
|
-
.statements.join("\n");
|
|
87
|
-
expect(sql).not.toMatch(/NOT NULL/i);
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
// ── Relations ────────────────────────────────────────────────────────────
|
|
91
|
-
//
|
|
92
|
-
// These columns were once skipped outright, on the reasoning that a bare
|
|
93
|
-
// column without its foreign key would disagree with what `db push` later
|
|
94
|
-
// generated. On a managed tenant nothing pushes afterwards, so the table
|
|
95
|
-
// arrived without the column its own collection reads: every insert 400ed
|
|
96
|
-
// with `column "author_id" does not exist`. The answer is to emit the key
|
|
97
|
-
// too, from the same planner `db push` uses.
|
|
98
|
-
|
|
99
|
-
const authors = {
|
|
100
|
-
name: "Authors",
|
|
101
|
-
slug: "authors",
|
|
102
|
-
properties: {
|
|
103
|
-
id: { name: "ID", type: "string", isId: "uuid" },
|
|
104
|
-
name: { name: "Name", type: "string" }
|
|
105
|
-
}
|
|
106
|
-
} as unknown as CollectionConfig;
|
|
107
|
-
|
|
108
|
-
const postsWithAuthor = {
|
|
109
|
-
...posts,
|
|
110
|
-
properties: {
|
|
111
|
-
...(posts as unknown as { properties: Record<string, unknown> }).properties,
|
|
112
|
-
author: { name: "Author", type: "reference", path: "authors" }
|
|
113
|
-
}
|
|
114
|
-
} as unknown as CollectionConfig;
|
|
115
|
-
|
|
116
|
-
const tags = {
|
|
117
|
-
name: "Tags",
|
|
118
|
-
slug: "tags",
|
|
119
|
-
properties: {
|
|
120
|
-
id: { name: "ID", type: "number", isId: "increment" },
|
|
121
|
-
name: { name: "Name", type: "string" }
|
|
122
|
-
}
|
|
123
|
-
} as unknown as CollectionConfig;
|
|
124
|
-
|
|
125
|
-
const postsWithTags = {
|
|
126
|
-
...posts,
|
|
127
|
-
properties: {
|
|
128
|
-
...(posts as unknown as { properties: Record<string, unknown> }).properties,
|
|
129
|
-
tags: {
|
|
130
|
-
name: "Tags",
|
|
131
|
-
type: "relation",
|
|
132
|
-
relation: { kind: "manyToMany", target: () => tags, relationName: "tags" }
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
} as unknown as CollectionConfig;
|
|
136
|
-
|
|
137
|
-
it("adds a reference column, and the foreign key that makes it one", () => {
|
|
138
|
-
const plan = planCollectionSchemaEnsure([postsWithAuthor, authors], withTable("public.posts", ["id"]));
|
|
139
|
-
|
|
140
|
-
expect(plan.actions.some(a => a.kind === "add-column" && a.target === "public.posts.author")).toBe(true);
|
|
141
|
-
const fk = plan.actions.find(a => a.kind === "add-constraint");
|
|
142
|
-
expect(fk?.target).toBe("public.posts.posts_author_fkey");
|
|
143
|
-
expect(fk?.sql).toMatch(/REFERENCES "public"\."authors" \("id"\)/);
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
it("orders every constraint after the columns and tables it depends on", () => {
|
|
147
|
-
const plan = planCollectionSchemaEnsure([postsWithAuthor, authors], empty());
|
|
148
|
-
const kinds = plan.actions.map(a => a.kind);
|
|
149
|
-
expect(kinds.lastIndexOf("add-column")).toBeLessThan(kinds.indexOf("add-constraint"));
|
|
150
|
-
expect(kinds.lastIndexOf("create-table")).toBeLessThan(kinds.indexOf("add-constraint"));
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it("skips a foreign key the database already has, since ADD CONSTRAINT has no IF NOT EXISTS", () => {
|
|
154
|
-
const existing: ExistingSchema = {
|
|
155
|
-
tables: new Map([
|
|
156
|
-
["public.posts", new Set(["id", "author"])],
|
|
157
|
-
["public.authors", new Set(["id", "name"])]
|
|
158
|
-
]),
|
|
159
|
-
enums: new Set(["public.posts_status"]),
|
|
160
|
-
constraints: new Set(["public.posts.posts_author_fkey"])
|
|
161
|
-
};
|
|
162
|
-
const plan = planCollectionSchemaEnsure([postsWithAuthor, authors], existing);
|
|
163
|
-
expect(plan.actions.some(a => a.kind === "add-constraint")).toBe(false);
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
it("creates the junction table behind a many-to-many, keyed on both endpoints", () => {
|
|
167
|
-
const plan = planCollectionSchemaEnsure([postsWithTags, tags], empty());
|
|
168
|
-
const junction = plan.actions.find(a => a.kind === "create-table" && a.target === "public.posts_tags");
|
|
169
|
-
|
|
170
|
-
expect(junction).toBeDefined();
|
|
171
|
-
// The endpoint key types have to match the primary keys they reference:
|
|
172
|
-
// posts is a uuid, tags an auto-increment integer.
|
|
173
|
-
expect(junction!.sql).toMatch(/"post_id" UUID NOT NULL/);
|
|
174
|
-
expect(junction!.sql).toMatch(/"tag_id" INTEGER NOT NULL/);
|
|
175
|
-
expect(junction!.sql).toMatch(/PRIMARY KEY \("post_id", "tag_id"\)/);
|
|
176
|
-
|
|
177
|
-
const fks = plan.actions.filter(a => a.kind === "add-constraint").map(a => a.target);
|
|
178
|
-
expect(fks).toContain("public.posts_tags.posts_tags_post_id_fkey");
|
|
179
|
-
expect(fks).toContain("public.posts_tags.posts_tags_tag_id_fkey");
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
it("leaves an existing junction table alone", () => {
|
|
183
|
-
const existing: ExistingSchema = {
|
|
184
|
-
tables: new Map([
|
|
185
|
-
["public.posts", new Set(["id", "title", "views", "status"])],
|
|
186
|
-
["public.tags", new Set(["id", "name"])],
|
|
187
|
-
["public.posts_tags", new Set(["post_id", "tag_id"])]
|
|
188
|
-
]),
|
|
189
|
-
enums: new Set(["public.posts_status"]),
|
|
190
|
-
constraints: new Set([
|
|
191
|
-
"public.posts_tags.posts_tags_post_id_fkey",
|
|
192
|
-
"public.posts_tags.posts_tags_tag_id_fkey"
|
|
193
|
-
])
|
|
194
|
-
};
|
|
195
|
-
expect(planCollectionSchemaEnsure([postsWithTags, tags], existing).actions).toEqual([]);
|
|
196
|
-
});
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* The three changes the ensure path used to apply *partly* while reporting
|
|
201
|
-
* success. Each one left a database that did not match its own configuration,
|
|
202
|
-
* and nothing anywhere said so — which is what made them worth more than the
|
|
203
|
-
* refusals beside them.
|
|
204
|
-
*/
|
|
205
|
-
describe("constraints the configuration asks for", () => {
|
|
206
|
-
const required = {
|
|
207
|
-
name: "Authors",
|
|
208
|
-
slug: "authors",
|
|
209
|
-
properties: {
|
|
210
|
-
id: { name: "ID", type: "string", isId: "uuid" },
|
|
211
|
-
name: { name: "Name", type: "string", validation: { required: true } }
|
|
212
|
-
}
|
|
213
|
-
} as unknown as CollectionConfig;
|
|
214
|
-
|
|
215
|
-
const optional = {
|
|
216
|
-
...required,
|
|
217
|
-
properties: {
|
|
218
|
-
...(required as unknown as { properties: Record<string, unknown> }).properties,
|
|
219
|
-
name: { name: "Name", type: "string" }
|
|
220
|
-
}
|
|
221
|
-
} as unknown as CollectionConfig;
|
|
222
|
-
|
|
223
|
-
it("adds NOT NULL when the existing table is empty", () => {
|
|
224
|
-
const plan = planCollectionSchemaEnsure([required], {
|
|
225
|
-
tables: new Map([["public.authors", new Set(["id"])]]),
|
|
226
|
-
enums: new Set(),
|
|
227
|
-
populatedTables: new Set()
|
|
228
|
-
});
|
|
229
|
-
const add = plan.actions.find(a => a.target === "public.authors.name");
|
|
230
|
-
expect(add!.sql).toContain("NOT NULL");
|
|
231
|
-
expect(plan.withheldConstraints).toEqual([]);
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
it("withholds NOT NULL when the table holds rows, and says so", () => {
|
|
235
|
-
const plan = planCollectionSchemaEnsure([required], {
|
|
236
|
-
tables: new Map([["public.authors", new Set(["id"])]]),
|
|
237
|
-
enums: new Set(),
|
|
238
|
-
populatedTables: new Set(["public.authors"])
|
|
239
|
-
});
|
|
240
|
-
const add = plan.actions.find(a => a.target === "public.authors.name");
|
|
241
|
-
expect(add!.sql).not.toContain("NOT NULL");
|
|
242
|
-
// The whole point: withheld is no longer the same as unmentioned.
|
|
243
|
-
expect(plan.withheldConstraints).toHaveLength(1);
|
|
244
|
-
expect(plan.withheldConstraints[0]).toMatchObject({
|
|
245
|
-
target: "public.authors.name",
|
|
246
|
-
kind: "not-null"
|
|
247
|
-
});
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
it("assumes rows when the caller does not know whether the table is empty", () => {
|
|
251
|
-
// A hand-built ExistingSchema carries no `populatedTables`. Reading that
|
|
252
|
-
// as "empty" would emit a NOT NULL checked against live data and abort
|
|
253
|
-
// the boot, so absent has to mean populated.
|
|
254
|
-
const plan = planCollectionSchemaEnsure([required], {
|
|
255
|
-
tables: new Map([["public.authors", new Set(["id"])]]),
|
|
256
|
-
enums: new Set()
|
|
257
|
-
});
|
|
258
|
-
expect(plan.actions.find(a => a.target === "public.authors.name")!.sql)
|
|
259
|
-
.not.toContain("NOT NULL");
|
|
260
|
-
expect(plan.withheldConstraints).toHaveLength(1);
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
it("leaves an existing column's constraints alone at boot", () => {
|
|
264
|
-
// `additive` is the boot default. A database adopted by introspection
|
|
265
|
-
// carries NOT NULL on columns the generated collection leaves optional,
|
|
266
|
-
// and converging those unasked would drop constraints nobody edited.
|
|
267
|
-
const plan = planCollectionSchemaEnsure([optional], {
|
|
268
|
-
tables: new Map([["public.authors", new Set(["id", "name"])]]),
|
|
269
|
-
enums: new Set(),
|
|
270
|
-
notNullColumns: new Set(["public.authors.name"]),
|
|
271
|
-
populatedTables: new Set()
|
|
272
|
-
});
|
|
273
|
-
expect(plan.actions).toEqual([]);
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
it("drops NOT NULL when a reviewed change relaxes the property", () => {
|
|
277
|
-
const plan = planCollectionSchemaEnsure([optional], {
|
|
278
|
-
tables: new Map([["public.authors", new Set(["id", "name"])]]),
|
|
279
|
-
enums: new Set(),
|
|
280
|
-
notNullColumns: new Set(["public.authors.name"]),
|
|
281
|
-
populatedTables: new Set(["public.authors"])
|
|
282
|
-
}, { constraints: "converge" });
|
|
283
|
-
expect(plan.actions).toEqual([{
|
|
284
|
-
kind: "drop-not-null",
|
|
285
|
-
target: "public.authors.name",
|
|
286
|
-
sql: `ALTER TABLE "public"."authors" ALTER COLUMN "name" DROP NOT NULL;`
|
|
287
|
-
}]);
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
it("sets NOT NULL on an existing empty column when the change is reviewed", () => {
|
|
291
|
-
const plan = planCollectionSchemaEnsure([required], {
|
|
292
|
-
tables: new Map([["public.authors", new Set(["id", "name"])]]),
|
|
293
|
-
enums: new Set(),
|
|
294
|
-
notNullColumns: new Set(),
|
|
295
|
-
populatedTables: new Set()
|
|
296
|
-
}, { constraints: "converge" });
|
|
297
|
-
expect(plan.actions).toEqual([{
|
|
298
|
-
kind: "set-not-null",
|
|
299
|
-
target: "public.authors.name",
|
|
300
|
-
sql: `ALTER TABLE "public"."authors" ALTER COLUMN "name" SET NOT NULL;`
|
|
301
|
-
}]);
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
it("refuses to set NOT NULL on a populated column, even when reviewed", () => {
|
|
305
|
-
const plan = planCollectionSchemaEnsure([required], {
|
|
306
|
-
tables: new Map([["public.authors", new Set(["id", "name"])]]),
|
|
307
|
-
enums: new Set(),
|
|
308
|
-
notNullColumns: new Set(),
|
|
309
|
-
populatedTables: new Set(["public.authors"])
|
|
310
|
-
}, { constraints: "converge" });
|
|
311
|
-
expect(plan.actions).toEqual([]);
|
|
312
|
-
expect(plan.withheldConstraints).toHaveLength(1);
|
|
313
|
-
expect(plan.withheldConstraints[0].remedy).toMatch(/Backfill/);
|
|
314
|
-
});
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
describe("applying the plan", () => {
|
|
318
|
-
function fakeClient(): { client: Queryable; executed: string[] } {
|
|
319
|
-
const executed: string[] = [];
|
|
320
|
-
const client: Queryable = {
|
|
321
|
-
async query<T>(sql: string): Promise<{ rows: T[] }> {
|
|
322
|
-
executed.push(sql);
|
|
323
|
-
return { rows: [] as T[] };
|
|
324
|
-
}
|
|
325
|
-
};
|
|
326
|
-
return { client, executed };
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
it("creates the schema, reads what exists, then applies", async () => {
|
|
330
|
-
const { client, executed } = fakeClient();
|
|
331
|
-
const plan = await ensureCollectionTables(client, [posts]);
|
|
332
|
-
|
|
333
|
-
expect(plan.actions.length).toBeGreaterThan(0);
|
|
334
|
-
expect(executed.some(s => s.includes("information_schema.columns"))).toBe(true);
|
|
335
|
-
expect(executed.some(s => s.includes("CREATE TABLE IF NOT EXISTS"))).toBe(true);
|
|
336
|
-
});
|
|
337
|
-
|
|
338
|
-
it("surfaces which statement failed rather than a bare driver error", async () => {
|
|
339
|
-
const client: Queryable = {
|
|
340
|
-
async query<T>(sql: string): Promise<{ rows: T[] }> {
|
|
341
|
-
if (sql.startsWith("CREATE TABLE")) throw new Error("permission denied");
|
|
342
|
-
return { rows: [] as T[] };
|
|
343
|
-
}
|
|
344
|
-
};
|
|
345
|
-
await expect(ensureCollectionTables(client, [posts])).rejects.toThrow(/permission denied/);
|
|
346
|
-
await expect(ensureCollectionTables(client, [posts])).rejects.toThrow(/public\.posts/);
|
|
347
|
-
});
|
|
348
|
-
|
|
349
|
-
it("does nothing when the database is already current", async () => {
|
|
350
|
-
const client: Queryable = {
|
|
351
|
-
async query<T>(sql: string): Promise<{ rows: T[] }> {
|
|
352
|
-
if (sql.includes("information_schema.columns")) {
|
|
353
|
-
return {
|
|
354
|
-
rows: ["id", "title", "views", "status"].map(c => ({
|
|
355
|
-
table_schema: "public",
|
|
356
|
-
table_name: "posts",
|
|
357
|
-
column_name: c,
|
|
358
|
-
is_nullable: "YES"
|
|
359
|
-
})) as unknown as T[]
|
|
360
|
-
};
|
|
361
|
-
}
|
|
362
|
-
// Before the `pg_type` arm: the enum *values* query joins
|
|
363
|
-
// pg_type too, and answering it with the type-name rows would
|
|
364
|
-
// describe a type whose only value is `undefined`.
|
|
365
|
-
if (sql.includes("pg_enum")) {
|
|
366
|
-
return {
|
|
367
|
-
rows: ["draft", "published"].map(value => ({
|
|
368
|
-
schema: "public",
|
|
369
|
-
name: "posts_status",
|
|
370
|
-
value
|
|
371
|
-
})) as unknown as T[]
|
|
372
|
-
};
|
|
373
|
-
}
|
|
374
|
-
if (sql.includes("pg_type")) {
|
|
375
|
-
return { rows: [{ schema: "public", name: "posts_status" }] as unknown as T[] };
|
|
376
|
-
}
|
|
377
|
-
return { rows: [] as T[] };
|
|
378
|
-
}
|
|
379
|
-
};
|
|
380
|
-
const plan = await ensureCollectionTables(client, [posts]);
|
|
381
|
-
expect(plan.actions).toEqual([]);
|
|
382
|
-
});
|
|
383
|
-
|
|
384
|
-
it("adds an enum value the existing type is missing", async () => {
|
|
385
|
-
// The bug this covers: the type exists, so the whole type was skipped by
|
|
386
|
-
// name, the boot reported success, and the first row using the new value
|
|
387
|
-
// was rejected by a type that had never heard of it.
|
|
388
|
-
const client: Queryable = {
|
|
389
|
-
async query<T>(sql: string): Promise<{ rows: T[] }> {
|
|
390
|
-
if (sql.includes("information_schema.columns")) {
|
|
391
|
-
return {
|
|
392
|
-
rows: ["id", "title", "views", "status"].map(c => ({
|
|
393
|
-
table_schema: "public",
|
|
394
|
-
table_name: "posts",
|
|
395
|
-
column_name: c,
|
|
396
|
-
is_nullable: "YES"
|
|
397
|
-
})) as unknown as T[]
|
|
398
|
-
};
|
|
399
|
-
}
|
|
400
|
-
if (sql.includes("pg_enum")) {
|
|
401
|
-
// "published" is missing from the type.
|
|
402
|
-
return {
|
|
403
|
-
rows: [{ schema: "public", name: "posts_status", value: "draft" }] as unknown as T[]
|
|
404
|
-
};
|
|
405
|
-
}
|
|
406
|
-
if (sql.includes("pg_type")) {
|
|
407
|
-
return { rows: [{ schema: "public", name: "posts_status" }] as unknown as T[] };
|
|
408
|
-
}
|
|
409
|
-
return { rows: [] as T[] };
|
|
410
|
-
}
|
|
411
|
-
};
|
|
412
|
-
const plan = await ensureCollectionTables(client, [posts]);
|
|
413
|
-
expect(plan.actions).toEqual([{
|
|
414
|
-
kind: "add-enum-value",
|
|
415
|
-
target: "public.posts_status.published",
|
|
416
|
-
sql: `ALTER TYPE "public"."posts_status" ADD VALUE IF NOT EXISTS 'published';`
|
|
417
|
-
}]);
|
|
418
|
-
});
|
|
419
|
-
});
|
|
420
|
-
|
|
421
|
-
/**
|
|
422
|
-
* Two instances booting into the same fresh database.
|
|
423
|
-
*
|
|
424
|
-
* These inject the error at the statement level rather than driving two real
|
|
425
|
-
* boots, and that is deliberate: the end state after a real race is usually
|
|
426
|
-
* correct anyway — one instance always finishes — so a test that only asserts
|
|
427
|
-
* the end state passes against the broken code as well as the fixed code. The
|
|
428
|
-
* defect was never "the table is missing"; it was that the throw abandoned
|
|
429
|
-
* every remaining action, so the loser skipped work it had not attempted yet.
|
|
430
|
-
*/
|
|
431
|
-
describe("a simultaneous boot", () => {
|
|
432
|
-
/** A driver error shaped the way node-postgres reports one, through drizzle. */
|
|
433
|
-
function pgError(code: string, extra: Record<string, unknown> = {}): Error {
|
|
434
|
-
const inner = Object.assign(new Error(`pg error ${code}`), { code, ...extra });
|
|
435
|
-
// Drizzle wraps the driver error; nothing useful is ever on the top level.
|
|
436
|
-
return Object.assign(new Error("Failed query"), { cause: inner });
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
/** Records every statement, and fails the first match of `failOn` once. */
|
|
440
|
-
function racingClient(
|
|
441
|
-
failOn: RegExp,
|
|
442
|
-
error: Error,
|
|
443
|
-
options: { forever?: boolean } = {}
|
|
444
|
-
): { client: Queryable; executed: string[] } {
|
|
445
|
-
const executed: string[] = [];
|
|
446
|
-
let thrown = false;
|
|
447
|
-
return {
|
|
448
|
-
executed,
|
|
449
|
-
client: {
|
|
450
|
-
async query<T>(sql: string): Promise<{ rows: T[] }> {
|
|
451
|
-
executed.push(sql);
|
|
452
|
-
if (failOn.test(sql) && (options.forever || !thrown)) {
|
|
453
|
-
thrown = true;
|
|
454
|
-
throw error;
|
|
455
|
-
}
|
|
456
|
-
return { rows: [] as T[] };
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
};
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
it("carries on to the remaining actions when it loses a CREATE TABLE race", async () => {
|
|
463
|
-
// 42P07 duplicate_table: a peer created it between our catalog read and
|
|
464
|
-
// our write. The table exists; everything after it still has to run.
|
|
465
|
-
const { client, executed } = racingClient(/^CREATE TABLE/, pgError("42P07"), { forever: true });
|
|
466
|
-
|
|
467
|
-
const plan = await ensureCollectionTables(client, [posts]);
|
|
468
|
-
|
|
469
|
-
expect(plan.actions.length).toBeGreaterThan(0);
|
|
470
|
-
// The proof: statements that come *after* the losing one were attempted.
|
|
471
|
-
expect(executed.some(s => s.startsWith("ALTER TABLE"))).toBe(true);
|
|
472
|
-
});
|
|
473
|
-
|
|
474
|
-
it("treats a catalog unique violation as the object already existing", async () => {
|
|
475
|
-
// The one measured in practice: `CREATE TYPE` has no IF NOT EXISTS, so
|
|
476
|
-
// the loser gets 23505 on pg_type's own index.
|
|
477
|
-
const { client, executed } = racingClient(
|
|
478
|
-
/^CREATE TYPE/,
|
|
479
|
-
pgError("23505", { constraint: "pg_type_typname_nsp_index" }),
|
|
480
|
-
{ forever: true }
|
|
481
|
-
);
|
|
482
|
-
|
|
483
|
-
await ensureCollectionTables(client, [posts]);
|
|
484
|
-
|
|
485
|
-
expect(executed.some(s => s.startsWith("CREATE TABLE"))).toBe(true);
|
|
486
|
-
});
|
|
487
|
-
|
|
488
|
-
it("retries a deadlock and then succeeds", async () => {
|
|
489
|
-
// 40P01: two boots taking catalog locks in step. Unlike a duplicate, the
|
|
490
|
-
// statement did nothing at all, so it must actually be run again.
|
|
491
|
-
const { client, executed } = racingClient(/^CREATE TABLE/, pgError("40P01"));
|
|
492
|
-
|
|
493
|
-
await ensureCollectionTables(client, [posts]);
|
|
494
|
-
|
|
495
|
-
expect(executed.filter(s => s.startsWith("CREATE TABLE")).length).toBeGreaterThan(1);
|
|
496
|
-
});
|
|
497
|
-
|
|
498
|
-
it("still fails loudly on a unique violation from the customer's own data", async () => {
|
|
499
|
-
// A named constraint, not a `pg_` catalog index — this is a real problem
|
|
500
|
-
// with real rows and must not be swallowed as "someone beat me to it".
|
|
501
|
-
// It is still retried first, because 23505 is in the retryable set and
|
|
502
|
-
// this shape cannot be told from a race until the constraint name is
|
|
503
|
-
// read; what matters is that it ends in a throw rather than a shrug.
|
|
504
|
-
const { client } = racingClient(
|
|
505
|
-
/^CREATE TABLE/,
|
|
506
|
-
pgError("23505", { constraint: "posts_slug_key" }),
|
|
507
|
-
{ forever: true }
|
|
508
|
-
);
|
|
509
|
-
|
|
510
|
-
await expect(ensureCollectionTables(client, [posts])).rejects.toThrow(/public\.posts/);
|
|
511
|
-
});
|
|
512
|
-
|
|
513
|
-
it("still fails loudly on a permission error", async () => {
|
|
514
|
-
const { client } = racingClient(/^CREATE TABLE/, pgError("42501"), { forever: true });
|
|
515
|
-
|
|
516
|
-
await expect(ensureCollectionTables(client, [posts])).rejects.toThrow(/public\.posts/);
|
|
517
|
-
});
|
|
518
|
-
});
|