@c9up/atlas 0.2.5 → 0.2.7

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.
Files changed (73) hide show
  1. package/db.win32-x64-msvc.node +0 -0
  2. package/dist/BaseEntity.d.ts +40 -27
  3. package/dist/BaseEntity.d.ts.map +1 -1
  4. package/dist/BaseEntity.js +82 -81
  5. package/dist/BaseEntity.js.map +1 -1
  6. package/dist/BaseModel.d.ts +10 -0
  7. package/dist/BaseModel.d.ts.map +1 -1
  8. package/dist/BaseModel.js +10 -0
  9. package/dist/BaseModel.js.map +1 -1
  10. package/dist/BaseRepository.d.ts.map +1 -1
  11. package/dist/BaseRepository.js +31 -5
  12. package/dist/BaseRepository.js.map +1 -1
  13. package/dist/ConnectionManager.d.ts.map +1 -1
  14. package/dist/ConnectionManager.js +22 -1
  15. package/dist/ConnectionManager.js.map +1 -1
  16. package/dist/ModelQuery.d.ts +22 -1
  17. package/dist/ModelQuery.d.ts.map +1 -1
  18. package/dist/ModelQuery.js +43 -20
  19. package/dist/ModelQuery.js.map +1 -1
  20. package/dist/Transaction.d.ts +9 -0
  21. package/dist/Transaction.d.ts.map +1 -1
  22. package/dist/Transaction.js +14 -0
  23. package/dist/Transaction.js.map +1 -1
  24. package/dist/adapters/NapiDbAdapter.d.ts.map +1 -1
  25. package/dist/adapters/NapiDbAdapter.js +7 -0
  26. package/dist/adapters/NapiDbAdapter.js.map +1 -1
  27. package/dist/decorators/entity.d.ts +4 -0
  28. package/dist/decorators/entity.d.ts.map +1 -1
  29. package/dist/decorators/entity.js +9 -1
  30. package/dist/decorators/entity.js.map +1 -1
  31. package/dist/decorators/hooks.d.ts +28 -0
  32. package/dist/decorators/hooks.d.ts.map +1 -1
  33. package/dist/decorators/hooks.js +43 -0
  34. package/dist/decorators/hooks.js.map +1 -1
  35. package/dist/index.d.ts +2 -2
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +4 -2
  38. package/dist/index.js.map +1 -1
  39. package/dist/query/DatabaseQueryBuilder.d.ts +24 -0
  40. package/dist/query/DatabaseQueryBuilder.d.ts.map +1 -1
  41. package/dist/query/DatabaseQueryBuilder.js +98 -3
  42. package/dist/query/DatabaseQueryBuilder.js.map +1 -1
  43. package/dist/schema/SchemaBuilder.d.ts +2 -1
  44. package/dist/schema/SchemaBuilder.d.ts.map +1 -1
  45. package/dist/schema/SchemaBuilder.js +2 -1
  46. package/dist/schema/SchemaBuilder.js.map +1 -1
  47. package/dist/schema/TableBuilder.d.ts +140 -132
  48. package/dist/schema/TableBuilder.d.ts.map +1 -1
  49. package/dist/schema/TableBuilder.js +232 -231
  50. package/dist/schema/TableBuilder.js.map +1 -1
  51. package/dist/services/db.d.ts +7 -0
  52. package/dist/services/db.d.ts.map +1 -1
  53. package/dist/services/db.js.map +1 -1
  54. package/index.darwin-arm64.node +0 -0
  55. package/index.darwin-x64.node +0 -0
  56. package/index.linux-arm64-gnu.node +0 -0
  57. package/index.linux-x64-gnu.node +0 -0
  58. package/index.win32-x64-msvc.node +0 -0
  59. package/package.json +4 -4
  60. package/src/BaseEntity.ts +115 -85
  61. package/src/BaseModel.ts +10 -0
  62. package/src/BaseRepository.ts +36 -4
  63. package/src/ConnectionManager.ts +26 -1
  64. package/src/ModelQuery.ts +73 -23
  65. package/src/Transaction.ts +23 -0
  66. package/src/adapters/NapiDbAdapter.ts +7 -0
  67. package/src/decorators/entity.ts +13 -1
  68. package/src/decorators/hooks.ts +67 -0
  69. package/src/index.ts +8 -1
  70. package/src/query/DatabaseQueryBuilder.ts +118 -7
  71. package/src/schema/SchemaBuilder.ts +2 -1
  72. package/src/schema/TableBuilder.ts +331 -303
  73. package/src/services/db.ts +7 -0
