@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.
- package/README.md +2 -2
- package/dist/billing/billable.d.ts +2 -3
- package/dist/billing/billable.js +2 -3
- package/dist/billing/billing.config.stub +1 -1
- package/dist/billing/builder.d.ts +2 -2
- package/dist/billing/builder.js +2 -2
- package/dist/billing/provider.d.ts +1 -1
- package/dist/billing/provider.js +2 -2
- package/dist/billing/subscription-item.d.ts +0 -1
- package/dist/billing/subscription-item.js +0 -1
- package/dist/core/decorators.d.ts +5 -7
- package/dist/core/decorators.js +5 -7
- package/dist/core/exceptions.d.ts +2 -3
- package/dist/core/exceptions.js +2 -3
- package/dist/core/model-query.d.ts +1 -1
- package/dist/core/model-query.js +1 -1
- package/dist/core/request-logger.d.ts +2 -2
- package/dist/core/request-logger.js +2 -2
- package/dist/core/request.js +1 -1
- package/dist/core/testing.d.ts +3 -3
- package/dist/core/testing.js +3 -3
- package/docs/ai-manifest.json +14 -14
- package/docs/billing.md +45 -6
- package/docs/changelog.md +124 -21
- package/docs/cors.md +28 -0
- package/docs/decorators.md +2 -6
- package/docs/errors.md +1 -1
- package/docs/examples/billing.ts +128 -0
- package/docs/examples/cors.ts +29 -0
- package/docs/examples/hono.ts +29 -0
- package/docs/examples/openapi.ts +35 -0
- package/docs/examples/orm.ts +30 -0
- package/docs/examples/packages.ts +26 -0
- package/docs/examples/query-builder.ts +118 -0
- package/docs/examples/security.ts +31 -0
- package/docs/examples/social-auth.ts +76 -0
- package/docs/examples/starter-kits.ts +14 -0
- package/docs/examples/watch.ts +10 -0
- package/docs/hooks.md +1 -3
- package/docs/logger.md +2 -2
- package/docs/orm.md +39 -0
- package/docs/packages.md +3 -3
- package/docs/providers.md +6 -6
- package/docs/request-response.md +2 -2
- package/docs/starter-kits.md +14 -0
- package/docs/testing.md +1 -2
- package/docs/validation.md +1 -2
- package/docs/watch.md +1 -1
- package/llms-full.txt +145 -34
- package/llms.txt +13 -2
- 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.
|
|
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
|
|
160
|
-
|
|
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) (
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
>
|
|
115
|
-
>
|
|
116
|
-
>
|
|
117
|
-
>
|
|
118
|
-
>
|
|
119
|
-
>
|
|
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
|
|
package/docs/request-response.md
CHANGED
|
@@ -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
|
|
55
|
-
|
|
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, …)
|
package/docs/starter-kits.md
CHANGED
|
@@ -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
|
|
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
|
|
package/docs/validation.md
CHANGED
|
@@ -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.
|
|
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
|
|
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.
|