@rebasepro/server-postgres 0.16.0 → 0.16.1-canary.g2d1aec8

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 (63) hide show
  1. package/dist/PostgresAdapter.d.ts +1 -1
  2. package/dist/PostgresBackendDriver.d.ts +16 -7
  3. package/dist/PostgresBootstrapper.d.ts +6 -6
  4. package/dist/auth/services.d.ts +1 -1
  5. package/dist/backup/backup-cron.d.ts +1 -1
  6. package/dist/backup/backup-service.d.ts +2 -2
  7. package/dist/backup/index.d.ts +4 -4
  8. package/dist/collections/PostgresCollectionRegistry.d.ts +1 -1
  9. package/dist/collections/buildRegistry.d.ts +1 -1
  10. package/dist/collections/validate-relations.d.ts +1 -1
  11. package/dist/data-transformer.d.ts +1 -1
  12. package/dist/{ensure-collection-policies-BVFb2olB.js → ensure-collection-policies-CVfVHS6o.js} +3 -3
  13. package/dist/{ensure-collection-policies-BVFb2olB.js.map → ensure-collection-policies-CVfVHS6o.js.map} +1 -1
  14. package/dist/{auth-users-columns-CgyPWQ18.js → ensure-collection-tables-jYsvOLZF.js} +1443 -11
  15. package/dist/ensure-collection-tables-jYsvOLZF.js.map +1 -0
  16. package/dist/index.d.ts +16 -16
  17. package/dist/index.es.js +2169 -1803
  18. package/dist/index.es.js.map +1 -1
  19. package/dist/{rls-enforcement-Ch0T6OwW.js → rls-enforcement-CsD7nZDn.js} +2 -2
  20. package/dist/{rls-enforcement-Ch0T6OwW.js.map → rls-enforcement-CsD7nZDn.js.map} +1 -1
  21. package/dist/schema/classify-change.d.ts +82 -0
  22. package/dist/schema/dynamic-tables.d.ts +1 -1
  23. package/dist/schema/ensure-collection-policies.d.ts +1 -1
  24. package/dist/schema/ensure-collection-tables.d.ts +93 -2
  25. package/dist/schema/generate-schema-commit.d.ts +136 -0
  26. package/dist/schema/introspect-db-constraints.d.ts +1 -1
  27. package/dist/schema/introspect-db-logic.d.ts +3 -3
  28. package/dist/schema/introspect-db-project.d.ts +1 -1
  29. package/dist/schema/introspect-db-queries.d.ts +1 -1
  30. package/dist/schema/introspect-db-structure.d.ts +2 -2
  31. package/dist/schema/introspect-runtime.d.ts +1 -1
  32. package/dist/schema/vector-index.d.ts +88 -0
  33. package/dist/services/BranchService.d.ts +2 -2
  34. package/dist/services/FetchService.d.ts +4 -4
  35. package/dist/services/PersistService.d.ts +5 -5
  36. package/dist/services/RelationService.d.ts +3 -3
  37. package/dist/services/RelationWriteService.d.ts +3 -3
  38. package/dist/services/cdc/junction-tables.d.ts +1 -1
  39. package/dist/services/cdc/trigger-cdc.d.ts +1 -1
  40. package/dist/services/channel-bus/PostgresChannelBus.d.ts +1 -1
  41. package/dist/services/channel-bus/index.d.ts +2 -2
  42. package/dist/services/collection-helpers.d.ts +1 -1
  43. package/dist/services/dataService.d.ts +10 -10
  44. package/dist/services/index.d.ts +4 -4
  45. package/dist/services/junction-writes.d.ts +2 -2
  46. package/dist/services/nested-path.d.ts +1 -1
  47. package/dist/services/realtimeService.d.ts +3 -3
  48. package/dist/services/row-pipeline.d.ts +1 -1
  49. package/dist/services/write-denial.d.ts +1 -1
  50. package/dist/utils/drizzle-conditions.d.ts +2 -2
  51. package/dist/websocket-BVgDVO-V.js.map +1 -1
  52. package/dist/websocket.d.ts +2 -2
  53. package/package.json +7 -7
  54. package/src/PostgresBackendDriver.ts +40 -0
  55. package/src/schema/classify-change.ts +436 -0
  56. package/src/schema/ensure-collection-tables.test.ts +168 -1
  57. package/src/schema/ensure-collection-tables.ts +344 -14
  58. package/src/schema/generate-postgres-ddl-logic.ts +15 -0
  59. package/src/schema/generate-schema-commit.ts +242 -0
  60. package/src/schema/vector-index.ts +278 -0
  61. package/dist/auth-users-columns-CgyPWQ18.js.map +0 -1
  62. package/dist/ensure-collection-tables-BY1pHRD_.js +0 -840
  63. package/dist/ensure-collection-tables-BY1pHRD_.js.map +0 -1