package/src/ModelQuery.ts CHANGED
@@ -2231,41 +2231,77 @@ export class ModelQuery<T extends BaseEntity> {
2231
2231
 
2232
2232
  // --- Top-level scalar executors (Story 29.5) ---
2233
2233
 
2234
- /** `SELECT COUNT(col)` — executes and returns the scalar. `col` defaults to `*`. */
2235
- async count(column: string = "*"): Promise<number> {
2234
+ /**
2235
+ * `COUNT` a terminal scalar, or a chainable projection when given an
2236
+ * alias.
2237
+ *
2238
+ * Lucid types this `count: Aggregate<this>`: `count('* as total')` returns
2239
+ * the BUILDER and the value lands in `$extras`. The no-alias form returning
2240
+ * a number is atlas's own, and matches what `DatabaseQueryBuilder` already
2241
+ * does — same method, same two shapes, on both builders.
2242
+ */
2243
+ count(aliasExpr: `${string} as ${string}`): this;
2244
+ count(column?: string): Promise<number>;
2245
+ count(column?: string): Promise<number> | this {
2246
+ if (column !== undefined && ALIASED.test(column)) {
2247
+ return this.#projectAggregate("COUNT", column);
2248
+ }
2249
+ const col = column ?? "*";
2236
2250
  const expr =
2237
- column === "*"
2251
+ col === "*"
2238
2252
  ? "COUNT(*)"
2239
- : `COUNT(${this.#quoteCol(this.#resolveColumn(column))})`;
2240
- return Number((await this.#runScalar(expr)) ?? 0);
2253
+ : `COUNT(${this.#quoteCol(this.#resolveColumn(col))})`;
2254
+ return this.#runScalar(expr).then((v) => Number(v ?? 0));
2255
+ }
2256
+
2257
+ /** Project `FN(col) AS alias` and keep chaining — the Lucid `Aggregate` form. */
2258
+ #projectAggregate(fn: string, aliasExpr: string): this {
2259
+ const match = ALIASED.exec(aliasExpr.trim());
2260
+ const column = (match?.[1] ?? "").trim();
2261
+ const alias = match?.[2] ?? "";
2262
+ const inner =
2263
+ column === "*" ? "*" : this.#quoteCol(this.#resolveColumn(column));
2264
+ this.#selectRaw.push({
2265
+ sql: `${fn}(${inner}) AS ${this.#quoteAliasName(alias)}`,
2266
+ params: [],
2267
+ });
2268
+ return this;
2241
2269
  }
2242
2270
 
2243
- async sum(column: string): Promise<number | null> {
2244
- const v = await this.#runScalar(
2271
+ sum(aliasExpr: `${string} as ${string}`): this;
2272
+ sum(column: string): Promise<number | null>;
2273
+ sum(column: string): Promise<number | null> | this {
2274
+ if (ALIASED.test(column)) return this.#projectAggregate("SUM", column);
2275
+ return this.#runScalar(
2245
2276
  `SUM(${this.#quoteCol(this.#resolveColumn(column))})`,
2246
- );
2247
- return v === null || v === undefined ? null : Number(v);
2277
+ ).then((v) => (v === null || v === undefined ? null : Number(v)));
2248
2278
  }
2249
2279
 
