@shaferllc/keel 0.81.1 → 0.82.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/docs/ai-manifest.json +13 -1
- package/docs/changelog.md +2095 -0
- package/docs/database.md +233 -59
- package/llms-full.txt +233 -59
- package/llms.txt +1 -0
- package/package.json +1 -1
package/llms-full.txt
CHANGED
|
@@ -7759,106 +7759,218 @@ An unregistered connection name doesn't fail when you *build* a query, only when
|
|
|
7759
7759
|
it runs — so a misconfigured name surfaces as a rejected read/write, not a
|
|
7760
7760
|
construction-time throw.
|
|
7761
7761
|
|
|
7762
|
-
##
|
|
7762
|
+
## Query builder
|
|
7763
7763
|
|
|
7764
|
-
Start a query with `db(table)`, chain constraints
|
|
7765
|
-
method
|
|
7764
|
+
Start a query with `db(table)`, chain constraints (they return the builder, so
|
|
7765
|
+
order doesn't matter), and finish with a terminal method. Nothing hits the
|
|
7766
|
+
database until a terminal runs. Every value becomes a **binding**, never
|
|
7767
|
+
string-interpolated SQL — the builder is injection-safe by construction. It's
|
|
7768
|
+
driver-agnostic and edge-safe: the same chain compiles for sqlite, MySQL, and
|
|
7769
|
+
Postgres.
|
|
7766
7770
|
|
|
7767
7771
|
```ts
|
|
7768
7772
|
import { db } from "@shaferllc/keel/core";
|
|
7769
7773
|
|
|
7770
|
-
await db("users")
|
|
7771
|
-
|
|
7772
|
-
|
|
7773
|
-
|
|
7774
|
-
|
|
7775
|
-
|
|
7776
|
-
await db("orders")
|
|
7777
|
-
.select("id", "total")
|
|
7778
|
-
.where("status", "paid")
|
|
7779
|
-
.orWhere("status", "shipped")
|
|
7774
|
+
const active = await db("users")
|
|
7775
|
+
.where("active", true)
|
|
7776
|
+
.where("age", ">", 18)
|
|
7777
|
+
.orderBy("name")
|
|
7778
|
+
.limit(20)
|
|
7780
7779
|
.get();
|
|
7781
7780
|
```
|
|
7782
7781
|
|
|
7783
|
-
|
|
7784
|
-
|
|
7785
|
-
|
|
7782
|
+
### Retrieving results
|
|
7783
|
+
|
|
7784
|
+
```ts
|
|
7785
|
+
await db("users").get(); // Row[]
|
|
7786
|
+
await db("users").where("id", 1).first(); // Row | null
|
|
7787
|
+
await db("users").where("id", 1).firstOrFail(); // Row, or throws NotFoundException
|
|
7788
|
+
await db("users").find(1); // by primary key (default "id")
|
|
7789
|
+
await db("users").where("email", e).sole(); // exactly one, else throws
|
|
7790
|
+
await db("users").where("id", 1).value("email"); // one column of the first row
|
|
7791
|
+
await db("posts").pluck("title"); // string[] of one column
|
|
7792
|
+
await db("tags").orderBy("name").implode("name", ", "); // "a, b, c"
|
|
7793
|
+
```
|
|
7786
7794
|
|
|
7787
|
-
|
|
7795
|
+
For large sets, `chunk` pages through without loading everything (return `false`
|
|
7796
|
+
to stop early):
|
|
7788
7797
|
|
|
7789
7798
|
```ts
|
|
7790
|
-
await db("
|
|
7791
|
-
|
|
7792
|
-
|
|
7793
|
-
await db("posts").latest().get(); // ORDER BY created_at DESC
|
|
7794
|
-
await db("posts").oldest("published_at").get();
|
|
7799
|
+
await db("users").orderBy("id").chunk(500, async (rows) => {
|
|
7800
|
+
for (const row of rows) await process(row);
|
|
7801
|
+
});
|
|
7795
7802
|
```
|
|
7796
7803
|
|
|
7797
|
-
|
|
7804
|
+
### Aggregates
|
|
7798
7805
|
|
|
7799
7806
|
```ts
|
|
7800
|
-
await db("
|
|
7801
|
-
|
|
7802
|
-
|
|
7807
|
+
await db("orders").count();
|
|
7808
|
+
await db("orders").where("paid", true).sum("total");
|
|
7809
|
+
await db("orders").avg("total"); // also min(col), max(col)
|
|
7810
|
+
await db("users").where("email", e).exists(); // boolean
|
|
7811
|
+
await db("users").where("banned", true).doesntExist();
|
|
7812
|
+
```
|
|
7813
|
+
|
|
7814
|
+
### Selects
|
|
7815
|
+
|
|
7816
|
+
```ts
|
|
7817
|
+
db("users").select("id", "email");
|
|
7818
|
+
db("users").select("id").addSelect("email"); // append, don't replace
|
|
7819
|
+
db("orders").selectRaw("SUM(total) AS revenue");
|
|
7820
|
+
db("users").distinct().select("country");
|
|
7821
|
+
```
|
|
7822
|
+
|
|
7823
|
+
### Where clauses
|
|
7824
|
+
|
|
7825
|
+
```ts
|
|
7826
|
+
db("users").where("votes", 100); // = is the default operator
|
|
7827
|
+
db("users").where("votes", ">=", 100);
|
|
7828
|
+
db("users").where("name", "like", "T%");
|
|
7829
|
+
|
|
7830
|
+
db("users").where("votes", 100).orWhere("name", "John");
|
|
7831
|
+
db("users").whereNot("status", "cancelled");
|
|
7832
|
+
|
|
7833
|
+
db("users").whereIn("id", [1, 2, 3]).whereNotIn("id", [4]);
|
|
7834
|
+
db("users").whereNull("deleted_at").whereNotNull("email_verified_at");
|
|
7835
|
+
db("products").whereBetween("price", [10, 100]).whereNotBetween("stock", [0, 5]);
|
|
7836
|
+
db("posts").whereLike("title", "%keel%");
|
|
7837
|
+
db("events").whereColumn("updated_at", ">", "created_at"); // column vs column
|
|
7838
|
+
db("users").whereRaw("score >= ? AND score <= ?", [10, 90]);
|
|
7839
|
+
```
|
|
7840
|
+
|
|
7841
|
+
Every clause has an `orWhere…` twin — `orWhereIn`, `orWhereNull`,
|
|
7842
|
+
`orWhereNotNull`, `orWhereBetween`, `orWhereColumn`, `orWhereLike`, `orWhereRaw`,
|
|
7843
|
+
`orWhereNotIn`.
|
|
7844
|
+
|
|
7845
|
+
**Grouped clauses.** Pass a callback to `where`/`orWhere` to parenthesize a set
|
|
7846
|
+
of conditions — the way to express `A AND (B OR C)`:
|
|
7847
|
+
|
|
7848
|
+
```ts
|
|
7849
|
+
await db("users")
|
|
7850
|
+
.where("active", true)
|
|
7851
|
+
.where((q) => q.where("role", "admin").orWhere("role", "owner"))
|
|
7803
7852
|
.get();
|
|
7853
|
+
// … WHERE active = ? AND (role = ? OR role = ?)
|
|
7854
|
+
```
|
|
7855
|
+
|
|
7856
|
+
### Ordering, grouping, limit & offset
|
|
7804
7857
|
|
|
7805
|
-
|
|
7806
|
-
|
|
7858
|
+
```ts
|
|
7859
|
+
db("users").orderBy("name").orderByDesc("created_at");
|
|
7860
|
+
db("posts").latest(); // ORDER BY created_at DESC (oldest() for ASC)
|
|
7861
|
+
db("posts").orderByRaw("LENGTH(title) DESC");
|
|
7862
|
+
db("users").inRandomOrder(); // dialect-aware RANDOM()/RAND()
|
|
7863
|
+
db("users").reorder("name"); // clear existing ordering, then set
|
|
7864
|
+
|
|
7865
|
+
db("orders")
|
|
7866
|
+
.select("user_id")
|
|
7867
|
+
.selectRaw("SUM(total) AS spent")
|
|
7868
|
+
.groupBy("user_id")
|
|
7869
|
+
.having("spent", ">", 1000) // also havingRaw(...), havingBetween(...)
|
|
7870
|
+
.get();
|
|
7871
|
+
|
|
7872
|
+
db("users").limit(10).offset(20); // take(10)/skip(20) are aliases
|
|
7873
|
+
db("users").forPage(3, 15); // page 3, 15 per page
|
|
7874
|
+
```
|
|
7807
7875
|
|
|
7808
|
-
|
|
7809
|
-
await db("users").whereRaw("score >= ?", [10]).orderByRaw("LENGTH(name) DESC").get();
|
|
7876
|
+
### Joins
|
|
7810
7877
|
|
|
7811
|
-
|
|
7878
|
+
```ts
|
|
7879
|
+
await db("posts")
|
|
7880
|
+
.join("users", "posts.user_id", "users.id") // INNER JOIN on equality
|
|
7881
|
+
.leftJoin("images", "images.post_id", "posts.id")
|
|
7882
|
+
.select("posts.title", "users.name")
|
|
7883
|
+
.get();
|
|
7812
7884
|
```
|
|
7813
7885
|
|
|
7814
|
-
|
|
7886
|
+
`rightJoin` and `crossJoin` round out the set. Joins with several `ON`
|
|
7887
|
+
conditions aren't modelled — use `whereRaw` or a view.
|
|
7888
|
+
|
|
7889
|
+
### Conditional clauses
|
|
7890
|
+
|
|
7891
|
+
`when` / `unless` apply a callback based on a runtime value, so you build a query
|
|
7892
|
+
without breaking the chain into `if`s. The callback receives the value:
|
|
7815
7893
|
|
|
7816
7894
|
```ts
|
|
7817
|
-
await db("
|
|
7818
|
-
|
|
7819
|
-
|
|
7820
|
-
|
|
7895
|
+
await db("users")
|
|
7896
|
+
.when(search, (q, term) => q.whereLike("name", `%${term}%`))
|
|
7897
|
+
.unless(includeArchived, (q) => q.whereNull("archived_at"))
|
|
7898
|
+
.get();
|
|
7899
|
+
```
|
|
7821
7900
|
|
|
7822
|
-
|
|
7823
|
-
await db("posts").pluck("title"); // string[] of one column
|
|
7901
|
+
### Inserts
|
|
7824
7902
|
|
|
7825
|
-
|
|
7903
|
+
```ts
|
|
7904
|
+
await db("users").insert({ email, name });
|
|
7905
|
+
const id = await db("users").insertGetId({ email, name }); // new primary key
|
|
7906
|
+
await db("logs").insertOrIgnore({ key, value }); // skip unique conflicts
|
|
7907
|
+
await db("users").upsert([{ id: 1, name: "Ada" }], ["id"], ["name"]); // insert/update
|
|
7826
7908
|
```
|
|
7827
7909
|
|
|
7828
|
-
`
|
|
7829
|
-
|
|
7910
|
+
`upsert(rows, uniqueBy, update?)` inserts, updating the `update` columns (default:
|
|
7911
|
+
everything not in `uniqueBy`) on a conflict — dialect-aware (`ON CONFLICT` /
|
|
7912
|
+
`ON DUPLICATE KEY UPDATE`).
|
|
7830
7913
|
|
|
7831
|
-
|
|
7914
|
+
### Updates
|
|
7832
7915
|
|
|
7833
7916
|
```ts
|
|
7834
|
-
const id = await db("users").insertGetId({ email, name });
|
|
7835
7917
|
await db("users").where("id", id).update({ name: "Grace" });
|
|
7836
|
-
await db("users").
|
|
7918
|
+
await db("users").updateOrInsert({ email }, { name }); // update match, else insert
|
|
7919
|
+
await db("posts").where("id", id).increment("views"); // += 1
|
|
7920
|
+
await db("posts").where("id", id).decrement("stock", 3, { updated_at: now });
|
|
7921
|
+
await db("counters").incrementEach({ hits: 1, misses: 2 }); // several columns at once
|
|
7922
|
+
```
|
|
7923
|
+
|
|
7924
|
+
### Deletes
|
|
7925
|
+
|
|
7926
|
+
```ts
|
|
7927
|
+
await db("sessions").where("expires_at", "<", now).delete();
|
|
7928
|
+
await db("cache").truncate(); // empty the table (DELETE on sqlite)
|
|
7837
7929
|
```
|
|
7838
7930
|
|
|
7839
|
-
|
|
7840
|
-
|
|
7841
|
-
|
|
7931
|
+
> **Guard your writes.** `update()`, `delete()`, and the increments apply to
|
|
7932
|
+
> every row matching the current `where` clause — with none, that's the whole
|
|
7933
|
+
> table. Scope every write unless you truly mean to touch every row.
|
|
7842
7934
|
|
|
7843
|
-
|
|
7935
|
+
### Pagination
|
|
7844
7936
|
|
|
7845
7937
|
```ts
|
|
7846
|
-
await db("posts").
|
|
7847
|
-
|
|
7938
|
+
const page = await db("posts").latest().paginate(2, 15);
|
|
7939
|
+
// { data, total, perPage, currentPage, lastPage } — a COUNT plus a page query
|
|
7848
7940
|
|
|
7849
|
-
|
|
7850
|
-
|
|
7851
|
-
|
|
7941
|
+
const feed = await db("posts").latest().simplePaginate(2, 15);
|
|
7942
|
+
// { data, perPage, currentPage, hasMore } — no COUNT; one extra row tells hasMore
|
|
7943
|
+
```
|
|
7852
7944
|
|
|
7853
|
-
|
|
7854
|
-
|
|
7855
|
-
|
|
7945
|
+
### Pessimistic locking
|
|
7946
|
+
|
|
7947
|
+
Inside a [transaction](#transactions), lock the selected rows against concurrent
|
|
7948
|
+
writes. No-ops on sqlite (which locks the whole database anyway):
|
|
7949
|
+
|
|
7950
|
+
```ts
|
|
7951
|
+
await transaction(async () => {
|
|
7952
|
+
const row = await db("accounts").where("id", id).lockForUpdate().first(); // FOR UPDATE
|
|
7953
|
+
await db("accounts").where("id", id).update({ balance: row.balance - 10 });
|
|
7856
7954
|
});
|
|
7955
|
+
// sharedLock() takes a read lock (FOR SHARE) instead.
|
|
7857
7956
|
```
|
|
7858
7957
|
|
|
7859
|
-
|
|
7860
|
-
|
|
7861
|
-
|
|
7958
|
+
### Debugging
|
|
7959
|
+
|
|
7960
|
+
```ts
|
|
7961
|
+
db("users").where("active", true).toSql(); // "SELECT * FROM users WHERE active = ?"
|
|
7962
|
+
db("users").where("active", true).getBindings(); // [true]
|
|
7963
|
+
db("users").where("active", true).dump(); // logs SQL + bindings, returns the builder
|
|
7964
|
+
db("users").where("active", true).dd(); // logs and throws (dump-and-die)
|
|
7965
|
+
```
|
|
7966
|
+
|
|
7967
|
+
### Not (yet) modelled
|
|
7968
|
+
|
|
7969
|
+
Kept out on purpose, to stay driver-agnostic and honest about what compiles
|
|
7970
|
+
everywhere: unions, subquery `where`/join builders (`whereExists`, `joinSub`),
|
|
7971
|
+
the `whereDate`/`whereMonth`/… date-function family (no portable form across
|
|
7972
|
+
dialects), and `cursor`/`lazy` streaming. Reach for `whereRaw`, a raw
|
|
7973
|
+
`connection().select(sql)`, or a database view when you need them.
|
|
7862
7974
|
|
|
7863
7975
|
## Transactions
|
|
7864
7976
|
|
|
@@ -8305,6 +8417,68 @@ Insert one or more rows, skipping any that violate a unique constraint
|
|
|
8305
8417
|
Process results a page at a time so a large table never loads at once. Return
|
|
8306
8418
|
`false` from the callback to stop early. Pair with `orderBy` for a stable order.
|
|
8307
8419
|
|
|
8420
|
+
#### `addSelect(...columns)` · `selectRaw(sql)`
|
|
8421
|
+
|
|
8422
|
+
Append columns to the SELECT list without replacing it; `selectRaw` appends a raw
|
|
8423
|
+
expression (`selectRaw("SUM(total) AS revenue")`).
|
|
8424
|
+
|
|
8425
|
+
#### `orWhere` family · `whereNot(...)` · `whereNotBetween(column, [min, max])`
|
|
8426
|
+
|
|
8427
|
+
Every `where…` clause has an `orWhere…` twin joined with `OR` — `orWhereIn`,
|
|
8428
|
+
`orWhereNotIn`, `orWhereNull`, `orWhereNotNull`, `orWhereBetween`,
|
|
8429
|
+
`orWhereColumn`, `orWhereLike`, `orWhereRaw`. `whereNot` negates a comparison;
|
|
8430
|
+
`whereNotBetween` is the inverse of `whereBetween`. Passing a **callback** to
|
|
8431
|
+
`where`/`orWhere` groups its conditions in parentheses.
|
|
8432
|
+
|
|
8433
|
+
#### `orderByDesc(column)` · `reorder(column?, direction?)` · `inRandomOrder()`
|
|
8434
|
+
|
|
8435
|
+
Descending order; clear existing ordering (optionally setting a new one); random
|
|
8436
|
+
order (dialect-aware `RANDOM()`/`RAND()`).
|
|
8437
|
+
|
|
8438
|
+
#### `groupByRaw(sql)` · `havingRaw(sql, bindings?)` · `havingBetween(column, [min, max])`
|
|
8439
|
+
|
|
8440
|
+
Raw `GROUP BY`, a raw/bound `HAVING`, and a `HAVING … BETWEEN`.
|
|
8441
|
+
|
|
8442
|
+
#### `take(n)` · `skip(n)` · `forPage(page, perPage?)`
|
|
8443
|
+
|
|
8444
|
+
Aliases for `limit`/`offset`, and limit+offset for a 1-based page.
|
|
8445
|
+
|
|
8446
|
+
#### `rightJoin(...)` · `crossJoin(table)`
|
|
8447
|
+
|
|
8448
|
+
`RIGHT JOIN` on an equality; `CROSS JOIN`.
|
|
8449
|
+
|
|
8450
|
+
#### `unless(condition, then, otherwise?)`
|
|
8451
|
+
|
|
8452
|
+
The inverse of `when` — runs `then` only when `condition` is falsy.
|
|
8453
|
+
|
|
8454
|
+
#### `find(id, key?)` · `firstOrFail()` · `sole()` · `doesntExist()` · `implode(column, glue?)`
|
|
8455
|
+
|
|
8456
|
+
Find by key (default `"id"`); first-or-throw; exactly-one-or-throw; the negation
|
|
8457
|
+
of `exists`; and join one column's values into a string.
|
|
8458
|
+
|
|
8459
|
+
#### `simplePaginate(page?, perPage?)`
|
|
8460
|
+
|
|
8461
|
+
`simplePaginate(page = 1, perPage = 15): Promise<SimplePaginated<T>>`
|
|
8462
|
+
|
|
8463
|
+
A page without a `COUNT` — fetches one extra row to set `hasMore`. Cheaper than
|
|
8464
|
+
`paginate` for "load more" UIs.
|
|
8465
|
+
|
|
8466
|
+
#### `lockForUpdate()` · `sharedLock()`
|
|
8467
|
+
|
|
8468
|
+
Add `FOR UPDATE` / `FOR SHARE` to the SELECT (inside a transaction). Ignored on
|
|
8469
|
+
sqlite.
|
|
8470
|
+
|
|
8471
|
+
#### `updateOrInsert(match, values?)` · `truncate()` · `incrementEach(cols, extra?)` · `decrementEach(cols, extra?)`
|
|
8472
|
+
|
|
8473
|
+
Update the first match or insert `{ ...match, ...values }`; empty the table
|
|
8474
|
+
(`DELETE` on sqlite); and step several numeric columns in one statement (`cols` is
|
|
8475
|
+
an array — each by 1 — or a `{ column: amount }` map).
|
|
8476
|
+
|
|
8477
|
+
#### `toSql()` · `getBindings()` · `dump()` · `dd()`
|
|
8478
|
+
|
|
8479
|
+
The compiled `?`-placeholder SQL and its bindings, without executing; `dump` logs
|
|
8480
|
+
them and returns the builder; `dd` logs and throws.
|
|
8481
|
+
|
|
8308
8482
|
### Interfaces & types
|
|
8309
8483
|
|
|
8310
8484
|
#### `Connection`
|
package/llms.txt
CHANGED
|
@@ -131,3 +131,4 @@ Every topic has a runnable, type-checked example:
|
|
|
131
131
|
- [Full text of all docs](https://github.com/shaferllc/keel/blob/main/llms-full.txt): every guide concatenated into one file
|
|
132
132
|
- [AGENTS.md](https://github.com/shaferllc/keel/blob/main/AGENTS.md): conventions and workflow for AI agents editing a Keel app
|
|
133
133
|
- [README](https://github.com/shaferllc/keel/blob/main/README.md): project overview
|
|
134
|
+
- [Changelog](https://github.com/shaferllc/keel/blob/main/CHANGELOG.md): release history, newest first
|
package/package.json
CHANGED