@shaferllc/keel 0.68.0 → 0.74.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.
Files changed (140) hide show
  1. package/AGENTS.md +2 -0
  2. package/README.md +14 -5
  3. package/dist/api/api.config.stub +9 -0
  4. package/dist/api/config.d.ts +13 -0
  5. package/dist/api/config.js +14 -0
  6. package/dist/api/index.d.ts +16 -0
  7. package/dist/api/index.js +13 -0
  8. package/dist/api/provider.d.ts +10 -0
  9. package/dist/api/provider.js +17 -0
  10. package/dist/api/query.d.ts +35 -0
  11. package/dist/api/query.js +42 -0
  12. package/dist/api/resource.d.ts +91 -0
  13. package/dist/api/resource.js +188 -0
  14. package/dist/core/application.js +6 -0
  15. package/dist/core/cache.d.ts +1 -2
  16. package/dist/core/cache.js +9 -2
  17. package/dist/core/cli/stubs.d.ts +14 -0
  18. package/dist/core/cli/stubs.js +105 -0
  19. package/dist/core/console-prompt.d.ts +79 -0
  20. package/dist/core/console-prompt.js +239 -0
  21. package/dist/core/console-ui.d.ts +96 -0
  22. package/dist/core/console-ui.js +187 -0
  23. package/dist/core/console.d.ts +188 -0
  24. package/dist/core/console.js +395 -0
  25. package/dist/core/database.d.ts +70 -1
  26. package/dist/core/database.js +174 -15
  27. package/dist/core/env.d.ts +96 -0
  28. package/dist/core/env.js +140 -0
  29. package/dist/core/health.d.ts +2 -2
  30. package/dist/core/health.js +2 -2
  31. package/dist/core/http/kernel.d.ts +2 -0
  32. package/dist/core/http/kernel.js +48 -0
  33. package/dist/core/http/router.d.ts +5 -5
  34. package/dist/core/http/router.js +5 -5
  35. package/dist/core/i18n.d.ts +162 -0
  36. package/dist/core/i18n.js +472 -0
  37. package/dist/core/index.d.ts +25 -4
  38. package/dist/core/index.js +13 -3
  39. package/dist/core/instrumentation.d.ts +113 -0
  40. package/dist/core/instrumentation.js +52 -0
  41. package/dist/core/logger.d.ts +7 -0
  42. package/dist/core/logger.js +28 -1
  43. package/dist/core/notification.js +10 -1
  44. package/dist/core/package.d.ts +120 -0
  45. package/dist/core/package.js +169 -0
  46. package/dist/core/pages.d.ts +108 -0
  47. package/dist/core/pages.js +199 -0
  48. package/dist/core/queue.js +26 -5
  49. package/dist/core/repl.d.ts +33 -0
  50. package/dist/core/repl.js +88 -0
  51. package/dist/core/scheduler.js +6 -0
  52. package/dist/core/social.d.ts +4 -4
  53. package/dist/core/social.js +4 -4
  54. package/dist/core/storage.js +15 -3
  55. package/dist/core/telemetry.d.ts +208 -0
  56. package/dist/core/telemetry.js +383 -0
  57. package/dist/core/template.d.ts +2 -3
  58. package/dist/core/template.js +2 -3
  59. package/dist/core/testing.d.ts +170 -1
  60. package/dist/core/testing.js +504 -2
  61. package/dist/db/d1.js +13 -0
  62. package/dist/db/pg.d.ts +13 -0
  63. package/dist/db/pg.js +46 -4
  64. package/dist/openapi/config.d.ts +28 -0
  65. package/dist/openapi/config.js +25 -0
  66. package/dist/openapi/doc.d.ts +40 -0
  67. package/dist/openapi/doc.js +20 -0
  68. package/dist/openapi/export.d.ts +8 -0
  69. package/dist/openapi/export.js +19 -0
  70. package/dist/openapi/gate.d.ts +15 -0
  71. package/dist/openapi/gate.js +27 -0
  72. package/dist/openapi/index.d.ts +19 -0
  73. package/dist/openapi/index.js +15 -0
  74. package/dist/openapi/openapi.config.stub +29 -0
  75. package/dist/openapi/provider.d.ts +18 -0
  76. package/dist/openapi/provider.js +35 -0
  77. package/dist/openapi/routes.d.ts +9 -0
  78. package/dist/openapi/routes.js +23 -0
  79. package/dist/openapi/spec.d.ts +23 -0
  80. package/dist/openapi/spec.js +132 -0
  81. package/dist/openapi/ui.d.ts +8 -0
  82. package/dist/openapi/ui.js +31 -0
  83. package/dist/openapi/zod.d.ts +12 -0
  84. package/dist/openapi/zod.js +46 -0
  85. package/dist/watch/config.d.ts +33 -0
  86. package/dist/watch/config.js +38 -0
  87. package/dist/watch/entry.d.ts +53 -0
  88. package/dist/watch/entry.js +105 -0
  89. package/dist/watch/gate.d.ts +20 -0
  90. package/dist/watch/gate.js +32 -0
  91. package/dist/watch/index.d.ts +21 -0
  92. package/dist/watch/index.js +17 -0
  93. package/dist/watch/migration.d.ts +7 -0
  94. package/dist/watch/migration.js +20 -0
  95. package/dist/watch/provider.d.ts +22 -0
  96. package/dist/watch/provider.js +58 -0
  97. package/dist/watch/prune.d.ts +11 -0
  98. package/dist/watch/prune.js +20 -0
  99. package/dist/watch/recorder.d.ts +24 -0
  100. package/dist/watch/recorder.js +39 -0
  101. package/dist/watch/routes.d.ts +13 -0
  102. package/dist/watch/routes.js +55 -0
  103. package/dist/watch/store.d.ts +54 -0
  104. package/dist/watch/store.js +158 -0
  105. package/dist/watch/ui/dist/watch.css +1 -0
  106. package/dist/watch/ui/dist/watch.js +555 -0
  107. package/dist/watch/ui-shell.d.ts +12 -0
  108. package/dist/watch/ui-shell.js +24 -0
  109. package/dist/watch/watch.config.stub +47 -0
  110. package/dist/watch/watchers.d.ts +12 -0
  111. package/dist/watch/watchers.js +156 -0
  112. package/docs/ai-manifest.json +737 -3
  113. package/docs/api-resources.md +118 -0
  114. package/docs/configuration.md +74 -0
  115. package/docs/console.md +193 -13
  116. package/docs/database.md +101 -0
  117. package/docs/examples/configuration.ts +40 -0
  118. package/docs/examples/console.ts +134 -0
  119. package/docs/examples/database.ts +90 -0
  120. package/docs/examples/i18n.ts +117 -0
  121. package/docs/examples/logger.ts +74 -0
  122. package/docs/examples/mail.ts +103 -0
  123. package/docs/examples/pages.ts +82 -0
  124. package/docs/examples/telemetry.ts +127 -0
  125. package/docs/examples/testing.ts +149 -0
  126. package/docs/health.md +4 -4
  127. package/docs/i18n.md +302 -0
  128. package/docs/logger.md +156 -10
  129. package/docs/mail.md +73 -0
  130. package/docs/openapi.md +111 -0
  131. package/docs/packages.md +118 -0
  132. package/docs/pages.md +217 -0
  133. package/docs/storage.md +5 -3
  134. package/docs/telemetry.md +263 -0
  135. package/docs/templates.md +3 -4
  136. package/docs/testing.md +251 -0
  137. package/docs/watch.md +118 -0
  138. package/llms-full.txt +2370 -248
  139. package/llms.txt +12 -1
  140. package/package.json +18 -2