2250
- async avg(column: string): Promise<number | null> {
2251
- const v = await this.#runScalar(
2280
+ avg(aliasExpr: `${string} as ${string}`): this;
2281
+ avg(column: string): Promise<number | null>;
2282
+ avg(column: string): Promise<number | null> | this {
2283
+ if (ALIASED.test(column)) return this.#projectAggregate("AVG", column);
2284
+ return this.#runScalar(
2252
2285
  `AVG(${this.#quoteCol(this.#resolveColumn(column))})`,
2253
- );
2254
- return v === null || v === undefined ? null : Number(v);
2286
+ ).then((v) => (v === null || v === undefined ? null : Number(v)));
2255
2287
  }
2256
2288
 
2257
- async min(column: string): Promise<number | null> {
2258
- const v = await this.#runScalar(
2289
+ min(aliasExpr: `${string} as ${string}`): this;
2290
+ min(column: string): Promise<number | null>;
2291
+ min(column: string): Promise<number | null> | this {
2292
+ if (ALIASED.test(column)) return this.#projectAggregate("MIN", column);
2293
+ return this.#runScalar(
2259
2294
  `MIN(${this.#quoteCol(this.#resolveColumn(column))})`,
2260
- );
2261
- return v === null || v === undefined ? null : Number(v);
2295
+ ).then((v) => (v === null || v === undefined ? null : Number(v)));
2262
2296
  }
2263
2297
 
2264
- async max(column: string): Promise<number | null> {
2265
- const v = await this.#runScalar(
2298
+ max(aliasExpr: `${string} as ${string}`): this;
2299
+ max(column: string): Promise<number | null>;
2300
+ max(column: string): Promise<number | null> | this {
2301
+ if (ALIASED.test(column)) return this.#projectAggregate("MAX", column);
2302
+ return this.#runScalar(
2266
2303
  `MAX(${this.#quoteCol(this.#resolveColumn(column))})`,
2267
- );
2268
- return v === null || v === undefined ? null : Number(v);
2304
+ ).then((v) => (v === null || v === undefined ? null : Number(v)));
2269
2305
  }
2270
2306
 
2271
2307
  /**
@@ -4589,6 +4625,14 @@ export class ModelQuery<T extends BaseEntity> {
4589
4625
  cursor?: string;
4590
4626
  limit: number;
4591
4627
  orderBy: string | string[];
4628
+ /**
4629
+ * Which way the cursor walks. Default `asc`.
4630
+ *
4631
+ * `desc` is what a newest-first feed needs — the common case for cursor
4632
+ * pagination. The direction was hardcoded to `asc`, and any `orderBy`
4633
+ * already set on the query was overwritten by it without a word.
4634
+ */
4635
+ direction?: "asc" | "desc";
4592
4636
  }): Promise<{ items: T[]; nextCursor: string | null; hasMore: boolean }> {
4593
4637
  // Keep BOTH forms: `props` (model property names) to read the cursor value
4594
4638
  // off the hydrated entity, and `cols` (resolved DB columns) for the SQL
@@ -4600,6 +4644,7 @@ export class ModelQuery<T extends BaseEntity> {
4600
4644
  if (cols.length === 0)
4601
4645
  throw new Error("cursorPaginate requires at least one orderBy column");
4602
4646
  const lim = Math.max(1, Math.floor(opts.limit));
4647
+ const descending = opts.direction === "desc";
4603
4648
  const clone = this.clone();
4604
4649
 
4605
4650
  if (opts.cursor) {
@@ -4626,7 +4671,9 @@ export class ModelQuery<T extends BaseEntity> {
4626
4671
  for (let i = 0; i < cols.length; i++) {
4627
4672
  q.orWhere((inner) => {
4628
4673
  for (let j = 0; j < i; j++) inner.where(cols[j], decoded.v[j]);
4629
- inner.where(cols[i], ">", decoded.v[i]);
4674
+ // The comparison has to follow the walk: `>` reads forward
4675
+ // through an ascending order, `<` through a descending one.
4676
+ inner.where(cols[i], descending ? "<" : ">", decoded.v[i]);
4630
4677
  });
4631
4678
  }
4632
4679
  });
@@ -4634,7 +4681,7 @@ export class ModelQuery<T extends BaseEntity> {
4634
4681
 
4635
4682
  clone.#orderBys = cols.map((column) => ({
4636
4683
  column,
4637
- direction: "asc" as const,
4684
+ direction: descending ? ("desc" as const) : ("asc" as const),
4638
4685
  }));
