@bakery-framework/orm 2.0.0-alpha.3 → 2.0.0-alpha.5
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/package.json +2 -2
- package/src/sync/diff.ts +339 -0
- package/src/sync/engine.ts +13 -6
- package/src/sync/execute.ts +522 -0
- package/src/sync/plan.ts +148 -0
- package/src/sync/rename.ts +188 -0
- package/src/sync/types.ts +46 -0
- package/src/sync/helpers.ts +0 -1145
package/src/sync/helpers.ts
DELETED
|
@@ -1,1145 +0,0 @@
|
|
|
1
|
-
import { Case } from '@bakery-framework/core/utils'
|
|
2
|
-
import { SQLAdapter } from '../adapters/base'
|
|
3
|
-
import { resolveCurrentState } from './ledger'
|
|
4
|
-
import type * as SyncTypes from './types'
|
|
5
|
-
import { normalizeViewBody } from './view-sql'
|
|
6
|
-
|
|
7
|
-
export namespace SyncPlan {
|
|
8
|
-
export interface TableRename {
|
|
9
|
-
oldName: string
|
|
10
|
-
newName: string
|
|
11
|
-
}
|
|
12
|
-
export interface ColumnDrop {
|
|
13
|
-
table: string
|
|
14
|
-
column: string
|
|
15
|
-
}
|
|
16
|
-
export interface ColumnAdd {
|
|
17
|
-
table: string
|
|
18
|
-
column: string
|
|
19
|
-
def: SyncTypes.ColumnConstraint
|
|
20
|
-
}
|
|
21
|
-
export interface ColumnRename {
|
|
22
|
-
table: string
|
|
23
|
-
oldColumn: string
|
|
24
|
-
newColumn: string
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export interface SyncPlan {
|
|
29
|
-
tablesToDrop: string[]
|
|
30
|
-
tablesToRename: SyncPlan.TableRename[]
|
|
31
|
-
columnsToDrop: SyncPlan.ColumnDrop[]
|
|
32
|
-
columnsToAdd: SyncPlan.ColumnAdd[]
|
|
33
|
-
columnsToRename: SyncPlan.ColumnRename[]
|
|
34
|
-
tablesToRebuild: Set<string>
|
|
35
|
-
/** Which source `dbConstraintsForDiff` came from, so the run can say so. */
|
|
36
|
-
ledgerSource?: 'ledger' | 'introspection'
|
|
37
|
-
ledgerReason?: string
|
|
38
|
-
viewsToUpdate: string[]
|
|
39
|
-
unmappedTsTables: Set<string>
|
|
40
|
-
dbConstraintsForDiff: SyncTypes.DBConstraints
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function getStringSimilarity(str1: string, str2: string) {
|
|
44
|
-
const getBigrams = (str: string) =>
|
|
45
|
-
new Set(
|
|
46
|
-
Array.from({ length: str.length - 1 }, (_, i) => str.slice(i, i + 2)),
|
|
47
|
-
)
|
|
48
|
-
const bg1 = getBigrams(str1.toLowerCase())
|
|
49
|
-
const bg2 = getBigrams(str2.toLowerCase())
|
|
50
|
-
const intersection = bg1.intersection(bg2).size
|
|
51
|
-
const union = bg1.union(bg2).size
|
|
52
|
-
return union === 0 ? (str1 === str2 ? 1 : 0) : intersection / union
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function findBestMatchAndPrompt(
|
|
56
|
-
oldName: string,
|
|
57
|
-
unmappedSet: Set<string>,
|
|
58
|
-
itemType: 'table' | 'column',
|
|
59
|
-
contextName: string,
|
|
60
|
-
logger: any,
|
|
61
|
-
threshold = 0.3,
|
|
62
|
-
): string | null {
|
|
63
|
-
let bestAutoMatch: string | null = null
|
|
64
|
-
let bestScore = 0
|
|
65
|
-
|
|
66
|
-
for (const newCamel of unmappedSet) {
|
|
67
|
-
const score = getStringSimilarity(oldName, Case.snake(newCamel))
|
|
68
|
-
if (score > bestScore && score >= threshold) {
|
|
69
|
-
bestScore = score
|
|
70
|
-
bestAutoMatch = newCamel
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const unmappedArr = Array.from(unmappedSet)
|
|
75
|
-
const options = [
|
|
76
|
-
bestAutoMatch
|
|
77
|
-
? `Pick automatically (${Case.snake(bestAutoMatch)}: ${Math.round(bestScore * 100)}%)`
|
|
78
|
-
: 'Pick automatically (none)',
|
|
79
|
-
...unmappedArr.map(t => `Use ${itemType}: ${Case.snake(t)}`),
|
|
80
|
-
`Drop ${itemType}`,
|
|
81
|
-
]
|
|
82
|
-
|
|
83
|
-
const promptMsg =
|
|
84
|
-
itemType === 'table'
|
|
85
|
-
? `Unmapped database table: '${contextName}'. What should we do?`
|
|
86
|
-
: `Unmapped column '${oldName}' in table '${contextName}'. What should we do?`
|
|
87
|
-
const sel = logger.selectIndex(promptMsg, options)
|
|
88
|
-
|
|
89
|
-
if (sel === 0) return bestAutoMatch
|
|
90
|
-
if (sel === options.length - 1) return null
|
|
91
|
-
return unmappedArr[sel - 1]
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function initDbTablesMap(dbConstraints: SyncTypes.DBConstraints) {
|
|
95
|
-
const dbTables: Record<
|
|
96
|
-
string,
|
|
97
|
-
{ dbName: string; camelName: string; cols: Set<string> }
|
|
98
|
-
> = {}
|
|
99
|
-
for (const [rawTable, tableObj] of Object.entries(dbConstraints)) {
|
|
100
|
-
if (tableObj._view) continue
|
|
101
|
-
const camelTable = Case.camel(rawTable)
|
|
102
|
-
dbTables[camelTable] = {
|
|
103
|
-
dbName: Case.snake(rawTable),
|
|
104
|
-
camelName: camelTable,
|
|
105
|
-
cols: new Set(Object.keys(tableObj).filter(k => k !== '_view')),
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
return dbTables
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function tableHasTransform(tsTableObj: any): boolean {
|
|
112
|
-
return (
|
|
113
|
-
!!tsTableObj._transform ||
|
|
114
|
-
Object.values(tsTableObj).some(
|
|
115
|
-
col => col && (col as SyncTypes.ColumnConstraint)._transform,
|
|
116
|
-
)
|
|
117
|
-
)
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function resolveOldTableMapping(
|
|
121
|
-
plan: SyncPlan,
|
|
122
|
-
dbTables: any,
|
|
123
|
-
unmappedDbTables: Set<string>,
|
|
124
|
-
newCamel: string,
|
|
125
|
-
tsTableObj: any,
|
|
126
|
-
hasTransform: boolean,
|
|
127
|
-
) {
|
|
128
|
-
if (!tsTableObj._oldTable || !plan.unmappedTsTables.has(newCamel)) return
|
|
129
|
-
const oldCamel = Case.camel(tsTableObj._oldTable)
|
|
130
|
-
if (!dbTables[oldCamel]) return
|
|
131
|
-
if (!hasTransform) {
|
|
132
|
-
plan.tablesToRename.push({
|
|
133
|
-
oldName: dbTables[oldCamel].dbName,
|
|
134
|
-
newName: Case.snake(newCamel),
|
|
135
|
-
})
|
|
136
|
-
}
|
|
137
|
-
unmappedDbTables.delete(oldCamel)
|
|
138
|
-
plan.unmappedTsTables.delete(newCamel)
|
|
139
|
-
dbTables[newCamel] = { ...dbTables[oldCamel], camelName: newCamel }
|
|
140
|
-
delete dbTables[oldCamel]
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function handleTableRenames(
|
|
144
|
-
plan: SyncPlan,
|
|
145
|
-
constraints: SyncTypes.DBConstraints,
|
|
146
|
-
dbTables: any,
|
|
147
|
-
) {
|
|
148
|
-
const normalizedConstraints: SyncTypes.DBConstraints = {}
|
|
149
|
-
for (const [k, v] of Object.entries(constraints)) {
|
|
150
|
-
normalizedConstraints[Case.camel(k)] = v
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const unmappedDbTables = new Set(
|
|
154
|
-
Object.keys(dbTables).filter(camel => !normalizedConstraints[camel]),
|
|
155
|
-
)
|
|
156
|
-
// Views are excluded, not merely absent. `initDbTablesMap` skips `_view`
|
|
157
|
-
// entries, so a view is never in `dbTables` and would look like a table that
|
|
158
|
-
// still needs creating — on every run, forever. `evaluateChanges` counts
|
|
159
|
-
// `unmappedTsTables`, so a schema with a view could never report a perfectly
|
|
160
|
-
// synced database. The view phase creates them; this set is about tables.
|
|
161
|
-
plan.unmappedTsTables = new Set(
|
|
162
|
-
Object.keys(normalizedConstraints).filter(
|
|
163
|
-
camel =>
|
|
164
|
-
!dbTables[camel] && !(normalizedConstraints[camel] as any)?._view,
|
|
165
|
-
),
|
|
166
|
-
)
|
|
167
|
-
|
|
168
|
-
for (const [newRaw, tsTableObj] of Object.entries(constraints)) {
|
|
169
|
-
if (!tsTableObj) continue
|
|
170
|
-
const newCamel = Case.camel(newRaw)
|
|
171
|
-
const hasTransform = tableHasTransform(tsTableObj)
|
|
172
|
-
if (hasTransform) plan.tablesToRebuild.add(Case.snake(newCamel))
|
|
173
|
-
resolveOldTableMapping(
|
|
174
|
-
plan,
|
|
175
|
-
dbTables,
|
|
176
|
-
unmappedDbTables,
|
|
177
|
-
newCamel,
|
|
178
|
-
tsTableObj,
|
|
179
|
-
hasTransform,
|
|
180
|
-
)
|
|
181
|
-
}
|
|
182
|
-
return unmappedDbTables
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function promptAndRenameTables(
|
|
186
|
-
plan: SyncPlan,
|
|
187
|
-
dbTables: any,
|
|
188
|
-
unmappedDbTables: Set<string>,
|
|
189
|
-
logger: any,
|
|
190
|
-
) {
|
|
191
|
-
for (const oldCamel of [...unmappedDbTables]) {
|
|
192
|
-
const dbName = dbTables[oldCamel]!.dbName
|
|
193
|
-
const bestMatch = findBestMatchAndPrompt(
|
|
194
|
-
dbName,
|
|
195
|
-
plan.unmappedTsTables,
|
|
196
|
-
'table',
|
|
197
|
-
dbName,
|
|
198
|
-
logger,
|
|
199
|
-
0.5,
|
|
200
|
-
)
|
|
201
|
-
|
|
202
|
-
if (bestMatch) {
|
|
203
|
-
plan.tablesToRename.push({
|
|
204
|
-
oldName: dbName,
|
|
205
|
-
newName: Case.snake(bestMatch),
|
|
206
|
-
})
|
|
207
|
-
unmappedDbTables.delete(oldCamel)
|
|
208
|
-
plan.unmappedTsTables.delete(bestMatch)
|
|
209
|
-
dbTables[bestMatch] = {
|
|
210
|
-
...dbTables[oldCamel]!,
|
|
211
|
-
camelName: bestMatch,
|
|
212
|
-
}
|
|
213
|
-
delete dbTables[oldCamel]
|
|
214
|
-
} else {
|
|
215
|
-
plan.tablesToDrop.push(dbName)
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
function diffColumnMismatch(
|
|
221
|
-
plan: SyncPlan,
|
|
222
|
-
dbName: string,
|
|
223
|
-
camelCol: string,
|
|
224
|
-
tsCol: any,
|
|
225
|
-
dbCol: any,
|
|
226
|
-
MESSAGES: any,
|
|
227
|
-
) {
|
|
228
|
-
const tsNullable = tsCol.primary ? false : tsCol.nullable === true
|
|
229
|
-
const dbNullable = dbCol.primary ? false : dbCol.nullable === true
|
|
230
|
-
const tsDefault = tsCol.default === undefined ? null : tsCol.default
|
|
231
|
-
const dbDefault = dbCol.default === undefined ? null : dbCol.default
|
|
232
|
-
const isTypeMatch =
|
|
233
|
-
tsCol.type === dbCol.type ||
|
|
234
|
-
(tsCol.type === 'boolean' && dbCol.type === 'integer')
|
|
235
|
-
const norm = (v: any) =>
|
|
236
|
-
v === null
|
|
237
|
-
? 'null'
|
|
238
|
-
: String(v)
|
|
239
|
-
.replace(/^\(+|\)+$/g, '')
|
|
240
|
-
.trim()
|
|
241
|
-
|
|
242
|
-
// Width joins the diff, so widening a Varchar migrates instead of silently
|
|
243
|
-
// doing nothing. It stayed out until all three adapters could be *measured*
|
|
244
|
-
// reporting it back exactly; see `SQLAdapter.sizedTextLength` for the MySQL
|
|
245
|
-
// TEXT trap that made this dangerous to add blind.
|
|
246
|
-
//
|
|
247
|
-
// Driven by the *schema* side only. When the schema declares a width, any
|
|
248
|
-
// other answer from the database differs — including no width at all, which
|
|
249
|
-
// is a real `TEXT` column that should become `VARCHAR(n)`. Requiring both
|
|
250
|
-
// sides to be sized meant sizing an existing TEXT column silently did
|
|
251
|
-
// nothing, and `db:sync` then reported a perfectly synced database whose
|
|
252
|
-
// columns did not match the schema it had just read.
|
|
253
|
-
//
|
|
254
|
-
// Converges because all three dialects report a `VARCHAR` width back exactly
|
|
255
|
-
// (measured, see `SQLAdapter.sizedTextLength`): after one rebuild the two
|
|
256
|
-
// agree. A column that reports *no* width really is unsized.
|
|
257
|
-
//
|
|
258
|
-
// When the schema declares no width, nothing differs — `Field.Text()` against
|
|
259
|
-
// an existing `VARCHAR` is not a request to shrink it.
|
|
260
|
-
const lengthDiffers =
|
|
261
|
-
typeof tsCol.length === 'number' && tsCol.length !== dbCol.length
|
|
262
|
-
|
|
263
|
-
// Enum members join the diff, so changing them migrates instead of silently
|
|
264
|
-
// doing nothing — but **only when the current state came from the ledger**.
|
|
265
|
-
//
|
|
266
|
-
// `_enum` is emitted as an inline `CHECK (col IN (...))` by all three
|
|
267
|
-
// dialects, and all three *will* report that constraint back — in three
|
|
268
|
-
// incompatible shapes. Measured:
|
|
269
|
-
//
|
|
270
|
-
// sqlite CHECK (status IN ('draft','live')) in the table DDL
|
|
271
|
-
// mysql (`status` in (_utf8mb4'draft',_utf8mb4'live')) charset prefixes
|
|
272
|
-
// pgsql CHECK (((status)::text = ANY ((ARRAY[...]))) re-rendered
|
|
273
|
-
//
|
|
274
|
-
// Postgres does not store the text it was given, it re-renders a parsed
|
|
275
|
-
// expression — the same trap that turned `EXTRACT` into `date_part` and
|
|
276
|
-
// rebuilt a table on every sync forever. Three parsers, each an opportunity
|
|
277
|
-
// for that bug, is the wrong trade when the ledger already holds the members
|
|
278
|
-
// exactly as declared.
|
|
279
|
-
//
|
|
280
|
-
// So under introspection this stays out of the diff. A schema-side-only
|
|
281
|
-
// comparison would find `_enum` on one side and nothing on the other, differ
|
|
282
|
-
// every time, and rebuild the table on every sync — which is precisely what
|
|
283
|
-
// the `length` note above says it waited to rule out before shipping.
|
|
284
|
-
const enumDiffers =
|
|
285
|
-
plan.ledgerSource === 'ledger' &&
|
|
286
|
-
!Bun.deepEquals(tsCol._enum ?? null, dbCol._enum ?? null)
|
|
287
|
-
|
|
288
|
-
if (
|
|
289
|
-
!isTypeMatch ||
|
|
290
|
-
tsNullable !== dbNullable ||
|
|
291
|
-
lengthDiffers ||
|
|
292
|
-
enumDiffers ||
|
|
293
|
-
norm(tsDefault) !== norm(dbDefault)
|
|
294
|
-
) {
|
|
295
|
-
MESSAGES.COL_MISMATCH({ table: dbName, column: camelCol })
|
|
296
|
-
MESSAGES.COL_MISMATCH_TS({
|
|
297
|
-
tsType: tsCol.type,
|
|
298
|
-
tsNullable: String(tsNullable),
|
|
299
|
-
tsDefault: String(tsDefault),
|
|
300
|
-
})
|
|
301
|
-
MESSAGES.COL_MISMATCH_DB({
|
|
302
|
-
dbType: dbCol.type,
|
|
303
|
-
dbNullable: String(dbNullable),
|
|
304
|
-
dbDefault: String(dbDefault),
|
|
305
|
-
})
|
|
306
|
-
plan.tablesToRebuild.add(dbName)
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
function resolveColumnRenames(
|
|
311
|
-
plan: SyncPlan,
|
|
312
|
-
camelTable: string,
|
|
313
|
-
dbName: string,
|
|
314
|
-
constraints: any,
|
|
315
|
-
unmappedDbCols: Set<string>,
|
|
316
|
-
unmappedTsCols: Set<string>,
|
|
317
|
-
existingDbCamelCols: Set<string>,
|
|
318
|
-
) {
|
|
319
|
-
for (const newCamel of [...unmappedTsCols]) {
|
|
320
|
-
const tsColObj = constraints[camelTable][newCamel]
|
|
321
|
-
if (!tsColObj?._oldColumn) continue
|
|
322
|
-
const oldCamel = Case.camel(tsColObj._oldColumn)
|
|
323
|
-
if (!existingDbCamelCols.has(oldCamel)) continue
|
|
324
|
-
plan.columnsToRename.push({
|
|
325
|
-
table: dbName,
|
|
326
|
-
oldColumn: Case.snake(tsColObj._oldColumn),
|
|
327
|
-
newColumn: Case.snake(newCamel),
|
|
328
|
-
})
|
|
329
|
-
unmappedDbCols.delete(Case.snake(tsColObj._oldColumn))
|
|
330
|
-
unmappedTsCols.delete(newCamel)
|
|
331
|
-
if (plan.dbConstraintsForDiff[camelTable]?.[oldCamel]) {
|
|
332
|
-
plan.dbConstraintsForDiff[camelTable][newCamel] =
|
|
333
|
-
plan.dbConstraintsForDiff[camelTable][oldCamel]
|
|
334
|
-
delete plan.dbConstraintsForDiff[camelTable][oldCamel]
|
|
335
|
-
}
|
|
336
|
-
existingDbCamelCols.delete(oldCamel)
|
|
337
|
-
existingDbCamelCols.add(newCamel)
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
function resolveUnmappedDbCols(
|
|
342
|
-
plan: SyncPlan,
|
|
343
|
-
camelTable: string,
|
|
344
|
-
dbName: string,
|
|
345
|
-
logger: any,
|
|
346
|
-
unmappedDbCols: Set<string>,
|
|
347
|
-
unmappedTsCols: Set<string>,
|
|
348
|
-
existingDbCamelCols: Set<string>,
|
|
349
|
-
) {
|
|
350
|
-
for (const oldDbCol of [...unmappedDbCols]) {
|
|
351
|
-
const bestMatch = unmappedTsCols.size
|
|
352
|
-
? findBestMatchAndPrompt(
|
|
353
|
-
oldDbCol,
|
|
354
|
-
unmappedTsCols,
|
|
355
|
-
'column',
|
|
356
|
-
dbName,
|
|
357
|
-
logger,
|
|
358
|
-
)
|
|
359
|
-
: null
|
|
360
|
-
if (bestMatch) {
|
|
361
|
-
plan.columnsToRename.push({
|
|
362
|
-
table: dbName,
|
|
363
|
-
oldColumn: oldDbCol,
|
|
364
|
-
newColumn: Case.snake(bestMatch),
|
|
365
|
-
})
|
|
366
|
-
unmappedDbCols.delete(oldDbCol)
|
|
367
|
-
unmappedTsCols.delete(bestMatch)
|
|
368
|
-
const oldCamel = Case.camel(oldDbCol)
|
|
369
|
-
if (plan.dbConstraintsForDiff[camelTable]?.[oldCamel]) {
|
|
370
|
-
plan.dbConstraintsForDiff[camelTable][bestMatch] =
|
|
371
|
-
plan.dbConstraintsForDiff[camelTable][oldCamel]
|
|
372
|
-
delete plan.dbConstraintsForDiff[camelTable][oldCamel]
|
|
373
|
-
}
|
|
374
|
-
existingDbCamelCols.delete(oldCamel)
|
|
375
|
-
existingDbCamelCols.add(bestMatch)
|
|
376
|
-
} else {
|
|
377
|
-
plan.columnsToDrop.push({ table: dbName, column: oldDbCol })
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
function diffTableColumns(
|
|
383
|
-
plan: SyncPlan,
|
|
384
|
-
camelTable: string,
|
|
385
|
-
dbName: string,
|
|
386
|
-
constraints: any,
|
|
387
|
-
logger: any,
|
|
388
|
-
MESSAGES: any,
|
|
389
|
-
) {
|
|
390
|
-
const existingDbCamelCols = new Set(
|
|
391
|
-
Object.keys(plan.dbConstraintsForDiff[camelTable] || {}).filter(
|
|
392
|
-
k => k !== '_view',
|
|
393
|
-
),
|
|
394
|
-
)
|
|
395
|
-
const unmappedDbCols = new Set(
|
|
396
|
-
[...existingDbCamelCols]
|
|
397
|
-
.filter(c => !constraints[camelTable][c])
|
|
398
|
-
.map(Case.snake),
|
|
399
|
-
)
|
|
400
|
-
const unmappedTsCols = new Set(
|
|
401
|
-
Object.keys(constraints[camelTable]).filter(
|
|
402
|
-
c =>
|
|
403
|
-
!existingDbCamelCols.has(c) &&
|
|
404
|
-
!['_view', '_oldTable', '_transform'].includes(c),
|
|
405
|
-
),
|
|
406
|
-
)
|
|
407
|
-
|
|
408
|
-
resolveColumnRenames(
|
|
409
|
-
plan,
|
|
410
|
-
camelTable,
|
|
411
|
-
dbName,
|
|
412
|
-
constraints,
|
|
413
|
-
unmappedDbCols,
|
|
414
|
-
unmappedTsCols,
|
|
415
|
-
existingDbCamelCols,
|
|
416
|
-
)
|
|
417
|
-
resolveUnmappedDbCols(
|
|
418
|
-
plan,
|
|
419
|
-
camelTable,
|
|
420
|
-
dbName,
|
|
421
|
-
logger,
|
|
422
|
-
unmappedDbCols,
|
|
423
|
-
unmappedTsCols,
|
|
424
|
-
existingDbCamelCols,
|
|
425
|
-
)
|
|
426
|
-
|
|
427
|
-
plan.columnsToAdd.push(
|
|
428
|
-
...[...unmappedTsCols].map(newCamel => ({
|
|
429
|
-
table: dbName,
|
|
430
|
-
column: Case.snake(newCamel),
|
|
431
|
-
def: constraints[camelTable][newCamel],
|
|
432
|
-
})),
|
|
433
|
-
)
|
|
434
|
-
|
|
435
|
-
for (const camelCol of existingDbCamelCols) {
|
|
436
|
-
if (unmappedDbCols.has(Case.snake(camelCol))) continue
|
|
437
|
-
const tsCol = constraints[camelTable][camelCol]
|
|
438
|
-
const dbCol = plan.dbConstraintsForDiff[camelTable]?.[camelCol]
|
|
439
|
-
if (tsCol && dbCol) {
|
|
440
|
-
diffColumnMismatch(plan, dbName, camelCol, tsCol, dbCol, MESSAGES)
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
function diffViewStrings(
|
|
446
|
-
plan: SyncPlan,
|
|
447
|
-
camelTable: string,
|
|
448
|
-
dbName: string,
|
|
449
|
-
constraints: any,
|
|
450
|
-
database?: string,
|
|
451
|
-
): boolean {
|
|
452
|
-
// Both sides through the same canonicaliser, which is the only thing that
|
|
453
|
-
// makes a text comparison viable: you write `SELECT id FROM users` and MySQL
|
|
454
|
-
// returns it fully qualified, fully quoted and aliased column by column.
|
|
455
|
-
//
|
|
456
|
-
// Symmetry is the whole requirement. Normalising the *generated file* while
|
|
457
|
-
// comparing raw — or stripping the schema on one side only — recreates the
|
|
458
|
-
// view on every sync, which is the same churn the column diff has hit twice.
|
|
459
|
-
const tsViewStr = normalizeViewBody(
|
|
460
|
-
String(constraints[camelTable]._view || ''),
|
|
461
|
-
database,
|
|
462
|
-
)
|
|
463
|
-
const dbViewStr = normalizeViewBody(
|
|
464
|
-
String(plan.dbConstraintsForDiff[camelTable]?._view || ''),
|
|
465
|
-
database,
|
|
466
|
-
)
|
|
467
|
-
if (tsViewStr || dbViewStr) {
|
|
468
|
-
if (tsViewStr !== dbViewStr) plan.viewsToUpdate.push(dbName)
|
|
469
|
-
if (tsViewStr && !dbViewStr) plan.tablesToDrop.push(dbName)
|
|
470
|
-
return true
|
|
471
|
-
}
|
|
472
|
-
return false
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
/**
|
|
476
|
-
* The view lifecycle: create, recreate, drop.
|
|
477
|
-
*
|
|
478
|
-
* Views were invisible to the planner. `initDbTablesMap` skips `_view` entries,
|
|
479
|
-
* and every existing comparison iterates that map — so a declared view never
|
|
480
|
-
* reached `diffViewStrings`, and nothing about a view ever reached
|
|
481
|
-
* `hasChanges`. The consequences, all three measured:
|
|
482
|
-
*
|
|
483
|
-
* - a **new** view was never planned,
|
|
484
|
-
* - an **edited** `SELECT` was never detected, so a view could not be changed,
|
|
485
|
-
* - a view the schema no longer declares was never dropped.
|
|
486
|
-
*
|
|
487
|
-
* They were invisible rather than broken: `syncViewsAndTablesPhase` recreates
|
|
488
|
-
* every declared view whenever a sync happens to run, so a view kept up to date
|
|
489
|
-
* as a side effect of unrelated work. With nothing else to do, `db:sync`
|
|
490
|
-
* reported a perfectly synced database and left the view alone.
|
|
491
|
-
*
|
|
492
|
-
* Bodies are compared through `normalizeViewBody` on both sides. That converges
|
|
493
|
-
* on SQLite, which stores the text verbatim, and via the ledger everywhere —
|
|
494
|
-
* the ledger records what was *applied*, so it holds the authored SELECT.
|
|
495
|
-
* Diffing against live introspection on MySQL or Postgres will still see a
|
|
496
|
-
* difference, because both re-render the body (MySQL re-qualifies every column,
|
|
497
|
-
* Postgres adds parentheses), and no amount of text normalisation short of a
|
|
498
|
-
* parser fixes that. It costs a recreate, and a view holds no data.
|
|
499
|
-
*/
|
|
500
|
-
function diffViews(
|
|
501
|
-
plan: SyncPlan,
|
|
502
|
-
constraints: SyncTypes.DBConstraints,
|
|
503
|
-
database?: string,
|
|
504
|
-
) {
|
|
505
|
-
const dbSide = plan.dbConstraintsForDiff
|
|
506
|
-
const declared = new Set<string>()
|
|
507
|
-
|
|
508
|
-
for (const [name, cols] of Object.entries(constraints)) {
|
|
509
|
-
const body = (cols as SyncTypes.TableConstraints)?._view
|
|
510
|
-
if (!body) continue
|
|
511
|
-
const camel = Case.camel(name)
|
|
512
|
-
declared.add(camel)
|
|
513
|
-
|
|
514
|
-
const dbBody = (dbSide[camel] as SyncTypes.TableConstraints | undefined)
|
|
515
|
-
?._view
|
|
516
|
-
const want = normalizeViewBody(String(body), database)
|
|
517
|
-
const have = dbBody ? normalizeViewBody(String(dbBody), database) : null
|
|
518
|
-
if (have !== want) plan.viewsToUpdate.push(Case.snake(name))
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
for (const [name, cols] of Object.entries(dbSide)) {
|
|
522
|
-
if (!(cols as SyncTypes.TableConstraints)?._view) continue
|
|
523
|
-
if (declared.has(Case.camel(name))) continue
|
|
524
|
-
// Same rule tables follow: what the schema does not declare, the database
|
|
525
|
-
// does not keep. Dropping is announced before it happens.
|
|
526
|
-
plan.tablesToDrop.push(Case.snake(name))
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
function diffTableViewsAndColumns(
|
|
531
|
-
plan: SyncPlan,
|
|
532
|
-
dbTables: any,
|
|
533
|
-
constraints: any,
|
|
534
|
-
logger: any,
|
|
535
|
-
MESSAGES: any,
|
|
536
|
-
database?: string,
|
|
537
|
-
) {
|
|
538
|
-
for (const camelTable of Object.keys(dbTables)) {
|
|
539
|
-
if (!constraints[camelTable]) continue
|
|
540
|
-
const dbName = dbTables[camelTable]!.dbName
|
|
541
|
-
if (
|
|
542
|
-
plan.tablesToRebuild.has(dbName) ||
|
|
543
|
-
plan.tablesToRebuild.has(Case.snake(camelTable))
|
|
544
|
-
)
|
|
545
|
-
continue
|
|
546
|
-
if (diffViewStrings(plan, camelTable, dbName, constraints, database))
|
|
547
|
-
continue
|
|
548
|
-
diffTableColumns(plan, camelTable, dbName, constraints, logger, MESSAGES)
|
|
549
|
-
}
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
export async function buildSyncPlan(
|
|
553
|
-
adapter: SQLAdapter,
|
|
554
|
-
constraints: SyncTypes.DBConstraints,
|
|
555
|
-
logger: any,
|
|
556
|
-
MESSAGES: any,
|
|
557
|
-
): Promise<SyncPlan> {
|
|
558
|
-
const plan: SyncPlan = {
|
|
559
|
-
tablesToDrop: [],
|
|
560
|
-
tablesToRename: [],
|
|
561
|
-
columnsToDrop: [],
|
|
562
|
-
columnsToAdd: [],
|
|
563
|
-
columnsToRename: [],
|
|
564
|
-
tablesToRebuild: new Set(),
|
|
565
|
-
viewsToUpdate: [],
|
|
566
|
-
unmappedTsTables: new Set(),
|
|
567
|
-
dbConstraintsForDiff: {},
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
// The one place sync decides what "currently" means. Prefers the ledger —
|
|
571
|
-
// what Bakery last applied — and falls back to introspection whenever the
|
|
572
|
-
// ledger no longer describes the same tables and columns. See sync/ledger.ts
|
|
573
|
-
// for why that fallback is the whole safety argument.
|
|
574
|
-
const current = await resolveCurrentState(adapter, {
|
|
575
|
-
ignoreLedger: process.argv.includes('--no-ledger'),
|
|
576
|
-
})
|
|
577
|
-
plan.ledgerSource = current.source
|
|
578
|
-
plan.ledgerReason = current.reason
|
|
579
|
-
plan.dbConstraintsForDiff = current.constraints
|
|
580
|
-
const dbTables = initDbTablesMap(plan.dbConstraintsForDiff)
|
|
581
|
-
const unmappedDbTables = handleTableRenames(plan, constraints, dbTables)
|
|
582
|
-
|
|
583
|
-
promptAndRenameTables(plan, dbTables, unmappedDbTables, logger)
|
|
584
|
-
diffTableViewsAndColumns(
|
|
585
|
-
plan,
|
|
586
|
-
dbTables,
|
|
587
|
-
constraints,
|
|
588
|
-
logger,
|
|
589
|
-
MESSAGES,
|
|
590
|
-
adapter.databaseName,
|
|
591
|
-
)
|
|
592
|
-
// Separately, because every comparison above walks `dbTables`, which by
|
|
593
|
-
// construction contains no views.
|
|
594
|
-
diffViews(plan, constraints, adapter.databaseName)
|
|
595
|
-
|
|
596
|
-
plan.tablesToRename = plan.tablesToRename.filter(
|
|
597
|
-
t =>
|
|
598
|
-
!plan.tablesToRebuild.has(t.newName) &&
|
|
599
|
-
!plan.tablesToRebuild.has(t.oldName),
|
|
600
|
-
)
|
|
601
|
-
return plan
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
export function calculateIndexDiff(
|
|
605
|
-
dbIndexes: SyncTypes.DBIndexes,
|
|
606
|
-
tsIndexes: SyncTypes.DBIndexes,
|
|
607
|
-
tablesToRebuild: Set<string>,
|
|
608
|
-
) {
|
|
609
|
-
const indexesToDrop = new Set<string>()
|
|
610
|
-
const indexesToAdd = new Map<string, SyncTypes.IndexConstraint>()
|
|
611
|
-
|
|
612
|
-
// Foreign keys ride in the same declaration map as index()/unique() but are
|
|
613
|
-
// a different thing: they are emitted with the table or by ALTER, never by
|
|
614
|
-
// CREATE INDEX. `calculateForeignKeyDiff` owns them.
|
|
615
|
-
const isFk = (i: any) => i?.type === 'foreign'
|
|
616
|
-
|
|
617
|
-
for (const [dbIdxName, dbIdx] of Object.entries(dbIndexes)) {
|
|
618
|
-
if (isFk(dbIdx)) continue
|
|
619
|
-
const tsIdx = tsIndexes[dbIdxName]
|
|
620
|
-
const isRebuilt = tablesToRebuild.has(Case.snake(dbIdx.table))
|
|
621
|
-
|
|
622
|
-
if (!tsIdx) {
|
|
623
|
-
indexesToDrop.add(Case.snake(dbIdxName))
|
|
624
|
-
} else if (
|
|
625
|
-
isRebuilt ||
|
|
626
|
-
tsIdx.type !== dbIdx.type ||
|
|
627
|
-
tsIdx.table !== dbIdx.table ||
|
|
628
|
-
tsIdx.cols.join(',') !== dbIdx.cols.join(',')
|
|
629
|
-
) {
|
|
630
|
-
if (!isRebuilt) indexesToDrop.add(Case.snake(dbIdxName))
|
|
631
|
-
indexesToAdd.set(Case.snake(dbIdxName), tsIdx)
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
for (const [tsIdxName, tsIdx] of Object.entries(tsIndexes)) {
|
|
636
|
-
if (isFk(tsIdx)) continue
|
|
637
|
-
if (!dbIndexes[tsIdxName]) indexesToAdd.set(Case.snake(tsIdxName), tsIdx)
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
return { indexesToDrop, indexesToAdd }
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
export function logPlannedChanges(
|
|
644
|
-
plan: SyncPlan,
|
|
645
|
-
indexesToDrop: Set<string>,
|
|
646
|
-
indexesToAdd: Map<string, SyncTypes.IndexConstraint>,
|
|
647
|
-
isDangerous: boolean,
|
|
648
|
-
MESSAGES: any,
|
|
649
|
-
) {
|
|
650
|
-
if (isDangerous) MESSAGES.DANGER_ZONE()
|
|
651
|
-
if (plan.tablesToDrop.length)
|
|
652
|
-
MESSAGES.DROP_TABLES({ tables: plan.tablesToDrop.join(', ') })
|
|
653
|
-
if (plan.tablesToRename.length)
|
|
654
|
-
MESSAGES.RENAME_TABLES({
|
|
655
|
-
tables: plan.tablesToRename
|
|
656
|
-
.map(t => `${t.oldName} -> ${t.newName}`)
|
|
657
|
-
.join(', '),
|
|
658
|
-
})
|
|
659
|
-
if (plan.columnsToDrop.length)
|
|
660
|
-
MESSAGES.DROP_COLS({
|
|
661
|
-
cols: plan.columnsToDrop.map(c => `${c.table}.${c.column}`).join(', '),
|
|
662
|
-
})
|
|
663
|
-
if (plan.columnsToRename.length)
|
|
664
|
-
MESSAGES.RENAME_COLS({
|
|
665
|
-
cols: plan.columnsToRename
|
|
666
|
-
.map(c => `${c.table}.${c.oldColumn} -> ${c.newColumn}`)
|
|
667
|
-
.join(', '),
|
|
668
|
-
})
|
|
669
|
-
if (plan.columnsToAdd.length)
|
|
670
|
-
MESSAGES.ADD_COLS({
|
|
671
|
-
cols: plan.columnsToAdd.map(c => `${c.table}.${c.column}`).join(', '),
|
|
672
|
-
})
|
|
673
|
-
if (plan.tablesToRebuild.size)
|
|
674
|
-
MESSAGES.REBUILD_TABLES({
|
|
675
|
-
tables: Array.from(plan.tablesToRebuild).join(', '),
|
|
676
|
-
})
|
|
677
|
-
if (plan.viewsToUpdate.length)
|
|
678
|
-
MESSAGES.UPDATE_VIEWS({ views: plan.viewsToUpdate.join(', ') })
|
|
679
|
-
if (indexesToDrop.size)
|
|
680
|
-
MESSAGES.DROP_INDEXES({ indexes: Array.from(indexesToDrop).join(', ') })
|
|
681
|
-
if (indexesToAdd.size > 0)
|
|
682
|
-
MESSAGES.ADD_INDEXES({
|
|
683
|
-
indexes: Array.from(indexesToAdd.keys()).join(', '),
|
|
684
|
-
})
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
async function processTableRebuild(
|
|
688
|
-
tx: SQLAdapter,
|
|
689
|
-
table: string,
|
|
690
|
-
constraints: SyncTypes.DBConstraints,
|
|
691
|
-
tsFks: SyncTypes.DBForeignKeys = {},
|
|
692
|
-
) {
|
|
693
|
-
const camelTable = Case.camel(table)
|
|
694
|
-
const tsTableObj = constraints[camelTable]
|
|
695
|
-
const sourceDbTable = tsTableObj?._oldTable || table
|
|
696
|
-
const tempName = `${table}_temp_build`
|
|
697
|
-
|
|
698
|
-
const validCols = Object.entries(constraints[camelTable]).filter(
|
|
699
|
-
([n]) => !['_oldTable', '_transform'].includes(n),
|
|
700
|
-
)
|
|
701
|
-
const colDefs = validCols.map(
|
|
702
|
-
([name, cons]) =>
|
|
703
|
-
` ${tx.quote(Case.snake(name))} ${tx.colDef(cons, Case.snake(name))}`,
|
|
704
|
-
)
|
|
705
|
-
|
|
706
|
-
// Inline, or the rebuild silently drops every foreign key the table had.
|
|
707
|
-
//
|
|
708
|
-
// A constraint is part of the table definition, so recreating the table
|
|
709
|
-
// without it removes it — and on SQLite there is no `ALTER` to put one back,
|
|
710
|
-
// which is precisely why the planner turns a foreign-key change into a
|
|
711
|
-
// rebuild. Without this, that plan could never *add* a key: the rebuild it
|
|
712
|
-
// scheduled was the thing dropping them.
|
|
713
|
-
for (const fk of Object.values(tsFks)) {
|
|
714
|
-
if (Case.snake(fk.table) !== Case.snake(table)) continue
|
|
715
|
-
colDefs.push(` ${tx.foreignKeyClause(fk)}`)
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
await tx.createTable(tempName, colDefs)
|
|
719
|
-
|
|
720
|
-
const currentDbCols = new Set(
|
|
721
|
-
(await tx.getSchema())
|
|
722
|
-
.find(t => t.name === sourceDbTable)
|
|
723
|
-
?.columns.map(c => c.name) || [],
|
|
724
|
-
)
|
|
725
|
-
const sharedColsList = validCols
|
|
726
|
-
.map(([n]) => Case.snake(n))
|
|
727
|
-
.filter(c => currentDbCols.has(c))
|
|
728
|
-
|
|
729
|
-
const transformFn = tsTableObj?._transform
|
|
730
|
-
const hasColTransforms = Object.values(constraints[camelTable]).some(
|
|
731
|
-
c => (c as SyncTypes.ColumnConstraint)?._transform,
|
|
732
|
-
)
|
|
733
|
-
|
|
734
|
-
if (transformFn || hasColTransforms) {
|
|
735
|
-
const oldRows = (await tx
|
|
736
|
-
.query(`SELECT * FROM ${tx.quote(sourceDbTable)}`)
|
|
737
|
-
.all()) as Record<string, any>[]
|
|
738
|
-
const batch = oldRows.map(oldRow => {
|
|
739
|
-
const keys = Object.keys(oldRow)
|
|
740
|
-
const camelRow: Record<string, unknown> = {}
|
|
741
|
-
for (let i = 0; i < keys.length; i++) {
|
|
742
|
-
const k = keys[i]
|
|
743
|
-
camelRow[Case.camel(k)] = oldRow[k]
|
|
744
|
-
}
|
|
745
|
-
if (transformFn) {
|
|
746
|
-
const tObj = transformFn(camelRow)! as Record<string, unknown>
|
|
747
|
-
const tKeys = Object.keys(tObj)
|
|
748
|
-
const result: Record<string, unknown> = {}
|
|
749
|
-
for (let i = 0; i < tKeys.length; i++) {
|
|
750
|
-
const k = tKeys[i]
|
|
751
|
-
result[Case.snake(k)] = tObj[k]
|
|
752
|
-
}
|
|
753
|
-
return result
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
const newRecord: Record<string, any> = {}
|
|
757
|
-
for (const [colName, colObj] of validCols.filter(
|
|
758
|
-
([n]) => n !== '_view',
|
|
759
|
-
)) {
|
|
760
|
-
const cons = colObj as SyncTypes.ColumnConstraint
|
|
761
|
-
const oldColName = cons._oldColumn || colName
|
|
762
|
-
const oldValue =
|
|
763
|
-
camelRow[Case.camel(oldColName)] ?? camelRow[oldColName]
|
|
764
|
-
newRecord[Case.snake(colName)] = cons._transform
|
|
765
|
-
? cons._transform(oldValue, camelRow)
|
|
766
|
-
: (oldValue ?? cons.default ?? null)
|
|
767
|
-
}
|
|
768
|
-
return newRecord
|
|
769
|
-
})
|
|
770
|
-
if (batch.length > 0) await tx.insert(tempName, batch, false)
|
|
771
|
-
} else if (sharedColsList.length > 0) {
|
|
772
|
-
await tx.copyTableData(sourceDbTable, tempName, sharedColsList)
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
await tx.drop('TABLE', sourceDbTable)
|
|
776
|
-
await tx.rename('TABLE', tempName, table)
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
function updateTableRefsAfterRename(
|
|
780
|
-
plan: SyncPlan,
|
|
781
|
-
oldName: string,
|
|
782
|
-
newName: string,
|
|
783
|
-
) {
|
|
784
|
-
for (const col of plan.columnsToDrop)
|
|
785
|
-
if (col.table === oldName) col.table = newName
|
|
786
|
-
for (const col of plan.columnsToRename)
|
|
787
|
-
if (col.table === oldName) col.table = newName
|
|
788
|
-
for (const col of plan.columnsToAdd)
|
|
789
|
-
if (col.table === oldName) col.table = newName
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
async function dropIndexesPhase(
|
|
793
|
-
tx: SQLAdapter,
|
|
794
|
-
indexesToDrop: Set<string>,
|
|
795
|
-
MESSAGES: any,
|
|
796
|
-
) {
|
|
797
|
-
for (const idx of indexesToDrop) {
|
|
798
|
-
MESSAGES.EXEC_DROP_INDEX({ idx })
|
|
799
|
-
await tx.drop('INDEX', idx)
|
|
800
|
-
}
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
async function renameTablesPhase(
|
|
804
|
-
tx: SQLAdapter,
|
|
805
|
-
plan: SyncPlan,
|
|
806
|
-
MESSAGES: any,
|
|
807
|
-
) {
|
|
808
|
-
for (const { oldName, newName } of plan.tablesToRename) {
|
|
809
|
-
MESSAGES.EXEC_RENAME_TABLE({ oldName, newName })
|
|
810
|
-
await tx.rename('TABLE', oldName, newName)
|
|
811
|
-
updateTableRefsAfterRename(plan, oldName, newName)
|
|
812
|
-
}
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
async function renameColumnsPhase(
|
|
816
|
-
tx: SQLAdapter,
|
|
817
|
-
plan: SyncPlan,
|
|
818
|
-
MESSAGES: any,
|
|
819
|
-
) {
|
|
820
|
-
for (const { table, oldColumn, newColumn } of plan.columnsToRename) {
|
|
821
|
-
MESSAGES.EXEC_RENAME_COL({ table, oldColumn, newColumn })
|
|
822
|
-
await tx.rename('COLUMN', table, oldColumn, newColumn)
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
async function dropTablesPhase(tx: SQLAdapter, plan: SyncPlan, MESSAGES: any) {
|
|
827
|
-
for (const table of plan.tablesToDrop) {
|
|
828
|
-
const tType = plan.dbConstraintsForDiff[Case.camel(table)]?._view
|
|
829
|
-
? 'view'
|
|
830
|
-
: 'table'
|
|
831
|
-
MESSAGES.EXEC_DROP_TABLE({ type: tType, table })
|
|
832
|
-
await tx.drop(tType === 'view' ? 'VIEW' : 'TABLE', table)
|
|
833
|
-
}
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
async function dropColumnsPhase(tx: SQLAdapter, plan: SyncPlan, MESSAGES: any) {
|
|
837
|
-
for (const { table, column } of plan.columnsToDrop) {
|
|
838
|
-
MESSAGES.EXEC_DROP_COL({ table, column })
|
|
839
|
-
await tx.drop('COLUMN', table, column)
|
|
840
|
-
}
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
async function addColumnsPhase(tx: SQLAdapter, plan: SyncPlan, MESSAGES: any) {
|
|
844
|
-
for (const { table, column, def } of plan.columnsToAdd) {
|
|
845
|
-
if (!(await tx.hasCol(table, column))) {
|
|
846
|
-
MESSAGES.EXEC_ADD_COL({ table, column })
|
|
847
|
-
await tx.addCol(table, column, def)
|
|
848
|
-
}
|
|
849
|
-
}
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
async function rebuildTablesPhase(
|
|
853
|
-
tx: SQLAdapter,
|
|
854
|
-
plan: SyncPlan,
|
|
855
|
-
constraints: SyncTypes.DBConstraints,
|
|
856
|
-
MESSAGES: any,
|
|
857
|
-
tsFks: SyncTypes.DBForeignKeys = {},
|
|
858
|
-
) {
|
|
859
|
-
for (const table of plan.tablesToRebuild) {
|
|
860
|
-
MESSAGES.EXEC_REBUILD({ table })
|
|
861
|
-
await processTableRebuild(tx, table, constraints, tsFks)
|
|
862
|
-
}
|
|
863
|
-
}
|
|
864
|
-
|
|
865
|
-
/**
|
|
866
|
-
* Drop declared views before any table is rebuilt, where the dialect needs it.
|
|
867
|
-
*
|
|
868
|
-
* A rebuild swaps the table out and back, and two of the three dialects refuse
|
|
869
|
-
* to do that while a view still names the table — SQLite at the rename, Postgres
|
|
870
|
-
* at the drop. `viewsBlockTableRebuild` carries which, and why; MySQL is the one
|
|
871
|
-
* that does not care and skips this entirely.
|
|
872
|
-
*
|
|
873
|
-
* Views hold no data and `syncViewsAndTablesPhase` recreates every declared one
|
|
874
|
-
* a moment later, so dropping them first costs nothing — it is the same "drop
|
|
875
|
-
* and recreate" the engine already does when a view's body changes.
|
|
876
|
-
*
|
|
877
|
-
* Only when something is actually being rebuilt: a sync with no rebuilds should
|
|
878
|
-
* not churn views, and `CREATE VIEW` has no `IF NOT EXISTS`, so a needless drop
|
|
879
|
-
* would be a needless recreate.
|
|
880
|
-
*/
|
|
881
|
-
async function dropViewsForRebuildPhase(
|
|
882
|
-
tx: SQLAdapter,
|
|
883
|
-
plan: SyncPlan,
|
|
884
|
-
constraints: SyncTypes.DBConstraints,
|
|
885
|
-
) {
|
|
886
|
-
if (!tx.viewsBlockTableRebuild) return
|
|
887
|
-
if (!plan.tablesToRebuild.size) return
|
|
888
|
-
for (const [name, cols] of Object.entries(constraints)) {
|
|
889
|
-
if (!(cols as SyncTypes.TableConstraints)._view) continue
|
|
890
|
-
await tx.drop('VIEW', Case.snake(name))
|
|
891
|
-
}
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
async function syncViewsAndTablesPhase(
|
|
895
|
-
tx: SQLAdapter,
|
|
896
|
-
constraints: SyncTypes.DBConstraints,
|
|
897
|
-
MESSAGES: any,
|
|
898
|
-
tsFks: SyncTypes.DBForeignKeys = {},
|
|
899
|
-
) {
|
|
900
|
-
// Parents before children: a foreign key needs the referenced table to exist,
|
|
901
|
-
// and an unordered CREATE simply fails.
|
|
902
|
-
for (const tableName of orderTablesByDependency(
|
|
903
|
-
Object.keys(constraints),
|
|
904
|
-
tsFks,
|
|
905
|
-
)) {
|
|
906
|
-
const cols = constraints[tableName]!
|
|
907
|
-
if ((cols as SyncTypes.TableConstraints)._view) {
|
|
908
|
-
MESSAGES.EXEC_SYNC_VIEW({ view: Case.snake(tableName) })
|
|
909
|
-
await tx.createView(
|
|
910
|
-
Case.snake(tableName),
|
|
911
|
-
(cols as SyncTypes.TableConstraints)._view!,
|
|
912
|
-
)
|
|
913
|
-
} else {
|
|
914
|
-
const colDefs = Object.entries(
|
|
915
|
-
cols as Record<string, SyncTypes.ColumnConstraint>,
|
|
916
|
-
)
|
|
917
|
-
.filter(([name]) => !['_oldTable', '_transform'].includes(name))
|
|
918
|
-
.map(
|
|
919
|
-
([name, cons]) =>
|
|
920
|
-
` ${tx.quote(Case.snake(name))} ${tx.colDef(cons, Case.snake(name))}`,
|
|
921
|
-
)
|
|
922
|
-
// Inline, not a later ALTER: SQLite has no
|
|
923
|
-
// `ALTER TABLE ADD FOREIGN KEY`, so this is the only spelling that works
|
|
924
|
-
// on all three dialects.
|
|
925
|
-
for (const fk of Object.values(tsFks)) {
|
|
926
|
-
if (Case.snake(fk.table) !== Case.snake(tableName)) continue
|
|
927
|
-
colDefs.push(` ${tx.foreignKeyClause(fk)}`)
|
|
928
|
-
}
|
|
929
|
-
MESSAGES.EXEC_SYNC_CONS({ table: Case.snake(tableName) })
|
|
930
|
-
await tx.createTable(Case.snake(tableName), colDefs, true)
|
|
931
|
-
}
|
|
932
|
-
}
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
async function addIndexesPhase(
|
|
936
|
-
tx: SQLAdapter,
|
|
937
|
-
indexesToAdd: Map<string, SyncTypes.IndexConstraint>,
|
|
938
|
-
MESSAGES: any,
|
|
939
|
-
) {
|
|
940
|
-
for (const [idxName, def] of indexesToAdd.entries()) {
|
|
941
|
-
MESSAGES.EXEC_ADD_INDEX({ type: def.type, name: idxName })
|
|
942
|
-
await tx.createIndex(
|
|
943
|
-
idxName,
|
|
944
|
-
Case.snake(def.table),
|
|
945
|
-
def.cols.map(Case.snake),
|
|
946
|
-
def.type === 'unique',
|
|
947
|
-
)
|
|
948
|
-
}
|
|
949
|
-
}
|
|
950
|
-
|
|
951
|
-
/**
|
|
952
|
-
* Foreign keys on tables that already existed.
|
|
953
|
-
*
|
|
954
|
-
* Only reachable where the dialect can ALTER one in. SQLite cannot, so its
|
|
955
|
-
* missing keys are handled by scheduling a table rebuild in the planner — the
|
|
956
|
-
* rebuild recreates the table through `createTable`, which emits them inline.
|
|
957
|
-
*/
|
|
958
|
-
async function foreignKeysPhase(
|
|
959
|
-
tx: SQLAdapter,
|
|
960
|
-
fksToAdd: Map<string, SyncTypes.ForeignKeyInfo>,
|
|
961
|
-
fksToDrop: Map<string, SyncTypes.ForeignKeyInfo>,
|
|
962
|
-
MESSAGES: any,
|
|
963
|
-
) {
|
|
964
|
-
if (!tx.supportsAlterForeignKey) return
|
|
965
|
-
for (const fk of fksToDrop.values()) {
|
|
966
|
-
MESSAGES.EXEC_DROP_FK?.({ table: fk.table, name: fk.name ?? '' })
|
|
967
|
-
await tx.dropForeignKey(fk)
|
|
968
|
-
}
|
|
969
|
-
for (const fk of fksToAdd.values()) {
|
|
970
|
-
MESSAGES.EXEC_ADD_FK?.({ table: fk.table, ref: fk.refTable })
|
|
971
|
-
await tx.addForeignKey(fk)
|
|
972
|
-
}
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
export async function executeSyncPlan(
|
|
976
|
-
tx: SQLAdapter,
|
|
977
|
-
plan: SyncPlan,
|
|
978
|
-
constraints: SyncTypes.DBConstraints,
|
|
979
|
-
indexesToDrop: Set<string>,
|
|
980
|
-
indexesToAdd: Map<string, SyncTypes.IndexConstraint>,
|
|
981
|
-
MESSAGES: any,
|
|
982
|
-
tsFks: SyncTypes.DBForeignKeys = {},
|
|
983
|
-
fksToAdd: Map<string, SyncTypes.ForeignKeyInfo> = new Map(),
|
|
984
|
-
fksToDrop: Map<string, SyncTypes.ForeignKeyInfo> = new Map(),
|
|
985
|
-
) {
|
|
986
|
-
await dropIndexesPhase(tx, indexesToDrop, MESSAGES)
|
|
987
|
-
await renameTablesPhase(tx, plan, MESSAGES)
|
|
988
|
-
await renameColumnsPhase(tx, plan, MESSAGES)
|
|
989
|
-
await dropTablesPhase(tx, plan, MESSAGES)
|
|
990
|
-
await dropColumnsPhase(tx, plan, MESSAGES)
|
|
991
|
-
await addColumnsPhase(tx, plan, MESSAGES)
|
|
992
|
-
await dropViewsForRebuildPhase(tx, plan, constraints)
|
|
993
|
-
await rebuildTablesPhase(tx, plan, constraints, MESSAGES, tsFks)
|
|
994
|
-
await syncViewsAndTablesPhase(tx, constraints, MESSAGES, tsFks)
|
|
995
|
-
await addIndexesPhase(tx, indexesToAdd, MESSAGES)
|
|
996
|
-
// Last, so every table a key could reference already exists.
|
|
997
|
-
await foreignKeysPhase(tx, fksToAdd, fksToDrop, MESSAGES)
|
|
998
|
-
}
|
|
999
|
-
|
|
1000
|
-
export function hasOldWrappers(constraints: SyncTypes.DBConstraints) {
|
|
1001
|
-
return Object.values(constraints).some(
|
|
1002
|
-
tObj =>
|
|
1003
|
-
tObj?._oldTable ||
|
|
1004
|
-
tObj?._transform ||
|
|
1005
|
-
Object.values(tObj as object).some(
|
|
1006
|
-
c => (c as any)?._oldColumn || (c as any)?._transform,
|
|
1007
|
-
),
|
|
1008
|
-
)
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
/** The `foreign()` declarations, normalised into the shape the diff uses. */
|
|
1012
|
-
export function collectForeignKeys(
|
|
1013
|
-
tsIndexes: SyncTypes.DBIndexes,
|
|
1014
|
-
): SyncTypes.DBForeignKeys {
|
|
1015
|
-
const out: SyncTypes.DBForeignKeys = {}
|
|
1016
|
-
for (const [name, idx] of Object.entries(tsIndexes)) {
|
|
1017
|
-
if ((idx as any)?.type !== 'foreign') continue
|
|
1018
|
-
const fk = idx as any
|
|
1019
|
-
if (!fk.refTable || !fk.refCols?.length) continue
|
|
1020
|
-
const info: SyncTypes.ForeignKeyInfo = {
|
|
1021
|
-
table: fk.table,
|
|
1022
|
-
cols: fk.cols,
|
|
1023
|
-
refTable: fk.refTable,
|
|
1024
|
-
refCols: fk.refCols,
|
|
1025
|
-
name,
|
|
1026
|
-
onDelete: fk.onDelete,
|
|
1027
|
-
onUpdate: fk.onUpdate,
|
|
1028
|
-
}
|
|
1029
|
-
out[SQLAdapter.foreignKeyId(info)] = info
|
|
1030
|
-
}
|
|
1031
|
-
return out
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
|
-
/**
|
|
1035
|
-
* Which foreign keys to add and which to drop.
|
|
1036
|
-
*
|
|
1037
|
-
* Keyed by the tuple, so a constraint the database named itself still matches
|
|
1038
|
-
* the declaration that produced it — including on SQLite, which reports no name
|
|
1039
|
-
* at all.
|
|
1040
|
-
*/
|
|
1041
|
-
export function calculateForeignKeyDiff(
|
|
1042
|
-
dbFks: SyncTypes.DBForeignKeys,
|
|
1043
|
-
tsFks: SyncTypes.DBForeignKeys,
|
|
1044
|
-
tablesToRebuild: Set<string>,
|
|
1045
|
-
/**
|
|
1046
|
-
* Tables that already exist, so a key on a table being *created* is left out.
|
|
1047
|
-
*
|
|
1048
|
-
* `CREATE TABLE` emits its foreign keys inline — the only spelling SQLite
|
|
1049
|
-
* has. Counting those as "to add" made MySQL and Postgres ALTER in a
|
|
1050
|
-
* constraint that already existed, and made SQLite schedule a rebuild of a
|
|
1051
|
-
* table that did not exist yet: a fresh `db:sync` announced
|
|
1052
|
-
* "Tables to rebuild: posts" against an empty database.
|
|
1053
|
-
*
|
|
1054
|
-
* Phrased as "being created" rather than "already exists" deliberately. The
|
|
1055
|
-
* inverse defaults to an *empty* set, which reads as "nothing exists" and so
|
|
1056
|
-
* suppressed every key precisely when the database was new — the case that
|
|
1057
|
-
* exposed this in the first place.
|
|
1058
|
-
*/
|
|
1059
|
-
tablesBeingCreated: Set<string> = new Set(),
|
|
1060
|
-
) {
|
|
1061
|
-
const fksToAdd = new Map<string, SyncTypes.ForeignKeyInfo>()
|
|
1062
|
-
const fksToDrop = new Map<string, SyncTypes.ForeignKeyInfo>()
|
|
1063
|
-
|
|
1064
|
-
// `NO ACTION` on both sides of the comparison, because that is what every
|
|
1065
|
-
// dialect reports for a key declared without one — so an omitted action and
|
|
1066
|
-
// an explicit `NO ACTION` must not read as a difference.
|
|
1067
|
-
const act = (a?: string) => a ?? 'NO ACTION'
|
|
1068
|
-
const sameActions = (
|
|
1069
|
-
a: SyncTypes.ForeignKeyInfo,
|
|
1070
|
-
b: SyncTypes.ForeignKeyInfo,
|
|
1071
|
-
) =>
|
|
1072
|
-
act(a.onDelete) === act(b.onDelete) && act(a.onUpdate) === act(b.onUpdate)
|
|
1073
|
-
|
|
1074
|
-
for (const [id, fk] of Object.entries(tsFks)) {
|
|
1075
|
-
// A rebuilt table is recreated from the constraints, foreign keys included,
|
|
1076
|
-
// so adding one separately would duplicate it.
|
|
1077
|
-
if (tablesToRebuild.has(Case.snake(fk.table))) continue
|
|
1078
|
-
if (tablesBeingCreated.has(Case.snake(fk.table))) continue
|
|
1079
|
-
|
|
1080
|
-
const existing = dbFks[id]
|
|
1081
|
-
if (existing) {
|
|
1082
|
-
// Same columns, different behaviour. No dialect alters a referential
|
|
1083
|
-
// action in place, so the constraint is replaced — and the drop carries
|
|
1084
|
-
// the name the *database* gave it, which is not necessarily the one we
|
|
1085
|
-
// would generate for it.
|
|
1086
|
-
if (!sameActions(existing, fk)) {
|
|
1087
|
-
fksToDrop.set(id, existing)
|
|
1088
|
-
fksToAdd.set(id, fk)
|
|
1089
|
-
}
|
|
1090
|
-
continue
|
|
1091
|
-
}
|
|
1092
|
-
fksToAdd.set(id, fk)
|
|
1093
|
-
}
|
|
1094
|
-
for (const [id, fk] of Object.entries(dbFks)) {
|
|
1095
|
-
if (tsFks[id] || tablesToRebuild.has(Case.snake(fk.table))) continue
|
|
1096
|
-
fksToDrop.set(id, fk)
|
|
1097
|
-
}
|
|
1098
|
-
return { fksToAdd, fksToDrop }
|
|
1099
|
-
}
|
|
1100
|
-
|
|
1101
|
-
/**
|
|
1102
|
-
* Table names ordered so a parent is always created before its children.
|
|
1103
|
-
*
|
|
1104
|
-
* Not cosmetic: a foreign key requires the referenced table to exist, so an
|
|
1105
|
-
* unordered CREATE fails outright — verified against Postgres, which answers
|
|
1106
|
-
* `relation "o_parent" does not exist`. Dropping runs in reverse for the
|
|
1107
|
-
* mirror-image reason.
|
|
1108
|
-
*
|
|
1109
|
-
* A cycle cannot be ordered at all. Rather than loop forever or drop tables,
|
|
1110
|
-
* the remainder is appended in declaration order: the foreign key that closes
|
|
1111
|
-
* the cycle then fails loudly at the database, which is the honest outcome —
|
|
1112
|
-
* breaking it needs a deferred constraint, which no dialect here spells alike.
|
|
1113
|
-
*/
|
|
1114
|
-
export function orderTablesByDependency(
|
|
1115
|
-
tables: string[],
|
|
1116
|
-
tsFks: SyncTypes.DBForeignKeys,
|
|
1117
|
-
): string[] {
|
|
1118
|
-
const deps = new Map<string, Set<string>>()
|
|
1119
|
-
for (const t of tables) deps.set(Case.snake(t), new Set())
|
|
1120
|
-
for (const fk of Object.values(tsFks)) {
|
|
1121
|
-
const child = Case.snake(fk.table)
|
|
1122
|
-
const parent = Case.snake(fk.refTable)
|
|
1123
|
-
// A self-reference is satisfied by the table's own CREATE.
|
|
1124
|
-
if (child === parent) continue
|
|
1125
|
-
if (deps.has(child) && deps.has(parent)) deps.get(child)!.add(parent)
|
|
1126
|
-
}
|
|
1127
|
-
|
|
1128
|
-
const ordered: string[] = []
|
|
1129
|
-
const done = new Set<string>()
|
|
1130
|
-
let progressed = true
|
|
1131
|
-
while (progressed && done.size < tables.length) {
|
|
1132
|
-
progressed = false
|
|
1133
|
-
for (const t of tables) {
|
|
1134
|
-
const snake = Case.snake(t)
|
|
1135
|
-
if (done.has(snake)) continue
|
|
1136
|
-
const unmet = [...(deps.get(snake) ?? [])].some(d => !done.has(d))
|
|
1137
|
-
if (unmet) continue
|
|
1138
|
-
ordered.push(t)
|
|
1139
|
-
done.add(snake)
|
|
1140
|
-
progressed = true
|
|
1141
|
-
}
|
|
1142
|
-
}
|
|
1143
|
-
for (const t of tables) if (!done.has(Case.snake(t))) ordered.push(t)
|
|
1144
|
-
return ordered
|
|
1145
|
-
}
|