@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
@@ -56,14 +56,38 @@ export async function memberOf(userId, teamId, atLeast = "member") {
56
56
  * leave the owner out of every membership query.
57
57
  */
58
58
  export async function createTeam(name, ownerId, slug) {
59
- const team = await Team.create({
60
- name,
61
- slug: slug ?? slugify(name),
62
- owner_id: ownerId,
63
- });
59
+ let team;
60
+ // Retry on the unique index, don't just look before leaping.
61
+ //
62
+ // Picking a free slug with a SELECT is a check-then-act race: two people called
63
+ // Ada signing up at the same moment both see "ada-s-team" is free, and one of them
64
+ // gets a constraint error instead of an account. The index is the only real
65
+ // arbiter, so the fix is to let it arbitrate and try again — not to look harder.
66
+ for (let attempt = 0; attempt < 5; attempt++) {
67
+ const candidate = slug ?? (await uniqueSlug(slugify(name)));
68
+ try {
69
+ team = (await Team.create({ name, slug: candidate, owner_id: ownerId }));
70
+ break;
71
+ }
72
+ catch (error) {
73
+ // An explicit slug was asked for and taken: that's the caller's problem.
74
+ if (slug || !isUniqueViolation(error))
75
+ throw error;
76
+ }
77
+ }
78
+ if (!team)
79
+ throw new Error(`Could not find a free slug for "${name}".`);
64
80
  await Membership.create({ team_id: team.id, user_id: ownerId, role: "owner" });
65
81
  return team;
66
82
  }
83
+ /** Every driver phrases it differently, and none of them agree on an error code. */
84
+ function isUniqueViolation(error) {
85
+ const message = String(error?.message ?? error);
86
+ return (/unique/i.test(message) || // sqlite / libsql / d1
87
+ /duplicate key/i.test(message) || // postgres
88
+ /duplicate entry/i.test(message) // mysql
89
+ );
90
+ }
67
91
  /**
68
92
  * Switch which team a user is acting as — **only** to a team they're actually in.
69
93
  *
@@ -77,6 +101,28 @@ export async function switchTeam(userId, teamId, userTable = "users") {
77
101
  await db(userTable).where("id", userId).update({ current_team_id: teamId });
78
102
  return true;
79
103
  }
104
+ /**
105
+ * A slug nobody else has taken.
106
+ *
107
+ * `teams.slug` is unique, and personal teams are named after their owner — so two
108
+ * people called Ada would collide and the second one's signup would 500. Names are
109
+ * not unique and were never going to be; the slug has to make itself so.
110
+ */
111
+ async function uniqueSlug(base) {
112
+ const stem = base || "team";
113
+ // The unique index is still the real guarantee; this just avoids the collision in
114
+ // the common case rather than surfacing a constraint error to someone signing up.
115
+ const taken = new Set((await db(Team.table).where("slug", "like", `${stem}%`).get()).map((row) => String(row.slug)));
116
+ if (!taken.has(stem))
117
+ return stem;
118
+ for (let n = 2; n < 1000; n++) {
119
+ const candidate = `${stem}-${n}`;
120
+ if (!taken.has(candidate))
121
+ return candidate;
122
+ }
123
+ // A thousand teams called the same thing. Fine — stop counting.
124
+ return `${stem}-${crypto.randomUUID().slice(0, 8)}`;
125
+ }
80
126
  function slugify(name) {
81
127
  return name
82
128
  .toLowerCase()
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shaferllc/keel",
3
- "version": "0.81.2",
3
+ "version": "0.83.0",
4
4
  "description": "The house framework for Node.js — a service container, providers, routing, JSX views, and a code-generating console.",
5
5
  "repo": "https://github.com/shaferllc/keel",
6
6
  "generated": "run `npm run build:ai` to regenerate",
@@ -271,6 +271,13 @@
271
271
  "path": "docs/openapi.md",
272
272
  "example": null
273
273
  },
274
+ {
275
+ "slug": "orm",
276
+ "title": "ORM",
277
+ "summary": "Keel's ORM is a compact active record over the query builder: a model is a class pointed at a table, and its rows come back as typed objects with methods.",
278
+ "path": "docs/orm.md",
279
+ "example": null
280
+ },
274
281
  {
275
282
  "slug": "packages",
276
283
  "title": "Packages",
@@ -292,6 +299,13 @@
292
299
  "path": "docs/providers.md",
293
300
  "example": "docs/examples/providers.ts"
294
301
  },
302
+ {
303
+ "slug": "query-builder",
304
+ "title": "Query Builder",
305
+ "summary": "Keel's driver-agnostic query builder — build and run SQL by chaining methods off db(table).",
306
+ "path": "docs/query-builder.md",
307
+ "example": null
308
+ },
295
309
  {
296
310
  "slug": "queues",
297
311
  "title": "Queues & Jobs",
@@ -355,6 +369,13 @@
355
369
  "path": "docs/social-auth.md",
356
370
  "example": null
357
371
  },
372
+ {
373
+ "slug": "starter-kits",
374
+ "title": "Starter kits",
375
+ "summary": "",
376
+ "path": "docs/starter-kits.md",
377
+ "example": null
378
+ },
358
379
  {
359
380
  "slug": "static-files",
360
381
  "title": "Static Files",
@@ -2811,6 +2832,11 @@
2811
2832
  "kind": "value",
2812
2833
  "module": "storage"
2813
2834
  },
2835
+ {
2836
+ "name": "SimplePaginated",
2837
+ "kind": "type",
2838
+ "module": "database"
2839
+ },
2814
2840
  {
2815
2841
  "name": "singleton",
2816
2842
  "kind": "value",
package/docs/database.md CHANGED
@@ -110,106 +110,12 @@ An unregistered connection name doesn't fail when you *build* a query, only when
110
110
  it runs — so a misconfigured name surfaces as a rejected read/write, not a
111
111
  construction-time throw.
112
112
 
113
- ## Querying
113
+ ## Queries & models
114
114
 
115
- Start a query with `db(table)`, chain constraints, and finish with a terminal
116
- method (`get`, `first`, `count`, `exists`):
117
-
118
- ```ts
119
- import { db } from "@shaferllc/keel/core";
120
-
121
- await db("users").where("active", true).orderBy("name").get();
122
- await db("users").where("id", 1).first(); // row | null
123
- await db("users").where("age", ">", 18).count();
124
- await db("posts").whereIn("id", [1, 2, 3]).get();
125
- await db("posts").whereNull("deleted_at").limit(20).offset(40).get();
126
-
127
- await db("orders")
128
- .select("id", "total")
129
- .where("status", "paid")
130
- .orWhere("status", "shipped")
131
- .get();
132
- ```
133
-
134
- Constraint methods return the builder, so they chain in any order; the query
135
- isn't sent until you call a terminal method. Multiple `where` calls combine with
136
- `AND`; `orWhere` joins with `OR`.
137
-
138
- More `where` clauses and ordering shortcuts:
139
-
140
- ```ts
141
- await db("posts").whereBetween("views", [10, 100]).get();
142
- await db("posts").whereNotIn("id", [4, 5]).get();
143
- await db("posts").whereLike("title", "%keel%").get();
144
- await db("posts").latest().get(); // ORDER BY created_at DESC
145
- await db("posts").oldest("published_at").get();
146
- ```
147
-
148
- Joins, grouping, and conditional/raw clauses:
149
-
150
- ```ts
151
- await db("posts")
152
- .join("users", "posts.user_id", "users.id") // also leftJoin(...)
153
- .select("posts.title", "users.name")
154
- .get();
155
-
156
- await db("posts").select("user_id").groupBy("user_id").having("COUNT(*)", ">", 5).get();
157
- await db("users").distinct().select("country").pluck("country");
158
-
159
- await db("events").whereColumn("updated_at", ">", "created_at").get();
160
- await db("users").whereRaw("score >= ?", [10]).orderByRaw("LENGTH(name) DESC").get();
161
-
162
- await db("users").when(search, (q, term) => q.whereLike("name", `%${term}%`)).get();
163
- ```
164
-
165
- ## Aggregates, single values, and pagination
166
-
167
- ```ts
168
- await db("orders").where("paid", true).sum("total"); // number
169
- await db("orders").avg("total");
170
- await db("orders").min("total");
171
- await db("orders").max("total");
172
-
173
- await db("users").where("id", 1).value("email"); // one column, first row
174
- await db("posts").pluck("title"); // string[] of one column
175
-
176
- const page = await db("posts").latest().paginate(2, 15); // { data, total, perPage, currentPage, lastPage }
177
- ```
178
-
179
- `paginate(page, perPage)` runs a `COUNT` then a `LIMIT`/`OFFSET` query and returns
180
- a `Paginated<T>` with the page and the metadata to render pager controls.
181
-
182
- ## Writing
183
-
184
- ```ts
185
- const id = await db("users").insertGetId({ email, name });
186
- await db("users").where("id", id).update({ name: "Grace" });
187
- await db("users").where("id", id).delete();
188
- ```
189
-
190
- Everything is parameterized — values become bindings, never string-interpolated
191
- SQL — so it's injection-safe by construction. Writes return a `WriteResult`;
192
- `insertGetId` unwraps it to just the new id.
193
-
194
- Counters, bulk upserts, and paged iteration:
195
-
196
- ```ts
197
- await db("posts").where("id", id).increment("views"); // += 1
198
- await db("posts").where("id", id).decrement("stock", 3, { updated_at: now });
199
-
200
- // Insert, updating the listed columns on a unique-key conflict (dialect-aware).
201
- await db("users").upsert([{ id: 1, name: "Ada" }], ["id"], ["name"]);
202
- await db("logs").insertOrIgnore({ key, value }); // skip duplicates
203
-
204
- // Process a large table a page at a time (return false to stop early).
205
- await db("users").orderBy("id").chunk(500, async (rows) => {
206
- for (const row of rows) await process(row);
207
- });
208
- ```
209
-
210
- > **Guard your writes.** `update()` and `delete()` apply to every row that
211
- > matches the current `where` clause — with no `where`, that's the whole table.
212
- > Always scope a write with `where` unless you truly mean to touch every row.
115
+ Building and running queries `where`, joins, aggregates, inserts, updates,
116
+ pagination lives in the [Query Builder](./query-builder.md) guide. The
117
+ [ORM](./orm.md) adds an active-record layer (models, relationships, events,
118
+ scopes) on top of the same builder.
213
119
 
214
120
  ## Transactions
215
121
 
@@ -407,255 +313,6 @@ unnamed `db(table)` and connectionless models use (throws if `name` isn't
407
313
  registered). `connectionNames()` returns the registered names.
408
314
  `clearConnections()` unregisters everything — a test helper.
409
315
 
410
- ### `QueryBuilder`
411
-
412
- Returned by `db()`. Constraint methods return `this` (chainable); terminal
413
- methods return a promise. You never construct it directly.
414
-
415
- #### `select(...columns)`
416
-
417
- `select(...columns: string[]): this`
418
-
419
- Restricts the selected columns. With no arguments, selects `*`.
420
-
421
- ```ts
422
- db("users").select("id", "email").get();
423
- ```
424
-
425
- **Notes:** column names are interpolated as-is (they are not parameterized), so
426
- never pass user input as a column name. Calling it again replaces the prior
427
- selection.
428
-
429
- #### `where(column, value)` / `where(column, operator, value)`
430
-
431
- `where(column: string, value: unknown): this`
432
- `where(column: string, operator: Operator, value: unknown): this`
433
-
434
- Adds an `AND` condition. The two-argument form uses `=`; the three-argument form
435
- takes an explicit operator.
436
-
437
- ```ts
438
- db("users").where("active", true);
439
- db("users").where("age", ">", 18);
440
- db("users").where("email", "like", "%@example.com");
441
- ```
442
-
443
- **Notes:** `Operator` is `"=" | "!=" | "<" | "<=" | ">" | ">=" | "like"`. Values
444
- are always parameterized. Chaining multiple `where`s combines them with `AND`.
445
-
446
- #### `orWhere(column, value)` / `orWhere(column, operator, value)`
447
-
448
- `orWhere(column: string, value: unknown): this`
449
- `orWhere(column: string, operator: Operator, value: unknown): this`
450
-
451
- Same as `where`, but joins the condition with `OR`.
452
-
453
- ```ts
454
- db("orders").where("status", "paid").orWhere("status", "shipped").get();
455
- ```
456
-
457
- **Notes:** conditions are combined left-to-right without grouping parentheses, so
458
- mixing `where` and `orWhere` follows SQL's `AND`/`OR` precedence — group complex
459
- logic in separate queries if you need explicit parenthesization.
460
-
461
- #### `whereIn(column, values)`
462
-
463
- `whereIn(column: string, values: unknown[]): this`
464
-
465
- Matches rows where `column` is any of `values` (`AND`-joined).
466
-
467
- ```ts
468
- db("posts").whereIn("id", [1, 2, 3]).get();
469
- ```
470
-
471
- **Notes:** each value becomes its own placeholder. An empty array produces
472
- `IN ()`, which most engines reject — guard against empty lists yourself.
473
-
474
- #### `whereNull(column)` / `whereNotNull(column)`
475
-
476
- `whereNull(column: string): this`
477
- `whereNotNull(column: string): this`
478
-
479
- Adds an `AND` `IS NULL` / `IS NOT NULL` condition — no binding.
480
-
481
- ```ts
482
- db("posts").whereNull("deleted_at").get();
483
- db("users").whereNotNull("verified_at").get();
484
- ```
485
-
486
- #### `orderBy(column, direction?)`
487
-
488
- `orderBy(column: string, direction?: "asc" | "desc"): this`
489
-
490
- Adds an `ORDER BY` clause (default `"asc"`). Call it repeatedly for multiple sort
491
- keys, applied in call order.
492
-
493
- ```ts
494
- db("users").orderBy("last_name").orderBy("created_at", "desc").get();
495
- ```
496
-
497
- **Notes:** the column is interpolated, not parameterized — don't pass user input.
498
-
499
- #### `limit(n)` / `offset(n)`
500
-
501
- `limit(n: number): this`
502
- `offset(n: number): this`
503
-
504
- Caps the number of rows / skips the first `n`. Together they paginate.
505
-
506
- ```ts
507
- db("posts").limit(20).offset(40).get(); // page 3, 20 per page
508
- ```
509
-
510
- **Notes:** `first()` sets `limit(1)` internally, overriding any prior `limit`.
511
-
512
- #### `get()`
513
-
514
- `get(): Promise<T[]>`
515
-
516
- Runs the SELECT and returns all matching rows.
517
-
518
- ```ts
519
- const rows = await db("users").where("active", true).get();
520
- ```
521
-
522
- #### `first()`
523
-
524
- `first(): Promise<T | null>`
525
-
526
- Runs the SELECT with `LIMIT 1` and returns the first row, or `null`.
527
-
528
- ```ts
529
- const user = await db("users").where("email", email).first();
530
- ```
531
-
532
- **Notes:** overrides any `limit` you set. Returns `null` (not `undefined`) when
533
- nothing matches.
534
-
535
- #### `count()`
536
-
537
- `count(): Promise<number>`
538
-
539
- Returns `COUNT(*)` for the current `where` clause.
540
-
541
- ```ts
542
- const active = await db("users").where("active", true).count();
543
- ```
544
-
545
- **Notes:** ignores `select`, `orderBy`, `limit`, and `offset` — it counts matching
546
- rows, not the paginated slice.
547
-
548
- #### `exists()`
549
-
550
- `exists(): Promise<boolean>`
551
-
552
- `true` when at least one row matches — a `count() > 0` shorthand.
553
-
554
- ```ts
555
- if (await db("users").where("email", email).exists()) { /* taken */ }
556
- ```
557
-
558
- #### `insert(data)`
559
-
560
- `insert(data: Row): Promise<WriteResult>`
561
-
562
- Inserts one row and returns write metadata.
563
-
564
- ```ts
565
- const result = await db("users").insert({ email, name });
566
- result.rowsAffected; // 1
567
- result.insertId; // driver-dependent
568
- ```
569
-
570
- **Notes:** column order follows `Object.keys(data)`. `insertId` is only populated
571
- if the driver reports it in `WriteResult`.
572
-
573
- #### `insertGetId(data)`
574
-
575
- `insertGetId(data: Row): Promise<number | string | undefined>`
576
-
577
- Inserts one row and returns just its new id (`insert` unwrapped).
578
-
579
- ```ts
580
- const id = await db("users").insertGetId({ email, name });
581
- ```
582
-
583
- **Notes:** returns `undefined` when the driver doesn't report an `insertId`.
584
-
585
- #### `update(data)`
586
-
587
- `update(data: Row): Promise<WriteResult>`
588
-
589
- Updates every row matching the `where` clause, setting the given columns.
590
-
591
- ```ts
592
- const r = await db("users").where("id", 1).update({ name: "Grace" });
593
- r.rowsAffected; // rows changed
594
- ```
595
-
596
- **Notes:** with no `where`, updates the entire table. Bindings are the new values
597
- followed by the where-clause values.
598
-
599
- #### `delete()`
600
-
601
- `delete(): Promise<WriteResult>`
602
-
603
- Deletes every row matching the `where` clause.
604
-
605
- ```ts
606
- await db("sessions").where("expires_at", "<", now).delete();
607
- ```
608
-
609
- **Notes:** with no `where`, empties the table. There's no soft-delete here — pair
610
- with a `deleted_at` column and `whereNull` if you want one.
611
-
612
- #### `whereColumn(first, operator?, second)` · `whereRaw(sql, bindings?)`
613
-
614
- Compare two columns (no binding) or add a raw WHERE fragment with its own
615
- bindings. `whereColumn("updated_at", ">", "created_at")`; `whereRaw("score >= ?", [10])`.
616
-
617
- #### `join(table, first, operator?, second)` · `leftJoin(...)`
618
-
619
- Add an `INNER JOIN` / `LEFT JOIN` on an equality (or the given operator).
620
- Included in `get`, `count`, and aggregates. Qualify ambiguous columns
621
- (`"posts.user_id"`).
622
-
623
- #### `groupBy(...columns)` · `having(column, operator?, value)` · `distinct()`
624
-
625
- `GROUP BY`, a bound `HAVING` predicate, and `SELECT DISTINCT`.
626
-
627
- #### `orderByRaw(sql)` · `when(condition, then, otherwise?)`
628
-
629
- A raw `ORDER BY` fragment; and conditional building — `then(query, value)` runs
630
- only when `condition` is truthy, else `otherwise`.
631
-
632
- #### `increment(column, amount?, extra?)` · `decrement(column, amount?, extra?)`
633
-
634
- `increment(column: string, amount = 1, extra: Row = {}): Promise<WriteResult>`
635
-
636
- Atomically `column = column ± amount` on matching rows, optionally setting other
637
- columns in the same statement. Scope with `where`.
638
-
639
- #### `upsert(rows, uniqueBy, update?)`
640
-
641
- `upsert(rows: Row | Row[], uniqueBy: string[], update?: string[]): Promise<WriteResult>`
642
-
643
- Insert rows, updating `update` columns (default: all non-unique) on a conflict
644
- against `uniqueBy`. Dialect-aware: `ON CONFLICT … DO UPDATE` (sqlite/postgres) or
645
- `ON DUPLICATE KEY UPDATE` (mysql).
646
-
647
- #### `insertOrIgnore(rows)`
648
-
649
- Insert one or more rows, skipping any that violate a unique constraint
650
- (`INSERT OR IGNORE` / `INSERT IGNORE` / `ON CONFLICT DO NOTHING`).
651
-
652
- #### `chunk(size, callback)`
653
-
654
- `chunk(size: number, callback: (rows: T[]) => void | boolean | Promise<void | boolean>): Promise<void>`
655
-
656
- Process results a page at a time so a large table never loads at once. Return
657
- `false` from the callback to stop early. Pair with `orderBy` for a stable order.
658
-
659
316
  ### Interfaces & types
660
317
 
661
318
  #### `Connection`
package/docs/models.md CHANGED
@@ -205,8 +205,11 @@ User.observe({
205
205
  ```
206
206
 
207
207
  Events: `retrieved`, `creating`/`created`, `updating`/`updated`,
208
- `saving`/`saved`, `deleting`/`deleted`, `restoring`/`restored`. They're keyed by
209
- the exact class (subclasses don't inherit a parent's hooks).
208
+ `saving`/`saved`, `deleting`/`deleted`, `restoring`/`restored`.
209
+
210
+ Hooks **inherit**, ancestors first: a hook on a base class fires for every model
211
+ that extends it. That's what lets a base class do real work — a `creating` hook
212
+ that stamps a tenant id is useless if subclasses never fire it.
210
213
 
211
214
  ## Query scopes
212
215
 
package/docs/orm.md ADDED
@@ -0,0 +1,57 @@
1
+ # ORM
2
+
3
+ Keel's ORM is a compact **active record** over the [query builder](./query-builder.md):
4
+ a model is a class pointed at a table, and its rows come back as typed objects
5
+ with methods. There's no mapper to configure and no separate schema layer — a
6
+ model *is* the row plus behaviour. It runs on whatever [connection](./database.md)
7
+ you registered, so the same code works on Node and the edge.
8
+
9
+ ```ts
10
+ import { Model } from "@shaferllc/keel/core";
11
+
12
+ class User extends Model {
13
+ static table = "users";
14
+ declare id: number;
15
+ declare email: string;
16
+
17
+ posts() { return this.hasMany(Post); }
18
+ }
19
+
20
+ const user = await User.find(1);
21
+ await user.posts(); // relations are awaitable
22
+ if (await user.subscribed()) { /* … */ }
23
+ ```
24
+
25
+ This page is the map; each capability has a deep-dive in **[Models](./models.md)**.
26
+
27
+ ## What the ORM gives you
28
+
29
+ | Area | What you get | Guide |
30
+ |------|--------------|-------|
31
+ | **CRUD** | `find` / `all` / `create` / `save` / `update` / `delete`, `firstOrCreate`, `updateOrCreate` | [Models → Reading/Writing](./models.md#reading) |
32
+ | **Casts** | `boolean` / `int` / `json` / `date` … columns round-trip as real JS types | [Models → Attribute casts](./models.md#attribute-casts) |
33
+ | **Mass assignment** | `fillable` / `guarded` allow/deny lists guard untrusted input | [Models → Mass assignment](./models.md#mass-assignment) |
34
+ | **Serialization** | `hidden` / `visible` / `appends` shape `toJSON()` | [Models → Serializing](./models.md#serializing) |
35
+ | **Relationships** | `hasOne` / `hasMany` / `belongsTo` / `belongsToMany` + polymorphic `morphOne` / `morphMany` / `morphTo` | [Models → Relationships](./models.md#relationships) |
36
+ | **Eager loading** | `with("posts.comments")` (nested), `withCount`, `Model.load` — no N+1 | [Models → Eager loading](./models.md#eager-loading-avoiding-n1) |
37
+ | **Relationship queries** | `whereHas` / `has` / `doesntHave` | [Models → Querying relationships](./models.md#querying-relationships-with-withcount-wherehas) |
38
+ | **Lifecycle events** | `creating`/`saved`/`deleting`/… hooks and observers, inherited by subclasses | [Models → Lifecycle events](./models.md#lifecycle-events) |
39
+ | **Scopes** | global scopes (tenancy, published-only) + local scope methods | [Models → Query scopes](./models.md#query-scopes) |
40
+ | **Soft deletes** | `deleted_at`, `withTrashed` / `onlyTrashed` / `restore` / `forceDelete` | [Models → Soft deletes](./models.md#soft-deletes) |
41
+
42
+ ## How it relates to the rest
43
+
44
+ - The **[query builder](./query-builder.md)** is the layer underneath. `Model.query()`
45
+ returns a model-aware builder, and everything an ORM query can't express
46
+ (raw joins, aggregates, bulk writes) is one `db()` call away.
47
+ - **[Migrations](./migrations.md)** define the tables models read and write.
48
+ - **[Factories & seeders](./factories.md)** generate model rows for tests and demos.
49
+ - **[API resources](./api-resources.md)** turn models into a REST API; **[transformers](./transformers.md)**
50
+ control their serialized shape at the boundary.
51
+
52
+ ## When to drop down
53
+
54
+ The ORM is deliberately small — enough for CRUD, relationships, and the common
55
+ query shapes without an ORM dependency. For a gnarly one-off report, reach for
56
+ the [query builder](./query-builder.md) or a raw `connection().select(sql)`; the
57
+ model layer never gets in the way.