package/docs/testing.md CHANGED
@@ -90,6 +90,257 @@ kernel.use(requestLogger());
90
90
  const client = testClient(kernel);
91
91
  ```
92
92
 
93
+ ## Authenticated requests
94
+
95
+ The client's `withX` methods return a **copy**, so a client configured once can be
96
+ reused without leaking into other tests:
97
+
98
+ ```ts
99
+ const authed = client.withToken("tok_123"); // Authorization: Bearer tok_123
100
+
101
+ await authed.get("/me");
102
+ await client.get("/me"); // still anonymous
103
+ ```
104
+
105
+ | Method | Sends |
106
+ |--------|-------|
107
+ | `withToken(token)` | `Authorization: Bearer <token>` |
108
+ | `withBasicAuth(user, pass)` | `Authorization: Basic <base64>` |
109
+ | `withHeader(name, value)` / `withHeaders({…})` | any header |
110
+ | `withCookie(name, value)` / `withCookies({…})` | a `Cookie` header |
111
+ | `acceptJson()` | `Accept: application/json` |
112
+
113
+ ## Forms and uploads
114
+
115
+ ```ts
116
+ await client.form("/login", { email: "a@b.com", password: "s3cret" }); // url-encoded
117
+ await client.multipart("/avatar", { file: new Blob([png]), name: "ada" }); // file upload
118
+ ```
119
+
120
+ ## More response assertions
121
+
122
+ ```ts
123
+ res.assertOk(); // 2xx
124
+ res.assertCreated(); // 201
125
+ res.assertNoContent(); // 204
126
+ res.assertUnauthorized(); // 401
127
+ res.assertForbidden(); // 403
128
+ res.assertNotFound(); // 404
129
+ res.assertUnprocessable(); // 422
130
+ res.assertServerError(); // 5xx
131
+ ```
132
+
133
+ **`assertJsonContains` is a subset match** — the one you usually want. It pins the
134
+ fields the test is about and ignores the rest, so adding a field to a response
135
+ doesn't break twenty tests:
136
+
137
+ ```ts
138
+ res.assertJsonContains({ user: { email: "a@b.com" } });
139
+ ```
140
+
141
+ `assertJson` still deep-equals the whole body, when that's what you mean.
142
+
143
+ ```ts
144
+ res.assertSee("Welcome back"); // body contains
145
+ res.assertDontSee("Sign up");
146
+
147
+ res.assertHeader("content-type", "application/json");
148
+ res.assertHeaderMissing("x-debug");
149
+
150
+ res.assertCookie("session"); // was set
151
+ res.assertCookie("session", "abc123"); // ...with this value
152
+ res.assertCookieMissing("admin");
153
+
154
+ res.dump(); // print status, headers, body — when you're stuck
155
+ ```
156
+
157
+ ### Validation
158
+
159
+ A failed `validate()` returns a 422 with per-field errors, so a test can assert on
160
+ the field rather than the message:
161
+
162
+ ```ts
163
+ const res = await client.post("/users", { email: "nope" });
164
+
165
+ res.assertValidationErrors("email", "password");
166
+ res.assertNoValidationError("name");
167
+ ```
168
+
169
+ ## Test doubles
170
+
171
+ Keel's fakes swap out a real backend for a recording one, so a test can assert
172
+ that something *would* have happened without it actually happening — no email
173
+ sent, no card charged, no file uploaded.
174
+
175
+ | Fake | Replaces | Assertions |
176
+ |------|----------|------------|
177
+ | [`fakeMail()`](./mail.md#in-tests) | the mailer | `assertSent`, `assertQueued`, … |
178
+ | [`fakeQueue()`](./queues.md#in-tests) | the queue | `assertPushed`, `assertNothingPushed`, … |
179
+ | [`fakeDisk()`](./storage.md#testing) | a storage disk | `assertExists`, `assertContents`, … |
180
+ | [`events().fake()`](./events.md#testing) | the emitter | `assertEmitted`, `assertNotEmitted`, … |
181
+ | [`hash.fake()`](./hashing.md) | PBKDF2 | — (just makes it fast) |
182
+
183
+ ```ts
184
+ const mailer = fakeMail();
185
+ const queue = fakeQueue();
186
+
187
+ await registerUser({ email: "ada@example.com" });
188
+
189
+ mailer.assertQueued((m) => m.subject === "Welcome");
190
+ queue.assertPushed(SendWelcome);
191
+ ```
192
+
193
+ For anything else, `swap()` replaces a container binding:
194
+
195
+ ```ts
196
+ swap(PaymentGateway, () => new FakeGateway());
197
+ ```
198
+
199
+ ### Spies
200
+
201
+ The smallest double: a function that records how it was called.
202
+
203
+ ```ts
204
+ import { spy, spyOn, restoreSpies } from "@shaferllc/keel/core";
205
+
206
+ const send = spy<[string], void>();
207
+ notify(send);
208
+
209
+ assert.equal(send.callCount, 1);
210
+ assert.ok(send.calledWith("hello"));
211
+ ```
212
+
213
+ `spyOn` replaces a method on an object. It **calls through** by default — so you're
214
+ observing, not stubbing — until you tell it otherwise:
215
+
216
+ ```ts
217
+ const charge = spyOn(gateway, "charge"); // still really charges
218
+ charge.returns(receipt); // now it doesn't
219
+
220
+ restoreSpies(); // put every spied method back
221
+ ```
222
+
223
+ ## Controlling time
224
+
225
+ Testing "this token expires in an hour" shouldn't take an hour.
226
+
227
+ ```ts
228
+ import { freezeTime, timeTravel, restoreTime } from "@shaferllc/keel/core";
229
+
230
+ freezeTime("2026-07-11T12:00:00Z");
231
+
232
+ const token = await jwt.sign({ sub: "1" }, { expiresIn: "1h" });
233
+ assert.ok(await jwt.verify(token)); // valid now
234
+
235
+ timeTravel(61 * 60 * 1000); // an hour and a minute later
236
+ assert.equal(await jwt.verify(token), null); // expired
237
+
238
+ restoreTime();
239
+ ```
240
+
241
+ `freezeTime()` mocks `Date` and `Date.now()`. It does **not** mock timers — a
242
+ `setTimeout` still fires on the real clock — and `new Date("2020-01-01")` still
243
+ parses normally. Only "what time is it *now*" is frozen.
244
+
245
+ ## Resetting state between tests
246
+
247
+ Keel's fakes, disks, queues, and cache are process-global, so one test can leak
248
+ into the next. `resetState()` puts it all back:
249
+
250
+ ```ts
251
+ import { resetState } from "@shaferllc/keel/core";
252
+
253
+ afterEach(() => resetState());
254
+ ```
255
+
256
+ It restores every fake (mail, queue, disk, hash), unfreezes the clock, drops event
257
+ listeners, empties the cache, and gives you a fresh lock store. It does **not**
258
+ touch the database.
259
+
260
+ For that, `truncate()`:
261
+
262
+ ```ts
263
+ afterEach(() => truncate("comments", "posts", "users")); // children before parents
264
+ ```
265
+
266
+ It deletes rows rather than rolling back a transaction, so it works on every driver
267
+ (D1, Postgres, libSQL) instead of only the ones with savepoints.
268
+
269
+ ## Database assertions
270
+
271
+ Assert against the database directly, rather than through an endpoint:
272
+
273
+ ```ts
274
+ import { assertDatabaseHas, assertDatabaseMissing, assertDatabaseCount } from "@shaferllc/keel/core";
275
+
276
+ await client.post("/users", { email: "ada@example.com" });
277
+
278
+ await assertDatabaseHas("users", { email: "ada@example.com" });
279
+ await assertDatabaseHas("users", { active: 1 }, 1); // exactly one match
280
+ await assertDatabaseMissing("users", { email: "deleted@example.com" });
281
+ await assertDatabaseCount("users", 1);
282
+ await assertDatabaseEmpty("sessions");
283
+ ```
284
+
285
+ A failure tells you what it looked for and how many rows the table actually holds.
286
+
287
+ ## Console tests
288
+
289
+ Run a command in-process — no subprocess, so it's fast and you can assert on it:
290
+
291
+ ```ts
292
+ import { runCommand } from "@shaferllc/keel/core";
293
+ import { run } from "../bin/keel"; // your app's console entry point
294
+
295
+ const result = await runCommand(() => run(["node", "keel", "routes"]));
296
+
297
+ result
298
+ .assertSucceeded() // exit code 0
299
+ .assertOutputContains("GET /users")
300
+ .assertOutputMatches(/POST\s+\/users/);
301
+ ```
302
+
303
+ You pass the command **in**, because the console entry point belongs to your app —
304
+ it's the thing that knows how to build your application — not to the core. Anything
305
+ that prints and sets an exit code works, so it's equally good for testing a
306
+ function you wrote yourself.
307
+
308
+ `console.log`/`warn` are captured as stdout and `console.error` as stderr; a
309
+ command that *throws* is recorded as a failure rather than blowing up the test.
310
+
311
+ `assertFailed()`, `assertExitCode(n)`, and `assertErrorContains(text)` cover the
312
+ rest. `result.stdout`, `result.stderr`, and `result.exitCode` are there if you'd
313
+ rather assert by hand.
314
+
315
+ ## Browser tests
316
+
317
+ Keel doesn't ship a browser driver — that's [Playwright](https://playwright.dev)'s
318
+ job, and wrapping it would only put a thinner API in front of a better one.
319
+
320
+ The test client injects requests *without a server*, which is what makes it fast;
321
+ a browser needs a real one. Start the app on a port, point Playwright at it, and
322
+ tear it down:
323
+
324
+ ```ts
325
+ import { serve } from "@hono/node-server";
326
+ import { chromium } from "playwright";
327
+
328
+ const server = serve({ fetch: new HttpKernel(app).build().fetch, port: 3001 });
329
+ const browser = await chromium.launch();
330
+
331
+ const page = await browser.newPage();
332
+ await page.goto("http://localhost:3001/login");
333
+ await page.fill("[name=email]", "ada@example.com");
334
+ await page.click("button[type=submit]");
335
+ await page.waitForURL("**/dashboard");
336
+
337
+ await browser.close();
338
+ server.close();
339
+ ```
340
+
341
+ Everything else on this page — the fakes, `freezeTime`, `resetState`, the database
342
+ assertions — works the same in a browser test, because it's the same process.
343
+
93
344
  ## API reference
94
345
 
95
346
  ### `testClient(target)`
package/docs/watch.md ADDED
@@ -0,0 +1,118 @@
1
+ # Watch
2
+
3
+ Keel Watch is a debug dashboard — a Telescope for Keel. It records the requests,
4
+ queries, exceptions, logs, jobs, mail, notifications, cache lookups, events, and
5
+ scheduled tasks flowing through your app, and shows them in a single-page UI at
6
+ `/watch`, with every request linked to the queries and logs it produced.
7
+
8
+ It ships as a Keel [package](./packages.md) and is its reference implementation:
9
+ one `register()` turns it on, and its watchers observe the framework's
10
+ instrumentation event stream — they patch nothing, so installing Watch changes no
11
+ behaviour, only visibility.
12
+
13
+ ## Install
14
+
15
+ ```ts
16
+ // bootstrap/providers.ts
17
+ import { WatchServiceProvider } from "@shaferllc/keel/watch";
18
+
19
+ export const providers = [AppServiceProvider, WatchServiceProvider];
20
+ ```
21
+
22
+ Publish the config and create the table:
23
+
24
+ ```bash
25
+ keel vendor:publish --tag watch-config # writes config/watch.ts
26
+ keel migrate # creates watch_entries
27
+ ```
28
+
29
+ Then open `http://localhost:3000/watch`.
30
+
31
+ > Watch exposes requests, payloads, and stack traces. It is gated shut in
32
+ > production by default (see [Access](#access)) — keep it that way, or lock it
33
+ > down with your own check.
34
+
35
+ ## What it records
36
+
37
+ A tab per type, each behind an on/off switch:
38
+
39
+ | Watcher | Records |
40
+ |---------|---------|
41
+ | Requests | method, path, status, duration, headers (auth/cookies redacted) |
42
+ | Queries | SQL, bindings, duration, connection; slow queries are tagged |
43
+ | Exceptions | class, message, stack, the request that threw |
44
+ | Logs | every log line, at any level |
45
+ | Mail | sent messages (subject, recipients, body) |
46
+ | Jobs | queued jobs as they complete or fail |
47
+ | Notifications | deliveries and their channels |
48
+ | Cache | hits and misses *(off by default — noisy)* |
49
+ | Events | app events *(off by default — noisy)* |
50
+ | Schedule | scheduled tasks as they run |
51
+
52
+ Clicking any entry shows its full detail and **everything else in its batch** —
53
+ the request it belonged to and every query, log, and exception that request
54
+ produced.
55
+
56
+ ## Configuration
57
+
58
+ `config/watch.ts` (publish it, then edit):
59
+
60
+ ```ts
61
+ export default {
62
+ enabled: env("WATCH_ENABLED", true),
63
+ path: "watch", // the dashboard mounts at /watch
64
+ storage: "database", // "database" persists; "memory" is a per-process ring
65
+ connection: undefined, // which DB connection (default when omitted)
66
+ table: "watch_entries",
67
+ limit: 100, // API page size; the memory ring is 10× this
68
+ sampling: 1, // record this fraction of entries (0–1)
69
+ slowQueryMs: 100, // queries at/above this are tagged "slow"
70
+ ignorePaths: [], // request paths to skip
71
+ retentionHours: 24, // keel watch:prune deletes entries older than this
72
+ watchers: { cache: false, event: false /* … the rest default on */ },
73
+ };
74
+ ```
75
+
76
+ ### Storage
77
+
78
+ - **`database`** (default) — a `watch_entries` table via any registered
79
+ connection. Survives restarts, shared across processes; needs `keel migrate`.
80
+ - **`memory`** — a per-process ring buffer. Zero setup, great for a single dev
81
+ process or the edge; entries vanish on restart and aren't shared.
82
+
83
+ ## Access
84
+
85
+ By default the dashboard is open only when `app.debug` is on or the app isn't in
86
+ production. Anywhere else, lock it to your own check:
87
+
88
+ ```ts
89
+ import { Watch } from "@shaferllc/keel/watch";
90
+ import { auth } from "@shaferllc/keel/core";
91
+
92
+ Watch.auth(() => auth().check() && Boolean(auth().user()?.isAdmin));
93
+ ```
94
+
95
+ The gate guards the JSON API too, so entries can't be read by anyone the
96
+ dashboard wouldn't show them to.
97
+
98
+ ## Pruning
99
+
100
+ With database storage, keep the table small:
101
+
102
+ ```bash
103
+ keel watch:prune # delete entries older than retentionHours
104
+ keel watch:prune --hours=6
105
+ ```
106
+
107
+ Schedule it (see [Scheduling](./scheduling.md)) to run it automatically.
108
+
109
+ ## How it works
110
+
111
+ Watch never wraps or monkey-patches the framework. The core emits a typed
112
+ [instrumentation event stream](./packages.md#observing-the-framework) —
113
+ `db.query`, `request.handled`, `exception`, `job.*`, and so on — and each watcher
114
+ is just a listener that turns an event into a stored entry, stamped with the
115
+ current request id so related entries group into one batch. Recording is
116
+ fire-and-forget: it never blocks or breaks the request it's watching, and the
117
+ store's own queries are filtered out so it never records itself.
118
+ ```