@bakery-framework/orm 2.0.0-alpha.4 → 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.
@@ -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
+ }
@@ -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
+ }