@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.
- 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 +22 -1
- package/docs/database.md +5 -522
- 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 +5299 -5115
- 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
|
@@ -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.
|