4639
4686
  clone.#limit = lim + 1;
4640
4687
  // `#doExec` (not `exec`) — cursorPaginate is an atlas-specific terminal, not a
@@ -5485,3 +5532,6 @@ export class ModelQuery<T extends BaseEntity> {
5485
5532
  }
5486
5533
  }
5487
5534
  }
5535
+
5536
+ /** `col as alias` — the Lucid aggregate spelling. */
5537
+ const ALIASED = /^(.*?)\s+as\s+(\S+)$/i;
@@ -26,6 +26,15 @@ export interface TransactionClient
26
26
  TransactionQueryBuilders {
27
27
  commit(): Promise<void>;
28
28
  rollback(): Promise<void>;
29
+ /**
30
+ * Whether this transaction has already committed or rolled back (Lucid
31
+ * `trx.isCompleted`).
32
+ *
33
+ * A helper handed a transaction cannot otherwise tell whether it is still
34
+ * usable, and issuing a statement on a finished one fails at the driver with
35
+ * an error that says nothing about the transaction.
36
+ */
37
+ readonly isCompleted: boolean;
29
38
  /**
30
39
  * Register a side effect to run AFTER the transaction is durable (Lucid
31
40
  * `trx.after('commit' | 'rollback', cb)`). A `commit` hook fires only once the
@@ -127,17 +136,25 @@ export async function openSavepoint(
127
136
  const commitHooks: AfterHook[] = [];
128
137
  const rollbackHooks: AfterHook[] = [];
129
138
  const evt = makeTrxEvents();
139
+ // Flipped by whichever of commit/rollback runs first, so a helper handed
140
+ // this client can tell whether it is still usable (Lucid `isCompleted`).
141
+ let completed = false;
130
142
  const base = {
131
143
  execute: parent.execute.bind(parent),
132
144
  query: parent.query.bind(parent),
145
+ get isCompleted(): boolean {
146
+ return completed;
147
+ },
133
148
  async commit(): Promise<void> {
134
149
  await parent.execute(`RELEASE SAVEPOINT ${name}`, []);
150
+ completed = true;
135
151
  evt.emit("commit"); // synchronous EventEmitter notification (this savepoint)
136
152
  for (const hook of commitHooks) parent.after("commit", hook);
137
153
  for (const hook of rollbackHooks) parent.after("rollback", hook);
138
154
  },
139
155
  async rollback(): Promise<void> {
140
156
  await parent.execute(`ROLLBACK TO SAVEPOINT ${name}`, []);
157
+ completed = true;
141
158
  try {
142
159
  await parent.execute(`RELEASE SAVEPOINT ${name}`, []);
143
160
  } catch {
@@ -250,16 +267,22 @@ export async function transaction<T>(
250
267
  const rollbackHooks: AfterHook[] = [];
251
268
  const evt = makeTrxEvents();
252
269
 
270
+ let completed = false;
253
271
  const base = {
254
272
  execute: db.execute.bind(db),
255
273
  query: db.query.bind(db),
274
+ get isCompleted(): boolean {
275
+ return completed;
276
+ },
256
277
  async commit() {
257
278
  await db.execute("COMMIT", []);
279
+ completed = true;
258
280
  evt.emit("commit"); // synchronous EventEmitter notification
259
281
  await runAfterHooks(commitHooks);
260
282
  },
261
283
  async rollback() {
262
284
  await db.execute("ROLLBACK", []);
285
+ completed = true;
263
286
  evt.emit("rollback");
264
287
  await runAfterHooks(rollbackHooks);
265
288
  },
@@ -291,6 +291,8 @@ export async function createNapiConnection(
291
291
  const native = await db.begin(isolationLevel);
292
292
  // Root (non-nested) transaction: after-hooks fire once the underlying
293
293
  // COMMIT / ROLLBACK is durable (Lucid `trx.after(...)`), errors swallowed.
294
+ // Flipped by whichever of commit/rollback runs first (Lucid isCompleted).
295
+ let completed = false;
294
296
  const commitHooks: AfterHook[] = [];
295
297
  const rollbackHooks: AfterHook[] = [];
296
298
  const evt = makeTrxEvents();
@@ -320,13 +322,18 @@ export async function createNapiConnection(
320
322
  );
321
323
  return JSON.parse(json, napiReviver) as T[];
322
324
  },
325
+ get isCompleted(): boolean {
326
+ return completed;
327
+ },
323
328
  async commit(): Promise<void> {
324
329
  await native.commit();
330
+ completed = true;
325
331
  evt.emit("commit"); // synchronous EventEmitter notification (Lucid trx.on)
326
332
  await runAfterHooks(commitHooks);
327
333
  },
328
334
  async rollback(): Promise<void> {
329
335
  await native.rollback();
336
+ completed = true;
330
337
  evt.emit("rollback");
331
338
  await runAfterHooks(rollbackHooks);
332
339
  },
@@ -125,8 +125,12 @@ export interface ManyToManyOptions {
125
125
  pivotTable: string;
126
126
  /** Foreign key in the pivot table pointing to THIS entity (default: `${thisTable}_id`). */
127
127
  foreignKey?: string;
128
+ /** AdonisJS spelling of {@link foreignKey}. Both are accepted. */
129
+ pivotForeignKey?: string;
128
130
  /** Foreign key in the pivot table pointing to the RELATED entity (default: `${relatedTable}_id`). */
129
131
  otherKey?: string;
132
+ /** AdonisJS spelling of {@link otherKey}. Both are accepted. */
133
+ pivotRelatedForeignKey?: string;
130
134
  /**
131
135
  * The column ON THE RELATED model that `otherKey` references (Adonis Lucid
132
136
  * `relatedKey`). Defaults to the related model's primary key; override when the
@@ -533,7 +537,15 @@ export function ManyToMany(
533
537
  propertyKey: String(propertyKey),
534
538
  type: "manyToMany",
535
539
  target,
536
- pivot: options,
540
+ // Normalised HERE, once: AdonisJS names these `pivotForeignKey` /
541
+ // `pivotRelatedForeignKey`. Folding them in at the decorator means the
542
+ // half-dozen places that read the pivot keys never learn there are two
543
+ // spellings — and cannot each forget one.
544
+ pivot: {
545
+ ...options,
546
+ foreignKey: options.foreignKey ?? options.pivotForeignKey,
547
+ otherKey: options.otherKey ?? options.pivotRelatedForeignKey,
548
+ },
537
549
  // `localKey` selects which parent column the pivot FK references
538
550
  // (default: the parent PK). Without copying it here the value the type
539
551
  // accepts is silently dropped and the pivot always targets the PK.
@@ -59,6 +59,24 @@ export interface HookArgs {
59
59
  afterPaginate: Paginator<BaseEntity>;
60
60
  }
61
61
 
62
+ /**
63
+ * The event half of a hook kind — what `Model.before(event)` takes. Lucid
64
+ * spells these lower-case (`'save'`, `'create'`) and prefixes them itself.
65
+ */
66
+ export type HookEvent =
67
+ | "save"
68
+ | "create"
69
+ | "update"
70
+ | "delete"
71
+ | "find"
72
+ | "fetch"
73
+ | "paginate";
74
+
75
+ /** `'save'` → `'Save'`, so `before` + event names the registry key. */
76
+ export function capitalize<E extends string>(event: E): Capitalize<E> {
77
+ return (event.charAt(0).toUpperCase() + event.slice(1)) as Capitalize<E>;
78
+ }
79
+
62
80
  /** A hook handler is a static function that receives the kind-specific arg. */
63
81
  export type HookHandler<K extends HookKind = HookKind> = (
64
82
  arg: HookArgs[K],
@@ -169,3 +187,52 @@ export async function fireHooks<K extends HookKind>(
169
187
  await handler(arg);
170
188
  }
171
189
  }
190
+
191
+ /**
192
+ * Register a hook at RUNTIME, without a decorator — Lucid's
193
+ * `Model.before(event, handler)` / `Model.after(event, handler)`, which do
194
+ * `this.$hooks.add(\`before:${event}\`, handler)`.
195
+ *
196
+ * The decorators cover the common case (a static method on the entity), but a
197
+ * hook that comes from somewhere else — a plugin, a test, a package wiring
198
+ * itself into an app's models — has no class body to decorate. Lucid supports
199
+ * both; so does this.
200
+ *
201
+ * Handlers land in the SAME per-class registry the decorators write to, so the
202
+ * ordering rule is unchanged: parent classes fire before child classes, and
203
+ * within a class, registration order.
204
+ */
205
+ export function addHook<K extends HookKind>(
206
+ entityClass: object,
207
+ kind: K,
208
+ handler: HookHandler<K>,
209
+ ): void {
210
+ if (!isHookHandler(handler)) {
211
+ throw new Error(`${kind} hook must be a function`);
212
+ }
213
+ const registry = getOwnRegistry(entityClass);
214
+ const list: HookHandler[] = registry[kind] ?? [];
215
+ list.push(handler as HookHandler);
216
+ registry[kind] = list;
217
+ }
218
+
219
+ /**
220
+ * Drop a handler registered with {@link addHook} (or a decorator, if you hold
221
+ * the same reference). Returns whether one was removed — a test that adds a
222
+ * hook needs a way to take it back out.
223
+ */
224
+ export function removeHook<K extends HookKind>(
225
+ entityClass: object,
226
+ kind: K,
227
+ handler: HookHandler<K>,
228
+ ): boolean {
229
+ const registry = Reflect.getOwnMetadata(HOOKS_KEY, entityClass) as
230
+ | HookRegistry
231
+ | undefined;
232
+ const list = registry?.[kind];
233
+ if (!list) return false;
234
+ const index = list.indexOf(handler as HookHandler);
235
+ if (index === -1) return false;
236
+ list.splice(index, 1);
237
+ return true;
238
+ }
package/src/index.ts CHANGED
@@ -99,7 +99,10 @@ export {
99
99
  PrimaryKey,
100
100
  SoftDeletes,
101
101
  } from "./decorators/entity.js";
102
+ // Runtime hook registration — what `BaseEntity.before()/after()` call, exported
103
+ // for a plugin that wires hooks into models it does not own.
102
104
  export {
105
+ addHook,
103
106
  afterCreate,
104
107
  afterDelete,
105
108
  afterFetch,
@@ -114,6 +117,10 @@ export {
114
117
  beforePaginate,
115
118
  beforeSave,
116
119
  beforeUpdate,
120
+ type HookEvent,
121
+ type HookHandler,
122
+ type HookKind,
123
+ removeHook,
117
124
  } from "./decorators/hooks.js";
118
125
  export type { ScopeFn } from "./decorators/scope.js";
119
126
  export { scope } from "./decorators/scope.js";
@@ -173,7 +180,7 @@ export type {
173
180
  export { MigrationRunner } from "./schema/MigrationRunner.js";
174
181
  export type { DefaultValue } from "./schema/raw.js";
175
182
  export type { ColumnDefinition, ColumnType } from "./schema/SchemaBuilder.js";
176
- export { Schema, TableBuilder } from "./schema/SchemaBuilder.js";
183
+ export { ColumnBuilder, Schema, TableBuilder } from "./schema/SchemaBuilder.js";
177
184
  export {
178
185
  checkSchema,
179
186
  formatSchemaFindings,
@@ -890,6 +890,59 @@ export class DatabaseQueryBuilder<T = Record<string, unknown>> {
890
890
  return this;
891
891
  }
892
892
 
893
+ // ─── HAVING EXISTS (Lucid/Knex) ───────────────────────────
894
+ // A correlated subquery in HAVING is how you filter GROUPS by something
895
+ // outside the aggregate — "groups that still have an open order", which no
896
+ // combination of `having(count)` can express.
897
+
898
+ /** HAVING EXISTS (subquery). */
899
+ havingExists(sub: SubqueryArg): this {
900
+ return this.#pushHavingExists("and", false, sub);
901
+ }
902
+
903
+ /** Alias of {@link havingExists} — AND is the default. */
904
+ andHavingExists(sub: SubqueryArg): this {
905
+ return this.#pushHavingExists("and", false, sub);
906
+ }
907
+
908
+ /** OR EXISTS in HAVING. */
909
+ orHavingExists(sub: SubqueryArg): this {
910
+ return this.#pushHavingExists("or", false, sub);
911
+ }
912
+
913
+ /** HAVING NOT EXISTS (subquery). */
914
+ havingNotExists(sub: SubqueryArg): this {
915
+ return this.#pushHavingExists("and", true, sub);
916
+ }
917
+
918
+ /** Alias of {@link havingNotExists}. */
919
+ andHavingNotExists(sub: SubqueryArg): this {
920
+ return this.#pushHavingExists("and", true, sub);
921
+ }
922
+
923
+ /** OR NOT EXISTS in HAVING. */
924
+ orHavingNotExists(sub: SubqueryArg): this {
925
+ return this.#pushHavingExists("or", true, sub);
926
+ }
927
+
928
+ #pushHavingExists(
929
+ type: "and" | "or",
930
+ negated: boolean,
931
+ sub: SubqueryArg,
932
+ ): this {
933
+ // Compiled to a raw HAVING rather than a new clause kind: the engine
934
+ // already carries raw havings with their bindings, and EXISTS has no
935
+ // operator/value to model.
936
+ const { sql, params } = this.#resolveSub(sub).#compiledNative();
937
+ this.#havings.push({
938
+ kind: "raw",
939
+ sql: `${negated ? "NOT EXISTS" : "EXISTS"} (${sql})`,
940
+ bindings: params,
941
+ type,
942
+ });
943
+ return this;
944
+ }
945
+
893
946
  /** `INNER JOIN` — alias of {@link innerJoin} (Lucid/Knex `join`). */
894
947
  join(table: string, left: string, right: string): this;
895
948
  join(table: string, left: string, operator: string, right: string): this;
@@ -1980,6 +2033,47 @@ export class DatabaseQueryBuilder<T = Record<string, unknown>> {
1980
2033
  return this;
1981
2034
  }
1982
2035
 
2036
+ // ─── Clearing clauses (Lucid/Knex `clear*`) ───────────────
2037
+ // A builder handed to a helper that added a filter, an order or a slice can
2038
+ // be reset clause by clause instead of rebuilt from scratch — which is what
2039
+ // a shared scope or a reusable base query needs.
2040
+
2041
+ /** Drop every selected column, back to `*`. */
2042
+ clearSelect(): this {
2043
+ this.#selects = [];
2044
+ return this;
2045
+ }
2046
+
2047
+ /** Drop every WHERE, including the raw and grouped ones. */
2048
+ clearWhere(): this {
2049
+ this.#wheres = [];
2050
+ return this;
2051
+ }
2052
+
2053
+ /** Drop every ORDER BY. */
2054
+ clearOrder(): this {
2055
+ this.#orderBys = [];
2056
+ return this;
2057
+ }
2058
+
2059
+ /** Drop every HAVING. */
2060
+ clearHaving(): this {
2061
+ this.#havings = [];
2062
+ return this;
2063
+ }
2064
+
2065
+ /** Drop the LIMIT. */
2066
+ clearLimit(): this {
2067
+ this.#limit = undefined;
2068
+ return this;
2069
+ }
2070
+
2071
+ /** Drop the OFFSET. */
2072
+ clearOffset(): this {
2073
+ this.#offset = undefined;
2074
+ return this;
2075
+ }
2076
+
1983
2077
  /** The table, qualified with a schema when {@link withSchema} was used. */
1984
2078
  #qualifiedTable(): string {
1985
2079
  return this.#schema ? `${this.#schema}.${this.#table}` : this.#table;
@@ -2672,19 +2766,36 @@ export class DatabaseQueryBuilder<T = Record<string, unknown>> {
2672
2766
  },
2673
2767
  this.#dialect,
2674
2768
  );
