@bakery-framework/orm 1.1.1 → 1.2.1
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 +1 -1
- package/src/sync/builder.ts +148 -2
- package/src/sync/engine.ts +50 -0
- package/src/sync/helpers.ts +26 -0
- package/src/sync/index.ts +102 -8
- package/src/sync/ledger.ts +80 -19
package/package.json
CHANGED
package/src/sync/builder.ts
CHANGED
|
@@ -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
|
-
|
|
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,9 +177,72 @@ 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')
|
|
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
|
+
}
|
|
166
187
|
return `${indent}${colName}: { ${parts.join(', ')} },\n`
|
|
167
188
|
}
|
|
168
189
|
|
|
190
|
+
/**
|
|
191
|
+
* Copy introspected foreign keys onto the columns that carry them.
|
|
192
|
+
*
|
|
193
|
+
* `getConstraints()` describes columns and `getForeignKeys()` describes
|
|
194
|
+
* references, and the generator only ever read the first — so a database whose
|
|
195
|
+
* `posts.author_id` references `users.id ON DELETE CASCADE` regenerated as a
|
|
196
|
+
* plain integer. The constraint stayed in the database, the schema stopped
|
|
197
|
+
* mentioning it, and the next sync therefore planned to rebuild the table to
|
|
198
|
+
* *remove* a key nobody asked to remove.
|
|
199
|
+
*
|
|
200
|
+
* Single-column keys only. A composite key cannot be a property of one column
|
|
201
|
+
* — it is declared with `foreign()` alongside the indexes — and inventing a
|
|
202
|
+
* per-column half of one would be worse than omitting it, so it is counted and
|
|
203
|
+
* reported rather than silently dropped.
|
|
204
|
+
*/
|
|
205
|
+
private static attachReferences(
|
|
206
|
+
constraints: Record<string, any>,
|
|
207
|
+
fks: SyncTypes.DBForeignKeys,
|
|
208
|
+
): { attached: number; composite: number } {
|
|
209
|
+
let attached = 0
|
|
210
|
+
let composite = 0
|
|
211
|
+
|
|
212
|
+
for (const fk of Object.values(fks)) {
|
|
213
|
+
if (fk.cols.length !== 1 || fk.refCols.length !== 1) {
|
|
214
|
+
composite++
|
|
215
|
+
continue
|
|
216
|
+
}
|
|
217
|
+
// Introspection reports SQL identifiers; the constraints map is keyed the
|
|
218
|
+
// way the schema declares them.
|
|
219
|
+
const table = Object.keys(constraints).find(
|
|
220
|
+
t => Case.camel(t) === Case.camel(fk.table),
|
|
221
|
+
)
|
|
222
|
+
const cols = table ? constraints[table] : undefined
|
|
223
|
+
if (!cols) continue
|
|
224
|
+
|
|
225
|
+
const col = Object.keys(cols).find(
|
|
226
|
+
c => Case.camel(c) === Case.camel(fk.cols[0]),
|
|
227
|
+
)
|
|
228
|
+
if (!col) continue
|
|
229
|
+
|
|
230
|
+
cols[col]._references = {
|
|
231
|
+
table: fk.refTable,
|
|
232
|
+
column: fk.refCols[0],
|
|
233
|
+
...(fk.onDelete && fk.onDelete !== 'NO ACTION'
|
|
234
|
+
? { onDelete: fk.onDelete }
|
|
235
|
+
: {}),
|
|
236
|
+
...(fk.onUpdate && fk.onUpdate !== 'NO ACTION'
|
|
237
|
+
? { onUpdate: fk.onUpdate }
|
|
238
|
+
: {}),
|
|
239
|
+
}
|
|
240
|
+
attached++
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return { attached, composite }
|
|
244
|
+
}
|
|
245
|
+
|
|
169
246
|
/**
|
|
170
247
|
* The `Field` call for a column, or `null` when none fits.
|
|
171
248
|
*
|
|
@@ -186,6 +263,13 @@ export class SchemaBuilder {
|
|
|
186
263
|
nullable: boolean,
|
|
187
264
|
): string | null {
|
|
188
265
|
if (cons.primary || cons.autoIncrement) return null
|
|
266
|
+
// A referencing column has no `Field.*` spelling that also carries the
|
|
267
|
+
// reference — `Field.Foreign` needs the parent table's column *value* in
|
|
268
|
+
// scope, which the generated file cannot guarantee (introspection order is
|
|
269
|
+
// not declaration order, and the single-file layout has no table values at
|
|
270
|
+
// all). Fall through to the object literal, which spells `_references`
|
|
271
|
+
// exactly as the loader reads it.
|
|
272
|
+
if (cons._references) return null
|
|
189
273
|
const hasLen = typeof cons.length === 'number'
|
|
190
274
|
// `undefined` from getDefaultValue means "no default", which is a different
|
|
191
275
|
// column from one defaulting to null.
|
|
@@ -354,6 +438,39 @@ ${body}`
|
|
|
354
438
|
return `${result} } as const;\n`
|
|
355
439
|
}
|
|
356
440
|
|
|
441
|
+
/**
|
|
442
|
+
* `orm/indexes.ts`: one exported declaration per index in the database.
|
|
443
|
+
*
|
|
444
|
+
* The folder layout's counterpart to the `indexes` block `DBInfo` carries, and
|
|
445
|
+
* the reason a regenerated folder schema no longer arms the next sync to drop
|
|
446
|
+
* every index it just read.
|
|
447
|
+
*/
|
|
448
|
+
private static buildIndexModule(dbIndexes: Record<string, any>): string {
|
|
449
|
+
let body = ''
|
|
450
|
+
for (const [idxName, idx] of Object.entries(dbIndexes)) {
|
|
451
|
+
const cols =
|
|
452
|
+
idx.cols.length === 1
|
|
453
|
+
? `'${idx.cols[0]}'`
|
|
454
|
+
: `[${idx.cols.map((c: string) => `'${c}'`).join(', ')}]`
|
|
455
|
+
const fn = idx.type === 'unique' ? 'Field.Unique' : 'Field.Index'
|
|
456
|
+
body += `export const ${Case.camel(idxName)} = ${fn}('${idx.table}', ${cols})\n`
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return `/**
|
|
460
|
+
* Generated from the database by \`db:sync\`.
|
|
461
|
+
*
|
|
462
|
+
* Seeded once and never overwritten — unlike \`tables.ts\`. An index this file
|
|
463
|
+
* does not declare is dropped by the next TS-wins sync, so add new ones here
|
|
464
|
+
* rather than only in the database.
|
|
465
|
+
*
|
|
466
|
+
* Composite foreign keys belong here too, declared with \`foreign()\`: a
|
|
467
|
+
* reference on a single column cannot express them.
|
|
468
|
+
*/
|
|
469
|
+
import { Field } from '@bakery-framework/orm'
|
|
470
|
+
|
|
471
|
+
${body}`
|
|
472
|
+
}
|
|
473
|
+
|
|
357
474
|
private static buildIndexesString(dbIndexes: Record<string, any>): string {
|
|
358
475
|
let result = '{\n'
|
|
359
476
|
for (const [idxName, idx] of Object.entries(dbIndexes)) {
|
|
@@ -517,6 +634,16 @@ declare module '@bakery-framework/orm/schema-registry' {
|
|
|
517
634
|
const { stripLedger } = await import('./ledger')
|
|
518
635
|
const constraints = stripLedger(await adapter.getConstraints())
|
|
519
636
|
|
|
637
|
+
// Before nullability is reconciled, so a referencing column is described
|
|
638
|
+
// completely by the time anything decides how to spell it.
|
|
639
|
+
const refs = SchemaBuilder.attachReferences(
|
|
640
|
+
constraints,
|
|
641
|
+
await adapter.getForeignKeys(),
|
|
642
|
+
)
|
|
643
|
+
if (refs.composite) {
|
|
644
|
+
messages.GEN_COMPOSITE_FK({ count: String(refs.composite) })
|
|
645
|
+
}
|
|
646
|
+
|
|
520
647
|
SchemaBuilder.syncNullableConstraints(constraints, existingConstraints)
|
|
521
648
|
|
|
522
649
|
// The write target and the shape written have to agree. `folder` means
|
|
@@ -564,6 +691,25 @@ declare module '@bakery-framework/orm/schema-registry' {
|
|
|
564
691
|
messages.VIEWS_SEEDED?.({ file: viewsPath })
|
|
565
692
|
}
|
|
566
693
|
}
|
|
694
|
+
|
|
695
|
+
// And `indexes.ts`, on the same seed-once terms.
|
|
696
|
+
//
|
|
697
|
+
// Nothing wrote this file before, which made the folder layout lossy in a
|
|
698
|
+
// way the single-file layout is not: `DBInfo` carries an `indexes` block,
|
|
699
|
+
// so `--choose=db` there round-trips them, while the folder layout left
|
|
700
|
+
// every index undeclared. A TS-wins sync then drops what the schema does
|
|
701
|
+
// not mention — so regenerating a folder-layout schema from a database
|
|
702
|
+
// armed the *next* sync to delete every index in it.
|
|
703
|
+
const indexes = await adapter.getIndexes()
|
|
704
|
+
if (Object.keys(indexes).length) {
|
|
705
|
+
const indexesPath = `${schemaPath.replace(/[^/\\]+$/, '')}indexes.ts`
|
|
706
|
+
if (await Bun.file(indexesPath).exists()) {
|
|
707
|
+
messages.INDEXES_KEPT?.({ file: indexesPath })
|
|
708
|
+
} else {
|
|
709
|
+
await Bun.write(indexesPath, SchemaBuilder.buildIndexModule(indexes))
|
|
710
|
+
messages.INDEXES_SEEDED?.({ file: indexesPath })
|
|
711
|
+
}
|
|
712
|
+
}
|
|
567
713
|
}
|
|
568
714
|
|
|
569
715
|
messages.SYNC_SUCCESS()
|
package/src/sync/engine.ts
CHANGED
|
@@ -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,22 @@ 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
|
+
await genLocal(constraints)
|
|
338
|
+
await SyncEngine.seedLedger(adapter)
|
|
339
|
+
MESSAGES.MIGRATE_DONE()
|
|
340
|
+
return
|
|
341
|
+
}
|
|
342
|
+
|
|
293
343
|
const isEmpty = await SyncEngine.checkEmptyConstraints(
|
|
294
344
|
adapter,
|
|
295
345
|
constraints,
|
package/src/sync/helpers.ts
CHANGED
|
@@ -260,10 +260,36 @@ function diffColumnMismatch(
|
|
|
260
260
|
const lengthDiffers =
|
|
261
261
|
typeof tsCol.length === 'number' && tsCol.length !== dbCol.length
|
|
262
262
|
|
|
263
|
+
// Enum members join the diff, so changing them migrates instead of silently
|
|
264
|
+
// doing nothing — but **only when the current state came from the ledger**.
|
|
265
|
+
//
|
|
266
|
+
// `_enum` is emitted as an inline `CHECK (col IN (...))` by all three
|
|
267
|
+
// dialects, and all three *will* report that constraint back — in three
|
|
268
|
+
// incompatible shapes. Measured:
|
|
269
|
+
//
|
|
270
|
+
// sqlite CHECK (status IN ('draft','live')) in the table DDL
|
|
271
|
+
// mysql (`status` in (_utf8mb4'draft',_utf8mb4'live')) charset prefixes
|
|
272
|
+
// pgsql CHECK (((status)::text = ANY ((ARRAY[...]))) re-rendered
|
|
273
|
+
//
|
|
274
|
+
// Postgres does not store the text it was given, it re-renders a parsed
|
|
275
|
+
// expression — the same trap that turned `EXTRACT` into `date_part` and
|
|
276
|
+
// rebuilt a table on every sync forever. Three parsers, each an opportunity
|
|
277
|
+
// for that bug, is the wrong trade when the ledger already holds the members
|
|
278
|
+
// exactly as declared.
|
|
279
|
+
//
|
|
280
|
+
// So under introspection this stays out of the diff. A schema-side-only
|
|
281
|
+
// comparison would find `_enum` on one side and nothing on the other, differ
|
|
282
|
+
// every time, and rebuild the table on every sync — which is precisely what
|
|
283
|
+
// the `length` note above says it waited to rule out before shipping.
|
|
284
|
+
const enumDiffers =
|
|
285
|
+
plan.ledgerSource === 'ledger' &&
|
|
286
|
+
!Bun.deepEquals(tsCol._enum ?? null, dbCol._enum ?? null)
|
|
287
|
+
|
|
263
288
|
if (
|
|
264
289
|
!isTypeMatch ||
|
|
265
290
|
tsNullable !== dbNullable ||
|
|
266
291
|
lengthDiffers ||
|
|
292
|
+
enumDiffers ||
|
|
267
293
|
norm(tsDefault) !== norm(dbDefault)
|
|
268
294
|
) {
|
|
269
295
|
MESSAGES.COL_MISMATCH({ table: dbName, column: camelCol })
|
package/src/sync/index.ts
CHANGED
|
@@ -14,11 +14,54 @@ 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.',
|
|
18
19
|
} as const
|
|
19
20
|
|
|
20
21
|
const MESSAGES = messageLogger(logger, syncMsgs)
|
|
21
22
|
|
|
23
|
+
/**
|
|
24
|
+
* `orm/index.ts` — the one file in the folder layout nothing else writes.
|
|
25
|
+
*
|
|
26
|
+
* `tables.ts` belongs to the generator, and `views.ts` / `indexes.ts` are seeded
|
|
27
|
+
* by it. This is the re-export barrel plus the type registration, and without
|
|
28
|
+
* the `declare module` block the ORM runs untyped: every table and column falls
|
|
29
|
+
* back to `any`.
|
|
30
|
+
*/
|
|
31
|
+
function ormIndexModule(hasViews: boolean, hasIndexes: boolean): string {
|
|
32
|
+
// Conditional, and it has to be: the generator seeds `views.ts` and
|
|
33
|
+
// `indexes.ts` only when the database actually has views or indexes, so an
|
|
34
|
+
// unconditional `export * from './views'` is a module that does not resolve
|
|
35
|
+
// in every project that has neither.
|
|
36
|
+
const viewImport = hasViews ? "import * as views from './views'\n" : ''
|
|
37
|
+
const model = hasViews ? 'typeof tables & typeof views' : 'typeof tables'
|
|
38
|
+
|
|
39
|
+
return `import type {
|
|
40
|
+
InferOptionals,
|
|
41
|
+
InferSchema,
|
|
42
|
+
InferViews,
|
|
43
|
+
} from '@bakery-framework/orm'
|
|
44
|
+
import * as tables from './tables'
|
|
45
|
+
${viewImport}
|
|
46
|
+
export * from './tables'
|
|
47
|
+
${hasViews ? "export * from './views'\n" : ''}${hasIndexes ? "export * from './indexes'\n" : ''}
|
|
48
|
+
type Model = ${model}
|
|
49
|
+
|
|
50
|
+
// Without this block the ORM still runs, untyped: every table and column falls
|
|
51
|
+
// back to \`any\`. The framework never imports this file at runtime — schema
|
|
52
|
+
// values are loaded by path — so this is purely the type registration.
|
|
53
|
+
declare module '@bakery-framework/orm/schema-registry' {
|
|
54
|
+
interface SchemaRegistry {
|
|
55
|
+
schema: {
|
|
56
|
+
DBSchema: InferSchema<Model>
|
|
57
|
+
DBOptionals: InferOptionals<Model>
|
|
58
|
+
Views: InferViews<Model>
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
`
|
|
63
|
+
}
|
|
64
|
+
|
|
22
65
|
export class SyncService {
|
|
23
66
|
protected constructor() {}
|
|
24
67
|
|
|
@@ -40,9 +83,13 @@ export class SyncService {
|
|
|
40
83
|
// CLI usage text goes to stdout verbatim — it is program output, not a
|
|
41
84
|
// log line, so it deliberately bypasses the structured logger.
|
|
42
85
|
console.log(`
|
|
43
|
-
Usage: bun run db:sync [--choose=db|ts] [--dry-run] [--force-sync] [--help]
|
|
86
|
+
Usage: bun run db:sync [--migrate] [--choose=db|ts] [--dry-run] [--force-sync] [--help]
|
|
44
87
|
|
|
45
88
|
Flags:
|
|
89
|
+
--migrate Adopt an existing database: write the schema from what is
|
|
90
|
+
already there, creating the orm/ folder if none exists, and
|
|
91
|
+
record it so the next sync has nothing to do. Changes no
|
|
92
|
+
tables. Use this once, on a database Bakery did not create.
|
|
46
93
|
--choose=db Generate schema.ts from the database (DB wins)
|
|
47
94
|
--choose=ts Apply schema.ts to the database (TS wins, default)
|
|
48
95
|
--dry-run Preview planned changes without applying them
|
|
@@ -52,6 +99,41 @@ Flags:
|
|
|
52
99
|
`)
|
|
53
100
|
}
|
|
54
101
|
|
|
102
|
+
/** `--migrate`: adopt what is already in the database. */
|
|
103
|
+
static migrateRequested(argv: string[] = process.argv.slice(2)): boolean {
|
|
104
|
+
return argv.includes('--migrate')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Write the three files the generator does *not* own, and return the one it
|
|
109
|
+
* does.
|
|
110
|
+
*
|
|
111
|
+
* Only `index.ts`, and that is the whole point of the restraint.
|
|
112
|
+
*
|
|
113
|
+
* The first version also wrote empty `views.ts` and `indexes.ts` stubs, which
|
|
114
|
+
* *broke adoption* in a way that only an end-to-end run showed: the generator
|
|
115
|
+
* seeds both, but only when the file does not already exist, so the stubs
|
|
116
|
+
* blocked it. The schema then declared no views and no indexes, and the very
|
|
117
|
+
* next `db:sync` planned to drop the view and all three indexes it had just
|
|
118
|
+
* adopted. An adoption path that arms a destructive sync is worse than none.
|
|
119
|
+
*
|
|
120
|
+
* `tables.ts` is not written here either: `SchemaBuilder` owns it outright.
|
|
121
|
+
*/
|
|
122
|
+
static async writeOrmIndex(cwd: string): Promise<void> {
|
|
123
|
+
const dir = `${cwd}/orm`
|
|
124
|
+
const indexPath = `${dir}/index.ts`
|
|
125
|
+
if (await Bun.file(indexPath).exists()) return
|
|
126
|
+
|
|
127
|
+
await Bun.write(
|
|
128
|
+
indexPath,
|
|
129
|
+
ormIndexModule(
|
|
130
|
+
await Bun.file(`${dir}/views.ts`).exists(),
|
|
131
|
+
await Bun.file(`${dir}/indexes.ts`).exists(),
|
|
132
|
+
),
|
|
133
|
+
)
|
|
134
|
+
MESSAGES.MIGRATE_SCAFFOLDED({ dir: 'orm/' })
|
|
135
|
+
}
|
|
136
|
+
|
|
55
137
|
static async run() {
|
|
56
138
|
// Before initConfig/initDB/loadSchema: help must not depend on a working
|
|
57
139
|
// connection, a loadable schema, or the absence of a `foreign()`.
|
|
@@ -97,12 +179,24 @@ Flags:
|
|
|
97
179
|
MESSAGES.NO_DBINFO()
|
|
98
180
|
}
|
|
99
181
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
182
|
+
// `--migrate` on a project with no schema at all creates the folder layout
|
|
183
|
+
// rather than a single `schema.ts`. Adoption is exactly the case where the
|
|
184
|
+
// folder earns its keep: the generator owns `tables.ts` and regenerating it
|
|
185
|
+
// cannot touch the views, indexes and registration beside it — which for an
|
|
186
|
+
// adopted database is the difference between re-running the command and
|
|
187
|
+
// hand-restoring what it overwrote.
|
|
188
|
+
const adopting = SyncService.migrateRequested() && loaded.layout === 'none'
|
|
189
|
+
const layout = adopting ? 'folder' : loaded.layout
|
|
190
|
+
const targetPath = adopting ? `${process.cwd()}/orm/tables.ts` : schemaPath
|
|
191
|
+
|
|
192
|
+
await connection.syncSchema(constraints, tsIndexes, targetPath, layout)
|
|
193
|
+
|
|
194
|
+
// After generation, not before: `index.ts` re-exports `views.ts` and
|
|
195
|
+
// `indexes.ts`, which the generator writes only when the database has views
|
|
196
|
+
// or indexes to write. Deciding what to import before knowing which files
|
|
197
|
+
// exist is how the first version produced a barrel pointing at nothing.
|
|
198
|
+
if (adopting) await SyncService.writeOrmIndex(process.cwd())
|
|
199
|
+
|
|
106
200
|
await closeDB()
|
|
107
201
|
}
|
|
108
202
|
}
|
package/src/sync/ledger.ts
CHANGED
|
@@ -216,26 +216,50 @@ export function stripLedger(
|
|
|
216
216
|
* same normalisation the ledger exists to avoid, and a disagreement there is
|
|
217
217
|
* exactly what the ledger is more trustworthy about. Names are names in every
|
|
218
218
|
* dialect, so this check cannot itself be wrong in the way the others were.
|
|
219
|
+
*
|
|
220
|
+
* **But a name has two spellings, and the two sides do not use the same one.**
|
|
221
|
+
* The ledger stores the keys of your TypeScript schema — whatever you passed to
|
|
222
|
+
* `table()` / `view()` — while `getConstraints()` camelCases everything it reads
|
|
223
|
+
* back. So a table declared `view('published_posts', …)` is `published_posts` in
|
|
224
|
+
* the ledger and `publishedPosts` from introspection, and this check called that
|
|
225
|
+
* a drifted database: *"tables differ (+publishedPosts; -published_posts)"*, on
|
|
226
|
+
* a database nothing had touched. Permanently — the spellings never converge, so
|
|
227
|
+
* every later sync re-reported it and the ledger was never used again. Every app
|
|
228
|
+
* `bun create bakery` generated hit it on the first `db:sync`, because the
|
|
229
|
+
* generated schema declares exactly that view.
|
|
230
|
+
*
|
|
231
|
+
* The damage is quieter than the warning. Falling back to introspection is
|
|
232
|
+
* *safe*, so nothing breaks loudly — it just silently withdraws the thing the
|
|
233
|
+
* ledger is for. Enum member changes, for one, only migrate when the diff runs
|
|
234
|
+
* against the ledger (`plan.ledgerSource === 'ledger'`), so on any such app that
|
|
235
|
+
* feature was inert.
|
|
236
|
+
*
|
|
237
|
+
* Comparing camel-normalised names fixes it at the one place the two spellings
|
|
238
|
+
* meet. `LEDGER_ALIASES` below is the same bug, found earlier and patched for a
|
|
239
|
+
* single known name; this is the general form of it.
|
|
219
240
|
*/
|
|
220
241
|
export function shapesMatch(
|
|
221
242
|
ledger: SyncTypes.DBConstraints,
|
|
222
243
|
live: SyncTypes.DBConstraints,
|
|
223
244
|
): { ok: true } | { ok: false; reason: string } {
|
|
224
245
|
const meta = (k: string) => k.startsWith('_')
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
Object.keys(
|
|
231
|
-
|
|
232
|
-
|
|
246
|
+
// Compare on the normalised spelling, report the declared one — a diff that
|
|
247
|
+
// named tables the reader cannot find in either their schema or their database
|
|
248
|
+
// would trade one confusion for another.
|
|
249
|
+
const namesOf = (o: object) => {
|
|
250
|
+
const out = new Map<string, string>()
|
|
251
|
+
for (const k of Object.keys(o ?? {})) {
|
|
252
|
+
if (!meta(k)) out.set(Case.camel(k), k)
|
|
253
|
+
}
|
|
254
|
+
return out
|
|
255
|
+
}
|
|
256
|
+
const keysOf = (o: object) => [...namesOf(o).keys()].sort()
|
|
233
257
|
|
|
234
|
-
const a =
|
|
235
|
-
const b =
|
|
236
|
-
if (
|
|
237
|
-
const added = b.filter(
|
|
238
|
-
const gone = a.filter(
|
|
258
|
+
const a = namesOf(ledger)
|
|
259
|
+
const b = namesOf(live)
|
|
260
|
+
if (keysOf(ledger).join() !== keysOf(live).join()) {
|
|
261
|
+
const added = [...b].filter(([k]) => !a.has(k)).map(([, name]) => name)
|
|
262
|
+
const gone = [...a].filter(([k]) => !b.has(k)).map(([, name]) => name)
|
|
239
263
|
return {
|
|
240
264
|
ok: false,
|
|
241
265
|
reason: `tables differ (${added.length ? `+${added.join(', ')}` : ''}${
|
|
@@ -244,16 +268,52 @@ export function shapesMatch(
|
|
|
244
268
|
}
|
|
245
269
|
}
|
|
246
270
|
|
|
247
|
-
for (const
|
|
248
|
-
const lc =
|
|
249
|
-
const dc =
|
|
271
|
+
for (const [key, name] of a) {
|
|
272
|
+
const lc = keysOf((ledger as any)[name])
|
|
273
|
+
const dc = keysOf((live as any)[b.get(key) as string])
|
|
250
274
|
if (lc.join() !== dc.join()) {
|
|
251
|
-
return { ok: false, reason: `columns of ${
|
|
275
|
+
return { ok: false, reason: `columns of ${name} differ` }
|
|
252
276
|
}
|
|
253
277
|
}
|
|
254
278
|
return { ok: true }
|
|
255
279
|
}
|
|
256
280
|
|
|
281
|
+
/**
|
|
282
|
+
* Re-key a ledger payload the way introspection keys its own.
|
|
283
|
+
*
|
|
284
|
+
* The ledger stores the keys of your TypeScript schema verbatim; every consumer
|
|
285
|
+
* of "current state" downstream looks tables up by `Case.camel(name)`, because
|
|
286
|
+
* that is what `getConstraints()` produces. Handing the raw ledger to the
|
|
287
|
+
* planner therefore made every lookup miss — `diffViews` asked for
|
|
288
|
+
* `publishedPosts`, the ledger held `published_posts`, and a miss reads as "the
|
|
289
|
+
* database does not have this view", so the view was recreated on every single
|
|
290
|
+
* sync. Silent and harmless-looking; a view holds no data, so the only symptom
|
|
291
|
+
* is churn in the log.
|
|
292
|
+
*
|
|
293
|
+
* Normalising on read rather than on write is deliberate: it repairs the ledgers
|
|
294
|
+
* already written by earlier versions, which a write-side fix could not.
|
|
295
|
+
*
|
|
296
|
+
* Meta keys (`_view`, `_references`, …) are values, not identifiers, and are
|
|
297
|
+
* copied through untouched.
|
|
298
|
+
*/
|
|
299
|
+
function normalizeLedgerKeys(
|
|
300
|
+
constraints: SyncTypes.DBConstraints,
|
|
301
|
+
): SyncTypes.DBConstraints {
|
|
302
|
+
const out: any = {}
|
|
303
|
+
for (const [table, cols] of Object.entries(constraints)) {
|
|
304
|
+
if (table.startsWith('_') || !cols || typeof cols !== 'object') {
|
|
305
|
+
out[table] = cols
|
|
306
|
+
continue
|
|
307
|
+
}
|
|
308
|
+
const next: any = {}
|
|
309
|
+
for (const [col, def] of Object.entries(cols)) {
|
|
310
|
+
next[col.startsWith('_') ? col : Case.camel(col)] = def
|
|
311
|
+
}
|
|
312
|
+
out[Case.camel(table)] = next
|
|
313
|
+
}
|
|
314
|
+
return out
|
|
315
|
+
}
|
|
316
|
+
|
|
257
317
|
/**
|
|
258
318
|
* The state sync should diff against: the ledger when it is still true of the
|
|
259
319
|
* database, introspection otherwise.
|
|
@@ -289,14 +349,15 @@ export async function resolveCurrentState(
|
|
|
289
349
|
reason: 'ledger ignored (--no-ledger)',
|
|
290
350
|
}
|
|
291
351
|
}
|
|
292
|
-
const
|
|
293
|
-
if (!
|
|
352
|
+
const raw = await readLedger(adapter)
|
|
353
|
+
if (!raw)
|
|
294
354
|
return {
|
|
295
355
|
constraints: live,
|
|
296
356
|
source: 'introspection',
|
|
297
357
|
reason: 'no ledger yet',
|
|
298
358
|
}
|
|
299
359
|
|
|
360
|
+
const ledger = normalizeLedgerKeys(raw)
|
|
300
361
|
const match = shapesMatch(ledger, live)
|
|
301
362
|
if (!match.ok) {
|
|
302
363
|
return { constraints: live, source: 'introspection', reason: match.reason }
|