@bakery-framework/orm 2.0.0-alpha.3 → 2.0.0-alpha.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakery-framework/orm",
3
- "version": "2.0.0-alpha.3",
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.3"
57
+ "@bakery-framework/core": "^2.0.0-alpha.5"
58
58
  }
59
59
  }
@@ -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
+ }
@@ -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
- logPlannedChanges,
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