@rebasepro/server-postgres 0.17.3 → 0.18.1

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 (204) hide show
  1. package/LICENSE +0 -1
  2. package/README.md +31 -0
  3. package/dist/BranchService-ByLfM_Tt.js +360 -0
  4. package/dist/BranchService-ByLfM_Tt.js.map +1 -0
  5. package/dist/__vite-browser-external-BnuHet1e.js +46 -0
  6. package/dist/__vite-browser-external-BnuHet1e.js.map +1 -0
  7. package/dist/arg-Dni7MzLB.js +87 -0
  8. package/dist/arg-Dni7MzLB.js.map +1 -0
  9. package/dist/auth-users-columns-D2LBFrMH.js +116 -0
  10. package/dist/auth-users-columns-D2LBFrMH.js.map +1 -0
  11. package/dist/backup-argv.d.ts +19 -0
  12. package/dist/backup-cli-oM9WQVlV.js +408 -0
  13. package/dist/backup-cli-oM9WQVlV.js.map +1 -0
  14. package/dist/backup-service-Br3tqwsy.js +715 -0
  15. package/dist/backup-service-Br3tqwsy.js.map +1 -0
  16. package/dist/branch-argv.d.ts +28 -0
  17. package/dist/branch-prune.d.ts +77 -0
  18. package/dist/cli-collections-path.d.ts +31 -0
  19. package/dist/cli-errors-DxN4yzdH.js +657 -0
  20. package/dist/cli-errors-DxN4yzdH.js.map +1 -0
  21. package/dist/cli-errors.d.ts +123 -0
  22. package/dist/cli-flags.d.ts +101 -0
  23. package/dist/cli-helpers.d.ts +94 -10
  24. package/dist/cli-output-CNdMql-L.js +47 -0
  25. package/dist/cli-output-CNdMql-L.js.map +1 -0
  26. package/dist/cli.js +2020 -0
  27. package/dist/cli.js.map +1 -0
  28. package/dist/collection-index-BHgT_TKd.js +276 -0
  29. package/dist/collection-index-BHgT_TKd.js.map +1 -0
  30. package/dist/{collections-schema-version-BMeu3cgv.js → collections-schema-version-T6cGvrDi.js} +1 -2
  31. package/dist/{collections-schema-version-BMeu3cgv.js.map → collections-schema-version-T6cGvrDi.js.map} +1 -1
  32. package/dist/{connection-GOKU3Hu5.js → databasePoolManager-Bj5FbeAs.js} +112 -40
  33. package/dist/databasePoolManager-Bj5FbeAs.js.map +1 -0
  34. package/dist/databasePoolManager.d.ts +27 -0
  35. package/dist/doctor-D7HWmGzs.js +974 -0
  36. package/dist/doctor-D7HWmGzs.js.map +1 -0
  37. package/dist/{ensure-collection-policies-UCqgv_8c.js → ensure-collection-policies-2c2NV8zX.js} +7 -27
  38. package/dist/ensure-collection-policies-2c2NV8zX.js.map +1 -0
  39. package/dist/ensure-collection-tables-D4wJzM4y.js +820 -0
  40. package/dist/ensure-collection-tables-D4wJzM4y.js.map +1 -0
  41. package/dist/ensure-tables-CIojK-Rx.js +650 -0
  42. package/dist/ensure-tables-CIojK-Rx.js.map +1 -0
  43. package/dist/generate-drizzle-schema-CwEgVyjO.js +123 -0
  44. package/dist/generate-drizzle-schema-CwEgVyjO.js.map +1 -0
  45. package/dist/generate-drizzle-schema-logic-so2Z_lyC.js +528 -0
  46. package/dist/generate-drizzle-schema-logic-so2Z_lyC.js.map +1 -0
  47. package/dist/{ensure-collection-tables-DgVixhX3.js → generate-postgres-ddl-logic-CtnpIBVv.js} +126 -984
  48. package/dist/generate-postgres-ddl-logic-CtnpIBVv.js.map +1 -0
  49. package/dist/generated-schema-staleness-DQdfJl2A.js +157 -0
  50. package/dist/generated-schema-staleness-DQdfJl2A.js.map +1 -0
  51. package/dist/index.d.ts +12 -0
  52. package/dist/index.es.js +7423 -1133
  53. package/dist/index.es.js.map +1 -1
  54. package/dist/introspect-db-logic-C6LQdTxj.js +1378 -0
  55. package/dist/introspect-db-logic-C6LQdTxj.js.map +1 -0
  56. package/dist/policy-drift-kNJ6X2Uj.js +313 -0
  57. package/dist/policy-drift-kNJ6X2Uj.js.map +1 -0
  58. package/dist/{backup-service-DCk7KhhL.js → rls-bootstrap-sql-DLDnUjtx.js} +737 -2689
  59. package/dist/rls-bootstrap-sql-DLDnUjtx.js.map +1 -0
  60. package/dist/{rls-enforcement-DvAbL9YJ.js → rls-enforcement-DeYEjYLt.js} +65 -190
  61. package/dist/rls-enforcement-DeYEjYLt.js.map +1 -0
  62. package/dist/schema/bin/generate-drizzle-schema.d.ts +1 -0
  63. package/dist/schema/bin/generate-postgres-ddl.d.ts +1 -0
  64. package/dist/schema/column-type-drift.d.ts +79 -0
  65. package/dist/schema/doctor-cli.js +129 -0
  66. package/dist/schema/doctor-cli.js.map +1 -0
  67. package/dist/schema/doctor.d.ts +42 -0
  68. package/dist/schema/ensure-collection-tables.d.ts +90 -0
  69. package/dist/schema/generate-drizzle-schema.d.ts +1 -1
  70. package/dist/schema/generate-drizzle-schema.js +24 -0
  71. package/dist/schema/generate-drizzle-schema.js.map +1 -0
  72. package/dist/schema/generate-next-step.d.ts +30 -0
  73. package/dist/schema/generate-postgres-ddl-logic.d.ts +28 -0
  74. package/dist/schema/generate-postgres-ddl.d.ts +1 -1
  75. package/dist/schema/generate-postgres-ddl.js +138 -0
  76. package/dist/schema/generate-postgres-ddl.js.map +1 -0
  77. package/dist/schema/generated-schema-staleness.d.ts +37 -0
  78. package/dist/schema/introspect-db-queries.d.ts +34 -0
  79. package/dist/schema/introspect-db.js +866 -0
  80. package/dist/schema/introspect-db.js.map +1 -0
  81. package/dist/security/rls-enforcement.d.ts +63 -2
  82. package/dist/services/BranchService.d.ts +86 -1
  83. package/dist/services/collection-helpers.d.ts +1 -1
  84. package/dist/services/realtimeService.d.ts +30 -2
  85. package/dist/services/row-pipeline.d.ts +12 -1
  86. package/dist/source-Br7L7GOI.js +410 -0
  87. package/dist/source-Br7L7GOI.js.map +1 -0
  88. package/dist/utils/pg-error-utils.d.ts +3 -1
  89. package/dist/utils/pg-numeric-number-patch.d.ts +31 -0
  90. package/dist/vector-index-DkWxm2de.js +309 -0
  91. package/dist/vector-index-DkWxm2de.js.map +1 -0
  92. package/package.json +32 -27
  93. package/dist/backup-service-DCk7KhhL.js.map +0 -1
  94. package/dist/collection-index-BRUg10H5.js +0 -5690
  95. package/dist/collection-index-BRUg10H5.js.map +0 -1
  96. package/dist/connection-GOKU3Hu5.js.map +0 -1
  97. package/dist/ensure-collection-policies-UCqgv_8c.js.map +0 -1
  98. package/dist/ensure-collection-tables-DgVixhX3.js.map +0 -1
  99. package/dist/rls-bootstrap-sql-B5C9LoJ6.js +0 -244
  100. package/dist/rls-bootstrap-sql-B5C9LoJ6.js.map +0 -1
  101. package/dist/rls-enforcement-DvAbL9YJ.js.map +0 -1
  102. package/dist/src-DiB5RP2Z.js +0 -541
  103. package/dist/src-DiB5RP2Z.js.map +0 -1
  104. package/dist/websocket-BZ4H5wUz.js +0 -8198
  105. package/dist/websocket-BZ4H5wUz.js.map +0 -1
  106. package/src/PostgresAdapter.ts +0 -91
  107. package/src/PostgresBackendDriver.ts +0 -1857
  108. package/src/PostgresBootstrapper.ts +0 -1157
  109. package/src/auth/ensure-tables.ts +0 -896
  110. package/src/auth/schema-version.ts +0 -260
  111. package/src/auth/services.ts +0 -1610
  112. package/src/backup/backup-cli.ts +0 -520
  113. package/src/backup/backup-cron.ts +0 -212
  114. package/src/backup/backup-logic.ts +0 -93
  115. package/src/backup/backup-service.ts +0 -485
  116. package/src/backup/index.ts +0 -12
  117. package/src/backup/pg-tools.ts +0 -403
  118. package/src/backup/retention.ts +0 -86
  119. package/src/cli-errors.ts +0 -265
  120. package/src/cli-helpers.ts +0 -569
  121. package/src/cli-output.ts +0 -43
  122. package/src/cli.ts +0 -1339
  123. package/src/collections/PostgresCollectionRegistry.ts +0 -108
  124. package/src/collections/buildRegistry.ts +0 -68
  125. package/src/collections/validate-relations.ts +0 -402
  126. package/src/connection.ts +0 -333
  127. package/src/data-transformer.ts +0 -845
  128. package/src/databasePoolManager.ts +0 -95
  129. package/src/history/HistoryService.ts +0 -254
  130. package/src/history/ensure-history-table.ts +0 -53
  131. package/src/index.ts +0 -19
  132. package/src/interfaces.ts +0 -60
  133. package/src/module-dir.ts +0 -7
  134. package/src/schema/atlas-argv.ts +0 -94
  135. package/src/schema/auth-schema.ts +0 -298
  136. package/src/schema/auth-users-columns.ts +0 -131
  137. package/src/schema/carved-out-migration.ts +0 -404
  138. package/src/schema/classify-change.ts +0 -436
  139. package/src/schema/collection-index.ts +0 -427
  140. package/src/schema/collections-schema-version.ts +0 -103
  141. package/src/schema/destructive-sql.ts +0 -94
  142. package/src/schema/doctor-cli.ts +0 -66
  143. package/src/schema/doctor-policy-checks.ts +0 -105
  144. package/src/schema/doctor.ts +0 -837
  145. package/src/schema/drizzle-ddl.ts +0 -33
  146. package/src/schema/dynamic-tables.test.ts +0 -302
  147. package/src/schema/dynamic-tables.ts +0 -293
  148. package/src/schema/ensure-collection-policies.ts +0 -198
  149. package/src/schema/ensure-collection-tables.test.ts +0 -518
  150. package/src/schema/ensure-collection-tables.ts +0 -1267
  151. package/src/schema/generate-drizzle-schema-logic.ts +0 -957
  152. package/src/schema/generate-drizzle-schema.ts +0 -143
  153. package/src/schema/generate-postgres-ddl-logic.ts +0 -1339
  154. package/src/schema/generate-postgres-ddl.ts +0 -180
  155. package/src/schema/generate-schema-commit.ts +0 -267
  156. package/src/schema/generated-schema-staleness.ts +0 -284
  157. package/src/schema/introspect-db-constraints.ts +0 -385
  158. package/src/schema/introspect-db-inference.ts +0 -255
  159. package/src/schema/introspect-db-logic.ts +0 -1387
  160. package/src/schema/introspect-db-naming.ts +0 -15
  161. package/src/schema/introspect-db-project.ts +0 -78
  162. package/src/schema/introspect-db-queries.ts +0 -326
  163. package/src/schema/introspect-db-structure.ts +0 -670
  164. package/src/schema/introspect-db-types.ts +0 -56
  165. package/src/schema/introspect-db.ts +0 -257
  166. package/src/schema/introspect-runtime.test.ts +0 -260
  167. package/src/schema/introspect-runtime.ts +0 -327
  168. package/src/schema/non-sql-collections.test.ts +0 -131
  169. package/src/schema/rls-bootstrap-sql.ts +0 -288
  170. package/src/schema/search-column.ts +0 -643
  171. package/src/schema/test-schema.ts +0 -11
  172. package/src/schema/vector-index.ts +0 -491
  173. package/src/security/anonymous-grants.test.ts +0 -73
  174. package/src/security/policy-drift.test.ts +0 -435
  175. package/src/security/policy-drift.ts +0 -473
  176. package/src/security/rls-enforcement.ts +0 -505
  177. package/src/services/BranchService.ts +0 -326
  178. package/src/services/FetchService.ts +0 -2115
  179. package/src/services/PersistService.ts +0 -557
  180. package/src/services/RelationService.ts +0 -978
  181. package/src/services/RelationWriteService.ts +0 -653
  182. package/src/services/cdc/CdcListener.ts +0 -103
  183. package/src/services/cdc/junction-tables.ts +0 -91
  184. package/src/services/cdc/trigger-cdc.ts +0 -173
  185. package/src/services/channel-bus/ChannelBus.ts +0 -44
  186. package/src/services/channel-bus/PostgresChannelBus.ts +0 -299
  187. package/src/services/channel-bus/index.ts +0 -114
  188. package/src/services/channel-history.ts +0 -424
  189. package/src/services/channel-presence.ts +0 -185
  190. package/src/services/collection-helpers.ts +0 -420
  191. package/src/services/dataService.ts +0 -256
  192. package/src/services/index.ts +0 -14
  193. package/src/services/junction-writes.ts +0 -295
  194. package/src/services/nested-path.ts +0 -145
  195. package/src/services/pg-notify-listener.ts +0 -151
  196. package/src/services/realtimeService.ts +0 -2545
  197. package/src/services/row-pipeline.ts +0 -240
  198. package/src/services/write-denial.ts +0 -55
  199. package/src/types.ts +0 -4
  200. package/src/utils/connection-string.ts +0 -58
  201. package/src/utils/drizzle-conditions.ts +0 -2743
  202. package/src/utils/pg-array-null-patch.ts +0 -42
  203. package/src/utils/pg-error-utils.ts +0 -391
  204. 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
- });