@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.
- package/dist/core/database.d.ts +83 -0
- package/dist/core/database.js +248 -4
- package/dist/core/index.d.ts +1 -1
- package/dist/db/d1-http.d.ts +34 -0
- package/dist/db/d1-http.js +61 -0
- package/dist/db/libsql.d.ts +13 -3
- package/dist/teams/models.js +51 -5
- package/docs/ai-manifest.json +27 -1
- package/docs/database.md +5 -348
- package/docs/models.md +5 -2
- package/docs/orm.md +57 -0
- package/docs/query-builder.md +533 -0
- package/docs/starter-kits.md +88 -0
- package/llms-full.txt +4599 -4241
- package/llms.txt +3 -0
- package/package.json +7 -2
- package/templates/api/.env.example +12 -0
- package/templates/api/app/Controllers/PostController.ts +37 -0
- package/templates/api/app/Http/Kernel.ts +13 -0
- package/templates/api/app/Http/Middleware/requestLogger.ts +8 -0
- package/templates/api/app/Models/Post.ts +12 -0
- package/templates/api/app/Providers/AppServiceProvider.ts +8 -0
- package/templates/api/app/Providers/DatabaseServiceProvider.ts +52 -0
- package/templates/api/bin/keel.ts +15 -0
- package/templates/api/bootstrap/app.ts +24 -0
- package/templates/api/bootstrap/providers.edge.ts +14 -0
- package/templates/api/bootstrap/providers.ts +7 -0
- package/templates/api/config/app.ts +10 -0
- package/templates/api/config/database.ts +42 -0
- package/templates/api/database/migrations/0001_create_posts.ts +20 -0
- package/templates/api/package.json +30 -0
- package/templates/api/routes/web.ts +12 -0
- package/templates/api/tests/posts.test.ts +30 -0
- package/templates/api/tsconfig.json +16 -0
- package/templates/api/worker.ts +33 -0
- package/templates/api/wrangler.jsonc +22 -0
- package/templates/app/.env.example +12 -0
- package/templates/app/app/Controllers/AuthController.ts +117 -0
- package/templates/app/app/Controllers/DashboardController.ts +37 -0
- package/templates/app/app/Controllers/HomeController.ts +10 -0
- package/templates/app/app/Http/Kernel.ts +21 -0
- package/templates/app/app/Http/Middleware/requestLogger.ts +8 -0
- package/templates/app/app/Models/User.ts +15 -0
- package/templates/app/app/Providers/AppServiceProvider.ts +12 -0
- package/templates/app/app/Providers/DatabaseServiceProvider.ts +52 -0
- package/templates/app/bin/keel.ts +15 -0
- package/templates/app/bootstrap/app.ts +24 -0
- package/templates/app/bootstrap/providers.edge.ts +15 -0
- package/templates/app/bootstrap/providers.ts +18 -0
- package/templates/app/config/app.ts +10 -0
- package/templates/app/config/database.ts +42 -0
- package/templates/app/database/migrations/0001_create_users.ts +21 -0
- package/templates/app/package.json +35 -0
- package/templates/app/public/.gitkeep +2 -0
- package/templates/app/resources/css/app.css +1 -0
- package/templates/app/resources/views/auth/forgot.tsx +30 -0
- package/templates/app/resources/views/auth/login.tsx +24 -0
- package/templates/app/resources/views/auth/register.tsx +24 -0
- package/templates/app/resources/views/auth/two-factor.tsx +22 -0
- package/templates/app/resources/views/dashboard.tsx +20 -0
- package/templates/app/resources/views/layout.tsx +15 -0
- package/templates/app/resources/views/welcome.tsx +29 -0
- package/templates/app/routes/web.ts +35 -0
- package/templates/app/tests/auth.test.ts +47 -0
- package/templates/app/tsconfig.json +31 -0
- package/templates/app/worker.ts +33 -0
- package/templates/app/wrangler.jsonc +25 -0
- package/templates/minimal/.env.example +8 -0
- package/templates/minimal/app/Controllers/HomeController.ts +14 -0
- package/templates/minimal/app/Http/Kernel.ts +22 -0
- package/templates/minimal/app/Http/Middleware/requestLogger.ts +8 -0
- package/templates/minimal/app/Providers/AppServiceProvider.ts +8 -0
- package/templates/minimal/bin/keel.ts +15 -0
- package/templates/minimal/bootstrap/app.ts +24 -0
- package/templates/minimal/bootstrap/providers.ts +6 -0
- package/templates/minimal/config/app.ts +10 -0
- package/templates/minimal/package.json +29 -0
- package/templates/minimal/public/.gitkeep +2 -0
- package/templates/minimal/resources/css/app.css +1 -0
- package/templates/minimal/resources/views/layout.tsx +15 -0
- package/templates/minimal/resources/views/welcome.tsx +15 -0
- package/templates/minimal/routes/web.ts +13 -0
- package/templates/minimal/tsconfig.json +18 -0
- package/templates/minimal/worker.ts +23 -0
- package/templates/minimal/wrangler.jsonc +10 -0
- package/templates/saas/.env.example +12 -0
- package/templates/saas/app/Controllers/AuthController.ts +125 -0
- package/templates/saas/app/Controllers/DashboardController.ts +37 -0
- package/templates/saas/app/Controllers/HomeController.ts +10 -0
- package/templates/saas/app/Controllers/TeamController.ts +88 -0
- package/templates/saas/app/Http/Kernel.ts +27 -0
- package/templates/saas/app/Http/Middleware/requestLogger.ts +8 -0
- package/templates/saas/app/Models/Project.ts +20 -0
- package/templates/saas/app/Models/User.ts +15 -0
- package/templates/saas/app/Providers/AppServiceProvider.ts +12 -0
- package/templates/saas/app/Providers/DatabaseServiceProvider.ts +52 -0
- package/templates/saas/bin/keel.ts +15 -0
- package/templates/saas/bootstrap/app.ts +24 -0
- package/templates/saas/bootstrap/providers.edge.ts +18 -0
- package/templates/saas/bootstrap/providers.ts +22 -0
- package/templates/saas/config/app.ts +10 -0
- package/templates/saas/config/database.ts +42 -0
- package/templates/saas/database/migrations/0001_create_users.ts +21 -0
- package/templates/saas/database/migrations/0002_create_projects.ts +20 -0
- package/templates/saas/package.json +35 -0
- package/templates/saas/public/.gitkeep +2 -0
- package/templates/saas/resources/css/app.css +1 -0
- package/templates/saas/resources/views/auth/forgot.tsx +30 -0
- package/templates/saas/resources/views/auth/login.tsx +24 -0
- package/templates/saas/resources/views/auth/register.tsx +24 -0
- package/templates/saas/resources/views/auth/two-factor.tsx +22 -0
- package/templates/saas/resources/views/dashboard.tsx +20 -0
- package/templates/saas/resources/views/layout.tsx +15 -0
- package/templates/saas/resources/views/teams/index.tsx +85 -0
- package/templates/saas/resources/views/welcome.tsx +29 -0
- package/templates/saas/routes/web.ts +44 -0
- package/templates/saas/tests/auth.test.ts +47 -0
- package/templates/saas/tests/teams.test.ts +27 -0
- package/templates/saas/tsconfig.json +31 -0
- package/templates/saas/worker.ts +33 -0
- package/templates/saas/wrangler.jsonc +25 -0
package/dist/teams/models.js
CHANGED
|
@@ -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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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()
|
package/docs/ai-manifest.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shaferllc/keel",
|
|
3
|
-
"version": "0.
|
|
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
|
-
##
|
|
113
|
+
## Queries & models
|
|
114
114
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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`.
|
|
209
|
-
|
|
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.
|