@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.
- package/dist/PostgresAdapter.d.ts +1 -1
- package/dist/PostgresBackendDriver.d.ts +16 -7
- package/dist/PostgresBootstrapper.d.ts +6 -6
- package/dist/auth/services.d.ts +1 -1
- package/dist/backup/backup-cron.d.ts +1 -1
- package/dist/backup/backup-service.d.ts +2 -2
- package/dist/backup/index.d.ts +4 -4
- package/dist/collections/PostgresCollectionRegistry.d.ts +1 -1
- package/dist/collections/buildRegistry.d.ts +1 -1
- package/dist/collections/validate-relations.d.ts +1 -1
- package/dist/data-transformer.d.ts +1 -1
- package/dist/{ensure-collection-policies-BVFb2olB.js → ensure-collection-policies-CVfVHS6o.js} +3 -3
- package/dist/{ensure-collection-policies-BVFb2olB.js.map → ensure-collection-policies-CVfVHS6o.js.map} +1 -1
- package/dist/{auth-users-columns-CgyPWQ18.js → ensure-collection-tables-jYsvOLZF.js} +1443 -11
- package/dist/ensure-collection-tables-jYsvOLZF.js.map +1 -0
- package/dist/index.d.ts +16 -16
- package/dist/index.es.js +2169 -1803
- package/dist/index.es.js.map +1 -1
- package/dist/{rls-enforcement-Ch0T6OwW.js → rls-enforcement-CsD7nZDn.js} +2 -2
- package/dist/{rls-enforcement-Ch0T6OwW.js.map → rls-enforcement-CsD7nZDn.js.map} +1 -1
- package/dist/schema/classify-change.d.ts +82 -0
- package/dist/schema/dynamic-tables.d.ts +1 -1
- package/dist/schema/ensure-collection-policies.d.ts +1 -1
- package/dist/schema/ensure-collection-tables.d.ts +93 -2
- package/dist/schema/generate-schema-commit.d.ts +136 -0
- package/dist/schema/introspect-db-constraints.d.ts +1 -1
- package/dist/schema/introspect-db-logic.d.ts +3 -3
- package/dist/schema/introspect-db-project.d.ts +1 -1
- package/dist/schema/introspect-db-queries.d.ts +1 -1
- package/dist/schema/introspect-db-structure.d.ts +2 -2
- package/dist/schema/introspect-runtime.d.ts +1 -1
- package/dist/schema/vector-index.d.ts +88 -0
- package/dist/services/BranchService.d.ts +2 -2
- package/dist/services/FetchService.d.ts +4 -4
- package/dist/services/PersistService.d.ts +5 -5
- package/dist/services/RelationService.d.ts +3 -3
- package/dist/services/RelationWriteService.d.ts +3 -3
- package/dist/services/cdc/junction-tables.d.ts +1 -1
- package/dist/services/cdc/trigger-cdc.d.ts +1 -1
- package/dist/services/channel-bus/PostgresChannelBus.d.ts +1 -1
- package/dist/services/channel-bus/index.d.ts +2 -2
- package/dist/services/collection-helpers.d.ts +1 -1
- package/dist/services/dataService.d.ts +10 -10
- package/dist/services/index.d.ts +4 -4
- package/dist/services/junction-writes.d.ts +2 -2
- package/dist/services/nested-path.d.ts +1 -1
- package/dist/services/realtimeService.d.ts +3 -3
- package/dist/services/row-pipeline.d.ts +1 -1
- package/dist/services/write-denial.d.ts +1 -1
- package/dist/utils/drizzle-conditions.d.ts +2 -2
- package/dist/websocket-BVgDVO-V.js.map +1 -1
- package/dist/websocket.d.ts +2 -2
- package/package.json +7 -7
- package/src/PostgresBackendDriver.ts +40 -0
- package/src/schema/classify-change.ts +436 -0
- package/src/schema/ensure-collection-tables.test.ts +168 -1
- package/src/schema/ensure-collection-tables.ts +344 -14
- package/src/schema/generate-postgres-ddl-logic.ts +15 -0
- package/src/schema/generate-schema-commit.ts +242 -0
- package/src/schema/vector-index.ts +278 -0
- package/dist/auth-users-columns-CgyPWQ18.js.map +0 -1
- package/dist/ensure-collection-tables-BY1pHRD_.js +0 -840
- 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
|
/**
|