@bakery-framework/orm 2.0.0-alpha.8 → 2.0.0-rc.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/README.md +9 -9
- package/package.json +3 -3
- package/src/adapters/base.ts +181 -62
- package/src/adapters/mysql.ts +29 -17
- package/src/adapters/observe.ts +11 -11
- package/src/adapters/pgsql.ts +54 -39
- package/src/adapters/registry.ts +13 -13
- package/src/adapters/sqlite.ts +175 -44
- package/src/adapters.ts +4 -4
- package/src/backup.ts +1 -1
- package/src/connection.ts +1 -1
- package/src/define.ts +22 -22
- package/src/field.ts +35 -35
- package/src/globals.d.ts +1 -1
- package/src/index.ts +4 -4
- package/src/orm/index.ts +2 -2
- package/src/orm/mutation.ts +36 -28
- package/src/orm/query.ts +92 -89
- package/src/pool.ts +5 -5
- package/src/schema-registry.ts +3 -3
- package/src/schema-util.ts +48 -17
- package/src/sync/builder.ts +32 -32
- package/src/sync/diff.ts +12 -12
- package/src/sync/engine.ts +19 -19
- package/src/sync/execute.ts +16 -16
- package/src/sync/history.ts +5 -5
- package/src/sync/index.ts +12 -12
- package/src/sync/ledger.ts +23 -23
- package/src/sync/load.ts +16 -16
- package/src/sync/plan.ts +1 -2
- package/src/sync/rename.ts +1 -1
- package/src/sync/rollback.ts +3 -3
- package/src/sync/types.ts +20 -4
- package/src/sync/view-sql.ts +9 -9
- package/templates/schema.example.ts +4 -4
package/src/schema-util.ts
CHANGED
|
@@ -24,12 +24,12 @@ export type DataTypes = keyof TypeMap
|
|
|
24
24
|
* Three parameters, where there were five (`type, default, nullable,
|
|
25
25
|
* autoIncrement, primary`). The two that went were positional booleans nothing
|
|
26
26
|
* outside `Field.Primary()` ever set, and `default` was carried only so
|
|
27
|
-
* nullability and optionality could be *derived* from it
|
|
27
|
+
* nullability and optionality could be *derived* from it, which is why
|
|
28
28
|
* "defaulted but not nullable" and "nullable" were indistinguishable to
|
|
29
29
|
* anything downstream.
|
|
30
30
|
*
|
|
31
31
|
* **`T` is the row type, not a dialect name.** `number`, `string | null`, or an
|
|
32
|
-
* enum's own union
|
|
32
|
+
* enum's own union, so `ExtractTableTypes` is a lookup rather than a mapping
|
|
33
33
|
* through `TypeMap`, and a type `TypeMap` cannot express (an enum, a branded
|
|
34
34
|
* id) needs no special case.
|
|
35
35
|
*
|
|
@@ -61,9 +61,8 @@ export const dateNow = '%dateNow%'
|
|
|
61
61
|
* The row type of a column descriptor.
|
|
62
62
|
*
|
|
63
63
|
* Two forms reach here. A `Field.*` builder returns `TableDef<T, …>`, whose
|
|
64
|
-
* `type` **is** the row type, so this is a lookup. A hand-written literal
|
|
65
|
-
*
|
|
66
|
-
* does not spell — carries a dialect name there instead, which still goes
|
|
64
|
+
* `type` **is** the row type, so this is a lookup. A hand-written literal ( * `{ type: 'integer', default: 0, nullable: true }`, the one shape `Field`
|
|
65
|
+
* does not spell) carries a dialect name there instead, which still goes
|
|
67
66
|
* through `TypeMap`.
|
|
68
67
|
*
|
|
69
68
|
* Distinguished by whether `type` is one of `TypeMap`'s keys. That is
|
|
@@ -85,12 +84,12 @@ export type ExtractTableTypes<C, K extends keyof C> = {
|
|
|
85
84
|
* Which columns may be omitted from an `INSERT`.
|
|
86
85
|
*
|
|
87
86
|
* `optional: true` when a `Field.*` builder said so, which is the whole check
|
|
88
|
-
* for anything `Field` produces
|
|
87
|
+
* for anything `Field` produces, it states optionality rather than leaving it
|
|
89
88
|
* to be reconstructed. The three derived conditions below it are the fallback
|
|
90
89
|
* for a hand-written literal, which has no `optional` key.
|
|
91
90
|
*
|
|
92
91
|
* Stating it also makes a shape expressible that derivation could not:
|
|
93
|
-
* **optional but neither nullable nor defaulted
|
|
92
|
+
* **optional but neither nullable nor defaulted**: a column the database fills
|
|
94
93
|
* in, such as a generated key.
|
|
95
94
|
*/
|
|
96
95
|
export type ExtractOptionals<C, T extends keyof C> = {
|
|
@@ -149,7 +148,7 @@ export const SQL_FUNCTIONS = new Set([
|
|
|
149
148
|
* `ROW_NUMBER()` outside an `OVER` clause is a syntax error on all three
|
|
150
149
|
* dialects, so putting it in the general list would let the builder emit SQL
|
|
151
150
|
* that cannot run. The aggregates in `SQL_FUNCTIONS` work in *both* positions
|
|
152
|
-
* and stay where they are
|
|
151
|
+
* and stay where they are: `evalOperands` accepts either list inside a window.
|
|
153
152
|
*
|
|
154
153
|
* Verified present on all three servers: SQLite has had window functions since
|
|
155
154
|
* 3.25, MySQL since 8.0, Postgres throughout.
|
|
@@ -171,15 +170,15 @@ export const WINDOW_FUNCTIONS = new Set([
|
|
|
171
170
|
/**
|
|
172
171
|
* A function call with an `OVER (…)` clause.
|
|
173
172
|
*
|
|
174
|
-
* `spec` is the inside of the parentheses
|
|
175
|
-
*
|
|
173
|
+
* `spec` is the inside of the parentheses (`PARTITION BY "a" ORDER BY "b" ASC`
|
|
174
|
+
*), and arrives **already validated and quoted**, exactly like `_joins[].on`.
|
|
176
175
|
* That is deliberate: `safeColumn` lives in the query builder, and duplicating
|
|
177
176
|
* the identifier rules here would make two writers of the same SQL, which is
|
|
178
177
|
* the thing convention 8 exists to prevent. This class stores; the builder
|
|
179
178
|
* validates.
|
|
180
179
|
*
|
|
181
180
|
* No frame clause (`ROWS BETWEEN …`). It is legal everywhere and would fit, but
|
|
182
|
-
* it has its own grammar and nobody has asked
|
|
181
|
+
* it has its own grammar and nobody has asked: an empty frame is the SQL
|
|
183
182
|
* default and is what every call here gets.
|
|
184
183
|
*/
|
|
185
184
|
export class WindowRef {
|
|
@@ -205,13 +204,13 @@ function activeQuoteChar(): string {
|
|
|
205
204
|
|
|
206
205
|
/**
|
|
207
206
|
* Memo for `Case.snake`, which is the single most expensive step in quoting an
|
|
208
|
-
* identifier
|
|
207
|
+
* identifier (measurably more than the quoting itself), and is re-run for the
|
|
209
208
|
* same handful of table and column names on every clause of every query.
|
|
210
209
|
*
|
|
211
210
|
* A capped `Map` rather than `LRUCache`: an LRU `get` deletes and re-inserts on
|
|
212
211
|
* every hit to maintain recency, which measured ~38x slower than `Map.get` and
|
|
213
212
|
* would cost more than the `Case.snake` call it is meant to replace. Convention
|
|
214
|
-
* 6 asks for bounded, and this is bounded by construction
|
|
213
|
+
* 6 asks for bounded, and this is bounded by construction: identifiers are
|
|
215
214
|
* schema-derived in practice, but `DB.col()` takes a caller-supplied string, so
|
|
216
215
|
* the bound cannot rest on that. Dropping the whole map on overflow is correct
|
|
217
216
|
* because every entry is recomputable: it is a memo, not state.
|
|
@@ -281,7 +280,7 @@ export class OperatorRef<R = any> {
|
|
|
281
280
|
|
|
282
281
|
// Convention 8 makes this the single writer of SQL identifiers, so the arms
|
|
283
282
|
// belong together: splitting by operand kind spreads that responsibility.
|
|
284
|
-
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: recursive operand evaluator
|
|
283
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: recursive operand evaluator, one arm per operand shape
|
|
285
284
|
export function evalOperands(
|
|
286
285
|
where: unknown,
|
|
287
286
|
params: unknown[],
|
|
@@ -324,7 +323,7 @@ export function evalOperands(
|
|
|
324
323
|
if (typeof where === 'object' && where !== null) {
|
|
325
324
|
if (where instanceof WindowRef) {
|
|
326
325
|
// The function half only. `spec` was built by the query builder out of
|
|
327
|
-
// `safeColumn` output and is inserted as-is
|
|
326
|
+
// `safeColumn` output and is inserted as-is. See the class comment.
|
|
328
327
|
let call: string
|
|
329
328
|
if (where.fn instanceof SQLFunctionRef) {
|
|
330
329
|
call = evalOperands(where.fn, params, true)
|
|
@@ -348,7 +347,7 @@ export function evalOperands(
|
|
|
348
347
|
const colArg = where.col
|
|
349
348
|
const extraArgs = where.extraArgs || []
|
|
350
349
|
|
|
351
|
-
// fnName is interpolated, not bound
|
|
350
|
+
// fnName is interpolated, not bound. Only known functions may pass.
|
|
352
351
|
if (!SQL_FUNCTIONS.has(fnName)) {
|
|
353
352
|
throws(`Unsupported SQL function: ${where.fnName}`)
|
|
354
353
|
}
|
|
@@ -436,7 +435,7 @@ export function old<TSchema extends SyncTypes.DBConstraints>(
|
|
|
436
435
|
* position, so it is where the two vocabularies meet: `ColumnConstraint.type`
|
|
437
436
|
* is the dialect name the sync engine reads, while a `Field.*` builder declares
|
|
438
437
|
* `type` as the **row type**. Requiring the sync-side shape here rejected every
|
|
439
|
-
* `Field` value
|
|
438
|
+
* `Field` value: `old('title', Field.Varchar(255))` stopped compiling.
|
|
440
439
|
*
|
|
441
440
|
* `old()` only copies the object and adds two keys, so it needs no more than
|
|
442
441
|
* "something with a `type`", and returning `T` unchanged means the row type and
|
|
@@ -465,3 +464,35 @@ export function old(
|
|
|
465
464
|
_transform: transform,
|
|
466
465
|
})
|
|
467
466
|
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* The SQL spelling of a comparison against an explicit `null`, or `null` when
|
|
470
|
+
* the comparison is an ordinary one.
|
|
471
|
+
*
|
|
472
|
+
* A bound NULL can never satisfy `=` or `<>`: three-valued logic makes both
|
|
473
|
+
* UNKNOWN for every row, so `where(col, null)` matched nothing while reporting
|
|
474
|
+
* success. On a `SELECT` that is an empty result; on a `DELETE` or an
|
|
475
|
+
* `UPDATE` it is a write that silently does nothing and answers zero changes,
|
|
476
|
+
* which a caller cannot tell from a genuine conflict.
|
|
477
|
+
*
|
|
478
|
+
* **This lives here, not in a `WHERE` builder, because there are three of
|
|
479
|
+
* them.** `formatClause` in `orm/query.ts` and the two `evalWhere` copies in
|
|
480
|
+
* `orm/mutation.ts` each compile a clause, and the copies have now diverged
|
|
481
|
+
* twice: once over the one-argument `where(DB.raw\`…\`)` form, which the
|
|
482
|
+
* comment in `DeleteExecutable.evalWhere` records, and once over this rule,
|
|
483
|
+
* which reached the builder and neither mutation path. Both were silent. One
|
|
484
|
+
* function three callers share cannot drift a third time.
|
|
485
|
+
*
|
|
486
|
+
* A column reference on the right is a different comparison and is left alone.
|
|
487
|
+
*/
|
|
488
|
+
export function nullComparison(
|
|
489
|
+
operator: string,
|
|
490
|
+
right: unknown,
|
|
491
|
+
isRightColumn?: boolean,
|
|
492
|
+
): string | null {
|
|
493
|
+
if (right !== null || isRightColumn) return null
|
|
494
|
+
const op = operator.toUpperCase()
|
|
495
|
+
if (op === '=') return 'IS NULL'
|
|
496
|
+
if (op === '!=' || op === '<>') return 'IS NOT NULL'
|
|
497
|
+
return null
|
|
498
|
+
}
|
package/src/sync/builder.ts
CHANGED
|
@@ -9,8 +9,8 @@ import { formatViewBody } from './view-sql'
|
|
|
9
9
|
/**
|
|
10
10
|
* Names that cannot bind a `const` in a module (modules are always strict), so
|
|
11
11
|
* they cannot be used as the export name for a generated `table()`. The export
|
|
12
|
-
* name is cosmetic
|
|
13
|
-
* the string passed to `table()`, not the binding
|
|
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
14
|
*/
|
|
15
15
|
const RESERVED_WORDS = new Set([
|
|
16
16
|
'await',
|
|
@@ -84,7 +84,7 @@ export class SchemaBuilder {
|
|
|
84
84
|
// Fall back to what introspection reported, not to `false`.
|
|
85
85
|
//
|
|
86
86
|
// The old `: false` flattened every view column to NOT NULL whenever
|
|
87
|
-
// there was no previous schema to copy from
|
|
87
|
+
// there was no previous schema to copy from, which is exactly the
|
|
88
88
|
// seeding path, the one a project with an existing database takes. On
|
|
89
89
|
// a real view that made `category` and `images` non-nullable in the
|
|
90
90
|
// generated interface while the database reports both nullable, so the
|
|
@@ -120,7 +120,7 @@ export class SchemaBuilder {
|
|
|
120
120
|
|
|
121
121
|
// **A null default is only spellable on a nullable column**, and conflating
|
|
122
122
|
// the two turned every NOT NULL column without a default into a nullable
|
|
123
|
-
// one. `Field`'s convention
|
|
123
|
+
// one. `Field`'s convention (stated in `asFieldCall` below) is that a null
|
|
124
124
|
// default *means* nullable, so `Field.Int(null)` does not describe
|
|
125
125
|
// `author_id integer NOT NULL`; it redefines it.
|
|
126
126
|
//
|
|
@@ -158,7 +158,7 @@ export class SchemaBuilder {
|
|
|
158
158
|
const named = SchemaBuilder.asFieldCall(cons, d, n)
|
|
159
159
|
if (named) return `${indent}${colName}: ${named},\n`
|
|
160
160
|
|
|
161
|
-
// Nothing in the `Field` vocabulary spells this column
|
|
161
|
+
// Nothing in the `Field` vocabulary spells this column, nullable *and*
|
|
162
162
|
// defaulted to something other than null, or an explicit `primary` that is
|
|
163
163
|
// not auto-increment.
|
|
164
164
|
//
|
|
@@ -168,7 +168,7 @@ export class SchemaBuilder {
|
|
|
168
168
|
// column, rather than dropping one it cannot spell.
|
|
169
169
|
const parts = [`type: '${cons.type}'`]
|
|
170
170
|
if (typeof cons.length === 'number') parts.push(`length: ${cons.length}`)
|
|
171
|
-
// `d` is already source text from `getDefaultValue
|
|
171
|
+
// `d` is already source text from `getDefaultValue`, a quoted literal, a
|
|
172
172
|
// bare number, `null`, or the identifier `dateNow`. The marker is spelled
|
|
173
173
|
// out here instead so the emitted file needs no import for it.
|
|
174
174
|
if (d !== undefined) {
|
|
@@ -187,10 +187,10 @@ export class SchemaBuilder {
|
|
|
187
187
|
// `as const`, and without it the folder layout loses these columns' types.
|
|
188
188
|
//
|
|
189
189
|
// `table()` takes `C extends ColumnMap`, which is `Record<string, unknown>`
|
|
190
|
-
|
|
190
|
+
//: a constraint that does not preserve literals, so `{ type: 'integer' }`
|
|
191
191
|
// widens to `{ type: string }` and `InferSchema` has no `'integer'` left to
|
|
192
|
-
// match. Every column the `Field` vocabulary cannot name
|
|
193
|
-
// foreign keys round-trip includes *every referencing column*
|
|
192
|
+
// match. Every column the `Field` vocabulary cannot name (which since
|
|
193
|
+
// foreign keys round-trip includes *every referencing column*), then infers
|
|
194
194
|
// as a string.
|
|
195
195
|
//
|
|
196
196
|
// Invisible in the single-file layout, where `DBInfo.constraints` is already
|
|
@@ -204,14 +204,14 @@ export class SchemaBuilder {
|
|
|
204
204
|
* Copy introspected foreign keys onto the columns that carry them.
|
|
205
205
|
*
|
|
206
206
|
* `getConstraints()` describes columns and `getForeignKeys()` describes
|
|
207
|
-
* references, and the generator only ever read the first
|
|
207
|
+
* references, and the generator only ever read the first, so a database whose
|
|
208
208
|
* `posts.author_id` references `users.id ON DELETE CASCADE` regenerated as a
|
|
209
209
|
* plain integer. The constraint stayed in the database, the schema stopped
|
|
210
210
|
* mentioning it, and the next sync therefore planned to rebuild the table to
|
|
211
211
|
* *remove* a key nobody asked to remove.
|
|
212
212
|
*
|
|
213
213
|
* Single-column keys only. A composite key cannot be a property of one column
|
|
214
|
-
*
|
|
214
|
+
* (it is declared with `foreign()` alongside the indexes), and inventing a
|
|
215
215
|
* per-column half of one would be worse than omitting it, so it is counted and
|
|
216
216
|
* reported rather than silently dropped.
|
|
217
217
|
*/
|
|
@@ -265,7 +265,7 @@ export class SchemaBuilder {
|
|
|
265
265
|
* anything else falls through to `value()` above rather than being
|
|
266
266
|
* approximated into a column that means something slightly different.
|
|
267
267
|
*
|
|
268
|
-
* `_enum` is deliberately absent
|
|
268
|
+
* `_enum` is deliberately absent: the members are not part of the column diff
|
|
269
269
|
* and are not introspected, so the database cannot tell us an enum from a
|
|
270
270
|
* `VARCHAR`, and guessing would silently invent a constraint.
|
|
271
271
|
*/
|
|
@@ -277,7 +277,7 @@ export class SchemaBuilder {
|
|
|
277
277
|
): string | null {
|
|
278
278
|
if (cons.primary || cons.autoIncrement) return null
|
|
279
279
|
// A referencing column has no `Field.*` spelling that also carries the
|
|
280
|
-
// reference
|
|
280
|
+
// reference, `Field.Foreign` needs the parent table's column *value* in
|
|
281
281
|
// scope, which the generated file cannot guarantee (introspection order is
|
|
282
282
|
// not declaration order, and the single-file layout has no table values at
|
|
283
283
|
// all). Fall through to the object literal, which spells `_references`
|
|
@@ -335,7 +335,7 @@ export class SchemaBuilder {
|
|
|
335
335
|
* The TypeScript type of a column, for a generated view interface.
|
|
336
336
|
*
|
|
337
337
|
* A view has no column DDL, so this is the only place its columns appear.
|
|
338
|
-
* Straight through `TypeMap`'s mapping, with `| null` for a nullable column
|
|
338
|
+
* Straight through `TypeMap`'s mapping, with `| null` for a nullable column:
|
|
339
339
|
* introspection reports that faithfully for a view's projected columns.
|
|
340
340
|
*/
|
|
341
341
|
private static tsTypeFor(cons: any): string {
|
|
@@ -365,13 +365,13 @@ export class SchemaBuilder {
|
|
|
365
365
|
* `SELECT ...`,
|
|
366
366
|
* )
|
|
367
367
|
*
|
|
368
|
-
* The interface is what a view *is
|
|
368
|
+
* The interface is what a view *is*, `CREATE VIEW` declares no column types,
|
|
369
369
|
* so emitting `Field.Varchar(64)` here would state a width the database
|
|
370
370
|
* neither stores nor enforces. It is also the thing worth exporting: the row
|
|
371
371
|
* type gets a name you can use in a signature.
|
|
372
372
|
*
|
|
373
373
|
* Both type arguments are written out because TypeScript stops inferring the
|
|
374
|
-
* rest once one is supplied, and the *name* has to stay a literal
|
|
374
|
+
* rest once one is supplied, and the *name* has to stay a literal: it is
|
|
375
375
|
* what the schema map is keyed on. Generated code, so the repetition is free.
|
|
376
376
|
*/
|
|
377
377
|
private static buildViewModule(
|
|
@@ -406,14 +406,14 @@ export class SchemaBuilder {
|
|
|
406
406
|
*
|
|
407
407
|
* A view is a stored SELECT; it has no column DDL, so each one is described by
|
|
408
408
|
* an interface rather than by column builders. Edit the SELECT here and the
|
|
409
|
-
* next sync recreates the view
|
|
409
|
+
* next sync recreates the view: views hold no data, so there is nothing to
|
|
410
410
|
* migrate.
|
|
411
411
|
*
|
|
412
412
|
* **This file is yours from now on. The generator writes it once and never
|
|
413
413
|
* overwrites it**, because the interfaces are the part worth editing by hand:
|
|
414
414
|
* introspection can only report a JSON column as \`unknown\`, and the shape it
|
|
415
|
-
* actually holds
|
|
416
|
-
* \`json_arrayagg(json_object(...))
|
|
415
|
+
* actually holds: \`{ id: number; name: string }[]\` for a
|
|
416
|
+
* \`json_arrayagg(json_object(...))\`. Is knowledge only you have. Regenerating
|
|
417
417
|
* over that would throw away the reason for writing it down.
|
|
418
418
|
*
|
|
419
419
|
* A view added to the database later will not appear here on its own; add it,
|
|
@@ -472,7 +472,7 @@ ${body}`
|
|
|
472
472
|
return `/**
|
|
473
473
|
* Generated from the database by \`db:sync\`.
|
|
474
474
|
*
|
|
475
|
-
* Seeded once and never overwritten
|
|
475
|
+
* Seeded once and never overwritten, unlike \`tables.ts\`. An index this file
|
|
476
476
|
* does not declare is dropped by the next TS-wins sync, so add new ones here
|
|
477
477
|
* rather than only in the database.
|
|
478
478
|
*
|
|
@@ -495,7 +495,7 @@ ${body}`
|
|
|
495
495
|
// `Field.Index` / `Field.Unique`, capitalised from the stored `index` /
|
|
496
496
|
// `unique` type. This emitted the bare `index(…)` / `unique(…)` names
|
|
497
497
|
// until they were removed, at which point the generated file referenced
|
|
498
|
-
// two identifiers it did not import
|
|
498
|
+
// two identifiers it did not import: invisible here because the *tables*
|
|
499
499
|
// are what the round-trip test imports, not the index block.
|
|
500
500
|
const fn = idx.type === 'unique' ? 'Field.Unique' : 'Field.Index'
|
|
501
501
|
result += ` ${idxName}: ${fn}('${idx.table}', ${colsStr}),\n`
|
|
@@ -523,7 +523,7 @@ ${body}`
|
|
|
523
523
|
*
|
|
524
524
|
* A depth-first walk with a visiting set, which is also the cycle break: a
|
|
525
525
|
* table already on the stack is left where it is, and the column pointing back
|
|
526
|
-
* at it falls through to the object literal
|
|
526
|
+
* at it falls through to the object literal, correct, just not pretty.
|
|
527
527
|
* Circular references between tables are legal SQL and this must not fail on
|
|
528
528
|
* them or, worse, emit a forward reference that is `undefined` at module
|
|
529
529
|
* evaluation.
|
|
@@ -561,7 +561,7 @@ ${body}`
|
|
|
561
561
|
/**
|
|
562
562
|
* `Field.Foreign(parent.column, …)` when the parent is already in scope.
|
|
563
563
|
*
|
|
564
|
-
* Returns null when it is not
|
|
564
|
+
* Returns null when it is not (the cycle case above), so the caller falls
|
|
565
565
|
* back to the literal rather than emitting a reference to a `const` declared
|
|
566
566
|
* further down the file, which is a TDZ error at import time.
|
|
567
567
|
*/
|
|
@@ -630,7 +630,7 @@ ${body}`
|
|
|
630
630
|
colName,
|
|
631
631
|
cons,
|
|
632
632
|
adapter,
|
|
633
|
-
// Never a view here
|
|
633
|
+
// Never a view here. Those were skipped above.
|
|
634
634
|
false,
|
|
635
635
|
' ',
|
|
636
636
|
)
|
|
@@ -653,7 +653,7 @@ ${body}`
|
|
|
653
653
|
*
|
|
654
654
|
* Tables only. In the orm/ folder layout \`index.ts\` owns the re-exports and
|
|
655
655
|
* the schema registration, \`indexes.ts\` owns the index and unique
|
|
656
|
-
* declarations, and \`views.ts\` owns the views
|
|
656
|
+
* declarations, and \`views.ts\` owns the views: none of which is written here.
|
|
657
657
|
*
|
|
658
658
|
* The cost of that separation, worth knowing: an index or a view the database
|
|
659
659
|
* has and its file does not declare is dropped by the next TS-wins sync. It
|
|
@@ -672,7 +672,7 @@ ${body}`
|
|
|
672
672
|
*
|
|
673
673
|
* `Field` is imported from the package root and the types from
|
|
674
674
|
* `schema-util`, in two statements rather than one, because `schema-util`
|
|
675
|
-
* cannot re-export `Field` without closing a cycle
|
|
675
|
+
* cannot re-export `Field` without closing a cycle: `field.ts` calls
|
|
676
676
|
* `value`/`primary`/`index`/`unique`, and this repo has already paid once for
|
|
677
677
|
* a cycle that typechecked and then failed at runtime.
|
|
678
678
|
*/
|
|
@@ -733,7 +733,7 @@ declare module '@bakery-framework/orm/schema-registry' {
|
|
|
733
733
|
messages.GEN_TYPES()
|
|
734
734
|
|
|
735
735
|
// Stripped, or `--choose=db` writes `__bakery_schema` into the app's own
|
|
736
|
-
// schema as an ordinary table
|
|
736
|
+
// schema as an ordinary table, after which sync manages the ledger, the
|
|
737
737
|
// ledger records itself, and the shape check never matches again. The diff
|
|
738
738
|
// path strips it in `resolveCurrentState`; this path reads the adapter
|
|
739
739
|
// directly and was missing it.
|
|
@@ -768,7 +768,7 @@ declare module '@bakery-framework/orm/schema-registry' {
|
|
|
768
768
|
|
|
769
769
|
await Bun.write(schemaPath, source)
|
|
770
770
|
|
|
771
|
-
// `views.ts` beside `tables.ts`, and only in the folder layout
|
|
771
|
+
// `views.ts` beside `tables.ts`, and only in the folder layout: the
|
|
772
772
|
// single-file layout already carries views inside its `DBInfo` namespace.
|
|
773
773
|
//
|
|
774
774
|
// Written only when the database *has* views: creating an empty file, and
|
|
@@ -781,7 +781,7 @@ declare module '@bakery-framework/orm/schema-registry' {
|
|
|
781
781
|
)
|
|
782
782
|
if (viewsSource) {
|
|
783
783
|
const viewsPath = `${schemaPath.replace(/[^/\\]+$/, '')}views.ts`
|
|
784
|
-
// Seeded once, then never overwritten
|
|
784
|
+
// Seeded once, then never overwritten, unlike `tables.ts`, which the
|
|
785
785
|
// generator owns outright.
|
|
786
786
|
//
|
|
787
787
|
// The interfaces are the part worth editing by hand. Introspection can
|
|
@@ -804,7 +804,7 @@ declare module '@bakery-framework/orm/schema-registry' {
|
|
|
804
804
|
// way the single-file layout is not: `DBInfo` carries an `indexes` block,
|
|
805
805
|
// so `--choose=db` there round-trips them, while the folder layout left
|
|
806
806
|
// every index undeclared. A TS-wins sync then drops what the schema does
|
|
807
|
-
// not mention
|
|
807
|
+
// not mention, so regenerating a folder-layout schema from a database
|
|
808
808
|
// armed the *next* sync to delete every index in it.
|
|
809
809
|
const indexes = await adapter.getIndexes()
|
|
810
810
|
if (Object.keys(indexes).length) {
|
|
@@ -828,13 +828,13 @@ declare module '@bakery-framework/orm/schema-registry' {
|
|
|
828
828
|
* Copy the current schema aside before it is overwritten.
|
|
829
829
|
*
|
|
830
830
|
* `--choose=db` regenerates the schema wholesale, and anything hand-written
|
|
831
|
-
* in it
|
|
831
|
+
* in it (a comment, a `_view`, an `old()` rename wrapper) is gone. The
|
|
832
832
|
* database is backed up before a destructive sync; the schema, which is
|
|
833
833
|
* source, was not. It is also gitignored, so `git checkout` cannot recover
|
|
834
834
|
* it either: this is the one file with no other safety net.
|
|
835
835
|
*
|
|
836
836
|
* Stored under `bakery/backups` (`Bakery.dataDir`) rather than the cache,
|
|
837
|
-
* because a cache is defined as safe to delete and this is not
|
|
837
|
+
* because a cache is defined as safe to delete and this is not: the
|
|
838
838
|
* framework itself deletes the cache directory on every version bump.
|
|
839
839
|
*/
|
|
840
840
|
private static async preserveExisting(
|
package/src/sync/diff.ts
CHANGED
|
@@ -34,7 +34,7 @@ function diffColumnMismatch(
|
|
|
34
34
|
// TEXT trap that made this dangerous to add blind.
|
|
35
35
|
//
|
|
36
36
|
// Driven by the *schema* side only. When the schema declares a width, any
|
|
37
|
-
// other answer from the database differs
|
|
37
|
+
// other answer from the database differs, including no width at all, which
|
|
38
38
|
// is a real `TEXT` column that should become `VARCHAR(n)`. Requiring both
|
|
39
39
|
// sides to be sized meant sizing an existing TEXT column silently did
|
|
40
40
|
// nothing, and `db:sync` then reported a perfectly synced database whose
|
|
@@ -44,16 +44,16 @@ function diffColumnMismatch(
|
|
|
44
44
|
// (measured, see `SQLAdapter.sizedTextLength`): after one rebuild the two
|
|
45
45
|
// agree. A column that reports *no* width really is unsized.
|
|
46
46
|
//
|
|
47
|
-
// When the schema declares no width, nothing differs
|
|
47
|
+
// When the schema declares no width, nothing differs: `Field.Text()` against
|
|
48
48
|
// an existing `VARCHAR` is not a request to shrink it.
|
|
49
49
|
const lengthDiffers =
|
|
50
50
|
typeof tsCol.length === 'number' && tsCol.length !== dbCol.length
|
|
51
51
|
|
|
52
52
|
// Enum members join the diff, so changing them migrates instead of silently
|
|
53
|
-
// doing nothing
|
|
53
|
+
// doing nothing, but **only when the current state came from the ledger**.
|
|
54
54
|
//
|
|
55
55
|
// `_enum` is emitted as an inline `CHECK (col IN (...))` by all three
|
|
56
|
-
// dialects, and all three *will* report that constraint back
|
|
56
|
+
// dialects, and all three *will* report that constraint back: in three
|
|
57
57
|
// incompatible shapes. Measured:
|
|
58
58
|
//
|
|
59
59
|
// sqlite CHECK (status IN ('draft','live')) in the table DDL
|
|
@@ -61,14 +61,14 @@ function diffColumnMismatch(
|
|
|
61
61
|
// pgsql CHECK (((status)::text = ANY ((ARRAY[...]))) re-rendered
|
|
62
62
|
//
|
|
63
63
|
// Postgres does not store the text it was given, it re-renders a parsed
|
|
64
|
-
// expression
|
|
64
|
+
// expression: the same trap that turned `EXTRACT` into `date_part` and
|
|
65
65
|
// rebuilt a table on every sync forever. Three parsers, each an opportunity
|
|
66
66
|
// for that bug, is the wrong trade when the ledger already holds the members
|
|
67
67
|
// exactly as declared.
|
|
68
68
|
//
|
|
69
69
|
// So under introspection this stays out of the diff. A schema-side-only
|
|
70
70
|
// comparison would find `_enum` on one side and nothing on the other, differ
|
|
71
|
-
// every time, and rebuild the table on every sync
|
|
71
|
+
// every time, and rebuild the table on every sync, which is precisely what
|
|
72
72
|
// the `length` note above says it waited to rule out before shipping.
|
|
73
73
|
const enumDiffers =
|
|
74
74
|
plan.ledgerSource === 'ledger' &&
|
|
@@ -238,12 +238,12 @@ function diffViewStrings(
|
|
|
238
238
|
constraints: any,
|
|
239
239
|
database?: string,
|
|
240
240
|
): boolean {
|
|
241
|
-
// Both sides through the same
|
|
241
|
+
// Both sides through the same canonicalizer, which is the only thing that
|
|
242
242
|
// makes a text comparison viable: you write `SELECT id FROM users` and MySQL
|
|
243
243
|
// returns it fully qualified, fully quoted and aliased column by column.
|
|
244
244
|
//
|
|
245
|
-
// Symmetry is the whole requirement.
|
|
246
|
-
// comparing raw
|
|
245
|
+
// Symmetry is the whole requirement. Normalizing the *generated file* while
|
|
246
|
+
// comparing raw (or stripping the schema on one side only) recreates the
|
|
247
247
|
// view on every sync, which is the same churn the column diff has hit twice.
|
|
248
248
|
const tsViewStr = normalizeViewBody(
|
|
249
249
|
String(constraints[camelTable]._view || ''),
|
|
@@ -265,7 +265,7 @@ function diffViewStrings(
|
|
|
265
265
|
* The view lifecycle: create, recreate, drop.
|
|
266
266
|
*
|
|
267
267
|
* Views were invisible to the planner. `initDbTablesMap` skips `_view` entries,
|
|
268
|
-
* and every existing comparison iterates that map
|
|
268
|
+
* and every existing comparison iterates that map, so a declared view never
|
|
269
269
|
* reached `diffViewStrings`, and nothing about a view ever reached
|
|
270
270
|
* `hasChanges`. The consequences, all three measured:
|
|
271
271
|
*
|
|
@@ -279,11 +279,11 @@ function diffViewStrings(
|
|
|
279
279
|
* reported a perfectly synced database and left the view alone.
|
|
280
280
|
*
|
|
281
281
|
* Bodies are compared through `normalizeViewBody` on both sides. That converges
|
|
282
|
-
* on SQLite, which stores the text verbatim, and via the ledger everywhere
|
|
282
|
+
* on SQLite, which stores the text verbatim, and via the ledger everywhere:
|
|
283
283
|
* the ledger records what was *applied*, so it holds the authored SELECT.
|
|
284
284
|
* Diffing against live introspection on MySQL or Postgres will still see a
|
|
285
285
|
* difference, because both re-render the body (MySQL re-qualifies every column,
|
|
286
|
-
* Postgres adds parentheses), and no amount of text
|
|
286
|
+
* Postgres adds parentheses), and no amount of text normalization short of a
|
|
287
287
|
* parser fixes that. It costs a recreate, and a view holds no data.
|
|
288
288
|
*/
|
|
289
289
|
export function diffViews(
|
package/src/sync/engine.ts
CHANGED
|
@@ -17,17 +17,17 @@ import type * as SyncTypes from './types'
|
|
|
17
17
|
export const syncMsgs = {
|
|
18
18
|
GEN_TYPES: 'I Generating types...',
|
|
19
19
|
// Counted rather than silently dropped: a composite key is not a property of
|
|
20
|
-
// one column, so the generator cannot spell it
|
|
20
|
+
// one column, so the generator cannot spell it, but a reader who is not told
|
|
21
21
|
// will believe the generated schema is complete.
|
|
22
22
|
GEN_COMPOSITE_FK:
|
|
23
|
-
'W %y{count}%* composite foreign key(s) could not be generated as column references
|
|
23
|
+
'W %y{count}%* composite foreign key(s) could not be generated as column references. Declare them with %yforeign()%* beside the indexes.',
|
|
24
24
|
INDEXES_SEEDED: 'I Seeded %y{file}%* from the database.',
|
|
25
25
|
INDEXES_KEPT:
|
|
26
26
|
'I Left %y{file}%* alone: index declarations are hand-owned once seeded. Delete it and re-run to reseed.',
|
|
27
27
|
MIGRATE_DONE:
|
|
28
28
|
'I %gAdopted the existing database%*. No tables were changed; run %ydb:sync%* to confirm nothing is pending.',
|
|
29
29
|
MIGRATE_NO_LEDGER:
|
|
30
|
-
'W Schema written, but the ledger could not be recorded
|
|
30
|
+
'W Schema written, but the ledger could not be recorded: the next sync will diff against live introspection instead.',
|
|
31
31
|
SYNC_SUCCESS: 'I %gschema.ts successfully synced%* to Database!',
|
|
32
32
|
INVALID_SCHEMA: 'W %yschema.ts is invalid or corrupt. Treating as new.%*',
|
|
33
33
|
NO_DBINFO: 'W %yDBInfo namespace not found in schema.ts!%*',
|
|
@@ -70,7 +70,7 @@ export const syncMsgs = {
|
|
|
70
70
|
EXEC_ADD_INDEX: 'I Creating %y{type}%* index: %g{name}%*...',
|
|
71
71
|
// Declared late. `executeSyncPlan` has always called these two, and
|
|
72
72
|
// `messageLogger`'s proxy answers an undeclared key with a live emitter that
|
|
73
|
-
// prints `E Error message not found: EXEC_ADD_FK
|
|
73
|
+
// prints `E Error message not found: EXEC_ADD_FK`, so every foreign key
|
|
74
74
|
// added or dropped on a dialect that can ALTER one logged an *error* where
|
|
75
75
|
// its ten sibling operations logged progress. Only reachable with
|
|
76
76
|
// `supportsAlterForeignKey`, which is why SQLite-only local runs never
|
|
@@ -89,11 +89,11 @@ export const syncMsgs = {
|
|
|
89
89
|
FATAL_ERROR:
|
|
90
90
|
'E %rFATAL ERROR: Sync failed! All changes have been safely rolled back. Detail: {error}%*',
|
|
91
91
|
VIEWS_SEEDED:
|
|
92
|
-
'I Seeded %y{file}%* from the database. It is yours from now on
|
|
92
|
+
'I Seeded %y{file}%* from the database. It is yours from now on: the generator will not overwrite it.',
|
|
93
93
|
VIEWS_KEPT:
|
|
94
94
|
'I Left %y{file}%* alone: view interfaces are hand-owned. Delete it and re-run to reseed.',
|
|
95
95
|
LEDGER_DRIFT:
|
|
96
|
-
'W %yDatabase has drifted from the last schema Bakery applied%*: {reason}. Diffing against live introspection instead of the ledger. Something changed this database outside Bakery
|
|
96
|
+
'W %yDatabase has drifted from the last schema Bakery applied%*: {reason}. Diffing against live introspection instead of the ledger. Something changed this database outside Bakery: if that was not deliberate, check it before syncing.',
|
|
97
97
|
} as const
|
|
98
98
|
|
|
99
99
|
const logger = new Logger('db-sync')
|
|
@@ -102,7 +102,7 @@ export const MESSAGES = messageLogger(logger, syncMsgs)
|
|
|
102
102
|
/**
|
|
103
103
|
* Whether a destructive sync in this process needs an explicit `--force-sync`.
|
|
104
104
|
*
|
|
105
|
-
* **`NODE_ENV` is the only term, deliberately
|
|
105
|
+
* **`NODE_ENV` is the only term, deliberately. Do not add a `PROD` check back.**
|
|
106
106
|
* There used to be a `process.env.PROD === 'true'` half that could never be
|
|
107
107
|
* true, since `core/init.ts` installs `PROD` as a getter returning a *boolean*.
|
|
108
108
|
* Repairing it rather than deleting it would have been worse: `PROD` means only
|
|
@@ -136,7 +136,7 @@ export class SyncEngine {
|
|
|
136
136
|
/**
|
|
137
137
|
* Record the adopted database as the schema Bakery last applied.
|
|
138
138
|
*
|
|
139
|
-
* Without this, the first sync after an adoption falls back to introspection
|
|
139
|
+
* Without this, the first sync after an adoption falls back to introspection:
|
|
140
140
|
* safe, but it withdraws the thing the ledger is for, and enum member changes
|
|
141
141
|
* only migrate when the diff runs against the ledger.
|
|
142
142
|
*
|
|
@@ -201,7 +201,7 @@ export class SyncEngine {
|
|
|
201
201
|
// Creating or recreating a view cannot lose anything: a view holds no data,
|
|
202
202
|
// and `createView` drops and recreates in one step. Counting it as
|
|
203
203
|
// destructive made a schema with a view demand `--force-sync` in production
|
|
204
|
-
// for no reason
|
|
204
|
+
// for no reason, and it matters more now that views are diffed at all,
|
|
205
205
|
// because MySQL and Postgres re-render a stored body, so a run that falls
|
|
206
206
|
// back to introspection sees one as changed every time.
|
|
207
207
|
//
|
|
@@ -298,11 +298,11 @@ export class SyncEngine {
|
|
|
298
298
|
// After the transaction commits, never inside it: MySQL commits DDL
|
|
299
299
|
// implicitly, so there is no unit of work these two could share anyway, and
|
|
300
300
|
// writing the ledger first would claim a migration that had not happened.
|
|
301
|
-
// Best-effort by design
|
|
301
|
+
// Best-effort by design: a sync that succeeded still succeeded, and a
|
|
302
302
|
// missing ledger only costs the next run its fast path.
|
|
303
303
|
// `tsIndexes`, not `adapter.getIndexes()`: the ledger records what was
|
|
304
304
|
// *applied*, and re-reading the database here would record whatever the
|
|
305
|
-
// dialect reports back
|
|
305
|
+
// dialect reports back, the exact normalization problem the ledger exists
|
|
306
306
|
// to route around.
|
|
307
307
|
await writeLedger(adapter, constraints, tsIndexes)
|
|
308
308
|
|
|
@@ -328,7 +328,7 @@ export class SyncEngine {
|
|
|
328
328
|
//
|
|
329
329
|
// It is not `--choose=db` with a nicer name. `--choose=db` sits *after* the
|
|
330
330
|
// "no changes" early return, so on a database that already matches it does
|
|
331
|
-
// nothing
|
|
331
|
+
// nothing, which is the common case when adopting one, since the schema
|
|
332
332
|
// being written is derived from that same database. And it never writes the
|
|
333
333
|
// ledger, so the first real sync afterwards diffs against introspection
|
|
334
334
|
// instead: the one place enum changes are invisible.
|
|
@@ -337,7 +337,7 @@ export class SyncEngine {
|
|
|
337
337
|
//
|
|
338
338
|
// Passing the loaded schema in makes `syncNullableConstraints` reconcile
|
|
339
339
|
// view column nullability against it, which is right for a regeneration of
|
|
340
|
-
// a schema you are keeping and wrong for this
|
|
340
|
+
// a schema you are keeping and wrong for this: the point of `--migrate`
|
|
341
341
|
// is that the database is the source of truth, and quietly carrying a
|
|
342
342
|
// detail over from the file being replaced would make the result depend on
|
|
343
343
|
// what happened to be there.
|
|
@@ -359,13 +359,13 @@ export class SyncEngine {
|
|
|
359
359
|
// column rename existed, which dated from SQLite before 3.25 (2018) having
|
|
360
360
|
// no ALTER TABLE … RENAME COLUMN. Bun ships 3.53. Measured against a real
|
|
361
361
|
// database, the special case lost a rename in a non-renamed table silently
|
|
362
|
-
//
|
|
362
|
+
// (reporting a perfect sync), and threw outright in the two cases where it
|
|
363
363
|
// did fire. See sync/engine.test.ts, which pins all four.
|
|
364
364
|
const plan = await buildSyncPlan(adapter, constraints, logger, MESSAGES)
|
|
365
365
|
|
|
366
366
|
// `buildSyncPlan` has always recorded which state it diffed against and
|
|
367
|
-
// nothing ever read it. Falling back to introspection is *correct
|
|
368
|
-
// sync/ledger.ts
|
|
367
|
+
// nothing ever read it. Falling back to introspection is *correct*. See
|
|
368
|
+
// sync/ledger.ts, but doing it silently means a column somebody added by
|
|
369
369
|
// hand in production looks exactly like an ordinary run. "No ledger yet" is
|
|
370
370
|
// the normal state of a fresh database and is not drift.
|
|
371
371
|
if (
|
|
@@ -394,8 +394,8 @@ export class SyncEngine {
|
|
|
394
394
|
plan.tablesToRebuild,
|
|
395
395
|
beingCreated,
|
|
396
396
|
)
|
|
397
|
-
// SQLite cannot ALTER a foreign key in or out
|
|
398
|
-
// the table definition
|
|
397
|
+
// SQLite cannot ALTER a foreign key in or out (the constraint is part of
|
|
398
|
+
// the table definition), so those become table rebuilds, which recreate the
|
|
399
399
|
// table with the keys inline. Decided here rather than in the adapter so
|
|
400
400
|
// the printed plan states the work that will actually happen.
|
|
401
401
|
if (!adapter.supportsAlterForeignKey) {
|
|
@@ -424,7 +424,7 @@ export class SyncEngine {
|
|
|
424
424
|
logPlannedChanges(plan, indexesToDrop, indexesToAdd, isDangerous, MESSAGES)
|
|
425
425
|
if (argv.includes('--dry-run')) {
|
|
426
426
|
// `logger.log` takes the level as its second argument. A leading 'D ' is
|
|
427
|
-
// `messageLogger` table syntax, and this is not a table
|
|
427
|
+
// `messageLogger` table syntax, and this is not a table, so the letter
|
|
428
428
|
// was printed verbatim and the level defaulted.
|
|
429
429
|
//
|
|
430
430
|
// `info`, not the `debug` the stray 'D' was reaching for: this line is
|