@assemora/database 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,187 @@
1
+ import type { ColumnDescriptor, RelationDescriptor, TableDescriptor } from './adapter.js';
2
+ /**
3
+ * The two ways a change can go wrong, carried by every change so that a caller never
4
+ * has to narrow before it can ask (SPEC.md §34).
5
+ *
6
+ * They are different questions. A destructive change succeeds and takes data with it;
7
+ * one that may fail on existing rows takes nothing, because the database refuses it
8
+ * until somebody fills the empty rows in or removes the duplicates. A change can be
9
+ * both, and a change that is neither is safe to apply to a live table.
10
+ */
11
+ export type ChangeRisk = {
12
+ /**
13
+ * Applying it may destroy data no later migration can bring back.
14
+ *
15
+ * A drop answers this for certain. A type change answers it as a possibility: a
16
+ * value that does not fit the new type is either rewritten or refused, and which of
17
+ * the two happens is the engine's decision rather than a property of the types, so
18
+ * an unclassified narrowing raises the warning that costs a person the most to
19
+ * ignore.
20
+ */
21
+ readonly destructive: boolean;
22
+ /** Applying it may be refused by a table whose rows do not already comply. */
23
+ readonly mayFailOnExistingRows: boolean;
24
+ };
25
+ /**
26
+ * A new table, whole.
27
+ *
28
+ * Its columns, indexes and foreign keys travel in the descriptor rather than as
29
+ * changes of their own — the generator that creates a table creates all of it.
30
+ */
31
+ export type TableAdded = ChangeRisk & {
32
+ readonly kind: 'tableAdded';
33
+ readonly table: string;
34
+ readonly after: TableDescriptor;
35
+ };
36
+ export type TableRemoved = ChangeRisk & {
37
+ readonly kind: 'tableRemoved';
38
+ readonly table: string;
39
+ readonly before: TableDescriptor;
40
+ };
41
+ /** A new column, with its uniqueness and its index already in the descriptor. */
42
+ export type ColumnAdded = ChangeRisk & {
43
+ readonly kind: 'columnAdded';
44
+ readonly table: string;
45
+ readonly column: string;
46
+ readonly after: ColumnDescriptor;
47
+ /**
48
+ * Whether this column arrives because the table is becoming translatable (SPEC.md §131).
49
+ *
50
+ * A column that cannot be null normally has no value to give the rows already there,
51
+ * which is why adding one is refused. `locale` is the exception and the only one: what
52
+ * every existing row is written in is the deployment's default language, and that is a
53
+ * fact the framework holds rather than a guess about the data.
54
+ */
55
+ readonly becomesTranslatable?: boolean;
56
+ };
57
+ export type ColumnRemoved = ChangeRisk & {
58
+ readonly kind: 'columnRemoved';
59
+ readonly table: string;
60
+ readonly column: string;
61
+ readonly before: ColumnDescriptor;
62
+ };
63
+ export type ColumnTypeChanged = ChangeRisk & {
64
+ readonly kind: 'columnTypeChanged';
65
+ readonly table: string;
66
+ readonly column: string;
67
+ readonly before: ColumnDescriptor;
68
+ readonly after: ColumnDescriptor;
69
+ };
70
+ export type ColumnNullabilityChanged = ChangeRisk & {
71
+ readonly kind: 'columnNullabilityChanged';
72
+ readonly table: string;
73
+ readonly column: string;
74
+ readonly before: ColumnDescriptor;
75
+ readonly after: ColumnDescriptor;
76
+ };
77
+ export type ColumnUniquenessChanged = ChangeRisk & {
78
+ readonly kind: 'columnUniquenessChanged';
79
+ readonly table: string;
80
+ readonly column: string;
81
+ readonly before: ColumnDescriptor;
82
+ readonly after: ColumnDescriptor;
83
+ };
84
+ /**
85
+ * The allowed values of an enum column moved.
86
+ *
87
+ * `added` and `removed` are computed once here: which values went is what decides
88
+ * both the warning and whether the change can be applied at all, and every caller
89
+ * would otherwise take the same two set differences.
90
+ */
91
+ export type ColumnEnumChanged = ChangeRisk & {
92
+ readonly kind: 'columnEnumChanged';
93
+ readonly table: string;
94
+ readonly column: string;
95
+ readonly before: ColumnDescriptor;
96
+ readonly after: ColumnDescriptor;
97
+ readonly added: readonly string[];
98
+ readonly removed: readonly string[];
99
+ };
100
+ export type PrimaryKeyMoved = ChangeRisk & {
101
+ readonly kind: 'primaryKeyMoved';
102
+ readonly table: string;
103
+ readonly before: string;
104
+ readonly after: string;
105
+ };
106
+ export type IndexAdded = ChangeRisk & {
107
+ readonly kind: 'indexAdded';
108
+ readonly table: string;
109
+ readonly column: string;
110
+ readonly after: ColumnDescriptor;
111
+ };
112
+ export type IndexRemoved = ChangeRisk & {
113
+ readonly kind: 'indexRemoved';
114
+ readonly table: string;
115
+ readonly column: string;
116
+ readonly before: ColumnDescriptor;
117
+ };
118
+ /** `column` is the local column the constraint sits on, not the relation's name. */
119
+ export type ForeignKeyAdded = ChangeRisk & {
120
+ readonly kind: 'foreignKeyAdded';
121
+ readonly table: string;
122
+ readonly column: string;
123
+ readonly after: RelationDescriptor;
124
+ };
125
+ export type ForeignKeyRemoved = ChangeRisk & {
126
+ readonly kind: 'foreignKeyRemoved';
127
+ readonly table: string;
128
+ readonly column: string;
129
+ readonly before: RelationDescriptor;
130
+ };
131
+ /**
132
+ * One difference between two schemas.
133
+ *
134
+ * `before` is always the state going away and `after` the state arriving, so an
135
+ * addition has only an `after` and a removal only a `before`. A generator switches on
136
+ * `kind` and writes both directions from what the change carries; it never has to
137
+ * find the descriptors again. Adding a member here is a compile error in every
138
+ * generator that exhausts the union, which is the point of it being one.
139
+ */
140
+ /**
141
+ * A group of columns unique together, arriving on or leaving a table that stays.
142
+ *
143
+ * `uniqueTogether` used to travel only inside `tableAdded` and `tableRemoved`, on the
144
+ * reasoning that a composite unique cannot move on a table that stays. Localisation
145
+ * moves one: `slug` unique on its own becomes `(slug, locale)` unique the moment a model
146
+ * is translatable, and without this the generated migration dropped the old constraint
147
+ * and added nothing — a table that had lost a guarantee and said nothing about it.
148
+ */
149
+ export type UniqueTogetherAdded = ChangeRisk & {
150
+ readonly kind: 'uniqueTogetherAdded';
151
+ readonly table: string;
152
+ readonly columns: readonly string[];
153
+ };
154
+ export type UniqueTogetherRemoved = ChangeRisk & {
155
+ readonly kind: 'uniqueTogetherRemoved';
156
+ readonly table: string;
157
+ readonly columns: readonly string[];
158
+ };
159
+ export type SchemaChange = TableAdded | TableRemoved | ColumnAdded | ColumnRemoved | ColumnTypeChanged | ColumnNullabilityChanged | ColumnUniquenessChanged | ColumnEnumChanged | PrimaryKeyMoved | IndexAdded | IndexRemoved | ForeignKeyAdded | ForeignKeyRemoved | UniqueTogetherAdded | UniqueTogetherRemoved;
160
+ export type SchemaDiff = {
161
+ /** In the order they must be applied. Empty when the two schemas agree. */
162
+ readonly changes: readonly SchemaChange[];
163
+ };
164
+ /**
165
+ * What has to happen for `before` to become `after` (SPEC.md §34).
166
+ *
167
+ * Pure, and dialect-neutral by construction: it produces a list of changes, and a
168
+ * generator turns each one into the statements its engine needs.
169
+ */
170
+ export declare const diffSchema: (before: readonly TableDescriptor[], after: readonly TableDescriptor[]) => SchemaDiff;
171
+ /** Whether applying the diff loses data. What SPEC.md §34 asks for a warning about. */
172
+ export declare const isDestructive: (diff: SchemaDiff) => boolean;
173
+ /**
174
+ * Whether applying the diff can be refused by a table that already holds rows.
175
+ *
176
+ * The other half of the warning: these changes lose nothing, they simply do not run
177
+ * until somebody has filled in or cleaned up what is already stored.
178
+ */
179
+ export declare const mayFailOnExistingRows: (diff: SchemaDiff) => boolean;
180
+ /**
181
+ * One change as a sentence a person can act on (SPEC.md §34).
182
+ *
183
+ * The switch has no default: a new kind of change has to be given words before it can
184
+ * reach anybody.
185
+ */
186
+ export declare const describeChange: (change: SchemaChange) => string;
187
+ //# sourceMappingURL=schema-diff.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schema-diff.d.ts","sourceRoot":"","sources":["../src/schema-diff.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,473 @@
1
+ /**
2
+ * Schema diffing (SPEC.md §34).
3
+ *
4
+ * `assemora db:generate` writes a migration, and a migration is the difference
5
+ * between two schemas rather than a schema. Working that difference out needs no
6
+ * SQL: whether a column can still hold what it held is a property of the types, and
7
+ * the types are the ones every adapter already shares. So the answer lives here, and
8
+ * each dialect turns the same list of changes into its own statements.
9
+ *
10
+ * Three things a descriptor carries are deliberately not compared. `hasDefault` is a
11
+ * data-layer concern that never reaches the DDL (ADR-0011), so it changes neither the
12
+ * statements nor the warning about them; `softDeleteColumn` names an ordinary column,
13
+ * already compared as one; and `isPrimary` is ignored in favour of
14
+ * `TableDescriptor.primaryKey`, because two fields stating one fact would report a
15
+ * moved key twice.
16
+ *
17
+ * What a descriptor cannot express, this cannot see: an index is a flag on a single
18
+ * column, so composite and partial indexes are invisible, and a foreign key exists
19
+ * only where a `belongsTo` relation puts one. `uniqueTogether` is expressible and
20
+ * still travels whole, inside `tableAdded` and `tableRemoved` — a composite unique
21
+ * moving on a table that stays needs a `SchemaChange` of its own, and no declaration
22
+ * can produce that yet: the only writer of one is the join table below, whose
23
+ * constraint is fixed by the two columns it is made of.
24
+ *
25
+ * Both sides have to be descriptors the framework produced — the snapshot in
26
+ * `.assemora/generated/` against the model registry (ADR-0021). What a database
27
+ * reports is not one: `DatabaseAdapter.introspect()` carries no relations and maps an
28
+ * enum column back to the `text` it is stored as, so diffing a live database against
29
+ * the registry adds every foreign key and re-enums every enum column, on every run.
30
+ * Comparing the two becomes possible when introspection reads constraints, and not
31
+ * before.
32
+ */
33
+ import { AssemoraError } from '@assemora/core';
34
+ import { withJoinTables } from './join-table.js';
35
+ /**
36
+ * Type changes that hold every value the old type could.
37
+ *
38
+ * The list is short on purpose. Calling a safe change destructive costs somebody a
39
+ * warning they can read and dismiss; calling a destructive one safe costs them the
40
+ * column, so a pair that is not listed here counts as a narrowing.
41
+ */
42
+ const WIDENINGS = {
43
+ uuid: ['string', 'text'],
44
+ string: ['text'],
45
+ integer: ['bigint', 'number', 'decimal'],
46
+ bigint: ['decimal'],
47
+ number: ['decimal'],
48
+ date: ['timestamp'],
49
+ enum: ['text'],
50
+ };
51
+ const widens = (from, to) => (WIDENINGS[from] ?? []).includes(to);
52
+ /**
53
+ * Conversions that rewrite every value, including the ones that fit.
54
+ *
55
+ * These are the type changes whose risk is certain in both directions: no row can
56
+ * refuse them, and what the column used to hold is gone. Every other narrowing is
57
+ * decided by the rows — see `riskOfTypeChange`.
58
+ */
59
+ const REWRITINGS = {
60
+ decimal: ['integer', 'bigint', 'number'],
61
+ number: ['integer', 'bigint'],
62
+ timestamp: ['date'],
63
+ integer: ['boolean'],
64
+ };
65
+ const rewrites = (from, to) => (REWRITINGS[from] ?? []).includes(to);
66
+ /**
67
+ * What a change of type risks (SPEC.md §34).
68
+ *
69
+ * A widening keeps every value, so neither question applies. A rewriting loses the
70
+ * values it converts and refuses nothing. Everything else narrows: some stored value
71
+ * may not fit, and whether the engine cuts it down or refuses the statement is the
72
+ * engine's choice, not the type pair's — PostgreSQL refuses `text -> string` with
73
+ * "value too long" and `text -> enum` with a check violation, where a lax engine
74
+ * would truncate. So an unclassified narrowing raises both warnings rather than
75
+ * guessing which one arrives, and neither is silently answered "no".
76
+ */
77
+ const riskOfTypeChange = (from, to) => {
78
+ if (widens(from, to))
79
+ return { destructive: false, mayFailOnExistingRows: false };
80
+ if (rewrites(from, to))
81
+ return { destructive: true, mayFailOnExistingRows: false };
82
+ return { destructive: true, mayFailOnExistingRows: true };
83
+ };
84
+ /**
85
+ * Where each kind of change sits in a migration.
86
+ *
87
+ * Constraints come off first, so nothing holds on to the columns underneath them;
88
+ * structure is created before anything can reference it; and the two changes that
89
+ * take data with them come last, so a migration that is going to fail fails while
90
+ * everything is still there.
91
+ */
92
+ const RANK = {
93
+ foreignKeyRemoved: 0,
94
+ indexRemoved: 1,
95
+ // Before the columns move: a group being dropped is what frees a column to stop being
96
+ // unique on its own, or to start.
97
+ uniqueTogetherRemoved: 2,
98
+ tableAdded: 3,
99
+ columnAdded: 4,
100
+ columnTypeChanged: 5,
101
+ columnEnumChanged: 6,
102
+ columnNullabilityChanged: 7,
103
+ columnUniquenessChanged: 8,
104
+ primaryKeyMoved: 9,
105
+ // After them, for the mirror reason: `(slug, locale)` cannot be declared until
106
+ // `locale` is there and filled in.
107
+ uniqueTogetherAdded: 10,
108
+ indexAdded: 11,
109
+ foreignKeyAdded: 12,
110
+ columnRemoved: 13,
111
+ tableRemoved: 14,
112
+ };
113
+ /**
114
+ * Keeping one of two objects that share a name would generate a migration for a
115
+ * shape nobody declared, and the mistake would only surface as SQL.
116
+ */
117
+ const byName = (items, nameOf, what) => {
118
+ const found = new Map();
119
+ for (const item of items) {
120
+ const name = nameOf(item);
121
+ if (found.has(name)) {
122
+ throw new AssemoraError('DUPLICATE_DESCRIPTOR', `Two ${what} are both named "${name}"`, {
123
+ status: 500,
124
+ });
125
+ }
126
+ found.set(name, item);
127
+ }
128
+ return found;
129
+ };
130
+ const columnsOf = (table) => byName(table.columns, (column) => column.name, `columns of "${table.name}"`);
131
+ /**
132
+ * The duplicate-column guard, for a table that never reaches the comparison.
133
+ *
134
+ * A table on one side alone is added or dropped whole, so its columns are paired with
135
+ * nothing — and a repeated name is likeliest in a table somebody has just written.
136
+ * The `create table` it produces is refused by PostgreSQL with 42701, long after the
137
+ * declaration that caused it was read.
138
+ */
139
+ const assertColumnNamesAreDistinct = (table) => {
140
+ columnsOf(table);
141
+ };
142
+ const namesOf = (before, after) => [...new Set([...before.keys(), ...after.keys()])].sort();
143
+ /**
144
+ * A foreign key identified by what it constrains, not by the relation's name.
145
+ *
146
+ * Renaming `author` to `writer` changes no constraint and must produce no migration;
147
+ * pointing it at another table changes one. Two relations that describe the identical
148
+ * constraint are one constraint.
149
+ */
150
+ const foreignKeysOf = (table) => {
151
+ const keys = new Map();
152
+ for (const relation of table.relations) {
153
+ if (relation.kind !== 'belongsTo')
154
+ continue;
155
+ keys.set(`${relation.foreignKey} -> ${relation.target}.${relation.ownerKey}`, relation);
156
+ }
157
+ return keys;
158
+ };
159
+ const columnAdded = (table, column, after, becomesTranslatable = false) => ({
160
+ kind: 'columnAdded',
161
+ ...(becomesTranslatable ? { becomesTranslatable } : {}),
162
+ table,
163
+ column,
164
+ after,
165
+ destructive: false,
166
+ // A required column with nothing to put in the rows that already exist is refused
167
+ // rather than filled with a guess. Nothing is lost; the migration is.
168
+ //
169
+ // `hasDefault` cannot excuse it: a model default is applied by the data layer on
170
+ // insert and never reaches the DDL (ADR-0011), so `add column ... not null` meets
171
+ // the existing rows with nothing either way. Reading it here once told somebody
172
+ // that `enumOf('draft', 'published').default('draft')` was safe to add to a table
173
+ // that already held rows, and PostgreSQL answered with 23502.
174
+ mayFailOnExistingRows: !after.isNullable,
175
+ });
176
+ const columnRemoved = (table, column, before) => ({
177
+ kind: 'columnRemoved',
178
+ table,
179
+ column,
180
+ before,
181
+ destructive: true,
182
+ mayFailOnExistingRows: false,
183
+ });
184
+ /** Everything that moved on a column both schemas have. */
185
+ const diffColumn = (table, column, before, after) => {
186
+ const changes = [];
187
+ if (before.type !== after.type) {
188
+ changes.push({
189
+ kind: 'columnTypeChanged',
190
+ table,
191
+ column,
192
+ before,
193
+ after,
194
+ ...riskOfTypeChange(before.type, after.type),
195
+ });
196
+ }
197
+ else if (after.type === 'enum') {
198
+ // Only when both sides are enums. Becoming one, or ceasing to be one, is a type
199
+ // change, and the values travel in its descriptors.
200
+ const was = before.enumValues ?? [];
201
+ const is = after.enumValues ?? [];
202
+ const added = is.filter((value) => !was.includes(value));
203
+ const removed = was.filter((value) => !is.includes(value));
204
+ if (added.length > 0 || removed.length > 0) {
205
+ changes.push({
206
+ kind: 'columnEnumChanged',
207
+ table,
208
+ column,
209
+ before,
210
+ after,
211
+ added,
212
+ removed,
213
+ destructive: false,
214
+ // The new set is what a row is measured against, so the warning belongs to
215
+ // the side that arrives: a column that declared no values constrained
216
+ // nothing, and every row in it may be holding something the set it gains
217
+ // does not allow. Losing the last value is the mirror case — the constraint
218
+ // goes away entirely, and nothing can refuse that.
219
+ mayFailOnExistingRows: after.enumValues !== undefined && (removed.length > 0 || before.enumValues === undefined),
220
+ });
221
+ }
222
+ }
223
+ if (before.isNullable !== after.isNullable) {
224
+ changes.push({
225
+ kind: 'columnNullabilityChanged',
226
+ table,
227
+ column,
228
+ before,
229
+ after,
230
+ destructive: false,
231
+ mayFailOnExistingRows: !after.isNullable,
232
+ });
233
+ }
234
+ if (before.isUnique !== after.isUnique) {
235
+ changes.push({
236
+ kind: 'columnUniquenessChanged',
237
+ table,
238
+ column,
239
+ before,
240
+ after,
241
+ destructive: false,
242
+ mayFailOnExistingRows: after.isUnique,
243
+ });
244
+ }
245
+ if (before.isIndexed !== after.isIndexed) {
246
+ changes.push(after.isIndexed
247
+ ? {
248
+ kind: 'indexAdded',
249
+ table,
250
+ column,
251
+ after,
252
+ destructive: false,
253
+ mayFailOnExistingRows: false,
254
+ }
255
+ : {
256
+ kind: 'indexRemoved',
257
+ table,
258
+ column,
259
+ before,
260
+ destructive: false,
261
+ mayFailOnExistingRows: false,
262
+ });
263
+ }
264
+ return changes;
265
+ };
266
+ const diffColumns = (table, before, after, becomesTranslatable = false) => {
267
+ const changes = [];
268
+ for (const column of namesOf(before, after)) {
269
+ const was = before.get(column);
270
+ const is = after.get(column);
271
+ if (was !== undefined && is !== undefined)
272
+ changes.push(...diffColumn(table, column, was, is));
273
+ else if (is !== undefined)
274
+ changes.push(columnAdded(table, column, is, becomesTranslatable));
275
+ else if (was !== undefined)
276
+ changes.push(columnRemoved(table, column, was));
277
+ }
278
+ return changes;
279
+ };
280
+ const diffTable = (before, after) => {
281
+ const table = after.name;
282
+ const changes = diffColumns(table, columnsOf(before), columnsOf(after), before.translatable !== true && after.translatable === true);
283
+ if (before.primaryKey !== after.primaryKey) {
284
+ changes.push({
285
+ kind: 'primaryKeyMoved',
286
+ table,
287
+ before: before.primaryKey,
288
+ after: after.primaryKey,
289
+ destructive: false,
290
+ // The rows have to be complete and unique on the new column before the database
291
+ // accepts it as a key.
292
+ mayFailOnExistingRows: true,
293
+ });
294
+ }
295
+ /**
296
+ * Compared as sorted, joined names, because a group is a set: `(slug, locale)` and
297
+ * `(locale, slug)` are one constraint, and a declaration that listed them the other
298
+ * way round must not read as a change.
299
+ */
300
+ const groupsOf = (table) => new Map((table.uniqueTogether ?? []).map((columns) => [[...columns].sort().join(','), columns]));
301
+ const wasGroups = groupsOf(before);
302
+ const isGroups = groupsOf(after);
303
+ for (const key of new Set([...wasGroups.keys(), ...isGroups.keys()])) {
304
+ const was = wasGroups.get(key);
305
+ const is = isGroups.get(key);
306
+ if (was !== undefined && is === undefined) {
307
+ changes.push({
308
+ kind: 'uniqueTogetherRemoved',
309
+ table,
310
+ columns: was,
311
+ destructive: false,
312
+ mayFailOnExistingRows: false,
313
+ });
314
+ }
315
+ else if (was === undefined && is !== undefined) {
316
+ changes.push({
317
+ kind: 'uniqueTogetherAdded',
318
+ table,
319
+ columns: is,
320
+ destructive: false,
321
+ // Rows that are already duplicates on the group refuse the constraint.
322
+ mayFailOnExistingRows: true,
323
+ });
324
+ }
325
+ }
326
+ const wasKeys = foreignKeysOf(before);
327
+ const isKeys = foreignKeysOf(after);
328
+ for (const key of namesOf(wasKeys, isKeys)) {
329
+ const was = wasKeys.get(key);
330
+ const is = isKeys.get(key);
331
+ if (was !== undefined && is === undefined) {
332
+ changes.push({
333
+ kind: 'foreignKeyRemoved',
334
+ table,
335
+ column: was.foreignKey,
336
+ before: was,
337
+ destructive: false,
338
+ mayFailOnExistingRows: false,
339
+ });
340
+ }
341
+ else if (was === undefined && is !== undefined) {
342
+ changes.push({
343
+ kind: 'foreignKeyAdded',
344
+ table,
345
+ column: is.foreignKey,
346
+ after: is,
347
+ destructive: false,
348
+ // A row already pointing at something that is not there refuses the
349
+ // constraint.
350
+ mayFailOnExistingRows: true,
351
+ });
352
+ }
353
+ }
354
+ return changes;
355
+ };
356
+ /**
357
+ * What has to happen for `before` to become `after` (SPEC.md §34).
358
+ *
359
+ * Pure, and dialect-neutral by construction: it produces a list of changes, and a
360
+ * generator turns each one into the statements its engine needs.
361
+ */
362
+ export const diffSchema = (before, after) => {
363
+ // A join table has no model, so neither side names it: it is derived from the
364
+ // `belongsToMany` relations on the tables that do (SPEC.md §23). Deriving it here
365
+ // rather than asking every caller to means a many-to-many arriving in the registry
366
+ // arrives in the migration, and `db:generate` needs to know nothing about it. The
367
+ // expansion is idempotent, so a snapshot that already holds the join table still
368
+ // compares clean against a registry that derives it.
369
+ const previous = withJoinTables(before);
370
+ const current = withJoinTables(after);
371
+ const was = byName(previous, (table) => table.name, 'tables');
372
+ const is = byName(current, (table) => table.name, 'tables');
373
+ const changes = [];
374
+ // Every table on both sides, not only the ones that end up being compared.
375
+ for (const table of [...previous, ...current])
376
+ assertColumnNamesAreDistinct(table);
377
+ for (const name of namesOf(was, is)) {
378
+ const previous = was.get(name);
379
+ const current = is.get(name);
380
+ if (previous !== undefined && current !== undefined) {
381
+ changes.push(...diffTable(previous, current));
382
+ }
383
+ else if (current !== undefined) {
384
+ changes.push({
385
+ kind: 'tableAdded',
386
+ table: name,
387
+ after: current,
388
+ destructive: false,
389
+ mayFailOnExistingRows: false,
390
+ });
391
+ }
392
+ else if (previous !== undefined) {
393
+ changes.push({
394
+ kind: 'tableRemoved',
395
+ table: name,
396
+ before: previous,
397
+ destructive: true,
398
+ mayFailOnExistingRows: false,
399
+ });
400
+ }
401
+ }
402
+ // Stable, so the order the changes were produced in — table by table and column by
403
+ // column, both by name — survives inside each rank. Two runs are the same migration
404
+ // byte for byte, and reordering the model registry does not rewrite it.
405
+ return { changes: changes.sort((left, right) => RANK[left.kind] - RANK[right.kind]) };
406
+ };
407
+ /** Whether applying the diff loses data. What SPEC.md §34 asks for a warning about. */
408
+ export const isDestructive = (diff) => diff.changes.some((change) => change.destructive);
409
+ /**
410
+ * Whether applying the diff can be refused by a table that already holds rows.
411
+ *
412
+ * The other half of the warning: these changes lose nothing, they simply do not run
413
+ * until somebody has filled in or cleaned up what is already stored.
414
+ */
415
+ export const mayFailOnExistingRows = (diff) => diff.changes.some((change) => change.mayFailOnExistingRows);
416
+ const quoteValues = (values) => values.map((value) => `"${value}"`).join(', ');
417
+ /** How a person names a column: the table it is in, then the field. */
418
+ const at = (change) => `${change.table}.${change.column}`;
419
+ /**
420
+ * One change as a sentence a person can act on (SPEC.md §34).
421
+ *
422
+ * The switch has no default: a new kind of change has to be given words before it can
423
+ * reach anybody.
424
+ */
425
+ export const describeChange = (change) => {
426
+ switch (change.kind) {
427
+ case 'tableAdded':
428
+ return `creates table ${change.table}`;
429
+ case 'tableRemoved':
430
+ return `drops table ${change.table}`;
431
+ case 'columnAdded':
432
+ return change.mayFailOnExistingRows
433
+ ? // "database" is the load-bearing word: the column may well declare a
434
+ // default, and the person reading this has to learn that the schema does
435
+ // not carry it (ADR-0011) before they can act on the warning.
436
+ `adds required column ${at(change)} with no database default`
437
+ : `adds column ${at(change)}`;
438
+ case 'columnRemoved':
439
+ return `drops column ${at(change)}`;
440
+ case 'columnTypeChanged':
441
+ return `changes ${at(change)} from ${change.before.type} to ${change.after.type}`;
442
+ case 'columnNullabilityChanged':
443
+ return change.after.isNullable
444
+ ? `makes ${at(change)} optional`
445
+ : `makes ${at(change)} required`;
446
+ case 'columnUniquenessChanged':
447
+ return change.after.isUnique
448
+ ? `makes ${at(change)} unique`
449
+ : `drops the unique constraint on ${at(change)}`;
450
+ case 'columnEnumChanged': {
451
+ const parts = [
452
+ ...(change.added.length > 0 ? [`adds ${quoteValues(change.added)}`] : []),
453
+ ...(change.removed.length > 0 ? [`removes ${quoteValues(change.removed)}`] : []),
454
+ ];
455
+ return `${parts.join(' and ')} on ${at(change)}`;
456
+ }
457
+ case 'primaryKeyMoved':
458
+ return `moves the primary key of ${change.table} from ${change.before} to ${change.after}`;
459
+ case 'indexAdded':
460
+ return `indexes ${at(change)}`;
461
+ case 'indexRemoved':
462
+ return `drops the index on ${at(change)}`;
463
+ case 'foreignKeyAdded':
464
+ return `adds a foreign key from ${at(change)} to ${change.after.target}.${change.after.ownerKey}`;
465
+ case 'foreignKeyRemoved':
466
+ return `drops the foreign key from ${at(change)} to ${change.before.target}.${change.before.ownerKey}`;
467
+ case 'uniqueTogetherAdded':
468
+ return `makes ${change.columns.join(' and ')} unique together on ${change.table}`;
469
+ case 'uniqueTogetherRemoved':
470
+ return `drops the unique constraint on ${change.columns.join(' and ')} of ${change.table}`;
471
+ }
472
+ };
473
+ //# sourceMappingURL=schema-diff.js.map