@bakery-framework/orm 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1119 @@
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
+ if (
264
+ !isTypeMatch ||
265
+ tsNullable !== dbNullable ||
266
+ lengthDiffers ||
267
+ norm(tsDefault) !== norm(dbDefault)
268
+ ) {
269
+ MESSAGES.COL_MISMATCH({ table: dbName, column: camelCol })
270
+ MESSAGES.COL_MISMATCH_TS({
271
+ tsType: tsCol.type,
272
+ tsNullable: String(tsNullable),
273
+ tsDefault: String(tsDefault),
274
+ })
275
+ MESSAGES.COL_MISMATCH_DB({
276
+ dbType: dbCol.type,
277
+ dbNullable: String(dbNullable),
278
+ dbDefault: String(dbDefault),
279
+ })
280
+ plan.tablesToRebuild.add(dbName)
281
+ }
282
+ }
283
+
284
+ function resolveColumnRenames(
285
+ plan: SyncPlan,
286
+ camelTable: string,
287
+ dbName: string,
288
+ constraints: any,
289
+ unmappedDbCols: Set<string>,
290
+ unmappedTsCols: Set<string>,
291
+ existingDbCamelCols: Set<string>,
292
+ ) {
293
+ for (const newCamel of [...unmappedTsCols]) {
294
+ const tsColObj = constraints[camelTable][newCamel]
295
+ if (!tsColObj?._oldColumn) continue
296
+ const oldCamel = Case.camel(tsColObj._oldColumn)
297
+ if (!existingDbCamelCols.has(oldCamel)) continue
298
+ plan.columnsToRename.push({
299
+ table: dbName,
300
+ oldColumn: Case.snake(tsColObj._oldColumn),
301
+ newColumn: Case.snake(newCamel),
302
+ })
303
+ unmappedDbCols.delete(Case.snake(tsColObj._oldColumn))
304
+ unmappedTsCols.delete(newCamel)
305
+ if (plan.dbConstraintsForDiff[camelTable]?.[oldCamel]) {
306
+ plan.dbConstraintsForDiff[camelTable][newCamel] =
307
+ plan.dbConstraintsForDiff[camelTable][oldCamel]
308
+ delete plan.dbConstraintsForDiff[camelTable][oldCamel]
309
+ }
310
+ existingDbCamelCols.delete(oldCamel)
311
+ existingDbCamelCols.add(newCamel)
312
+ }
313
+ }
314
+
315
+ function resolveUnmappedDbCols(
316
+ plan: SyncPlan,
317
+ camelTable: string,
318
+ dbName: string,
319
+ logger: any,
320
+ unmappedDbCols: Set<string>,
321
+ unmappedTsCols: Set<string>,
322
+ existingDbCamelCols: Set<string>,
323
+ ) {
324
+ for (const oldDbCol of [...unmappedDbCols]) {
325
+ const bestMatch = unmappedTsCols.size
326
+ ? findBestMatchAndPrompt(
327
+ oldDbCol,
328
+ unmappedTsCols,
329
+ 'column',
330
+ dbName,
331
+ logger,
332
+ )
333
+ : null
334
+ if (bestMatch) {
335
+ plan.columnsToRename.push({
336
+ table: dbName,
337
+ oldColumn: oldDbCol,
338
+ newColumn: Case.snake(bestMatch),
339
+ })
340
+ unmappedDbCols.delete(oldDbCol)
341
+ unmappedTsCols.delete(bestMatch)
342
+ const oldCamel = Case.camel(oldDbCol)
343
+ if (plan.dbConstraintsForDiff[camelTable]?.[oldCamel]) {
344
+ plan.dbConstraintsForDiff[camelTable][bestMatch] =
345
+ plan.dbConstraintsForDiff[camelTable][oldCamel]
346
+ delete plan.dbConstraintsForDiff[camelTable][oldCamel]
347
+ }
348
+ existingDbCamelCols.delete(oldCamel)
349
+ existingDbCamelCols.add(bestMatch)
350
+ } else {
351
+ plan.columnsToDrop.push({ table: dbName, column: oldDbCol })
352
+ }
353
+ }
354
+ }
355
+
356
+ function diffTableColumns(
357
+ plan: SyncPlan,
358
+ camelTable: string,
359
+ dbName: string,
360
+ constraints: any,
361
+ logger: any,
362
+ MESSAGES: any,
363
+ ) {
364
+ const existingDbCamelCols = new Set(
365
+ Object.keys(plan.dbConstraintsForDiff[camelTable] || {}).filter(
366
+ k => k !== '_view',
367
+ ),
368
+ )
369
+ const unmappedDbCols = new Set(
370
+ [...existingDbCamelCols]
371
+ .filter(c => !constraints[camelTable][c])
372
+ .map(Case.snake),
373
+ )
374
+ const unmappedTsCols = new Set(
375
+ Object.keys(constraints[camelTable]).filter(
376
+ c =>
377
+ !existingDbCamelCols.has(c) &&
378
+ !['_view', '_oldTable', '_transform'].includes(c),
379
+ ),
380
+ )
381
+
382
+ resolveColumnRenames(
383
+ plan,
384
+ camelTable,
385
+ dbName,
386
+ constraints,
387
+ unmappedDbCols,
388
+ unmappedTsCols,
389
+ existingDbCamelCols,
390
+ )
391
+ resolveUnmappedDbCols(
392
+ plan,
393
+ camelTable,
394
+ dbName,
395
+ logger,
396
+ unmappedDbCols,
397
+ unmappedTsCols,
398
+ existingDbCamelCols,
399
+ )
400
+
401
+ plan.columnsToAdd.push(
402
+ ...[...unmappedTsCols].map(newCamel => ({
403
+ table: dbName,
404
+ column: Case.snake(newCamel),
405
+ def: constraints[camelTable][newCamel],
406
+ })),
407
+ )
408
+
409
+ for (const camelCol of existingDbCamelCols) {
410
+ if (unmappedDbCols.has(Case.snake(camelCol))) continue
411
+ const tsCol = constraints[camelTable][camelCol]
412
+ const dbCol = plan.dbConstraintsForDiff[camelTable]?.[camelCol]
413
+ if (tsCol && dbCol) {
414
+ diffColumnMismatch(plan, dbName, camelCol, tsCol, dbCol, MESSAGES)
415
+ }
416
+ }
417
+ }
418
+
419
+ function diffViewStrings(
420
+ plan: SyncPlan,
421
+ camelTable: string,
422
+ dbName: string,
423
+ constraints: any,
424
+ database?: string,
425
+ ): boolean {
426
+ // Both sides through the same canonicaliser, which is the only thing that
427
+ // makes a text comparison viable: you write `SELECT id FROM users` and MySQL
428
+ // returns it fully qualified, fully quoted and aliased column by column.
429
+ //
430
+ // Symmetry is the whole requirement. Normalising the *generated file* while
431
+ // comparing raw — or stripping the schema on one side only — recreates the
432
+ // view on every sync, which is the same churn the column diff has hit twice.
433
+ const tsViewStr = normalizeViewBody(
434
+ String(constraints[camelTable]._view || ''),
435
+ database,
436
+ )
437
+ const dbViewStr = normalizeViewBody(
438
+ String(plan.dbConstraintsForDiff[camelTable]?._view || ''),
439
+ database,
440
+ )
441
+ if (tsViewStr || dbViewStr) {
442
+ if (tsViewStr !== dbViewStr) plan.viewsToUpdate.push(dbName)
443
+ if (tsViewStr && !dbViewStr) plan.tablesToDrop.push(dbName)
444
+ return true
445
+ }
446
+ return false
447
+ }
448
+
449
+ /**
450
+ * The view lifecycle: create, recreate, drop.
451
+ *
452
+ * Views were invisible to the planner. `initDbTablesMap` skips `_view` entries,
453
+ * and every existing comparison iterates that map — so a declared view never
454
+ * reached `diffViewStrings`, and nothing about a view ever reached
455
+ * `hasChanges`. The consequences, all three measured:
456
+ *
457
+ * - a **new** view was never planned,
458
+ * - an **edited** `SELECT` was never detected, so a view could not be changed,
459
+ * - a view the schema no longer declares was never dropped.
460
+ *
461
+ * They were invisible rather than broken: `syncViewsAndTablesPhase` recreates
462
+ * every declared view whenever a sync happens to run, so a view kept up to date
463
+ * as a side effect of unrelated work. With nothing else to do, `db:sync`
464
+ * reported a perfectly synced database and left the view alone.
465
+ *
466
+ * Bodies are compared through `normalizeViewBody` on both sides. That converges
467
+ * on SQLite, which stores the text verbatim, and via the ledger everywhere —
468
+ * the ledger records what was *applied*, so it holds the authored SELECT.
469
+ * Diffing against live introspection on MySQL or Postgres will still see a
470
+ * difference, because both re-render the body (MySQL re-qualifies every column,
471
+ * Postgres adds parentheses), and no amount of text normalisation short of a
472
+ * parser fixes that. It costs a recreate, and a view holds no data.
473
+ */
474
+ function diffViews(
475
+ plan: SyncPlan,
476
+ constraints: SyncTypes.DBConstraints,
477
+ database?: string,
478
+ ) {
479
+ const dbSide = plan.dbConstraintsForDiff
480
+ const declared = new Set<string>()
481
+
482
+ for (const [name, cols] of Object.entries(constraints)) {
483
+ const body = (cols as SyncTypes.TableConstraints)?._view
484
+ if (!body) continue
485
+ const camel = Case.camel(name)
486
+ declared.add(camel)
487
+
488
+ const dbBody = (dbSide[camel] as SyncTypes.TableConstraints | undefined)
489
+ ?._view
490
+ const want = normalizeViewBody(String(body), database)
491
+ const have = dbBody ? normalizeViewBody(String(dbBody), database) : null
492
+ if (have !== want) plan.viewsToUpdate.push(Case.snake(name))
493
+ }
494
+
495
+ for (const [name, cols] of Object.entries(dbSide)) {
496
+ if (!(cols as SyncTypes.TableConstraints)?._view) continue
497
+ if (declared.has(Case.camel(name))) continue
498
+ // Same rule tables follow: what the schema does not declare, the database
499
+ // does not keep. Dropping is announced before it happens.
500
+ plan.tablesToDrop.push(Case.snake(name))
501
+ }
502
+ }
503
+
504
+ function diffTableViewsAndColumns(
505
+ plan: SyncPlan,
506
+ dbTables: any,
507
+ constraints: any,
508
+ logger: any,
509
+ MESSAGES: any,
510
+ database?: string,
511
+ ) {
512
+ for (const camelTable of Object.keys(dbTables)) {
513
+ if (!constraints[camelTable]) continue
514
+ const dbName = dbTables[camelTable]!.dbName
515
+ if (
516
+ plan.tablesToRebuild.has(dbName) ||
517
+ plan.tablesToRebuild.has(Case.snake(camelTable))
518
+ )
519
+ continue
520
+ if (diffViewStrings(plan, camelTable, dbName, constraints, database))
521
+ continue
522
+ diffTableColumns(plan, camelTable, dbName, constraints, logger, MESSAGES)
523
+ }
524
+ }
525
+
526
+ export async function buildSyncPlan(
527
+ adapter: SQLAdapter,
528
+ constraints: SyncTypes.DBConstraints,
529
+ logger: any,
530
+ MESSAGES: any,
531
+ ): Promise<SyncPlan> {
532
+ const plan: SyncPlan = {
533
+ tablesToDrop: [],
534
+ tablesToRename: [],
535
+ columnsToDrop: [],
536
+ columnsToAdd: [],
537
+ columnsToRename: [],
538
+ tablesToRebuild: new Set(),
539
+ viewsToUpdate: [],
540
+ unmappedTsTables: new Set(),
541
+ dbConstraintsForDiff: {},
542
+ }
543
+
544
+ // The one place sync decides what "currently" means. Prefers the ledger —
545
+ // what Bakery last applied — and falls back to introspection whenever the
546
+ // ledger no longer describes the same tables and columns. See sync/ledger.ts
547
+ // for why that fallback is the whole safety argument.
548
+ const current = await resolveCurrentState(adapter, {
549
+ ignoreLedger: process.argv.includes('--no-ledger'),
550
+ })
551
+ plan.ledgerSource = current.source
552
+ plan.ledgerReason = current.reason
553
+ plan.dbConstraintsForDiff = current.constraints
554
+ const dbTables = initDbTablesMap(plan.dbConstraintsForDiff)
555
+ const unmappedDbTables = handleTableRenames(plan, constraints, dbTables)
556
+
557
+ promptAndRenameTables(plan, dbTables, unmappedDbTables, logger)
558
+ diffTableViewsAndColumns(
559
+ plan,
560
+ dbTables,
561
+ constraints,
562
+ logger,
563
+ MESSAGES,
564
+ adapter.databaseName,
565
+ )
566
+ // Separately, because every comparison above walks `dbTables`, which by
567
+ // construction contains no views.
568
+ diffViews(plan, constraints, adapter.databaseName)
569
+
570
+ plan.tablesToRename = plan.tablesToRename.filter(
571
+ t =>
572
+ !plan.tablesToRebuild.has(t.newName) &&
573
+ !plan.tablesToRebuild.has(t.oldName),
574
+ )
575
+ return plan
576
+ }
577
+
578
+ export function calculateIndexDiff(
579
+ dbIndexes: SyncTypes.DBIndexes,
580
+ tsIndexes: SyncTypes.DBIndexes,
581
+ tablesToRebuild: Set<string>,
582
+ ) {
583
+ const indexesToDrop = new Set<string>()
584
+ const indexesToAdd = new Map<string, SyncTypes.IndexConstraint>()
585
+
586
+ // Foreign keys ride in the same declaration map as index()/unique() but are
587
+ // a different thing: they are emitted with the table or by ALTER, never by
588
+ // CREATE INDEX. `calculateForeignKeyDiff` owns them.
589
+ const isFk = (i: any) => i?.type === 'foreign'
590
+
591
+ for (const [dbIdxName, dbIdx] of Object.entries(dbIndexes)) {
592
+ if (isFk(dbIdx)) continue
593
+ const tsIdx = tsIndexes[dbIdxName]
594
+ const isRebuilt = tablesToRebuild.has(Case.snake(dbIdx.table))
595
+
596
+ if (!tsIdx) {
597
+ indexesToDrop.add(Case.snake(dbIdxName))
598
+ } else if (
599
+ isRebuilt ||
600
+ tsIdx.type !== dbIdx.type ||
601
+ tsIdx.table !== dbIdx.table ||
602
+ tsIdx.cols.join(',') !== dbIdx.cols.join(',')
603
+ ) {
604
+ if (!isRebuilt) indexesToDrop.add(Case.snake(dbIdxName))
605
+ indexesToAdd.set(Case.snake(dbIdxName), tsIdx)
606
+ }
607
+ }
608
+
609
+ for (const [tsIdxName, tsIdx] of Object.entries(tsIndexes)) {
610
+ if (isFk(tsIdx)) continue
611
+ if (!dbIndexes[tsIdxName]) indexesToAdd.set(Case.snake(tsIdxName), tsIdx)
612
+ }
613
+
614
+ return { indexesToDrop, indexesToAdd }
615
+ }
616
+
617
+ export function logPlannedChanges(
618
+ plan: SyncPlan,
619
+ indexesToDrop: Set<string>,
620
+ indexesToAdd: Map<string, SyncTypes.IndexConstraint>,
621
+ isDangerous: boolean,
622
+ MESSAGES: any,
623
+ ) {
624
+ if (isDangerous) MESSAGES.DANGER_ZONE()
625
+ if (plan.tablesToDrop.length)
626
+ MESSAGES.DROP_TABLES({ tables: plan.tablesToDrop.join(', ') })
627
+ if (plan.tablesToRename.length)
628
+ MESSAGES.RENAME_TABLES({
629
+ tables: plan.tablesToRename
630
+ .map(t => `${t.oldName} -> ${t.newName}`)
631
+ .join(', '),
632
+ })
633
+ if (plan.columnsToDrop.length)
634
+ MESSAGES.DROP_COLS({
635
+ cols: plan.columnsToDrop.map(c => `${c.table}.${c.column}`).join(', '),
636
+ })
637
+ if (plan.columnsToRename.length)
638
+ MESSAGES.RENAME_COLS({
639
+ cols: plan.columnsToRename
640
+ .map(c => `${c.table}.${c.oldColumn} -> ${c.newColumn}`)
641
+ .join(', '),
642
+ })
643
+ if (plan.columnsToAdd.length)
644
+ MESSAGES.ADD_COLS({
645
+ cols: plan.columnsToAdd.map(c => `${c.table}.${c.column}`).join(', '),
646
+ })
647
+ if (plan.tablesToRebuild.size)
648
+ MESSAGES.REBUILD_TABLES({
649
+ tables: Array.from(plan.tablesToRebuild).join(', '),
650
+ })
651
+ if (plan.viewsToUpdate.length)
652
+ MESSAGES.UPDATE_VIEWS({ views: plan.viewsToUpdate.join(', ') })
653
+ if (indexesToDrop.size)
654
+ MESSAGES.DROP_INDEXES({ indexes: Array.from(indexesToDrop).join(', ') })
655
+ if (indexesToAdd.size > 0)
656
+ MESSAGES.ADD_INDEXES({
657
+ indexes: Array.from(indexesToAdd.keys()).join(', '),
658
+ })
659
+ }
660
+
661
+ async function processTableRebuild(
662
+ tx: SQLAdapter,
663
+ table: string,
664
+ constraints: SyncTypes.DBConstraints,
665
+ tsFks: SyncTypes.DBForeignKeys = {},
666
+ ) {
667
+ const camelTable = Case.camel(table)
668
+ const tsTableObj = constraints[camelTable]
669
+ const sourceDbTable = tsTableObj?._oldTable || table
670
+ const tempName = `${table}_temp_build`
671
+
672
+ const validCols = Object.entries(constraints[camelTable]).filter(
673
+ ([n]) => !['_oldTable', '_transform'].includes(n),
674
+ )
675
+ const colDefs = validCols.map(
676
+ ([name, cons]) =>
677
+ ` ${tx.quote(Case.snake(name))} ${tx.colDef(cons, Case.snake(name))}`,
678
+ )
679
+
680
+ // Inline, or the rebuild silently drops every foreign key the table had.
681
+ //
682
+ // A constraint is part of the table definition, so recreating the table
683
+ // without it removes it — and on SQLite there is no `ALTER` to put one back,
684
+ // which is precisely why the planner turns a foreign-key change into a
685
+ // rebuild. Without this, that plan could never *add* a key: the rebuild it
686
+ // scheduled was the thing dropping them.
687
+ for (const fk of Object.values(tsFks)) {
688
+ if (Case.snake(fk.table) !== Case.snake(table)) continue
689
+ colDefs.push(` ${tx.foreignKeyClause(fk)}`)
690
+ }
691
+
692
+ await tx.createTable(tempName, colDefs)
693
+
694
+ const currentDbCols = new Set(
695
+ (await tx.getSchema())
696
+ .find(t => t.name === sourceDbTable)
697
+ ?.columns.map(c => c.name) || [],
698
+ )
699
+ const sharedColsList = validCols
700
+ .map(([n]) => Case.snake(n))
701
+ .filter(c => currentDbCols.has(c))
702
+
703
+ const transformFn = tsTableObj?._transform
704
+ const hasColTransforms = Object.values(constraints[camelTable]).some(
705
+ c => (c as SyncTypes.ColumnConstraint)?._transform,
706
+ )
707
+
708
+ if (transformFn || hasColTransforms) {
709
+ const oldRows = (await tx
710
+ .query(`SELECT * FROM ${tx.quote(sourceDbTable)}`)
711
+ .all()) as Record<string, any>[]
712
+ const batch = oldRows.map(oldRow => {
713
+ const keys = Object.keys(oldRow)
714
+ const camelRow: Record<string, unknown> = {}
715
+ for (let i = 0; i < keys.length; i++) {
716
+ const k = keys[i]
717
+ camelRow[Case.camel(k)] = oldRow[k]
718
+ }
719
+ if (transformFn) {
720
+ const tObj = transformFn(camelRow)! as Record<string, unknown>
721
+ const tKeys = Object.keys(tObj)
722
+ const result: Record<string, unknown> = {}
723
+ for (let i = 0; i < tKeys.length; i++) {
724
+ const k = tKeys[i]
725
+ result[Case.snake(k)] = tObj[k]
726
+ }
727
+ return result
728
+ }
729
+
730
+ const newRecord: Record<string, any> = {}
731
+ for (const [colName, colObj] of validCols.filter(
732
+ ([n]) => n !== '_view',
733
+ )) {
734
+ const cons = colObj as SyncTypes.ColumnConstraint
735
+ const oldColName = cons._oldColumn || colName
736
+ const oldValue =
737
+ camelRow[Case.camel(oldColName)] ?? camelRow[oldColName]
738
+ newRecord[Case.snake(colName)] = cons._transform
739
+ ? cons._transform(oldValue, camelRow)
740
+ : (oldValue ?? cons.default ?? null)
741
+ }
742
+ return newRecord
743
+ })
744
+ if (batch.length > 0) await tx.insert(tempName, batch, false)
745
+ } else if (sharedColsList.length > 0) {
746
+ await tx.copyTableData(sourceDbTable, tempName, sharedColsList)
747
+ }
748
+
749
+ await tx.drop('TABLE', sourceDbTable)
750
+ await tx.rename('TABLE', tempName, table)
751
+ }
752
+
753
+ function updateTableRefsAfterRename(
754
+ plan: SyncPlan,
755
+ oldName: string,
756
+ newName: string,
757
+ ) {
758
+ for (const col of plan.columnsToDrop)
759
+ if (col.table === oldName) col.table = newName
760
+ for (const col of plan.columnsToRename)
761
+ if (col.table === oldName) col.table = newName
762
+ for (const col of plan.columnsToAdd)
763
+ if (col.table === oldName) col.table = newName
764
+ }
765
+
766
+ async function dropIndexesPhase(
767
+ tx: SQLAdapter,
768
+ indexesToDrop: Set<string>,
769
+ MESSAGES: any,
770
+ ) {
771
+ for (const idx of indexesToDrop) {
772
+ MESSAGES.EXEC_DROP_INDEX({ idx })
773
+ await tx.drop('INDEX', idx)
774
+ }
775
+ }
776
+
777
+ async function renameTablesPhase(
778
+ tx: SQLAdapter,
779
+ plan: SyncPlan,
780
+ MESSAGES: any,
781
+ ) {
782
+ for (const { oldName, newName } of plan.tablesToRename) {
783
+ MESSAGES.EXEC_RENAME_TABLE({ oldName, newName })
784
+ await tx.rename('TABLE', oldName, newName)
785
+ updateTableRefsAfterRename(plan, oldName, newName)
786
+ }
787
+ }
788
+
789
+ async function renameColumnsPhase(
790
+ tx: SQLAdapter,
791
+ plan: SyncPlan,
792
+ MESSAGES: any,
793
+ ) {
794
+ for (const { table, oldColumn, newColumn } of plan.columnsToRename) {
795
+ MESSAGES.EXEC_RENAME_COL({ table, oldColumn, newColumn })
796
+ await tx.rename('COLUMN', table, oldColumn, newColumn)
797
+ }
798
+ }
799
+
800
+ async function dropTablesPhase(tx: SQLAdapter, plan: SyncPlan, MESSAGES: any) {
801
+ for (const table of plan.tablesToDrop) {
802
+ const tType = plan.dbConstraintsForDiff[Case.camel(table)]?._view
803
+ ? 'view'
804
+ : 'table'
805
+ MESSAGES.EXEC_DROP_TABLE({ type: tType, table })
806
+ await tx.drop(tType === 'view' ? 'VIEW' : 'TABLE', table)
807
+ }
808
+ }
809
+
810
+ async function dropColumnsPhase(tx: SQLAdapter, plan: SyncPlan, MESSAGES: any) {
811
+ for (const { table, column } of plan.columnsToDrop) {
812
+ MESSAGES.EXEC_DROP_COL({ table, column })
813
+ await tx.drop('COLUMN', table, column)
814
+ }
815
+ }
816
+
817
+ async function addColumnsPhase(tx: SQLAdapter, plan: SyncPlan, MESSAGES: any) {
818
+ for (const { table, column, def } of plan.columnsToAdd) {
819
+ if (!(await tx.hasCol(table, column))) {
820
+ MESSAGES.EXEC_ADD_COL({ table, column })
821
+ await tx.addCol(table, column, def)
822
+ }
823
+ }
824
+ }
825
+
826
+ async function rebuildTablesPhase(
827
+ tx: SQLAdapter,
828
+ plan: SyncPlan,
829
+ constraints: SyncTypes.DBConstraints,
830
+ MESSAGES: any,
831
+ tsFks: SyncTypes.DBForeignKeys = {},
832
+ ) {
833
+ for (const table of plan.tablesToRebuild) {
834
+ MESSAGES.EXEC_REBUILD({ table })
835
+ await processTableRebuild(tx, table, constraints, tsFks)
836
+ }
837
+ }
838
+
839
+ /**
840
+ * Drop declared views before any table is rebuilt, where the dialect needs it.
841
+ *
842
+ * A rebuild swaps the table out and back, and two of the three dialects refuse
843
+ * to do that while a view still names the table — SQLite at the rename, Postgres
844
+ * at the drop. `viewsBlockTableRebuild` carries which, and why; MySQL is the one
845
+ * that does not care and skips this entirely.
846
+ *
847
+ * Views hold no data and `syncViewsAndTablesPhase` recreates every declared one
848
+ * a moment later, so dropping them first costs nothing — it is the same "drop
849
+ * and recreate" the engine already does when a view's body changes.
850
+ *
851
+ * Only when something is actually being rebuilt: a sync with no rebuilds should
852
+ * not churn views, and `CREATE VIEW` has no `IF NOT EXISTS`, so a needless drop
853
+ * would be a needless recreate.
854
+ */
855
+ async function dropViewsForRebuildPhase(
856
+ tx: SQLAdapter,
857
+ plan: SyncPlan,
858
+ constraints: SyncTypes.DBConstraints,
859
+ ) {
860
+ if (!tx.viewsBlockTableRebuild) return
861
+ if (!plan.tablesToRebuild.size) return
862
+ for (const [name, cols] of Object.entries(constraints)) {
863
+ if (!(cols as SyncTypes.TableConstraints)._view) continue
864
+ await tx.drop('VIEW', Case.snake(name))
865
+ }
866
+ }
867
+
868
+ async function syncViewsAndTablesPhase(
869
+ tx: SQLAdapter,
870
+ constraints: SyncTypes.DBConstraints,
871
+ MESSAGES: any,
872
+ tsFks: SyncTypes.DBForeignKeys = {},
873
+ ) {
874
+ // Parents before children: a foreign key needs the referenced table to exist,
875
+ // and an unordered CREATE simply fails.
876
+ for (const tableName of orderTablesByDependency(
877
+ Object.keys(constraints),
878
+ tsFks,
879
+ )) {
880
+ const cols = constraints[tableName]!
881
+ if ((cols as SyncTypes.TableConstraints)._view) {
882
+ MESSAGES.EXEC_SYNC_VIEW({ view: Case.snake(tableName) })
883
+ await tx.createView(
884
+ Case.snake(tableName),
885
+ (cols as SyncTypes.TableConstraints)._view!,
886
+ )
887
+ } else {
888
+ const colDefs = Object.entries(
889
+ cols as Record<string, SyncTypes.ColumnConstraint>,
890
+ )
891
+ .filter(([name]) => !['_oldTable', '_transform'].includes(name))
892
+ .map(
893
+ ([name, cons]) =>
894
+ ` ${tx.quote(Case.snake(name))} ${tx.colDef(cons, Case.snake(name))}`,
895
+ )
896
+ // Inline, not a later ALTER: SQLite has no
897
+ // `ALTER TABLE ADD FOREIGN KEY`, so this is the only spelling that works
898
+ // on all three dialects.
899
+ for (const fk of Object.values(tsFks)) {
900
+ if (Case.snake(fk.table) !== Case.snake(tableName)) continue
901
+ colDefs.push(` ${tx.foreignKeyClause(fk)}`)
902
+ }
903
+ MESSAGES.EXEC_SYNC_CONS({ table: Case.snake(tableName) })
904
+ await tx.createTable(Case.snake(tableName), colDefs, true)
905
+ }
906
+ }
907
+ }
908
+
909
+ async function addIndexesPhase(
910
+ tx: SQLAdapter,
911
+ indexesToAdd: Map<string, SyncTypes.IndexConstraint>,
912
+ MESSAGES: any,
913
+ ) {
914
+ for (const [idxName, def] of indexesToAdd.entries()) {
915
+ MESSAGES.EXEC_ADD_INDEX({ type: def.type, name: idxName })
916
+ await tx.createIndex(
917
+ idxName,
918
+ Case.snake(def.table),
919
+ def.cols.map(Case.snake),
920
+ def.type === 'unique',
921
+ )
922
+ }
923
+ }
924
+
925
+ /**
926
+ * Foreign keys on tables that already existed.
927
+ *
928
+ * Only reachable where the dialect can ALTER one in. SQLite cannot, so its
929
+ * missing keys are handled by scheduling a table rebuild in the planner — the
930
+ * rebuild recreates the table through `createTable`, which emits them inline.
931
+ */
932
+ async function foreignKeysPhase(
933
+ tx: SQLAdapter,
934
+ fksToAdd: Map<string, SyncTypes.ForeignKeyInfo>,
935
+ fksToDrop: Map<string, SyncTypes.ForeignKeyInfo>,
936
+ MESSAGES: any,
937
+ ) {
938
+ if (!tx.supportsAlterForeignKey) return
939
+ for (const fk of fksToDrop.values()) {
940
+ MESSAGES.EXEC_DROP_FK?.({ table: fk.table, name: fk.name ?? '' })
941
+ await tx.dropForeignKey(fk)
942
+ }
943
+ for (const fk of fksToAdd.values()) {
944
+ MESSAGES.EXEC_ADD_FK?.({ table: fk.table, ref: fk.refTable })
945
+ await tx.addForeignKey(fk)
946
+ }
947
+ }
948
+
949
+ export async function executeSyncPlan(
950
+ tx: SQLAdapter,
951
+ plan: SyncPlan,
952
+ constraints: SyncTypes.DBConstraints,
953
+ indexesToDrop: Set<string>,
954
+ indexesToAdd: Map<string, SyncTypes.IndexConstraint>,
955
+ MESSAGES: any,
956
+ tsFks: SyncTypes.DBForeignKeys = {},
957
+ fksToAdd: Map<string, SyncTypes.ForeignKeyInfo> = new Map(),
958
+ fksToDrop: Map<string, SyncTypes.ForeignKeyInfo> = new Map(),
959
+ ) {
960
+ await dropIndexesPhase(tx, indexesToDrop, MESSAGES)
961
+ await renameTablesPhase(tx, plan, MESSAGES)
962
+ await renameColumnsPhase(tx, plan, MESSAGES)
963
+ await dropTablesPhase(tx, plan, MESSAGES)
964
+ await dropColumnsPhase(tx, plan, MESSAGES)
965
+ await addColumnsPhase(tx, plan, MESSAGES)
966
+ await dropViewsForRebuildPhase(tx, plan, constraints)
967
+ await rebuildTablesPhase(tx, plan, constraints, MESSAGES, tsFks)
968
+ await syncViewsAndTablesPhase(tx, constraints, MESSAGES, tsFks)
969
+ await addIndexesPhase(tx, indexesToAdd, MESSAGES)
970
+ // Last, so every table a key could reference already exists.
971
+ await foreignKeysPhase(tx, fksToAdd, fksToDrop, MESSAGES)
972
+ }
973
+
974
+ export function hasOldWrappers(constraints: SyncTypes.DBConstraints) {
975
+ return Object.values(constraints).some(
976
+ tObj =>
977
+ tObj?._oldTable ||
978
+ tObj?._transform ||
979
+ Object.values(tObj as object).some(
980
+ c => (c as any)?._oldColumn || (c as any)?._transform,
981
+ ),
982
+ )
983
+ }
984
+
985
+ /** The `foreign()` declarations, normalised into the shape the diff uses. */
986
+ export function collectForeignKeys(
987
+ tsIndexes: SyncTypes.DBIndexes,
988
+ ): SyncTypes.DBForeignKeys {
989
+ const out: SyncTypes.DBForeignKeys = {}
990
+ for (const [name, idx] of Object.entries(tsIndexes)) {
991
+ if ((idx as any)?.type !== 'foreign') continue
992
+ const fk = idx as any
993
+ if (!fk.refTable || !fk.refCols?.length) continue
994
+ const info: SyncTypes.ForeignKeyInfo = {
995
+ table: fk.table,
996
+ cols: fk.cols,
997
+ refTable: fk.refTable,
998
+ refCols: fk.refCols,
999
+ name,
1000
+ onDelete: fk.onDelete,
1001
+ onUpdate: fk.onUpdate,
1002
+ }
1003
+ out[SQLAdapter.foreignKeyId(info)] = info
1004
+ }
1005
+ return out
1006
+ }
1007
+
1008
+ /**
1009
+ * Which foreign keys to add and which to drop.
1010
+ *
1011
+ * Keyed by the tuple, so a constraint the database named itself still matches
1012
+ * the declaration that produced it — including on SQLite, which reports no name
1013
+ * at all.
1014
+ */
1015
+ export function calculateForeignKeyDiff(
1016
+ dbFks: SyncTypes.DBForeignKeys,
1017
+ tsFks: SyncTypes.DBForeignKeys,
1018
+ tablesToRebuild: Set<string>,
1019
+ /**
1020
+ * Tables that already exist, so a key on a table being *created* is left out.
1021
+ *
1022
+ * `CREATE TABLE` emits its foreign keys inline — the only spelling SQLite
1023
+ * has. Counting those as "to add" made MySQL and Postgres ALTER in a
1024
+ * constraint that already existed, and made SQLite schedule a rebuild of a
1025
+ * table that did not exist yet: a fresh `db:sync` announced
1026
+ * "Tables to rebuild: posts" against an empty database.
1027
+ *
1028
+ * Phrased as "being created" rather than "already exists" deliberately. The
1029
+ * inverse defaults to an *empty* set, which reads as "nothing exists" and so
1030
+ * suppressed every key precisely when the database was new — the case that
1031
+ * exposed this in the first place.
1032
+ */
1033
+ tablesBeingCreated: Set<string> = new Set(),
1034
+ ) {
1035
+ const fksToAdd = new Map<string, SyncTypes.ForeignKeyInfo>()
1036
+ const fksToDrop = new Map<string, SyncTypes.ForeignKeyInfo>()
1037
+
1038
+ // `NO ACTION` on both sides of the comparison, because that is what every
1039
+ // dialect reports for a key declared without one — so an omitted action and
1040
+ // an explicit `NO ACTION` must not read as a difference.
1041
+ const act = (a?: string) => a ?? 'NO ACTION'
1042
+ const sameActions = (
1043
+ a: SyncTypes.ForeignKeyInfo,
1044
+ b: SyncTypes.ForeignKeyInfo,
1045
+ ) =>
1046
+ act(a.onDelete) === act(b.onDelete) && act(a.onUpdate) === act(b.onUpdate)
1047
+
1048
+ for (const [id, fk] of Object.entries(tsFks)) {
1049
+ // A rebuilt table is recreated from the constraints, foreign keys included,
1050
+ // so adding one separately would duplicate it.
1051
+ if (tablesToRebuild.has(Case.snake(fk.table))) continue
1052
+ if (tablesBeingCreated.has(Case.snake(fk.table))) continue
1053
+
1054
+ const existing = dbFks[id]
1055
+ if (existing) {
1056
+ // Same columns, different behaviour. No dialect alters a referential
1057
+ // action in place, so the constraint is replaced — and the drop carries
1058
+ // the name the *database* gave it, which is not necessarily the one we
1059
+ // would generate for it.
1060
+ if (!sameActions(existing, fk)) {
1061
+ fksToDrop.set(id, existing)
1062
+ fksToAdd.set(id, fk)
1063
+ }
1064
+ continue
1065
+ }
1066
+ fksToAdd.set(id, fk)
1067
+ }
1068
+ for (const [id, fk] of Object.entries(dbFks)) {
1069
+ if (tsFks[id] || tablesToRebuild.has(Case.snake(fk.table))) continue
1070
+ fksToDrop.set(id, fk)
1071
+ }
1072
+ return { fksToAdd, fksToDrop }
1073
+ }
1074
+
1075
+ /**
1076
+ * Table names ordered so a parent is always created before its children.
1077
+ *
1078
+ * Not cosmetic: a foreign key requires the referenced table to exist, so an
1079
+ * unordered CREATE fails outright — verified against Postgres, which answers
1080
+ * `relation "o_parent" does not exist`. Dropping runs in reverse for the
1081
+ * mirror-image reason.
1082
+ *
1083
+ * A cycle cannot be ordered at all. Rather than loop forever or drop tables,
1084
+ * the remainder is appended in declaration order: the foreign key that closes
1085
+ * the cycle then fails loudly at the database, which is the honest outcome —
1086
+ * breaking it needs a deferred constraint, which no dialect here spells alike.
1087
+ */
1088
+ export function orderTablesByDependency(
1089
+ tables: string[],
1090
+ tsFks: SyncTypes.DBForeignKeys,
1091
+ ): string[] {
1092
+ const deps = new Map<string, Set<string>>()
1093
+ for (const t of tables) deps.set(Case.snake(t), new Set())
1094
+ for (const fk of Object.values(tsFks)) {
1095
+ const child = Case.snake(fk.table)
1096
+ const parent = Case.snake(fk.refTable)
1097
+ // A self-reference is satisfied by the table's own CREATE.
1098
+ if (child === parent) continue
1099
+ if (deps.has(child) && deps.has(parent)) deps.get(child)!.add(parent)
1100
+ }
1101
+
1102
+ const ordered: string[] = []
1103
+ const done = new Set<string>()
1104
+ let progressed = true
1105
+ while (progressed && done.size < tables.length) {
1106
+ progressed = false
1107
+ for (const t of tables) {
1108
+ const snake = Case.snake(t)
1109
+ if (done.has(snake)) continue
1110
+ const unmet = [...(deps.get(snake) ?? [])].some(d => !done.has(d))
1111
+ if (unmet) continue
1112
+ ordered.push(t)
1113
+ done.add(snake)
1114
+ progressed = true
1115
+ }
1116
+ }
1117
+ for (const t of tables) if (!done.has(Case.snake(t))) ordered.push(t)
1118
+ return ordered
1119
+ }