@bakery-framework/orm 2.0.0-alpha.11 → 2.0.0-alpha.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakery-framework/orm",
3
- "version": "2.0.0-alpha.11",
3
+ "version": "2.0.0-alpha.13",
4
4
  "description": "Bakery database layer: adapters, query builder, schema sync and backup.",
5
5
  "keywords": [
6
6
  "bakery",
@@ -54,6 +54,6 @@
54
54
  "bun": ">=1.4.0"
55
55
  },
56
56
  "dependencies": {
57
- "@bakery-framework/core": "^2.0.0-alpha.11"
57
+ "@bakery-framework/core": "^2.0.0-alpha.13"
58
58
  }
59
59
  }
@@ -73,7 +73,33 @@ export namespace SQLAdapter {
73
73
  export interface TableDataOptions extends FilterSortOptions {
74
74
  page: number
75
75
  pageSize: number
76
+ /**
77
+ * A total the caller already has, so the `COUNT(*)` can be skipped.
78
+ *
79
+ * **The count is almost the whole cost of a page.** Measured on a
80
+ * 200,000-row SQLite table with a page size of 50, reading page 101:
81
+ *
82
+ * count, no filter 11.7 ms rows, no filter 0.4 ms
83
+ * count, filtered 51.3 ms rows, filtered 1.6 ms
84
+ *
85
+ * 97% of the work either way. The rows come off an index and stop at the
86
+ * page; the count has to visit every row that matches, and a filtered
87
+ * count visits them a second time after the rows query already did.
88
+ *
89
+ * Opt-in and caller-asserted, because it trades a guarantee for that.
90
+ * Passing a total says "I counted, with exactly these filters" - an
91
+ * adapter cannot check it, and a wrong one shows a wrong row count and a
92
+ * wrong page count. It never affects which rows come back, so the
93
+ * failure mode is a stale readout rather than wrong data. Paging through
94
+ * a table while another writer inserts is exactly when it goes stale, and
95
+ * exactly when re-counting on every page is least worth it.
96
+ *
97
+ * Ignored unless it is a non-negative finite number, so a bad value
98
+ * degrades to the count rather than to a negative page total.
99
+ */
100
+ knownTotal?: number
76
101
  }
102
+
77
103
  export interface NameRow {
78
104
  name: string
79
105
  }
@@ -202,6 +228,34 @@ export const DEFAULT_STREAM_CHUNK = 500
202
228
  * offset pagination has, and exactly what `seek()` exists to avoid. What it
203
229
  * does buy is the thing people reach for streaming to get: memory bounded by
204
230
  * the chunk instead of by the result.
231
+ *
232
+ * **What it costs, and why it is not one number.** The statement is
233
+ * re-executed per chunk, so the total is the chunk count times whatever one
234
+ * window costs — and what one window costs depends entirely on whether the
235
+ * ordering can be served from an index. If it cannot, every chunk sorts the
236
+ * whole result and throws away the offset, which is quadratic in the chunk
237
+ * count. Measured on 100,000 rows, a chunk size of 500, so 200 statements:
238
+ *
239
+ * sqlite ORDER BY the primary key all 180 ms iterate 593 ms 3x
240
+ * sqlite ORDER BY an unindexed column all 275 ms iterate 35,687 ms 130x
241
+ * postgres ORDER BY the primary key all 83 ms iterate 1,590 ms 19x
242
+ * postgres ORDER BY an unindexed column all 126 ms iterate 12,767 ms 101x
243
+ *
244
+ * Thirty-five seconds to walk what `all()` returns in 275 ms. The backlog
245
+ * recorded a flat "393x", which is the right neighbourhood for the unindexed
246
+ * case and says nothing about the case a caller can fix: **order by something
247
+ * indexed and the cost collapses by 40x.**
248
+ *
249
+ * The two dialects differ for different reasons. Postgres pays a network round
250
+ * trip per chunk, which is why its indexed case is 19x where SQLite's is 3x.
251
+ * SQLite pays nothing for the trip and everything for the sort, which is why
252
+ * its unindexed case is the worst of the four.
253
+ *
254
+ * So: reach for this when the result does not fit in memory, not when it is
255
+ * merely large. If it fits, `all()` is between 3 and 130 times faster. If it
256
+ * does not, index the column you order by, and prefer `seek()` where the shape
257
+ * allows it — that is keyset pagination and it has neither the cost nor the
258
+ * skipped-row hazard described above.
205
259
  */
206
260
  export function pagedIterate(
207
261
  all: SQLAdapter.Executor['all'],
@@ -303,6 +357,47 @@ export abstract class SQLAdapter {
303
357
  return DEFAULT_MAX_QUERY_PARAMS
304
358
  }
305
359
 
360
+ /**
361
+ * Whether a caller-supplied total can be used in place of a `COUNT(*)`.
362
+ *
363
+ * One rule in one place, because all three adapters answer it and a dialect
364
+ * that disagreed would be a bug rather than a capability. A static on the
365
+ * class rather than a function in the namespace: the namespace is declared
366
+ * above the class it merges with, so it can hold types and not values.
367
+ */
368
+ static usableTotal(value: unknown): value is number {
369
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0
370
+ }
371
+
372
+ /**
373
+ * A cheap value that changes whenever the schema changes, or `null`.
374
+ *
375
+ * The capability is expressed by the return, not by a boolean beside it: a
376
+ * dialect that cannot answer this cheaply returns `null` and its callers
377
+ * fall back to asking the schema directly. That is the default here, so a
378
+ * new adapter is correct without implementing anything.
379
+ *
380
+ * **It must not move for ordinary DML.** A value that changed on every
381
+ * `INSERT` would still be *correct* as a cache key and useless as one,
382
+ * invalidating on the busiest thing a database does. SQLite's
383
+ * `PRAGMA schema_version` has exactly the right semantics, verified rather
384
+ * than assumed: it advances on `CREATE TABLE`, on `ALTER TABLE ... ADD
385
+ * COLUMN` and on `DROP TABLE`, and stays put across an `INSERT`.
386
+ *
387
+ * A method rather than one of the capability *getters* above because it
388
+ * costs a round trip. Those describe the dialect and are answered from
389
+ * nothing; this one asks the server.
390
+ *
391
+ * Postgres and MySQL return `null`. Neither exposes a single counter for
392
+ * this - Postgres has no schema-level equivalent of `schema_version`, and
393
+ * MySQL's `information_schema` would have to be aggregated, which is the
394
+ * expense being avoided. Adding one later is a per-adapter override and
395
+ * needs no change here or in any caller.
396
+ */
397
+ async schemaFingerprint(): Promise<string | null> {
398
+ return null
399
+ }
400
+
306
401
  constructor(
307
402
  public readonly driver: SQLAdapter.Driver,
308
403
  public readonly filename?: string,
@@ -1243,6 +1338,19 @@ export class DatabaseStatement {
1243
1338
  values(...params: any[]) {
1244
1339
  return this.connection.execute.values(this.sql, params)
1245
1340
  }
1341
+ /**
1342
+ * Walk the result a chunk at a time, holding only the chunk.
1343
+ *
1344
+ * **Not a cursor, and not cheap.** Bun cannot stream, so this pages: the
1345
+ * statement becomes a derived table and is re-executed once per 500 rows.
1346
+ * On 100,000 rows that is 200 statements, and the total depends on whether
1347
+ * the ordering is indexed — 3x `all()` on SQLite ordered by a primary key,
1348
+ * **130x** ordered by a column with no index (35.7 seconds against 275 ms).
1349
+ * The full table and the reasoning are on {@link pagedIterate}.
1350
+ *
1351
+ * Use it when the result does not fit in memory. When it does, `all()` is
1352
+ * faster by between 3 and 130 times.
1353
+ */
1246
1354
  iterate(...params: any[]) {
1247
1355
  return this.connection.execute.iterate(this.sql, params)
1248
1356
  }
@@ -332,10 +332,15 @@ export class MySQLAdapter extends SQLAdapter {
332
332
  new Set(cols.map(c => c.name)),
333
333
  )
334
334
  const tName = this.quote(tableName)
335
- const countRes = (await this.query(
336
- `SELECT COUNT(*) as count FROM ${tName}${whereSql}`,
337
- ).get(...whereParams)) as SQLAdapter.CountRow
338
- const totalRows = countRes?.count || 0
335
+ // See `TableDataOptions.knownTotal`: the count is almost the whole cost
336
+ // of a page, and a caller that has already counted can say so.
337
+ const reuse = SQLAdapter.usableTotal(options.knownTotal)
338
+ const countRes = reuse
339
+ ? null
340
+ : ((await this.query(
341
+ `SELECT COUNT(*) as count FROM ${tName}${whereSql}`,
342
+ ).get(...whereParams)) as SQLAdapter.CountRow)
343
+ const totalRows = reuse ? options.knownTotal! : countRes?.count || 0
339
344
  const rows = await this.query(
340
345
  `SELECT * FROM ${tName}${whereSql}${orderSql} LIMIT ? OFFSET ?`,
341
346
  ).all(
@@ -57,7 +57,11 @@ export class PGAdapter extends SQLAdapter {
57
57
 
58
58
  private static handleSpecial(
59
59
  char: string,
60
- nextChar: string | undefined,
60
+ // Underscored because it is deliberately unread: the branch that would
61
+ // have used it is the backslash branch, and the comment below is why
62
+ // there is not one. Kept in the signature so the shape matches
63
+ // `handleSpecial` in the other two adapters.
64
+ _nextChar: string | undefined,
61
65
  state: PGSQLParserState,
62
66
  ): string | null {
63
67
  // No backslash branch, deliberately. Postgres with
@@ -333,10 +337,15 @@ export class PGAdapter extends SQLAdapter {
333
337
  new Set(cols.map(c => c.name)),
334
338
  )
335
339
  const tName = this.quote(tableName)
336
- const countRes = (await this.query(
337
- `SELECT COUNT(*) as count FROM ${tName}${whereSql}`,
338
- ).get(...whereParams)) as SQLAdapter.CountRow
339
- const totalRows = countRes?.count || 0
340
+ // See `TableDataOptions.knownTotal`: the count is almost the whole cost
341
+ // of a page, and a caller that has already counted can say so.
342
+ const reuse = SQLAdapter.usableTotal(options.knownTotal)
343
+ const countRes = reuse
344
+ ? null
345
+ : ((await this.query(
346
+ `SELECT COUNT(*) as count FROM ${tName}${whereSql}`,
347
+ ).get(...whereParams)) as SQLAdapter.CountRow)
348
+ const totalRows = reuse ? options.knownTotal! : countRes?.count || 0
340
349
  const rows = (await this.query(
341
350
  `SELECT ctid::text AS rowid, * FROM ${tName}` +
342
351
  `${whereSql}${orderSql} LIMIT ? OFFSET ?`,
@@ -1,10 +1,20 @@
1
1
  import path from 'node:path'
2
2
  import { Bakery } from '@bakery-framework/core/core/bakery'
3
+ import { Logger, messageLogger } from '@bakery-framework/core/logger'
3
4
  import { Case, Try } from '@bakery-framework/core/utils'
4
5
  import { SQL } from 'bun'
5
6
  import type * as SyncTypes from '../sync/types'
6
7
  import { createExecutor, SQLAdapter } from './base'
7
8
 
9
+ // Convention 4: logging is data, declared in a table rather than formatted at
10
+ // the call site.
11
+ const MESSAGES = messageLogger(new Logger('database'), {
12
+ JOURNAL_WAL_REFUSED:
13
+ 'W SQLite refused WAL for %y{file}%* (answered %y{answer}%*) — running the %y{mode}%* journal instead. Expected on a network path; on a local disk it costs every write.',
14
+ PRAGMA_FAILED:
15
+ 'W A SQLite performance pragma was rejected: %r{error}%*. The database works; it is not tuned.',
16
+ } as const)
17
+
8
18
  export class SQLiteAdapter extends SQLAdapter {
9
19
  // SQLite's standard identifier quote. The base class defaults to MySQL's
10
20
  // backtick, which SQLite only tolerates as a compatibility extension.
@@ -12,6 +22,20 @@ export class SQLiteAdapter extends SQLAdapter {
12
22
 
13
23
  protected readonly sql: SQL
14
24
 
25
+ /**
26
+ * Resolves when the performance pragmas have finished, or failed loudly.
27
+ *
28
+ * They are deliberately not awaited by every query the way `ready` is —
29
+ * tuning is not correctness, and making the first statement of every process
30
+ * wait on six round trips to gain nothing is the wrong trade. But `close()`
31
+ * has to wait, because a handle closed underneath an in-flight pragma
32
+ * rejects with `Connection closed`, and that is indistinguishable at the
33
+ * catch from a filesystem genuinely refusing one. A short-lived adapter — a
34
+ * test, a one-shot script — would otherwise warn on every close about a
35
+ * failure that never happened.
36
+ */
37
+ protected readonly tuned: Promise<unknown> = Promise.resolve()
38
+
15
39
  /**
16
40
  * Resolves once `foreign_keys` is on, and every query awaits it.
17
41
  *
@@ -151,18 +175,105 @@ export class SQLiteAdapter extends SQLAdapter {
151
175
 
152
176
  if (ownsConnection && filename !== ':memory:') {
153
177
  const cacheSize = import.meta.env.THREAD_WORKER ? -1000 : -10000
154
- const journalMode = process.platform === 'win32' ? 'DELETE' : 'WAL'
155
- this.sql
156
- .unsafe(`PRAGMA journal_mode = ${journalMode};`)
178
+ this.tuned = this.sql
179
+ .unsafe('PRAGMA journal_mode = WAL;')
180
+ .then((rows: unknown) => this.confirmWAL(rows, filename))
157
181
  .then(() => this.sql.unsafe('PRAGMA synchronous = NORMAL;'))
158
182
  .then(() => this.sql.unsafe('PRAGMA temp_store = memory;'))
159
183
  .then(() => this.sql.unsafe(`PRAGMA cache_size = ${cacheSize};`))
160
184
  .then(() => this.sql.unsafe('PRAGMA busy_timeout = 5000;'))
161
185
  .then(() => this.sql.unsafe('PRAGMA mmap_size = 0;'))
162
- .catch(() => {})
186
+ // Not `catch(() => {})`. Every performance pragma failing silently is
187
+ // how a database ends up running at a fraction of its speed with
188
+ // nothing anywhere saying so, and convention 3 bans the bare form for
189
+ // exactly this. These are tuning, not correctness — `foreign_keys`
190
+ // above is awaited and is allowed to reject — so a failure is a
191
+ // warning rather than a throw.
192
+ .catch((error: Error) => {
193
+ MESSAGES.PRAGMA_FAILED({ error: error.message })
194
+ })
163
195
  }
164
196
  }
165
197
 
198
+ /**
199
+ * Waits for the pragma chain before closing the handle. See `tuned`.
200
+ */
201
+ override async close(): Promise<void> {
202
+ await Try.catch(this.tuned)
203
+ await super.close()
204
+ }
205
+
206
+ /**
207
+ * **WAL first, DELETE only where WAL is actually refused.**
208
+ *
209
+ * The journal mode used to be `platform === 'win32' ? 'DELETE' : 'WAL'`, a
210
+ * rule with no recorded reason. The hypothesis behind it was that a Windows
211
+ * network path cannot host WAL, which is true — WAL needs a shared `-shm`
212
+ * mapping that SMB and WebDAV do not provide — but the rule applied to every
213
+ * Windows install, local disks included, where WAL works.
214
+ *
215
+ * Measured on this machine, four interleaved rounds against a CPU-bound
216
+ * control that stayed flat at 29-34 ms:
217
+ *
218
+ * single autocommit write DELETE 3629 us WAL 36 us 100x
219
+ * 100-row transaction DELETE 3.91 ms WAL 0.13 ms 29x
220
+ *
221
+ * An attempt and a check replaces the rule, which is what makes it safe to
222
+ * change without knowing why it was there. SQLite refuses WAL in two
223
+ * observable ways and both are handled: it returns the *unchanged* mode in
224
+ * the pragma's own result row (an in-memory database answers `memory`), or
225
+ * it throws. Both shapes were reproduced before this was written, so the
226
+ * fallback rests on a measurement rather than on the documentation.
227
+ *
228
+ * The same logic runs on the cache database in
229
+ * `@bakery-framework/core/cache/shared-db`. It is deliberately not shared:
230
+ * that site is synchronous `bun:sqlite` and this one is an async
231
+ * `Bun.SQL`, so the control flow has nothing in common and only the
232
+ * predicate would move — at the cost of a new published export subpath on a
233
+ * surface that was closed on purpose before 2.0.0.
234
+ */
235
+ private async confirmWAL(rows: unknown, filename: string): Promise<void> {
236
+ const first = Array.isArray(rows) ? rows[0] : undefined
237
+ const answer = String(
238
+ (first as { journal_mode?: unknown } | undefined)?.journal_mode ?? '',
239
+ )
240
+ if (answer.toLowerCase() === 'wal') return
241
+
242
+ const fell = await this.sql.unsafe('PRAGMA journal_mode = DELETE;')
243
+ const mode = Array.isArray(fell)
244
+ ? String((fell[0] as { journal_mode?: unknown })?.journal_mode ?? 'unknown')
245
+ : 'unknown'
246
+ MESSAGES.JOURNAL_WAL_REFUSED({
247
+ file: filename,
248
+ answer: answer || '(no row)',
249
+ mode,
250
+ })
251
+ }
252
+
253
+ /**
254
+ * `PRAGMA schema_version`, which is a counter SQLite bumps on every schema
255
+ * change and leaves alone for every row change.
256
+ *
257
+ * Measured on a 50-table database: the full introspection the explorer runs
258
+ * per write is **250 statements and 8.28 ms**, and this is **10.4 us** - 795x.
259
+ * The semantics were checked rather than taken from the documentation: the
260
+ * counter advances on `CREATE TABLE`, on `ALTER TABLE ... ADD COLUMN` and on
261
+ * `DROP TABLE`, and does not move for an `INSERT`.
262
+ *
263
+ * Prefixed with the driver and the file so two different SQLite databases
264
+ * cannot collide on the same small integer. A fresh database starts near
265
+ * zero, so "version 3" is a value many of them hold at once.
266
+ */
267
+ override async schemaFingerprint(): Promise<string | null> {
268
+ const row = (await this.query('PRAGMA schema_version').get()) as
269
+ | { schema_version?: number }
270
+ | null
271
+ | undefined
272
+ const version = row?.schema_version
273
+ if (typeof version !== 'number') return null
274
+ return `sqlite:${this.filename ?? ''}:${version}`
275
+ }
276
+
166
277
  private static resolveFilename(rawValue?: string | null): string {
167
278
  const envVal = process.env.DATABASE_URL || process.env.SQLITE_PATH
168
279
  // `Bakery.dataDir`, not a literal: the default database file has to follow
@@ -351,17 +462,23 @@ export class SQLiteAdapter extends SQLAdapter {
351
462
 
352
463
  const { page, pageSize } = options
353
464
 
465
+ // See `TableDataOptions.knownTotal`: the count is 97% of a page's cost,
466
+ // and a caller that has already counted can say so.
467
+ const reuse = SQLAdapter.usableTotal(options.knownTotal)
468
+
354
469
  const [countRes, rows] = (await Promise.all([
355
- this.query(`SELECT COUNT(*) as count FROM ${tname}${whereSql}`).get(
356
- ...whereParams,
357
- ),
470
+ reuse
471
+ ? Promise.resolve(null)
472
+ : this.query(`SELECT COUNT(*) as count FROM ${tname}${whereSql}`).get(
473
+ ...whereParams,
474
+ ),
358
475
  this.query(
359
476
  `SELECT rowid AS rowid, * FROM ${tname}` +
360
477
  `${whereSql}${orderSql} LIMIT ? OFFSET ?`,
361
478
  ).all(...whereParams, pageSize, (page - 1) * pageSize),
362
- ])) as [SQLAdapter.CountRow, any[]]
479
+ ])) as [SQLAdapter.CountRow | null, any[]]
363
480
 
364
- const totalRows = countRes?.count || 0
481
+ const totalRows = reuse ? options.knownTotal! : countRes?.count || 0
365
482
  return {
366
483
  rows,
367
484
  totalRows,
@@ -469,9 +586,13 @@ export class SQLiteAdapter extends SQLAdapter {
469
586
  const tName = Case.camel(table.name)
470
587
  dbConstraints[tName] = {} as SyncTypes.TableConstraints
471
588
 
589
+ // Bound, not interpolated — the same conversion `hasCol` above
590
+ // records. This one survived it: `getConstraints` runs over every table
591
+ // the database reports, so a name carrying an apostrophe closed the
592
+ // literal and the rest of the statement went with it.
472
593
  const cols = (await this.query(
473
- `PRAGMA table_info('${table.name}')`,
474
- ).all()) as any[]
594
+ 'SELECT * FROM pragma_table_info(?)',
595
+ ).all(table.name)) as any[]
475
596
 
476
597
  if (table.type === 'view') {
477
598
  const match = table.sql.match(/AS\s+(.*)/is)
@@ -509,8 +630,12 @@ export class SQLiteAdapter extends SQLAdapter {
509
630
  return Object.fromEntries(
510
631
  await Promise.all(
511
632
  indexes.map(async idx => {
633
+ // Bound for the same reason as `getConstraints` above: an index
634
+ // name comes from `sqlite_master`, not from this codebase.
512
635
  const raw = (
513
- (await this.query(`PRAGMA index_info('${idx.name}')`).all()) as any[]
636
+ (await this.query('SELECT * FROM pragma_index_info(?)').all(
637
+ idx.name,
638
+ )) as any[]
514
639
  ).map(c => String(c.name))
515
640
  return [
516
641
  Case.camel(idx.name),
@@ -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 {
@@ -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
  }
@@ -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
  }
package/src/orm/query.ts CHANGED
@@ -2,18 +2,7 @@ import { Case } from '@bakery-framework/core/utils'
2
2
  import { throws } from '@bakery-framework/core/utils/common'
3
3
  import { getActiveDb, txStorage } from '../connection'
4
4
  import type { AppDBSchema as DBSchema } from '../schema-registry'
5
- import {
6
- evalOperands,
7
- isSafeIdentifier,
8
- qId,
9
- qRaw,
10
- SQL_FUNCTIONS,
11
- col as schemaCol,
12
- ColumnRef as schemaColumnRef,
13
- OperatorRef as schemaOperatorRef,
14
- SQLFunctionRef as schemaSQLFunctionRef,
15
- WindowRef as schemaWindowRef,
16
- } from '../schema-util'
5
+ import { ColumnRef as schemaColumnRef, OperatorRef as schemaOperatorRef, SQLFunctionRef as schemaSQLFunctionRef, SQL_FUNCTIONS, WindowRef as schemaWindowRef, col as schemaCol, evalOperands, isSafeIdentifier, nullComparison, qId, qRaw } from '../schema-util'
17
6
  import { Mutation } from './mutation'
18
7
 
19
8
  export namespace DB {
@@ -501,6 +490,27 @@ export namespace DB {
501
490
  ])
502
491
  }
503
492
 
493
+ /**
494
+ * Walk the result a chunk at a time, holding only the chunk.
495
+ *
496
+ * **The name promises a cursor and the implementation is paging**, because
497
+ * Bun cannot stream: the statement becomes a derived table and is
498
+ * re-executed once per 500 rows. Two consequences, and the second is the
499
+ * one that surprises people.
500
+ *
501
+ * Chunk boundaries are only stable under a total order, so a row inserted
502
+ * or deleted mid-walk can be seen twice or missed. `seek()` is keyset
503
+ * pagination and has neither problem.
504
+ *
505
+ * And it is slow in proportion to how badly the ordering indexes.
506
+ * Measured on 100,000 rows: 3x `all()` on SQLite ordered by a primary key,
507
+ * **130x** ordered by a column with no index — 35.7 seconds against
508
+ * 275 ms. Postgres pays a round trip per chunk instead and lands at 19x
509
+ * and 101x. The table is on `pagedIterate` in `adapters/base.ts`.
510
+ *
511
+ * Worth it when the result does not fit in memory. Not worth it merely
512
+ * because the result is large.
513
+ */
504
514
  async *iterable(): AsyncIterable<P> {
505
515
  const { sql, params } = this.parse()
506
516
  for await (const row of getActiveDb()
@@ -1072,16 +1082,9 @@ export namespace DB {
1072
1082
  if (op === 'IS NULL' || op === 'IS NOT NULL') {
1073
1083
  return `${left} ${op}`
1074
1084
  }
1075
- // A bound NULL can never satisfy `=` or `<>` three-valued logic makes
1076
- // both comparisons UNKNOWN for every row, so `where(col, null)` silently
1077
- // matched nothing while cheerfully reporting success. The caller who
1078
- // passes an explicit null means the SQL spelling of it. Only literal
1079
- // values: a column reference on the right is a different comparison and
1080
- // is left alone.
1081
- if (rightArg === null && !isRightColumn) {
1082
- if (op === '=') return `${left} IS NULL`
1083
- if (op === '!=' || op === '<>') return `${left} IS NOT NULL`
1084
- }
1085
+ // One rule, three callerssee `nullComparison`.
1086
+ const nullOp = nullComparison(op, rightArg, isRightColumn)
1087
+ if (nullOp) return `${left} ${nullOp}`
1085
1088
  if (op === 'BETWEEN' && Array.isArray(rightArg)) {
1086
1089
  const min = evalOperands(rightArg[0], params, false)
1087
1090
  const max = evalOperands(rightArg[1], params, false)
@@ -465,3 +465,35 @@ export function old(
465
465
  _transform: transform,
466
466
  })
467
467
  }
468
+
469
+ /**
470
+ * The SQL spelling of a comparison against an explicit `null`, or `null` when
471
+ * the comparison is an ordinary one.
472
+ *
473
+ * A bound NULL can never satisfy `=` or `<>`: three-valued logic makes both
474
+ * UNKNOWN for every row, so `where(col, null)` matched nothing while reporting
475
+ * success. On a `SELECT` that is an empty result; on a `DELETE` or an
476
+ * `UPDATE` it is a write that silently does nothing and answers zero changes,
477
+ * which a caller cannot tell from a genuine conflict.
478
+ *
479
+ * **This lives here, not in a `WHERE` builder, because there are three of
480
+ * them.** `formatClause` in `orm/query.ts` and the two `evalWhere` copies in
481
+ * `orm/mutation.ts` each compile a clause, and the copies have now diverged
482
+ * twice: once over the one-argument `where(DB.raw\`…\`)` form, which the
483
+ * comment in `DeleteExecutable.evalWhere` records, and once over this rule,
484
+ * which reached the builder and neither mutation path. Both were silent. One
485
+ * function three callers share cannot drift a third time.
486
+ *
487
+ * A column reference on the right is a different comparison and is left alone.
488
+ */
489
+ export function nullComparison(
490
+ operator: string,
491
+ right: unknown,
492
+ isRightColumn?: boolean,
493
+ ): string | null {
494
+ if (right !== null || isRightColumn) return null
495
+ const op = operator.toUpperCase()
496
+ if (op === '=') return 'IS NULL'
497
+ if (op === '!=' || op === '<>') return 'IS NOT NULL'
498
+ return null
499
+ }