@shaferllc/keel 0.83.0 → 0.83.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +2 -2
  2. package/dist/billing/billable.d.ts +2 -3
  3. package/dist/billing/billable.js +2 -3
  4. package/dist/billing/billing.config.stub +1 -1
  5. package/dist/billing/builder.d.ts +2 -2
  6. package/dist/billing/builder.js +2 -2
  7. package/dist/billing/provider.d.ts +1 -1
  8. package/dist/billing/provider.js +2 -2
  9. package/dist/billing/subscription-item.d.ts +0 -1
  10. package/dist/billing/subscription-item.js +0 -1
  11. package/dist/core/decorators.d.ts +5 -7
  12. package/dist/core/decorators.js +5 -7
  13. package/dist/core/exceptions.d.ts +2 -3
  14. package/dist/core/exceptions.js +2 -3
  15. package/dist/core/model-query.d.ts +1 -1
  16. package/dist/core/model-query.js +1 -1
  17. package/dist/core/request-logger.d.ts +2 -2
  18. package/dist/core/request-logger.js +2 -2
  19. package/dist/core/request.js +1 -1
  20. package/dist/core/testing.d.ts +3 -3
  21. package/dist/core/testing.js +3 -3
  22. package/docs/ai-manifest.json +14 -14
  23. package/docs/billing.md +45 -6
  24. package/docs/changelog.md +124 -21
  25. package/docs/cors.md +28 -0
  26. package/docs/decorators.md +2 -6
  27. package/docs/errors.md +1 -1
  28. package/docs/examples/billing.ts +128 -0
  29. package/docs/examples/cors.ts +29 -0
  30. package/docs/examples/hono.ts +29 -0
  31. package/docs/examples/openapi.ts +35 -0
  32. package/docs/examples/orm.ts +30 -0
  33. package/docs/examples/packages.ts +26 -0
  34. package/docs/examples/query-builder.ts +118 -0
  35. package/docs/examples/security.ts +31 -0
  36. package/docs/examples/social-auth.ts +76 -0
  37. package/docs/examples/starter-kits.ts +14 -0
  38. package/docs/examples/watch.ts +10 -0
  39. package/docs/hooks.md +1 -3
  40. package/docs/logger.md +2 -2
  41. package/docs/orm.md +39 -0
  42. package/docs/packages.md +3 -3
  43. package/docs/providers.md +6 -6
  44. package/docs/request-response.md +2 -2
  45. package/docs/starter-kits.md +14 -0
  46. package/docs/testing.md +1 -2
  47. package/docs/validation.md +1 -2
  48. package/docs/watch.md +1 -1
  49. package/llms-full.txt +145 -34
  50. package/llms.txt +13 -2
  51. package/package.json +1 -1
