@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.
- package/package.json +2 -2
- package/src/sync/diff.ts +339 -0
- package/src/sync/engine.ts +13 -6
- package/src/sync/execute.ts +522 -0
- package/src/sync/plan.ts +148 -0
- package/src/sync/rename.ts +188 -0
- package/src/sync/types.ts +46 -0
- package/src/sync/helpers.ts +0 -1145
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakery-framework/orm",
|
|
3
|
-
"version": "2.0.0-alpha.
|
|
3
|
+
"version": "2.0.0-alpha.5",
|
|
4
4
|
"description": "Bakery database layer: adapters, query builder, schema sync and backup.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bakery",
|
|
@@ -54,6 +54,6 @@
|
|
|
54
54
|
"bun": ">=1.3.14"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@bakery-framework/core": "^2.0.0-alpha.
|
|
57
|
+
"@bakery-framework/core": "^2.0.0-alpha.5"
|
|
58
58
|
}
|
|
59
59
|
}
|
package/src/sync/diff.ts
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import type { Logger } from '@bakery-framework/core/logger'
|
|
2
|
+
import { Case } from '@bakery-framework/core/utils'
|
|
3
|
+
import { findBestMatchAndPrompt } from './rename'
|
|
4
|
+
import type * as SyncTypes from './types'
|
|
5
|
+
import { normalizeViewBody } from './view-sql'
|
|
6
|
+
|
|
7
|
+
type SyncPlan = SyncTypes.SyncPlan
|
|
8
|
+
|
|
9
|
+
function diffColumnMismatch(
|
|
10
|
+
plan: SyncPlan,
|
|
11
|
+
dbName: string,
|
|
12
|
+
camelCol: string,
|
|
13
|
+
tsCol: any,
|
|
14
|
+
dbCol: any,
|
|
15
|
+
MESSAGES: any,
|
|
16
|
+
) {
|
|
17
|
+
const tsNullable = tsCol.primary ? false : tsCol.nullable === true
|
|
18
|
+
const dbNullable = dbCol.primary ? false : dbCol.nullable === true
|
|
19
|
+
const tsDefault = tsCol.default === undefined ? null : tsCol.default
|
|
20
|
+
const dbDefault = dbCol.default === undefined ? null : dbCol.default
|
|
21
|
+
const isTypeMatch =
|
|
22
|
+
tsCol.type === dbCol.type ||
|
|
23
|
+
(tsCol.type === 'boolean' && dbCol.type === 'integer')
|
|
24
|
+
const norm = (v: any) =>
|
|
25
|
+
v === null
|
|
26
|
+
? 'null'
|
|
27
|
+
: String(v)
|
|
28
|
+
.replace(/^\(+|\)+$/g, '')
|
|
29
|
+
.trim()
|
|
30
|
+
|
|
31
|
+
// Width joins the diff, so widening a Varchar migrates instead of silently
|
|
32
|
+
// doing nothing. It stayed out until all three adapters could be *measured*
|
|
33
|
+
// reporting it back exactly; see `SQLAdapter.sizedTextLength` for the MySQL
|
|
34
|
+
// TEXT trap that made this dangerous to add blind.
|
|
35
|
+
//
|
|
36
|
+
// Driven by the *schema* side only. When the schema declares a width, any
|
|
37
|
+
// other answer from the database differs — including no width at all, which
|
|
38
|
+
// is a real `TEXT` column that should become `VARCHAR(n)`. Requiring both
|
|
39
|
+
// sides to be sized meant sizing an existing TEXT column silently did
|
|
40
|
+
// nothing, and `db:sync` then reported a perfectly synced database whose
|
|
41
|
+
// columns did not match the schema it had just read.
|
|
42
|
+
//
|
|
43
|
+
// Converges because all three dialects report a `VARCHAR` width back exactly
|
|
44
|
+
// (measured, see `SQLAdapter.sizedTextLength`): after one rebuild the two
|
|
45
|
+
// agree. A column that reports *no* width really is unsized.
|
|
46
|
+
//
|
|
47
|
+
// When the schema declares no width, nothing differs — `Field.Text()` against
|
|
48
|
+
// an existing `VARCHAR` is not a request to shrink it.
|
|
49
|
+
const lengthDiffers =
|
|
50
|
+
typeof tsCol.length === 'number' && tsCol.length !== dbCol.length
|
|
51
|
+
|
|
52
|
+
// Enum members join the diff, so changing them migrates instead of silently
|
|
53
|
+
// doing nothing — but **only when the current state came from the ledger**.
|
|
54
|
+
//
|
|
55
|
+
// `_enum` is emitted as an inline `CHECK (col IN (...))` by all three
|
|
56
|
+
// dialects, and all three *will* report that constraint back — in three
|
|
57
|
+
// incompatible shapes. Measured:
|
|
58
|
+
//
|
|
59
|
+
// sqlite CHECK (status IN ('draft','live')) in the table DDL
|
|
60
|
+
// mysql (`status` in (_utf8mb4'draft',_utf8mb4'live')) charset prefixes
|
|
61
|
+
// pgsql CHECK (((status)::text = ANY ((ARRAY[...]))) re-rendered
|
|
62
|
+
//
|
|
63
|
+
// Postgres does not store the text it was given, it re-renders a parsed
|
|
64
|
+
// expression — the same trap that turned `EXTRACT` into `date_part` and
|
|
65
|
+
// rebuilt a table on every sync forever. Three parsers, each an opportunity
|
|
66
|
+
// for that bug, is the wrong trade when the ledger already holds the members
|
|
67
|
+
// exactly as declared.
|
|
68
|
+
//
|
|
69
|
+
// So under introspection this stays out of the diff. A schema-side-only
|
|
70
|
+
// comparison would find `_enum` on one side and nothing on the other, differ
|
|
71
|
+
// every time, and rebuild the table on every sync — which is precisely what
|
|
72
|
+
// the `length` note above says it waited to rule out before shipping.
|
|
73
|
+
const enumDiffers =
|
|
74
|
+
plan.ledgerSource === 'ledger' &&
|
|
75
|
+
!Bun.deepEquals(tsCol._enum ?? null, dbCol._enum ?? null)
|
|
76
|
+
|
|
77
|
+
if (
|
|
78
|
+
!isTypeMatch ||
|
|
79
|
+
tsNullable !== dbNullable ||
|
|
80
|
+
lengthDiffers ||
|
|
81
|
+
enumDiffers ||
|
|
82
|
+
norm(tsDefault) !== norm(dbDefault)
|
|
83
|
+
) {
|
|
84
|
+
MESSAGES.COL_MISMATCH({ table: dbName, column: camelCol })
|
|
85
|
+
MESSAGES.COL_MISMATCH_TS({
|
|
86
|
+
tsType: tsCol.type,
|
|
87
|
+
tsNullable: String(tsNullable),
|
|
88
|
+
tsDefault: String(tsDefault),
|
|
89
|
+
})
|
|
90
|
+
MESSAGES.COL_MISMATCH_DB({
|
|
91
|
+
dbType: dbCol.type,
|
|
92
|
+
dbNullable: String(dbNullable),
|
|
93
|
+
dbDefault: String(dbDefault),
|
|
94
|
+
})
|
|
95
|
+
plan.tablesToRebuild.add(dbName)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function resolveColumnRenames(
|
|
100
|
+
plan: SyncPlan,
|
|
101
|
+
camelTable: string,
|
|
102
|
+
dbName: string,
|
|
103
|
+
constraints: any,
|
|
104
|
+
unmappedDbCols: Set<string>,
|
|
105
|
+
unmappedTsCols: Set<string>,
|
|
106
|
+
existingDbCamelCols: Set<string>,
|
|
107
|
+
) {
|
|
108
|
+
for (const newCamel of [...unmappedTsCols]) {
|
|
109
|
+
const tsColObj = constraints[camelTable][newCamel]
|
|
110
|
+
if (!tsColObj?._oldColumn) continue
|
|
111
|
+
const oldCamel = Case.camel(tsColObj._oldColumn)
|
|
112
|
+
if (!existingDbCamelCols.has(oldCamel)) continue
|
|
113
|
+
plan.columnsToRename.push({
|
|
114
|
+
table: dbName,
|
|
115
|
+
oldColumn: Case.snake(tsColObj._oldColumn),
|
|
116
|
+
newColumn: Case.snake(newCamel),
|
|
117
|
+
})
|
|
118
|
+
unmappedDbCols.delete(Case.snake(tsColObj._oldColumn))
|
|
119
|
+
unmappedTsCols.delete(newCamel)
|
|
120
|
+
if (plan.dbConstraintsForDiff[camelTable]?.[oldCamel]) {
|
|
121
|
+
plan.dbConstraintsForDiff[camelTable][newCamel] =
|
|
122
|
+
plan.dbConstraintsForDiff[camelTable][oldCamel]
|
|
123
|
+
delete plan.dbConstraintsForDiff[camelTable][oldCamel]
|
|
124
|
+
}
|
|
125
|
+
existingDbCamelCols.delete(oldCamel)
|
|
126
|
+
existingDbCamelCols.add(newCamel)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function resolveUnmappedDbCols(
|
|
131
|
+
plan: SyncPlan,
|
|
132
|
+
camelTable: string,
|
|
133
|
+
dbName: string,
|
|
134
|
+
logger: Logger,
|
|
135
|
+
unmappedDbCols: Set<string>,
|
|
136
|
+
unmappedTsCols: Set<string>,
|
|
137
|
+
existingDbCamelCols: Set<string>,
|
|
138
|
+
) {
|
|
139
|
+
for (const oldDbCol of [...unmappedDbCols]) {
|
|
140
|
+
const bestMatch = unmappedTsCols.size
|
|
141
|
+
? findBestMatchAndPrompt(
|
|
142
|
+
oldDbCol,
|
|
143
|
+
unmappedTsCols,
|
|
144
|
+
'column',
|
|
145
|
+
dbName,
|
|
146
|
+
logger,
|
|
147
|
+
)
|
|
148
|
+
: null
|
|
149
|
+
if (bestMatch) {
|
|
150
|
+
plan.columnsToRename.push({
|
|
151
|
+
table: dbName,
|
|
152
|
+
oldColumn: oldDbCol,
|
|
153
|
+
newColumn: Case.snake(bestMatch),
|
|
154
|
+
})
|
|
155
|
+
unmappedDbCols.delete(oldDbCol)
|
|
156
|
+
unmappedTsCols.delete(bestMatch)
|
|
157
|
+
const oldCamel = Case.camel(oldDbCol)
|
|
158
|
+
if (plan.dbConstraintsForDiff[camelTable]?.[oldCamel]) {
|
|
159
|
+
plan.dbConstraintsForDiff[camelTable][bestMatch] =
|
|
160
|
+
plan.dbConstraintsForDiff[camelTable][oldCamel]
|
|
161
|
+
delete plan.dbConstraintsForDiff[camelTable][oldCamel]
|
|
162
|
+
}
|
|
163
|
+
existingDbCamelCols.delete(oldCamel)
|
|
164
|
+
existingDbCamelCols.add(bestMatch)
|
|
165
|
+
} else {
|
|
166
|
+
plan.columnsToDrop.push({ table: dbName, column: oldDbCol })
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function diffTableColumns(
|
|
172
|
+
plan: SyncPlan,
|
|
173
|
+
camelTable: string,
|
|
174
|
+
dbName: string,
|
|
175
|
+
constraints: any,
|
|
176
|
+
logger: Logger,
|
|
177
|
+
MESSAGES: any,
|
|
178
|
+
) {
|
|
179
|
+
const existingDbCamelCols = new Set(
|
|
180
|
+
Object.keys(plan.dbConstraintsForDiff[camelTable] || {}).filter(
|
|
181
|
+
k => k !== '_view',
|
|
182
|
+
),
|
|
183
|
+
)
|
|
184
|
+
const unmappedDbCols = new Set(
|
|
185
|
+
[...existingDbCamelCols]
|
|
186
|
+
.filter(c => !constraints[camelTable][c])
|
|
187
|
+
.map(Case.snake),
|
|
188
|
+
)
|
|
189
|
+
const unmappedTsCols = new Set(
|
|
190
|
+
Object.keys(constraints[camelTable]).filter(
|
|
191
|
+
c =>
|
|
192
|
+
!existingDbCamelCols.has(c) &&
|
|
193
|
+
!['_view', '_oldTable', '_transform'].includes(c),
|
|
194
|
+
),
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
resolveColumnRenames(
|
|
198
|
+
plan,
|
|
199
|
+
camelTable,
|
|
200
|
+
dbName,
|
|
201
|
+
constraints,
|
|
202
|
+
unmappedDbCols,
|
|
203
|
+
unmappedTsCols,
|
|
204
|
+
existingDbCamelCols,
|
|
205
|
+
)
|
|
206
|
+
resolveUnmappedDbCols(
|
|
207
|
+
plan,
|
|
208
|
+
camelTable,
|
|
209
|
+
dbName,
|
|
210
|
+
logger,
|
|
211
|
+
unmappedDbCols,
|
|
212
|
+
unmappedTsCols,
|
|
213
|
+
existingDbCamelCols,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
plan.columnsToAdd.push(
|
|
217
|
+
...[...unmappedTsCols].map(newCamel => ({
|
|
218
|
+
table: dbName,
|
|
219
|
+
column: Case.snake(newCamel),
|
|
220
|
+
def: constraints[camelTable][newCamel],
|
|
221
|
+
})),
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
for (const camelCol of existingDbCamelCols) {
|
|
225
|
+
if (unmappedDbCols.has(Case.snake(camelCol))) continue
|
|
226
|
+
const tsCol = constraints[camelTable][camelCol]
|
|
227
|
+
const dbCol = plan.dbConstraintsForDiff[camelTable]?.[camelCol]
|
|
228
|
+
if (tsCol && dbCol) {
|
|
229
|
+
diffColumnMismatch(plan, dbName, camelCol, tsCol, dbCol, MESSAGES)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function diffViewStrings(
|
|
235
|
+
plan: SyncPlan,
|
|
236
|
+
camelTable: string,
|
|
237
|
+
dbName: string,
|
|
238
|
+
constraints: any,
|
|
239
|
+
database?: string,
|
|
240
|
+
): boolean {
|
|
241
|
+
// Both sides through the same canonicaliser, which is the only thing that
|
|
242
|
+
// makes a text comparison viable: you write `SELECT id FROM users` and MySQL
|
|
243
|
+
// returns it fully qualified, fully quoted and aliased column by column.
|
|
244
|
+
//
|
|
245
|
+
// Symmetry is the whole requirement. Normalising the *generated file* while
|
|
246
|
+
// comparing raw — or stripping the schema on one side only — recreates the
|
|
247
|
+
// view on every sync, which is the same churn the column diff has hit twice.
|
|
248
|
+
const tsViewStr = normalizeViewBody(
|
|
249
|
+
String(constraints[camelTable]._view || ''),
|
|
250
|
+
database,
|
|
251
|
+
)
|
|
252
|
+
const dbViewStr = normalizeViewBody(
|
|
253
|
+
String(plan.dbConstraintsForDiff[camelTable]?._view || ''),
|
|
254
|
+
database,
|
|
255
|
+
)
|
|
256
|
+
if (tsViewStr || dbViewStr) {
|
|
257
|
+
if (tsViewStr !== dbViewStr) plan.viewsToUpdate.push(dbName)
|
|
258
|
+
if (tsViewStr && !dbViewStr) plan.tablesToDrop.push(dbName)
|
|
259
|
+
return true
|
|
260
|
+
}
|
|
261
|
+
return false
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The view lifecycle: create, recreate, drop.
|
|
266
|
+
*
|
|
267
|
+
* Views were invisible to the planner. `initDbTablesMap` skips `_view` entries,
|
|
268
|
+
* and every existing comparison iterates that map — so a declared view never
|
|
269
|
+
* reached `diffViewStrings`, and nothing about a view ever reached
|
|
270
|
+
* `hasChanges`. The consequences, all three measured:
|
|
271
|
+
*
|
|
272
|
+
* - a **new** view was never planned,
|
|
273
|
+
* - an **edited** `SELECT` was never detected, so a view could not be changed,
|
|
274
|
+
* - a view the schema no longer declares was never dropped.
|
|
275
|
+
*
|
|
276
|
+
* They were invisible rather than broken: `syncViewsAndTablesPhase` recreates
|
|
277
|
+
* every declared view whenever a sync happens to run, so a view kept up to date
|
|
278
|
+
* as a side effect of unrelated work. With nothing else to do, `db:sync`
|
|
279
|
+
* reported a perfectly synced database and left the view alone.
|
|
280
|
+
*
|
|
281
|
+
* Bodies are compared through `normalizeViewBody` on both sides. That converges
|
|
282
|
+
* on SQLite, which stores the text verbatim, and via the ledger everywhere —
|
|
283
|
+
* the ledger records what was *applied*, so it holds the authored SELECT.
|
|
284
|
+
* Diffing against live introspection on MySQL or Postgres will still see a
|
|
285
|
+
* difference, because both re-render the body (MySQL re-qualifies every column,
|
|
286
|
+
* Postgres adds parentheses), and no amount of text normalisation short of a
|
|
287
|
+
* parser fixes that. It costs a recreate, and a view holds no data.
|
|
288
|
+
*/
|
|
289
|
+
export function diffViews(
|
|
290
|
+
plan: SyncPlan,
|
|
291
|
+
constraints: SyncTypes.DBConstraints,
|
|
292
|
+
database?: string,
|
|
293
|
+
) {
|
|
294
|
+
const dbSide = plan.dbConstraintsForDiff
|
|
295
|
+
const declared = new Set<string>()
|
|
296
|
+
|
|
297
|
+
for (const [name, cols] of Object.entries(constraints)) {
|
|
298
|
+
const body = (cols as SyncTypes.TableConstraints)?._view
|
|
299
|
+
if (!body) continue
|
|
300
|
+
const camel = Case.camel(name)
|
|
301
|
+
declared.add(camel)
|
|
302
|
+
|
|
303
|
+
const dbBody = (dbSide[camel] as SyncTypes.TableConstraints | undefined)
|
|
304
|
+
?._view
|
|
305
|
+
const want = normalizeViewBody(String(body), database)
|
|
306
|
+
const have = dbBody ? normalizeViewBody(String(dbBody), database) : null
|
|
307
|
+
if (have !== want) plan.viewsToUpdate.push(Case.snake(name))
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
for (const [name, cols] of Object.entries(dbSide)) {
|
|
311
|
+
if (!(cols as SyncTypes.TableConstraints)?._view) continue
|
|
312
|
+
if (declared.has(Case.camel(name))) continue
|
|
313
|
+
// Same rule tables follow: what the schema does not declare, the database
|
|
314
|
+
// does not keep. Dropping is announced before it happens.
|
|
315
|
+
plan.tablesToDrop.push(Case.snake(name))
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function diffTableViewsAndColumns(
|
|
320
|
+
plan: SyncPlan,
|
|
321
|
+
dbTables: any,
|
|
322
|
+
constraints: any,
|
|
323
|
+
logger: Logger,
|
|
324
|
+
MESSAGES: any,
|
|
325
|
+
database?: string,
|
|
326
|
+
) {
|
|
327
|
+
for (const camelTable of Object.keys(dbTables)) {
|
|
328
|
+
if (!constraints[camelTable]) continue
|
|
329
|
+
const dbName = dbTables[camelTable]!.dbName
|
|
330
|
+
if (
|
|
331
|
+
plan.tablesToRebuild.has(dbName) ||
|
|
332
|
+
plan.tablesToRebuild.has(Case.snake(camelTable))
|
|
333
|
+
)
|
|
334
|
+
continue
|
|
335
|
+
if (diffViewStrings(plan, camelTable, dbName, constraints, database))
|
|
336
|
+
continue
|
|
337
|
+
diffTableColumns(plan, camelTable, dbName, constraints, logger, MESSAGES)
|
|
338
|
+
}
|
|
339
|
+
}
|
package/src/sync/engine.ts
CHANGED
|
@@ -3,16 +3,14 @@ import { Case } from '@bakery-framework/core/utils'
|
|
|
3
3
|
import type { SQLAdapter } from '../adapters/base'
|
|
4
4
|
import { SchemaBuilder } from './builder'
|
|
5
5
|
import {
|
|
6
|
-
buildSyncPlan,
|
|
7
6
|
calculateForeignKeyDiff,
|
|
8
|
-
calculateIndexDiff,
|
|
9
7
|
collectForeignKeys,
|
|
10
8
|
executeSyncPlan,
|
|
11
9
|
hasOldWrappers,
|
|
12
|
-
|
|
13
|
-
} from './helpers'
|
|
10
|
+
} from './execute'
|
|
14
11
|
import { writeLedger } from './ledger'
|
|
15
12
|
import type { SchemaLayout } from './load'
|
|
13
|
+
import { buildSyncPlan, calculateIndexDiff, logPlannedChanges } from './plan'
|
|
16
14
|
import type * as SyncTypes from './types'
|
|
17
15
|
|
|
18
16
|
// prettier-ignore
|
|
@@ -70,6 +68,15 @@ export const syncMsgs = {
|
|
|
70
68
|
EXEC_SYNC_VIEW: 'D Syncing view: %y{view}%*...',
|
|
71
69
|
EXEC_SYNC_CONS: 'D Syncing constraints for: %y{table}%*...',
|
|
72
70
|
EXEC_ADD_INDEX: 'I Creating %y{type}%* index: %g{name}%*...',
|
|
71
|
+
// Declared late. `executeSyncPlan` has always called these two, and
|
|
72
|
+
// `messageLogger`'s proxy answers an undeclared key with a live emitter that
|
|
73
|
+
// prints `E Error message not found: EXEC_ADD_FK` — so every foreign key
|
|
74
|
+
// added or dropped on a dialect that can ALTER one logged an *error* where
|
|
75
|
+
// its ten sibling operations logged progress. Only reachable with
|
|
76
|
+
// `supportsAlterForeignKey`, which is why SQLite-only local runs never
|
|
77
|
+
// showed it.
|
|
78
|
+
EXEC_DROP_FK: 'I Dropping foreign key: %r{table}.{name}%*...',
|
|
79
|
+
EXEC_ADD_FK: 'I Adding foreign key: %g{table}%* -> %g{ref}%*...',
|
|
73
80
|
CATCH_UP_SUCCESS: 'I %gDatabase successfully caught up%*!',
|
|
74
81
|
PROD_FORCE_REQUIRED: 'E %rProduction requires %y--force-sync%* to proceed.%*',
|
|
75
82
|
BACKUP_REQUIRED:
|
|
@@ -274,7 +281,7 @@ export class SyncEngine {
|
|
|
274
281
|
{
|
|
275
282
|
await using _session = new SyncSession(adapter)
|
|
276
283
|
await adapter.transaction(tx =>
|
|
277
|
-
executeSyncPlan(
|
|
284
|
+
executeSyncPlan({
|
|
278
285
|
tx,
|
|
279
286
|
plan,
|
|
280
287
|
constraints,
|
|
@@ -284,7 +291,7 @@ export class SyncEngine {
|
|
|
284
291
|
tsFks,
|
|
285
292
|
fksToAdd,
|
|
286
293
|
fksToDrop,
|
|
287
|
-
),
|
|
294
|
+
}),
|
|
288
295
|
)
|
|
289
296
|
}
|
|
290
297
|
|