@bakery-framework/orm 2.0.0-alpha.4 → 2.0.0-alpha.6

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