@shaferllc/keel 0.81.2 → 0.83.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.
Files changed (121) hide show
  1. package/dist/core/database.d.ts +83 -0
  2. package/dist/core/database.js +248 -4
  3. package/dist/core/index.d.ts +1 -1
  4. package/dist/db/d1-http.d.ts +34 -0
  5. package/dist/db/d1-http.js +61 -0
  6. package/dist/db/libsql.d.ts +13 -3
  7. package/dist/teams/models.js +51 -5
  8. package/docs/ai-manifest.json +27 -1
  9. package/docs/database.md +5 -348
  10. package/docs/models.md +5 -2
  11. package/docs/orm.md +57 -0
  12. package/docs/query-builder.md +533 -0
  13. package/docs/starter-kits.md +88 -0
  14. package/llms-full.txt +4599 -4241
  15. package/llms.txt +3 -0
  16. package/package.json +7 -2
  17. package/templates/api/.env.example +12 -0
  18. package/templates/api/app/Controllers/PostController.ts +37 -0
  19. package/templates/api/app/Http/Kernel.ts +13 -0
  20. package/templates/api/app/Http/Middleware/requestLogger.ts +8 -0
  21. package/templates/api/app/Models/Post.ts +12 -0
  22. package/templates/api/app/Providers/AppServiceProvider.ts +8 -0
  23. package/templates/api/app/Providers/DatabaseServiceProvider.ts +52 -0
  24. package/templates/api/bin/keel.ts +15 -0
  25. package/templates/api/bootstrap/app.ts +24 -0
  26. package/templates/api/bootstrap/providers.edge.ts +14 -0
  27. package/templates/api/bootstrap/providers.ts +7 -0
  28. package/templates/api/config/app.ts +10 -0
  29. package/templates/api/config/database.ts +42 -0
  30. package/templates/api/database/migrations/0001_create_posts.ts +20 -0
  31. package/templates/api/package.json +30 -0
  32. package/templates/api/routes/web.ts +12 -0
  33. package/templates/api/tests/posts.test.ts +30 -0
  34. package/templates/api/tsconfig.json +16 -0
  35. package/templates/api/worker.ts +33 -0
  36. package/templates/api/wrangler.jsonc +22 -0
  37. package/templates/app/.env.example +12 -0
  38. package/templates/app/app/Controllers/AuthController.ts +117 -0
  39. package/templates/app/app/Controllers/DashboardController.ts +37 -0
  40. package/templates/app/app/Controllers/HomeController.ts +10 -0
  41. package/templates/app/app/Http/Kernel.ts +21 -0
  42. package/templates/app/app/Http/Middleware/requestLogger.ts +8 -0
  43. package/templates/app/app/Models/User.ts +15 -0
  44. package/templates/app/app/Providers/AppServiceProvider.ts +12 -0
  45. package/templates/app/app/Providers/DatabaseServiceProvider.ts +52 -0
  46. package/templates/app/bin/keel.ts +15 -0
  47. package/templates/app/bootstrap/app.ts +24 -0
  48. package/templates/app/bootstrap/providers.edge.ts +15 -0
  49. package/templates/app/bootstrap/providers.ts +18 -0
  50. package/templates/app/config/app.ts +10 -0
  51. package/templates/app/config/database.ts +42 -0
  52. package/templates/app/database/migrations/0001_create_users.ts +21 -0
  53. package/templates/app/package.json +35 -0
  54. package/templates/app/public/.gitkeep +2 -0
  55. package/templates/app/resources/css/app.css +1 -0
  56. package/templates/app/resources/views/auth/forgot.tsx +30 -0
  57. package/templates/app/resources/views/auth/login.tsx +24 -0
  58. package/templates/app/resources/views/auth/register.tsx +24 -0
  59. package/templates/app/resources/views/auth/two-factor.tsx +22 -0
  60. package/templates/app/resources/views/dashboard.tsx +20 -0
  61. package/templates/app/resources/views/layout.tsx +15 -0
  62. package/templates/app/resources/views/welcome.tsx +29 -0
  63. package/templates/app/routes/web.ts +35 -0
  64. package/templates/app/tests/auth.test.ts +47 -0
  65. package/templates/app/tsconfig.json +31 -0
  66. package/templates/app/worker.ts +33 -0
  67. package/templates/app/wrangler.jsonc +25 -0
  68. package/templates/minimal/.env.example +8 -0
  69. package/templates/minimal/app/Controllers/HomeController.ts +14 -0
  70. package/templates/minimal/app/Http/Kernel.ts +22 -0
  71. package/templates/minimal/app/Http/Middleware/requestLogger.ts +8 -0
  72. package/templates/minimal/app/Providers/AppServiceProvider.ts +8 -0
  73. package/templates/minimal/bin/keel.ts +15 -0
  74. package/templates/minimal/bootstrap/app.ts +24 -0
  75. package/templates/minimal/bootstrap/providers.ts +6 -0
  76. package/templates/minimal/config/app.ts +10 -0
  77. package/templates/minimal/package.json +29 -0
  78. package/templates/minimal/public/.gitkeep +2 -0
  79. package/templates/minimal/resources/css/app.css +1 -0
  80. package/templates/minimal/resources/views/layout.tsx +15 -0
  81. package/templates/minimal/resources/views/welcome.tsx +15 -0
  82. package/templates/minimal/routes/web.ts +13 -0
  83. package/templates/minimal/tsconfig.json +18 -0
  84. package/templates/minimal/worker.ts +23 -0
  85. package/templates/minimal/wrangler.jsonc +10 -0
  86. package/templates/saas/.env.example +12 -0
  87. package/templates/saas/app/Controllers/AuthController.ts +125 -0
  88. package/templates/saas/app/Controllers/DashboardController.ts +37 -0
  89. package/templates/saas/app/Controllers/HomeController.ts +10 -0
  90. package/templates/saas/app/Controllers/TeamController.ts +88 -0
  91. package/templates/saas/app/Http/Kernel.ts +27 -0
  92. package/templates/saas/app/Http/Middleware/requestLogger.ts +8 -0
  93. package/templates/saas/app/Models/Project.ts +20 -0
  94. package/templates/saas/app/Models/User.ts +15 -0
  95. package/templates/saas/app/Providers/AppServiceProvider.ts +12 -0
  96. package/templates/saas/app/Providers/DatabaseServiceProvider.ts +52 -0
  97. package/templates/saas/bin/keel.ts +15 -0
  98. package/templates/saas/bootstrap/app.ts +24 -0
  99. package/templates/saas/bootstrap/providers.edge.ts +18 -0
  100. package/templates/saas/bootstrap/providers.ts +22 -0
  101. package/templates/saas/config/app.ts +10 -0
  102. package/templates/saas/config/database.ts +42 -0
  103. package/templates/saas/database/migrations/0001_create_users.ts +21 -0
  104. package/templates/saas/database/migrations/0002_create_projects.ts +20 -0
  105. package/templates/saas/package.json +35 -0
  106. package/templates/saas/public/.gitkeep +2 -0
  107. package/templates/saas/resources/css/app.css +1 -0
  108. package/templates/saas/resources/views/auth/forgot.tsx +30 -0
  109. package/templates/saas/resources/views/auth/login.tsx +24 -0
  110. package/templates/saas/resources/views/auth/register.tsx +24 -0
  111. package/templates/saas/resources/views/auth/two-factor.tsx +22 -0
  112. package/templates/saas/resources/views/dashboard.tsx +20 -0
  113. package/templates/saas/resources/views/layout.tsx +15 -0
  114. package/templates/saas/resources/views/teams/index.tsx +85 -0
  115. package/templates/saas/resources/views/welcome.tsx +29 -0
  116. package/templates/saas/routes/web.ts +44 -0
  117. package/templates/saas/tests/auth.test.ts +47 -0
  118. package/templates/saas/tests/teams.test.ts +27 -0
  119. package/templates/saas/tsconfig.json +31 -0
  120. package/templates/saas/worker.ts +33 -0
  121. package/templates/saas/wrangler.jsonc +25 -0
