@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
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
import { Case } from '@bakery-framework/core/utils'
|
|
2
|
+
import { SQLAdapter } from '../adapters/base'
|
|
3
|
+
import type { MESSAGES } from './engine'
|
|
4
|
+
import type * as SyncTypes from './types'
|
|
5
|
+
|
|
6
|
+
type SyncPlan = SyncTypes.SyncPlan
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The message table the DDL phases log through.
|
|
10
|
+
*
|
|
11
|
+
* Derived from the real one in `sync/engine.ts` rather than hand-written, so a
|
|
12
|
+
* message that is renamed or removed there fails the build here instead of
|
|
13
|
+
* printing "Error message not found" at run time. Type-only, so it adds no
|
|
14
|
+
* module edge back into the engine.
|
|
15
|
+
*/
|
|
16
|
+
export type SyncMessages = typeof MESSAGES
|
|
17
|
+
|
|
18
|
+
async function processTableRebuild(
|
|
19
|
+
tx: SQLAdapter,
|
|
20
|
+
table: string,
|
|
21
|
+
constraints: SyncTypes.DBConstraints,
|
|
22
|
+
tsFks: SyncTypes.DBForeignKeys = {},
|
|
23
|
+
) {
|
|
24
|
+
const camelTable = Case.camel(table)
|
|
25
|
+
const tsTableObj = constraints[camelTable]
|
|
26
|
+
const sourceDbTable = tsTableObj?._oldTable || table
|
|
27
|
+
const tempName = `${table}_temp_build`
|
|
28
|
+
|
|
29
|
+
const validCols = Object.entries(constraints[camelTable]).filter(
|
|
30
|
+
([n]) => !['_oldTable', '_transform'].includes(n),
|
|
31
|
+
)
|
|
32
|
+
const colDefs = validCols.map(
|
|
33
|
+
([name, cons]) =>
|
|
34
|
+
` ${tx.quote(Case.snake(name))} ${tx.colDef(cons, Case.snake(name))}`,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
// Inline, or the rebuild silently drops every foreign key the table had.
|
|
38
|
+
//
|
|
39
|
+
// A constraint is part of the table definition, so recreating the table
|
|
40
|
+
// without it removes it — and on SQLite there is no `ALTER` to put one back,
|
|
41
|
+
// which is precisely why the planner turns a foreign-key change into a
|
|
42
|
+
// rebuild. Without this, that plan could never *add* a key: the rebuild it
|
|
43
|
+
// scheduled was the thing dropping them.
|
|
44
|
+
for (const fk of Object.values(tsFks)) {
|
|
45
|
+
if (Case.snake(fk.table) !== Case.snake(table)) continue
|
|
46
|
+
colDefs.push(` ${tx.foreignKeyClause(fk)}`)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
await tx.createTable(tempName, colDefs)
|
|
50
|
+
|
|
51
|
+
const currentDbCols = new Set(
|
|
52
|
+
(await tx.getSchema())
|
|
53
|
+
.find(t => t.name === sourceDbTable)
|
|
54
|
+
?.columns.map(c => c.name) || [],
|
|
55
|
+
)
|
|
56
|
+
const sharedColsList = validCols
|
|
57
|
+
.map(([n]) => Case.snake(n))
|
|
58
|
+
.filter(c => currentDbCols.has(c))
|
|
59
|
+
|
|
60
|
+
const transformFn = tsTableObj?._transform
|
|
61
|
+
const hasColTransforms = Object.values(constraints[camelTable]).some(
|
|
62
|
+
c => (c as SyncTypes.ColumnConstraint)?._transform,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
if (transformFn || hasColTransforms) {
|
|
66
|
+
const oldRows = (await tx
|
|
67
|
+
.query(`SELECT * FROM ${tx.quote(sourceDbTable)}`)
|
|
68
|
+
.all()) as Record<string, any>[]
|
|
69
|
+
const batch = oldRows.map(oldRow => {
|
|
70
|
+
const keys = Object.keys(oldRow)
|
|
71
|
+
const camelRow: Record<string, unknown> = {}
|
|
72
|
+
for (let i = 0; i < keys.length; i++) {
|
|
73
|
+
const k = keys[i]
|
|
74
|
+
camelRow[Case.camel(k)] = oldRow[k]
|
|
75
|
+
}
|
|
76
|
+
if (transformFn) {
|
|
77
|
+
const tObj = transformFn(camelRow)! as Record<string, unknown>
|
|
78
|
+
const tKeys = Object.keys(tObj)
|
|
79
|
+
const result: Record<string, unknown> = {}
|
|
80
|
+
for (let i = 0; i < tKeys.length; i++) {
|
|
81
|
+
const k = tKeys[i]
|
|
82
|
+
result[Case.snake(k)] = tObj[k]
|
|
83
|
+
}
|
|
84
|
+
return result
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const newRecord: Record<string, any> = {}
|
|
88
|
+
for (const [colName, colObj] of validCols.filter(
|
|
89
|
+
([n]) => n !== '_view',
|
|
90
|
+
)) {
|
|
91
|
+
const cons = colObj as SyncTypes.ColumnConstraint
|
|
92
|
+
const oldColName = cons._oldColumn || colName
|
|
93
|
+
const oldValue =
|
|
94
|
+
camelRow[Case.camel(oldColName)] ?? camelRow[oldColName]
|
|
95
|
+
newRecord[Case.snake(colName)] = cons._transform
|
|
96
|
+
? cons._transform(oldValue, camelRow)
|
|
97
|
+
: (oldValue ?? cons.default ?? null)
|
|
98
|
+
}
|
|
99
|
+
return newRecord
|
|
100
|
+
})
|
|
101
|
+
if (batch.length > 0) await tx.insert(tempName, batch, false)
|
|
102
|
+
} else if (sharedColsList.length > 0) {
|
|
103
|
+
await tx.copyTableData(sourceDbTable, tempName, sharedColsList)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
await tx.drop('TABLE', sourceDbTable)
|
|
107
|
+
await tx.rename('TABLE', tempName, table)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function updateTableRefsAfterRename(
|
|
111
|
+
plan: SyncPlan,
|
|
112
|
+
oldName: string,
|
|
113
|
+
newName: string,
|
|
114
|
+
) {
|
|
115
|
+
for (const col of plan.columnsToDrop)
|
|
116
|
+
if (col.table === oldName) col.table = newName
|
|
117
|
+
for (const col of plan.columnsToRename)
|
|
118
|
+
if (col.table === oldName) col.table = newName
|
|
119
|
+
for (const col of plan.columnsToAdd)
|
|
120
|
+
if (col.table === oldName) col.table = newName
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function dropIndexesPhase(
|
|
124
|
+
tx: SQLAdapter,
|
|
125
|
+
indexesToDrop: Set<string>,
|
|
126
|
+
MESSAGES: SyncMessages,
|
|
127
|
+
) {
|
|
128
|
+
for (const idx of indexesToDrop) {
|
|
129
|
+
MESSAGES.EXEC_DROP_INDEX({ idx })
|
|
130
|
+
await tx.drop('INDEX', idx)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function renameTablesPhase(
|
|
135
|
+
tx: SQLAdapter,
|
|
136
|
+
plan: SyncPlan,
|
|
137
|
+
MESSAGES: SyncMessages,
|
|
138
|
+
) {
|
|
139
|
+
for (const { oldName, newName } of plan.tablesToRename) {
|
|
140
|
+
MESSAGES.EXEC_RENAME_TABLE({ oldName, newName })
|
|
141
|
+
await tx.rename('TABLE', oldName, newName)
|
|
142
|
+
updateTableRefsAfterRename(plan, oldName, newName)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function renameColumnsPhase(
|
|
147
|
+
tx: SQLAdapter,
|
|
148
|
+
plan: SyncPlan,
|
|
149
|
+
MESSAGES: SyncMessages,
|
|
150
|
+
) {
|
|
151
|
+
for (const { table, oldColumn, newColumn } of plan.columnsToRename) {
|
|
152
|
+
MESSAGES.EXEC_RENAME_COL({ table, oldColumn, newColumn })
|
|
153
|
+
await tx.rename('COLUMN', table, oldColumn, newColumn)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function dropTablesPhase(
|
|
158
|
+
tx: SQLAdapter,
|
|
159
|
+
plan: SyncPlan,
|
|
160
|
+
MESSAGES: SyncMessages,
|
|
161
|
+
) {
|
|
162
|
+
for (const table of plan.tablesToDrop) {
|
|
163
|
+
const tType = plan.dbConstraintsForDiff[Case.camel(table)]?._view
|
|
164
|
+
? 'view'
|
|
165
|
+
: 'table'
|
|
166
|
+
MESSAGES.EXEC_DROP_TABLE({ type: tType, table })
|
|
167
|
+
await tx.drop(tType === 'view' ? 'VIEW' : 'TABLE', table)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function dropColumnsPhase(
|
|
172
|
+
tx: SQLAdapter,
|
|
173
|
+
plan: SyncPlan,
|
|
174
|
+
MESSAGES: SyncMessages,
|
|
175
|
+
) {
|
|
176
|
+
for (const { table, column } of plan.columnsToDrop) {
|
|
177
|
+
MESSAGES.EXEC_DROP_COL({ table, column })
|
|
178
|
+
await tx.drop('COLUMN', table, column)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function addColumnsPhase(
|
|
183
|
+
tx: SQLAdapter,
|
|
184
|
+
plan: SyncPlan,
|
|
185
|
+
MESSAGES: SyncMessages,
|
|
186
|
+
) {
|
|
187
|
+
for (const { table, column, def } of plan.columnsToAdd) {
|
|
188
|
+
if (!(await tx.hasCol(table, column))) {
|
|
189
|
+
MESSAGES.EXEC_ADD_COL({ table, column })
|
|
190
|
+
await tx.addCol(table, column, def)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function rebuildTablesPhase(
|
|
196
|
+
tx: SQLAdapter,
|
|
197
|
+
plan: SyncPlan,
|
|
198
|
+
constraints: SyncTypes.DBConstraints,
|
|
199
|
+
MESSAGES: SyncMessages,
|
|
200
|
+
tsFks: SyncTypes.DBForeignKeys = {},
|
|
201
|
+
) {
|
|
202
|
+
for (const table of plan.tablesToRebuild) {
|
|
203
|
+
MESSAGES.EXEC_REBUILD({ table })
|
|
204
|
+
await processTableRebuild(tx, table, constraints, tsFks)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Drop declared views before any table is rebuilt, where the dialect needs it.
|
|
210
|
+
*
|
|
211
|
+
* A rebuild swaps the table out and back, and two of the three dialects refuse
|
|
212
|
+
* to do that while a view still names the table — SQLite at the rename, Postgres
|
|
213
|
+
* at the drop. `viewsBlockTableRebuild` carries which, and why; MySQL is the one
|
|
214
|
+
* that does not care and skips this entirely.
|
|
215
|
+
*
|
|
216
|
+
* Views hold no data and `syncViewsAndTablesPhase` recreates every declared one
|
|
217
|
+
* a moment later, so dropping them first costs nothing — it is the same "drop
|
|
218
|
+
* and recreate" the engine already does when a view's body changes.
|
|
219
|
+
*
|
|
220
|
+
* Only when something is actually being rebuilt: a sync with no rebuilds should
|
|
221
|
+
* not churn views, and `CREATE VIEW` has no `IF NOT EXISTS`, so a needless drop
|
|
222
|
+
* would be a needless recreate.
|
|
223
|
+
*/
|
|
224
|
+
async function dropViewsForRebuildPhase(
|
|
225
|
+
tx: SQLAdapter,
|
|
226
|
+
plan: SyncPlan,
|
|
227
|
+
constraints: SyncTypes.DBConstraints,
|
|
228
|
+
) {
|
|
229
|
+
if (!tx.viewsBlockTableRebuild) return
|
|
230
|
+
if (!plan.tablesToRebuild.size) return
|
|
231
|
+
for (const [name, cols] of Object.entries(constraints)) {
|
|
232
|
+
if (!(cols as SyncTypes.TableConstraints)._view) continue
|
|
233
|
+
await tx.drop('VIEW', Case.snake(name))
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function syncViewsAndTablesPhase(
|
|
238
|
+
tx: SQLAdapter,
|
|
239
|
+
constraints: SyncTypes.DBConstraints,
|
|
240
|
+
MESSAGES: SyncMessages,
|
|
241
|
+
tsFks: SyncTypes.DBForeignKeys = {},
|
|
242
|
+
) {
|
|
243
|
+
// Parents before children: a foreign key needs the referenced table to exist,
|
|
244
|
+
// and an unordered CREATE simply fails.
|
|
245
|
+
for (const tableName of orderTablesByDependency(
|
|
246
|
+
Object.keys(constraints),
|
|
247
|
+
tsFks,
|
|
248
|
+
)) {
|
|
249
|
+
const cols = constraints[tableName]!
|
|
250
|
+
if ((cols as SyncTypes.TableConstraints)._view) {
|
|
251
|
+
MESSAGES.EXEC_SYNC_VIEW({ view: Case.snake(tableName) })
|
|
252
|
+
await tx.createView(
|
|
253
|
+
Case.snake(tableName),
|
|
254
|
+
(cols as SyncTypes.TableConstraints)._view!,
|
|
255
|
+
)
|
|
256
|
+
} else {
|
|
257
|
+
const colDefs = Object.entries(
|
|
258
|
+
cols as Record<string, SyncTypes.ColumnConstraint>,
|
|
259
|
+
)
|
|
260
|
+
.filter(([name]) => !['_oldTable', '_transform'].includes(name))
|
|
261
|
+
.map(
|
|
262
|
+
([name, cons]) =>
|
|
263
|
+
` ${tx.quote(Case.snake(name))} ${tx.colDef(cons, Case.snake(name))}`,
|
|
264
|
+
)
|
|
265
|
+
// Inline, not a later ALTER: SQLite has no
|
|
266
|
+
// `ALTER TABLE ADD FOREIGN KEY`, so this is the only spelling that works
|
|
267
|
+
// on all three dialects.
|
|
268
|
+
for (const fk of Object.values(tsFks)) {
|
|
269
|
+
if (Case.snake(fk.table) !== Case.snake(tableName)) continue
|
|
270
|
+
colDefs.push(` ${tx.foreignKeyClause(fk)}`)
|
|
271
|
+
}
|
|
272
|
+
MESSAGES.EXEC_SYNC_CONS({ table: Case.snake(tableName) })
|
|
273
|
+
await tx.createTable(Case.snake(tableName), colDefs, true)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function addIndexesPhase(
|
|
279
|
+
tx: SQLAdapter,
|
|
280
|
+
indexesToAdd: Map<string, SyncTypes.IndexConstraint>,
|
|
281
|
+
MESSAGES: SyncMessages,
|
|
282
|
+
) {
|
|
283
|
+
for (const [idxName, def] of indexesToAdd.entries()) {
|
|
284
|
+
MESSAGES.EXEC_ADD_INDEX({ type: def.type, name: idxName })
|
|
285
|
+
await tx.createIndex(
|
|
286
|
+
idxName,
|
|
287
|
+
Case.snake(def.table),
|
|
288
|
+
def.cols.map(Case.snake),
|
|
289
|
+
def.type === 'unique',
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Foreign keys on tables that already existed.
|
|
296
|
+
*
|
|
297
|
+
* Only reachable where the dialect can ALTER one in. SQLite cannot, so its
|
|
298
|
+
* missing keys are handled by scheduling a table rebuild in the planner — the
|
|
299
|
+
* rebuild recreates the table through `createTable`, which emits them inline.
|
|
300
|
+
*/
|
|
301
|
+
async function foreignKeysPhase(
|
|
302
|
+
tx: SQLAdapter,
|
|
303
|
+
fksToAdd: Map<string, SyncTypes.ForeignKeyInfo>,
|
|
304
|
+
fksToDrop: Map<string, SyncTypes.ForeignKeyInfo>,
|
|
305
|
+
MESSAGES: SyncMessages,
|
|
306
|
+
) {
|
|
307
|
+
if (!tx.supportsAlterForeignKey) return
|
|
308
|
+
for (const fk of fksToDrop.values()) {
|
|
309
|
+
// Called unconditionally, like every other phase. These two were `?.` for
|
|
310
|
+
// as long as `syncMsgs` did not declare them, which read as "this message
|
|
311
|
+
// is optional" and was really "this message does not exist" — the optional
|
|
312
|
+
// call is what let the gap survive: it type-checked, and the proxy turned
|
|
313
|
+
// the miss into an error line at run time instead of a build failure.
|
|
314
|
+
MESSAGES.EXEC_DROP_FK({ table: fk.table, name: fk.name ?? '' })
|
|
315
|
+
await tx.dropForeignKey(fk)
|
|
316
|
+
}
|
|
317
|
+
for (const fk of fksToAdd.values()) {
|
|
318
|
+
MESSAGES.EXEC_ADD_FK({ table: fk.table, ref: fk.refTable })
|
|
319
|
+
await tx.addForeignKey(fk)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Everything `executeSyncPlan` needs, as one value.
|
|
325
|
+
*
|
|
326
|
+
* An options object rather than nine positional parameters: six of them are
|
|
327
|
+
* some flavour of "a set or a map of things to change", and three carry a
|
|
328
|
+
* default, so a call site that wants only the last one had to spell out the two
|
|
329
|
+
* before it. The four optional members keep the defaults the positional form
|
|
330
|
+
* had.
|
|
331
|
+
*/
|
|
332
|
+
export interface ExecuteSyncPlanOptions {
|
|
333
|
+
/** The adapter *inside the transaction*, not the outer connection. */
|
|
334
|
+
tx: SQLAdapter
|
|
335
|
+
plan: SyncPlan
|
|
336
|
+
constraints: SyncTypes.DBConstraints
|
|
337
|
+
indexesToDrop: Set<string>
|
|
338
|
+
indexesToAdd: Map<string, SyncTypes.IndexConstraint>
|
|
339
|
+
MESSAGES: SyncMessages
|
|
340
|
+
tsFks?: SyncTypes.DBForeignKeys
|
|
341
|
+
fksToAdd?: Map<string, SyncTypes.ForeignKeyInfo>
|
|
342
|
+
fksToDrop?: Map<string, SyncTypes.ForeignKeyInfo>
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export async function executeSyncPlan({
|
|
346
|
+
tx,
|
|
347
|
+
plan,
|
|
348
|
+
constraints,
|
|
349
|
+
indexesToDrop,
|
|
350
|
+
indexesToAdd,
|
|
351
|
+
MESSAGES,
|
|
352
|
+
tsFks = {},
|
|
353
|
+
fksToAdd = new Map(),
|
|
354
|
+
fksToDrop = new Map(),
|
|
355
|
+
}: ExecuteSyncPlanOptions) {
|
|
356
|
+
await dropIndexesPhase(tx, indexesToDrop, MESSAGES)
|
|
357
|
+
await renameTablesPhase(tx, plan, MESSAGES)
|
|
358
|
+
await renameColumnsPhase(tx, plan, MESSAGES)
|
|
359
|
+
await dropTablesPhase(tx, plan, MESSAGES)
|
|
360
|
+
await dropColumnsPhase(tx, plan, MESSAGES)
|
|
361
|
+
await addColumnsPhase(tx, plan, MESSAGES)
|
|
362
|
+
await dropViewsForRebuildPhase(tx, plan, constraints)
|
|
363
|
+
await rebuildTablesPhase(tx, plan, constraints, MESSAGES, tsFks)
|
|
364
|
+
await syncViewsAndTablesPhase(tx, constraints, MESSAGES, tsFks)
|
|
365
|
+
await addIndexesPhase(tx, indexesToAdd, MESSAGES)
|
|
366
|
+
// Last, so every table a key could reference already exists.
|
|
367
|
+
await foreignKeysPhase(tx, fksToAdd, fksToDrop, MESSAGES)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export function hasOldWrappers(constraints: SyncTypes.DBConstraints) {
|
|
371
|
+
return Object.values(constraints).some(tObj => {
|
|
372
|
+
if (tObj?._oldTable || tObj?._transform) return true
|
|
373
|
+
// The two `any` casts this replaces were not simply redundant.
|
|
374
|
+
// `TableConstraints` is a column map *intersected* with `_view` (a string)
|
|
375
|
+
// and a table-level `_transform` (a function), so `Object.values` really
|
|
376
|
+
// does yield a mixed union and `_oldColumn` really is absent from two of
|
|
377
|
+
// its three members. Reading it off the string or the function gives
|
|
378
|
+
// `undefined`, which is what this check wants — so the narrowing is
|
|
379
|
+
// deliberate rather than a lie, and the emitted code is unchanged.
|
|
380
|
+
const cols = Object.values(tObj) as (
|
|
381
|
+
| SyncTypes.ColumnConstraint
|
|
382
|
+
| undefined
|
|
383
|
+
)[]
|
|
384
|
+
return cols.some(c => c?._oldColumn || c?._transform)
|
|
385
|
+
})
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** The `foreign()` declarations, normalised into the shape the diff uses. */
|
|
389
|
+
export function collectForeignKeys(
|
|
390
|
+
tsIndexes: SyncTypes.DBIndexes,
|
|
391
|
+
): SyncTypes.DBForeignKeys {
|
|
392
|
+
const out: SyncTypes.DBForeignKeys = {}
|
|
393
|
+
for (const [name, idx] of Object.entries(tsIndexes)) {
|
|
394
|
+
if ((idx as any)?.type !== 'foreign') continue
|
|
395
|
+
const fk = idx as any
|
|
396
|
+
if (!fk.refTable || !fk.refCols?.length) continue
|
|
397
|
+
const info: SyncTypes.ForeignKeyInfo = {
|
|
398
|
+
table: fk.table,
|
|
399
|
+
cols: fk.cols,
|
|
400
|
+
refTable: fk.refTable,
|
|
401
|
+
refCols: fk.refCols,
|
|
402
|
+
name,
|
|
403
|
+
onDelete: fk.onDelete,
|
|
404
|
+
onUpdate: fk.onUpdate,
|
|
405
|
+
}
|
|
406
|
+
out[SQLAdapter.foreignKeyId(info)] = info
|
|
407
|
+
}
|
|
408
|
+
return out
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Which foreign keys to add and which to drop.
|
|
413
|
+
*
|
|
414
|
+
* Keyed by the tuple, so a constraint the database named itself still matches
|
|
415
|
+
* the declaration that produced it — including on SQLite, which reports no name
|
|
416
|
+
* at all.
|
|
417
|
+
*/
|
|
418
|
+
export function calculateForeignKeyDiff(
|
|
419
|
+
dbFks: SyncTypes.DBForeignKeys,
|
|
420
|
+
tsFks: SyncTypes.DBForeignKeys,
|
|
421
|
+
tablesToRebuild: Set<string>,
|
|
422
|
+
/**
|
|
423
|
+
* Tables that already exist, so a key on a table being *created* is left out.
|
|
424
|
+
*
|
|
425
|
+
* `CREATE TABLE` emits its foreign keys inline — the only spelling SQLite
|
|
426
|
+
* has. Counting those as "to add" made MySQL and Postgres ALTER in a
|
|
427
|
+
* constraint that already existed, and made SQLite schedule a rebuild of a
|
|
428
|
+
* table that did not exist yet: a fresh `db:sync` announced
|
|
429
|
+
* "Tables to rebuild: posts" against an empty database.
|
|
430
|
+
*
|
|
431
|
+
* Phrased as "being created" rather than "already exists" deliberately. The
|
|
432
|
+
* inverse defaults to an *empty* set, which reads as "nothing exists" and so
|
|
433
|
+
* suppressed every key precisely when the database was new — the case that
|
|
434
|
+
* exposed this in the first place.
|
|
435
|
+
*/
|
|
436
|
+
tablesBeingCreated: Set<string> = new Set(),
|
|
437
|
+
) {
|
|
438
|
+
const fksToAdd = new Map<string, SyncTypes.ForeignKeyInfo>()
|
|
439
|
+
const fksToDrop = new Map<string, SyncTypes.ForeignKeyInfo>()
|
|
440
|
+
|
|
441
|
+
// `NO ACTION` on both sides of the comparison, because that is what every
|
|
442
|
+
// dialect reports for a key declared without one — so an omitted action and
|
|
443
|
+
// an explicit `NO ACTION` must not read as a difference.
|
|
444
|
+
const act = (a?: string) => a ?? 'NO ACTION'
|
|
445
|
+
const sameActions = (
|
|
446
|
+
a: SyncTypes.ForeignKeyInfo,
|
|
447
|
+
b: SyncTypes.ForeignKeyInfo,
|
|
448
|
+
) =>
|
|
449
|
+
act(a.onDelete) === act(b.onDelete) && act(a.onUpdate) === act(b.onUpdate)
|
|
450
|
+
|
|
451
|
+
for (const [id, fk] of Object.entries(tsFks)) {
|
|
452
|
+
// A rebuilt table is recreated from the constraints, foreign keys included,
|
|
453
|
+
// so adding one separately would duplicate it.
|
|
454
|
+
if (tablesToRebuild.has(Case.snake(fk.table))) continue
|
|
455
|
+
if (tablesBeingCreated.has(Case.snake(fk.table))) continue
|
|
456
|
+
|
|
457
|
+
const existing = dbFks[id]
|
|
458
|
+
if (existing) {
|
|
459
|
+
// Same columns, different behaviour. No dialect alters a referential
|
|
460
|
+
// action in place, so the constraint is replaced — and the drop carries
|
|
461
|
+
// the name the *database* gave it, which is not necessarily the one we
|
|
462
|
+
// would generate for it.
|
|
463
|
+
if (!sameActions(existing, fk)) {
|
|
464
|
+
fksToDrop.set(id, existing)
|
|
465
|
+
fksToAdd.set(id, fk)
|
|
466
|
+
}
|
|
467
|
+
continue
|
|
468
|
+
}
|
|
469
|
+
fksToAdd.set(id, fk)
|
|
470
|
+
}
|
|
471
|
+
for (const [id, fk] of Object.entries(dbFks)) {
|
|
472
|
+
if (tsFks[id] || tablesToRebuild.has(Case.snake(fk.table))) continue
|
|
473
|
+
fksToDrop.set(id, fk)
|
|
474
|
+
}
|
|
475
|
+
return { fksToAdd, fksToDrop }
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Table names ordered so a parent is always created before its children.
|
|
480
|
+
*
|
|
481
|
+
* Not cosmetic: a foreign key requires the referenced table to exist, so an
|
|
482
|
+
* unordered CREATE fails outright — verified against Postgres, which answers
|
|
483
|
+
* `relation "o_parent" does not exist`. Dropping runs in reverse for the
|
|
484
|
+
* mirror-image reason.
|
|
485
|
+
*
|
|
486
|
+
* A cycle cannot be ordered at all. Rather than loop forever or drop tables,
|
|
487
|
+
* the remainder is appended in declaration order: the foreign key that closes
|
|
488
|
+
* the cycle then fails loudly at the database, which is the honest outcome —
|
|
489
|
+
* breaking it needs a deferred constraint, which no dialect here spells alike.
|
|
490
|
+
*/
|
|
491
|
+
export function orderTablesByDependency(
|
|
492
|
+
tables: string[],
|
|
493
|
+
tsFks: SyncTypes.DBForeignKeys,
|
|
494
|
+
): string[] {
|
|
495
|
+
const deps = new Map<string, Set<string>>()
|
|
496
|
+
for (const t of tables) deps.set(Case.snake(t), new Set())
|
|
497
|
+
for (const fk of Object.values(tsFks)) {
|
|
498
|
+
const child = Case.snake(fk.table)
|
|
499
|
+
const parent = Case.snake(fk.refTable)
|
|
500
|
+
// A self-reference is satisfied by the table's own CREATE.
|
|
501
|
+
if (child === parent) continue
|
|
502
|
+
if (deps.has(child) && deps.has(parent)) deps.get(child)!.add(parent)
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const ordered: string[] = []
|
|
506
|
+
const done = new Set<string>()
|
|
507
|
+
let progressed = true
|
|
508
|
+
while (progressed && done.size < tables.length) {
|
|
509
|
+
progressed = false
|
|
510
|
+
for (const t of tables) {
|
|
511
|
+
const snake = Case.snake(t)
|
|
512
|
+
if (done.has(snake)) continue
|
|
513
|
+
const unmet = [...(deps.get(snake) ?? [])].some(d => !done.has(d))
|
|
514
|
+
if (unmet) continue
|
|
515
|
+
ordered.push(t)
|
|
516
|
+
done.add(snake)
|
|
517
|
+
progressed = true
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
for (const t of tables) if (!done.has(Case.snake(t))) ordered.push(t)
|
|
521
|
+
return ordered
|
|
522
|
+
}
|
package/src/sync/plan.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type { Logger } from '@bakery-framework/core/logger'
|
|
2
|
+
import { Case } from '@bakery-framework/core/utils'
|
|
3
|
+
import type { SQLAdapter } from '../adapters/base'
|
|
4
|
+
import { diffTableViewsAndColumns, diffViews } from './diff'
|
|
5
|
+
import { resolveCurrentState } from './ledger'
|
|
6
|
+
import {
|
|
7
|
+
handleTableRenames,
|
|
8
|
+
initDbTablesMap,
|
|
9
|
+
promptAndRenameTables,
|
|
10
|
+
} from './rename'
|
|
11
|
+
import type * as SyncTypes from './types'
|
|
12
|
+
|
|
13
|
+
type SyncPlan = SyncTypes.SyncPlan
|
|
14
|
+
|
|
15
|
+
export async function buildSyncPlan(
|
|
16
|
+
adapter: SQLAdapter,
|
|
17
|
+
constraints: SyncTypes.DBConstraints,
|
|
18
|
+
logger: Logger,
|
|
19
|
+
MESSAGES: any,
|
|
20
|
+
): Promise<SyncPlan> {
|
|
21
|
+
const plan: SyncPlan = {
|
|
22
|
+
tablesToDrop: [],
|
|
23
|
+
tablesToRename: [],
|
|
24
|
+
columnsToDrop: [],
|
|
25
|
+
columnsToAdd: [],
|
|
26
|
+
columnsToRename: [],
|
|
27
|
+
tablesToRebuild: new Set(),
|
|
28
|
+
viewsToUpdate: [],
|
|
29
|
+
unmappedTsTables: new Set(),
|
|
30
|
+
dbConstraintsForDiff: {},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The one place sync decides what "currently" means. Prefers the ledger —
|
|
34
|
+
// what Bakery last applied — and falls back to introspection whenever the
|
|
35
|
+
// ledger no longer describes the same tables and columns. See sync/ledger.ts
|
|
36
|
+
// for why that fallback is the whole safety argument.
|
|
37
|
+
const current = await resolveCurrentState(adapter, {
|
|
38
|
+
ignoreLedger: process.argv.includes('--no-ledger'),
|
|
39
|
+
})
|
|
40
|
+
plan.ledgerSource = current.source
|
|
41
|
+
plan.ledgerReason = current.reason
|
|
42
|
+
plan.dbConstraintsForDiff = current.constraints
|
|
43
|
+
const dbTables = initDbTablesMap(plan.dbConstraintsForDiff)
|
|
44
|
+
const unmappedDbTables = handleTableRenames(plan, constraints, dbTables)
|
|
45
|
+
|
|
46
|
+
promptAndRenameTables(plan, dbTables, unmappedDbTables, logger)
|
|
47
|
+
diffTableViewsAndColumns(
|
|
48
|
+
plan,
|
|
49
|
+
dbTables,
|
|
50
|
+
constraints,
|
|
51
|
+
logger,
|
|
52
|
+
MESSAGES,
|
|
53
|
+
adapter.databaseName,
|
|
54
|
+
)
|
|
55
|
+
// Separately, because every comparison above walks `dbTables`, which by
|
|
56
|
+
// construction contains no views.
|
|
57
|
+
diffViews(plan, constraints, adapter.databaseName)
|
|
58
|
+
|
|
59
|
+
plan.tablesToRename = plan.tablesToRename.filter(
|
|
60
|
+
t =>
|
|
61
|
+
!plan.tablesToRebuild.has(t.newName) &&
|
|
62
|
+
!plan.tablesToRebuild.has(t.oldName),
|
|
63
|
+
)
|
|
64
|
+
return plan
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function calculateIndexDiff(
|
|
68
|
+
dbIndexes: SyncTypes.DBIndexes,
|
|
69
|
+
tsIndexes: SyncTypes.DBIndexes,
|
|
70
|
+
tablesToRebuild: Set<string>,
|
|
71
|
+
) {
|
|
72
|
+
const indexesToDrop = new Set<string>()
|
|
73
|
+
const indexesToAdd = new Map<string, SyncTypes.IndexConstraint>()
|
|
74
|
+
|
|
75
|
+
// Foreign keys ride in the same declaration map as index()/unique() but are
|
|
76
|
+
// a different thing: they are emitted with the table or by ALTER, never by
|
|
77
|
+
// CREATE INDEX. `calculateForeignKeyDiff` owns them.
|
|
78
|
+
const isFk = (i: any) => i?.type === 'foreign'
|
|
79
|
+
|
|
80
|
+
for (const [dbIdxName, dbIdx] of Object.entries(dbIndexes)) {
|
|
81
|
+
if (isFk(dbIdx)) continue
|
|
82
|
+
const tsIdx = tsIndexes[dbIdxName]
|
|
83
|
+
const isRebuilt = tablesToRebuild.has(Case.snake(dbIdx.table))
|
|
84
|
+
|
|
85
|
+
if (!tsIdx) {
|
|
86
|
+
indexesToDrop.add(Case.snake(dbIdxName))
|
|
87
|
+
} else if (
|
|
88
|
+
isRebuilt ||
|
|
89
|
+
tsIdx.type !== dbIdx.type ||
|
|
90
|
+
tsIdx.table !== dbIdx.table ||
|
|
91
|
+
tsIdx.cols.join(',') !== dbIdx.cols.join(',')
|
|
92
|
+
) {
|
|
93
|
+
if (!isRebuilt) indexesToDrop.add(Case.snake(dbIdxName))
|
|
94
|
+
indexesToAdd.set(Case.snake(dbIdxName), tsIdx)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for (const [tsIdxName, tsIdx] of Object.entries(tsIndexes)) {
|
|
99
|
+
if (isFk(tsIdx)) continue
|
|
100
|
+
if (!dbIndexes[tsIdxName]) indexesToAdd.set(Case.snake(tsIdxName), tsIdx)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { indexesToDrop, indexesToAdd }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function logPlannedChanges(
|
|
107
|
+
plan: SyncPlan,
|
|
108
|
+
indexesToDrop: Set<string>,
|
|
109
|
+
indexesToAdd: Map<string, SyncTypes.IndexConstraint>,
|
|
110
|
+
isDangerous: boolean,
|
|
111
|
+
MESSAGES: any,
|
|
112
|
+
) {
|
|
113
|
+
if (isDangerous) MESSAGES.DANGER_ZONE()
|
|
114
|
+
if (plan.tablesToDrop.length)
|
|
115
|
+
MESSAGES.DROP_TABLES({ tables: plan.tablesToDrop.join(', ') })
|
|
116
|
+
if (plan.tablesToRename.length)
|
|
117
|
+
MESSAGES.RENAME_TABLES({
|
|
118
|
+
tables: plan.tablesToRename
|
|
119
|
+
.map(t => `${t.oldName} -> ${t.newName}`)
|
|
120
|
+
.join(', '),
|
|
121
|
+
})
|
|
122
|
+
if (plan.columnsToDrop.length)
|
|
123
|
+
MESSAGES.DROP_COLS({
|
|
124
|
+
cols: plan.columnsToDrop.map(c => `${c.table}.${c.column}`).join(', '),
|
|
125
|
+
})
|
|
126
|
+
if (plan.columnsToRename.length)
|
|
127
|
+
MESSAGES.RENAME_COLS({
|
|
128
|
+
cols: plan.columnsToRename
|
|
129
|
+
.map(c => `${c.table}.${c.oldColumn} -> ${c.newColumn}`)
|
|
130
|
+
.join(', '),
|
|
131
|
+
})
|
|
132
|
+
if (plan.columnsToAdd.length)
|
|
133
|
+
MESSAGES.ADD_COLS({
|
|
134
|
+
cols: plan.columnsToAdd.map(c => `${c.table}.${c.column}`).join(', '),
|
|
135
|
+
})
|
|
136
|
+
if (plan.tablesToRebuild.size)
|
|
137
|
+
MESSAGES.REBUILD_TABLES({
|
|
138
|
+
tables: Array.from(plan.tablesToRebuild).join(', '),
|
|
139
|
+
})
|
|
140
|
+
if (plan.viewsToUpdate.length)
|
|
141
|
+
MESSAGES.UPDATE_VIEWS({ views: plan.viewsToUpdate.join(', ') })
|
|
142
|
+
if (indexesToDrop.size)
|
|
143
|
+
MESSAGES.DROP_INDEXES({ indexes: Array.from(indexesToDrop).join(', ') })
|
|
144
|
+
if (indexesToAdd.size > 0)
|
|
145
|
+
MESSAGES.ADD_INDEXES({
|
|
146
|
+
indexes: Array.from(indexesToAdd.keys()).join(', '),
|
|
147
|
+
})
|
|
148
|
+
}
|