@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.
- package/LICENSE +19 -0
- package/README.md +88 -0
- package/package.json +59 -0
- package/src/adapters/base.ts +1128 -0
- package/src/adapters/mysql.ts +619 -0
- package/src/adapters/observe.ts +261 -0
- package/src/adapters/pgsql.ts +611 -0
- package/src/adapters/registry.ts +204 -0
- package/src/adapters/sqlite.ts +588 -0
- package/src/adapters.ts +72 -0
- package/src/backup.ts +37 -0
- package/src/connection.ts +69 -0
- package/src/define.ts +380 -0
- package/src/field.ts +595 -0
- package/src/globals.d.ts +22 -0
- package/src/index.ts +63 -0
- package/src/orm/index.ts +24 -0
- package/src/orm/mutation.ts +692 -0
- package/src/orm/query.ts +1680 -0
- package/src/pool.ts +83 -0
- package/src/schema-registry.ts +75 -0
- package/src/schema-util.ts +467 -0
- package/src/sync/builder.ts +618 -0
- package/src/sync/engine.ts +399 -0
- package/src/sync/helpers.ts +1119 -0
- package/src/sync/history.ts +169 -0
- package/src/sync/index.ts +113 -0
- package/src/sync/ledger.ts +335 -0
- package/src/sync/load.ts +368 -0
- package/src/sync/rollback.ts +200 -0
- package/src/sync/types.ts +101 -0
- package/src/sync/view-sql.ts +160 -0
- package/templates/schema.example.ts +94 -0
|
@@ -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
|
+
}
|