@@ -0,0 +1,118 @@
1
+ // Type-check harness for docs/query-builder.md. Compile-only — never executed.
2
+ import { db, type Row } from "@shaferllc/keel/core";
3
+
4
+ declare const email: string;
5
+ declare const name: string;
6
+ declare const id: number;
7
+ declare const now: number;
8
+ declare const search: string | undefined;
9
+ declare const includeArchived: boolean;
10
+ declare const key: string;
11
+ declare const value: string;
12
+
13
+ export async function retrieving() {
14
+ await db("users").get();
15
+ await db("users").where("id", 1).first();
16
+ await db("users").where("id", 1).firstOrFail();
17
+ await db("users").find(1);
18
+ await db("users").where("email", email).sole();
19
+ await db("users").where("id", 1).value("email");
20
+ await db("posts").pluck("title");
21
+ await db("tags").orderBy("name").implode("name", ", ");
22
+
23
+ await db("users")
24
+ .orderBy("id")
25
+ .chunk(500, async (rows) => {
26
+ for (const _row of rows) {
27
+ /* process */
28
+ }
29
+ });
30
+ }
31
+
32
+ export async function aggregates() {
33
+ await db("orders").count();
34
+ await db("orders").where("paid", true).sum("total");
35
+ await db("orders").avg("total");
36
+ await db("orders").min("total");
37
+ await db("orders").max("total");
38
+ await db("users").where("email", email).exists();
39
+ await db("users").where("banned", true).doesntExist();
40
+ }
41
+
42
+ export async function selectsAndWheres() {
43
+ db("users").select("id", "email");
44
+ db("users").select("id").addSelect("email");
45
+ db("orders").selectRaw("SUM(total) AS revenue");
46
+ db("users").distinct().select("country");
47
+
48
+ db("users").where("votes", 100);
49
+ db("users").where("votes", ">=", 100);
50
+ db("users").where("name", "like", "T%");
51
+ db("users").where("votes", 100).orWhere("name", "John");
52
+ db("users").whereNot("status", "cancelled");
53
+ db("users").whereIn("id", [1, 2, 3]).whereNotIn("id", [4]);
54
+ db("users").whereNull("deleted_at").whereNotNull("email_verified_at");
55
+ db("products").whereBetween("price", [10, 100]).whereNotBetween("stock", [0, 5]);
56
+ db("posts").whereLike("title", "%keel%");
57
+ db("events").whereColumn("updated_at", ">", "created_at");
58
+ db("users").whereRaw("score >= ? AND score <= ?", [10, 90]);
59
+
60
+ await db("users")
61
+ .where("active", true)
62
+ .where((q) => q.where("role", "admin").orWhere("role", "owner"))
63
+ .get();
64
+ }
65
+
66
+ export async function orderingJoinsWhen() {
67
+ db("users").orderBy("name").orderByDesc("created_at");
68
+ db("posts").latest();
69
+ db("posts").oldest();
70
+ db("posts").orderByRaw("LENGTH(title) DESC");
71
+ db("users").inRandomOrder();
72
+ db("users").reorder("name");
73
+ db("users").limit(10).offset(20);
74
+ db("users").take(10).skip(20);
75
+ db("users").forPage(3, 15);
76
+
77
+ await db("orders")
78
+ .select("user_id")
79
+ .selectRaw("SUM(total) AS spent")
80
+ .groupBy("user_id")
81
+ .having("spent", ">", 1000)
82
+ .get();
83
+
84
+ await db("posts")
85
+ .join("users", "posts.user_id", "users.id")
86
+ .leftJoin("images", "images.post_id", "posts.id")
87
+ .select("posts.title", "users.name")
88
+ .get();
89
+
90
+ await db("users")
91
+ .when(search, (q, term) => q.whereLike("name", `%${term}%`))
92
+ .unless(includeArchived, (q) => q.whereNull("archived_at"))
93
+ .get();
94
+ }
95
+
96
+ export async function writes() {
97
+ await db("users").insert({ email, name });
98
+ await db("users").insertGetId({ email, name });
99
+ await db("logs").insertOrIgnore({ key, value });
100
+ await db("users").upsert([{ id: 1, name: "Ada" }], ["id"], ["name"]);
101
+
102
+ await db("users").where("id", id).update({ name: "Grace" });
103
+ await db("users").updateOrInsert({ email }, { name });
104
+ await db("posts").where("id", id).increment("views");
105
+ await db("posts").where("id", id).decrement("stock", 3, { updated_at: now });
106
+ await db("counters").incrementEach({ hits: 1, misses: 2 });
107
+
108
+ await db("sessions").where("expires_at", "<", now).delete();
109
+ await db("cache").truncate();
110
+ }
111
+
112
+ export async function typed() {
113
+ type User = { id: number; email: string };
114
+ const row: User | null = await db<User>("users").where("id", 1).first();
115
+ const rows: User[] = await db<User>("users").get();
116
+ const raw: Row[] = await db("users").get();
117
+ return { row, rows, raw };
118
+ }
@@ -0,0 +1,31 @@
1
+ // Type-check harness for docs/security.md. Compile-only — never executed.
2
+ import type { MiddlewareHandler } from "hono";
3
+ import {
4
+ securityHeaders,
5
+ csrf,
6
+ csrfField,
7
+ csrfToken,
8
+ sessionMiddleware,
9
+ } from "@shaferllc/keel/core";
10
+
11
+ export function headers(): MiddlewareHandler[] {
12
+ return [
13
+ securityHeaders(),
14
+ securityHeaders({
15
+ csp: { defaultSrc: ["'self'"], scriptSrc: ["'self'", "https://cdn.example.com"] },
16
+ hsts: { maxAge: 15552000, includeSubDomains: true },
17
+ frameGuard: "DENY",
18
+ }),
19
+ ];
20
+ }
21
+
22
+ export function csrfStack(): MiddlewareHandler[] {
23
+ return [sessionMiddleware(), csrf(), csrf({ except: ["/billing/webhook/*"] })];
24
+ }
25
+
26
+ export function forms() {
27
+ return {
28
+ field: csrfField(),
29
+ token: csrfToken(),
30
+ };
31
+ }
@@ -0,0 +1,76 @@
1
+ // Type-check harness for docs/social-auth.md. Compile-only — never executed.
2
+ import {
3
+ social,
4
+ session,
5
+ redirect,
6
+ request,
7
+ auth,
8
+ ForbiddenException,
9
+ Model,
10
+ config,
11
+ } from "@shaferllc/keel/core";
12
+
13
+ class User extends Model {
14
+ static override table = "users";
15
+ declare id: number;
16
+ declare email: string | null;
17
+ declare name: string | null;
18
+ declare github_id: string;
19
+ declare twitter_id: string;
20
+ }
21
+
22
+ const github = social.github({
23
+ clientId: config("services.github.id") as string,
24
+ clientSecret: config("services.github.secret") as string,
25
+ redirectUri: "https://app.example.com/auth/github/callback",
26
+ });
27
+
28
+ const twitter = social.twitter({
29
+ clientId: config("services.twitter.key") as string,
30
+ clientSecret: config("services.twitter.secret") as string,
31
+ redirectUri: "https://app.example.com/auth/twitter/callback",
32
+ });
33
+
34
+ export function redirectToGithub() {
35
+ const state = social.state();
36
+ session().put("oauth_state", state);
37
+ return redirect(github.redirect({ state }));
38
+ }
39
+
40
+ export async function githubCallback() {
41
+ const state = String(request.query("state") ?? "");
42
+ if (state !== session().pull("oauth_state")) {
43
+ throw new ForbiddenException("Invalid OAuth state");
44
+ }
45
+
46
+ const code = String(request.query("code") ?? "");
47
+ const gh = await github.user(code);
48
+ const existing = await User.query().where("github_id", gh.id).first();
49
+ const user =
50
+ existing ??
51
+ (await User.create({ github_id: gh.id, email: gh.email, name: gh.name }));
52
+
53
+ auth().login(user.id as number);
54
+ return redirect("/dashboard");
55
+ }
56
+
57
+ export async function twitterFlow() {
58
+ const requestToken = await twitter.requestToken();
59
+ session().put("twitter_secret", requestToken.tokenSecret);
60
+ const toProvider = redirect(twitter.redirect(requestToken));
61
+
62
+ const tw = await twitter.user(
63
+ String(request.query("oauth_token") ?? ""),
64
+ String(request.query("oauth_verifier") ?? ""),
65
+ String(session().pull("twitter_secret") ?? ""),
66
+ );
67
+ const user = await User.firstOrCreate({ twitter_id: tw.id }, { name: tw.name });
68
+ auth().login(user.id as number);
69
+ return { toProvider, user };
70
+ }
71
+
72
+ export async function tokenReuse() {
73
+ const code = String(request.query("code") ?? "");
74
+ const token = await github.exchangeCode(code);
75
+ return github.userFromToken(token);
76
+ }
@@ -0,0 +1,14 @@
1
+ // Type-check harness for docs/starter-kits.md. Compile-only — never executed.
2
+ import { TenantModel } from "@shaferllc/keel/teams";
3
+
4
+ class Project extends TenantModel {
5
+ static override table = "projects";
6
+ declare id: number;
7
+ declare name: string;
8
+ }
9
+
10
+ export async function tenantScoped() {
11
+ await Project.all();
12
+ await Project.create({ name: "Hi" });
13
+ await Project.find(1); // null if another team's
14
+ }
@@ -0,0 +1,10 @@
1
+ // Type-check harness for docs/watch.md. Compile-only — never executed.
2
+ import { Application } from "@shaferllc/keel/core";
3
+ import { WatchServiceProvider, Watch } from "@shaferllc/keel/watch";
4
+
5
+ export function install() {
6
+ const app = new Application();
7
+ app.register(WatchServiceProvider);
8
+ Watch.auth((c) => c.req.header("x-watch-key") === "secret");
9
+ return app;
10
+ }
package/docs/hooks.md CHANGED
@@ -1,9 +1,7 @@
1
1
  # Lifecycle Hooks
