@bakery-framework/orm 2.0.0-alpha.9 → 2.0.0-rc.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/src/field.ts CHANGED
@@ -7,7 +7,7 @@ import type * as SyncTypes from './sync/types'
7
7
  *
8
8
  * The former `value()` primitive, now private and two arguments shorter.
9
9
  * `autoIncrement` and `primary` were positional booleans that only
10
- * `Field.Primary()` ever set, and it can state them directly — which is the
10
+ * `Field.Primary()` ever set, and it can state them directly, which is the
11
11
  * whole reason `value('integer', undefined, false, true, true)` was worth
12
12
  * replacing.
13
13
  *
@@ -27,7 +27,7 @@ function column<T, N extends boolean = false, O extends boolean = false>(
27
27
  if (nullable === true || d === null) result.nullable = true
28
28
  // The runtime object is byte-for-byte what it was before `TableDef` changed
29
29
  // shape: `type` holds the dialect name, `optional` is type-level only. That
30
- // is the whole safety argument for this change — no adapter, no generated
30
+ // is the whole safety argument for this change: no adapter, no generated
31
31
  // file and no stored ledger payload sees any difference.
32
32
  return result as unknown as TableDef<T, N, O>
33
33
  }
@@ -52,9 +52,9 @@ const isColumnValue = (v: unknown): v is ColumnValue =>
52
52
  /**
53
53
  * Resolve either calling convention to `{ table, cols }`.
54
54
  *
55
- * The string form — `Field.Index('posts', ['authorId'])` — cannot catch a typo
56
- * in either argument until sync time, if then. The column form —
57
- * `Field.Index(posts.authorId)` — carries its own table, so that argument
55
+ * The string form: `Field.Index('posts', ['authorId'])`: cannot catch a typo
56
+ * in either argument until sync time, if then. The column form:
57
+ * `Field.Index(posts.authorId)`: carries its own table, so that argument
58
58
  * disappears and a mistake becomes a compile error.
59
59
  */
