@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/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
- ## Querying
7762
+ ## Query builder
7763
7763
 
7764
- Start a query with `db(table)`, chain constraints, and finish with a terminal
7765
- method (`get`, `first`, `count`, `exists`):
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").where("active", true).orderBy("name").get();
7771
- await db("users").where("id", 1).first(); // row | null
7772
- await db("users").where("age", ">", 18).count();
7773
- await db("posts").whereIn("id", [1, 2, 3]).get();
7774
- await db("posts").whereNull("deleted_at").limit(20).offset(40).get();
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
- Constraint methods return the builder, so they chain in any order; the query
7784
- isn't sent until you call a terminal method. Multiple `where` calls combine with
7785
- `AND`; `orWhere` joins with `OR`.
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
- More `where` clauses and ordering shortcuts:
7795
+ For large sets, `chunk` pages through without loading everything (return `false`
7796
+ to stop early):
7788
7797
 
7789
7798
  ```ts
7790
- await db("posts").whereBetween("views", [10, 100]).get();
7791
- await db("posts").whereNotIn("id", [4, 5]).get();
7792
- await db("posts").whereLike("title", "%keel%").get();
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
- Joins, grouping, and conditional/raw clauses:
7804
+ ### Aggregates
7798
7805
 
7799
7806
  ```ts
7800
- await db("posts")
7801
- .join("users", "posts.user_id", "users.id") // also leftJoin(...)
7802
- .select("posts.title", "users.name")
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
- await db("posts").select("user_id").groupBy("user_id").having("COUNT(*)", ">", 5).get();
7806
- await db("users").distinct().select("country").pluck("country");
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
- await db("events").whereColumn("updated_at", ">", "created_at").get();
7809
- await db("users").whereRaw("score >= ?", [10]).orderByRaw("LENGTH(name) DESC").get();
7876
+ ### Joins
7810
7877
 
7811
- await db("users").when(search, (q, term) => q.whereLike("name", `%${term}%`)).get();
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
- ## Aggregates, single values, and pagination
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("orders").where("paid", true).sum("total"); // number
7818
- await db("orders").avg("total");
7819
- await db("orders").min("total");
7820
- await db("orders").max("total");
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
- await db("users").where("id", 1).value("email"); // one column, first row
7823
- await db("posts").pluck("title"); // string[] of one column
7901
+ ### Inserts
7824
7902
 
7825
- const page = await db("posts").latest().paginate(2, 15); // { data, total, perPage, currentPage, lastPage }
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
- `paginate(page, perPage)` runs a `COUNT` then a `LIMIT`/`OFFSET` query and returns
7829
- a `Paginated<T>` with the page and the metadata to render pager controls.
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
- ## Writing
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").where("id", id).delete();
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
- Everything is parameterized values become bindings, never string-interpolated
7840
- SQL so it's injection-safe by construction. Writes return a `WriteResult`;
7841
- `insertGetId` unwraps it to just the new id.
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
- Counters, bulk upserts, and paged iteration:
7935
+ ### Pagination
7844
7936
 
7845
7937
  ```ts
7846
- await db("posts").where("id", id).increment("views"); // += 1
7847
- await db("posts").where("id", id).decrement("stock", 3, { updated_at: now });
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
- // Insert, updating the listed columns on a unique-key conflict (dialect-aware).
7850
- await db("users").upsert([{ id: 1, name: "Ada" }], ["id"], ["name"]);
7851
- await db("logs").insertOrIgnore({ key, value }); // skip duplicates
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
- // Process a large table a page at a time (return false to stop early).
7854
- await db("users").orderBy("id").chunk(500, async (rows) => {
7855
- for (const row of rows) await process(row);
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
- > **Guard your writes.** `update()` and `delete()` apply to every row that
7860
- > matches the current `where` clause — with no `where`, that's the whole table.
7861
- > Always scope a write with `where` unless you truly mean to touch every row.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shaferllc/keel",
3
- "version": "0.81.1",
3
+ "version": "0.82.0",
4
4
  "type": "module",
5
5
  "description": "The house framework for Node.js — a service container, providers, routing, JSX views, and a code-generating console.",
6
6
  "license": "MIT",