@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,618 @@
1
+ import { Bakery } from '@bakery-framework/core/core/bakery'
2
+ import { Case } from '@bakery-framework/core/utils'
3
+ import type { SQLAdapter } from '../adapters/base'
4
+ import { isSafeIdentifier } from '../schema-util'
5
+ import type { SchemaLayout } from './load'
6
+ import type * as SyncTypes from './types'
7
+ import { formatViewBody } from './view-sql'
8
+
9
+ /**
10
+ * Names that cannot bind a `const` in a module (modules are always strict), so
11
+ * they cannot be used as the export name for a generated `table()`. The export
12
+ * name is cosmetic — `InferConstraints` and `collectConstraints` both key off
13
+ * the string passed to `table()`, not the binding — so renaming one is safe.
14
+ */
15
+ const RESERVED_WORDS = new Set([
16
+ 'await',
17
+ 'break',
18
+ 'case',
19
+ 'catch',
20
+ 'class',
21
+ 'const',
22
+ 'continue',
23
+ 'debugger',
24
+ 'default',
25
+ 'delete',
26
+ 'do',
27
+ 'else',
28
+ 'enum',
29
+ 'export',
30
+ 'extends',
31
+ 'false',
32
+ 'finally',
33
+ 'for',
34
+ 'function',
35
+ 'if',
36
+ 'implements',
37
+ 'import',
38
+ 'in',
39
+ 'instanceof',
40
+ 'interface',
41
+ 'let',
42
+ 'new',
43
+ 'null',
44
+ 'package',
45
+ 'private',
46
+ 'protected',
47
+ 'public',
48
+ 'return',
49
+ 'static',
50
+ 'super',
51
+ 'switch',
52
+ 'this',
53
+ 'throw',
54
+ 'true',
55
+ 'try',
56
+ 'typeof',
57
+ 'var',
58
+ 'void',
59
+ 'while',
60
+ 'with',
61
+ 'yield',
62
+ ])
63
+
64
+ function exportNameFor(tableName: string): string {
65
+ const safe = tableName.replace(/[^A-Za-z0-9_]/g, '_')
66
+ return isSafeIdentifier(safe) && !RESERVED_WORDS.has(safe)
67
+ ? safe
68
+ : `table_${safe}`
69
+ }
70
+
71
+ export class SchemaBuilder {
72
+ protected constructor() {}
73
+
74
+ private static syncNullableConstraints(
75
+ constraints: Record<string, any>,
76
+ existingConstraints: SyncTypes.DBConstraints,
77
+ ): void {
78
+ for (const [tableName, cols] of Object.entries(constraints)) {
79
+ if (!cols._view) continue
80
+
81
+ for (const [colName, cons] of Object.entries<any>(cols)) {
82
+ if (colName === '_view') continue
83
+ const existingCol = existingConstraints[tableName]?.[colName]
84
+ // Fall back to what introspection reported, not to `false`.
85
+ //
86
+ // The old `: false` flattened every view column to NOT NULL whenever
87
+ // there was no previous schema to copy from — which is exactly the
88
+ // seeding path, the one a project with an existing database takes. On
89
+ // a real view that made `category` and `images` non-nullable in the
90
+ // generated interface while the database reports both nullable, so the
91
+ // first thing the file said about the data was wrong.
92
+ cons.nullable = existingCol
93
+ ? existingCol.nullable === true
94
+ : cons.nullable === true
95
+ }
96
+ }
97
+ }
98
+
99
+ private static getDefaultValue(
100
+ cons: any,
101
+ isView: boolean,
102
+ adapter: SQLAdapter,
103
+ ): string | undefined {
104
+ if (cons.primary) return undefined
105
+ const def = cons.default
106
+ const nul = cons.nullable ?? false
107
+
108
+ const hasDefault = def !== undefined && def !== null && def !== 'NULL'
109
+ const isExplicitNull = def === null || def === 'NULL' || (!isView && nul)
110
+
111
+ if (hasDefault) {
112
+ const isStr = typeof def === 'string'
113
+ const isDateNow = isStr && adapter.isDateNowDefault(def as string)
114
+
115
+ return isStr && isDateNow
116
+ ? 'dateNow'
117
+ : isStr
118
+ ? JSON.stringify(def)
119
+ : String(def)
120
+ }
121
+
122
+ if (isExplicitNull) return 'null'
123
+ return undefined
124
+ }
125
+
126
+ private static formatColumnConstraint(
127
+ colName: string,
128
+ cons: any,
129
+ adapter: SQLAdapter,
130
+ isView: boolean,
131
+ indent = ' ',
132
+ ): string {
133
+ if (colName === '_view') return ''
134
+
135
+ const p = cons.primary ?? false
136
+ const a = cons.autoIncrement ?? false
137
+ const n = p ? false : (cons.nullable ?? false)
138
+
139
+ if (p && cons.type === 'integer' && a) {
140
+ return `${indent}${colName}: Field.Primary(),\n`
141
+ }
142
+
143
+ const d = SchemaBuilder.getDefaultValue(cons, isView, adapter)
144
+ const named = SchemaBuilder.asFieldCall(cons, d, n)
145
+ if (named) return `${indent}${colName}: ${named},\n`
146
+
147
+ // Nothing in the `Field` vocabulary spells this column — nullable *and*
148
+ // defaulted to something other than null, or an explicit `primary` that is
149
+ // not auto-increment.
150
+ //
151
+ // A plain object literal, not a helper call. Constraints *are* plain
152
+ // objects, so this needs nothing imported and reads honestly as "this shape
153
+ // has no name". It also keeps the generator total: it can always emit a
154
+ // column, rather than dropping one it cannot spell.
155
+ const parts = [`type: '${cons.type}'`]
156
+ if (typeof cons.length === 'number') parts.push(`length: ${cons.length}`)
157
+ // `d` is already source text from `getDefaultValue` — a quoted literal, a
158
+ // bare number, `null`, or the identifier `dateNow`. The marker is spelled
159
+ // out here instead so the emitted file needs no import for it.
160
+ if (d !== undefined) {
161
+ parts.push(`default: ${d === 'dateNow' ? "'%dateNow%'" : d}`)
162
+ }
163
+ if (n) parts.push('nullable: true')
164
+ if (a) parts.push('autoIncrement: true')
165
+ if (p) parts.push('primary: true')
166
+ return `${indent}${colName}: { ${parts.join(', ')} },\n`
167
+ }
168
+
169
+ /**
170
+ * The `Field` call for a column, or `null` when none fits.
171
+ *
172
+ * Generated schemas are the first thing most people read, and `value('string',
173
+ * undefined, true)` teaches three positional booleans where
174
+ * `Field.Text(true)` teaches a name. Only shapes that round-trip are emitted:
175
+ * anything else falls through to `value()` above rather than being
176
+ * approximated into a column that means something slightly different.
177
+ *
178
+ * `_enum` is deliberately absent — the members are not part of the column diff
179
+ * and are not introspected, so the database cannot tell us an enum from a
180
+ * `VARCHAR`, and guessing would silently invent a constraint.
181
+ */
182
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: decision table: column constraints to a Field.* call
183
+ private static asFieldCall(
184
+ cons: any,
185
+ def: string | undefined,
186
+ nullable: boolean,
187
+ ): string | null {
188
+ if (cons.primary || cons.autoIncrement) return null
189
+ const hasLen = typeof cons.length === 'number'
190
+ // `undefined` from getDefaultValue means "no default", which is a different
191
+ // column from one defaulting to null.
192
+ const arg = def === undefined ? '' : def
193
+
194
+ // `Field`'s one convention is that a **null default means nullable**, which
195
+ // makes "nullable *and* defaulted to something else" unspellable: emitting
196
+ // `Field.Int(0)` for `value('integer', 0, true)` would quietly turn a
197
+ // nullable column into NOT NULL. That shape falls through to `value()`,
198
+ // which has a separate argument for it.
199
+ if (nullable && def !== undefined && def !== 'null') return null
200
+
201
+ // Markers are matched on the *raw* default, not on `def`, which is already
202
+ // source text: `getDefaultValue` turns `%dateNow%` into the identifier
203
+ // `dateNow` and `%uuid%` into a quoted literal, so comparing against either
204
+ // spelling is a guess about formatting rather than about the column.
205
+ //
206
+ // Emitting `Field.Uuid()` / `Field.Date.now()` also removes the need for the
207
+ // `dateNow` import, which is added only when the body still mentions it.
208
+ if (cons.type === 'string' && cons.default === '%uuid%')
209
+ return 'Field.Uuid()'
210
+ switch (cons.type) {
211
+ case 'integer':
212
+ if (cons.default === '%dateNow%') return 'Field.Date.now()'
213
+ return nullable && def === undefined ? null : `Field.Int(${arg})`
214
+ case 'number':
215
+ return nullable && def === undefined ? null : `Field.Float(${arg})`
216
+ case 'boolean':
217
+ return nullable && def === undefined ? null : `Field.Bool(${arg})`
218
+ case 'bigint':
219
+ return nullable && def === undefined ? null : `Field.BigInt(${arg})`
220
+ case 'buffer':
221
+ // `Field.Blob()` is always nullable, so it can only stand in for one.
222
+ return nullable && def === 'null' ? 'Field.Blob()' : null
223
+ case 'json':
224
+ if (def === undefined) return 'Field.Json()'
225
+ return nullable && def === 'null' ? 'Field.Json(true)' : null
226
+ case 'string':
227
+ if (hasLen)
228
+ return `Field.Varchar(${cons.length}${arg ? `, ${arg}` : ''})`
229
+ if (def === undefined)
230
+ return nullable ? 'Field.Text(true)' : 'Field.Text()'
231
+ return `Field.String(${arg})`
232
+ default:
233
+ return null
234
+ }
235
+ }
236
+
237
+ /**
238
+ * The TypeScript type of a column, for a generated view interface.
239
+ *
240
+ * A view has no column DDL, so this is the only place its columns appear.
241
+ * Straight through `TypeMap`'s mapping, with `| null` for a nullable column —
242
+ * introspection reports that faithfully for a view's projected columns.
243
+ */
244
+ private static tsTypeFor(cons: any): string {
245
+ const base =
246
+ {
247
+ integer: 'number',
248
+ number: 'number',
249
+ bigint: 'number',
250
+ string: 'string',
251
+ boolean: 'boolean',
252
+ buffer: 'Buffer',
253
+ json: 'unknown',
254
+ }[cons.type as string] || 'unknown'
255
+ return cons.nullable ? `${base} | null` : base
256
+ }
257
+
258
+ /**
259
+ * `orm/views.ts`: one interface plus one `view()` per view in the database.
260
+ *
261
+ * export interface ActiveUsersView {
262
+ * id: number
263
+ * name: string
264
+ * }
265
+ *
266
+ * export const activeUsers = view<'active_users', ActiveUsersView>(
267
+ * 'active_users',
268
+ * `SELECT ...`,
269
+ * )
270
+ *
271
+ * The interface is what a view *is* — `CREATE VIEW` declares no column types,
272
+ * so emitting `Field.Varchar(64)` here would state a width the database
273
+ * neither stores nor enforces. It is also the thing worth exporting: the row
274
+ * type gets a name you can use in a signature.
275
+ *
276
+ * Both type arguments are written out because TypeScript stops inferring the
277
+ * rest once one is supplied, and the *name* has to stay a literal — it is
278
+ * what the schema map is keyed on. Generated code, so the repetition is free.
279
+ */
280
+ private static buildViewModule(
281
+ constraints: Record<string, any>,
282
+ database?: string,
283
+ ): string | null {
284
+ const views = Object.entries(constraints).filter(([, c]) => c?._view)
285
+ if (!views.length) return null
286
+
287
+ let body = ''
288
+ for (const [name, cols] of views) {
289
+ // `productView` would otherwise become `ProductViewView`. Naming a view
290
+ // `*_view` is common enough that the stutter is the normal case, not the
291
+ // edge one.
292
+ const pascal = Case.pascal(name)
293
+ const iface = /view$/i.test(pascal) ? pascal : `${pascal}View`
294
+ let fields = ''
295
+ for (const [colName, cons] of Object.entries<any>(cols)) {
296
+ if (colName === '_view') continue
297
+ fields += ` ${colName}: ${SchemaBuilder.tsTypeFor(cons)}\n`
298
+ }
299
+ body +=
300
+ `export interface ${iface} {\n${fields}}\n\n` +
301
+ `export const ${exportNameFor(name)} = view<'${name}', ${iface}>(\n` +
302
+ ` '${name}',\n` +
303
+ ` \`${formatViewBody(String(cols._view), database).replace(/`/g, '\\`')}\`,\n` +
304
+ `)\n\n`
305
+ }
306
+
307
+ return `/**
308
+ * Views, seeded from the database by \`db:sync --choose=db\`.
309
+ *
310
+ * A view is a stored SELECT; it has no column DDL, so each one is described by
311
+ * an interface rather than by column builders. Edit the SELECT here and the
312
+ * next sync recreates the view — views hold no data, so there is nothing to
313
+ * migrate.
314
+ *
315
+ * **This file is yours from now on. The generator writes it once and never
316
+ * overwrites it**, because the interfaces are the part worth editing by hand:
317
+ * introspection can only report a JSON column as \`unknown\`, and the shape it
318
+ * actually holds — \`{ id: number; name: string }[]\` for a
319
+ * \`json_arrayagg(json_object(...))\` — is knowledge only you have. Regenerating
320
+ * over that would throw away the reason for writing it down.
321
+ *
322
+ * A view added to the database later will not appear here on its own; add it,
323
+ * or delete this file and re-run to reseed the lot.
324
+ */
325
+ import { view } from '@bakery-framework/orm'
326
+
327
+ ${body}`
328
+ }
329
+
330
+ private static buildConstraintsString(
331
+ constraints: Record<string, any>,
332
+ adapter: SQLAdapter,
333
+ ): string {
334
+ let result = '{\n'
335
+ for (const [tableName, cols] of Object.entries(constraints)) {
336
+ result += ` ${tableName}: {\n`
337
+
338
+ if (cols._view) {
339
+ result += ` _view: \`${cols._view.replace(/`/g, '\\`')}\`,\n`
340
+ }
341
+
342
+ for (const [colName, cons] of Object.entries(
343
+ cols as Record<string, SyncTypes.ColumnConstraint>,
344
+ )) {
345
+ result += SchemaBuilder.formatColumnConstraint(
346
+ colName,
347
+ cons,
348
+ adapter,
349
+ !!cols._view,
350
+ )
351
+ }
352
+ result += ` },\n`
353
+ }
354
+ return `${result} } as const;\n`
355
+ }
356
+
357
+ private static buildIndexesString(dbIndexes: Record<string, any>): string {
358
+ let result = '{\n'
359
+ for (const [idxName, idx] of Object.entries(dbIndexes)) {
360
+ const colsStr =
361
+ idx.cols.length === 1
362
+ ? `'${idx.cols[0]}'`
363
+ : `[${idx.cols.map((c: string) => `'${c}'`).join(', ')}]`
364
+
365
+ // `Field.Index` / `Field.Unique`, capitalised from the stored `index` /
366
+ // `unique` type. This emitted the bare `index(…)` / `unique(…)` names
367
+ // until they were removed, at which point the generated file referenced
368
+ // two identifiers it did not import — invisible here because the *tables*
369
+ // are what the round-trip test imports, not the index block.
370
+ const fn = idx.type === 'unique' ? 'Field.Unique' : 'Field.Index'
371
+ result += ` ${idxName}: ${fn}('${idx.table}', ${colsStr}),\n`
372
+ }
373
+ return `${result} } as const;\n`
374
+ }
375
+
376
+ /**
377
+ * The `orm/` folder layout's `schema.ts`: `table()` values and nothing else.
378
+ *
379
+ * The generator only ever emitted the `DBInfo` namespace, and for a folder
380
+ * project the write target is `orm/schema.ts` — which `orm/index.ts`
381
+ * re-exports. So a regeneration replaced every `table()` with a namespace
382
+ * *and* added a second `declare module '@bakery-framework/orm/schema-registry'` block
383
+ * colliding with the one in `index.ts`. It fired on `--choose=db` and, less
384
+ * visibly, after any sync involving `old()` wrappers.
385
+ *
386
+ * Tables only, deliberately: `index.ts` owns the re-exports and the schema
387
+ * registration, `indexes.ts` owns the index and unique declarations, and
388
+ * neither is the generator's to rewrite. That separation is the reason the
389
+ * folder layout exists (see `load.ts`).
390
+ */
391
+ private static buildTableModule(
392
+ constraints: Record<string, any>,
393
+ adapter: SQLAdapter,
394
+ ): string {
395
+ let body = ''
396
+ for (const [tableName, cols] of Object.entries(constraints)) {
397
+ // Views are left to `views.ts`, exactly as indexes are left to
398
+ // `indexes.ts`. This file is the only one the generator owns; emitting a
399
+ // view here as well would leave the same declaration in two files after
400
+ // a single `--choose=db`, and `collectConstraints` would silently keep
401
+ // whichever was exported last.
402
+ if (cols._view) continue
403
+ let colsStr = ''
404
+ for (const [colName, cons] of Object.entries(
405
+ cols as Record<string, SyncTypes.ColumnConstraint>,
406
+ )) {
407
+ colsStr += SchemaBuilder.formatColumnConstraint(
408
+ colName,
409
+ cons,
410
+ adapter,
411
+ // Never a view here — those were skipped above.
412
+ false,
413
+ ' ',
414
+ )
415
+ }
416
+ body += `export const ${exportNameFor(tableName)} = table('${tableName}', {\n${colsStr}})\n\n`
417
+ }
418
+
419
+ // Imported from what was actually emitted. An import list fixed up front
420
+ // would either miss a helper or leave an unused one in a file the app
421
+ // typechecks.
422
+ // `table`, plus `Field` when the body uses one. Nothing else: a column
423
+ // `Field` cannot spell is emitted as a plain object literal, which imports
424
+ // nothing at all.
425
+ const helpers = ['table']
426
+ if (/\bField\./.test(body)) helpers.push('Field')
427
+
428
+ return `/**
429
+ * Generated from the database by \`db:sync\`.
430
+ *
431
+ * Tables only. In the orm/ folder layout \`index.ts\` owns the re-exports and
432
+ * the schema registration, \`indexes.ts\` owns the index and unique
433
+ * declarations, and \`views.ts\` owns the views — none of which is written here.
434
+ *
435
+ * The cost of that separation, worth knowing: an index or a view the database
436
+ * has and its file does not declare is dropped by the next TS-wins sync. It
437
+ * says so before it does it.
438
+ *
439
+ * This file is rewritten wholesale on every regeneration; the previous copy is
440
+ * kept under \`bakery/backups/\`.
441
+ */
442
+ import { ${helpers.sort().join(', ')} } from '@bakery-framework/orm'
443
+
444
+ ${body}`
445
+ }
446
+
447
+ /**
448
+ * The standalone `schema.ts`: constraints, indexes and the registration block.
449
+ *
450
+ * `Field` is imported from the package root and the types from
451
+ * `schema-util`, in two statements rather than one, because `schema-util`
452
+ * cannot re-export `Field` without closing a cycle — `field.ts` calls
453
+ * `value`/`primary`/`index`/`unique`, and this repo has already paid once for
454
+ * a cycle that typechecked and then failed at runtime.
455
+ */
456
+ private static buildDbInfoBlock(
457
+ stringifiedConstraints: string,
458
+ stringifiedIndexes: string,
459
+ ): string {
460
+ return `
461
+ import { Field } from '@bakery-framework/orm';
462
+ import {
463
+ type ExtractOptionals,
464
+ type ExtractTableTypes,
465
+ type ExtractViews,
466
+ old,
467
+ } from '@bakery-framework/orm/schema-util';
468
+
469
+ export namespace DBInfo {
470
+ export const constraints = ${stringifiedConstraints}
471
+ export const indexes = ${stringifiedIndexes}
472
+ type C = typeof constraints;
473
+ export type Table<T extends keyof C> = ExtractTableTypes<C, T>;
474
+ export type Optionals<T extends keyof C> = ExtractOptionals<C, T>;
475
+ export type Views = ExtractViews<C>;
476
+ }
477
+
478
+ export type DBSchema = {
479
+ [T in keyof typeof DBInfo.constraints]: DBInfo.Table<T>;
480
+ };
481
+
482
+ export type DBOptionals = {
483
+ [T in keyof typeof DBInfo.constraints]: DBInfo.Optionals<T>;
484
+ };
485
+
486
+ /**
487
+ * Registers this schema with the framework's type system. Generated
488
+ * deliberately: the framework never imports schema.ts, so without this block
489
+ * the ORM still runs but every table and column is \`any\`.
490
+ */
491
+ declare module '@bakery-framework/orm/schema-registry' {
492
+ interface SchemaRegistry {
493
+ schema: {
494
+ DBSchema: DBSchema;
495
+ DBOptionals: DBOptionals;
496
+ Views: DBInfo.Views;
497
+ };
498
+ }
499
+ }
500
+ `
501
+ }
502
+
503
+ static async generate(
504
+ adapter: SQLAdapter,
505
+ schemaPath: string,
506
+ messages: any,
507
+ existingConstraints: SyncTypes.DBConstraints = {},
508
+ layout: SchemaLayout = 'file',
509
+ ): Promise<void> {
510
+ messages.GEN_TYPES()
511
+
512
+ // Stripped, or `--choose=db` writes `__bakery_schema` into the app's own
513
+ // schema as an ordinary table — after which sync manages the ledger, the
514
+ // ledger records itself, and the shape check never matches again. The diff
515
+ // path strips it in `resolveCurrentState`; this path reads the adapter
516
+ // directly and was missing it.
517
+ const { stripLedger } = await import('./ledger')
518
+ const constraints = stripLedger(await adapter.getConstraints())
519
+
520
+ SchemaBuilder.syncNullableConstraints(constraints, existingConstraints)
521
+
522
+ // The write target and the shape written have to agree. `folder` means
523
+ // `orm/schema.ts` beside an `index.ts` that already registers the schema;
524
+ // `file`/`none` mean a standalone schema.ts that has to register itself.
525
+ const source =
526
+ layout === 'folder'
527
+ ? SchemaBuilder.buildTableModule(constraints, adapter)
528
+ : SchemaBuilder.buildDbInfoBlock(
529
+ SchemaBuilder.buildConstraintsString(constraints, adapter),
530
+ SchemaBuilder.buildIndexesString(await adapter.getIndexes()),
531
+ )
532
+
533
+ const preserved = await SchemaBuilder.preserveExisting(schemaPath)
534
+ if (preserved) messages.SCHEMA_PRESERVED({ file: preserved })
535
+
536
+ await Bun.write(schemaPath, source)
537
+
538
+ // `views.ts` beside `tables.ts`, and only in the folder layout — the
539
+ // single-file layout already carries views inside its `DBInfo` namespace.
540
+ //
541
+ // Written only when the database *has* views: creating an empty file, and
542
+ // then an `export * from './views'` that resolves to nothing, would be
543
+ // noise in every project that has none.
544
+ if (layout === 'folder') {
545
+ const viewsSource = SchemaBuilder.buildViewModule(
546
+ constraints,
547
+ adapter.databaseName,
548
+ )
549
+ if (viewsSource) {
550
+ const viewsPath = `${schemaPath.replace(/[^/\\]+$/, '')}views.ts`
551
+ // Seeded once, then never overwritten — unlike `tables.ts`, which the
552
+ // generator owns outright.
553
+ //
554
+ // The interfaces are the part worth editing by hand. Introspection can
555
+ // only ever report a JSON column as `unknown`; that a
556
+ // `json_arrayagg(json_object(...))` column holds
557
+ // `{ id: number; name: string }[]` is knowledge the schema does not
558
+ // carry and the database cannot state. Overwriting would delete exactly
559
+ // the work the interface form exists to make possible.
560
+ if (await Bun.file(viewsPath).exists()) {
561
+ messages.VIEWS_KEPT?.({ file: viewsPath })
562
+ } else {
563
+ await Bun.write(viewsPath, viewsSource)
564
+ messages.VIEWS_SEEDED?.({ file: viewsPath })
565
+ }
566
+ }
567
+ }
568
+
569
+ messages.SYNC_SUCCESS()
570
+ }
571
+
572
+ /** Keep this many previous schemas; the rest are pruned oldest-first. */
573
+ private static readonly SCHEMA_BACKUPS = 10
574
+
575
+ /**
576
+ * Copy the current schema aside before it is overwritten.
577
+ *
578
+ * `--choose=db` regenerates the schema wholesale, and anything hand-written
579
+ * in it — a comment, a `_view`, an `old()` rename wrapper — is gone. The
580
+ * database is backed up before a destructive sync; the schema, which is
581
+ * source, was not. It is also gitignored, so `git checkout` cannot recover
582
+ * it either: this is the one file with no other safety net.
583
+ *
584
+ * Stored under `bakery/backups` (`Bakery.dataDir`) rather than the cache,
585
+ * because a cache is defined as safe to delete and this is not — the
586
+ * framework itself deletes the cache directory on every version bump.
587
+ */
588
+ private static async preserveExisting(
589
+ schemaPath: string,
590
+ ): Promise<string | null> {
591
+ const current = Bun.file(schemaPath)
592
+ if (!(await current.exists())) return null
593
+
594
+ const contents = await current.text()
595
+ if (!contents.trim()) return null
596
+
597
+ const dir = `${Bakery.dataDir}/backups`
598
+ const name = `schema.${Date.now()}.ts`
599
+ await Bun.write(`${dir}/${name}`, contents)
600
+
601
+ await SchemaBuilder.pruneSchemaBackups(dir)
602
+ return name
603
+ }
604
+
605
+ private static async pruneSchemaBackups(dir: string) {
606
+ const { readdir, unlink } = await import('node:fs/promises')
607
+ const entries = await readdir(dir).catch(() => [] as string[])
608
+
609
+ const backups = entries
610
+ .filter(name => /^schema\.\d+\.ts$/.test(name))
611
+ .sort()
612
+ .reverse()
613
+
614
+ for (const stale of backups.slice(SchemaBuilder.SCHEMA_BACKUPS)) {
615
+ await unlink(`${dir}/${stale}`).catch(() => {})
616
+ }
617
+ }
618
+ }