60
60
  function resolveTarget(
@@ -86,8 +86,8 @@ export interface ForeignKeyActions {
86
86
  /**
87
87
  * The shape `Field.Foreign()` contributes to a row type.
88
88
  *
89
- * Always `integer`, because that is what `Field.Primary()` always is — an
90
- * `INTEGER PRIMARY KEY AUTOINCREMENT` — and a foreign key exists to point at
89
+ * Always `integer`, because that is what `Field.Primary()` always is (an
90
+ * `INTEGER PRIMARY KEY AUTOINCREMENT`), and a foreign key exists to point at
91
91
  * one. Nullable adds `| null`, which is the only variation worth having.
92
92
  *
93
93
  * The runtime object still resolves its type from the referenced column (see
@@ -102,14 +102,14 @@ type ForeignDef<N extends true | undefined> = TableDef<
102
102
  N extends true ? true : false
103
103
  >
104
104
 
105
- /** What `Field.Index` / `Field.Unique` accept — a column produced by `table()`. */
105
+ /** What `Field.Index` / `Field.Unique` accept: a column produced by `table()`. */
106
106
  type ColumnValue = { __table: string; __column: string }
107
107
 
108
108
  /**
109
109
  * What `Field.Enum` accepts: a literal array, or a TypeScript string enum.
110
110
  *
111
111
  * A string enum is an ordinary object at runtime (`{ Draft: 'draft' }`), so
112
- * both forms reduce to the same list of members — `Object.values` for the
112
+ * both forms reduce to the same list of members, `Object.values` for the
113
113
  * object, the tuple itself for the array.
114
114
  *
115
115
  * **String enums only.** A *numeric* enum compiles to an object with a reverse
@@ -127,7 +127,7 @@ type EnumValues<V> = V extends readonly (infer U extends string)[]
127
127
  : V[keyof V]
128
128
 
129
129
  /**
130
- * `Field` — the column vocabulary, namespaced so it is discoverable.
130
+ * `Field`: the column vocabulary, namespaced so it is discoverable.
131
131
  *
132
132
  * This replaced `value('string', null, true, false, false)`, which required
133
133
  * remembering a type string *and* the meaning of four positional booleans.
@@ -137,8 +137,8 @@ type EnumValues<V> = V extends readonly (infer U extends string)[]
137
137
  * **`Field` is now the whole vocabulary, not sugar over one.** `value`,
138
138
  * `primary`, `index`, `unique` and `foreign` are gone; the construction they
139
139
  * did lives in `column()` and `resolveTarget()` above, private to this file.
140
- * What the sync engine and the type inference consume is unchanged — plain
141
- * descriptor objects — so this moved the API without moving the contract.
140
+ * What the sync engine and the type inference consume is unchanged (plain
141
+ * descriptor objects), so this moved the API without moving the contract.
142
142
  *
143
143
  * The one shape deliberately left unspellable is **nullable *and* defaulted to
144
144
  * something other than null**, because a null default is how you say nullable.
@@ -158,7 +158,7 @@ type EnumValues<V> = V extends readonly (infer U extends string)[]
158
158
  */
159
159
  export const Field = {
160
160
  /**
161
- * `INTEGER PRIMARY KEY AUTOINCREMENT` — the id column, spelled once.
161
+ * `INTEGER PRIMARY KEY AUTOINCREMENT`: the id column, spelled once.
162
162
  *
163
163
  * The single most repeated line in any schema, and the one most likely to be
164
164
  * written wrong by hand: `value('integer', undefined, false, true, true)`.
@@ -185,7 +185,7 @@ export const Field = {
185
185
  >('integer', d),
186
186
 
187
187
  /**
188
- * A fractional number — `DOUBLE` on MySQL, `DOUBLE PRECISION` on Postgres,
188
+ * A fractional number: `DOUBLE` on MySQL, `DOUBLE PRECISION` on Postgres,
189
189
  * `REAL` on SQLite.
190
190
  *
191
191
  * Named `Float` rather than mirroring the underlying `'number'` type string,
@@ -208,12 +208,12 @@ export const Field = {
208
208
  >('string', d),
209
209
 
210
210
  /**
211
- * Unbounded text — `TEXT` on every dialect.
211
+ * Unbounded text: `TEXT` on every dialect.
212
212
  *
213
213
  * **MySQL rejects a literal `DEFAULT` on TEXT**, so this takes no default.
214
214
  * That is not an omission: `Field.String('')` emits
215
215
  * `TEXT NOT NULL DEFAULT ''`, which MySQL refuses outright with "BLOB, TEXT,
216
- * GEOMETRY or JSON column can't have a default value" — the shipped schema
216
+ * GEOMETRY or JSON column can't have a default value", the shipped schema
217
217
  * template could not `db:sync` against MySQL because of exactly this. Use
218
218
  * `Varchar` when you need a default.
219
219
  */
@@ -227,18 +227,27 @@ export const Field = {
227
227
  },
228
228
 
229
229
  /**
230
- * Sized text — `VARCHAR(n)`, and the answer to TEXT's default problem, since
230
+ * Sized text: `VARCHAR(n)`, and the answer to TEXT's default problem, since
231
231
  * every dialect accepts a default on a sized column.
232
232
  *
233
233
  * slug: Field.Varchar(255, ''),
234
234
  *
235
- * SQLite has no real `VARCHAR` — all text is TEXT affinity — but it stores
235
+ * SQLite has no real `VARCHAR` (all text is TEXT affinity), but it stores
236
236
  * the declared type verbatim and reads it back, so one schema round-trips on
237
237
  * all three.
238
238
  *
239
- * `length` is not part of the column diff, so **widening a Varchar does not
240
- * migrate on its own**; see `ColumnConstraint.length` for why that is
241
- * deliberate rather than missing.
239
+ * **`length` *is* part of the column diff** (`sync/diff.ts`), so adopting
240
+ * `Varchar` on an existing table rebuilds it once. That is deliberate and
241
+ * it converges: all three dialects report a VARCHAR width back exactly, so
242
+ * after one rebuild the two agree and the table stays synced. A column that
243
+ * reports no width really is unsized, and a schema that declares no width
244
+ * asks for nothing.
245
+ *
246
+ * This comment claimed the opposite until 2026-09-20, and it is the comment
247
+ * a schema author reads when deciding whether the change is safe. Measured
248
+ * on a real app: switching 12 tables' text columns to `Varchar` moved
249
+ * `db:sync` from "perfectly synced" to a 12-table rebuild, and the second
250
+ * sync was clean.
242
251
  */
243
252
  Varchar: <D extends string | null | undefined = undefined>(
244
253
  length: number,
@@ -254,7 +263,7 @@ export const Field = {
254
263
  ),
255
264
 
256
265
  /**
257
- * A 64-bit integer — `BIGINT` everywhere.
266
+ * A 64-bit integer: `BIGINT` everywhere.
258
267
  *
259
268
  * Reads back as a **string** on MySQL and Postgres, which is how they avoid
260
269
  * losing precision, and as a **number** on SQLite, which does not: values
@@ -269,11 +278,11 @@ export const Field = {
269
278
  >('bigint' as any, d),
270
279
 
271
280
  /**
272
- * A JSON document — `JSON` on MySQL, `JSONB` on Postgres, a `JSON`-declared
281
+ * A JSON document: `JSON` on MySQL, `JSONB` on Postgres, a `JSON`-declared
273
282
  * text column on SQLite.
274
283
  *
275
284
  * MySQL and Postgres parse it into an object on read; SQLite hands back the
276
- * raw string. The row type is therefore `unknown` — narrow it where you use
285
+ * raw string. The row type is therefore `unknown`: narrow it where you use
277
286
  * it rather than trusting a type that would be wrong on one of the three.
278
287
  *
279
288
  * Takes no default, for the same reason `Text` does not: MySQL refuses a
@@ -286,7 +295,7 @@ export const Field = {
286
295
  (nullable: true): TableDef<unknown, true, true>
287
296
  },
288
297
 
289
- /** True/false — `BOOLEAN` on Postgres, `TINYINT(1)` on MySQL. */
298
+ /** True/false: `BOOLEAN` on Postgres, `TINYINT(1)` on MySQL. */
290
299
  Bool: <D extends boolean | null | undefined = undefined>(d?: D) =>
291
300
  column<
292
301
  Nullable<boolean, D extends null ? true : false>,
@@ -312,8 +321,8 @@ export const Field = {
312
321
  *
313
322
  * **The column's type is copied from the target, not declared here**, and
314
323
  * that is the real reason to prefer this form. MySQL refuses a foreign key
315
- * whose column type does not match the referenced key *exactly* — an
316
- * `INT` child against a `BIGINT` parent is rejected outright — and that
324
+ * whose column type does not match the referenced key *exactly* (an
325
+ * `INT` child against a `BIGINT` parent is rejected outright), and that
317
326
  * mismatch is invisible in a schema where the two columns are declared pages
318
327
  * apart. Resolution happens in `resolveColumnForeignKeys()`, where the whole
319
328
  * schema is in scope, so the two cannot disagree.
@@ -332,11 +341,11 @@ export const Field = {
332
341
  } = {},
333
342
  ) =>
334
343
  ({
335
- // `integer` in the row type, always — see `ForeignDef`. At *runtime* the
344
+ // `integer` in the row type, always. See `ForeignDef`. At *runtime* the
336
345
  // type is still overwritten from the referenced column by
337
346
  // `resolveColumnForeignKeys()`, because MySQL refuses a key whose column
338
- // type does not match the target exactly. For the ordinary case — a key
339
- // pointing at a `Field.Primary()` — the two agree and there is nothing to
347
+ // type does not match the target exactly. For the ordinary case: a key
348
+ // pointing at a `Field.Primary()`: the two agree and there is nothing to
340
349
  // reconcile.
341
350
  type: 'integer',
342
351
  ...(options.nullable ? { nullable: true, default: null } : {}),
@@ -429,7 +438,7 @@ export const Field = {
429
438
  * The column form carries its own table, so there is no separate table
430
439
  * argument to get wrong; the string form exists because the `DBInfo`
431
440
  * namespace layout has no `table()` values to point at. Several columns make
432
- * one composite index, in the order given — which is the order that decides
441
+ * one composite index, in the order given, which is the order that decides
433
442
  * which queries it can serve.
434
443
  *
435
444
  * A direct alias of `index()` rather than a wrapper, so the two cannot drift
@@ -460,7 +469,7 @@ export const Field = {
460
469
  },
461
470
 
462
471
  /**
463
- * A UUID, generated by the database — `CHAR(36)` sized text with a
472
+ * A UUID, generated by the database: `CHAR(36)` sized text with a
464
473
  * per-dialect default expression.
465
474
  *
466
475
  * id: Field.Uuid(),
@@ -468,7 +477,7 @@ export const Field = {
468
477
  * `gen_random_uuid()` on Postgres, `UUID()` on MySQL, and
469
478
  * `lower(hex(randomblob(16)))` shaped into the canonical form on SQLite,
470
479
  * which has no UUID function of its own. All three round-trip through the
471
- * `%uuid%` marker, the same way `Field.Date.now()` round-trips `%dateNow%` —
480
+ * `%uuid%` marker, the same way `Field.Date.now()` round-trips `%dateNow%`,
472
481
  * without the read-back half the database reports its own expression, the
473
482
  * schema says `%uuid%`, and the column rebuilds on every sync forever.
474
483
  *
@@ -496,13 +505,19 @@ export const Field = {
496
505
  *
497
506
  * Enforced in the database too, and **the same way on all three dialects**:
498
507
  * a `CHECK (col IN (…))`. MySQL has a native `ENUM` and this deliberately
499
- * does not use it — a value rejected by MySQL and accepted by SQLite means
508
+ * does not use it: a value rejected by MySQL and accepted by SQLite means
500
509
  * an app that behaves differently depending on where it runs, which is worse
501
510
  * than either choice made consistently.
502
511
  *
503
- * The members are **not part of the column diff** — see
504
- * `ColumnConstraint._enum`. Adding or removing one does not migrate on its
505
- * own; the table has to be rebuilt for the CHECK to change.
512
+ * **The members join the column diff when the current state came from the
513
+ * ledger**, so changing them migrates rather than silently doing nothing.
514
+ * Under introspection they stay out of it, because all three dialects
515
+ * report a CHECK back in a different shape and Postgres re-renders it
516
+ * entirely: three parsers, three chances to rebuild the table on every sync
517
+ * forever. `sync/diff.ts` has the measured shapes.
518
+ *
519
+ * So adopting `Enum` on an existing ledger-tracked table rebuilds it once.
520
+ * This comment said the members were never diffed until 2026-09-20.
506
521
  */
507
522
  Enum: <
508
523
  const V extends EnumSource,
@@ -520,7 +535,7 @@ export const Field = {
520
535
  throws(
521
536
  'Field.Enum() takes a string enum or an array of strings. A numeric ' +
522
537
  'enum reverse-maps its members, so its values are half names and ' +
523
- 'half numbers — use Field.Int() and validate in your code, or give ' +
538
+ 'half numbers. Use Field.Int() and validate in your code, or give ' +
524
539
  'the enum string values.',
525
540
  )
526
541
  }
@@ -555,7 +570,7 @@ export const Field = {
555
570
  * **`updatedAt` is not auto-maintained on write, and that is a deliberate
556
571
  * limit rather than an oversight.** Doing it silently needs the query layer
557
572
  * to know which tables have the column, and the query layer has no runtime
558
- * view of the schema at all — `schema.ts` is loaded by the sync engine, not
573
+ * view of the schema at all: `schema.ts` is loaded by the sync engine, not
559
574
  * by the ORM. The alternatives were a `hasCol` probe on every UPDATE, or
560
575
  * stamping a column that might not exist. Stamp it yourself:
561
576
  *
@@ -570,7 +585,7 @@ export const Field = {
570
585
  * The current time as Unix **seconds**, for a value position.
571
586
  *
572
587
  * A plain number, so it binds as an ordinary parameter and needs no dialect
573
- * handling — unlike `%dateNow%`, which is a DDL default marker and means
588
+ * handling: unlike `%dateNow%`, which is a DDL default marker and means
574
589
  * nothing in an INSERT or UPDATE.
575
590
  */
576
591
  now: () => Math.floor(Date.now() / 1000),
package/src/globals.d.ts CHANGED
@@ -4,7 +4,7 @@ import type { AppDBOptionals, AppDBSchema } from './schema-registry'
4
4
  * Ambient database types.
5
5
  *
6
6
  * These live with the orm package rather than in core's `global.d.ts`: they are
7
- * derived from this package's schema registry, and core cannot reference orm —
7
+ * derived from this package's schema registry, and core cannot reference orm,
8
8
  * orm depends on core, so the reverse would be circular.
9
9
  *
10
10
  * Both are read only inside this package (`orm/query.ts`, `orm/mutation.ts`,
package/src/index.ts CHANGED
@@ -12,7 +12,7 @@ export type {
12
12
  *
13
13
  * Re-exported from the root rather than given a `./observe` subpath. The
14
14
  * export map is closed and every entry in it is public API from the moment it
15
- * ships, so a new subpath needs a reason — and there is none here: this is one
15
+ * ships, so a new subpath needs a reason, and there is none here: this is one
16
16
  * function and three types, the observer is process-wide, and an app sets it
17
17
  * once at boot next to where it already imports `DB`.
18
18
  *
@@ -34,15 +34,15 @@ export type {
34
34
  * Schema authoring, from one place.
35
35
  *
36
36
  * `table`/`alias` live in `define.ts` and the column and constraint helpers in
37
- * `schema-util.ts`, but that split is an implementation detail — someone
37
+ * `schema-util.ts`, but that split is an implementation detail: someone
38
38
  * writing `orm/schema.ts` should import from `@bakery-framework/orm` without having to
39
39
  * know which file a helper happens to sit in.
40
40
  */
41
41
  export { alias, table, view } from './define'
42
42
  export { Field } from './field'
43
43
  /**
44
- * `TableDef` is the **column** descriptor — `TableDef<TYPE, nullable, optional>`
45
- * — and it now comes from the root barrel, which is where someone writing a
44
+ * `TableDef` is the **column** descriptor (`TableDef<TYPE, nullable, optional>`
45
+ *), and it now comes from the root barrel, which is where someone writing a
46
46
  * schema would look for it.
47
47
  *
48
48
  * It did not, and that was a defect rather than an omission: the barrel used to
package/src/orm/index.ts CHANGED
@@ -8,14 +8,14 @@
8
8
  *
9
9
  * `./query` first works. `./mutation` first does not: `mutation.ts` pulls in
10
10
  * `query.ts`, whose top-level `Mutation.Insert` runs while `Mutation` is still
11
- * initialising, and the whole ORM dies with
11
+ * initializing, and the whole ORM dies with
12
12
  * `TypeError: undefined is not an object (evaluating 'Mutation.Insert')`.
13
13
  *
14
14
  * Biome's `organizeImports` sorts these alphabetically and puts `./mutation`
15
15
  * first. The compiler cannot see the breakage, because the types are fine
16
16
  * either way. Hence the suppression.
17
17
  */
18
- // biome-ignore-all assist/source/organizeImports: cycle — see above
18
+ // biome-ignore-all assist/source/organizeImports: cycle. See above
19
19
  import { DB } from './query'
20
20
  import { Mutation } from './mutation'
21
21
 
@@ -7,7 +7,7 @@ import type {
7
7
  AppDBOptionals as DBOptionals,
8
8
  AppDBSchema as DBSchema,
9
9
  } from '../schema-registry'
10
- import { evalOperands, qId } from '../schema-util'
10
+ import { evalOperands, nullComparison, qId } from '../schema-util'
11
11
  import { DB } from './query'
12
12
 
13
13
  export namespace Mutation {
@@ -16,7 +16,7 @@ export namespace Mutation {
16
16
  *
17
17
  * There used to be a `| (string & {})` member here, for autocomplete on the
18
18
  * literals while still accepting any string. It also made the rest of the
19
- * type decorative — `Exclude<…, AppViews>` never rejected anything, so a
19
+ * type decorative: `Exclude<…, AppViews>` never rejected anything, so a
20
20
  * `DB.Insert.into('some_view')` compiled and failed at the database instead,
21
21
  * and so did a typo'd table name.
22
22
  *
@@ -36,7 +36,7 @@ export namespace Mutation {
36
36
  export type Prettify<T> = { [K in keyof T]: T[K] } & {}
37
37
 
38
38
  /**
39
- * `RETURNING` takes an identifier list and is interpolated, not bound — the
39
+ * `RETURNING` takes an identifier list and is interpolated, not bound: the
40
40
  * same position `orderBy` and `groupBy` guard with `safeColumn`. It was the
41
41
  * one identifier writer on Insert/Update/Delete with no guard at all, so
42
42
  * `.returning('* FROM users; DROP TABLE t --')` was emitted verbatim.
@@ -71,10 +71,10 @@ export namespace Mutation {
71
71
  : string
72
72
 
73
73
  /**
74
- * What a write returns — the adapter's own result, not a copy of it.
74
+ * What a write returns: the adapter's own result, not a copy of it.
75
75
  *
76
76
  * It *was* a copy: an identical `{lastInsertRowid, changes}` declared here as
77
- * well as on `SQLAdapter`. Identical today is the whole problem — that is the
77
+ * well as on `SQLAdapter`. Identical today is the whole problem: that is the
78
78
  * state `SQLAdapter.ColumnConstraint` was in before it fell behind
79
79
  * `sync/types.ts` and started erasing fields at the cast.
80
80
  */
@@ -87,14 +87,14 @@ export namespace Mutation {
87
87
  }
88
88
 
89
89
  /**
90
- * The rows to insert — as a spread, or as one array.
90
+ * The rows to insert: as a spread, or as one array.
91
91
  *
92
92
  * Both forms exist because the variadic signature alone made the obvious
93
93
  * call wrong in a way nothing announced: `values(rows)` bound the *array*
94
94
  * as a single record, and the only symptom was
95
95
  * `table big has no column named 0`. An array is never a record, so the two
96
- * forms cannot be confused, and anything that is neither — a mixed
97
- * `values(rows, extra)`, a primitive — is rejected by name rather than
96
+ * forms cannot be confused, and anything that is neither (a mixed
97
+ * `values(rows, extra)`, a primitive) is rejected by name rather than
98
98
  * turned into columns called `0` and `1`.
99
99
  */
100
100
  values(records: InsertSchema<T>[]): InsertExecutable
@@ -115,7 +115,7 @@ export namespace Mutation {
115
115
  'values() takes records: values(row), values(rowA, rowB) or ' +
116
116
  'values(rows). Got a ' +
117
117
  (Array.isArray(record) ? 'nested array' : typeof record) +
118
- ' — if you meant to pass an array of rows, pass it as the only ' +
118
+ ': if you meant to pass an array of rows, pass it as the only ' +
119
119
  'argument.',
120
120
  )
121
121
  }
@@ -142,7 +142,7 @@ export namespace Mutation {
142
142
  /**
143
143
  * Insert, or update the row that is already there.
144
144
  *
145
- * `cols` names the unique columns that decide "already there" — a primary
145
+ * `cols` names the unique columns that decide "already there": a primary
146
146
  * key or a unique index. Without an upsert the only way to express this is
147
147
  * to check and then branch, which is a **race**: two requests both see no
148
148
  * row and both insert.
@@ -150,8 +150,8 @@ export namespace Mutation {
150
150
  * DB.Insert.into('users').values({ email, name }).upsert(['email'])
151
151
  *
152
152
  * By default every inserted column except the conflict columns is
153
- * overwritten. Pass a second argument to narrow that — `upsert(['email'],
154
- * ['name'])` leaves everything else as it was — or an empty array for
153
+ * overwritten. Pass a second argument to narrow that (`upsert(['email'],
154
+ * ['name'])` leaves everything else as it was), or an empty array for
155
155
  * insert-if-absent, which becomes `DO NOTHING`.
156
156
  *
157
157
  * MySQL ignores `cols` because `ON DUPLICATE KEY UPDATE` fires on *any*
@@ -180,7 +180,7 @@ export namespace Mutation {
180
180
  * How many records fit in one statement, under the adapter's ceiling.
181
181
  *
182
182
  * The ceiling comes from the adapter because only it knows its dialect, but
183
- * a statement can be *rendered* without a connection — `parse()` is how the
183
+ * a statement can be *rendered* without a connection: `parse()` is how the
184
184
  * tests read the SQL, and the stub adapter in `orm.test.ts` implements two
185
185
  * members. An unreachable or silent adapter falls back to the same default
186
186
  * the base class publishes rather than making `parse()` require a database.
@@ -222,7 +222,7 @@ export namespace Mutation {
222
222
  }
223
223
 
224
224
  /**
225
- * The insert as one statement per batch — the form the executors run.
225
+ * The insert as one statement per batch: the form the executors run.
226
226
  *
227
227
  * A single `INSERT … VALUES (…),(…),…` carries three parameters per row, so
228
228
  * it stops being a legal statement somewhere around eleven thousand rows.
@@ -233,15 +233,15 @@ export namespace Mutation {
233
233
  * like for an arbitrary `rows`.
234
234
  *
235
235
  * The column list is computed once, across every record, so every batch
236
- * inserts the same columns in the same order — a per-batch union would
236
+ * inserts the same columns in the same order: a per-batch union would
237
237
  * change the shape of the statement halfway through the insert.
238
238
  *
239
239
  * **`RETURNING` is accumulated, not refused.** Refusing is the
240
240
  * safe-looking option and the wrong one: the batches run sequentially
241
241
  * inside one transaction, so concatenating each batch's rows in batch order
242
242
  * reproduces the sequence a single statement would have produced. Ordering
243
- * *within* a batch is whatever the dialect gives — Postgres does not
244
- * promise `RETURNING` follows `VALUES` order — but that is equally true
243
+ * *within* a batch is whatever the dialect gives (Postgres does not
244
+ * promise `RETURNING` follows `VALUES` order), but that is equally true
245
245
  * unchunked, so batching neither adds nor removes a guarantee. Refusing
246
246
  * would have cost the main reason to write `.values(rows).returning('id')`
247
247
  * at all: getting the generated ids of a bulk import back.
@@ -288,7 +288,7 @@ export namespace Mutation {
288
288
  *
289
289
  * The transaction is what keeps a batched insert meaning what the unbatched
290
290
  * one meant: all rows or none. It is opened only when a batch boundary
291
- * exists — one statement is already atomic — and only when the caller is
291
+ * exists (one statement is already atomic), and only when the caller is
292
292
  * not already inside `DB.transaction`, where the outer transaction is
293
293
  * already the atomic unit.
294
294
  *
@@ -329,7 +329,7 @@ export namespace Mutation {
329
329
  * Two shapes, not three: Postgres and SQLite share
330
330
  * `ON CONFLICT (…) DO UPDATE SET col = excluded.col`, while MySQL spells it
331
331
  * `ON DUPLICATE KEY UPDATE col = VALUES(col)` and takes no conflict target
332
- * at all — it fires on whichever unique key was violated.
332
+ * at all, it fires on whichever unique key was violated.
333
333
  *
334
334
  * Every identifier goes through `qId`, and no value is interpolated: the
335
335
  * new row's values are already bound as the INSERT's parameters, and both
@@ -345,7 +345,7 @@ export namespace Mutation {
345
345
  ).filter(k => insertedKeys.includes(k))
346
346
 
347
347
  // The adapter spells it. This used to branch on `driver === 'mysql'`
348
- // here, which put one dialect's syntax in the shared query builder — the
348
+ // here, which put one dialect's syntax in the shared query builder: the
349
349
  // thing every other difference (quote character, placeholder ceiling,
350
350
  // date expression, foreign-key clause) is kept out of it for.
351
351
  return getActiveDb().upsertClause(cols, targets)
@@ -362,7 +362,7 @@ export namespace Mutation {
362
362
  }
363
363
 
364
364
  async fetch<R = any>(): Promise<R | undefined> {
365
- // Every batch still runs — `fetch()` means "insert, and hand me a row
365
+ // Every batch still runs: `fetch()` means "insert, and hand me a row
366
366
  // back", not "insert the first batch". `Executor.get` is defined as
367
367
  // `all(…)[0]`, so this is the same row it would have produced.
368
368
  const rows = await this.array<R>()
@@ -381,7 +381,7 @@ export namespace Mutation {
381
381
  const changes = results.reduce((n, r) => n + Number(r?.changes ?? 0), 0)
382
382
  // `changes` sums, because it answers "how many rows did this insert
383
383
  // write". `lastInsertRowid` cannot sum, and the dialects do not agree
384
- // what it means for a multi-row insert — so the adapter says which end of
384
+ // what it means for a multi-row insert, so the adapter says which end of
385
385
  // the batched run carries its answer, rather than this file knowing.
386
386
  const pick =
387
387
  getActiveDb().batchInsertIdPosition === 'first'
@@ -475,8 +475,12 @@ export namespace Mutation {
475
475
  if (c.operator === '') {
476
476
  parts.push(i === 0 ? left : `${c.connector} ${left}`)
477
477
  } else {
478
- const right = evalOperands(c.right, params, c.isRightColumn)
479
- const clauseStr = `${left} ${c.operator} ${right}`
478
+ // `nullComparison` before `evalOperands`, and the order is the point:
479
+ // a null that becomes `IS NULL` must not also bind a parameter.
480
+ const nullOp = nullComparison(c.operator, c.right, c.isRightColumn)
481
+ const clauseStr = nullOp
482
+ ? `${left} ${nullOp}`
483
+ : `${left} ${c.operator} ${evalOperands(c.right, params, c.isRightColumn)}`
480
484
  parts.push(i === 0 ? clauseStr : `${c.connector} ${clauseStr}`)
481
485
  }
482
486
  }
@@ -618,8 +622,8 @@ export namespace Mutation {
618
622
  for (let i = 0; i < this._clauses.length; i++) {
619
623
  const c = this._clauses[i]!
620
624
  const left = evalOperands(c.left, params, true)
621
- // `parseWhereArgs` emits `operator: ''` for the one-argument form —
622
- // `where(DB.raw`…`)` or `where(<subquery>)` — where the left operand
625
+ // `parseWhereArgs` emits `operator: ''` for the one-argument form,
626
+ // `where(DB.raw`…`)` or `where(<subquery>)`, where the left operand
623
627
  // is the whole condition and there is no right one. This branch is a
624
628
  // copy of `UpdateExecutable.evalWhere` above, which is a copy of
625
629
  // `formatClause` in query.ts; it was the copy that never got it. The
@@ -630,8 +634,12 @@ export namespace Mutation {
630
634
  if (c.operator === '') {
631
635
  parts.push(i === 0 ? left : `${c.connector} ${left}`)
632
636
  } else {
633
- const right = evalOperands(c.right, params, c.isRightColumn)
634
- const clauseStr = `${left} ${c.operator} ${right}`
637
+ // `nullComparison` before `evalOperands`, and the order is the point:
638
+ // a null that becomes `IS NULL` must not also bind a parameter.
639
+ const nullOp = nullComparison(c.operator, c.right, c.isRightColumn)
640
+ const clauseStr = nullOp
641
+ ? `${left} ${nullOp}`
642
+ : `${left} ${c.operator} ${evalOperands(c.right, params, c.isRightColumn)}`
635
643
  parts.push(i === 0 ? clauseStr : `${c.connector} ${clauseStr}`)
636
644
  }
637
645
  }