@bakery-framework/orm 1.2.1 → 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 +1 -1
- package/src/sync/builder.ts +121 -10
- package/src/sync/engine.ts +9 -1
- package/src/sync/index.ts +38 -2
package/package.json
CHANGED
package/src/sync/builder.ts
CHANGED
|
@@ -184,7 +184,20 @@ export class SchemaBuilder {
|
|
|
184
184
|
if (r.onUpdate) opts.push(`onUpdate: '${r.onUpdate}'`)
|
|
185
185
|
parts.push(`_references: { ${opts.join(', ')} }`)
|
|
186
186
|
}
|
|
187
|
-
|
|
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`
|
|
188
201
|
}
|
|
189
202
|
|
|
190
203
|
/**
|
|
@@ -505,12 +518,106 @@ ${body}`
|
|
|
505
518
|
* neither is the generator's to rewrite. That separation is the reason the
|
|
506
519
|
* folder layout exists (see `load.ts`).
|
|
507
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
|
+
|
|
508
607
|
private static buildTableModule(
|
|
509
608
|
constraints: Record<string, any>,
|
|
510
609
|
adapter: SQLAdapter,
|
|
511
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
|
+
|
|
512
618
|
let body = ''
|
|
513
|
-
for (const
|
|
619
|
+
for (const tableName of order) {
|
|
620
|
+
const cols = constraints[tableName]
|
|
514
621
|
// Views are left to `views.ts`, exactly as indexes are left to
|
|
515
622
|
// `indexes.ts`. This file is the only one the generator owns; emitting a
|
|
516
623
|
// view here as well would leave the same declaration in two files after
|
|
@@ -521,15 +628,19 @@ ${body}`
|
|
|
521
628
|
for (const [colName, cons] of Object.entries(
|
|
522
629
|
cols as Record<string, SyncTypes.ColumnConstraint>,
|
|
523
630
|
)) {
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
+
)
|
|
532
642
|
}
|
|
643
|
+
declared.add(tableName)
|
|
533
644
|
body += `export const ${exportNameFor(tableName)} = table('${tableName}', {\n${colsStr}})\n\n`
|
|
534
645
|
}
|
|
535
646
|
|
package/src/sync/engine.ts
CHANGED
|
@@ -334,7 +334,15 @@ export class SyncEngine {
|
|
|
334
334
|
// ledger, so the first real sync afterwards diffs against introspection
|
|
335
335
|
// instead: the one place enum changes are invisible.
|
|
336
336
|
if (process.argv.includes('--migrate')) {
|
|
337
|
-
|
|
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({})
|
|
338
346
|
await SyncEngine.seedLedger(adapter)
|
|
339
347
|
MESSAGES.MIGRATE_DONE()
|
|
340
348
|
return
|
package/src/sync/index.ts
CHANGED
|
@@ -16,6 +16,8 @@ const syncMsgs = {
|
|
|
16
16
|
SCHEMA_NOT_FOUND:
|
|
17
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
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.',
|
|
19
21
|
} as const
|
|
20
22
|
|
|
21
23
|
const MESSAGES = messageLogger(logger, syncMsgs)
|
|
@@ -134,6 +136,31 @@ Flags:
|
|
|
134
136
|
MESSAGES.MIGRATE_SCAFFOLDED({ dir: 'orm/' })
|
|
135
137
|
}
|
|
136
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
|
+
|
|
137
164
|
static async run() {
|
|
138
165
|
// Before initConfig/initDB/loadSchema: help must not depend on a working
|
|
139
166
|
// connection, a loadable schema, or the absence of a `foreign()`.
|
|
@@ -185,7 +212,13 @@ Flags:
|
|
|
185
212
|
// cannot touch the views, indexes and registration beside it — which for an
|
|
186
213
|
// adopted database is the difference between re-running the command and
|
|
187
214
|
// hand-restoring what it overwrote.
|
|
188
|
-
|
|
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()
|
|
189
222
|
const layout = adopting ? 'folder' : loaded.layout
|
|
190
223
|
const targetPath = adopting ? `${process.cwd()}/orm/tables.ts` : schemaPath
|
|
191
224
|
|
|
@@ -195,7 +228,10 @@ Flags:
|
|
|
195
228
|
// `indexes.ts`, which the generator writes only when the database has views
|
|
196
229
|
// or indexes to write. Deciding what to import before knowing which files
|
|
197
230
|
// exist is how the first version produced a barrel pointing at nothing.
|
|
198
|
-
if (adopting)
|
|
231
|
+
if (adopting) {
|
|
232
|
+
await SyncService.writeOrmIndex(process.cwd())
|
|
233
|
+
await SyncService.retireSingleFileSchema(process.cwd(), loaded.layout)
|
|
234
|
+
}
|
|
199
235
|
|
|
200
236
|
await closeDB()
|
|
201
237
|
}
|