2769
+ // A flat `SELECT COUNT(*) … GROUP BY x` returns one row PER GROUP, each
2770
+ // holding that group's size — so `rows[0]` would be the first group's
2771
+ // size, not the number of rows. Wrap the grouped query and count ITS
2772
+ // rows, which is what ModelQuery.paginate already does.
2773
+ const countSql =
2774
+ this.#groupBys.length > 0
2775
+ ? `SELECT COUNT(*) AS aggregate FROM (${this.#compiledNative().sql}) AS __paginate_count`
2776
+ : countCompiled.statements[0];
2777
+ const countParams =
2778
+ this.#groupBys.length > 0
2779
+ ? this.#compiledNative().params
2780
+ : countCompiled.params;
2675
2781
  const countRows = await this.#exec.query<{
2676
2782
  aggregate: number | string | null;
2677
- }>(
2678
- countCompiled.statements[0],
2679
- countCompiled.params,
2680
- this.#queryMeta("paginate"),
2681
- );
2783
+ }>(countSql, countParams, this.#queryMeta("paginate"));
2682
2784
  const total = Number(countRows[0]?.aggregate ?? 0);
2683
2785
 
2786
+ // Restore the slice afterwards: `paginate()` is a read, and a builder
2787
+ // left carrying a LIMIT silently truncated the next query run off it.
2788
+ const previousLimit = this.#limit;
2789
+ const previousOffset = this.#offset;
2684
2790
  this.#limit = pp;
2685
2791
  this.#offset = (p - 1) * pp;
2686
- const items = await this.exec();
2687
- return new Paginator<T>(items, { total, perPage: pp, currentPage: p });
2792
+ try {
2793
+ const items = await this.exec();
2794
+ return new Paginator<T>(items, { total, perPage: pp, currentPage: p });
2795
+ } finally {
2796
+ this.#limit = previousLimit;
2797
+ this.#offset = previousOffset;
2798
+ }
2688
2799
  }
2689
2800
 
2690
2801
  /** Thenable, so `await db.from('users').where(...)` resolves to the rows. */
@@ -4,11 +4,12 @@
4
4
  * The classes were split into one-class-per-file:
5
5
  * - {@link Schema} → ./Schema.ts
6
6
  * - {@link TableBuilder} → ./TableBuilder.ts
7
+ * - {@link ColumnBuilder} → ./TableBuilder.ts
7
8
  * - shared types → ./types.ts
8
9
  *
9
10
  * @implements FR34
10
11
  */
11
12
 
12
13
  export { Schema } from "./Schema.js";
13
- export { TableBuilder } from "./TableBuilder.js";
14
+ export { ColumnBuilder, TableBuilder } from "./TableBuilder.js";
14
15
  export type { ColumnDefinition, ColumnType, IndexDefinition } from "./types.js";