@bakery-framework/orm 1.2.0 → 1.2.2

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": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "Bakery database layer: adapters, query builder, schema sync and backup.",
5
5
  "keywords": [
6
6
  "bakery",
@@ -106,7 +106,6 @@ export class SchemaBuilder {
106
106
  const nul = cons.nullable ?? false
107
107
 
108
108
  const hasDefault = def !== undefined && def !== null && def !== 'NULL'
109
- const isExplicitNull = def === null || def === 'NULL' || (!isView && nul)
110
109
 
111
110
  if (hasDefault) {
112
111
  const isStr = typeof def === 'string'
@@ -119,7 +118,22 @@ export class SchemaBuilder {
119
118
  : String(def)
120
119
  }
121
120
 
122
- if (isExplicitNull) return 'null'
121
+ // **A null default is only spellable on a nullable column**, and conflating
122
+ // the two turned every NOT NULL column without a default into a nullable
123
+ // one. `Field`'s convention — stated in `asFieldCall` below — is that a null
124
+ // default *means* nullable, so `Field.Int(null)` does not describe
125
+ // `author_id integer NOT NULL`; it redefines it.
126
+ //
127
+ // Introspection is not the problem and reports this correctly: a NOT NULL
128
+ // column with no default comes back as `{type, default: null}` with no
129
+ // `nullable` key, and a genuinely nullable one carries `nullable: true`.
130
+ // This function was reading only the default and ignoring the flag beside
131
+ // it, so `--choose=db` on a real database produced a schema whose very next
132
+ // sync proposed rebuilding the table it had just been generated from.
133
+ if (!isView && nul) return 'null'
134
+ // A view has no NOT NULL to speak of, so there the null-ish default is all
135
+ // there is to go on.
136
+ if (isView && (def === null || def === 'NULL')) return 'null'
123
137
  return undefined
124
138
  }
125
139
 
@@ -163,7 +177,83 @@ export class SchemaBuilder {
163
177
  if (n) parts.push('nullable: true')
164
178
  if (a) parts.push('autoIncrement: true')
165
179
  if (p) parts.push('primary: true')
166
- return `${indent}${colName}: { ${parts.join(', ')} },\n`
180
+ if (cons._references) {
181
+ const r = cons._references
182
+ const opts = [`table: '${r.table}'`, `column: '${r.column}'`]
183
+ if (r.onDelete) opts.push(`onDelete: '${r.onDelete}'`)
184
+ if (r.onUpdate) opts.push(`onUpdate: '${r.onUpdate}'`)
185
+ parts.push(`_references: { ${opts.join(', ')} }`)
186
+ }
187
+ // `as const`, and without it the folder layout loses these columns' types.
188
+ //
189
+ // `table()` takes `C extends ColumnMap`, which is `Record<string, unknown>`
190
+ // — a constraint that does not preserve literals, so `{ type: 'integer' }`
191
+ // widens to `{ type: string }` and `InferSchema` has no `'integer'` left to
192
+ // match. Every column the `Field` vocabulary cannot name — which since
193
+ // foreign keys round-trip includes *every referencing column* — then infers
194
+ // as a string.
195
+ //
196
+ // Invisible in the single-file layout, where `DBInfo.constraints` is already
197
+ // `as const`, and that is why it took a real app on the folder layout to
198
+ // surface: 28 errors of the form `number is not assignable to string` on
199
+ // inserts into tables whose foreign keys are integers.
200
+ return `${indent}${colName}: { ${parts.join(', ')} } as const,\n`
201
+ }
202
+
203
+ /**
204
+ * Copy introspected foreign keys onto the columns that carry them.
205
+ *
206
+ * `getConstraints()` describes columns and `getForeignKeys()` describes
207
+ * references, and the generator only ever read the first — so a database whose
208
+ * `posts.author_id` references `users.id ON DELETE CASCADE` regenerated as a
209
+ * plain integer. The constraint stayed in the database, the schema stopped
210
+ * mentioning it, and the next sync therefore planned to rebuild the table to
211
+ * *remove* a key nobody asked to remove.
212
+ *
213
+ * Single-column keys only. A composite key cannot be a property of one column
214
+ * — it is declared with `foreign()` alongside the indexes — and inventing a
215
+ * per-column half of one would be worse than omitting it, so it is counted and
216
+ * reported rather than silently dropped.
217
+ */
218
+ private static attachReferences(
219
+ constraints: Record<string, any>,
220
+ fks: SyncTypes.DBForeignKeys,
221
+ ): { attached: number; composite: number } {
222
+ let attached = 0
223
+ let composite = 0
224
+
225
+ for (const fk of Object.values(fks)) {
226
+ if (fk.cols.length !== 1 || fk.refCols.length !== 1) {
227
+ composite++
228
+ continue
229
+ }
230
+ // Introspection reports SQL identifiers; the constraints map is keyed the
231
+ // way the schema declares them.
232
+ const table = Object.keys(constraints).find(
233
+ t => Case.camel(t) === Case.camel(fk.table),
234
+ )
235
+ const cols = table ? constraints[table] : undefined
236
+ if (!cols) continue
237
+
238
+ const col = Object.keys(cols).find(
239
+ c => Case.camel(c) === Case.camel(fk.cols[0]),
240
+ )
241
+ if (!col) continue
242
+
243
+ cols[col]._references = {
244
+ table: fk.refTable,
245
+ column: fk.refCols[0],
246
+ ...(fk.onDelete && fk.onDelete !== 'NO ACTION'
247
+ ? { onDelete: fk.onDelete }
248
+ : {}),
249
+ ...(fk.onUpdate && fk.onUpdate !== 'NO ACTION'
250
+ ? { onUpdate: fk.onUpdate }
251
+ : {}),
252
+ }
253
+ attached++
254
+ }
255
+
256
+ return { attached, composite }
167
257
  }
168
258
 
169
259
  /**
@@ -186,6 +276,13 @@ export class SchemaBuilder {
186
276
  nullable: boolean,
187
277
  ): string | null {
188
278
  if (cons.primary || cons.autoIncrement) return null
279
+ // A referencing column has no `Field.*` spelling that also carries the
280
+ // reference — `Field.Foreign` needs the parent table's column *value* in
281
+ // scope, which the generated file cannot guarantee (introspection order is
282
+ // not declaration order, and the single-file layout has no table values at
283
+ // all). Fall through to the object literal, which spells `_references`
284
+ // exactly as the loader reads it.
285
+ if (cons._references) return null
189
286
  const hasLen = typeof cons.length === 'number'
190
287
  // `undefined` from getDefaultValue means "no default", which is a different
191
288
  // column from one defaulting to null.
@@ -354,6 +451,39 @@ ${body}`
354
451
  return `${result} } as const;\n`
355
452
  }
356
453
 
454
+ /**
455
+ * `orm/indexes.ts`: one exported declaration per index in the database.
456
+ *
457
+ * The folder layout's counterpart to the `indexes` block `DBInfo` carries, and
458
+ * the reason a regenerated folder schema no longer arms the next sync to drop
459
+ * every index it just read.
460
+ */
461
+ private static buildIndexModule(dbIndexes: Record<string, any>): string {
462
+ let body = ''
463
+ for (const [idxName, idx] of Object.entries(dbIndexes)) {
464
+ const cols =
465
+ idx.cols.length === 1
466
+ ? `'${idx.cols[0]}'`
467
+ : `[${idx.cols.map((c: string) => `'${c}'`).join(', ')}]`
468
+ const fn = idx.type === 'unique' ? 'Field.Unique' : 'Field.Index'
469
+ body += `export const ${Case.camel(idxName)} = ${fn}('${idx.table}', ${cols})\n`
470
+ }
471
+
472
+ return `/**
473
+ * Generated from the database by \`db:sync\`.
474
+ *
475
+ * Seeded once and never overwritten — unlike \`tables.ts\`. An index this file
476
+ * does not declare is dropped by the next TS-wins sync, so add new ones here
477
+ * rather than only in the database.
478
+ *
479
+ * Composite foreign keys belong here too, declared with \`foreign()\`: a
480
+ * reference on a single column cannot express them.
481
+ */
482
+ import { Field } from '@bakery-framework/orm'
483
+
484
+ ${body}`
485
+ }
486
+
357
487
  private static buildIndexesString(dbIndexes: Record<string, any>): string {
358
488
  let result = '{\n'
359
489
  for (const [idxName, idx] of Object.entries(dbIndexes)) {
@@ -388,12 +518,106 @@ ${body}`
388
518
  * neither is the generator's to rewrite. That separation is the reason the
389
519
  * folder layout exists (see `load.ts`).
390
520
  */
521
+ /**
522
+ * The constraints key a `_references.table` names, or `undefined`.
523
+ *
524
+ * Introspection reports SQL identifiers while the constraints map is keyed the
525
+ * way the schema declares them, so the two are compared through `Case.camel`.
526
+ */
527
+ private static tableKeyFor(
528
+ constraints: Record<string, any>,
529
+ sqlName: string,
530
+ ): string | undefined {
531
+ return Object.keys(constraints).find(
532
+ k => Case.camel(k) === Case.camel(sqlName),
533
+ )
534
+ }
535
+
536
+ /**
537
+ * Table names ordered so every table follows the ones it references.
538
+ *
539
+ * A depth-first walk with a visiting set, which is also the cycle break: a
540
+ * table already on the stack is left where it is, and the column pointing back
541
+ * at it falls through to the object literal — correct, just not pretty.
542
+ * Circular references between tables are legal SQL and this must not fail on
543
+ * them or, worse, emit a forward reference that is `undefined` at module
544
+ * evaluation.
545
+ */
546
+ private static tablesParentsFirst(
547
+ constraints: Record<string, any>,
548
+ ): string[] {
549
+ const order: string[] = []
550
+ const done = new Set<string>()
551
+ const visiting = new Set<string>()
552
+
553
+ const visit = (name: string) => {
554
+ if (done.has(name) || visiting.has(name)) return
555
+ visiting.add(name)
556
+
557
+ const cols = constraints[name]
558
+ if (cols && !cols._view) {
559
+ for (const cons of Object.values<any>(cols)) {
560
+ const parent = cons?._references?.table
561
+ if (!parent) continue
562
+ const key = SchemaBuilder.tableKeyFor(constraints, parent)
563
+ if (key && key !== name) visit(key)
564
+ }
565
+ }
566
+
567
+ visiting.delete(name)
568
+ done.add(name)
569
+ order.push(name)
570
+ }
571
+
572
+ for (const name of Object.keys(constraints)) visit(name)
573
+ return order
574
+ }
575
+
576
+ /**
577
+ * `Field.Foreign(parent.column, …)` when the parent is already in scope.
578
+ *
579
+ * Returns null when it is not — the cycle case above — so the caller falls
580
+ * back to the literal rather than emitting a reference to a `const` declared
581
+ * further down the file, which is a TDZ error at import time.
582
+ */
583
+ private static asForeignCall(
584
+ cons: any,
585
+ constraints: Record<string, any>,
586
+ declared: Set<string>,
587
+ ): string | null {
588
+ const ref = cons?._references
589
+ if (!ref) return null
590
+
591
+ const key = SchemaBuilder.tableKeyFor(constraints, ref.table)
592
+ if (!key || !declared.has(key)) return null
593
+
594
+ const opts: string[] = []
595
+ // The literal form spells nullability as `nullable: true, default: null`;
596
+ // `Field.Foreign` takes the one flag and writes both.
597
+ if (cons.nullable) opts.push('nullable: true')
598
+ if (ref.onDelete) opts.push(`onDelete: '${ref.onDelete}'`)
599
+ if (ref.onUpdate) opts.push(`onUpdate: '${ref.onUpdate}'`)
600
+
601
+ const target = `${exportNameFor(key)}.${ref.column}`
602
+ return opts.length
603
+ ? `Field.Foreign(${target}, { ${opts.join(', ')} })`
604
+ : `Field.Foreign(${target})`
605
+ }
606
+
391
607
  private static buildTableModule(
392
608
  constraints: Record<string, any>,
393
609
  adapter: SQLAdapter,
394
610
  ): string {
611
+ // Parents before children, so a referencing column can name the table value
612
+ // it points at. `Field.Foreign(sections.id)` is the form a person writes and
613
+ // the one the scaffolder's own template uses; the alternative is the object
614
+ // literal below, which states the same thing in four times the width.
615
+ const order = SchemaBuilder.tablesParentsFirst(constraints)
616
+ const declared = new Set<string>()
617
+
395
618
  let body = ''
396
- for (const [tableName, cols] of Object.entries(constraints)) {
619
+ for (const tableName of order) {
620
+ const cols = constraints[tableName]
397
621
  // Views are left to `views.ts`, exactly as indexes are left to
398
622
  // `indexes.ts`. This file is the only one the generator owns; emitting a
399
623
  // view here as well would leave the same declaration in two files after
@@ -404,15 +628,19 @@ ${body}`
404
628
  for (const [colName, cons] of Object.entries(
405
629
  cols as Record<string, SyncTypes.ColumnConstraint>,
406
630
  )) {
407
- colsStr += SchemaBuilder.formatColumnConstraint(
408
- colName,
409
- cons,
410
- adapter,
411
- // Never a view here — those were skipped above.
412
- false,
413
- ' ',
414
- )
631
+ const ref = SchemaBuilder.asForeignCall(cons, constraints, declared)
632
+ colsStr += ref
633
+ ? ` ${colName}: ${ref},\n`
634
+ : SchemaBuilder.formatColumnConstraint(
635
+ colName,
636
+ cons,
637
+ adapter,
638
+ // Never a view here — those were skipped above.
639
+ false,
640
+ ' ',
641
+ )
415
642
  }
643
+ declared.add(tableName)
416
644
  body += `export const ${exportNameFor(tableName)} = table('${tableName}', {\n${colsStr}})\n\n`
417
645
  }
418
646
 
@@ -517,6 +745,16 @@ declare module '@bakery-framework/orm/schema-registry' {
517
745
  const { stripLedger } = await import('./ledger')
518
746
  const constraints = stripLedger(await adapter.getConstraints())
519
747
 
748
+ // Before nullability is reconciled, so a referencing column is described
749
+ // completely by the time anything decides how to spell it.
750
+ const refs = SchemaBuilder.attachReferences(
751
+ constraints,
752
+ await adapter.getForeignKeys(),
753
+ )
754
+ if (refs.composite) {
755
+ messages.GEN_COMPOSITE_FK({ count: String(refs.composite) })
756
+ }
757
+
520
758
  SchemaBuilder.syncNullableConstraints(constraints, existingConstraints)
521
759
 
522
760
  // The write target and the shape written have to agree. `folder` means
@@ -564,6 +802,25 @@ declare module '@bakery-framework/orm/schema-registry' {
564
802
  messages.VIEWS_SEEDED?.({ file: viewsPath })
565
803
  }
566
804
  }
805
+
806
+ // And `indexes.ts`, on the same seed-once terms.
807
+ //
808
+ // Nothing wrote this file before, which made the folder layout lossy in a
809
+ // way the single-file layout is not: `DBInfo` carries an `indexes` block,
810
+ // so `--choose=db` there round-trips them, while the folder layout left
811
+ // every index undeclared. A TS-wins sync then drops what the schema does
812
+ // not mention — so regenerating a folder-layout schema from a database
813
+ // armed the *next* sync to delete every index in it.
814
+ const indexes = await adapter.getIndexes()
815
+ if (Object.keys(indexes).length) {
816
+ const indexesPath = `${schemaPath.replace(/[^/\\]+$/, '')}indexes.ts`
817
+ if (await Bun.file(indexesPath).exists()) {
818
+ messages.INDEXES_KEPT?.({ file: indexesPath })
819
+ } else {
820
+ await Bun.write(indexesPath, SchemaBuilder.buildIndexModule(indexes))
821
+ messages.INDEXES_SEEDED?.({ file: indexesPath })
822
+ }
823
+ }
567
824
  }
568
825
 
569
826
  messages.SYNC_SUCCESS()
@@ -18,6 +18,18 @@ import type * as SyncTypes from './types'
18
18
  // prettier-ignore
19
19
  export const syncMsgs = {
20
20
  GEN_TYPES: 'I Generating types...',
21
+ // Counted rather than silently dropped: a composite key is not a property of
22
+ // one column, so the generator cannot spell it — but a reader who is not told
23
+ // will believe the generated schema is complete.
24
+ GEN_COMPOSITE_FK:
25
+ 'W %y{count}%* composite foreign key(s) could not be generated as column references — declare them with %yforeign()%* beside the indexes.',
26
+ INDEXES_SEEDED: 'I Seeded %y{file}%* from the database.',
27
+ INDEXES_KEPT:
28
+ 'I Left %y{file}%* alone: index declarations are hand-owned once seeded. Delete it and re-run to reseed.',
29
+ MIGRATE_DONE:
30
+ 'I %gAdopted the existing database%*. No tables were changed; run %ydb:sync%* to confirm nothing is pending.',
31
+ MIGRATE_NO_LEDGER:
32
+ 'W Schema written, but the ledger could not be recorded — the next sync will diff against live introspection instead.',
21
33
  SYNC_SUCCESS: 'I %gschema.ts successfully synced%* to Database!',
22
34
  INVALID_SCHEMA: 'W %yschema.ts is invalid or corrupt. Treating as new.%*',
23
35
  NO_DBINFO: 'W %yDBInfo namespace not found in schema.ts!%*',
@@ -122,6 +134,28 @@ class SyncSession implements AsyncDisposable {
122
134
  export class SyncEngine {
123
135
  protected constructor() {}
124
136
 
137
+ /**
138
+ * Record the adopted database as the schema Bakery last applied.
139
+ *
140
+ * Without this, the first sync after an adoption falls back to introspection —
141
+ * safe, but it withdraws the thing the ledger is for, and enum member changes
142
+ * only migrate when the diff runs against the ledger.
143
+ *
144
+ * Seeded from introspection rather than from the generated file, deliberately:
145
+ * the ledger's job is to record *what is in the database*, and reading it back
146
+ * from a file that was itself just derived from the database would add a
147
+ * lossy hop for no gain.
148
+ */
149
+ private static async seedLedger(adapter: SQLAdapter): Promise<void> {
150
+ const { writeLedger, stripLedger } = await import('./ledger')
151
+ const wrote = await writeLedger(
152
+ adapter,
153
+ stripLedger(await adapter.getConstraints()),
154
+ await adapter.getIndexes(),
155
+ )
156
+ if (!wrote) MESSAGES.MIGRATE_NO_LEDGER()
157
+ }
158
+
125
159
  private static async checkEmptyConstraints(
126
160
  adapter: SQLAdapter,
127
161
  constraints: SyncTypes.DBConstraints,
@@ -290,6 +324,30 @@ export class SyncEngine {
290
324
  ): Promise<void> {
291
325
  const genLocal = (c: any = {}) =>
292
326
  SchemaBuilder.generate(adapter, schemaPath, MESSAGES, c, layout)
327
+
328
+ // `--migrate` runs before every other branch, and changes no tables.
329
+ //
330
+ // It is not `--choose=db` with a nicer name. `--choose=db` sits *after* the
331
+ // "no changes" early return, so on a database that already matches it does
332
+ // nothing — which is the common case when adopting one, since the schema
333
+ // being written is derived from that same database. And it never writes the
334
+ // ledger, so the first real sync afterwards diffs against introspection
335
+ // instead: the one place enum changes are invisible.
336
+ if (process.argv.includes('--migrate')) {
337
+ // `{}`, not `constraints`: adoption generates from the **database alone**.
338
+ //
339
+ // Passing the loaded schema in makes `syncNullableConstraints` reconcile
340
+ // view column nullability against it, which is right for a regeneration of
341
+ // a schema you are keeping and wrong for this — the point of `--migrate`
342
+ // is that the database is the source of truth, and quietly carrying a
343
+ // detail over from the file being replaced would make the result depend on
344
+ // what happened to be there.
345
+ await genLocal({})
346
+ await SyncEngine.seedLedger(adapter)
347
+ MESSAGES.MIGRATE_DONE()
348
+ return
349
+ }
350
+
293
351
  const isEmpty = await SyncEngine.checkEmptyConstraints(
294
352
  adapter,
295
353
  constraints,
package/src/sync/index.ts CHANGED
@@ -14,11 +14,56 @@ const syncMsgs = {
14
14
  FOREIGN_UNSUPPORTED:
15
15
  'E %rforeign() is declared but not implemented%*: {names}. No adapter emits FOREIGN KEY DDL, so it would be created as a plain index and then re-diffed on every sync. Use index() on the column and enforce the reference in your application.',
16
16
  SCHEMA_NOT_FOUND:
17
- 'E %rConfigured schema path not found%*: {path}. %yschema%* in server.config.ts must name a file or an orm/ folder that exists; remove it to auto-detect. Generating one from the database? Create the (empty) file first.',
17
+ 'E %rConfigured schema path not found%*: {path}. %yschema%* in server.config.ts must name a file or an orm/ folder that exists; remove it to auto-detect. Generating one from the database? Create the (empty) file first, or run %ydb:sync --migrate%*.',
18
+ MIGRATE_SCAFFOLDED: 'I Created %y{dir}%* — the generator owns tables.ts.',
19
+ MIGRATE_RETIRED:
20
+ 'I Converted to the orm/ folder. The previous %yschema.ts%* was moved to %y{to}%*, not deleted.',
18
21
  } as const
19
22
 
20
23
  const MESSAGES = messageLogger(logger, syncMsgs)
21
24
 
25
+ /**
26
+ * `orm/index.ts` — the one file in the folder layout nothing else writes.
27
+ *
28
+ * `tables.ts` belongs to the generator, and `views.ts` / `indexes.ts` are seeded
29
+ * by it. This is the re-export barrel plus the type registration, and without
30
+ * the `declare module` block the ORM runs untyped: every table and column falls
31
+ * back to `any`.
32
+ */
33
+ function ormIndexModule(hasViews: boolean, hasIndexes: boolean): string {
34
+ // Conditional, and it has to be: the generator seeds `views.ts` and
35
+ // `indexes.ts` only when the database actually has views or indexes, so an
36
+ // unconditional `export * from './views'` is a module that does not resolve
37
+ // in every project that has neither.
38
+ const viewImport = hasViews ? "import * as views from './views'\n" : ''
39
+ const model = hasViews ? 'typeof tables & typeof views' : 'typeof tables'
40
+
41
+ return `import type {
42
+ InferOptionals,
43
+ InferSchema,
44
+ InferViews,
45
+ } from '@bakery-framework/orm'
46
+ import * as tables from './tables'
47
+ ${viewImport}
48
+ export * from './tables'
49
+ ${hasViews ? "export * from './views'\n" : ''}${hasIndexes ? "export * from './indexes'\n" : ''}
50
+ type Model = ${model}
51
+
52
+ // Without this block the ORM still runs, untyped: every table and column falls
53
+ // back to \`any\`. The framework never imports this file at runtime — schema
54
+ // values are loaded by path — so this is purely the type registration.
55
+ declare module '@bakery-framework/orm/schema-registry' {
56
+ interface SchemaRegistry {
57
+ schema: {
58
+ DBSchema: InferSchema<Model>
59
+ DBOptionals: InferOptionals<Model>
60
+ Views: InferViews<Model>
61
+ }
62
+ }
63
+ }
64
+ `
65
+ }
66
+
22
67
  export class SyncService {
23
68
  protected constructor() {}
24
69
 
@@ -40,9 +85,13 @@ export class SyncService {
40
85
  // CLI usage text goes to stdout verbatim — it is program output, not a
41
86
  // log line, so it deliberately bypasses the structured logger.
42
87
  console.log(`
43
- Usage: bun run db:sync [--choose=db|ts] [--dry-run] [--force-sync] [--help]
88
+ Usage: bun run db:sync [--migrate] [--choose=db|ts] [--dry-run] [--force-sync] [--help]
44
89
 
45
90
  Flags:
91
+ --migrate Adopt an existing database: write the schema from what is
92
+ already there, creating the orm/ folder if none exists, and
93
+ record it so the next sync has nothing to do. Changes no
94
+ tables. Use this once, on a database Bakery did not create.
46
95
  --choose=db Generate schema.ts from the database (DB wins)
47
96
  --choose=ts Apply schema.ts to the database (TS wins, default)
48
97
  --dry-run Preview planned changes without applying them
@@ -52,6 +101,66 @@ Flags:
52
101
  `)
53
102
  }
54
103
 
104
+ /** `--migrate`: adopt what is already in the database. */
105
+ static migrateRequested(argv: string[] = process.argv.slice(2)): boolean {
106
+ return argv.includes('--migrate')
107
+ }
108
+
109
+ /**
110
+ * Write the three files the generator does *not* own, and return the one it
111
+ * does.
112
+ *
113
+ * Only `index.ts`, and that is the whole point of the restraint.
114
+ *
115
+ * The first version also wrote empty `views.ts` and `indexes.ts` stubs, which
116
+ * *broke adoption* in a way that only an end-to-end run showed: the generator
117
+ * seeds both, but only when the file does not already exist, so the stubs
118
+ * blocked it. The schema then declared no views and no indexes, and the very
119
+ * next `db:sync` planned to drop the view and all three indexes it had just
120
+ * adopted. An adoption path that arms a destructive sync is worse than none.
121
+ *
122
+ * `tables.ts` is not written here either: `SchemaBuilder` owns it outright.
123
+ */
124
+ static async writeOrmIndex(cwd: string): Promise<void> {
125
+ const dir = `${cwd}/orm`
126
+ const indexPath = `${dir}/index.ts`
127
+ if (await Bun.file(indexPath).exists()) return
128
+
129
+ await Bun.write(
130
+ indexPath,
131
+ ormIndexModule(
132
+ await Bun.file(`${dir}/views.ts`).exists(),
133
+ await Bun.file(`${dir}/indexes.ts`).exists(),
134
+ ),
135
+ )
136
+ MESSAGES.MIGRATE_SCAFFOLDED({ dir: 'orm/' })
137
+ }
138
+
139
+ /**
140
+ * Move the old single-file `schema.ts` out of the way after a conversion.
141
+ *
142
+ * Moved, never deleted: it goes to `bakery/backups/`, beside the copies the
143
+ * generator already keeps there. `loadSchema` prefers `orm/index.ts`, so a
144
+ * leftover `schema.ts` would be *ignored* rather than used — which is the
145
+ * quiet kind of wrong, since it looks like the file still describes the app
146
+ * while nothing reads it.
147
+ */
148
+ static async retireSingleFileSchema(
149
+ cwd: string,
150
+ previous: 'folder' | 'file' | 'none',
151
+ ): Promise<void> {
152
+ if (previous !== 'file') return
153
+
154
+ const from = `${cwd}/schema.ts`
155
+ const file = Bun.file(from)
156
+ if (!(await file.exists())) return
157
+
158
+ const to = `${cwd}/bakery/backups/schema.pre-migrate.${Date.now()}.ts`
159
+ await Bun.write(to, await file.text())
160
+ await file.delete()
161
+ MESSAGES.MIGRATE_RETIRED({ to: to.slice(cwd.length + 1) })
162
+ }
163
+
55
164
  static async run() {
56
165
  // Before initConfig/initDB/loadSchema: help must not depend on a working
57
166
  // connection, a loadable schema, or the absence of a `foreign()`.
@@ -97,12 +206,33 @@ Flags:
97
206
  MESSAGES.NO_DBINFO()
98
207
  }
99
208
 
100
- await connection.syncSchema(
101
- constraints,
102
- tsIndexes,
103
- schemaPath,
104
- loaded.layout,
105
- )
209
+ // `--migrate` on a project with no schema at all creates the folder layout
210
+ // rather than a single `schema.ts`. Adoption is exactly the case where the
211
+ // folder earns its keep: the generator owns `tables.ts` and regenerating it
212
+ // cannot touch the views, indexes and registration beside it — which for an
213
+ // adopted database is the difference between re-running the command and
214
+ // hand-restoring what it overwrote.
215
+ // `--migrate` always lands on the folder layout, including from an existing
216
+ // single-file `schema.ts`. It used to convert only from *nothing*, which
217
+ // read as an arbitrary distinction: the reason to prefer the folder is that
218
+ // the generator owns `tables.ts` and cannot touch the views, indexes and
219
+ // registration beside it — and that is worth exactly as much to a project
220
+ // that already has a schema as to one that does not.
221
+ const adopting = SyncService.migrateRequested()
222
+ const layout = adopting ? 'folder' : loaded.layout
223
+ const targetPath = adopting ? `${process.cwd()}/orm/tables.ts` : schemaPath
224
+
225
+ await connection.syncSchema(constraints, tsIndexes, targetPath, layout)
226
+
227
+ // After generation, not before: `index.ts` re-exports `views.ts` and
228
+ // `indexes.ts`, which the generator writes only when the database has views
229
+ // or indexes to write. Deciding what to import before knowing which files
230
+ // exist is how the first version produced a barrel pointing at nothing.
231
+ if (adopting) {
232
+ await SyncService.writeOrmIndex(process.cwd())
233
+ await SyncService.retireSingleFileSchema(process.cwd(), loaded.layout)
234
+ }
235
+
106
236
  await closeDB()
107
237
  }
108
238
  }