@@ -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";
@@ -0,0 +1,34 @@
1
+ /**
2
+ * A Keel `Connection` for Cloudflare D1 over its **HTTP API**.
3
+ *
4
+ * The D1 binding (`env.DB`) only exists inside a running Worker, which leaves an
5
+ * awkward hole: `keel migrate` runs on your laptop and in CI, where there is no
6
+ * binding — so there was no way to create your tables. This closes it. Same
7
+ * `Connection` interface, so migrations, models, and the query builder all work
8
+ * against a real D1 database from anywhere:
9
+ *
10
+ * import { d1HttpConnection } from "@shaferllc/keel/db/d1-http";
11
+ *
12
+ * setConnection(d1HttpConnection({
13
+ * accountId: env("CLOUDFLARE_ACCOUNT_ID"),
14
+ * databaseId: env("D1_DATABASE_ID"),
15
+ * apiToken: env("CLOUDFLARE_API_TOKEN"),
16
+ * }), "sqlite");
17
+ *
18
+ * Use the **binding** (`@shaferllc/keel/db/d1`) inside the Worker — it's a direct
19
+ * call with no network hop. Use this one for migrations and scripts. Both speak
20
+ * SQLite, so the same schema serves both.
21
+ *
22
+ * `fetch` only — no SDK, nothing Node-specific.
23
+ */
24
+ import type { Connection } from "../core/database.js";
25
+ export interface D1HttpOptions {
26
+ accountId: string;
27
+ databaseId: string;
28
+ /** An API token with D1 edit permission. */
29
+ apiToken: string;
30
+ /** Override the API base (for tests, or a proxy). */
31
+ baseUrl?: string;
32
+ }
33
+ /** Build a `Connection` that talks to D1 over HTTP. */
34
+ export declare function d1HttpConnection(options: D1HttpOptions): Connection;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * A Keel `Connection` for Cloudflare D1 over its **HTTP API**.
3
+ *
4
+ * The D1 binding (`env.DB`) only exists inside a running Worker, which leaves an
5
+ * awkward hole: `keel migrate` runs on your laptop and in CI, where there is no
6
+ * binding — so there was no way to create your tables. This closes it. Same
7
+ * `Connection` interface, so migrations, models, and the query builder all work
8
+ * against a real D1 database from anywhere:
9
+ *
10
+ * import { d1HttpConnection } from "@shaferllc/keel/db/d1-http";
11
+ *
12
+ * setConnection(d1HttpConnection({
13
+ * accountId: env("CLOUDFLARE_ACCOUNT_ID"),
14
+ * databaseId: env("D1_DATABASE_ID"),
15
+ * apiToken: env("CLOUDFLARE_API_TOKEN"),
16
+ * }), "sqlite");
17
+ *
18
+ * Use the **binding** (`@shaferllc/keel/db/d1`) inside the Worker — it's a direct
19
+ * call with no network hop. Use this one for migrations and scripts. Both speak
20
+ * SQLite, so the same schema serves both.
21
+ *
22
+ * `fetch` only — no SDK, nothing Node-specific.
23
+ */
24
+ /** Build a `Connection` that talks to D1 over HTTP. */
25
+ export function d1HttpConnection(options) {
26
+ const base = options.baseUrl ?? "https://api.cloudflare.com/client/v4";
27
+ const url = `${base}/accounts/${options.accountId}/d1/database/${options.databaseId}/query`;
28
+ async function query(sql, params) {
29
+ const response = await fetch(url, {
30
+ method: "POST",
31
+ headers: {
32
+ authorization: `Bearer ${options.apiToken}`,
33
+ "content-type": "application/json",
34
+ },
35
+ body: JSON.stringify({ sql, params }),
36
+ });
37
+ const payload = (await response.json());
38
+ if (!response.ok || !payload.success) {
39
+ // Cloudflare returns its errors in the body with a 200 as often as not, so
40
+ // the status alone is not enough to tell whether the statement ran.
41
+ const message = payload.errors?.map((e) => `${e.code}: ${e.message}`).join("; ") ??
42
+ `D1 request failed (${response.status})`;
43
+ throw new Error(`D1: ${message}`);
44
+ }
45
+ return payload.result;
46
+ }
47
+ return {
48
+ async select(sql, bindings) {
49
+ const result = await query(sql, bindings);
50
+ return result?.[0]?.results ?? [];
51
+ },
52
+ async write(sql, bindings) {
53
+ const result = await query(sql, bindings);
54
+ const meta = result?.[0]?.meta ?? {};
55
+ return {
56
+ rowsAffected: meta.changes ?? meta.rows_written ?? 0,
57
+ insertId: meta.last_row_id != null ? Number(meta.last_row_id) : undefined,
58
+ };
59
+ },
60
+ };
61
+ }
@@ -13,14 +13,24 @@
13
13
  * The client is duck-typed — this module imports no driver, so it never bundles
14
14
  * `@libsql/client`.
15
15
  */
16
- import type { Connection, Row } from "../core/database.js";
16
+ import type { Connection } from "../core/database.js";
17
17
  /** The slice of the `@libsql/client` API this adapter uses. */
18
18
  export interface LibSqlLike {
19
+ /**
20
+ * `any` rather than `unknown[]` / `Row[]` on purpose.
21
+ *
22
+ * The official client types its arguments as `InArgs` and its rows as its own
23
+ * `Row`, and under `strictFunctionTypes` a narrower parameter type makes the real
24
+ * `Client` *unassignable* to this interface — so wiring libSQL the obvious way
25
+ * required `client as unknown as LibSqlLike`, which is a cast a user shouldn't
26
+ * have to discover. Widening here keeps `libsqlConnection(createClient(…))`
27
+ * working with no ceremony; the values are normalized below anyway.
28
+ */
19
29
  execute(stmt: {
20
30
  sql: string;
21
- args: unknown[];
31
+ args: any[];
22
32
  }): Promise<{
23
- rows: Row[];
33
+ rows: any[];
24
34
  rowsAffected: number;
25
35
  lastInsertRowid?: bigint | number;
26
36
  }>;