@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.
Files changed (149) hide show
  1. package/LICENSE +0 -1
  2. package/README.md +31 -0
  3. package/dist/backup-argv.d.ts +19 -0
  4. package/dist/branch-argv.d.ts +28 -0
  5. package/dist/branch-prune.d.ts +77 -0
  6. package/dist/cli-collections-path.d.ts +31 -0
  7. package/dist/cli-errors.d.ts +123 -0
  8. package/dist/cli-flags.d.ts +101 -0
  9. package/dist/cli-helpers.d.ts +94 -10
  10. package/dist/collection-index-C7LopMyX.js +247 -0
  11. package/dist/collection-index-C7LopMyX.js.map +1 -0
  12. package/dist/databasePoolManager.d.ts +27 -0
  13. package/dist/{ensure-collection-policies-UCqgv_8c.js → ensure-collection-policies-CagOzEPS.js} +6 -6
  14. package/dist/{ensure-collection-policies-UCqgv_8c.js.map → ensure-collection-policies-CagOzEPS.js.map} +1 -1
  15. package/dist/{ensure-collection-tables-DgVixhX3.js → ensure-collection-tables-DbAhrjnG.js} +614 -52
  16. package/dist/ensure-collection-tables-DbAhrjnG.js.map +1 -0
  17. package/dist/index.d.ts +12 -0
  18. package/dist/index.es.js +19307 -3059
  19. package/dist/index.es.js.map +1 -1
  20. package/dist/{rls-enforcement-DvAbL9YJ.js → rls-enforcement-ClJoTboT.js} +53 -189
  21. package/dist/rls-enforcement-ClJoTboT.js.map +1 -0
  22. package/dist/schema/column-type-drift.d.ts +79 -0
  23. package/dist/schema/doctor.d.ts +42 -0
  24. package/dist/schema/ensure-collection-tables.d.ts +90 -0
  25. package/dist/schema/generate-next-step.d.ts +30 -0
  26. package/dist/schema/generate-postgres-ddl-logic.d.ts +28 -0
  27. package/dist/schema/generated-schema-staleness.d.ts +37 -0
  28. package/dist/schema/introspect-db-queries.d.ts +34 -0
  29. package/dist/security/rls-enforcement.d.ts +63 -2
  30. package/dist/services/BranchService.d.ts +86 -1
  31. package/dist/services/collection-helpers.d.ts +1 -1
  32. package/dist/services/realtimeService.d.ts +30 -2
  33. package/dist/services/row-pipeline.d.ts +12 -1
  34. package/dist/utils/pg-error-utils.d.ts +3 -1
  35. package/dist/utils/pg-numeric-number-patch.d.ts +31 -0
  36. package/package.json +32 -27
  37. package/dist/backup-service-DCk7KhhL.js +0 -9044
  38. package/dist/backup-service-DCk7KhhL.js.map +0 -1
  39. package/dist/collection-index-BRUg10H5.js +0 -5690
  40. package/dist/collection-index-BRUg10H5.js.map +0 -1
  41. package/dist/connection-GOKU3Hu5.js +0 -277
  42. package/dist/connection-GOKU3Hu5.js.map +0 -1
  43. package/dist/ensure-collection-tables-DgVixhX3.js.map +0 -1
  44. package/dist/rls-bootstrap-sql-B5C9LoJ6.js +0 -244
  45. package/dist/rls-bootstrap-sql-B5C9LoJ6.js.map +0 -1
  46. package/dist/rls-enforcement-DvAbL9YJ.js.map +0 -1
  47. package/dist/src-DiB5RP2Z.js +0 -541
  48. package/dist/src-DiB5RP2Z.js.map +0 -1
  49. package/dist/websocket-BZ4H5wUz.js +0 -8198
  50. package/dist/websocket-BZ4H5wUz.js.map +0 -1
  51. package/src/PostgresAdapter.ts +0 -91
  52. package/src/PostgresBackendDriver.ts +0 -1857
  53. package/src/PostgresBootstrapper.ts +0 -1157
  54. package/src/auth/ensure-tables.ts +0 -896
  55. package/src/auth/schema-version.ts +0 -260
  56. package/src/auth/services.ts +0 -1610
  57. package/src/backup/backup-cli.ts +0 -520
  58. package/src/backup/backup-cron.ts +0 -212
  59. package/src/backup/backup-logic.ts +0 -93
  60. package/src/backup/backup-service.ts +0 -485
  61. package/src/backup/index.ts +0 -12
  62. package/src/backup/pg-tools.ts +0 -403
  63. package/src/backup/retention.ts +0 -86
  64. package/src/cli-errors.ts +0 -265
  65. package/src/cli-helpers.ts +0 -569
  66. package/src/cli-output.ts +0 -43
  67. package/src/cli.ts +0 -1339
  68. package/src/collections/PostgresCollectionRegistry.ts +0 -108
  69. package/src/collections/buildRegistry.ts +0 -68
  70. package/src/collections/validate-relations.ts +0 -402
  71. package/src/connection.ts +0 -333
  72. package/src/data-transformer.ts +0 -845
  73. package/src/databasePoolManager.ts +0 -95
  74. package/src/history/HistoryService.ts +0 -254
  75. package/src/history/ensure-history-table.ts +0 -53
  76. package/src/index.ts +0 -19
  77. package/src/interfaces.ts +0 -60
  78. package/src/module-dir.ts +0 -7
  79. package/src/schema/atlas-argv.ts +0 -94
  80. package/src/schema/auth-schema.ts +0 -298
  81. package/src/schema/auth-users-columns.ts +0 -131
  82. package/src/schema/carved-out-migration.ts +0 -404
  83. package/src/schema/classify-change.ts +0 -436
  84. package/src/schema/collection-index.ts +0 -427
  85. package/src/schema/collections-schema-version.ts +0 -103
  86. package/src/schema/destructive-sql.ts +0 -94
  87. package/src/schema/doctor-cli.ts +0 -66
  88. package/src/schema/doctor-policy-checks.ts +0 -105
  89. package/src/schema/doctor.ts +0 -837
  90. package/src/schema/drizzle-ddl.ts +0 -33
  91. package/src/schema/dynamic-tables.test.ts +0 -302
  92. package/src/schema/dynamic-tables.ts +0 -293
  93. package/src/schema/ensure-collection-policies.ts +0 -198
  94. package/src/schema/ensure-collection-tables.test.ts +0 -518
  95. package/src/schema/ensure-collection-tables.ts +0 -1267
  96. package/src/schema/generate-drizzle-schema-logic.ts +0 -957
  97. package/src/schema/generate-drizzle-schema.ts +0 -143
  98. package/src/schema/generate-postgres-ddl-logic.ts +0 -1339
  99. package/src/schema/generate-postgres-ddl.ts +0 -180
  100. package/src/schema/generate-schema-commit.ts +0 -267
  101. package/src/schema/generated-schema-staleness.ts +0 -284
  102. package/src/schema/introspect-db-constraints.ts +0 -385
  103. package/src/schema/introspect-db-inference.ts +0 -255
  104. package/src/schema/introspect-db-logic.ts +0 -1387
  105. package/src/schema/introspect-db-naming.ts +0 -15
  106. package/src/schema/introspect-db-project.ts +0 -78
  107. package/src/schema/introspect-db-queries.ts +0 -326
  108. package/src/schema/introspect-db-structure.ts +0 -670
  109. package/src/schema/introspect-db-types.ts +0 -56
  110. package/src/schema/introspect-db.ts +0 -257
  111. package/src/schema/introspect-runtime.test.ts +0 -260
  112. package/src/schema/introspect-runtime.ts +0 -327
  113. package/src/schema/non-sql-collections.test.ts +0 -131
  114. package/src/schema/rls-bootstrap-sql.ts +0 -288
  115. package/src/schema/search-column.ts +0 -643
  116. package/src/schema/test-schema.ts +0 -11
  117. package/src/schema/vector-index.ts +0 -491
  118. package/src/security/anonymous-grants.test.ts +0 -73
  119. package/src/security/policy-drift.test.ts +0 -435
  120. package/src/security/policy-drift.ts +0 -473
  121. package/src/security/rls-enforcement.ts +0 -505
  122. package/src/services/BranchService.ts +0 -326
  123. package/src/services/FetchService.ts +0 -2115
  124. package/src/services/PersistService.ts +0 -557
  125. package/src/services/RelationService.ts +0 -978
  126. package/src/services/RelationWriteService.ts +0 -653
  127. package/src/services/cdc/CdcListener.ts +0 -103
  128. package/src/services/cdc/junction-tables.ts +0 -91
  129. package/src/services/cdc/trigger-cdc.ts +0 -173
  130. package/src/services/channel-bus/ChannelBus.ts +0 -44
  131. package/src/services/channel-bus/PostgresChannelBus.ts +0 -299
  132. package/src/services/channel-bus/index.ts +0 -114
  133. package/src/services/channel-history.ts +0 -424
  134. package/src/services/channel-presence.ts +0 -185
  135. package/src/services/collection-helpers.ts +0 -420
  136. package/src/services/dataService.ts +0 -256
  137. package/src/services/index.ts +0 -14
  138. package/src/services/junction-writes.ts +0 -295
  139. package/src/services/nested-path.ts +0 -145
  140. package/src/services/pg-notify-listener.ts +0 -151
  141. package/src/services/realtimeService.ts +0 -2545
  142. package/src/services/row-pipeline.ts +0 -240
  143. package/src/services/write-denial.ts +0 -55
  144. package/src/types.ts +0 -4
  145. package/src/utils/connection-string.ts +0 -58
  146. package/src/utils/drizzle-conditions.ts +0 -2743
  147. package/src/utils/pg-array-null-patch.ts +0 -42
  148. package/src/utils/pg-error-utils.ts +0 -391
  149. 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
- });