@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,399 @@
1
+ import { Logger, messageLogger } from '@bakery-framework/core/logger'
2
+ import { Case } from '@bakery-framework/core/utils'
3
+ import type { SQLAdapter } from '../adapters/base'
4
+ import { SchemaBuilder } from './builder'
5
+ import {
6
+ buildSyncPlan,
7
+ calculateForeignKeyDiff,
8
+ calculateIndexDiff,
9
+ collectForeignKeys,
10
+ executeSyncPlan,
11
+ hasOldWrappers,
12
+ logPlannedChanges,
13
+ } from './helpers'
14
+ import { writeLedger } from './ledger'
15
+ import type { SchemaLayout } from './load'
16
+ import type * as SyncTypes from './types'
17
+
18
+ // prettier-ignore
19
+ export const syncMsgs = {
20
+ GEN_TYPES: 'I Generating types...',
21
+ SYNC_SUCCESS: 'I %gschema.ts successfully synced%* to Database!',
22
+ INVALID_SCHEMA: 'W %yschema.ts is invalid or corrupt. Treating as new.%*',
23
+ NO_DBINFO: 'W %yDBInfo namespace not found in schema.ts!%*',
24
+ PERFECT_SYNC: 'I %gschema.ts is perfectly synced%* with Database!',
25
+ DB_NEWER: 'I Database is newer than TS. Generating types...',
26
+ TS_NEWER: 'I %yschema.ts is newer! Syncing to the database...%*',
27
+ BACKUP_CREATED: 'I Created database backup: %y{file}%*',
28
+ SCHEMA_PRESERVED: 'I Preserved previous schema: %y{file}%*',
29
+ NO_CONSTRAINTS:
30
+ 'E Could not find %rDBInfo.constraints%* in schema.ts to run the reverse sync!',
31
+ COL_MISMATCH:
32
+ "W Table '%y{table}%*' needs rebuild because of column '%y{column}%*' mismatch:",
33
+ COL_MISMATCH_TS:
34
+ 'W - TS: type=%c{tsType}%*, nullable=%c{tsNullable}%*, default=%c{tsDefault}%*',
35
+ COL_MISMATCH_DB:
36
+ 'W - DB: type=%c{dbType}%*, nullable=%c{dbNullable}%*, default=%c{dbDefault}%*',
37
+ DANGER_ZONE: 'W %rDANGER ZONE: Destructive or major changes detected!%*',
38
+ DROP_TABLES: 'W Tables to drop: %r{tables}%*',
39
+ RENAME_TABLES: 'I Tables to rename: %y{tables}%*',
40
+ DROP_COLS: 'W Columns to drop: %r{cols}%*',
41
+ RENAME_COLS: 'I Columns to rename: %y{cols}%*',
42
+ ADD_COLS: 'I Columns to add: %g{cols}%*',
43
+ REBUILD_TABLES: 'W Tables to rebuild (schema modified): %r{tables}%*',
44
+ UPDATE_VIEWS: 'I Views to update/recreate: %y{views}%*',
45
+ DROP_INDEXES: 'I Indexes to drop: %r{indexes}%*',
46
+ ADD_INDEXES: 'I Indexes to add: %g{indexes}%*',
47
+ REVIEW_WARNING: 'W %yThese changes may affect data. Review carefully.%*',
48
+ SYNC_ABORTED: 'I %ySync aborted. Your data is safe!%*',
49
+ EXEC_RENAME_TABLE: 'I Renaming table: %y{oldName}%* -> %y{newName}%*...',
50
+ EXEC_RENAME_COL:
51
+ 'I Renaming column: %y{table}.{oldColumn}%* -> %y{newColumn}%*...',
52
+ EXEC_DROP_TABLE: 'I Dropping %y{type}%*: %r{table}%*...',
53
+ EXEC_DROP_COL: 'I Dropping column: %r{table}.{column}%*...',
54
+ EXEC_ADD_COL: 'I Adding column: %g{table}.{column}%*...',
55
+ EXEC_DROP_INDEX: 'I Dropping index: %r{idx}%*...',
56
+ EXEC_REBUILD:
57
+ 'I Rebuilding table to apply schema modifications: %y{table}%*...',
58
+ EXEC_SYNC_VIEW: 'D Syncing view: %y{view}%*...',
59
+ EXEC_SYNC_CONS: 'D Syncing constraints for: %y{table}%*...',
60
+ EXEC_ADD_INDEX: 'I Creating %y{type}%* index: %g{name}%*...',
61
+ CATCH_UP_SUCCESS: 'I %gDatabase successfully caught up%*!',
62
+ PROD_FORCE_REQUIRED: 'E %rProduction requires %y--force-sync%* to proceed.%*',
63
+ BACKUP_REQUIRED:
64
+ 'E %rAborting: this sync drops data but no backup was created. Fix the backup first.%*',
65
+ SCHEMA_GENERATED:
66
+ 'I Database is empty. Generated %yschema.ts%* with empty boilerplate.',
67
+ NOTHING_TO_SYNC: 'I Database and %yschema.ts%* are empty. Nothing to sync!',
68
+ OVERRIDE_SCHEMA:
69
+ 'I %yschema.ts contains _oldTable/_transform wrappers. Overriding file to match DB.%*',
70
+ FATAL_ERROR:
71
+ 'E %rFATAL ERROR: Sync failed! All changes have been safely rolled back. Detail: {error}%*',
72
+ VIEWS_SEEDED:
73
+ 'I Seeded %y{file}%* from the database. It is yours from now on — the generator will not overwrite it.',
74
+ VIEWS_KEPT:
75
+ 'I Left %y{file}%* alone: view interfaces are hand-owned. Delete it and re-run to reseed.',
76
+ LEDGER_DRIFT:
77
+ 'W %yDatabase has drifted from the last schema Bakery applied%*: {reason}. Diffing against live introspection instead of the ledger. Something changed this database outside Bakery — if that was not deliberate, check it before syncing.',
78
+ } as const
79
+
80
+ const logger = new Logger('db-sync')
81
+ export const MESSAGES = messageLogger(logger, syncMsgs)
82
+
83
+ /**
84
+ * Whether a destructive sync in this process needs an explicit `--force-sync`.
85
+ *
86
+ * The second half used to read `process.env.PROD === 'true'`, which could
87
+ * never be true: `core/init.ts` installs `PROD` on `process.env` with
88
+ * `Object.defineProperties` as a getter returning a **boolean**, so the guard
89
+ * on the most destructive operation the framework performs rested entirely on
90
+ * `NODE_ENV`. `import.meta.env` is the same object as `process.env` and
91
+ * `import.meta.env.PROD` is the framework's idiom for reading these flags.
92
+ *
93
+ * The dead term was **deleted rather than repaired**, and that is the whole
94
+ * decision here. `import.meta.env.PROD` means only "`--dev` is absent", and
95
+ * `db:sync` is a separate CLI invocation that never passes `--dev` — so for
96
+ * this caller the flag is a constant `true`, carrying no information about the
97
+ * environment at all. Activating it would not have made the guard smarter; it
98
+ * would have made the `isProd` branch unconditional and left `handleSafetyChecks`'s
99
+ * interactive `Proceed with sync?` unreachable for the one workflow it exists
100
+ * for. That is dead code traded for different dead code, plus a silent UX
101
+ * change to the documented way of applying a schema.
102
+ *
103
+ * `NODE_ENV` is the only term that actually says "deployment", which is what
104
+ * the guard is protecting against. Set it, and a destructive plan requires
105
+ * `--force-sync`; otherwise a human at a terminal gets asked.
106
+ *
107
+ * Exported for the regression test: the value is process-wide ambient state,
108
+ * and the alternative is driving `SyncEngine.run` far enough to reach a
109
+ * `process.exit`.
110
+ */
111
+ export function isProductionSync(): boolean {
112
+ return process.env.NODE_ENV === 'production'
113
+ }
114
+
115
+ class SyncSession implements AsyncDisposable {
116
+ constructor(private adapter: SQLAdapter) {}
117
+ async [Symbol.asyncDispose]() {
118
+ await (this.adapter as any).postSync?.(this.adapter)
119
+ }
120
+ }
121
+
122
+ export class SyncEngine {
123
+ protected constructor() {}
124
+
125
+ private static async checkEmptyConstraints(
126
+ adapter: SQLAdapter,
127
+ constraints: SyncTypes.DBConstraints,
128
+ genLocal: (c?: any) => Promise<void>,
129
+ schemaPath: string,
130
+ ): Promise<boolean> {
131
+ const schemaExists = await Bun.file(schemaPath).exists()
132
+ if (!schemaExists) {
133
+ await genLocal(constraints)
134
+ const dbConstraints = await adapter.getConstraints()
135
+ if (Object.keys(dbConstraints).length === 0) {
136
+ MESSAGES.SCHEMA_GENERATED()
137
+ }
138
+ return true
139
+ }
140
+ if (Object.keys(constraints).length) return false
141
+
142
+ MESSAGES.NO_CONSTRAINTS()
143
+ const dbConstraints = await adapter.getConstraints()
144
+
145
+ if (Object.keys(dbConstraints).length) {
146
+ MESSAGES.DB_NEWER()
147
+ await genLocal(constraints)
148
+ } else {
149
+ MESSAGES.NOTHING_TO_SYNC()
150
+ }
151
+
152
+ if (process.env.DEV_WATCHER_ACTIVE && Object.keys(dbConstraints).length) {
153
+ process.exit(42)
154
+ }
155
+ return true
156
+ }
157
+
158
+ private static evaluateChanges(
159
+ plan: any,
160
+ indexesToDrop: any,
161
+ indexesToAdd: any,
162
+ ) {
163
+ // Index and view changes are destructive too: any index present in the DB
164
+ // but absent from the schema is dropped, which silently removes indexes an
165
+ // operator added by hand in production.
166
+ // `viewsToUpdate` is deliberately not here, though it *is* a change below.
167
+ //
168
+ // Creating or recreating a view cannot lose anything: a view holds no data,
169
+ // and `createView` drops and recreates in one step. Counting it as
170
+ // destructive made a schema with a view demand `--force-sync` in production
171
+ // for no reason — and it matters more now that views are diffed at all,
172
+ // because MySQL and Postgres re-render a stored body, so a run that falls
173
+ // back to introspection sees one as changed every time.
174
+ //
175
+ // Dropping a view the schema no longer declares is a different act, and it
176
+ // lands in `tablesToDrop`, which is still dangerous.
177
+ const isDangerous = Boolean(
178
+ plan.tablesToDrop.length ||
179
+ plan.tablesToRename.length ||
180
+ plan.columnsToDrop.length ||
181
+ plan.columnsToRename.length ||
182
+ plan.tablesToRebuild.size ||
183
+ indexesToDrop.size,
184
+ )
185
+
186
+ const hasChanges = Boolean(
187
+ isDangerous ||
188
+ plan.unmappedTsTables.size ||
189
+ plan.columnsToAdd.length ||
190
+ plan.viewsToUpdate.length ||
191
+ indexesToDrop.size ||
192
+ indexesToAdd.size,
193
+ )
194
+
195
+ return { isDangerous, hasChanges }
196
+ }
197
+
198
+ private static handleSafetyChecks(
199
+ isDangerous: boolean,
200
+ argv: string[],
201
+ ): void {
202
+ if (!isDangerous) return
203
+
204
+ const isProd = isProductionSync()
205
+ const force = argv.includes('--force-sync')
206
+
207
+ if (!force)
208
+ logger.log(
209
+ "Tip: use '--choose=db', '--choose=ts', or '--dry-run'.",
210
+ 'info',
211
+ )
212
+
213
+ if (isProd && !force) {
214
+ MESSAGES.PROD_FORCE_REQUIRED()
215
+ process.exit(1)
216
+ }
217
+
218
+ if (!isProd && !force && !logger.confirm('Proceed with sync?')) {
219
+ MESSAGES.SYNC_ABORTED()
220
+ process.exit(0)
221
+ }
222
+ }
223
+
224
+ private static async executeSyncPipeline(
225
+ adapter: SQLAdapter,
226
+ plan: any,
227
+ constraints: SyncTypes.DBConstraints,
228
+ indexesToDrop: any,
229
+ indexesToAdd: any,
230
+ genLocal: (c?: any) => Promise<void>,
231
+ isDangerous = false,
232
+ tsFks: SyncTypes.DBForeignKeys = {},
233
+ fksToAdd: Map<string, SyncTypes.ForeignKeyInfo> = new Map(),
234
+ fksToDrop: Map<string, SyncTypes.ForeignKeyInfo> = new Map(),
235
+ tsIndexes: SyncTypes.DBIndexes = {},
236
+ ): Promise<void> {
237
+ const { backupDatabase } = await import('../backup')
238
+ const backedUp = await backupDatabase(adapter)
239
+
240
+ // Never run an irreversible migration without a recoverable copy.
241
+ if (isDangerous && !backedUp) {
242
+ MESSAGES.BACKUP_REQUIRED()
243
+ process.exit(1)
244
+ }
245
+
246
+ await (adapter as any).preSync?.(adapter)
247
+
248
+ {
249
+ await using _session = new SyncSession(adapter)
250
+ await adapter.transaction(tx =>
251
+ executeSyncPlan(
252
+ tx,
253
+ plan,
254
+ constraints,
255
+ indexesToDrop,
256
+ indexesToAdd,
257
+ MESSAGES,
258
+ tsFks,
259
+ fksToAdd,
260
+ fksToDrop,
261
+ ),
262
+ )
263
+ }
264
+
265
+ // After the transaction commits, never inside it: MySQL commits DDL
266
+ // implicitly, so there is no unit of work these two could share anyway, and
267
+ // writing the ledger first would claim a migration that had not happened.
268
+ // Best-effort by design — a sync that succeeded still succeeded, and a
269
+ // missing ledger only costs the next run its fast path.
270
+ // `tsIndexes`, not `adapter.getIndexes()`: the ledger records what was
271
+ // *applied*, and re-reading the database here would record whatever the
272
+ // dialect reports back — the exact normalisation problem the ledger exists
273
+ // to route around.
274
+ await writeLedger(adapter, constraints, tsIndexes)
275
+
276
+ MESSAGES.CATCH_UP_SUCCESS()
277
+
278
+ if (hasOldWrappers(constraints)) {
279
+ MESSAGES.OVERRIDE_SCHEMA()
280
+ await genLocal(constraints)
281
+ }
282
+ }
283
+
284
+ static async run(
285
+ adapter: SQLAdapter,
286
+ constraints: SyncTypes.DBConstraints,
287
+ tsIndexes: SyncTypes.DBIndexes,
288
+ schemaPath: string,
289
+ layout: SchemaLayout = 'file',
290
+ ): Promise<void> {
291
+ const genLocal = (c: any = {}) =>
292
+ SchemaBuilder.generate(adapter, schemaPath, MESSAGES, c, layout)
293
+ const isEmpty = await SyncEngine.checkEmptyConstraints(
294
+ adapter,
295
+ constraints,
296
+ genLocal,
297
+ schemaPath,
298
+ )
299
+ if (isEmpty) return
300
+ // No SQLite special case here. `adjustSqlitePlan` used to rebuild every
301
+ // renamed table and then discard `columnsToRename` wholesale whenever any
302
+ // column rename existed, which dated from SQLite before 3.25 (2018) having
303
+ // no ALTER TABLE … RENAME COLUMN. Bun ships 3.53. Measured against a real
304
+ // database, the special case lost a rename in a non-renamed table silently
305
+ // — reporting a perfect sync — and threw outright in the two cases where it
306
+ // did fire. See sync/engine.test.ts, which pins all four.
307
+ const plan = await buildSyncPlan(adapter, constraints, logger, MESSAGES)
308
+
309
+ // `buildSyncPlan` has always recorded which state it diffed against and
310
+ // nothing ever read it. Falling back to introspection is *correct* — see
311
+ // sync/ledger.ts — but doing it silently means a column somebody added by
312
+ // hand in production looks exactly like an ordinary run. "No ledger yet" is
313
+ // the normal state of a fresh database and is not drift.
314
+ if (
315
+ plan.ledgerSource === 'introspection' &&
316
+ plan.ledgerReason &&
317
+ plan.ledgerReason !== 'no ledger yet'
318
+ ) {
319
+ MESSAGES.LEDGER_DRIFT({ reason: plan.ledgerReason })
320
+ }
321
+
322
+ const dbIndexes = await adapter.getIndexes()
323
+ const { indexesToDrop, indexesToAdd } = calculateIndexDiff(
324
+ dbIndexes,
325
+ tsIndexes,
326
+ plan.tablesToRebuild,
327
+ )
328
+
329
+ const tsFks = collectForeignKeys(tsIndexes)
330
+ const dbFks = await adapter.getForeignKeys()
331
+ // A table being created carries its foreign keys inline from CREATE TABLE,
332
+ // so counting them here would double up.
333
+ const beingCreated = new Set([...plan.unmappedTsTables].map(Case.snake))
334
+ const { fksToAdd, fksToDrop } = calculateForeignKeyDiff(
335
+ dbFks,
336
+ tsFks,
337
+ plan.tablesToRebuild,
338
+ beingCreated,
339
+ )
340
+ // SQLite cannot ALTER a foreign key in or out — the constraint is part of
341
+ // the table definition — so those become table rebuilds, which recreate the
342
+ // table with the keys inline. Decided here rather than in the adapter so
343
+ // the printed plan states the work that will actually happen.
344
+ if (!adapter.supportsAlterForeignKey) {
345
+ for (const fk of [...fksToAdd.values(), ...fksToDrop.values()]) {
346
+ plan.tablesToRebuild.add(Case.snake(fk.table))
347
+ }
348
+ }
349
+
350
+ const { isDangerous, hasChanges: planChanged } = SyncEngine.evaluateChanges(
351
+ plan,
352
+ indexesToDrop,
353
+ indexesToAdd,
354
+ )
355
+ const hasChanges = planChanged || fksToAdd.size > 0 || fksToDrop.size > 0
356
+
357
+ if (!hasChanges) {
358
+ MESSAGES.PERFECT_SYNC()
359
+ return
360
+ }
361
+
362
+ const argv = process.argv
363
+ if (argv.find(a => a.startsWith('--choose='))?.split('=')[1] === 'db') {
364
+ MESSAGES.GEN_TYPES()
365
+ return await genLocal(plan.dbConstraintsForDiff)
366
+ }
367
+ logPlannedChanges(plan, indexesToDrop, indexesToAdd, isDangerous, MESSAGES)
368
+ if (argv.includes('--dry-run')) {
369
+ // `logger.log` takes the level as its second argument. A leading 'D ' is
370
+ // `messageLogger` table syntax, and this is not a table — so the letter
371
+ // was printed verbatim and the level defaulted.
372
+ //
373
+ // `info`, not the `debug` the stray 'D' was reaching for: this line is
374
+ // the confirmation that nothing was written. Routing it to a level that
375
+ // is filtered by default would mean `--dry-run` could print a page of
376
+ // destructive-looking changes and never say it did not apply them.
377
+ logger.log(
378
+ 'Dry-run enabled: planned changes shown above, not applying.',
379
+ 'info',
380
+ )
381
+ return
382
+ }
383
+
384
+ SyncEngine.handleSafetyChecks(isDangerous, argv)
385
+ await SyncEngine.executeSyncPipeline(
386
+ adapter,
387
+ plan,
388
+ constraints,
389
+ indexesToDrop,
390
+ indexesToAdd,
391
+ genLocal,
392
+ isDangerous,
393
+ tsFks,
394
+ fksToAdd,
395
+ fksToDrop,
396
+ tsIndexes,
397
+ )
398
+ }
399
+ }