@@ -0,0 +1,436 @@
1
+ /**
2
+ * What a collection change means for a live database.
3
+ *
4
+ * The live schema editor may only make changes the boot-time ensure path can
5
+ * actually carry out, because that is the one mechanism that changes a schema
6
+ * (see `ensure-collection-tables.ts`). Its vocabulary is small and deliberately
7
+ * so: create a table, add a column, create an enum *type*, create an index, add
8
+ * a foreign key, and rename a column via the legacy-name path. There is no
9
+ * `ALTER COLUMN TYPE` and no `DROP` of anything.
10
+ *
11
+ * So this module answers one question per change: **can the ensure path express
12
+ * it, and if it can, will the result actually match what the config says?**
13
+ *
14
+ * ## Three answers, not two
15
+ *
16
+ * The obvious split is safe / unsafe. It is not enough, because the most
17
+ * dangerous case is neither: a change the ensure path *partly* applies, leaving
18
+ * a database that does not match the configuration and says nothing about it.
19
+ * Two of those exist today and both are documented in the code they come from:
20
+ *
21
+ * - **A required property added to an existing collection.** `ensure` withholds
22
+ * `NOT NULL` on a table that already exists, because the constraint is checked
23
+ * against live rows. The column arrives nullable. The config says required;
24
+ * the database does not enforce it.
25
+ * - **A value added to an existing enum.** `ensure` skips an enum type it
26
+ * already sees — `if (existing.enums.has(name)) continue`. The new value never
27
+ * reaches the database, and the first insert using it fails.
28
+ *
29
+ * Both would read as "applied successfully" to anyone watching. Calling them
30
+ * `diverges` is the whole point of this module: an editor that reports them as
31
+ * safe is worse than one that refuses them.
32
+ *
33
+ * ## Why refusing is the right default for the rest
34
+ *
35
+ * Dropping a column, narrowing a type, changing a primary key: each is
36
+ * expressible in SQL and none is expressible by `ensure`. They need a migration
37
+ * somebody wrote and read. `needs-migration` says exactly that, and naming the
38
+ * change is more useful than attempting it.
39
+ */
40
+ import type {
41
+ CollectionConfig,
42
+ Property,
43
+ SchemaChange,
44
+ SchemaChangeKind,
45
+ SchemaChangeVerdict,
46
+ ClassifiedSchemaChanges
47
+ } from "@rebasepro/types";
48
+ import { getTableName } from "@rebasepro/common";
49
+ import { resolveColumnName } from "./generate-postgres-ddl-logic";
50
+
51
+ /**
52
+ * The vocabulary lives in `@rebasepro/types` so that `@rebasepro/server`, which
53
+ * cannot import this package, can still describe a change. Re-exported here
54
+ * under the names this module has always used.
55
+ */
56
+ export type ChangeVerdict = SchemaChangeVerdict;
57
+ export type ChangeKind = SchemaChangeKind;
58
+ export type { SchemaChange };
59
+ export type ClassifiedChanges = ClassifiedSchemaChanges;
60
+
61
+ const VERDICT_RANK: Record<SchemaChangeVerdict, number> = {
62
+ safe: 0,
63
+ diverges: 1,
64
+ "needs-migration": 2
65
+ };
66
+
67
+ const bySlug = (collections: CollectionConfig[]): Map<string, CollectionConfig> => {
68
+ const map = new Map<string, CollectionConfig>();
69
+ for (const collection of collections) {
70
+ if (collection.slug) map.set(collection.slug, collection);
71
+ }
72
+ return map;
73
+ };
74
+
75
+ const propertiesOf = (collection: CollectionConfig): Record<string, Property> =>
76
+ (collection.properties ?? {}) as Record<string, Property>;
77
+
78
+ /** Enum values a string property declares, or undefined when it is not an enum. */
79
+ const enumValuesOf = (prop: Property): string[] | undefined => {
80
+ const values = (prop as { enum?: unknown }).enum;
81
+ if (!Array.isArray(values)) return undefined;
82
+ return values.map(value =>
83
+ typeof value === "string" ? value : String((value as { id?: unknown })?.id ?? value)
84
+ );
85
+ };
86
+
87
+ const isRequired = (prop: Property): boolean => prop.validation?.required === true;
88
+
89
+ const isIdProperty = (prop: Property): boolean => Boolean((prop as { isId?: unknown }).isId);
90
+
91
+ /**
92
+ * A change to any of these alters the physical column, which the ensure path
93
+ * cannot do. Compared as a tuple so a change to *any* of them is caught without
94
+ * this module having to re-derive the SQL type — which is the DDL generator's
95
+ * job, and duplicating it here is how the two would drift.
96
+ */
97
+ const physicalShapeOf = (prop: Property): string => JSON.stringify([
98
+ prop.type,
99
+ (prop as { columnType?: unknown }).columnType ?? null,
100
+ (prop as { dimensions?: unknown }).dimensions ?? null,
101
+ (prop as { isId?: unknown }).isId ?? null,
102
+ (prop.validation as { max?: unknown } | undefined)?.max ?? null
103
+ ]);
104
+
105
+ /**
106
+ * Facts about the database a change is destined for.
107
+ *
108
+ * Three of the verdicts below cannot be reached from the collections alone:
109
+ * whether a NOT NULL can be added comes down to whether the table holds rows,
110
+ * and whether an enum value will land comes down to which values the type
111
+ * already has. Without these, the classifier answers conservatively — the
112
+ * change *may* diverge — which is the right answer for a caller that has no
113
+ * database to look at, and the wrong one to show somebody staring at theirs.
114
+ */
115
+ export interface SchemaFacts {
116
+ /** `schema.table` → columns. */
117
+ tables: Map<string, Set<string>>;
118
+ /** Tables known to hold at least one row. */
119
+ populatedTables?: Set<string>;
120
+ /** `schema.table.column` for every column the database marks NOT NULL. */
121
+ notNullColumns?: Set<string>;
122
+ /** `schema.typename` → the values that type currently holds. */
123
+ enumValues?: Map<string, string[]>;
124
+ }
125
+
126
+ /** Whether the table behind a collection exists and is empty. */
127
+ const tableIsEmpty = (collection: CollectionConfig, facts?: SchemaFacts): boolean => {
128
+ if (!facts?.populatedTables) return false;
129
+ const key = qualifiedTable(collection);
130
+ // A table the database does not have yet is one this change creates, and a
131
+ // table being created has no rows to check a constraint against.
132
+ if (!facts.tables.has(key)) return true;
133
+ return !facts.populatedTables.has(key);
134
+ };
135
+
136
+ const qualifiedTable = (collection: CollectionConfig): string => {
137
+ const schema = (collection as { schema?: string }).schema ?? "public";
138
+ return `${schema}.${getTableName(collection)}`;
139
+ };
140
+
141
+ /**
142
+ * Classify the difference between two collection sets.
143
+ *
144
+ * `before` is what the running database was built from; `after` is what the
145
+ * editor is proposing. Order within each array is irrelevant.
146
+ *
147
+ * `facts` is what the database actually looks like. Supplied by the live
148
+ * editor; omitted by callers reasoning about collections in the abstract, who
149
+ * get the conservative reading.
150
+ */
151
+ export function classifyCollectionChanges(
152
+ before: CollectionConfig[],
153
+ after: CollectionConfig[],
154
+ facts?: SchemaFacts
155
+ ): ClassifiedChanges {
156
+ const previous = bySlug(before);
157
+ const next = bySlug(after);
158
+ const changes: SchemaChange[] = [];
159
+
160
+ for (const [slug, collection] of next) {
161
+ if (!previous.has(slug)) {
162
+ changes.push({
163
+ kind: "add-collection",
164
+ verdict: "safe",
165
+ collection: slug,
166
+ detail: `New collection "${slug}" — creates table "${getTableName(collection)}".`
167
+ });
168
+ continue;
169
+ }
170
+ classifyProperties(previous.get(slug)!, collection, changes, facts);
171
+ }
172
+
173
+ for (const [slug, collection] of previous) {
174
+ if (next.has(slug)) continue;
175
+ changes.push({
176
+ kind: "remove-collection",
177
+ verdict: "needs-migration",
178
+ collection: slug,
179
+ detail:
180
+ `Collection "${slug}" was removed, which would drop table ` +
181
+ `"${getTableName(collection)}" and everything in it.`,
182
+ remedy:
183
+ "The ensure path never drops anything, so this cannot be applied here. Remove the " +
184
+ "collection in a migration you have read, or keep it and stop serving it."
185
+ });
186
+ }
187
+
188
+ const verdict = changes.reduce<ChangeVerdict>(
189
+ (worst, change) => (VERDICT_RANK[change.verdict] > VERDICT_RANK[worst] ? change.verdict : worst),
190
+ "safe"
191
+ );
192
+
193
+ return { changes, verdict, applicable: verdict === "safe" };
194
+ }
195
+
196
+ function classifyProperties(
197
+ before: CollectionConfig,
198
+ after: CollectionConfig,
199
+ changes: SchemaChange[],
200
+ facts?: SchemaFacts
201
+ ): void {
202
+ const slug = after.slug ?? "";
203
+ const previous = propertiesOf(before);
204
+ const next = propertiesOf(after);
205
+ const empty = tableIsEmpty(after, facts);
206
+
207
+ for (const [name, prop] of Object.entries(next)) {
208
+ const old = previous[name];
209
+
210
+ if (!old) {
211
+ // A NOT NULL is checked against rows that are already there, so
212
+ // whether this is safe is a question about the data, not about the
213
+ // configuration. On an empty table the constraint cannot fail and
214
+ // the ensure path applies it; on a populated one it is withheld and
215
+ // the column arrives nullable, which is a database that disagrees
216
+ // with its own config — still worth refusing, now for a reason the
217
+ // reader can act on.
218
+ if (isRequired(prop) && !empty) {
219
+ changes.push({
220
+ kind: "add-property",
221
+ verdict: "diverges",
222
+ collection: slug,
223
+ property: name,
224
+ detail:
225
+ `"${name}" is required, but "${getTableName(after)}" already holds rows, so ` +
226
+ "NOT NULL would be checked against data that has no value for it yet. The " +
227
+ "column would arrive nullable.",
228
+ remedy:
229
+ "Add it optional, backfill every row, then make it required — the editor " +
230
+ "will apply the constraint once no row violates it."
231
+ });
232
+ } else {
233
+ const column = resolveColumnName(name, prop);
234
+ changes.push({
235
+ kind: "add-property",
236
+ verdict: "safe",
237
+ collection: slug,
238
+ property: name,
239
+ detail: isRequired(prop)
240
+ ? `New required property "${name}" — adds column "${column}" NOT NULL, ` +
241
+ `which "${getTableName(after)}" can take because it holds no rows.`
242
+ : `New optional property "${name}" — adds column "${column}".`
243
+ });
244
+ }
245
+ continue;
246
+ }
247
+
248
+ classifyProperty(slug, after, name, old, prop, changes, facts);
249
+ }
250
+
251
+ for (const [name, prop] of Object.entries(previous)) {
252
+ if (next[name]) continue;
253
+ changes.push({
254
+ kind: "remove-property",
255
+ verdict: "needs-migration",
256
+ collection: slug,
257
+ property: name,
258
+ detail:
259
+ `"${name}" was removed, which would drop column ` +
260
+ `"${resolveColumnName(name, prop)}" and its data.`,
261
+ remedy:
262
+ "The ensure path never drops a column. Remove it in a migration you have read, or " +
263
+ "leave the column and stop exposing the property."
264
+ });
265
+ }
266
+ }
267
+
268
+ function classifyProperty(
269
+ slug: string,
270
+ collection: CollectionConfig,
271
+ name: string,
272
+ before: Property,
273
+ after: Property,
274
+ changes: SchemaChange[],
275
+ facts?: SchemaFacts
276
+ ): void {
277
+ const beforeColumn = resolveColumnName(name, before);
278
+ const afterColumn = resolveColumnName(name, after);
279
+
280
+ if (beforeColumn !== afterColumn) {
281
+ changes.push({
282
+ kind: "rename-column",
283
+ verdict: "needs-migration",
284
+ collection: slug,
285
+ property: name,
286
+ detail: `"${name}" changes column from "${beforeColumn}" to "${afterColumn}".`,
287
+ remedy:
288
+ "The ensure path only renames a column through its legacy-name path, which this is " +
289
+ "not. Rename it in a migration, or the old column stays and the new one is created " +
290
+ "empty beside it."
291
+ });
292
+ }
293
+
294
+ if (isIdProperty(before) !== isIdProperty(after)) {
295
+ changes.push({
296
+ kind: "change-primary-key",
297
+ verdict: "needs-migration",
298
+ collection: slug,
299
+ property: name,
300
+ detail: `"${name}" changes whether it is the primary key.`,
301
+ remedy: "A primary key change rewrites the table and every foreign key into it. Migration only."
302
+ });
303
+ return;
304
+ }
305
+
306
+ if (physicalShapeOf(before) !== physicalShapeOf(after)) {
307
+ changes.push({
308
+ kind: "change-property-type",
309
+ verdict: "needs-migration",
310
+ collection: slug,
311
+ property: name,
312
+ detail: `"${name}" changes physical type — ${before.type} to ${after.type}.`,
313
+ remedy:
314
+ "There is no ALTER COLUMN TYPE in the ensure path, and a cast can fail on data that " +
315
+ "is already there. Change it in a migration."
316
+ });
317
+ }
318
+
319
+ if (!isRequired(before) && isRequired(after)) {
320
+ // Tightening. `SET NOT NULL` scans the table, so this comes down to
321
+ // whether anything in it is null — which on an empty table is nothing.
322
+ const empty = tableIsEmpty(collection, facts);
323
+ changes.push(empty
324
+ ? {
325
+ kind: "change-required",
326
+ verdict: "safe",
327
+ collection: slug,
328
+ property: name,
329
+ detail:
330
+ `"${name}" became required — sets NOT NULL on "${afterColumn}", which ` +
331
+ `"${getTableName(collection)}" can take because it holds no rows.`
332
+ }
333
+ : {
334
+ kind: "change-required",
335
+ verdict: "diverges",
336
+ collection: slug,
337
+ property: name,
338
+ detail:
339
+ `"${name}" became required, but "${getTableName(collection)}" holds rows and ` +
340
+ "SET NOT NULL is checked against every one of them. The database would keep " +
341
+ "accepting nulls.",
342
+ remedy:
343
+ `Backfill first — UPDATE the rows where "${afterColumn}" IS NULL — then apply ` +
344
+ "this again."
345
+ });
346
+ }
347
+
348
+ if (isRequired(before) && !isRequired(after)) {
349
+ // Relaxing. `DROP NOT NULL` cannot fail and cannot lose data, so this is
350
+ // safe on any table; the editor plans it because a reviewed change is
351
+ // the one context in which touching an existing column's constraints is
352
+ // something somebody asked for.
353
+ changes.push({
354
+ kind: "change-required",
355
+ verdict: "safe",
356
+ collection: slug,
357
+ property: name,
358
+ detail: `"${name}" is no longer required — drops NOT NULL from "${afterColumn}".`
359
+ });
360
+ }
361
+
362
+ classifyEnum(slug, collection, name, before, after, changes, facts);
363
+ }
364
+
365
+ function classifyEnum(
366
+ slug: string,
367
+ collection: CollectionConfig,
368
+ name: string,
369
+ before: Property,
370
+ after: Property,
371
+ changes: SchemaChange[],
372
+ facts?: SchemaFacts
373
+ ): void {
374
+ const oldValues = enumValuesOf(before);
375
+ const newValues = enumValuesOf(after);
376
+ if (!oldValues || !newValues) return;
377
+
378
+ const added = newValues.filter(value => !oldValues.includes(value));
379
+ const removed = oldValues.filter(value => !newValues.includes(value));
380
+
381
+ if (added.length > 0) {
382
+ // `ADD VALUE` is additive, idempotent with IF NOT EXISTS, and needs no
383
+ // table scan, so the ensure path now carries it — but only when it can
384
+ // see which values the type already has. A caller with no database
385
+ // cannot know that, and for them this is still the change that silently
386
+ // does not land.
387
+ const seesTheType = facts?.enumValues !== undefined;
388
+ changes.push(seesTheType
389
+ ? {
390
+ kind: "add-enum-value",
391
+ verdict: "safe",
392
+ collection: slug,
393
+ property: name,
394
+ detail: `"${name}" gains ${added.map(v => `"${v}"`).join(", ")} — ALTER TYPE … ADD VALUE.`
395
+ }
396
+ : {
397
+ kind: "add-enum-value",
398
+ verdict: "diverges",
399
+ collection: slug,
400
+ property: name,
401
+ detail:
402
+ `"${name}" gains ${added.map(v => `"${v}"`).join(", ")}, and the values this ` +
403
+ "type already has are not known here, so whether they would land cannot be said.",
404
+ remedy: "Plan this against the database it is destined for."
405
+ });
406
+ }
407
+
408
+ if (removed.length > 0) {
409
+ changes.push({
410
+ kind: "remove-enum-value",
411
+ verdict: "needs-migration",
412
+ collection: slug,
413
+ property: name,
414
+ detail: `"${name}" drops ${removed.map(v => `"${v}"`).join(", ")}.`,
415
+ remedy:
416
+ "Postgres cannot remove a value from an enum type. Recreate the type in a migration, " +
417
+ "after rewriting every row still using the value."
418
+ });
419
+ }
420
+ }
421
+
422
+ /** A one-line summary, for a log or a refusal message. */
423
+ export function summarizeChanges(classified: ClassifiedChanges): string {
424
+ if (classified.changes.length === 0) return "No schema changes.";
425
+
426
+ const counts = classified.changes.reduce<Record<ChangeVerdict, number>>(
427
+ (acc, change) => ({ ...acc, [change.verdict]: (acc[change.verdict] ?? 0) + 1 }),
428
+ { safe: 0, diverges: 0, "needs-migration": 0 }
429
+ );
430
+
431
+ const parts = (["needs-migration", "diverges", "safe"] as ChangeVerdict[])
432
+ .filter(verdict => counts[verdict] > 0)
433
+ .map(verdict => `${counts[verdict]} ${verdict}`);
434
+
435
+ return `${classified.changes.length} change(s): ${parts.join(", ")}.`;
436
+ }
@@ -196,6 +196,124 @@ describe("planning an additive schema ensure", () => {
196
196
  });
197
197
  });
198
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
+
199
317
  describe("applying the plan", () => {
200
318
  function fakeClient(): { client: Queryable; executed: string[] } {
201
319
  const executed: string[] = [];
@@ -236,7 +354,20 @@ describe("applying the plan", () => {
236
354
  rows: ["id", "title", "views", "status"].map(c => ({
237
355
  table_schema: "public",
238
356
  table_name: "posts",
239
- column_name: c
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
240
371
  })) as unknown as T[]
241
372
  };
242
373
  }
@@ -249,6 +380,42 @@ describe("applying the plan", () => {
249
380
  const plan = await ensureCollectionTables(client, [posts]);
250
381
  expect(plan.actions).toEqual([]);
251
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
+ });
252
419
  });
253
420
 
254
421
  /**