2
2
 
3
3
  Tap into the **application lifecycle** — run code once the app is ready, clean up
4
- on shutdown, and observe route registration. Inspired by
5
- [Fastify's hooks](https://fastify.dev/docs/latest/Reference/Hooks/), scoped to the
6
- parts Keel doesn't already cover.
4
+ on shutdown, and observe route registration.
7
5
 
8
6
  > **Request-lifecycle hooks** (before/after a request, on error) are
9
7
  > [middleware](./middleware.md) in Keel — `HttpKernel.use()`, route/group
package/docs/logger.md CHANGED
@@ -156,8 +156,8 @@ With no options it defaults to `level: "info"`, `pretty: false`, and no bindings
156
156
 
157
157
  `requestLogger()` is a built-in middleware that binds a **child logger with a
158
158
  generated `reqId` to each request**, so every log line within a request
159
- correlates — Fastify's `request.log`. Install it in your HTTP kernel, then reach
160
- the request's logger anywhere with `requestLog()`:
159
+ correlates. Install it in your HTTP kernel, then reach the request's logger
160
+ anywhere with `requestLog()`:
161
161
 
162
162
  ```ts
163
163
  import { requestLogger, requestLog } from "@shaferllc/keel/core";
package/docs/orm.md CHANGED
@@ -55,3 +55,42 @@ The ORM is deliberately small — enough for CRUD, relationships, and the common
55
55
  query shapes without an ORM dependency. For a gnarly one-off report, reach for
56
56
  the [query builder](./query-builder.md) or a raw `connection().select(sql)`; the
57
57
  model layer never gets in the way.
58
+
59
+ ## A worked example
60
+
61
+ A blog with authors and posts — enough to see CRUD, a relation, and eager loading
62
+ together:
63
+
64
+ ```ts
65
+ import { Model } from "@shaferllc/keel/core";
66
+
67
+ class Post extends Model {
68
+ static table = "posts";
69
+ static fillable = ["title", "body"];
70
+ declare id: number;
71
+ declare title: string;
72
+ declare user_id: number;
73
+
74
+ author() { return this.belongsTo(User); }
75
+ }
76
+
77
+ class User extends Model {
78
+ static table = "users";
79
+ declare id: number;
80
+ declare email: string;
81
+
82
+ posts() { return this.hasMany(Post); }
83
+ }
84
+
85
+ const ada = await User.create({ email: "ada@example.com" });
86
+ await ada.posts().create({ title: "Notes on engines", body: "…" });
87
+
88
+ const withPosts = await User.with("posts").where("id", ada.id).first();
89
+ for (const post of (withPosts as User & { posts: Post[] }).posts) {
90
+ console.log(post.title);
91
+ }
92
+ ```
93
+
94
+ For the full surface — casts, soft deletes, scopes, polymorphic relations —
95
+ see [Models](./models.md).
96
+
package/docs/packages.md CHANGED
@@ -7,9 +7,9 @@ adds the conventions a *shippable* package needs so it can carry its own schema
7
7
  and assets instead of asking the app to wire them by hand.
8
8
 
9
9
  [Keel Watch](./watch.md) — the debug dashboard — is a first-party package and the
10
- reference implementation of everything below. [Billing](./billing.md) (a Cashier
11
- port for Stripe and Paddle) is another, and shows a package contributing models,
12
- a schema migration, gateway drivers, and verified webhook routes.
10
+ reference implementation of everything below. [Billing](./billing.md) (Stripe and
11
+ Paddle subscriptions) is another, and shows a package contributing models, a
12
+ schema migration, gateway drivers, and verified webhook routes.
13
13
 
14
14
  ## The shape of a package
15
15
 
package/docs/providers.md CHANGED
@@ -111,12 +111,12 @@ app.register(RateLimitProvider, { max: 100 }); // parameterized, like a plugin
111
111
  The same provider class can be registered more than once with different options.
112
112
  Without options, `this.options` is an empty object.
113
113
 
114
- > Unlike Fastify plugins, Keel providers are **not encapsulated** — bindings,
115
- > decorators, and routes are registered into the one global container. That's a
116
- > deliberate simplification: there's a single, predictable scope, and no
117
- > plugin-boundary rules to reason about. For per-request behavior in the HTTP
118
- > pipeline (auth, logging, etc.), reach for [middleware](./middleware.md), which
119
- > *is* scoped to the routes you attach it to.
114
+ > Keel providers are **not encapsulated** — bindings, decorators, and routes are
115
+ > registered into the one global container. That's a deliberate simplification:
116
+ > there's a single, predictable scope, and no plugin-boundary rules to reason
117
+ > about. For per-request behavior in the HTTP pipeline (auth, logging, etc.),
118
+ > reach for [middleware](./middleware.md), which *is* scoped to the routes you
119
+ > attach it to.
120
120
 
121
121
  ## Generating a provider
122
122
 
@@ -51,8 +51,8 @@ and host (handy for building absolute links or forcing HTTPS).
51
51
 
52
52
  `all()` understands JSON and form bodies. For anything else — XML, CSV, a binary
53
53
  payload, a custom format — read the raw body and parse it yourself. There's no
54
- content-type parser registry to configure (unlike Fastify): parsing is explicit,
55
- so you call the accessor you want.
54
+ content-type parser registry to configure: parsing is explicit, so you call the
55
+ accessor you want.
56
56
 
57
57
  ```ts
58
58
  await request.text(); // the body as a string (XML, CSV, …)
@@ -14,6 +14,20 @@ to finish.
14
14
  | `app` *(default)* | Full-stack: views, sessions, register/login, password reset, two-factor. |
15
15
  | `saas` | `app` plus teams, roles, invitations, billing, and multi-tenancy. |
16
16
 
17
+ ## Pick a kit
18
+
19
+ ```bash
20
+ npm create keeljs@latest my-app # app (default)
21
+ npm create keeljs@latest my-api -- --preset api
22
+ npm create keeljs@latest my-saas -- --preset saas
23
+ npm create keeljs@latest bare -- --preset minimal
24
+ cd my-app && npm install && npm run dev
25
+ ```
26
+
27
+ Then open `http://localhost:3000`. The SaaS kit already has a team switcher,
28
+ invites, and a billing stub wired through [teams](./teams.md) and
29
+ [billing](./billing.md) — start by editing `app/Models` and `routes/web.ts`.
30
+
17
31
  ## Every database, Cloudflare first
18
32
 
19
33
  Each kit ships with all four drivers wired. Switching is `DB_CONNECTION` and nothing
package/docs/testing.md CHANGED
@@ -2,8 +2,7 @@
2
2
 
3
3
  Test your app by **injecting requests** — no server, no port, no network — and
4
4
  asserting on the response. `testClient()` wraps your app's Hono instance (which
5
- already does fetch-style injection) with verb helpers and fluent assertions, the
6
- way Fastify's `inject()` works.
5
+ already does fetch-style injection) with verb helpers and fluent assertions.
7
6
 
8
7
  ## The client
9
8
 
@@ -112,8 +112,7 @@ router.get("/posts/:id", [Posts, "show"]).middleware([
112
112
 
113
113
  `validated(part)` returns the parsed, typed value for that part (defaults to
114
114
  `"body"`). Coercion (`z.coerce.number()`) is the schema's job — it applies before
115
- your handler sees the value. This is the declarative counterpart to Fastify's
116
- route schemas, on top of the same `validate()` engine.
115
+ your handler sees the value. Built on the same `validate()` engine.
117
116
 
118
117
  ## Body parsing is JSON-only
119
118
 
package/docs/watch.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Watch
2
2
 
3
- Keel Watch is a debug dashboard — a Telescope for Keel. It records the requests,
3
+ Keel Watch is a debug dashboard for Keel apps. It records the requests,
4
4
  queries, exceptions, logs, jobs, mail, notifications, cache lookups, events, and
5
5
  scheduled tasks flowing through your app, and shows them in a single-page UI at
6
6
  `/watch`, with every request linked to the queries and logs it produced.