@shaferllc/keel 0.81.2 → 0.82.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,6 +22,13 @@ export interface Paginated<T> {
22
22
  currentPage: number;
23
23
  lastPage: number;
24
24
  }
25
+ /** A page without a total, returned by `simplePaginate()`. */
26
+ export interface SimplePaginated<T> {
27
+ data: T[];
28
+ perPage: number;
29
+ currentPage: number;
30
+ hasMore: boolean;
31
+ }
25
32
  /** The bridge to your database driver. */
26
33
  export interface Connection {
27
34
  /** Run a SELECT (or any row-returning query) and return the rows. */
@@ -85,16 +92,28 @@ export declare class QueryBuilder<T extends Row = Row> {
85
92
  private groups;
86
93
  private havings;
87
94
  private _distinct;
95
+ private _randomOrder;
96
+ private _lock?;
88
97
  constructor(table: string, getSource: () => Source);
89
98
  /** Run a row-returning query on this builder's connection, dialect-adjusted. */
90
99
  private runSelect;
91
100
  /** Run a write on this builder's connection, dialect-adjusted. */
92
101
  private runWrite;
93
102
  select(...columns: string[]): this;
103
+ /** Append columns to the SELECT list without replacing it. */
104
+ addSelect(...columns: string[]): this;
105
+ /** A raw SELECT expression, e.g. `selectRaw("COUNT(*) AS n")`. */
106
+ selectRaw(sql: string): this;
107
+ /** Wrap a callback's conditions in parentheses, joined to the rest with `boolean`. */
108
+ private whereGroup;
109
+ where(group: (query: QueryBuilder) => void): this;
94
110
  where(column: string, value: unknown): this;
95
111
  where(column: string, operator: Operator, value: unknown): this;
112
+ orWhere(group: (query: QueryBuilder) => void): this;
96
113
  orWhere(column: string, value: unknown): this;
97
114
  orWhere(column: string, operator: Operator, value: unknown): this;
115
+ /** Negate a simple comparison: `whereNot("status", "active")`. */
116
+ whereNot(column: string, opOrValue: unknown, value?: unknown): this;
98
117
  whereIn(column: string, values: unknown[]): this;
99
118
  whereNull(column: string): this;
100
119
  whereNotNull(column: string): this;
@@ -103,30 +122,78 @@ export declare class QueryBuilder<T extends Row = Row> {
103
122
  whereLike(column: string, pattern: string): this;
104
123
  /** Compare two columns (no binding): `whereColumn("updated_at", ">", "created_at")`. */
105
124
  whereColumn(first: string, opOrSecond: string, second?: string): this;
125
+ whereNotBetween(column: string, [min, max]: [unknown, unknown]): this;
106
126
  /** A raw WHERE fragment with its own bindings. */
107
127
  whereRaw(sql: string, bindings?: unknown[]): this;
128
+ orWhereIn(column: string, values: unknown[]): this;
129
+ orWhereNotIn(column: string, values: unknown[]): this;
130
+ orWhereNull(column: string): this;
131
+ orWhereNotNull(column: string): this;
132
+ orWhereBetween(column: string, [min, max]: [unknown, unknown]): this;
133
+ orWhereColumn(first: string, opOrSecond: string, second?: string): this;
134
+ orWhereLike(column: string, pattern: string): this;
135
+ orWhereRaw(sql: string, bindings?: unknown[]): this;
108
136
  join(table: string, first: string, opOrSecond: string, second?: string): this;
109
137
  leftJoin(table: string, first: string, opOrSecond: string, second?: string): this;
138
+ rightJoin(table: string, first: string, opOrSecond: string, second?: string): this;
139
+ crossJoin(table: string): this;
110
140
  groupBy(...columns: string[]): this;
141
+ groupByRaw(sql: string): this;
111
142
  having(column: string, opOrValue: unknown, value?: unknown): this;
143
+ havingRaw(sql: string, bindings?: unknown[]): this;
144
+ havingBetween(column: string, [min, max]: [unknown, unknown]): this;
112
145
  distinct(): this;
113
146
  /** Apply `then` only when `condition` is truthy (optionally `otherwise`). */
114
147
  when(condition: unknown, then: (query: this, value: unknown) => void, otherwise?: (query: this, value: unknown) => void): this;
148
+ /** The inverse of `when` — apply `then` only when `condition` is falsy. */
149
+ unless(condition: unknown, then: (query: this, value: unknown) => void, otherwise?: (query: this, value: unknown) => void): this;
115
150
  orderBy(column: string, direction?: "asc" | "desc"): this;
151
+ orderByDesc(column: string): this;
116
152
  /** A raw ORDER BY fragment, e.g. `orderByRaw("LENGTH(name) DESC")`. */
117
153
  orderByRaw(sql: string): this;
154
+ /** Drop existing ordering, optionally setting a new one. */
155
+ reorder(column?: string, direction?: "asc" | "desc"): this;
156
+ /** Order rows randomly (dialect-aware `RANDOM()` / `RAND()`). */
157
+ inRandomOrder(): this;
118
158
  /** Newest-first / oldest-first by a timestamp column (default `created_at`). */
119
159
  latest(column?: string): this;
120
160
  oldest(column?: string): this;
121
161
  limit(n: number): this;
122
162
  offset(n: number): this;
163
+ /** Alias of `limit`. */
164
+ take(n: number): this;
165
+ /** Alias of `offset`. */
166
+ skip(n: number): this;
167
+ /** Limit/offset for a given page (1-based). */
168
+ forPage(page: number, perPage?: number): this;
169
+ /** Add `FOR UPDATE` (write lock) to the SELECT; ignored on sqlite. */
170
+ lockForUpdate(): this;
171
+ /** Add `FOR SHARE` (read lock) to the SELECT; ignored on sqlite. */
172
+ sharedLock(): this;
123
173
  private whereClause;
124
174
  private havingClause;
125
175
  private joinSql;
176
+ /** Compile the SELECT into `?`-placeholder SQL plus its bindings. */
177
+ private compileSelect;
126
178
  get(): Promise<T[]>;
127
179
  first(): Promise<T | null>;
180
+ /** The first row, or throw `NotFoundException` when there is none. */
181
+ firstOrFail(): Promise<T>;
182
+ /** Find by primary key (default `id`), or null. */
183
+ find(id: unknown, key?: string): Promise<T | null>;
184
+ /** Exactly one row: throws if none, or if more than one matches. */
185
+ sole(): Promise<T>;
186
+ /** The SQL this query would run, with `?` placeholders (does not execute). */
187
+ toSql(): string;
188
+ /** The bindings this query would run with. */
189
+ getBindings(): unknown[];
190
+ /** Log the compiled SQL + bindings, then return the builder for chaining. */
191
+ dump(): this;
192
+ /** Dump the compiled SQL + bindings and throw, halting execution. */
193
+ dd(): never;
128
194
  count(): Promise<number>;
129
195
  exists(): Promise<boolean>;
196
+ doesntExist(): Promise<boolean>;
130
197
  private aggregate;
131
198
  sum(column: string): Promise<number>;
132
199
  avg(column: string): Promise<number>;
@@ -136,16 +203,32 @@ export declare class QueryBuilder<T extends Row = Row> {
136
203
  value<V = unknown>(column: string): Promise<V | null>;
137
204
  /** An array of a single column across all matching rows. */
138
205
  pluck<V = unknown>(column: string): Promise<V[]>;
206
+ /** Join a single column's values into a string. */
207
+ implode(column: string, glue?: string): Promise<string>;
139
208
  /** A page of results plus pagination metadata. */
140
209
  paginate(page?: number, perPage?: number): Promise<Paginated<T>>;
210
+ /**
211
+ * A lighter page: no `COUNT` query. Fetches one extra row to know whether a
212
+ * next page exists — cheaper for "load more" UIs that don't need a total.
213
+ */
214
+ simplePaginate(page?: number, perPage?: number): Promise<SimplePaginated<T>>;
141
215
  insert(data: Row): Promise<WriteResult>;
142
216
  insertGetId(data: Row): Promise<number | string | undefined>;
143
217
  update(data: Row): Promise<WriteResult>;
144
218
  delete(): Promise<WriteResult>;
219
+ /** Empty the table. */
220
+ truncate(): Promise<WriteResult>;
221
+ /** Update the first matching row, or insert `{ ...match, ...values }` if none. */
222
+ updateOrInsert(match: Row, values?: Row): Promise<WriteResult>;
145
223
  /** Atomically add to a numeric column (optionally setting other columns too). */
146
224
  increment(column: string, amount?: number, extra?: Row): Promise<WriteResult>;
147
225
  /** Atomically subtract from a numeric column. */
148
226
  decrement(column: string, amount?: number, extra?: Row): Promise<WriteResult>;
227
+ /** Increment several columns by 1 (or per-column amounts) in one statement. */
228
+ incrementEach(columns: string[] | Record<string, number>, extra?: Row): Promise<WriteResult>;
229
+ /** Decrement several columns by 1 (or per-column amounts) in one statement. */
230
+ decrementEach(columns: string[] | Record<string, number>, extra?: Row): Promise<WriteResult>;
231
+ private stepEach;
149
232
  /** Insert one or more rows, ignoring any that violate a unique constraint. */
150
233
  insertOrIgnore(rows: Row | Row[]): Promise<WriteResult>;
151
234
  /**
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import { AsyncLocalStorage } from "node:async_hooks";
13
13
  import { instrument, currentRequestId } from "./instrumentation.js";
14
+ import { NotFoundException } from "./exceptions.js";
14
15
  /**
15
16
  * Run a row-returning query on a source and report it to the instrumentation
16
17
  * stream (`db.query`) once it settles. Emitting here — the one place every
@@ -126,6 +127,8 @@ export class QueryBuilder {
126
127
  groups = [];
127
128
  havings = [];
128
129
  _distinct = false;
130
+ _randomOrder = false;
131
+ _lock;
129
132
  // The connection is resolved lazily, when a query actually runs — so building
130
133
  // a query never throws, and an unregistered connection surfaces as a rejected
131
134
  // read/write rather than a synchronous error at construction.
@@ -145,16 +148,47 @@ export class QueryBuilder {
145
148
  this.columns = columns.length ? columns.join(", ") : "*";
146
149
  return this;
147
150
  }
151
+ /** Append columns to the SELECT list without replacing it. */
152
+ addSelect(...columns) {
153
+ if (!columns.length)
154
+ return this;
155
+ this.columns = this.columns === "*" ? columns.join(", ") : `${this.columns}, ${columns.join(", ")}`;
156
+ return this;
157
+ }
158
+ /** A raw SELECT expression, e.g. `selectRaw("COUNT(*) AS n")`. */
159
+ selectRaw(sql) {
160
+ return this.addSelect(sql);
161
+ }
162
+ /** Wrap a callback's conditions in parentheses, joined to the rest with `boolean`. */
163
+ whereGroup(fn, boolean) {
164
+ const sub = new QueryBuilder(this.table, this.getSource);
165
+ fn(sub);
166
+ if (sub.wheres.length) {
167
+ const inner = sub.wheres.map((w, i) => (i === 0 ? "" : `${w.boolean} `) + w.sql).join(" ");
168
+ this.wheres.push({ boolean, sql: `(${inner})`, bindings: sub.wheres.flatMap((w) => w.bindings) });
169
+ }
170
+ return this;
171
+ }
148
172
  where(column, opOrValue, value) {
173
+ if (typeof column === "function")
174
+ return this.whereGroup(column, "AND");
149
175
  const [op, val] = value === undefined ? ["=", opOrValue] : [opOrValue, value];
150
176
  this.wheres.push({ boolean: "AND", sql: `${column} ${op} ?`, bindings: [val] });
151
177
  return this;
152
178
  }
153
179
  orWhere(column, opOrValue, value) {
180
+ if (typeof column === "function")
181
+ return this.whereGroup(column, "OR");
154
182
  const [op, val] = value === undefined ? ["=", opOrValue] : [opOrValue, value];
155
183
  this.wheres.push({ boolean: "OR", sql: `${column} ${op} ?`, bindings: [val] });
156
184
  return this;
157
185
  }
186
+ /** Negate a simple comparison: `whereNot("status", "active")`. */
187
+ whereNot(column, opOrValue, value) {
188
+ const [op, val] = value === undefined ? ["=", opOrValue] : [opOrValue, value];
189
+ this.wheres.push({ boolean: "AND", sql: `NOT (${column} ${op} ?)`, bindings: [val] });
190
+ return this;
191
+ }
158
192
  whereIn(column, values) {
159
193
  const marks = values.map(() => "?").join(", ");
160
194
  this.wheres.push({ boolean: "AND", sql: `${column} IN (${marks})`, bindings: values });
@@ -187,11 +221,51 @@ export class QueryBuilder {
187
221
  this.wheres.push({ boolean: "AND", sql: `${first} ${op} ${col}`, bindings: [] });
188
222
  return this;
189
223
  }
224
+ whereNotBetween(column, [min, max]) {
225
+ this.wheres.push({ boolean: "AND", sql: `${column} NOT BETWEEN ? AND ?`, bindings: [min, max] });
226
+ return this;
227
+ }
190
228
  /** A raw WHERE fragment with its own bindings. */
191
229
  whereRaw(sql, bindings = []) {
192
230
  this.wheres.push({ boolean: "AND", sql, bindings });
193
231
  return this;
194
232
  }
233
+ /* --------------------------- OR variants --------------------------- */
234
+ orWhereIn(column, values) {
235
+ const marks = values.map(() => "?").join(", ");
236
+ this.wheres.push({ boolean: "OR", sql: `${column} IN (${marks})`, bindings: values });
237
+ return this;
238
+ }
239
+ orWhereNotIn(column, values) {
240
+ const marks = values.map(() => "?").join(", ");
241
+ this.wheres.push({ boolean: "OR", sql: `${column} NOT IN (${marks})`, bindings: values });
242
+ return this;
243
+ }
244
+ orWhereNull(column) {
245
+ this.wheres.push({ boolean: "OR", sql: `${column} IS NULL`, bindings: [] });
246
+ return this;
247
+ }
248
+ orWhereNotNull(column) {
249
+ this.wheres.push({ boolean: "OR", sql: `${column} IS NOT NULL`, bindings: [] });
250
+ return this;
251
+ }
252
+ orWhereBetween(column, [min, max]) {
253
+ this.wheres.push({ boolean: "OR", sql: `${column} BETWEEN ? AND ?`, bindings: [min, max] });
254
+ return this;
255
+ }
256
+ orWhereColumn(first, opOrSecond, second) {
257
+ const [op, col] = second === undefined ? ["=", opOrSecond] : [opOrSecond, second];
258
+ this.wheres.push({ boolean: "OR", sql: `${first} ${op} ${col}`, bindings: [] });
259
+ return this;
260
+ }
261
+ orWhereLike(column, pattern) {
262
+ this.wheres.push({ boolean: "OR", sql: `${column} LIKE ?`, bindings: [pattern] });
263
+ return this;
264
+ }
265
+ orWhereRaw(sql, bindings = []) {
266
+ this.wheres.push({ boolean: "OR", sql, bindings });
267
+ return this;
268
+ }
195
269
  join(table, first, opOrSecond, second) {
196
270
  const [op, col] = second === undefined ? ["=", opOrSecond] : [opOrSecond, second];
197
271
  this.joins.push(`INNER JOIN ${table} ON ${first} ${op} ${col}`);
@@ -202,15 +276,36 @@ export class QueryBuilder {
202
276
  this.joins.push(`LEFT JOIN ${table} ON ${first} ${op} ${col}`);
203
277
  return this;
204
278
  }
279
+ rightJoin(table, first, opOrSecond, second) {
280
+ const [op, col] = second === undefined ? ["=", opOrSecond] : [opOrSecond, second];
281
+ this.joins.push(`RIGHT JOIN ${table} ON ${first} ${op} ${col}`);
282
+ return this;
283
+ }
284
+ crossJoin(table) {
285
+ this.joins.push(`CROSS JOIN ${table}`);
286
+ return this;
287
+ }
205
288
  groupBy(...columns) {
206
289
  this.groups.push(...columns);
207
290
  return this;
208
291
  }
292
+ groupByRaw(sql) {
293
+ this.groups.push(sql);
294
+ return this;
295
+ }
209
296
  having(column, opOrValue, value) {
210
297
  const [op, val] = value === undefined ? ["=", opOrValue] : [opOrValue, value];
211
298
  this.havings.push({ boolean: "AND", sql: `${column} ${op} ?`, bindings: [val] });
212
299
  return this;
213
300
  }
301
+ havingRaw(sql, bindings = []) {
302
+ this.havings.push({ boolean: "AND", sql, bindings });
303
+ return this;
304
+ }
305
+ havingBetween(column, [min, max]) {
306
+ this.havings.push({ boolean: "AND", sql: `${column} BETWEEN ? AND ?`, bindings: [min, max] });
307
+ return this;
308
+ }
214
309
  distinct() {
215
310
  this._distinct = true;
216
311
  return this;
@@ -223,15 +318,39 @@ export class QueryBuilder {
223
318
  otherwise?.(this, condition);
224
319
  return this;
225
320
  }
321
+ /** The inverse of `when` — apply `then` only when `condition` is falsy. */
322
+ unless(condition, then, otherwise) {
323
+ if (!condition)
324
+ then(this, condition);
325
+ else
326
+ otherwise?.(this, condition);
327
+ return this;
328
+ }
226
329
  orderBy(column, direction = "asc") {
227
330
  this.orders.push(`${column} ${direction.toUpperCase()}`);
228
331
  return this;
229
332
  }
333
+ orderByDesc(column) {
334
+ return this.orderBy(column, "desc");
335
+ }
230
336
  /** A raw ORDER BY fragment, e.g. `orderByRaw("LENGTH(name) DESC")`. */
231
337
  orderByRaw(sql) {
232
338
  this.orders.push(sql);
233
339
  return this;
234
340
  }
341
+ /** Drop existing ordering, optionally setting a new one. */
342
+ reorder(column, direction = "asc") {
343
+ this.orders = [];
344
+ this._randomOrder = false;
345
+ if (column)
346
+ this.orderBy(column, direction);
347
+ return this;
348
+ }
349
+ /** Order rows randomly (dialect-aware `RANDOM()` / `RAND()`). */
350
+ inRandomOrder() {
351
+ this._randomOrder = true;
352
+ return this;
353
+ }
235
354
  /** Newest-first / oldest-first by a timestamp column (default `created_at`). */
236
355
  latest(column = "created_at") {
237
356
  return this.orderBy(column, "desc");
@@ -247,6 +366,28 @@ export class QueryBuilder {
247
366
  this._offset = n;
248
367
  return this;
249
368
  }
369
+ /** Alias of `limit`. */
370
+ take(n) {
371
+ return this.limit(n);
372
+ }
373
+ /** Alias of `offset`. */
374
+ skip(n) {
375
+ return this.offset(n);
376
+ }
377
+ /** Limit/offset for a given page (1-based). */
378
+ forPage(page, perPage = 15) {
379
+ return this.offset((Math.max(1, page) - 1) * perPage).limit(perPage);
380
+ }
381
+ /** Add `FOR UPDATE` (write lock) to the SELECT; ignored on sqlite. */
382
+ lockForUpdate() {
383
+ this._lock = "update";
384
+ return this;
385
+ }
386
+ /** Add `FOR SHARE` (read lock) to the SELECT; ignored on sqlite. */
387
+ sharedLock() {
388
+ this._lock = "share";
389
+ return this;
390
+ }
250
391
  whereClause() {
251
392
  if (!this.wheres.length)
252
393
  return { sql: "", bindings: [] };
@@ -265,7 +406,8 @@ export class QueryBuilder {
265
406
  return this.joins.length ? ` ${this.joins.join(" ")}` : "";
266
407
  }
267
408
  /* ------------------------------- reads ------------------------------- */
268
- async get() {
409
+ /** Compile the SELECT into `?`-placeholder SQL plus its bindings. */
410
+ compileSelect(dialect) {
269
411
  const where = this.whereClause();
270
412
  const having = this.havingClause();
271
413
  let sql = `SELECT ${this._distinct ? "DISTINCT " : ""}${this.columns} FROM ${this.table}`;
@@ -273,19 +415,69 @@ export class QueryBuilder {
273
415
  if (this.groups.length)
274
416
  sql += ` GROUP BY ${this.groups.join(", ")}`;
275
417
  sql += having.sql;
276
- if (this.orders.length)
277
- sql += ` ORDER BY ${this.orders.join(", ")}`;
418
+ const orders = [...this.orders];
419
+ if (this._randomOrder)
420
+ orders.push(dialect === "mysql" ? "RAND()" : "RANDOM()");
421
+ if (orders.length)
422
+ sql += ` ORDER BY ${orders.join(", ")}`;
278
423
  if (this._limit != null)
279
424
  sql += ` LIMIT ${this._limit}`;
280
425
  if (this._offset != null)
281
426
  sql += ` OFFSET ${this._offset}`;
282
- return (await this.runSelect(sql, [...where.bindings, ...having.bindings]));
427
+ if (this._lock && dialect && dialect !== "sqlite") {
428
+ sql += this._lock === "update" ? " FOR UPDATE" : " FOR SHARE";
429
+ }
430
+ return { sql, bindings: [...where.bindings, ...having.bindings] };
431
+ }
432
+ async get() {
433
+ const source = this.getSource();
434
+ const { sql, bindings } = this.compileSelect(source.dialect);
435
+ return (await selectOn(source, sql, bindings));
283
436
  }
284
437
  async first() {
285
438
  this._limit = 1;
286
439
  const rows = await this.get();
287
440
  return rows[0] ?? null;
288
441
  }
442
+ /** The first row, or throw `NotFoundException` when there is none. */
443
+ async firstOrFail() {
444
+ const row = await this.first();
445
+ if (!row)
446
+ throw new NotFoundException(`No row found in ${this.table}`);
447
+ return row;
448
+ }
449
+ /** Find by primary key (default `id`), or null. */
450
+ async find(id, key = "id") {
451
+ return this.where(key, id).first();
452
+ }
453
+ /** Exactly one row: throws if none, or if more than one matches. */
454
+ async sole() {
455
+ this._limit = 2;
456
+ const rows = await this.get();
457
+ if (rows.length === 0)
458
+ throw new NotFoundException(`No row found in ${this.table}`);
459
+ if (rows.length > 1)
460
+ throw new Error(`Multiple rows found in ${this.table}`);
461
+ return rows[0];
462
+ }
463
+ /** The SQL this query would run, with `?` placeholders (does not execute). */
464
+ toSql() {
465
+ return this.compileSelect().sql;
466
+ }
467
+ /** The bindings this query would run with. */
468
+ getBindings() {
469
+ return this.compileSelect().bindings;
470
+ }
471
+ /** Log the compiled SQL + bindings, then return the builder for chaining. */
472
+ dump() {
473
+ console.log(this.toSql(), this.getBindings());
474
+ return this;
475
+ }
476
+ /** Dump the compiled SQL + bindings and throw, halting execution. */
477
+ dd() {
478
+ console.log(this.toSql(), this.getBindings());
479
+ throw new Error("dd(): query dumped");
480
+ }
289
481
  async count() {
290
482
  const where = this.whereClause();
291
483
  const rows = (await this.runSelect(`SELECT COUNT(*) AS count FROM ${this.table}${this.joinSql()}${where.sql}`, where.bindings));
@@ -294,6 +486,9 @@ export class QueryBuilder {
294
486
  async exists() {
295
487
  return (await this.count()) > 0;
296
488
  }
489
+ async doesntExist() {
490
+ return !(await this.exists());
491
+ }
297
492
  async aggregate(fn, column) {
298
493
  const where = this.whereClause();
299
494
  const rows = (await this.runSelect(`SELECT ${fn}(${column}) AS agg FROM ${this.table}${this.joinSql()}${where.sql}`, where.bindings));
@@ -323,6 +518,10 @@ export class QueryBuilder {
323
518
  const rows = await this.get();
324
519
  return rows.map((row) => row[column]);
325
520
  }
521
+ /** Join a single column's values into a string. */
522
+ async implode(column, glue = "") {
523
+ return (await this.pluck(column)).join(glue);
524
+ }
326
525
  /** A page of results plus pagination metadata. */
327
526
  async paginate(page = 1, perPage = 15) {
328
527
  const total = await this.count();
@@ -337,6 +536,17 @@ export class QueryBuilder {
337
536
  lastPage: Math.max(1, Math.ceil(total / perPage)),
338
537
  };
339
538
  }
539
+ /**
540
+ * A lighter page: no `COUNT` query. Fetches one extra row to know whether a
541
+ * next page exists — cheaper for "load more" UIs that don't need a total.
542
+ */
543
+ async simplePaginate(page = 1, perPage = 15) {
544
+ this._limit = perPage + 1;
545
+ this._offset = (Math.max(1, page) - 1) * perPage;
546
+ const rows = await this.get();
547
+ const hasMore = rows.length > perPage;
548
+ return { data: hasMore ? rows.slice(0, perPage) : rows, perPage, currentPage: page, hasMore };
549
+ }
340
550
  /* ------------------------------- writes ------------------------------ */
341
551
  async insert(data) {
342
552
  const keys = Object.keys(data);
@@ -361,6 +571,22 @@ export class QueryBuilder {
361
571
  const where = this.whereClause();
362
572
  return this.runWrite(`DELETE FROM ${this.table}${where.sql}`, where.bindings);
363
573
  }
574
+ /** Empty the table. */
575
+ async truncate() {
576
+ const dialect = this.getSource().dialect;
577
+ // sqlite has no TRUNCATE; a bare DELETE is the portable equivalent.
578
+ if (dialect === "sqlite")
579
+ return this.runWrite(`DELETE FROM ${this.table}`, []);
580
+ return this.runWrite(`TRUNCATE TABLE ${this.table}`, []);
581
+ }
582
+ /** Update the first matching row, or insert `{ ...match, ...values }` if none. */
583
+ async updateOrInsert(match, values = {}) {
584
+ for (const [column, value] of Object.entries(match))
585
+ this.where(column, value);
586
+ if (await this.exists())
587
+ return this.update(values);
588
+ return this.insert({ ...match, ...values });
589
+ }
364
590
  /** Atomically add to a numeric column (optionally setting other columns too). */
365
591
  increment(column, amount = 1, extra = {}) {
366
592
  const where = this.whereClause();
@@ -375,6 +601,24 @@ export class QueryBuilder {
375
601
  const bindings = [amount, ...Object.values(extra), ...where.bindings];
376
602
  return this.runWrite(`UPDATE ${this.table} SET ${sets.join(", ")}${where.sql}`, bindings);
377
603
  }
604
+ /** Increment several columns by 1 (or per-column amounts) in one statement. */
605
+ incrementEach(columns, extra = {}) {
606
+ return this.stepEach("+", columns, extra);
607
+ }
608
+ /** Decrement several columns by 1 (or per-column amounts) in one statement. */
609
+ decrementEach(columns, extra = {}) {
610
+ return this.stepEach("-", columns, extra);
611
+ }
612
+ stepEach(op, columns, extra) {
613
+ const entries = Array.isArray(columns) ? columns.map((c) => [c, 1]) : Object.entries(columns);
614
+ const where = this.whereClause();
615
+ const sets = [
616
+ ...entries.map(([c]) => `${c} = ${c} ${op} ?`),
617
+ ...Object.keys(extra).map((k) => `${k} = ?`),
618
+ ];
619
+ const bindings = [...entries.map(([, n]) => n), ...Object.values(extra), ...where.bindings];
620
+ return this.runWrite(`UPDATE ${this.table} SET ${sets.join(", ")}${where.sql}`, bindings);
621
+ }
378
622
  /** Insert one or more rows, ignoring any that violate a unique constraint. */
379
623
  async insertOrIgnore(rows) {
380
624
  const list = Array.isArray(rows) ? rows : [rows];
@@ -38,7 +38,7 @@ export type { SecurityHeadersOptions, HstsOptions } from "./shield.js";
38
38
  export { csrf, csrfToken, csrfField } from "./csrf.js";
39
39
  export type { CsrfOptions } from "./csrf.js";
40
40
  export { db, connection, getConnection, setConnection, addConnection, setDefaultConnection, connectionNames, clearConnections, transaction, inTransaction, QueryBuilder, } from "./database.js";
41
- export type { Connection, TransactionConnection, TransactionHandle, ConnectionHandle, WriteResult, Row, Dialect, Operator, Paginated, } from "./database.js";
41
+ export type { Connection, TransactionConnection, TransactionHandle, ConnectionHandle, WriteResult, Row, Dialect, Operator, Paginated, SimplePaginated, } from "./database.js";
42
42
  export { Model } from "./model.js";
43
43
  export type { GlobalScope } from "./model.js";
44
44
  export { ModelQuery } from "./model-query.js";
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shaferllc/keel",
3
- "version": "0.81.2",
3
+ "version": "0.82.0",
4
4
  "description": "The house framework for Node.js — a service container, providers, routing, JSX views, and a code-generating console.",
5
5
  "repo": "https://github.com/shaferllc/keel",
6
6
  "generated": "run `npm run build:ai` to regenerate",
@@ -2811,6 +2811,11 @@
2811
2811
  "kind": "value",
2812
2812
  "module": "storage"
2813
2813
  },
2814
+ {
2815
+ "name": "SimplePaginated",
2816
+ "kind": "type",
2817
+ "module": "database"
2818
+ },
2814
2819
  {
2815
2820
  "name": "singleton",
2816
2821
  "kind": "value",
package/docs/database.md CHANGED
@@ -110,106 +110,218 @@ An unregistered connection name doesn't fail when you *build* a query, only when
110
110
  it runs — so a misconfigured name surfaces as a rejected read/write, not a
111
111
  construction-time throw.
112
112
 
113
- ## Querying
113
+ ## Query builder
114
114
 
115
- Start a query with `db(table)`, chain constraints, and finish with a terminal
116
- method (`get`, `first`, `count`, `exists`):
115
+ Start a query with `db(table)`, chain constraints (they return the builder, so
116
+ order doesn't matter), and finish with a terminal method. Nothing hits the
117
+ database until a terminal runs. Every value becomes a **binding**, never
118
+ string-interpolated SQL — the builder is injection-safe by construction. It's
119
+ driver-agnostic and edge-safe: the same chain compiles for sqlite, MySQL, and
120
+ Postgres.
117
121
 
118
122
  ```ts
119
123
  import { db } from "@shaferllc/keel/core";
120
124
 
121
- await db("users").where("active", true).orderBy("name").get();
122
- await db("users").where("id", 1).first(); // row | null
123
- await db("users").where("age", ">", 18).count();
124
- await db("posts").whereIn("id", [1, 2, 3]).get();
125
- await db("posts").whereNull("deleted_at").limit(20).offset(40).get();
126
-
127
- await db("orders")
128
- .select("id", "total")
129
- .where("status", "paid")
130
- .orWhere("status", "shipped")
125
+ const active = await db("users")
126
+ .where("active", true)
127
+ .where("age", ">", 18)
128
+ .orderBy("name")
129
+ .limit(20)
131
130
  .get();
132
131
  ```
133
132
 
134
- Constraint methods return the builder, so they chain in any order; the query
135
- isn't sent until you call a terminal method. Multiple `where` calls combine with
136
- `AND`; `orWhere` joins with `OR`.
133
+ ### Retrieving results
134
+
135
+ ```ts
136
+ await db("users").get(); // Row[]
137
+ await db("users").where("id", 1).first(); // Row | null
138
+ await db("users").where("id", 1).firstOrFail(); // Row, or throws NotFoundException
139
+ await db("users").find(1); // by primary key (default "id")
140
+ await db("users").where("email", e).sole(); // exactly one, else throws
141
+ await db("users").where("id", 1).value("email"); // one column of the first row
142
+ await db("posts").pluck("title"); // string[] of one column
143
+ await db("tags").orderBy("name").implode("name", ", "); // "a, b, c"
144
+ ```
137
145
 
138
- More `where` clauses and ordering shortcuts:
146
+ For large sets, `chunk` pages through without loading everything (return `false`
147
+ to stop early):
139
148
 
140
149
  ```ts
141
- await db("posts").whereBetween("views", [10, 100]).get();
142
- await db("posts").whereNotIn("id", [4, 5]).get();
143
- await db("posts").whereLike("title", "%keel%").get();
144
- await db("posts").latest().get(); // ORDER BY created_at DESC
145
- await db("posts").oldest("published_at").get();
150
+ await db("users").orderBy("id").chunk(500, async (rows) => {
151
+ for (const row of rows) await process(row);
152
+ });
146
153
  ```
147
154
 
148
- Joins, grouping, and conditional/raw clauses:
155
+ ### Aggregates
149
156
 
150
157
  ```ts
151
- await db("posts")
152
- .join("users", "posts.user_id", "users.id") // also leftJoin(...)
153
- .select("posts.title", "users.name")
158
+ await db("orders").count();
159
+ await db("orders").where("paid", true).sum("total");
160
+ await db("orders").avg("total"); // also min(col), max(col)
161
+ await db("users").where("email", e).exists(); // boolean
162
+ await db("users").where("banned", true).doesntExist();
163
+ ```
164
+
165
+ ### Selects
166
+
167
+ ```ts
168
+ db("users").select("id", "email");
169
+ db("users").select("id").addSelect("email"); // append, don't replace
170
+ db("orders").selectRaw("SUM(total) AS revenue");
171
+ db("users").distinct().select("country");
172
+ ```
173
+
174
+ ### Where clauses
175
+
176
+ ```ts
177
+ db("users").where("votes", 100); // = is the default operator
178
+ db("users").where("votes", ">=", 100);
179
+ db("users").where("name", "like", "T%");
180
+
181
+ db("users").where("votes", 100).orWhere("name", "John");
182
+ db("users").whereNot("status", "cancelled");
183
+
184
+ db("users").whereIn("id", [1, 2, 3]).whereNotIn("id", [4]);
185
+ db("users").whereNull("deleted_at").whereNotNull("email_verified_at");
186
+ db("products").whereBetween("price", [10, 100]).whereNotBetween("stock", [0, 5]);
187
+ db("posts").whereLike("title", "%keel%");
188
+ db("events").whereColumn("updated_at", ">", "created_at"); // column vs column
189
+ db("users").whereRaw("score >= ? AND score <= ?", [10, 90]);
190
+ ```
191
+
192
+ Every clause has an `orWhere…` twin — `orWhereIn`, `orWhereNull`,
193
+ `orWhereNotNull`, `orWhereBetween`, `orWhereColumn`, `orWhereLike`, `orWhereRaw`,
194
+ `orWhereNotIn`.
195
+
196
+ **Grouped clauses.** Pass a callback to `where`/`orWhere` to parenthesize a set
197
+ of conditions — the way to express `A AND (B OR C)`:
198
+
199
+ ```ts
200
+ await db("users")
201
+ .where("active", true)
202
+ .where((q) => q.where("role", "admin").orWhere("role", "owner"))
154
203
  .get();
204
+ // … WHERE active = ? AND (role = ? OR role = ?)
205
+ ```
155
206
 
156
- await db("posts").select("user_id").groupBy("user_id").having("COUNT(*)", ">", 5).get();
157
- await db("users").distinct().select("country").pluck("country");
207
+ ### Ordering, grouping, limit & offset
158
208
 
159
- await db("events").whereColumn("updated_at", ">", "created_at").get();
160
- await db("users").whereRaw("score >= ?", [10]).orderByRaw("LENGTH(name) DESC").get();
209
+ ```ts
210
+ db("users").orderBy("name").orderByDesc("created_at");
211
+ db("posts").latest(); // ORDER BY created_at DESC (oldest() for ASC)
212
+ db("posts").orderByRaw("LENGTH(title) DESC");
213
+ db("users").inRandomOrder(); // dialect-aware RANDOM()/RAND()
214
+ db("users").reorder("name"); // clear existing ordering, then set
215
+
216
+ db("orders")
217
+ .select("user_id")
218
+ .selectRaw("SUM(total) AS spent")
219
+ .groupBy("user_id")
220
+ .having("spent", ">", 1000) // also havingRaw(...), havingBetween(...)
221
+ .get();
161
222
 
162
- await db("users").when(search, (q, term) => q.whereLike("name", `%${term}%`)).get();
223
+ db("users").limit(10).offset(20); // take(10)/skip(20) are aliases
224
+ db("users").forPage(3, 15); // page 3, 15 per page
163
225
  ```
164
226
 
165
- ## Aggregates, single values, and pagination
227
+ ### Joins
166
228
 
167
229
  ```ts
168
- await db("orders").where("paid", true).sum("total"); // number
169
- await db("orders").avg("total");
170
- await db("orders").min("total");
171
- await db("orders").max("total");
230
+ await db("posts")
231
+ .join("users", "posts.user_id", "users.id") // INNER JOIN on equality
232
+ .leftJoin("images", "images.post_id", "posts.id")
233
+ .select("posts.title", "users.name")
234
+ .get();
235
+ ```
236
+
237
+ `rightJoin` and `crossJoin` round out the set. Joins with several `ON`
238
+ conditions aren't modelled — use `whereRaw` or a view.
172
239
 
173
- await db("users").where("id", 1).value("email"); // one column, first row
174
- await db("posts").pluck("title"); // string[] of one column
240
+ ### Conditional clauses
175
241
 
176
- const page = await db("posts").latest().paginate(2, 15); // { data, total, perPage, currentPage, lastPage }
242
+ `when` / `unless` apply a callback based on a runtime value, so you build a query
243
+ without breaking the chain into `if`s. The callback receives the value:
244
+
245
+ ```ts
246
+ await db("users")
247
+ .when(search, (q, term) => q.whereLike("name", `%${term}%`))
248
+ .unless(includeArchived, (q) => q.whereNull("archived_at"))
249
+ .get();
250
+ ```
251
+
252
+ ### Inserts
253
+
254
+ ```ts
255
+ await db("users").insert({ email, name });
256
+ const id = await db("users").insertGetId({ email, name }); // new primary key
257
+ await db("logs").insertOrIgnore({ key, value }); // skip unique conflicts
258
+ await db("users").upsert([{ id: 1, name: "Ada" }], ["id"], ["name"]); // insert/update
177
259
  ```
178
260
 
179
- `paginate(page, perPage)` runs a `COUNT` then a `LIMIT`/`OFFSET` query and returns
180
- a `Paginated<T>` with the page and the metadata to render pager controls.
261
+ `upsert(rows, uniqueBy, update?)` inserts, updating the `update` columns (default:
262
+ everything not in `uniqueBy`) on a conflict dialect-aware (`ON CONFLICT` /
263
+ `ON DUPLICATE KEY UPDATE`).
181
264
 
182
- ## Writing
265
+ ### Updates
183
266
 
184
267
  ```ts
185
- const id = await db("users").insertGetId({ email, name });
186
268
  await db("users").where("id", id).update({ name: "Grace" });
187
- await db("users").where("id", id).delete();
269
+ await db("users").updateOrInsert({ email }, { name }); // update match, else insert
270
+ await db("posts").where("id", id).increment("views"); // += 1
271
+ await db("posts").where("id", id).decrement("stock", 3, { updated_at: now });
272
+ await db("counters").incrementEach({ hits: 1, misses: 2 }); // several columns at once
188
273
  ```
189
274
 
190
- Everything is parameterized — values become bindings, never string-interpolated
191
- SQL — so it's injection-safe by construction. Writes return a `WriteResult`;
192
- `insertGetId` unwraps it to just the new id.
275
+ ### Deletes
193
276
 
194
- Counters, bulk upserts, and paged iteration:
277
+ ```ts
278
+ await db("sessions").where("expires_at", "<", now).delete();
279
+ await db("cache").truncate(); // empty the table (DELETE on sqlite)
280
+ ```
281
+
282
+ > **Guard your writes.** `update()`, `delete()`, and the increments apply to
283
+ > every row matching the current `where` clause — with none, that's the whole
284
+ > table. Scope every write unless you truly mean to touch every row.
285
+
286
+ ### Pagination
195
287
 
196
288
  ```ts
197
- await db("posts").where("id", id).increment("views"); // += 1
198
- await db("posts").where("id", id).decrement("stock", 3, { updated_at: now });
289
+ const page = await db("posts").latest().paginate(2, 15);
290
+ // { data, total, perPage, currentPage, lastPage } — a COUNT plus a page query
199
291
 
200
- // Insert, updating the listed columns on a unique-key conflict (dialect-aware).
201
- await db("users").upsert([{ id: 1, name: "Ada" }], ["id"], ["name"]);
202
- await db("logs").insertOrIgnore({ key, value }); // skip duplicates
292
+ const feed = await db("posts").latest().simplePaginate(2, 15);
293
+ // { data, perPage, currentPage, hasMore } no COUNT; one extra row tells hasMore
294
+ ```
203
295
 
204
- // Process a large table a page at a time (return false to stop early).
205
- await db("users").orderBy("id").chunk(500, async (rows) => {
206
- for (const row of rows) await process(row);
296
+ ### Pessimistic locking
297
+
298
+ Inside a [transaction](#transactions), lock the selected rows against concurrent
299
+ writes. No-ops on sqlite (which locks the whole database anyway):
300
+
301
+ ```ts
302
+ await transaction(async () => {
303
+ const row = await db("accounts").where("id", id).lockForUpdate().first(); // FOR UPDATE
304
+ await db("accounts").where("id", id).update({ balance: row.balance - 10 });
207
305
  });
306
+ // sharedLock() takes a read lock (FOR SHARE) instead.
307
+ ```
308
+
309
+ ### Debugging
310
+
311
+ ```ts
312
+ db("users").where("active", true).toSql(); // "SELECT * FROM users WHERE active = ?"
313
+ db("users").where("active", true).getBindings(); // [true]
314
+ db("users").where("active", true).dump(); // logs SQL + bindings, returns the builder
315
+ db("users").where("active", true).dd(); // logs and throws (dump-and-die)
208
316
  ```
209
317
 
210
- > **Guard your writes.** `update()` and `delete()` apply to every row that
211
- > matches the current `where` clause — with no `where`, that's the whole table.
212
- > Always scope a write with `where` unless you truly mean to touch every row.
318
+ ### Not (yet) modelled
319
+
320
+ Kept out on purpose, to stay driver-agnostic and honest about what compiles
321
+ everywhere: unions, subquery `where`/join builders (`whereExists`, `joinSub`),
322
+ the `whereDate`/`whereMonth`/… date-function family (no portable form across
323
+ dialects), and `cursor`/`lazy` streaming. Reach for `whereRaw`, a raw
324
+ `connection().select(sql)`, or a database view when you need them.
213
325
 
214
326
  ## Transactions
215
327
 
@@ -656,6 +768,68 @@ Insert one or more rows, skipping any that violate a unique constraint
656
768
  Process results a page at a time so a large table never loads at once. Return
657
769
  `false` from the callback to stop early. Pair with `orderBy` for a stable order.
658
770
 
771
+ #### `addSelect(...columns)` · `selectRaw(sql)`
772
+
773
+ Append columns to the SELECT list without replacing it; `selectRaw` appends a raw
774
+ expression (`selectRaw("SUM(total) AS revenue")`).
775
+
776
+ #### `orWhere` family · `whereNot(...)` · `whereNotBetween(column, [min, max])`
777
+
778
+ Every `where…` clause has an `orWhere…` twin joined with `OR` — `orWhereIn`,
779
+ `orWhereNotIn`, `orWhereNull`, `orWhereNotNull`, `orWhereBetween`,
780
+ `orWhereColumn`, `orWhereLike`, `orWhereRaw`. `whereNot` negates a comparison;
781
+ `whereNotBetween` is the inverse of `whereBetween`. Passing a **callback** to
782
+ `where`/`orWhere` groups its conditions in parentheses.
783
+
784
+ #### `orderByDesc(column)` · `reorder(column?, direction?)` · `inRandomOrder()`
785
+
786
+ Descending order; clear existing ordering (optionally setting a new one); random
787
+ order (dialect-aware `RANDOM()`/`RAND()`).
788
+
789
+ #### `groupByRaw(sql)` · `havingRaw(sql, bindings?)` · `havingBetween(column, [min, max])`
790
+
791
+ Raw `GROUP BY`, a raw/bound `HAVING`, and a `HAVING … BETWEEN`.
792
+
793
+ #### `take(n)` · `skip(n)` · `forPage(page, perPage?)`
794
+
795
+ Aliases for `limit`/`offset`, and limit+offset for a 1-based page.
796
+
797
+ #### `rightJoin(...)` · `crossJoin(table)`
798
+
799
+ `RIGHT JOIN` on an equality; `CROSS JOIN`.
800
+
801
+ #### `unless(condition, then, otherwise?)`
802
+
803
+ The inverse of `when` — runs `then` only when `condition` is falsy.
804
+
805
+ #### `find(id, key?)` · `firstOrFail()` · `sole()` · `doesntExist()` · `implode(column, glue?)`
806
+
807
+ Find by key (default `"id"`); first-or-throw; exactly-one-or-throw; the negation
808
+ of `exists`; and join one column's values into a string.
809
+
810
+ #### `simplePaginate(page?, perPage?)`
811
+
812
+ `simplePaginate(page = 1, perPage = 15): Promise<SimplePaginated<T>>`
813
+
814
+ A page without a `COUNT` — fetches one extra row to set `hasMore`. Cheaper than
815
+ `paginate` for "load more" UIs.
816
+
817
+ #### `lockForUpdate()` · `sharedLock()`
818
+
819
+ Add `FOR UPDATE` / `FOR SHARE` to the SELECT (inside a transaction). Ignored on
820
+ sqlite.
821
+
822
+ #### `updateOrInsert(match, values?)` · `truncate()` · `incrementEach(cols, extra?)` · `decrementEach(cols, extra?)`
823
+
824
+ Update the first match or insert `{ ...match, ...values }`; empty the table
825
+ (`DELETE` on sqlite); and step several numeric columns in one statement (`cols` is
826
+ an array — each by 1 — or a `{ column: amount }` map).
827
+
828
+ #### `toSql()` · `getBindings()` · `dump()` · `dd()`
829
+
830
+ The compiled `?`-placeholder SQL and its bindings, without executing; `dump` logs
831
+ them and returns the builder; `dd` logs and throws.
832
+
659
833
  ### Interfaces & types
660
834
 
661
835
  #### `Connection`
package/llms-full.txt CHANGED
@@ -7759,106 +7759,218 @@ An unregistered connection name doesn't fail when you *build* a query, only when
7759
7759
  it runs — so a misconfigured name surfaces as a rejected read/write, not a
7760
7760
  construction-time throw.
7761
7761
 
7762
- ## Querying
7762
+ ## Query builder
7763
7763
 
7764
- Start a query with `db(table)`, chain constraints, and finish with a terminal
7765
- method (`get`, `first`, `count`, `exists`):
7764
+ Start a query with `db(table)`, chain constraints (they return the builder, so
7765
+ order doesn't matter), and finish with a terminal method. Nothing hits the
7766
+ database until a terminal runs. Every value becomes a **binding**, never
7767
+ string-interpolated SQL — the builder is injection-safe by construction. It's
7768
+ driver-agnostic and edge-safe: the same chain compiles for sqlite, MySQL, and
7769
+ Postgres.
7766
7770
 
7767
7771
  ```ts
7768
7772
  import { db } from "@shaferllc/keel/core";
7769
7773
 
7770
- await db("users").where("active", true).orderBy("name").get();
7771
- await db("users").where("id", 1).first(); // row | null
7772
- await db("users").where("age", ">", 18).count();
7773
- await db("posts").whereIn("id", [1, 2, 3]).get();
7774
- await db("posts").whereNull("deleted_at").limit(20).offset(40).get();
7775
-
7776
- await db("orders")
7777
- .select("id", "total")
7778
- .where("status", "paid")
7779
- .orWhere("status", "shipped")
7774
+ const active = await db("users")
7775
+ .where("active", true)
7776
+ .where("age", ">", 18)
7777
+ .orderBy("name")
7778
+ .limit(20)
7780
7779
  .get();
7781
7780
  ```
7782
7781
 
7783
- Constraint methods return the builder, so they chain in any order; the query
7784
- isn't sent until you call a terminal method. Multiple `where` calls combine with
7785
- `AND`; `orWhere` joins with `OR`.
7782
+ ### Retrieving results
7783
+
7784
+ ```ts
7785
+ await db("users").get(); // Row[]
7786
+ await db("users").where("id", 1).first(); // Row | null
7787
+ await db("users").where("id", 1).firstOrFail(); // Row, or throws NotFoundException
7788
+ await db("users").find(1); // by primary key (default "id")
7789
+ await db("users").where("email", e).sole(); // exactly one, else throws
7790
+ await db("users").where("id", 1).value("email"); // one column of the first row
7791
+ await db("posts").pluck("title"); // string[] of one column
7792
+ await db("tags").orderBy("name").implode("name", ", "); // "a, b, c"
7793
+ ```
7786
7794
 
7787
- More `where` clauses and ordering shortcuts:
7795
+ For large sets, `chunk` pages through without loading everything (return `false`
7796
+ to stop early):
7788
7797
 
7789
7798
  ```ts
7790
- await db("posts").whereBetween("views", [10, 100]).get();
7791
- await db("posts").whereNotIn("id", [4, 5]).get();
7792
- await db("posts").whereLike("title", "%keel%").get();
7793
- await db("posts").latest().get(); // ORDER BY created_at DESC
7794
- await db("posts").oldest("published_at").get();
7799
+ await db("users").orderBy("id").chunk(500, async (rows) => {
7800
+ for (const row of rows) await process(row);
7801
+ });
7795
7802
  ```
7796
7803
 
7797
- Joins, grouping, and conditional/raw clauses:
7804
+ ### Aggregates
7798
7805
 
7799
7806
  ```ts
7800
- await db("posts")
7801
- .join("users", "posts.user_id", "users.id") // also leftJoin(...)
7802
- .select("posts.title", "users.name")
7807
+ await db("orders").count();
7808
+ await db("orders").where("paid", true).sum("total");
7809
+ await db("orders").avg("total"); // also min(col), max(col)
7810
+ await db("users").where("email", e).exists(); // boolean
7811
+ await db("users").where("banned", true).doesntExist();
7812
+ ```
7813
+
7814
+ ### Selects
7815
+
7816
+ ```ts
7817
+ db("users").select("id", "email");
7818
+ db("users").select("id").addSelect("email"); // append, don't replace
7819
+ db("orders").selectRaw("SUM(total) AS revenue");
7820
+ db("users").distinct().select("country");
7821
+ ```
7822
+
7823
+ ### Where clauses
7824
+
7825
+ ```ts
7826
+ db("users").where("votes", 100); // = is the default operator
7827
+ db("users").where("votes", ">=", 100);
7828
+ db("users").where("name", "like", "T%");
7829
+
7830
+ db("users").where("votes", 100).orWhere("name", "John");
7831
+ db("users").whereNot("status", "cancelled");
7832
+
7833
+ db("users").whereIn("id", [1, 2, 3]).whereNotIn("id", [4]);
7834
+ db("users").whereNull("deleted_at").whereNotNull("email_verified_at");
7835
+ db("products").whereBetween("price", [10, 100]).whereNotBetween("stock", [0, 5]);
7836
+ db("posts").whereLike("title", "%keel%");
7837
+ db("events").whereColumn("updated_at", ">", "created_at"); // column vs column
7838
+ db("users").whereRaw("score >= ? AND score <= ?", [10, 90]);
7839
+ ```
7840
+
7841
+ Every clause has an `orWhere…` twin — `orWhereIn`, `orWhereNull`,
7842
+ `orWhereNotNull`, `orWhereBetween`, `orWhereColumn`, `orWhereLike`, `orWhereRaw`,
7843
+ `orWhereNotIn`.
7844
+
7845
+ **Grouped clauses.** Pass a callback to `where`/`orWhere` to parenthesize a set
7846
+ of conditions — the way to express `A AND (B OR C)`:
7847
+
7848
+ ```ts
7849
+ await db("users")
7850
+ .where("active", true)
7851
+ .where((q) => q.where("role", "admin").orWhere("role", "owner"))
7803
7852
  .get();
7853
+ // … WHERE active = ? AND (role = ? OR role = ?)
7854
+ ```
7855
+
7856
+ ### Ordering, grouping, limit & offset
7804
7857
 
7805
- await db("posts").select("user_id").groupBy("user_id").having("COUNT(*)", ">", 5).get();
7806
- await db("users").distinct().select("country").pluck("country");
7858
+ ```ts
7859
+ db("users").orderBy("name").orderByDesc("created_at");
7860
+ db("posts").latest(); // ORDER BY created_at DESC (oldest() for ASC)
7861
+ db("posts").orderByRaw("LENGTH(title) DESC");
7862
+ db("users").inRandomOrder(); // dialect-aware RANDOM()/RAND()
7863
+ db("users").reorder("name"); // clear existing ordering, then set
7864
+
7865
+ db("orders")
7866
+ .select("user_id")
7867
+ .selectRaw("SUM(total) AS spent")
7868
+ .groupBy("user_id")
7869
+ .having("spent", ">", 1000) // also havingRaw(...), havingBetween(...)
7870
+ .get();
7871
+
7872
+ db("users").limit(10).offset(20); // take(10)/skip(20) are aliases
7873
+ db("users").forPage(3, 15); // page 3, 15 per page
7874
+ ```
7807
7875
 
7808
- await db("events").whereColumn("updated_at", ">", "created_at").get();
7809
- await db("users").whereRaw("score >= ?", [10]).orderByRaw("LENGTH(name) DESC").get();
7876
+ ### Joins
7810
7877
 
7811
- await db("users").when(search, (q, term) => q.whereLike("name", `%${term}%`)).get();
7878
+ ```ts
7879
+ await db("posts")
7880
+ .join("users", "posts.user_id", "users.id") // INNER JOIN on equality
7881
+ .leftJoin("images", "images.post_id", "posts.id")
7882
+ .select("posts.title", "users.name")
7883
+ .get();
7812
7884
  ```
7813
7885
 
7814
- ## Aggregates, single values, and pagination
7886
+ `rightJoin` and `crossJoin` round out the set. Joins with several `ON`
7887
+ conditions aren't modelled — use `whereRaw` or a view.
7888
+
7889
+ ### Conditional clauses
7890
+
7891
+ `when` / `unless` apply a callback based on a runtime value, so you build a query
7892
+ without breaking the chain into `if`s. The callback receives the value:
7815
7893
 
7816
7894
  ```ts
7817
- await db("orders").where("paid", true).sum("total"); // number
7818
- await db("orders").avg("total");
7819
- await db("orders").min("total");
7820
- await db("orders").max("total");
7895
+ await db("users")
7896
+ .when(search, (q, term) => q.whereLike("name", `%${term}%`))
7897
+ .unless(includeArchived, (q) => q.whereNull("archived_at"))
7898
+ .get();
7899
+ ```
7821
7900
 
7822
- await db("users").where("id", 1).value("email"); // one column, first row
7823
- await db("posts").pluck("title"); // string[] of one column
7901
+ ### Inserts
7824
7902
 
7825
- const page = await db("posts").latest().paginate(2, 15); // { data, total, perPage, currentPage, lastPage }
7903
+ ```ts
7904
+ await db("users").insert({ email, name });
7905
+ const id = await db("users").insertGetId({ email, name }); // new primary key
7906
+ await db("logs").insertOrIgnore({ key, value }); // skip unique conflicts
7907
+ await db("users").upsert([{ id: 1, name: "Ada" }], ["id"], ["name"]); // insert/update
7826
7908
  ```
7827
7909
 
7828
- `paginate(page, perPage)` runs a `COUNT` then a `LIMIT`/`OFFSET` query and returns
7829
- a `Paginated<T>` with the page and the metadata to render pager controls.
7910
+ `upsert(rows, uniqueBy, update?)` inserts, updating the `update` columns (default:
7911
+ everything not in `uniqueBy`) on a conflict dialect-aware (`ON CONFLICT` /
7912
+ `ON DUPLICATE KEY UPDATE`).
7830
7913
 
7831
- ## Writing
7914
+ ### Updates
7832
7915
 
7833
7916
  ```ts
7834
- const id = await db("users").insertGetId({ email, name });
7835
7917
  await db("users").where("id", id).update({ name: "Grace" });
7836
- await db("users").where("id", id).delete();
7918
+ await db("users").updateOrInsert({ email }, { name }); // update match, else insert
7919
+ await db("posts").where("id", id).increment("views"); // += 1
7920
+ await db("posts").where("id", id).decrement("stock", 3, { updated_at: now });
7921
+ await db("counters").incrementEach({ hits: 1, misses: 2 }); // several columns at once
7922
+ ```
7923
+
7924
+ ### Deletes
7925
+
7926
+ ```ts
7927
+ await db("sessions").where("expires_at", "<", now).delete();
7928
+ await db("cache").truncate(); // empty the table (DELETE on sqlite)
7837
7929
  ```
7838
7930
 
7839
- Everything is parameterized values become bindings, never string-interpolated
7840
- SQL so it's injection-safe by construction. Writes return a `WriteResult`;
7841
- `insertGetId` unwraps it to just the new id.
7931
+ > **Guard your writes.** `update()`, `delete()`, and the increments apply to
7932
+ > every row matching the current `where` clause with none, that's the whole
7933
+ > table. Scope every write unless you truly mean to touch every row.
7842
7934
 
7843
- Counters, bulk upserts, and paged iteration:
7935
+ ### Pagination
7844
7936
 
7845
7937
  ```ts
7846
- await db("posts").where("id", id).increment("views"); // += 1
7847
- await db("posts").where("id", id).decrement("stock", 3, { updated_at: now });
7938
+ const page = await db("posts").latest().paginate(2, 15);
7939
+ // { data, total, perPage, currentPage, lastPage } — a COUNT plus a page query
7848
7940
 
7849
- // Insert, updating the listed columns on a unique-key conflict (dialect-aware).
7850
- await db("users").upsert([{ id: 1, name: "Ada" }], ["id"], ["name"]);
7851
- await db("logs").insertOrIgnore({ key, value }); // skip duplicates
7941
+ const feed = await db("posts").latest().simplePaginate(2, 15);
7942
+ // { data, perPage, currentPage, hasMore } no COUNT; one extra row tells hasMore
7943
+ ```
7852
7944
 
7853
- // Process a large table a page at a time (return false to stop early).
7854
- await db("users").orderBy("id").chunk(500, async (rows) => {
7855
- for (const row of rows) await process(row);
7945
+ ### Pessimistic locking
7946
+
7947
+ Inside a [transaction](#transactions), lock the selected rows against concurrent
7948
+ writes. No-ops on sqlite (which locks the whole database anyway):
7949
+
7950
+ ```ts
7951
+ await transaction(async () => {
7952
+ const row = await db("accounts").where("id", id).lockForUpdate().first(); // FOR UPDATE
7953
+ await db("accounts").where("id", id).update({ balance: row.balance - 10 });
7856
7954
  });
7955
+ // sharedLock() takes a read lock (FOR SHARE) instead.
7857
7956
  ```
7858
7957
 
7859
- > **Guard your writes.** `update()` and `delete()` apply to every row that
7860
- > matches the current `where` clause — with no `where`, that's the whole table.
7861
- > Always scope a write with `where` unless you truly mean to touch every row.
7958
+ ### Debugging
7959
+
7960
+ ```ts
7961
+ db("users").where("active", true).toSql(); // "SELECT * FROM users WHERE active = ?"
7962
+ db("users").where("active", true).getBindings(); // [true]
7963
+ db("users").where("active", true).dump(); // logs SQL + bindings, returns the builder
7964
+ db("users").where("active", true).dd(); // logs and throws (dump-and-die)
7965
+ ```
7966
+
7967
+ ### Not (yet) modelled
7968
+
7969
+ Kept out on purpose, to stay driver-agnostic and honest about what compiles
7970
+ everywhere: unions, subquery `where`/join builders (`whereExists`, `joinSub`),
7971
+ the `whereDate`/`whereMonth`/… date-function family (no portable form across
7972
+ dialects), and `cursor`/`lazy` streaming. Reach for `whereRaw`, a raw
7973
+ `connection().select(sql)`, or a database view when you need them.
7862
7974
 
7863
7975
  ## Transactions
7864
7976
 
@@ -8305,6 +8417,68 @@ Insert one or more rows, skipping any that violate a unique constraint
8305
8417
  Process results a page at a time so a large table never loads at once. Return
8306
8418
  `false` from the callback to stop early. Pair with `orderBy` for a stable order.
8307
8419
 
8420
+ #### `addSelect(...columns)` · `selectRaw(sql)`
8421
+
8422
+ Append columns to the SELECT list without replacing it; `selectRaw` appends a raw
8423
+ expression (`selectRaw("SUM(total) AS revenue")`).
8424
+
8425
+ #### `orWhere` family · `whereNot(...)` · `whereNotBetween(column, [min, max])`
8426
+
8427
+ Every `where…` clause has an `orWhere…` twin joined with `OR` — `orWhereIn`,
8428
+ `orWhereNotIn`, `orWhereNull`, `orWhereNotNull`, `orWhereBetween`,
8429
+ `orWhereColumn`, `orWhereLike`, `orWhereRaw`. `whereNot` negates a comparison;
8430
+ `whereNotBetween` is the inverse of `whereBetween`. Passing a **callback** to
8431
+ `where`/`orWhere` groups its conditions in parentheses.
8432
+
8433
+ #### `orderByDesc(column)` · `reorder(column?, direction?)` · `inRandomOrder()`
8434
+
8435
+ Descending order; clear existing ordering (optionally setting a new one); random
8436
+ order (dialect-aware `RANDOM()`/`RAND()`).
8437
+
8438
+ #### `groupByRaw(sql)` · `havingRaw(sql, bindings?)` · `havingBetween(column, [min, max])`
8439
+
8440
+ Raw `GROUP BY`, a raw/bound `HAVING`, and a `HAVING … BETWEEN`.
8441
+
8442
+ #### `take(n)` · `skip(n)` · `forPage(page, perPage?)`
8443
+
8444
+ Aliases for `limit`/`offset`, and limit+offset for a 1-based page.
8445
+
8446
+ #### `rightJoin(...)` · `crossJoin(table)`
8447
+
8448
+ `RIGHT JOIN` on an equality; `CROSS JOIN`.
8449
+
8450
+ #### `unless(condition, then, otherwise?)`
8451
+
8452
+ The inverse of `when` — runs `then` only when `condition` is falsy.
8453
+
8454
+ #### `find(id, key?)` · `firstOrFail()` · `sole()` · `doesntExist()` · `implode(column, glue?)`
8455
+
8456
+ Find by key (default `"id"`); first-or-throw; exactly-one-or-throw; the negation
8457
+ of `exists`; and join one column's values into a string.
8458
+
8459
+ #### `simplePaginate(page?, perPage?)`
8460
+
8461
+ `simplePaginate(page = 1, perPage = 15): Promise<SimplePaginated<T>>`
8462
+
8463
+ A page without a `COUNT` — fetches one extra row to set `hasMore`. Cheaper than
8464
+ `paginate` for "load more" UIs.
8465
+
8466
+ #### `lockForUpdate()` · `sharedLock()`
8467
+
8468
+ Add `FOR UPDATE` / `FOR SHARE` to the SELECT (inside a transaction). Ignored on
8469
+ sqlite.
8470
+
8471
+ #### `updateOrInsert(match, values?)` · `truncate()` · `incrementEach(cols, extra?)` · `decrementEach(cols, extra?)`
8472
+
8473
+ Update the first match or insert `{ ...match, ...values }`; empty the table
8474
+ (`DELETE` on sqlite); and step several numeric columns in one statement (`cols` is
8475
+ an array — each by 1 — or a `{ column: amount }` map).
8476
+
8477
+ #### `toSql()` · `getBindings()` · `dump()` · `dd()`
8478
+
8479
+ The compiled `?`-placeholder SQL and its bindings, without executing; `dump` logs
8480
+ them and returns the builder; `dd` logs and throws.
8481
+
8308
8482
  ### Interfaces & types
8309
8483
 
8310
8484
  #### `Connection`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shaferllc/keel",
3
- "version": "0.81.2",
3
+ "version": "0.82.0",
4
4
  "type": "module",
5
5
  "description": "The house framework for Node.js — a service container, providers, routing, JSX views, and a code-generating console.",
6
6
  "license": "MIT",