@shaferllc/keel 0.82.0 → 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 (118) hide show
  1. package/dist/db/d1-http.d.ts +34 -0
  2. package/dist/db/d1-http.js +61 -0
  3. package/dist/db/libsql.d.ts +13 -3
  4. package/dist/teams/models.js +51 -5
  5. package/docs/ai-manifest.json +22 -1
  6. package/docs/database.md +5 -522
  7. package/docs/models.md +5 -2
  8. package/docs/orm.md +57 -0
  9. package/docs/query-builder.md +533 -0
  10. package/docs/starter-kits.md +88 -0
  11. package/llms-full.txt +5299 -5115
  12. package/llms.txt +3 -0
  13. package/package.json +7 -2
  14. package/templates/api/.env.example +12 -0
  15. package/templates/api/app/Controllers/PostController.ts +37 -0
  16. package/templates/api/app/Http/Kernel.ts +13 -0
  17. package/templates/api/app/Http/Middleware/requestLogger.ts +8 -0
  18. package/templates/api/app/Models/Post.ts +12 -0
  19. package/templates/api/app/Providers/AppServiceProvider.ts +8 -0
  20. package/templates/api/app/Providers/DatabaseServiceProvider.ts +52 -0
  21. package/templates/api/bin/keel.ts +15 -0
  22. package/templates/api/bootstrap/app.ts +24 -0
  23. package/templates/api/bootstrap/providers.edge.ts +14 -0
  24. package/templates/api/bootstrap/providers.ts +7 -0
  25. package/templates/api/config/app.ts +10 -0
  26. package/templates/api/config/database.ts +42 -0
  27. package/templates/api/database/migrations/0001_create_posts.ts +20 -0
  28. package/templates/api/package.json +30 -0
  29. package/templates/api/routes/web.ts +12 -0
  30. package/templates/api/tests/posts.test.ts +30 -0
  31. package/templates/api/tsconfig.json +16 -0
  32. package/templates/api/worker.ts +33 -0
  33. package/templates/api/wrangler.jsonc +22 -0
  34. package/templates/app/.env.example +12 -0
  35. package/templates/app/app/Controllers/AuthController.ts +117 -0
  36. package/templates/app/app/Controllers/DashboardController.ts +37 -0
  37. package/templates/app/app/Controllers/HomeController.ts +10 -0
  38. package/templates/app/app/Http/Kernel.ts +21 -0
  39. package/templates/app/app/Http/Middleware/requestLogger.ts +8 -0
  40. package/templates/app/app/Models/User.ts +15 -0
  41. package/templates/app/app/Providers/AppServiceProvider.ts +12 -0
  42. package/templates/app/app/Providers/DatabaseServiceProvider.ts +52 -0
  43. package/templates/app/bin/keel.ts +15 -0
  44. package/templates/app/bootstrap/app.ts +24 -0
  45. package/templates/app/bootstrap/providers.edge.ts +15 -0
  46. package/templates/app/bootstrap/providers.ts +18 -0
  47. package/templates/app/config/app.ts +10 -0
  48. package/templates/app/config/database.ts +42 -0
  49. package/templates/app/database/migrations/0001_create_users.ts +21 -0
  50. package/templates/app/package.json +35 -0
  51. package/templates/app/public/.gitkeep +2 -0
  52. package/templates/app/resources/css/app.css +1 -0
  53. package/templates/app/resources/views/auth/forgot.tsx +30 -0
  54. package/templates/app/resources/views/auth/login.tsx +24 -0
  55. package/templates/app/resources/views/auth/register.tsx +24 -0
  56. package/templates/app/resources/views/auth/two-factor.tsx +22 -0
  57. package/templates/app/resources/views/dashboard.tsx +20 -0
  58. package/templates/app/resources/views/layout.tsx +15 -0
  59. package/templates/app/resources/views/welcome.tsx +29 -0
  60. package/templates/app/routes/web.ts +35 -0
  61. package/templates/app/tests/auth.test.ts +47 -0
  62. package/templates/app/tsconfig.json +31 -0
  63. package/templates/app/worker.ts +33 -0
  64. package/templates/app/wrangler.jsonc +25 -0
  65. package/templates/minimal/.env.example +8 -0
  66. package/templates/minimal/app/Controllers/HomeController.ts +14 -0
  67. package/templates/minimal/app/Http/Kernel.ts +22 -0
  68. package/templates/minimal/app/Http/Middleware/requestLogger.ts +8 -0
  69. package/templates/minimal/app/Providers/AppServiceProvider.ts +8 -0
  70. package/templates/minimal/bin/keel.ts +15 -0
  71. package/templates/minimal/bootstrap/app.ts +24 -0
  72. package/templates/minimal/bootstrap/providers.ts +6 -0
  73. package/templates/minimal/config/app.ts +10 -0
  74. package/templates/minimal/package.json +29 -0
  75. package/templates/minimal/public/.gitkeep +2 -0
  76. package/templates/minimal/resources/css/app.css +1 -0
  77. package/templates/minimal/resources/views/layout.tsx +15 -0
  78. package/templates/minimal/resources/views/welcome.tsx +15 -0
  79. package/templates/minimal/routes/web.ts +13 -0
  80. package/templates/minimal/tsconfig.json +18 -0
  81. package/templates/minimal/worker.ts +23 -0
  82. package/templates/minimal/wrangler.jsonc +10 -0
  83. package/templates/saas/.env.example +12 -0
  84. package/templates/saas/app/Controllers/AuthController.ts +125 -0
  85. package/templates/saas/app/Controllers/DashboardController.ts +37 -0
  86. package/templates/saas/app/Controllers/HomeController.ts +10 -0
  87. package/templates/saas/app/Controllers/TeamController.ts +88 -0
  88. package/templates/saas/app/Http/Kernel.ts +27 -0
  89. package/templates/saas/app/Http/Middleware/requestLogger.ts +8 -0
  90. package/templates/saas/app/Models/Project.ts +20 -0
  91. package/templates/saas/app/Models/User.ts +15 -0
  92. package/templates/saas/app/Providers/AppServiceProvider.ts +12 -0
  93. package/templates/saas/app/Providers/DatabaseServiceProvider.ts +52 -0
  94. package/templates/saas/bin/keel.ts +15 -0
  95. package/templates/saas/bootstrap/app.ts +24 -0
  96. package/templates/saas/bootstrap/providers.edge.ts +18 -0
  97. package/templates/saas/bootstrap/providers.ts +22 -0
  98. package/templates/saas/config/app.ts +10 -0
  99. package/templates/saas/config/database.ts +42 -0
  100. package/templates/saas/database/migrations/0001_create_users.ts +21 -0
  101. package/templates/saas/database/migrations/0002_create_projects.ts +20 -0
  102. package/templates/saas/package.json +35 -0
  103. package/templates/saas/public/.gitkeep +2 -0
  104. package/templates/saas/resources/css/app.css +1 -0
  105. package/templates/saas/resources/views/auth/forgot.tsx +30 -0
  106. package/templates/saas/resources/views/auth/login.tsx +24 -0
  107. package/templates/saas/resources/views/auth/register.tsx +24 -0
  108. package/templates/saas/resources/views/auth/two-factor.tsx +22 -0
  109. package/templates/saas/resources/views/dashboard.tsx +20 -0
  110. package/templates/saas/resources/views/layout.tsx +15 -0
  111. package/templates/saas/resources/views/teams/index.tsx +85 -0
  112. package/templates/saas/resources/views/welcome.tsx +29 -0
  113. package/templates/saas/routes/web.ts +44 -0
  114. package/templates/saas/tests/auth.test.ts +47 -0
  115. package/templates/saas/tests/teams.test.ts +27 -0
  116. package/templates/saas/tsconfig.json +31 -0
  117. package/templates/saas/worker.ts +33 -0
  118. package/templates/saas/wrangler.jsonc +25 -0
@@ -0,0 +1,533 @@
1
+ # Query Builder
2
+
3
+ Keel's **driver-agnostic query builder** — build and run SQL by chaining
4
+ methods off `db(table)`. Nothing hits the database until a terminal method runs,
5
+ every value is a parameterized binding (injection-safe), and the same chain
6
+ compiles for sqlite, MySQL, and Postgres. Models add an active-record layer on
7
+ top — see the [ORM](./orm.md).
8
+
9
+
10
+ Start a query with `db(table)`, chain constraints (they return the builder, so
11
+ order doesn't matter), and finish with a terminal method. Nothing hits the
12
+ database until a terminal runs. Every value becomes a **binding**, never
13
+ string-interpolated SQL — the builder is injection-safe by construction. It's
14
+ driver-agnostic and edge-safe: the same chain compiles for sqlite, MySQL, and
15
+ Postgres.
16
+
17
+ ```ts
18
+ import { db } from "@shaferllc/keel/core";
19
+
20
+ const active = await db("users")
21
+ .where("active", true)
22
+ .where("age", ">", 18)
23
+ .orderBy("name")
24
+ .limit(20)
25
+ .get();
26
+ ```
27
+
28
+ ## Retrieving results
29
+
30
+ ```ts
31
+ await db("users").get(); // Row[]
32
+ await db("users").where("id", 1).first(); // Row | null
33
+ await db("users").where("id", 1).firstOrFail(); // Row, or throws NotFoundException
34
+ await db("users").find(1); // by primary key (default "id")
35
+ await db("users").where("email", e).sole(); // exactly one, else throws
36
+ await db("users").where("id", 1).value("email"); // one column of the first row
37
+ await db("posts").pluck("title"); // string[] of one column
38
+ await db("tags").orderBy("name").implode("name", ", "); // "a, b, c"
39
+ ```
40
+
41
+ For large sets, `chunk` pages through without loading everything (return `false`
42
+ to stop early):
43
+
44
+ ```ts
45
+ await db("users").orderBy("id").chunk(500, async (rows) => {
46
+ for (const row of rows) await process(row);
47
+ });
48
+ ```
49
+
50
+ ## Aggregates
51
+
52
+ ```ts
53
+ await db("orders").count();
54
+ await db("orders").where("paid", true).sum("total");
55
+ await db("orders").avg("total"); // also min(col), max(col)
56
+ await db("users").where("email", e).exists(); // boolean
57
+ await db("users").where("banned", true).doesntExist();
58
+ ```
59
+
60
+ ## Selects
61
+
62
+ ```ts
63
+ db("users").select("id", "email");
64
+ db("users").select("id").addSelect("email"); // append, don't replace
65
+ db("orders").selectRaw("SUM(total) AS revenue");
66
+ db("users").distinct().select("country");
67
+ ```
68
+
69
+ ## Where clauses
70
+
71
+ ```ts
72
+ db("users").where("votes", 100); // = is the default operator
73
+ db("users").where("votes", ">=", 100);
74
+ db("users").where("name", "like", "T%");
75
+
76
+ db("users").where("votes", 100).orWhere("name", "John");
77
+ db("users").whereNot("status", "cancelled");
78
+
79
+ db("users").whereIn("id", [1, 2, 3]).whereNotIn("id", [4]);
80
+ db("users").whereNull("deleted_at").whereNotNull("email_verified_at");
81
+ db("products").whereBetween("price", [10, 100]).whereNotBetween("stock", [0, 5]);
82
+ db("posts").whereLike("title", "%keel%");
83
+ db("events").whereColumn("updated_at", ">", "created_at"); // column vs column
84
+ db("users").whereRaw("score >= ? AND score <= ?", [10, 90]);
85
+ ```
86
+
87
+ Every clause has an `orWhere…` twin — `orWhereIn`, `orWhereNull`,
88
+ `orWhereNotNull`, `orWhereBetween`, `orWhereColumn`, `orWhereLike`, `orWhereRaw`,
89
+ `orWhereNotIn`.
90
+
91
+ **Grouped clauses.** Pass a callback to `where`/`orWhere` to parenthesize a set
92
+ of conditions — the way to express `A AND (B OR C)`:
93
+
94
+ ```ts
95
+ await db("users")
96
+ .where("active", true)
97
+ .where((q) => q.where("role", "admin").orWhere("role", "owner"))
98
+ .get();
99
+ // … WHERE active = ? AND (role = ? OR role = ?)
100
+ ```
101
+
102
+ ## Ordering, grouping, limit & offset
103
+
104
+ ```ts
105
+ db("users").orderBy("name").orderByDesc("created_at");
106
+ db("posts").latest(); // ORDER BY created_at DESC (oldest() for ASC)
107
+ db("posts").orderByRaw("LENGTH(title) DESC");
108
+ db("users").inRandomOrder(); // dialect-aware RANDOM()/RAND()
109
+ db("users").reorder("name"); // clear existing ordering, then set
110
+
111
+ db("orders")
112
+ .select("user_id")
113
+ .selectRaw("SUM(total) AS spent")
114
+ .groupBy("user_id")
115
+ .having("spent", ">", 1000) // also havingRaw(...), havingBetween(...)
116
+ .get();
117
+
118
+ db("users").limit(10).offset(20); // take(10)/skip(20) are aliases
119
+ db("users").forPage(3, 15); // page 3, 15 per page
120
+ ```
121
+
122
+ ## Joins
123
+
124
+ ```ts
125
+ await db("posts")
126
+ .join("users", "posts.user_id", "users.id") // INNER JOIN on equality
127
+ .leftJoin("images", "images.post_id", "posts.id")
128
+ .select("posts.title", "users.name")
129
+ .get();
130
+ ```
131
+
132
+ `rightJoin` and `crossJoin` round out the set. Joins with several `ON`
133
+ conditions aren't modelled — use `whereRaw` or a view.
134
+
135
+ ## Conditional clauses
136
+
137
+ `when` / `unless` apply a callback based on a runtime value, so you build a query
138
+ without breaking the chain into `if`s. The callback receives the value:
139
+
140
+ ```ts
141
+ await db("users")
142
+ .when(search, (q, term) => q.whereLike("name", `%${term}%`))
143
+ .unless(includeArchived, (q) => q.whereNull("archived_at"))
144
+ .get();
145
+ ```
146
+
147
+ ## Inserts
148
+
149
+ ```ts
150
+ await db("users").insert({ email, name });
151
+ const id = await db("users").insertGetId({ email, name }); // new primary key
152
+ await db("logs").insertOrIgnore({ key, value }); // skip unique conflicts
153
+ await db("users").upsert([{ id: 1, name: "Ada" }], ["id"], ["name"]); // insert/update
154
+ ```
155
+
156
+ `upsert(rows, uniqueBy, update?)` inserts, updating the `update` columns (default:
157
+ everything not in `uniqueBy`) on a conflict — dialect-aware (`ON CONFLICT` /
158
+ `ON DUPLICATE KEY UPDATE`).
159
+
160
+ ## Updates
161
+
162
+ ```ts
163
+ await db("users").where("id", id).update({ name: "Grace" });
164
+ await db("users").updateOrInsert({ email }, { name }); // update match, else insert
165
+ await db("posts").where("id", id).increment("views"); // += 1
166
+ await db("posts").where("id", id).decrement("stock", 3, { updated_at: now });
167
+ await db("counters").incrementEach({ hits: 1, misses: 2 }); // several columns at once
168
+ ```
169
+
170
+ ## Deletes
171
+
172
+ ```ts
173
+ await db("sessions").where("expires_at", "<", now).delete();
174
+ await db("cache").truncate(); // empty the table (DELETE on sqlite)
175
+ ```
176
+
177
+ > **Guard your writes.** `update()`, `delete()`, and the increments apply to
178
+ > every row matching the current `where` clause — with none, that's the whole
179
+ > table. Scope every write unless you truly mean to touch every row.
180
+
181
+ ## Pagination
182
+
183
+ ```ts
184
+ const page = await db("posts").latest().paginate(2, 15);
185
+ // { data, total, perPage, currentPage, lastPage } — a COUNT plus a page query
186
+
187
+ const feed = await db("posts").latest().simplePaginate(2, 15);
188
+ // { data, perPage, currentPage, hasMore } — no COUNT; one extra row tells hasMore
189
+ ```
190
+
191
+ ## Pessimistic locking
192
+
193
+ Inside a [transaction](./database.md#transactions), lock the selected rows against concurrent
194
+ writes. No-ops on sqlite (which locks the whole database anyway):
195
+
196
+ ```ts
197
+ await transaction(async () => {
198
+ const row = await db("accounts").where("id", id).lockForUpdate().first(); // FOR UPDATE
199
+ await db("accounts").where("id", id).update({ balance: row.balance - 10 });
200
+ });
201
+ // sharedLock() takes a read lock (FOR SHARE) instead.
202
+ ```
203
+
204
+ ## Debugging
205
+
206
+ ```ts
207
+ db("users").where("active", true).toSql(); // "SELECT * FROM users WHERE active = ?"
208
+ db("users").where("active", true).getBindings(); // [true]
209
+ db("users").where("active", true).dump(); // logs SQL + bindings, returns the builder
210
+ db("users").where("active", true).dd(); // logs and throws (dump-and-die)
211
+ ```
212
+
213
+ ## Not (yet) modelled
214
+
215
+ Kept out on purpose, to stay driver-agnostic and honest about what compiles
216
+ everywhere: unions, subquery `where`/join builders (`whereExists`, `joinSub`),
217
+ the `whereDate`/`whereMonth`/… date-function family (no portable form across
218
+ dialects), and `cursor`/`lazy` streaming. Reach for `whereRaw`, a raw
219
+ `connection().select(sql)`, or a database view when you need them.
220
+
221
+
222
+ ## `QueryBuilder` — method reference
223
+
224
+
225
+ Returned by `db()`. Constraint methods return `this` (chainable); terminal
226
+ methods return a promise. You never construct it directly.
227
+
228
+ #### `select(...columns)`
229
+
230
+ `select(...columns: string[]): this`
231
+
232
+ Restricts the selected columns. With no arguments, selects `*`.
233
+
234
+ ```ts
235
+ db("users").select("id", "email").get();
236
+ ```
237
+
238
+ **Notes:** column names are interpolated as-is (they are not parameterized), so
239
+ never pass user input as a column name. Calling it again replaces the prior
240
+ selection.
241
+
242
+ #### `where(column, value)` / `where(column, operator, value)`
243
+
244
+ `where(column: string, value: unknown): this`
245
+ `where(column: string, operator: Operator, value: unknown): this`
246
+
247
+ Adds an `AND` condition. The two-argument form uses `=`; the three-argument form
248
+ takes an explicit operator.
249
+
250
+ ```ts
251
+ db("users").where("active", true);
252
+ db("users").where("age", ">", 18);
253
+ db("users").where("email", "like", "%@example.com");
254
+ ```
255
+
256
+ **Notes:** `Operator` is `"=" | "!=" | "<" | "<=" | ">" | ">=" | "like"`. Values
257
+ are always parameterized. Chaining multiple `where`s combines them with `AND`.
258
+
259
+ #### `orWhere(column, value)` / `orWhere(column, operator, value)`
260
+
261
+ `orWhere(column: string, value: unknown): this`
262
+ `orWhere(column: string, operator: Operator, value: unknown): this`
263
+
264
+ Same as `where`, but joins the condition with `OR`.
265
+
266
+ ```ts
267
+ db("orders").where("status", "paid").orWhere("status", "shipped").get();
268
+ ```
269
+
270
+ **Notes:** conditions are combined left-to-right without grouping parentheses, so
271
+ mixing `where` and `orWhere` follows SQL's `AND`/`OR` precedence — group complex
272
+ logic in separate queries if you need explicit parenthesization.
273
+
274
+ #### `whereIn(column, values)`
275
+
276
+ `whereIn(column: string, values: unknown[]): this`
277
+
278
+ Matches rows where `column` is any of `values` (`AND`-joined).
279
+
280
+ ```ts
281
+ db("posts").whereIn("id", [1, 2, 3]).get();
282
+ ```
283
+
284
+ **Notes:** each value becomes its own placeholder. An empty array produces
285
+ `IN ()`, which most engines reject — guard against empty lists yourself.
286
+
287
+ #### `whereNull(column)` / `whereNotNull(column)`
288
+
289
+ `whereNull(column: string): this`
290
+ `whereNotNull(column: string): this`
291
+
292
+ Adds an `AND` `IS NULL` / `IS NOT NULL` condition — no binding.
293
+
294
+ ```ts
295
+ db("posts").whereNull("deleted_at").get();
296
+ db("users").whereNotNull("verified_at").get();
297
+ ```
298
+
299
+ #### `orderBy(column, direction?)`
300
+
301
+ `orderBy(column: string, direction?: "asc" | "desc"): this`
302
+
303
+ Adds an `ORDER BY` clause (default `"asc"`). Call it repeatedly for multiple sort
304
+ keys, applied in call order.
305
+
306
+ ```ts
307
+ db("users").orderBy("last_name").orderBy("created_at", "desc").get();
308
+ ```
309
+
310
+ **Notes:** the column is interpolated, not parameterized — don't pass user input.
311
+
312
+ #### `limit(n)` / `offset(n)`
313
+
314
+ `limit(n: number): this`
315
+ `offset(n: number): this`
316
+
317
+ Caps the number of rows / skips the first `n`. Together they paginate.
318
+
319
+ ```ts
320
+ db("posts").limit(20).offset(40).get(); // page 3, 20 per page
321
+ ```
322
+
323
+ **Notes:** `first()` sets `limit(1)` internally, overriding any prior `limit`.
324
+
325
+ #### `get()`
326
+
327
+ `get(): Promise<T[]>`
328
+
329
+ Runs the SELECT and returns all matching rows.
330
+
331
+ ```ts
332
+ const rows = await db("users").where("active", true).get();
333
+ ```
334
+
335
+ #### `first()`
336
+
337
+ `first(): Promise<T | null>`
338
+
339
+ Runs the SELECT with `LIMIT 1` and returns the first row, or `null`.
340
+
341
+ ```ts
342
+ const user = await db("users").where("email", email).first();
343
+ ```
344
+
345
+ **Notes:** overrides any `limit` you set. Returns `null` (not `undefined`) when
346
+ nothing matches.
347
+
348
+ #### `count()`
349
+
350
+ `count(): Promise<number>`
351
+
352
+ Returns `COUNT(*)` for the current `where` clause.
353
+
354
+ ```ts
355
+ const active = await db("users").where("active", true).count();
356
+ ```
357
+
358
+ **Notes:** ignores `select`, `orderBy`, `limit`, and `offset` — it counts matching
359
+ rows, not the paginated slice.
360
+
361
+ #### `exists()`
362
+
363
+ `exists(): Promise<boolean>`
364
+
365
+ `true` when at least one row matches — a `count() > 0` shorthand.
366
+
367
+ ```ts
368
+ if (await db("users").where("email", email).exists()) { /* taken */ }
369
+ ```
370
+
371
+ #### `insert(data)`
372
+
373
+ `insert(data: Row): Promise<WriteResult>`
374
+
375
+ Inserts one row and returns write metadata.
376
+
377
+ ```ts
378
+ const result = await db("users").insert({ email, name });
379
+ result.rowsAffected; // 1
380
+ result.insertId; // driver-dependent
381
+ ```
382
+
383
+ **Notes:** column order follows `Object.keys(data)`. `insertId` is only populated
384
+ if the driver reports it in `WriteResult`.
385
+
386
+ #### `insertGetId(data)`
387
+
388
+ `insertGetId(data: Row): Promise<number | string | undefined>`
389
+
390
+ Inserts one row and returns just its new id (`insert` unwrapped).
391
+
392
+ ```ts
393
+ const id = await db("users").insertGetId({ email, name });
394
+ ```
395
+
396
+ **Notes:** returns `undefined` when the driver doesn't report an `insertId`.
397
+
398
+ #### `update(data)`
399
+
400
+ `update(data: Row): Promise<WriteResult>`
401
+
402
+ Updates every row matching the `where` clause, setting the given columns.
403
+
404
+ ```ts
405
+ const r = await db("users").where("id", 1).update({ name: "Grace" });
406
+ r.rowsAffected; // rows changed
407
+ ```
408
+
409
+ **Notes:** with no `where`, updates the entire table. Bindings are the new values
410
+ followed by the where-clause values.
411
+
412
+ #### `delete()`
413
+
414
+ `delete(): Promise<WriteResult>`
415
+
416
+ Deletes every row matching the `where` clause.
417
+
418
+ ```ts
419
+ await db("sessions").where("expires_at", "<", now).delete();
420
+ ```
421
+
422
+ **Notes:** with no `where`, empties the table. There's no soft-delete here — pair
423
+ with a `deleted_at` column and `whereNull` if you want one.
424
+
425
+ #### `whereColumn(first, operator?, second)` · `whereRaw(sql, bindings?)`
426
+
427
+ Compare two columns (no binding) or add a raw WHERE fragment with its own
428
+ bindings. `whereColumn("updated_at", ">", "created_at")`; `whereRaw("score >= ?", [10])`.
429
+
430
+ #### `join(table, first, operator?, second)` · `leftJoin(...)`
431
+
432
+ Add an `INNER JOIN` / `LEFT JOIN` on an equality (or the given operator).
433
+ Included in `get`, `count`, and aggregates. Qualify ambiguous columns
434
+ (`"posts.user_id"`).
435
+
436
+ #### `groupBy(...columns)` · `having(column, operator?, value)` · `distinct()`
437
+
438
+ `GROUP BY`, a bound `HAVING` predicate, and `SELECT DISTINCT`.
439
+
440
+ #### `orderByRaw(sql)` · `when(condition, then, otherwise?)`
441
+
442
+ A raw `ORDER BY` fragment; and conditional building — `then(query, value)` runs
443
+ only when `condition` is truthy, else `otherwise`.
444
+
445
+ #### `increment(column, amount?, extra?)` · `decrement(column, amount?, extra?)`
446
+
447
+ `increment(column: string, amount = 1, extra: Row = {}): Promise<WriteResult>`
448
+
449
+ Atomically `column = column ± amount` on matching rows, optionally setting other
450
+ columns in the same statement. Scope with `where`.
451
+
452
+ #### `upsert(rows, uniqueBy, update?)`
453
+
454
+ `upsert(rows: Row | Row[], uniqueBy: string[], update?: string[]): Promise<WriteResult>`
455
+
456
+ Insert rows, updating `update` columns (default: all non-unique) on a conflict
457
+ against `uniqueBy`. Dialect-aware: `ON CONFLICT … DO UPDATE` (sqlite/postgres) or
458
+ `ON DUPLICATE KEY UPDATE` (mysql).
459
+
460
+ #### `insertOrIgnore(rows)`
461
+
462
+ Insert one or more rows, skipping any that violate a unique constraint
463
+ (`INSERT OR IGNORE` / `INSERT IGNORE` / `ON CONFLICT DO NOTHING`).
464
+
465
+ #### `chunk(size, callback)`
466
+
467
+ `chunk(size: number, callback: (rows: T[]) => void | boolean | Promise<void | boolean>): Promise<void>`
468
+
469
+ Process results a page at a time so a large table never loads at once. Return
470
+ `false` from the callback to stop early. Pair with `orderBy` for a stable order.
471
+
472
+ #### `addSelect(...columns)` · `selectRaw(sql)`
473
+
474
+ Append columns to the SELECT list without replacing it; `selectRaw` appends a raw
475
+ expression (`selectRaw("SUM(total) AS revenue")`).
476
+
477
+ #### `orWhere` family · `whereNot(...)` · `whereNotBetween(column, [min, max])`
478
+
479
+ Every `where…` clause has an `orWhere…` twin joined with `OR` — `orWhereIn`,
480
+ `orWhereNotIn`, `orWhereNull`, `orWhereNotNull`, `orWhereBetween`,
481
+ `orWhereColumn`, `orWhereLike`, `orWhereRaw`. `whereNot` negates a comparison;
482
+ `whereNotBetween` is the inverse of `whereBetween`. Passing a **callback** to
483
+ `where`/`orWhere` groups its conditions in parentheses.
484
+
485
+ #### `orderByDesc(column)` · `reorder(column?, direction?)` · `inRandomOrder()`
486
+
487
+ Descending order; clear existing ordering (optionally setting a new one); random
488
+ order (dialect-aware `RANDOM()`/`RAND()`).
489
+
490
+ #### `groupByRaw(sql)` · `havingRaw(sql, bindings?)` · `havingBetween(column, [min, max])`
491
+
492
+ Raw `GROUP BY`, a raw/bound `HAVING`, and a `HAVING … BETWEEN`.
493
+
494
+ #### `take(n)` · `skip(n)` · `forPage(page, perPage?)`
495
+
496
+ Aliases for `limit`/`offset`, and limit+offset for a 1-based page.
497
+
498
+ #### `rightJoin(...)` · `crossJoin(table)`
499
+
500
+ `RIGHT JOIN` on an equality; `CROSS JOIN`.
501
+
502
+ #### `unless(condition, then, otherwise?)`
503
+
504
+ The inverse of `when` — runs `then` only when `condition` is falsy.
505
+
506
+ #### `find(id, key?)` · `firstOrFail()` · `sole()` · `doesntExist()` · `implode(column, glue?)`
507
+
508
+ Find by key (default `"id"`); first-or-throw; exactly-one-or-throw; the negation
509
+ of `exists`; and join one column's values into a string.
510
+
511
+ #### `simplePaginate(page?, perPage?)`
512
+
513
+ `simplePaginate(page = 1, perPage = 15): Promise<SimplePaginated<T>>`
514
+
515
+ A page without a `COUNT` — fetches one extra row to set `hasMore`. Cheaper than
516
+ `paginate` for "load more" UIs.
517
+
518
+ #### `lockForUpdate()` · `sharedLock()`
519
+
520
+ Add `FOR UPDATE` / `FOR SHARE` to the SELECT (inside a transaction). Ignored on
521
+ sqlite.
522
+
523
+ #### `updateOrInsert(match, values?)` · `truncate()` · `incrementEach(cols, extra?)` · `decrementEach(cols, extra?)`
524
+
525
+ Update the first match or insert `{ ...match, ...values }`; empty the table
526
+ (`DELETE` on sqlite); and step several numeric columns in one statement (`cols` is
527
+ an array — each by 1 — or a `{ column: amount }` map).
528
+
529
+ #### `toSql()` · `getBindings()` · `dump()` · `dd()`
530
+
531
+ The compiled `?`-placeholder SQL and its bindings, without executing; `dump` logs
532
+ them and returns the builder; `dd` logs and throws.
533
+
@@ -0,0 +1,88 @@
1
+ # Starter kits
2
+
3
+ ```bash
4
+ npm create keeljs@latest my-app -- --preset saas
5
+ ```
6
+
7
+ Four curated applications. Each is a complete, working app — not a scaffold you have
8
+ to finish.
9
+
10
+ | Preset | What you get |
11
+ | --- | --- |
12
+ | `minimal` | Routes, a controller, a JSX view, Tailwind. No database. |
13
+ | `api` | JSON only — models, migrations, validation, tests. No views. |
14
+ | `app` *(default)* | Full-stack: views, sessions, register/login, password reset, two-factor. |
15
+ | `saas` | `app` plus teams, roles, invitations, billing, and multi-tenancy. |
16
+
17
+ ## Every database, Cloudflare first
18
+
19
+ Each kit ships with all four drivers wired. Switching is `DB_CONNECTION` and nothing
20
+ else — no model or query changes, because they talk to a `Connection`, not a driver.
21
+
22
+ | | |
23
+ | --- | --- |
24
+ | **D1** | The default for deploys. Inside the Worker Keel uses the binding; migrations and scripts reach the same database over [the HTTP API](./database.md), so `keel migrate` works from your laptop and from CI. |
25
+ | **SQLite** (libSQL) | A local file. What `npm run dev` uses — no account, no wrangler. |
26
+ | **Turso** | libSQL over the network. |
27
+ | **Postgres** | For when you want it. |
28
+
29
+ Local and production are both SQLite dialects, so one schema and one set of
30
+ migrations serve both.
31
+
32
+ ```bash
33
+ npm run dev # Node, SQLite file, no setup
34
+ npm run dev:edge # wrangler, local D1
35
+ npm run deploy # wrangler deploy
36
+ ```
37
+
38
+ To deploy:
39
+
40
+ ```bash
41
+ wrangler d1 create my-app # paste the id into wrangler.jsonc
42
+ npm run deploy
43
+ ```
44
+
45
+ ## What's in the box
46
+
47
+ `app` and `saas` mount [accounts](./accounts.md), so password reset, email
48
+ verification, and two-factor already work — the flows live in the framework, tested
49
+ once, rather than being copy-pasted into each new app. `saas` also mounts
50
+ [teams](./teams.md) and [billing](./billing.md).
51
+
52
+ In `saas`, a tenant-owned model is one word:
53
+
54
+ ```ts
55
+ import { TenantModel } from "@shaferllc/keel/teams";
56
+
57
+ class Project extends TenantModel {
58
+ static table = "projects";
59
+ }
60
+
61
+ await Project.all(); // only the current team's. Always.
62
+ await Project.create({ name: "Hi" }); // stamped with the current team
63
+ ```
64
+
65
+ Another team's project isn't merely hidden from a list — `Project.find(id)` returns
66
+ `null`. You never write `.where("team_id", …)`, which is what makes it impossible to
67
+ forget.
68
+
69
+ ## Why a generator, and not a template repo
70
+
71
+ Because a second repo rots. The old starter sat pinned to `0.78.2` while the
72
+ framework was on `0.79.0`, and nothing noticed.
73
+
74
+ The templates live **inside the framework package**, so the version a kit is
75
+ generated from is, by construction, the version it was written for. And CI generates
76
+ all four on every push, then typechecks, migrates, boots, serves a request, bundles
77
+ the Worker, and runs their tests — so a breaking change fails in the pull request
78
+ that caused it, not in your `npm create` three weeks later.
79
+
80
+ ## The Node/edge seam
81
+
82
+ Each kit has two provider lists. `bootstrap/providers.ts` runs under Node;
83
+ `bootstrap/providers.edge.ts` runs in the Worker and deliberately **omits the
84
+ database provider** — it reaches for `pg`, which needs `net`/`tls`, and wrangler
85
+ cannot bundle a TCP driver for the edge. `worker.ts` binds D1 before the app boots,
86
+ so nothing on the edge needs to open a connection.
87
+
88
+ If you add a provider that touches a Node-only module, add it to the Node list only.