@shaferllc/keel 0.68.0 → 0.77.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 (143) hide show
  1. package/AGENTS.md +2 -0
  2. package/README.md +15 -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 +93 -0
  13. package/dist/api/resource.js +185 -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/index.d.ts +28 -0
  18. package/dist/core/cli/index.js +427 -0
  19. package/dist/core/cli/stubs.d.ts +14 -0
  20. package/dist/core/cli/stubs.js +105 -0
  21. package/dist/core/console-prompt.d.ts +79 -0
  22. package/dist/core/console-prompt.js +239 -0
  23. package/dist/core/console-ui.d.ts +96 -0
  24. package/dist/core/console-ui.js +187 -0
  25. package/dist/core/console.d.ts +188 -0
  26. package/dist/core/console.js +395 -0
  27. package/dist/core/database.d.ts +70 -1
  28. package/dist/core/database.js +174 -15
  29. package/dist/core/env.d.ts +96 -0
  30. package/dist/core/env.js +140 -0
  31. package/dist/core/health.d.ts +2 -2
  32. package/dist/core/health.js +2 -2
  33. package/dist/core/http/kernel.d.ts +2 -0
  34. package/dist/core/http/kernel.js +48 -0
  35. package/dist/core/http/router.d.ts +5 -5
  36. package/dist/core/http/router.js +5 -5
  37. package/dist/core/i18n.d.ts +162 -0
  38. package/dist/core/i18n.js +472 -0
  39. package/dist/core/index.d.ts +25 -4
  40. package/dist/core/index.js +13 -3
  41. package/dist/core/instrumentation.d.ts +113 -0
  42. package/dist/core/instrumentation.js +52 -0
  43. package/dist/core/logger.d.ts +7 -0
  44. package/dist/core/logger.js +28 -1
  45. package/dist/core/notification.js +10 -1
  46. package/dist/core/package.d.ts +117 -0
  47. package/dist/core/package.js +169 -0
  48. package/dist/core/pages.d.ts +108 -0
  49. package/dist/core/pages.js +199 -0
  50. package/dist/core/queue.js +26 -5
  51. package/dist/core/repl.d.ts +33 -0
  52. package/dist/core/repl.js +88 -0
  53. package/dist/core/scheduler.js +6 -0
  54. package/dist/core/social.d.ts +4 -4
  55. package/dist/core/social.js +4 -4
  56. package/dist/core/storage.js +15 -3
  57. package/dist/core/telemetry.d.ts +208 -0
  58. package/dist/core/telemetry.js +383 -0
  59. package/dist/core/template.d.ts +2 -3
  60. package/dist/core/template.js +2 -3
  61. package/dist/core/testing.d.ts +171 -1
  62. package/dist/core/testing.js +505 -2
  63. package/dist/db/d1.js +13 -0
  64. package/dist/db/pg.d.ts +13 -0
  65. package/dist/db/pg.js +46 -4
  66. package/dist/openapi/config.d.ts +28 -0
  67. package/dist/openapi/config.js +25 -0
  68. package/dist/openapi/doc.d.ts +40 -0
  69. package/dist/openapi/doc.js +20 -0
  70. package/dist/openapi/export.d.ts +8 -0
  71. package/dist/openapi/export.js +19 -0
  72. package/dist/openapi/gate.d.ts +15 -0
  73. package/dist/openapi/gate.js +27 -0
  74. package/dist/openapi/index.d.ts +19 -0
  75. package/dist/openapi/index.js +15 -0
  76. package/dist/openapi/openapi.config.stub +29 -0
  77. package/dist/openapi/provider.d.ts +18 -0
  78. package/dist/openapi/provider.js +35 -0
  79. package/dist/openapi/routes.d.ts +9 -0
  80. package/dist/openapi/routes.js +23 -0
  81. package/dist/openapi/spec.d.ts +23 -0
  82. package/dist/openapi/spec.js +132 -0
  83. package/dist/openapi/ui.d.ts +8 -0
  84. package/dist/openapi/ui.js +31 -0
  85. package/dist/openapi/zod.d.ts +12 -0
  86. package/dist/openapi/zod.js +46 -0
  87. package/dist/watch/config.d.ts +33 -0
  88. package/dist/watch/config.js +38 -0
  89. package/dist/watch/entry.d.ts +53 -0
  90. package/dist/watch/entry.js +105 -0
  91. package/dist/watch/gate.d.ts +20 -0
  92. package/dist/watch/gate.js +32 -0
  93. package/dist/watch/index.d.ts +21 -0
  94. package/dist/watch/index.js +17 -0
  95. package/dist/watch/migration.d.ts +7 -0
  96. package/dist/watch/migration.js +20 -0
  97. package/dist/watch/provider.d.ts +22 -0
  98. package/dist/watch/provider.js +58 -0
  99. package/dist/watch/prune.d.ts +11 -0
  100. package/dist/watch/prune.js +22 -0
  101. package/dist/watch/recorder.d.ts +24 -0
  102. package/dist/watch/recorder.js +39 -0
  103. package/dist/watch/routes.d.ts +13 -0
  104. package/dist/watch/routes.js +55 -0
  105. package/dist/watch/store.d.ts +54 -0
  106. package/dist/watch/store.js +158 -0
  107. package/dist/watch/ui/dist/watch.css +1 -0
  108. package/dist/watch/ui/dist/watch.js +555 -0
  109. package/dist/watch/ui-shell.d.ts +12 -0
  110. package/dist/watch/ui-shell.js +24 -0
  111. package/dist/watch/watch.config.stub +47 -0
  112. package/dist/watch/watchers.d.ts +12 -0
  113. package/dist/watch/watchers.js +156 -0
  114. package/docs/ai-manifest.json +737 -3
  115. package/docs/api-resources.md +119 -0
  116. package/docs/configuration.md +74 -0
  117. package/docs/console.md +217 -13
  118. package/docs/database.md +101 -0
  119. package/docs/examples/api-resources.ts +86 -0
  120. package/docs/examples/configuration.ts +40 -0
  121. package/docs/examples/console.ts +134 -0
  122. package/docs/examples/database.ts +90 -0
  123. package/docs/examples/i18n.ts +117 -0
  124. package/docs/examples/logger.ts +74 -0
  125. package/docs/examples/mail.ts +103 -0
  126. package/docs/examples/pages.ts +82 -0
  127. package/docs/examples/telemetry.ts +127 -0
  128. package/docs/examples/testing.ts +150 -0
  129. package/docs/health.md +4 -4
  130. package/docs/i18n.md +302 -0
  131. package/docs/logger.md +156 -10
  132. package/docs/mail.md +73 -0
  133. package/docs/openapi.md +111 -0
  134. package/docs/packages.md +118 -0
  135. package/docs/pages.md +217 -0
  136. package/docs/storage.md +5 -3
  137. package/docs/telemetry.md +263 -0
  138. package/docs/templates.md +3 -4
  139. package/docs/testing.md +252 -0
  140. package/docs/watch.md +118 -0
  141. package/llms-full.txt +2389 -241
  142. package/llms.txt +13 -1
  143. package/package.json +32 -6
package/llms-full.txt CHANGED
@@ -1547,6 +1547,80 @@ string. Pass a fallback of the type you expect and the type follows it.
1547
1547
  Use `env()` **only inside config files**, not scattered through your app. That
1548
1548
  keeps all environment coupling in one layer.
1549
1549
 
1550
+ ## Validating the environment
1551
+
1552
+ `env("DATABASE_URL")` hands back whatever is — or isn't — in `process.env`. A
1553
+ missing variable is `undefined`, the app boots looking perfectly healthy, and then
1554
+ dies on the first request that actually needs it. In production. At night.
1555
+
1556
+ `defineEnv()` checks the whole environment **at boot** and refuses to start
1557
+ otherwise:
1558
+
1559
+ ```ts
1560
+ // config/env.ts
1561
+ import { defineEnv, envVar } from "@shaferllc/keel/core";
1562
+
1563
+ export const env = defineEnv({
1564
+ APP_KEY: envVar.string({ required: true, description: "32+ random characters" }),
1565
+ PORT: envVar.number({ default: 3000 }),
1566
+ NODE_ENV: envVar.enum(["development", "test", "production"], { default: "development" }),
1567
+ DATABASE_URL: envVar.url({ required: true }),
1568
+ SENTRY_DSN: envVar.string(), // optional
1569
+ });
1570
+ ```
1571
+
1572
+ ```ts
1573
+ env.PORT; // number — not "3000"
1574
+ env.NODE_ENV; // "development" | "test" | "production" — not string
1575
+ env.SENTRY_DSN; // string | undefined
1576
+ ```
1577
+
1578
+ The types are **inferred from the rules**. A `number` rule gives you a `number`; an
1579
+ `enum` gives you the union, not `string`; anything optional without a default is
1580
+ `| undefined`, so you can't forget to handle it.
1581
+
1582
+ ### It reports every problem at once
1583
+
1584
+ ```
1585
+ The environment is not valid:
1586
+
1587
+ • APP_KEY is required but not set (32+ random characters).
1588
+ • PORT must be a number, got "eighty".
1589
+ • NODE_ENV must be one of development, test, production, got "staging".
1590
+ • DATABASE_URL must be a valid URL, got "not a url".
1591
+
1592
+ Set these in your .env (or your host's environment) and start again.
1593
+ ```
1594
+
1595
+ Not the first problem — **all** of them. Fixing a deploy one missing variable per
1596
+ restart is its own small hell.
1597
+
1598
+ ### Rules
1599
+
1600
+ | Rule | Value | Notes |
1601
+ |------|-------|-------|
1602
+ | `envVar.string()` | `string` | |
1603
+ | `envVar.number()` | `number` | rejects `"eighty"` |
1604
+ | `envVar.boolean()` | `boolean` | accepts `true/false/1/0/yes/no/on/off` |
1605
+ | `envVar.enum([...])` | the union | typed as the literal union |
1606
+ | `envVar.url()` | `string` | must parse as a URL — catches a truncated connection string |
1607
+
1608
+ Each takes `required`, `default`, `description` (shown in the error, so they know
1609
+ what to set), and `validate` for anything else:
1610
+
1611
+ ```ts
1612
+ APP_KEY: envVar.string({
1613
+ required: true,
1614
+ validate: (value) => (value.length >= 32 ? true : "must be at least 32 characters"),
1615
+ });
1616
+ ```
1617
+
1618
+ **An empty string counts as absent.** `PORT=` in a `.env` file is a typo, not a
1619
+ deliberate empty port.
1620
+
1621
+ The returned object is frozen, so nothing can quietly reassign your config at
1622
+ runtime.
1623
+
1550
1624
  ## Config files
1551
1625
 
1552
1626
  Each file in `config/` exports a default object and is loaded under its
@@ -4414,6 +4488,132 @@ output.
4414
4488
 
4415
4489
 
4416
4490
 
4491
+ ---
4492
+
4493
+ <!-- source: docs/api-resources.md -->
4494
+
4495
+ # API Resources
4496
+
4497
+ `apiResource(router, Model, options)` generates a full CRUD REST API from a Keel
4498
+ [model](./models.md) — explicit, server-side, and composed from pieces you already
4499
+ have. It's imported
4500
+ from `@shaferllc/keel/api`.
4501
+
4502
+ ```ts
4503
+ import { apiResource } from "@shaferllc/keel/api";
4504
+ import { Post } from "../app/Models/Post.js";
4505
+ import { z } from "zod";
4506
+
4507
+ export default function routes(router) {
4508
+ apiResource(router, Post, {
4509
+ filter: ["status", "authorId"],
4510
+ sort: ["createdAt", "title"],
4511
+ body: z.object({ title: z.string(), body: z.string(), status: z.string() }),
4512
+ access: { read: true, write: (c) => isEditor(c) },
4513
+ });
4514
+ }
4515
+ ```
4516
+
4517
+ That registers five routes:
4518
+
4519
+ | Method | Path | Action |
4520
+ |--------|------|--------|
4521
+ | `GET` | `/posts` | list (filtered, sorted, paginated) |
4522
+ | `GET` | `/posts/:id` | read one |
4523
+ | `POST` | `/posts` | create |
4524
+ | `PUT` / `PATCH` | `/posts/:id` | update |
4525
+ | `DELETE` | `/posts/:id` | delete |
4526
+
4527
+ They're real routes, so [`@shaferllc/keel/openapi`](./openapi.md) documents them
4528
+ automatically, and writes go through the model's mass-assignment guard and your
4529
+ Zod schema.
4530
+
4531
+ ## Access is deny-by-default
4532
+
4533
+ An auto-generated API that's open by default is a footgun. Every action whose
4534
+ access you don't declare returns **403**. You opt routes open — never shut.
4535
+
4536
+ ```ts
4537
+ access: {
4538
+ read: true, // list + read: anyone
4539
+ write: (c) => auth().check(), // create + update + delete: signed-in only
4540
+ }
4541
+ ```
4542
+
4543
+ Rules resolve per action: the action's own key (`list`, `get`, `create`,
4544
+ `update`, `delete`), then the `read` / `write` shorthand, then `all`, then denied.
4545
+ Each rule is a boolean or a `(c) => boolean | Promise<boolean>` predicate.
4546
+
4547
+ ## Filtering, sorting, pagination — allow-listed
4548
+
4549
+ The list endpoint reads the query string, but **only** columns you allow-list:
4550
+
4551
+ - `filter: ["status"]` → `GET /posts?status=published` filters; `?secret=x` is
4552
+ ignored. Nothing reaches SQL unless it's on the list.
4553
+ - `sort: ["title", "createdAt"]` → `GET /posts?sort=title,-createdAt` (a `-`
4554
+ prefix is descending); unknown columns are dropped.
4555
+ - `GET /posts?page=2&perPage=20` paginates. `perPage` is clamped to `maxPerPage`
4556
+ (default 100) — the guard against "give me everything".
4557
+
4558
+ The response is a paginated envelope:
4559
+
4560
+ ```json
4561
+ { "data": [ … ], "meta": { "total": 42, "perPage": 20, "currentPage": 2, "lastPage": 3 } }
4562
+ ```
4563
+
4564
+ ## Row-level security with `scope`
4565
+
4566
+ `scope` constrains the base query for **every** row operation — list, read,
4567
+ update, delete. A row outside the scope reads as 404, so it can't be fetched,
4568
+ changed, or removed:
4569
+
4570
+ ```ts
4571
+ apiResource(router, Post, {
4572
+ access: { read: true, write: true },
4573
+ scope: (q, c) => q.where("authorId", currentUserId(c)), // only your own posts
4574
+ });
4575
+ ```
4576
+
4577
+ ## Shaping input and output
4578
+
4579
+ - **`body` / `createBody` / `updateBody`** — a Zod schema validating writes
4580
+ (a failure is a 422). It also becomes the request-body schema in the OpenAPI
4581
+ docs.
4582
+ - **`beforeWrite(data, c, action)`** — mutate the write payload (stamp an owner
4583
+ id, a timestamp) before it's saved.
4584
+ - **`transform`** — shape the output: a `(model, c) => …` function, or a Keel
4585
+ [Transformer](./transformers.md) (its `item`/`collection` are used).
4586
+
4587
+ ## Options reference
4588
+
4589
+ | Option | Purpose |
4590
+ |--------|---------|
4591
+ | `path` | Base path (default: the model's table). |
4592
+ | `name` | Route-name prefix (default: the path). |
4593
+ | `only` / `except` | Restrict which of the five actions are generated. |
4594
+ | `filter` / `sort` | Allow-listed columns for `?filter` and `?sort`. |
4595
+ | `perPage` / `maxPerPage` | Page-size default and ceiling. |
4596
+ | `body` / `createBody` / `updateBody` | Write validation schemas. |
4597
+ | `access` | Per-action access rules (deny by default). |
4598
+ | `scope` | Row-level query constraint for every operation. |
4599
+ | `transform` | Output shaping. |
4600
+ | `beforeWrite` | Mutate the payload before save. |
4601
+ | `tags` | OpenAPI tags for the routes. |
4602
+ | `label` | Singular name in doc summaries (default: the model's class name). |
4603
+
4604
+ Global pagination defaults live in `config/api.ts` (register the optional
4605
+ `ApiServiceProvider`, then `keel vendor:publish --tag api-config`).
4606
+
4607
+ ## What this isn't
4608
+
4609
+ There's no isomorphic frontend client here — no shared model object that runs on
4610
+ both sides of the wire. Keel deliberately stops at the server boundary: this
4611
+ generates a plain REST API you call however you like (fetch, your Inertia pages,
4612
+ a mobile app). That keeps the model server-only and the wire contract explicit —
4613
+ and it's exactly the contract the OpenAPI docs describe.
4614
+
4615
+
4616
+
4417
4617
  ---
4418
4618
 
4419
4619
  <!-- source: docs/authentication.md -->
@@ -6606,24 +6806,228 @@ write the stub, confirming with:
6606
6806
 
6607
6807
  Delete the existing file first if you truly mean to regenerate it.
6608
6808
 
6609
- ## Adding your own commands
6809
+ ## Your console entry point
6610
6810
 
6611
- Commands are defined with [commander](https://github.com/tj/commander.js) in
6612
- [`src/core/cli/index.ts`](../src/core/cli/index.ts). Register a new one on the
6613
- `program`:
6811
+ The console ships in the package, and takes your application factory:
6614
6812
 
6615
6813
  ```ts
6616
- program
6617
- .command("cache:clear")
6618
- .description("Clear the application cache")
6619
- .action(async () => {
6620
- const app = await createApplication();
6621
- // ...your logic, with full access to the container
6622
- });
6814
+ #!/usr/bin/env tsx
6815
+ // bin/keel.ts
6816
+ import { run } from "@shaferllc/keel/cli";
6817
+ import { createApplication } from "../bootstrap/app.js";
6818
+
6819
+ run(process.argv, { createApplication }).catch((error) => {
6820
+ console.error(error);
6821
+ process.exit(1);
6822
+ });
6823
+ ```
6824
+
6825
+ It's handed `createApplication` rather than importing it, because a framework that
6826
+ imports an *application* has its dependency pointing the wrong way — and that one
6827
+ import is what kept the console out of the published build until now.
6828
+
6829
+ Commands that need the app (`serve`, `routes`, `migrate`) boot it once and share it.
6830
+ Scaffolding commands (`make:*`) don't, so a boot failure isn't fatal — it's surfaced
6831
+ only when a command that actually needs the app runs.
6832
+
6833
+ ## Writing your own commands
6834
+
6835
+ `keel make:command greet` scaffolds `app/Commands/greet.ts`. Everything in
6836
+ `app/Commands` is discovered automatically — no registration step.
6837
+
6838
+ ```ts
6839
+ import { defineCommand, arg, flag } from "@shaferllc/keel/core";
6840
+
6841
+ export const greet = defineCommand({
6842
+ name: "greet",
6843
+ description: "Greet someone",
6844
+
6845
+ args: { name: arg.string({ description: "who to greet" }) },
6846
+ flags: { loud: flag.boolean({ alias: "l", description: "SHOUT IT" }) },
6847
+
6848
+ async run({ args, flags, ui }) {
6849
+ const message = `Hello, ${args.name}!`;
6850
+ ui.success(flags.loud ? message.toUpperCase() : message);
6851
+ },
6852
+ });
6853
+ ```
6854
+
6855
+ ```bash
6856
+ keel greet Ada --loud # ✔ HELLO, ADA!
6857
+ keel greet --help # generated usage, args, and options
6858
+ ```
6859
+
6860
+ **`args.name` is a `string` and `flags.loud` is a `boolean` — inferred, not cast.**
6861
+ That's the point of declaring them: the parsing is generated from the types, so the
6862
+ two can't drift apart. Make an arg optional and its type becomes
6863
+ `string | undefined`; give it a default and it's a `string` again.
6864
+
6865
+ Commands run with the application booted, so they get the same container, config,
6866
+ and providers your HTTP requests do.
6867
+
6868
+ ### Arguments
6869
+
6870
+ Positional, in declaration order. Required by default.
6871
+
6872
+ | Builder | Value |
6873
+ |---------|-------|
6874
+ | `arg.string()` | `string` |
6875
+ | `arg.number()` | `number` — rejected with a clear error if it isn't one |
6876
+ | `arg.spread()` | `string[]` — swallows the rest; must be last |
6877
+
6878
+ Options: `description`, `required: false`, `default`, `parse`.
6879
+
6880
+ ### Flags
6881
+
6882
+ | Builder | Value |
6883
+ |---------|-------|
6884
+ | `flag.boolean()` | `boolean` — defaults to `false`, so it's never `undefined` |
6885
+ | `flag.string()` | `string \| undefined` |
6886
+ | `flag.number()` | `number \| undefined` |
6887
+ | `flag.array()` | `string[]` — repeatable, defaults to `[]` |
6888
+
6889
+ Options: `description`, `alias` (a single letter), `required`, `default`, `parse`.
6890
+
6891
+ The parser understands `--flag value`, `--flag=value`, `--no-flag`, `-f value`,
6892
+ bundled shorthands (`-lt 5`), and `--`, after which everything is passed through
6893
+ untouched in `rest`.
6894
+
6895
+ An **unknown flag is an error**, not a shrug — a typo'd `--forse` should tell you,
6896
+ not silently do nothing. Set `allowUnknownFlags: true` if a command genuinely needs
6897
+ to pass flags on to something else.
6898
+
6899
+ ### Exit codes
6900
+
6901
+ Return a number to set the exit code; return nothing for `0`. A thrown error is
6902
+ caught, reported, and exits `1` — a console is a bad place to show a user a stack
6903
+ trace because they mistyped a flag. A **usage** error (missing arg, bad flag) prints
6904
+ what's wrong *and the command's help*.
6905
+
6906
+ ## Terminal UI
6907
+
6908
+ Every command gets a `ui`:
6909
+
6910
+ ```ts
6911
+ ui.info("Checking…");
6912
+ ui.success("Migrated 3 tables");
6913
+ ui.warning("Nothing to do");
6914
+ ui.error("Failed"); // stderr
6915
+ ui.debug("verbose detail");
6916
+
6917
+ ui.action("create", "app/Models/User.ts"); // CREATE app/Models/User.ts
6918
+ ui.action("skip", "app/Models/Post.ts", "skipped");
6919
+
6920
+ ui.table(["Name", "Rows"]).row(["users", "42"]).row(["orgs", "7"]).render();
6921
+
6922
+ ui.sticker(["http://localhost:3000"], "Server running");
6923
+ ui.instructions(["cd my-app", "npm install", "keel serve"], "Next steps");
6924
+
6925
+ ui.colors("green", "done"); // paint a string yourself
6926
+ ```
6927
+
6928
+ ### Tasks
6929
+
6930
+ For a command that does several things in a row:
6931
+
6932
+ ```ts
6933
+ await ui
6934
+ .tasks()
6935
+ .add("Install dependencies", async (task) => {
6936
+ task.update("resolving…");
6937
+ return "42 packages";
6938
+ })
6939
+ .add("Run migrations", async () => "3 tables")
6940
+ .run();
6941
+ ```
6942
+
6943
+ It **stops at the first failure**, because the tasks after it almost certainly
6944
+ depended on it and a cascade of red tells you nothing new. `run()` resolves to
6945
+ `false` if anything failed.
6946
+
6947
+ ## Prompts
6948
+
6949
+ ```ts
6950
+ const name = await prompt.ask("Project name?", { default: "my-app" });
6951
+ const secret = await prompt.secure("API key?");
6952
+ const ok = await prompt.confirm("Delete everything?");
6953
+ const driver = await prompt.choice("Database?", ["sqlite", "postgres"]);
6954
+ const features = await prompt.multiple("Features?", ["auth", "queue", "mail"]);
6955
+ ```
6956
+
6957
+ `ask` re-asks on a failed `validate` rather than dying — a typo shouldn't cost
6958
+ someone the whole command. Every prompt takes `default`, `hint`, `validate`, and
6959
+ `result`.
6960
+
6961
+ ## Testing a command
6962
+
6963
+ A command that asks questions is normally a command you can't test. So prompts can
6964
+ be **trapped**: script the answers up front, and nothing touches the terminal.
6965
+
6966
+ ```ts
6967
+ import { ConsoleKernel, createUi, createPrompt } from "@shaferllc/keel/core";
6968
+
6969
+ const ui = createUi({ raw: true }); // buffer the output, drop the colors
6970
+ const prompt = createPrompt({ trap: true });
6971
+ const kernel = new ConsoleKernel({ ui, prompt }).register(setup);
6972
+
6973
+ prompt.trap("Project name?").replyWith("keel-app");
6974
+ prompt.trap("Database?").chooseOption(1);
6975
+ prompt.trap("Write the config?").accept();
6976
+
6977
+ const code = await kernel.run(["setup"]);
6978
+
6979
+ assert.equal(code, 0);
6980
+ assert.match(ui.logs.join("\n"), /keel-app on postgres/);
6981
+ prompt.assertAllTrapsUsed(); // every scripted question was actually asked
6982
+ ```
6983
+
6984
+ An **untrapped prompt throws** instead of hanging. That matters more than it
6985
+ sounds: without it, the test would block forever on stdin no test will ever
6986
+ provide, and your suite would simply stop — with no failure to read.
6987
+
6988
+ A trap can also assert the prompt's own validation:
6989
+
6990
+ ```ts
6991
+ prompt
6992
+ .trap("Email?")
6993
+ .assertFails("", "Email is required")
6994
+ .assertPasses("ada@example.com")
6995
+ .replyWith("ada@example.com");
6996
+ ```
6997
+
6998
+ `ui.logs` and `ui.errors` hold every line written, colorless, so you can assert on
6999
+ exactly what the command said.
7000
+
7001
+ ## The REPL
7002
+
7003
+ ```bash
7004
+ keel repl
6623
7005
  ```
6624
7006
 
6625
- Because commands boot the application, they get the same container, config, and
6626
- providers your HTTP requests do.
7007
+ An interactive shell with the **application booted** the container is up, the
7008
+ providers have run, and the helpers are in scope:
7009
+
7010
+ ```
7011
+ keel > await db("users").where("active", 1).get()
7012
+ keel > make(Router).all()
7013
+ keel > await cache().get("stats")
7014
+ keel > .ls # what's in scope
7015
+ keel > .exit
7016
+ ```
7017
+
7018
+ Poking at a model in a REPL is the fastest debugging loop there is, and it
7019
+ shouldn't cost you a throwaway script to get one. History persists in
7020
+ `.keel_repl_history`.
7021
+
7022
+ ---
7023
+
7024
+ ## A note on the built-ins
7025
+
7026
+ The commands *above* (`serve`, `routes`, `make:*`, `migrate:*`) still run through
7027
+ Keel's original console wrapper, and package-contributed commands do too. Your
7028
+ commands — anything in `app/Commands` — run on the system documented here, and take
7029
+ precedence over a built-in of the same name. Migrating the built-ins across is
7030
+ mechanical and will happen; nothing about the API here changes when it does.
6627
7031
 
6628
7032
 
6629
7033
 
@@ -6869,6 +7273,107 @@ SQL — so it's injection-safe by construction. Writes return a `WriteResult`;
6869
7273
  > matches the current `where` clause — with no `where`, that's the whole table.
6870
7274
  > Always scope a write with `where` unless you truly mean to touch every row.
6871
7275
 
7276
+ ## Transactions
7277
+
7278
+ Two related writes should either both land or neither should. `transaction()`
7279
+ commits when your callback returns and **rolls back if it throws**:
7280
+
7281
+ ```ts
7282
+ import { transaction, db } from "@shaferllc/keel/core";
7283
+
7284
+ await transaction(async () => {
7285
+ await db("orders").insert(order);
7286
+ await db("stock").where("id", id).decrement("count"); // a throw here undoes the insert
7287
+ });
7288
+ ```
7289
+
7290
+ The error still reaches you — it's rethrown after the rollback. Nothing is
7291
+ swallowed.
7292
+
7293
+ ### Queries inside are ambient
7294
+
7295
+ You don't have to thread a transaction object through your code. `db()`, models,
7296
+ and relations all pick up the open transaction automatically:
7297
+
7298
+ ```ts
7299
+ await transaction(async () => {
7300
+ const user = await User.create({ email }); // the model is in the transaction
7301
+ await user.related("posts").create({ title }); // so is the relation
7302
+ await db("audit").insert({ userId: user.id }); // and the raw builder
7303
+ });
7304
+ ```
7305
+
7306
+ That works because the transaction lives in `AsyncLocalStorage`, not a module
7307
+ global — so two requests running transactions at the same time can't steal each
7308
+ other's connection.
7309
+
7310
+ If you'd rather be explicit, the callback gets a handle:
7311
+
7312
+ ```ts
7313
+ await transaction(async (tx) => {
7314
+ await tx.table("orders").insert(order);
7315
+ await tx.write("UPDATE stock SET count = count - 1 WHERE id = ?", [id]);
7316
+ });
7317
+ ```
7318
+
7319
+ `tx.rollback()` abandons the transaction without committing. `inTransaction()`
7320
+ tells you whether one is open.
7321
+
7322
+ ### Nesting uses savepoints
7323
+
7324
+ A `transaction()` inside another doesn't open a second one — databases don't have
7325
+ those. It takes a **savepoint**, so an inner failure rolls back only the inner
7326
+ work and the outer transaction carries on:
7327
+
7328
+ ```ts
7329
+ await transaction(async () => {
7330
+ await db("orders").insert(order); // survives
7331
+
7332
+ try {
7333
+ await transaction(async () => {
7334
+ await db("items").insert(item);
7335
+ throw new Error("out of stock"); // only this is rolled back
7336
+ });
7337
+ } catch {
7338
+ // handle it
7339
+ }
7340
+
7341
+ await db("audit").insert(entry); // still in the outer transaction
7342
+ });
7343
+
7344
+ // the outer transaction commits: the order and the audit row are both saved
7345
+ ```
7346
+
7347
+ Without savepoints, a nested helper's failure would silently abandon its caller's
7348
+ writes too — which is the sort of bug you find in production, months later.
7349
+
7350
+ ### Drivers and the pooling trap
7351
+
7352
+ A transaction needs every statement to run on **one** connection. A connection
7353
+ *pool* hands each statement to whichever connection is free — so issuing `BEGIN`
7354
+ through a pool wraps nothing: the `INSERT` after it can land on a different
7355
+ connection entirely, the `COMMIT` commits nothing, and a failure half-writes.
7356
+ It looks like it works. It doesn't.
7357
+
7358
+ So a pooled driver implements `begin()` on its `Connection`, checking one
7359
+ connection out and running the whole transaction on it. Keel's Postgres adapter
7360
+ does this automatically when you hand it a `Pool` (it checks for `connect()`), and
7361
+ releases the connection afterwards even if the `COMMIT` throws.
7362
+
7363
+ | Driver | Transactions |
7364
+ |--------|--------------|
7365
+ | Postgres (`Pool`) | ✅ a dedicated connection is checked out |
7366
+ | Postgres (`Client`), SQLite, libSQL | ✅ `BEGIN` / `COMMIT` on the one connection they have |
7367
+ | **Cloudflare D1** | ❌ — no interactive transactions; use `database.batch([...])` |
7368
+
7369
+ D1 can't hold a transaction open across awaits, so `transaction()` on it **throws
7370
+ a clear error** rather than letting a `BEGIN` fail cryptically. A transaction that
7371
+ quietly isn't one is far worse than one that refuses to start.
7372
+
7373
+ Writing your own driver? Implement `begin(): Promise<TransactionConnection>` if it
7374
+ pools. If it owns a single connection, you can leave it out and Keel will use
7375
+ `BEGIN`/`COMMIT`/`ROLLBACK`.
7376
+
6872
7377
  ## Typed rows
6873
7378
 
6874
7379
  Pass a row type for typed results — it flows through to `get()` and `first()`:
@@ -8905,10 +9410,10 @@ with **200** while `isHealthy` is true and **503** the moment a check fails.
8905
9410
  | `RedisCheck` | Reads a key — a failed read means a broken connection |
8906
9411
  | `CacheCheck` | Writes a key, reads it back, deletes it |
8907
9412
 
8908
- Notably **absent**: AdonisJS's disk-space, heap, and RSS checks. Those measure a
8909
- Node process, and on Workers there isn't one — a memory threshold you can't
8910
- observe is worse than no check at all. If you're on Node and want them, `check()`
8911
- below takes ten lines.
9413
+ Notably **absent**: disk-space, heap, and RSS checks. Those measure a Node
9414
+ process, and on Workers there isn't one — a memory threshold you can't observe is
9415
+ worse than no check at all. If you're on Node and want them, `check()` below takes
9416
+ ten lines.
8912
9417
 
8913
9418
  ## Your own checks
8914
9419
 
@@ -9742,77 +10247,386 @@ The signature of `onReady` / `onShutdown` hooks.
9742
10247
 
9743
10248
  ---
9744
10249
 
9745
- <!-- source: docs/inertia.md -->
10250
+ <!-- source: docs/i18n.md -->
9746
10251
 
9747
- # Inertia
10252
+ # Internationalization
9748
10253
 
9749
- Keel ships a server-side [Inertia.js](https://inertiajs.com) adapter. Pair Keel's
9750
- routing with an Inertia client (React, Vue, or Svelte) and render page components
9751
- from the server without building an API — `inertia("Page", props)` returns the
9752
- right response automatically.
10254
+ Translations with ICU message formatting, plus the `Intl` formatters that go with
10255
+ them.
9753
10256
 
9754
- ## Configure it
10257
+ ```ts
10258
+ import { setTranslations, t } from "@shaferllc/keel/core";
9755
10259
 
9756
- Bind an `Inertia` instance in a service provider. You supply the **root view**
9757
- (the HTML shell that embeds the page data and loads your client bundle) and an
9758
- optional asset **version**:
10260
+ setTranslations({
10261
+ en: { "cart.items": "{count, plural, =0 {Your cart is empty} one {# item} other {# items}}" },
10262
+ fr: { "cart.items": "{count, plural, =0 {Panier vide} one {# article} other {# articles}}" },
10263
+ });
10264
+
10265
+ t("cart.items", { count: 3 }); // "3 items" — in the request's locale
10266
+ ```
10267
+
10268
+ There is **no dependency here, and there doesn't need to be.** `Intl` ships with
10269
+ every modern runtime — Node and Cloudflare Workers both carry the full ICU data —
10270
+ so plurals, currencies, dates, and relative times are the platform's job. What Keel
10271
+ adds is the message parser on top, which is the part `Intl` doesn't do.
10272
+
10273
+ ## Setting it up
10274
+
10275
+ Register translations once (in a service provider), and add the middleware that
10276
+ works out each request's locale:
9759
10277
 
9760
10278
  ```ts
9761
- import { ServiceProvider, singleton, Inertia, inertiaPageAttr } from "@shaferllc/keel/core";
10279
+ import { setI18n, I18nManager, setTranslations, detectLocale, HttpKernel } from "@shaferllc/keel/core";
9762
10280
 
9763
- export class InertiaServiceProvider extends ServiceProvider {
9764
- register(): void {
9765
- singleton(
9766
- Inertia,
9767
- () =>
9768
- new Inertia({
9769
- version: "1",
9770
- rootView: (page) =>
9771
- `<!DOCTYPE html><html><head><meta charset="utf-8"></head>` +
9772
- `<body><div id="app" data-page="${inertiaPageAttr(page)}"></div>` +
9773
- `<script src="/assets/app.js"></script></body></html>`,
9774
- }),
9775
- );
10281
+ export class I18nServiceProvider extends ServiceProvider {
10282
+ boot(): void {
10283
+ setI18n(new I18nManager({ defaultLocale: "en" }));
10284
+
10285
+ setTranslations({
10286
+ en: await import("../resources/lang/en.json", { with: { type: "json" } }).then((m) => m.default),
10287
+ fr: await import("../resources/lang/fr.json", { with: { type: "json" } }).then((m) => m.default),
10288
+ });
10289
+
10290
+ this.app.make(HttpKernel).use(detectLocale());
9776
10291
  }
9777
10292
  }
9778
10293
  ```
9779
10294
 
9780
- `inertiaPageAttr(page)` serializes and HTML-escapes the page object for the
9781
- `data-page` attribute.
9782
-
9783
- > **The version defaults to `"1"`.** Omit it and every deploy reports the same
9784
- > asset version — fine until you ship new assets, at which point stale clients
9785
- > won't be told to hard-reload. Bump it (a build hash, a timestamp) whenever your
9786
- > bundle changes so the adapter can force a full reload on mismatch.
9787
-
9788
- ## Render a page
10295
+ Now `t()` works anywhere in the request a controller, a view, a transformer —
10296
+ without threading a locale through every call.
9789
10297
 
9790
- From a controller, or straight from a route:
10298
+ ## Translation files
9791
10299
 
9792
- ```ts
9793
- import { inertia } from "@shaferllc/keel/core";
10300
+ Nested objects and flat dot-keys are the same thing, and you can mix them:
9794
10301
 
9795
- // controller
9796
- show() {
9797
- return inertia("Users/Show", { user: getUser(param("id")) });
10302
+ ```json
10303
+ {
10304
+ "cart": {
10305
+ "items": "{count, plural, one {# item} other {# items}}",
10306
+ "empty": "Your cart is empty"
10307
+ },
10308
+ "checkout.title": "Checkout"
9798
10309
  }
9799
-
9800
- // brisk route
9801
- router.on("/dashboard").renderInertia("Dashboard", { title: "Welcome" });
9802
10310
  ```
9803
10311
 
9804
- `inertia()` looks up the bound `Inertia` instance and delegates to its `render`.
9805
- The component name is the client-side path Inertia resolves (e.g. `Users/Show`
9806
- maps to your `Pages/Users/Show` component); `props` is any JSON-serializable
9807
- object.
10312
+ Both `t("cart.items")` and `t("checkout.title")` resolve.
9808
10313
 
9809
- > **Configure the adapter before you render.** `inertia()` throws
9810
- > `Inertia is not configured…` if no `Inertia` instance is bound in the
9811
- > container. Register the provider (above) during boot, before any route runs.
10314
+ ## The message format
9812
10315
 
9813
- ## What the adapter does
10316
+ The supported ICU subset is the part people actually use.
9814
10317
 
9815
- It implements the Inertia protocol for you. Every branch below is decided from
10318
+ ### Interpolation
10319
+
10320
+ ```
10321
+ Hello {name}!
10322
+ ```
10323
+
10324
+ ### Plurals
10325
+
10326
+ ```
10327
+ {count, plural, =0 {Your cart is empty} one {# item} other {# items}}
10328
+ ```
10329
+
10330
+ `#` becomes the count, formatted for the locale (`1,234 items`). An exact `=N`
10331
+ branch beats the plural category — which is the whole point of `=0`, because "Your
10332
+ cart is empty" reads better than "0 items".
10333
+
10334
+ **Categories are the locale's, not English's.** French treats 0 and 1 as singular;
10335
+ Polish has `one`/`few`/`many`/`other`. That's exactly why you write a message rather
10336
+ than `count === 1 ? "item" : "items"` — that ternary is a bug in most of the world.
10337
+
10338
+ ### Ordinals
10339
+
10340
+ ```
10341
+ {n, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}
10342
+ ```
10343
+
10344
+ → 1st, 2nd, 3rd, 4th, 11th.
10345
+
10346
+ ### Select
10347
+
10348
+ ```
10349
+ {gender, select, male {He} female {She} other {They}} replied
10350
+ ```
10351
+
10352
+ An unmatched value (or a missing one) takes the `other` branch.
10353
+
10354
+ ### Numbers, dates, times
10355
+
10356
+ ```
10357
+ {n, number} 1,234.5
10358
+ {n, number, percent} 25%
10359
+ {n, number, integer} 4
10360
+ {n, number, ::currency/USD} $9.50
10361
+ {d, date, medium} Jul 11, 2026
10362
+ {d, time, short} 3:30 PM
10363
+ ```
10364
+
10365
+ ### Nesting
10366
+
10367
+ Branches are themselves messages, so they nest as deep as you need:
10368
+
10369
+ ```
10370
+ {count, plural,
10371
+ =0 {No messages for {name}}
10372
+ one {{name} has # message}
10373
+ other {{name} has # messages}}
10374
+ ```
10375
+
10376
+ ### Literal braces
10377
+
10378
+ `'{'` and `'}'` render as literal braces.
10379
+
10380
+ ## Formatters
10381
+
10382
+ `Intl`, bound to the request's locale:
10383
+
10384
+ ```ts
10385
+ const l = i18n();
10386
+
10387
+ l.formatNumber(1234.5); // "1,234.5" (de-DE: "1.234,5")
10388
+ l.formatCurrency(9.5, "USD"); // "$9.50"
10389
+ l.formatDate(order.createdAt); // "Jul 11, 2026"
10390
+ l.formatTime(order.createdAt); // "3:30:00 PM"
10391
+ l.formatRelativeTime(post.publishedAt); // "3 days ago"
10392
+ l.formatList(["a", "b", "c"]); // "a, b, and c"
10393
+ l.formatList(names, { type: "disjunction" }); // "a, b, or c"
10394
+ l.formatPlural(5); // "other"
10395
+ l.formatDisplayName("fr"); // "French"
10396
+ ```
10397
+
10398
+ `formatRelativeTime` picks a sensible unit from the distance on its own — seconds,
10399
+ hours, days — or takes one: `formatRelativeTime(date, "hour")`.
10400
+
10401
+ These are worth using even in a **single-locale** app: they're the correct way to
10402
+ render money and dates, and they cost nothing.
10403
+
10404
+ ## Locale detection
10405
+
10406
+ `detectLocale()` works out the request's locale and stashes it, in this order:
10407
+
10408
+ 1. a custom `resolve(c)` you supply
10409
+ 2. a query param — `detectLocale({ query: "lang" })` → `?lang=fr`
10410
+ 3. a cookie — `detectLocale({ cookie: "locale" })`
10411
+ 4. the `Accept-Language` header (turn it off with `header: false`)
10412
+ 5. the default locale
10413
+
10414
+ **Only supported locales are honored**, so `?lang=xx` can't push the app into a
10415
+ locale you have no translations for — it falls through to the next source.
10416
+
10417
+ `negotiateLocale()` is the header parser on its own, if you want it:
10418
+
10419
+ ```ts
10420
+ negotiateLocale("fr-CA,fr;q=0.9,en;q=0.8", ["en", "fr"], "en"); // "fr"
10421
+ ```
10422
+
10423
+ It honors `q` weights and matches `fr-CA` against a supported `fr`.
10424
+
10425
+ ## Fallbacks
10426
+
10427
+ A key with no translation in the active locale falls back — down a chain, not off
10428
+ a cliff:
10429
+
10430
+ 1. the locale itself (`es-MX`)
10431
+ 2. its configured fallback (`fallbackLocales: { "es-MX": "es" }`)
10432
+ 3. its **base language** (`es`)
10433
+ 4. the default locale (`en`)
10434
+
10435
+ So you can ship `es` fully and `es-MX` as a handful of regional overrides, and
10436
+ everything else still resolves:
10437
+
10438
+ ```ts
10439
+ setTranslations({
10440
+ es: { greeting: "Hola", chair: "silla" },
10441
+ "es-MX": { chair: "banca" }, // just the override
10442
+ });
10443
+
10444
+ i18n("es-MX").t("chair"); // "banca"
10445
+ i18n("es-MX").t("greeting"); // "Hola" — from `es`
10446
+ ```
10447
+
10448
+ ## Missing keys
10449
+
10450
+ A missing key **does not throw**. It renders as the key itself (`cart.items`), so
10451
+ the page still works and the gap is obvious rather than blank. It also fires an
10452
+ `i18n.missing` event, which is how you find them in production:
10453
+
10454
+ ```ts
10455
+ listen("i18n.missing", ({ key, locale }) => {
10456
+ logger().warn("missing translation", { key, locale });
10457
+ });
10458
+ ```
10459
+
10460
+ Override what's rendered with the `missing` option:
10461
+
10462
+ ```ts
10463
+ new I18nManager({ missing: (key, locale) => `[${locale}:${key}]` });
10464
+ ```
10465
+
10466
+ ---
10467
+
10468
+ ## API reference
10469
+
10470
+ ### `t(key, data?)`
10471
+
10472
+ `t(key: string, data?: Record<string, unknown>): string`
10473
+
10474
+ Translate a key in the **current request's** locale (or the default, outside a
10475
+ request), formatting its ICU message with `data`.
10476
+
10477
+ ### `i18n(locale?)`
10478
+
10479
+ `i18n(locale?: string): I18n`
10480
+
10481
+ An `I18n` for a locale — or, with no argument, the current request's.
10482
+
10483
+ ### `I18n`
10484
+
10485
+ | Method | Signature |
10486
+ |--------|-----------|
10487
+ | `t` | `(key, data?) => string` |
10488
+ | `has` | `(key) => boolean` |
10489
+ | `formatNumber` | `(value, options?: Intl.NumberFormatOptions) => string` |
10490
+ | `formatCurrency` | `(value, currency, options?) => string` |
10491
+ | `formatDate` | `(value, options?: Intl.DateTimeFormatOptions) => string` |
10492
+ | `formatTime` | `(value, options?) => string` |
10493
+ | `formatRelativeTime` | `(value, unit?, options?) => string` |
10494
+ | `formatList` | `(items, options?: Intl.ListFormatOptions) => string` |
10495
+ | `formatPlural` | `(count, options?) => Intl.LDMLPluralRule` |
10496
+ | `formatDisplayName` | `(code, type?) => string` |
10497
+ | `locale` | the locale code |
10498
+
10499
+ ### `I18nManager`
10500
+
10501
+ | Method | Signature |
10502
+ |--------|-----------|
10503
+ | `add` | `(data: TranslationsByLocale) => this` |
10504
+ | `load` | `(...loaders: TranslationLoader[]) => Promise<this>` |
10505
+ | `locale` | `(code?) => I18n` |
10506
+ | `supported` | `() => string[]` |
10507
+ | `defaultLocale` | the default locale code |
10508
+
10509
+ `new I18nManager(options)` — see `I18nOptions`.
10510
+
10511
+ ### `setI18n(manager)` / `getI18n()` / `setTranslations(data)`
10512
+
10513
+ Replace the active manager, read it, or add translations to it.
10514
+
10515
+ ### `detectLocale(options?)`
10516
+
10517
+ `detectLocale(options?: DetectLocaleOptions): MiddlewareHandler`
10518
+
10519
+ Work out the request's locale and stash it for `t()` / `i18n()`.
10520
+
10521
+ ### `negotiateLocale(header, supported, defaultLocale)`
10522
+
10523
+ `negotiateLocale(header: string | null | undefined, supported: string[], defaultLocale: string): string`
10524
+
10525
+ The `Accept-Language` parser, standalone.
10526
+
10527
+ ### `formatMessage(message, data?, locale?)`
10528
+
10529
+ `formatMessage(message: string, data?: Record<string, unknown>, locale?: string): string`
10530
+
10531
+ Format an ICU message directly, without a translation lookup.
10532
+
10533
+ ### `objectLoader(data)`
10534
+
10535
+ `objectLoader(data: TranslationsByLocale): TranslationLoader` — the simplest loader.
10536
+
10537
+ ### Interfaces & types
10538
+
10539
+ #### `I18nOptions`
10540
+
10541
+ `{ defaultLocale?, supportedLocales?, fallbackLocales?, missing? }`.
10542
+
10543
+ #### `DetectLocaleOptions`
10544
+
10545
+ `{ query?, cookie?, header?, resolve? }`.
10546
+
10547
+ #### `Translations` / `TranslationsByLocale`
10548
+
10549
+ A locale's messages (nested or flat), and those keyed by locale.
10550
+
10551
+ #### `TranslationLoader`
10552
+
10553
+ `{ load(): Promise<TranslationsByLocale> | TranslationsByLocale }`.
10554
+
10555
+
10556
+
10557
+ ---
10558
+
10559
+ <!-- source: docs/inertia.md -->
10560
+
10561
+ # Inertia
10562
+
10563
+ Keel ships a server-side [Inertia.js](https://inertiajs.com) adapter. Pair Keel's
10564
+ routing with an Inertia client (React, Vue, or Svelte) and render page components
10565
+ from the server without building an API — `inertia("Page", props)` returns the
10566
+ right response automatically.
10567
+
10568
+ ## Configure it
10569
+
10570
+ Bind an `Inertia` instance in a service provider. You supply the **root view**
10571
+ (the HTML shell that embeds the page data and loads your client bundle) and an
10572
+ optional asset **version**:
10573
+
10574
+ ```ts
10575
+ import { ServiceProvider, singleton, Inertia, inertiaPageAttr } from "@shaferllc/keel/core";
10576
+
10577
+ export class InertiaServiceProvider extends ServiceProvider {
10578
+ register(): void {
10579
+ singleton(
10580
+ Inertia,
10581
+ () =>
10582
+ new Inertia({
10583
+ version: "1",
10584
+ rootView: (page) =>
10585
+ `<!DOCTYPE html><html><head><meta charset="utf-8"></head>` +
10586
+ `<body><div id="app" data-page="${inertiaPageAttr(page)}"></div>` +
10587
+ `<script src="/assets/app.js"></script></body></html>`,
10588
+ }),
10589
+ );
10590
+ }
10591
+ }
10592
+ ```
10593
+
10594
+ `inertiaPageAttr(page)` serializes and HTML-escapes the page object for the
10595
+ `data-page` attribute.
10596
+
10597
+ > **The version defaults to `"1"`.** Omit it and every deploy reports the same
10598
+ > asset version — fine until you ship new assets, at which point stale clients
10599
+ > won't be told to hard-reload. Bump it (a build hash, a timestamp) whenever your
10600
+ > bundle changes so the adapter can force a full reload on mismatch.
10601
+
10602
+ ## Render a page
10603
+
10604
+ From a controller, or straight from a route:
10605
+
10606
+ ```ts
10607
+ import { inertia } from "@shaferllc/keel/core";
10608
+
10609
+ // controller
10610
+ show() {
10611
+ return inertia("Users/Show", { user: getUser(param("id")) });
10612
+ }
10613
+
10614
+ // brisk route
10615
+ router.on("/dashboard").renderInertia("Dashboard", { title: "Welcome" });
10616
+ ```
10617
+
10618
+ `inertia()` looks up the bound `Inertia` instance and delegates to its `render`.
10619
+ The component name is the client-side path Inertia resolves (e.g. `Users/Show`
10620
+ maps to your `Pages/Users/Show` component); `props` is any JSON-serializable
10621
+ object.
10622
+
10623
+ > **Configure the adapter before you render.** `inertia()` throws
10624
+ > `Inertia is not configured…` if no `Inertia` instance is bound in the
10625
+ > container. Register the provider (above) during boot, before any route runs.
10626
+
10627
+ ## What the adapter does
10628
+
10629
+ It implements the Inertia protocol for you. Every branch below is decided from
9816
10630
  the incoming request headers — you call `inertia("Page", props)` once and the
9817
10631
  adapter picks the response:
9818
10632
 
@@ -10353,23 +11167,94 @@ field; steer clear of those names in your payloads.
10353
11167
 
10354
11168
  ## Levels
10355
11169
 
10356
- `debug` < `info` < `warn` < `error`. Only events at or above the configured
10357
- level are emitted. Set the threshold via config:
11170
+ `trace` < `debug` < `info` < `warn` < `error` < `fatal`. Only events at or above
11171
+ the configured level are emitted. Set the threshold via config:
10358
11172
 
10359
11173
  ```ts
10360
11174
  // config/logger.ts
10361
11175
  export default { level: env("LOG_LEVEL", "info") };
10362
11176
  ```
10363
11177
 
10364
- Under the hood the levels are ordinal (`debug` 10, `info` 20, `warn` 30,
10365
- `error` 40); a line is dropped when its level sits below the threshold. The
10366
- default threshold is `"info"`, so `debug` lines stay silent until you lower it.
11178
+ Under the hood the levels are ordinal (`trace` 10, `debug` 20, `info` 30, `warn`
11179
+ 40, `error` 50, `fatal` 60); a line is dropped when its level sits below the
11180
+ threshold. The default threshold is `"info"`, so `debug` and `trace` stay silent
11181
+ until you lower it.
11182
+
11183
+ `log(level, message, context?)` takes the level at runtime, when it isn't known
11184
+ statically.
10367
11185
 
10368
11186
  Pretty output turns on automatically when `app.debug` is true. In pretty mode
10369
11187
  each event is a single human-readable line —
10370
11188
  `[2026-07-10T…] INFO user registered {"userId":42}` — and the writer routes by
10371
- level: `warn` goes to `console.warn`, `error` to `console.error`, everything else
10372
- to `console.log`. In JSON mode every level is written to `console.log`.
11189
+ level: `warn` goes to `console.warn`, `error` and `fatal` to `console.error`,
11190
+ everything else to `console.log`. In JSON mode every level is written to
11191
+ `console.log`.
11192
+
11193
+ `enabled: false` silences a logger entirely, at every level.
11194
+
11195
+ ### Don't pay for lines you won't emit
11196
+
11197
+ The threshold drops the *line*, but the **context object is built either way** —
11198
+ so an expensive snapshot costs you even when nobody sees it. Gate it:
11199
+
11200
+ ```ts
11201
+ if (logger().isLevelEnabled("debug")) {
11202
+ logger().debug("state", { snapshot: expensiveSnapshot() });
11203
+ }
11204
+
11205
+ // ...or the callback form
11206
+ logger().ifLevelEnabled("debug", (log) => log.debug("state", { snapshot: expensiveSnapshot() }));
11207
+ ```
11208
+
11209
+ ## Where the lines go
11210
+
11211
+ A **sink** is where log records land. The default writes to the console (JSON, or
11212
+ pretty), but it's just a function, so logs can go anywhere — a file, an HTTP
11213
+ collector, a buffer:
11214
+
11215
+ ```ts
11216
+ import { Logger, type Sink } from "@shaferllc/keel/core";
11217
+
11218
+ const httpSink: Sink = (record) => {
11219
+ void fetch("https://logs.example.com", { method: "POST", body: JSON.stringify(record) });
11220
+ };
11221
+
11222
+ new Logger({ sink: httpSink });
11223
+ ```
11224
+
11225
+ A sink receives the structured `LogRecord` — `{ level, time, msg, fields }` — not a
11226
+ formatted string, so it can do what it likes with the shape. `fields` is already
11227
+ redacted.
11228
+
11229
+ `MemorySink` collects records in memory, which is what you want in a test:
11230
+
11231
+ ```ts
11232
+ import { Logger, MemorySink } from "@shaferllc/keel/core";
11233
+
11234
+ const sink = new MemorySink();
11235
+ const log = new Logger({ level: "trace", sink: sink.sink });
11236
+
11237
+ log.info("hello", { userId: 1 });
11238
+
11239
+ sink.messages(); // ["hello"]
11240
+ sink.at("info"); // the records at one level
11241
+ sink.records[0].fields; // { userId: 1 }
11242
+ sink.clear();
11243
+ ```
11244
+
11245
+ ## Named loggers
11246
+
11247
+ Give a subsystem its own level or destination:
11248
+
11249
+ ```ts
11250
+ import { setLogger, namedLogger, Logger } from "@shaferllc/keel/core";
11251
+
11252
+ setLogger(new Logger({ level: "trace", sink: auditSink }), "audit");
11253
+
11254
+ namedLogger("audit").trace("permission granted", { userId });
11255
+ ```
11256
+
11257
+ The application's own logger stays where it is — reach that with `logger()`.
10373
11258
 
10374
11259
  ## Child loggers
10375
11260
 
@@ -10437,7 +11322,8 @@ middleware), `requestLog()` falls back to the base `logger()`.
10437
11322
  ## Redaction
10438
11323
 
10439
11324
  Keep secrets out of your logs with `redact` — top-level keys or dot paths. Matched
10440
- values are replaced with `"[redacted]"`; the original object is never mutated:
11325
+ values are replaced with `"[redacted]"`; **the original object is never mutated**,
11326
+ so redacting doesn't corrupt the data you're still using:
10441
11327
 
10442
11328
  ```ts
10443
11329
  const log = new Logger({
@@ -10448,8 +11334,30 @@ log.info("login", { user: "ada", password: "s3cret", req: { headers: { authoriza
10448
11334
  // {"level":"info",…,"user":"ada","password":"[redacted]","req":{"headers":{"authorization":"[redacted]"}}}
10449
11335
  ```
10450
11336
 
11337
+ A `*` segment matches every key at that level — which is how you catch a secret
11338
+ that appears under a key you don't know in advance:
11339
+
11340
+ ```ts
11341
+ new Logger({ redact: ["*.password", "creds.*.token"] });
11342
+
11343
+ log.info("audit", {
11344
+ alice: { password: "a", name: "Alice" },
11345
+ bob: { password: "b", name: "Bob" },
11346
+ });
11347
+ // both passwords redacted; both names kept
11348
+ ```
11349
+
11350
+ Pass an object instead of an array to change the placeholder, or drop the key
11351
+ outright:
11352
+
11353
+ ```ts
11354
+ new Logger({ redact: { paths: ["password"], censor: "***" } });
11355
+ new Logger({ redact: { paths: ["password"], remove: true } }); // the key disappears
11356
+ ```
11357
+
10451
11358
  Redaction is inherited by child loggers, so a redacting base logger keeps
10452
- per-request loggers safe too.
11359
+ per-request loggers safe too, and it runs **before** the sink — a custom sink can
11360
+ never see the unredacted values.
10453
11361
 
10454
11362
  ---
10455
11363
 
@@ -10603,7 +11511,7 @@ const log = new Logger({ level: "debug", pretty: true, bindings: { app: "api" }
10603
11511
 
10604
11512
  #### `LogLevel`
10605
11513
 
10606
- `type LogLevel = "debug" | "info" | "warn" | "error"`
11514
+ `type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal"`
10607
11515
 
10608
11516
  The four severity levels, in ascending order. Used for the `level` option and
10609
11517
  selected implicitly by each method.
@@ -10613,6 +11521,58 @@ const threshold: LogLevel = "warn";
10613
11521
  new Logger({ level: threshold });
10614
11522
  ```
10615
11523
 
11524
+ ### `isLevelEnabled(level)` / `ifLevelEnabled(level, fn)`
11525
+
11526
+ `isLevelEnabled(level: LogLevel): boolean` — whether a level would be emitted.
11527
+ Check it before building an expensive context object.
11528
+
11529
+ `ifLevelEnabled(level: LogLevel, fn: (log: Logger) => void): void` — the callback
11530
+ form.
11531
+
11532
+ ### `log(level, message, context?)`
11533
+
11534
+ `log(level: LogLevel, message: string, context?: Record<string, unknown>): void` —
11535
+ log at a level chosen at runtime.
11536
+
11537
+ ### `consoleSink(pretty?)`
11538
+
11539
+ `consoleSink(pretty = false): Sink` — the default sink. JSON to stdout, or a pretty
11540
+ single line.
11541
+
11542
+ ### `MemorySink`
11543
+
11544
+ Collects records in memory — for tests.
11545
+
11546
+ | Member | Signature |
11547
+ |--------|-----------|
11548
+ | `sink` | `Sink` — hand this to `LoggerOptions.sink` |
11549
+ | `records` | `LogRecord[]` |
11550
+ | `at` | `(level) => LogRecord[]` |
11551
+ | `messages` | `() => string[]` |
11552
+ | `clear` | `() => void` |
11553
+
11554
+ ### `setLogger(logger, name)` / `namedLogger(name)`
11555
+
11556
+ Register a logger under a name, and resolve it. `namedLogger` throws for an unknown
11557
+ name.
11558
+
11559
+ ### Interfaces & types (added)
11560
+
11561
+ #### `Sink`
11562
+
11563
+ `type Sink = (record: LogRecord) => void` — where log lines go.
11564
+
11565
+ #### `LogRecord`
11566
+
11567
+ `{ level: LogLevel; time: string; msg: string; fields: Record<string, unknown> }` —
11568
+ `fields` is already redacted.
11569
+
11570
+ #### `RedactOptions`
11571
+
11572
+ `{ paths: string[]; censor?: string; remove?: boolean }` — a `*` path segment
11573
+ matches every key at that level. `LoggerOptions.redact` also accepts a bare
11574
+ `string[]`.
11575
+
10616
11576
 
10617
11577
 
10618
11578
  ---
@@ -11298,23 +12258,96 @@ const opts: FetchTransportOptions = {
11298
12258
  };
11299
12259
  ```
11300
12260
 
12261
+ ### `mailer(name?)`
11301
12262
 
12263
+ `mailer(name?: string): Mailer` — the default mailer, or a named one. Throws for an
12264
+ unknown name.
11302
12265
 
11303
- ---
12266
+ ### `send(email, name?)` / `sendLater(email, name?)`
11304
12267
 
11305
- <!-- source: docs/migrations.md -->
12268
+ `send(email: BaseMail, name?: string): Promise<Message>` — build a class-based mail
12269
+ and send it. `sendLater` queues it instead.
11306
12270
 
11307
- # Migrations
12271
+ ### `BaseMail`
11308
12272
 
11309
- Version your database schema. A migration is a `{ name, up, down }` object; a
11310
- fluent **schema builder** describes tables, and the **migrator** runs them
11311
- against your [connection](./database.md), tracking what's applied. The SQL is
11312
- dialect-aware (sqlite / mysql / postgres) and the core imports no driver.
12273
+ Abstract. Implement `build(message: PendingMail): void | Promise<void>` to compose
12274
+ the message.
11313
12275
 
11314
- ## Define migrations
12276
+ ### `PendingMail.sendLater()`
11315
12277
 
11316
- ```ts
11317
- import type { Migration } from "@shaferllc/keel/core";
12278
+ `sendLater(): Promise<void>` — validate now, then put the message on the queue.
12279
+
12280
+ ### `PendingMail.attach()` / `.embed()`
12281
+
12282
+ `attach(filename, content: string | Uint8Array, contentType?): this` — content type
12283
+ inferred from the extension when omitted.
12284
+
12285
+ `embed(cid, content, filename?, contentType?): this` — an inline attachment,
12286
+ referenced from the HTML as `cid:<cid>`.
12287
+
12288
+ ### `PendingMail.toMessage()`
12289
+
12290
+ `toMessage(): Message` — the message as composed, before the mailer applies its
12291
+ defaults.
12292
+
12293
+ ### Testing
12294
+
12295
+ #### `fakeMail(name?)` / `restoreMail(name?)`
12296
+
12297
+ `fakeMail(name?): FakeMailer` swaps a mailer for one that records instead of
12298
+ delivering. `restoreMail(name?)` puts the real one back — with no name, every faked
12299
+ mailer.
12300
+
12301
+ `FakeMailer`:
12302
+
12303
+ | Method | Signature |
12304
+ |--------|-----------|
12305
+ | `assertSent` | `(where?) => void` |
12306
+ | `assertNotSent` | `(where?) => void` |
12307
+ | `assertSentCount` | `(count) => void` |
12308
+ | `assertQueued` | `(where?) => void` |
12309
+ | `assertNotQueued` | `(where?) => void` |
12310
+ | `assertQueuedCount` | `(count) => void` |
12311
+ | `assertNothingSent` | `() => void` — nothing sent *and* nothing queued |
12312
+ | `sent()` / `queued()` | `() => Message[]` |
12313
+
12314
+ ### Interfaces & types
12315
+
12316
+ #### `Attachment`
12317
+
12318
+ `{ filename, content: string | Uint8Array, contentType?, cid? }` — a `cid` makes it
12319
+ an inline attachment.
12320
+
12321
+ #### `MailerOptions`
12322
+
12323
+ `{ from?, replyTo? }` — defaults applied to messages that don't set their own.
12324
+
12325
+ #### `RecordedMail`
12326
+
12327
+ `{ message: Message, queued: boolean }` — what a `FakeMailer` records.
12328
+
12329
+ #### `SendMailJob`
12330
+
12331
+ The `Job` that carries a queued message. Exported so a custom queue driver can
12332
+ recognize it.
12333
+
12334
+
12335
+
12336
+ ---
12337
+
12338
+ <!-- source: docs/migrations.md -->
12339
+
12340
+ # Migrations
12341
+
12342
+ Version your database schema. A migration is a `{ name, up, down }` object; a
12343
+ fluent **schema builder** describes tables, and the **migrator** runs them
12344
+ against your [connection](./database.md), tracking what's applied. The SQL is
12345
+ dialect-aware (sqlite / mysql / postgres) and the core imports no driver.
12346
+
12347
+ ## Define migrations
12348
+
12349
+ ```ts
12350
+ import type { Migration } from "@shaferllc/keel/core";
11318
12351
 
11319
12352
  export const migrations: Migration[] = [
11320
12353
  {
@@ -12962,122 +13995,589 @@ The `database` channel. Persists the `toArray` payload through the query builder
12962
13995
 
12963
13996
  `new DatabaseChannel(table?: string)`
12964
13997
 
12965
- Creates a channel that writes to `table`.
13998
+ Creates a channel that writes to `table`.
13999
+
14000
+ ```ts
14001
+ new DatabaseChannel(); // → "notifications"
14002
+ new DatabaseChannel("alerts"); // → "alerts"
14003
+ ```
14004
+
14005
+ **Notes:** defaults to the `notifications` table.
14006
+
14007
+ #### `send(notifiable, notification)`
14008
+
14009
+ `send(notifiable: Notifiable, notification: Notification): Promise<void>`
14010
+
14011
+ Inserts one row: `type` (the notification's class name), `notifiable_id`
14012
+ (`routeFor(notifiable, "database")`, or `null`), and `data` (JSON of `toArray`).
14013
+
14014
+ ```ts
14015
+ await new DatabaseChannel().send(user, new InvoicePaid(4200));
14016
+ ```
14017
+
14018
+ **Notes:** stores `"{}"` for `data` when the notification has no `toArray`. The
14019
+ target table must exist — create it in a migration.
14020
+
14021
+ ### `ArrayChannel`
14022
+
14023
+ An in-memory channel for tests — records deliveries instead of sending them.
14024
+
14025
+ #### `sent`
14026
+
14027
+ `readonly sent: { notifiable: Notifiable; notification: Notification }[]`
14028
+
14029
+ The log of everything this channel received, in delivery order.
14030
+
14031
+ ```ts
14032
+ const array = new ArrayChannel();
14033
+ // … after notify …
14034
+ array.sent[0].notification; // the Notification instance
14035
+ ```
14036
+
14037
+ **Notes:** it keeps the notification *instance*, so you can `instanceof`-check it
14038
+ or read its fields — no serialization through `toArray`.
14039
+
14040
+ #### `send(notifiable, notification)`
14041
+
14042
+ `send(notifiable: Notifiable, notification: Notification): Promise<void>`
14043
+
14044
+ Pushes `{ notifiable, notification }` onto `sent`. Never touches the network.
14045
+
14046
+ ```ts
14047
+ new Notifier().channel("array", new ArrayChannel());
14048
+ ```
14049
+
14050
+ ### Interfaces & types
14051
+
14052
+ #### `Notifiable`
14053
+
14054
+ ```ts
14055
+ interface Notifiable {
14056
+ routeNotificationFor?(channel: string): string | number | undefined;
14057
+ [key: string]: unknown;
14058
+ }
14059
+ ```
14060
+
14061
+ A recipient — anything with routing info, most often a `User` model. Implement
14062
+ `routeNotificationFor` to steer specific channels; otherwise `routeFor` reads
14063
+ `email`/`id` off the index signature.
14064
+
14065
+ ```ts
14066
+ class User extends Model {
14067
+ routeNotificationFor(channel: string) {
14068
+ return channel === "mail" ? this.billing_email : undefined;
14069
+ }
14070
+ }
14071
+ ```
14072
+
14073
+ #### `MailContent`
14074
+
14075
+ ```ts
14076
+ interface MailContent {
14077
+ subject: string;
14078
+ text?: string;
14079
+ html?: string;
14080
+ from?: string;
14081
+ to?: string;
14082
+ }
14083
+ ```
14084
+
14085
+ What `toMail()` returns and the `mail` channel consumes. `subject` is required;
14086
+ supply `text`, `html`, or both. `to` overrides the resolved recipient; `from`
14087
+ overrides the mailer default.
14088
+
14089
+ ```ts
14090
+ toMail(): MailContent {
14091
+ return { subject: "Welcome", html: "<h1>Hi</h1>", to: "override@app.com" };
14092
+ }
14093
+ ```
14094
+
14095
+ #### `Channel`
14096
+
14097
+ ```ts
14098
+ interface Channel {
14099
+ send(notifiable: Notifiable, notification: Notification): Promise<void>;
14100
+ }
14101
+ ```
14102
+
14103
+ The seam a custom transport implements — SMS, Slack, push, anything. One method:
14104
+ `send`. Register your implementation with `Notifier.channel(name, channel)`.
14105
+
14106
+ ```ts
14107
+ const slack: Channel = {
14108
+ async send(notifiable, notification) {
14109
+ const payload = notification.toArray?.(notifiable) ?? {};
14110
+ // POST payload to a Slack webhook via fetch…
14111
+ },
14112
+ };
14113
+ ```
14114
+
14115
+
14116
+
14117
+ ---
14118
+
14119
+ <!-- source: docs/openapi.md -->
14120
+
14121
+ # OpenAPI
14122
+
14123
+ Keel OpenAPI generates an [OpenAPI 3](https://spec.openapis.org/oas/v3.0.3) spec
14124
+ from your routes and serves [Swagger UI](https://swagger.io/tools/swagger-ui/) to
14125
+ explore it. It's a Keel [package](./packages.md): one `register()` mounts the docs
14126
+ at `/docs` and the spec at `/docs/openapi.json`.
14127
+
14128
+ Nothing is scraped or guessed. The generator reads Keel's own route table —
14129
+ methods, paths, names, and param constraints are always correct — and enriches
14130
+ each operation with whatever the route attaches via `.config(apiDoc(...))`.
14131
+
14132
+ ## Install
14133
+
14134
+ ```ts
14135
+ // bootstrap/providers.ts
14136
+ import { OpenApiServiceProvider } from "@shaferllc/keel/openapi";
14137
+
14138
+ export const providers = [AppServiceProvider, OpenApiServiceProvider];
14139
+ ```
14140
+
14141
+ Open `http://localhost:3000/docs`. That's enough for a spec of every route (paths,
14142
+ methods, path params). To add summaries, request/response schemas, and tags,
14143
+ document the routes.
14144
+
14145
+ ## Documenting a route
14146
+
14147
+ `apiDoc()` returns route config the generator understands. Its `request` field is
14148
+ the same `{ body, query, params }` shape you hand `validateRequest`, so one set of
14149
+ Zod schemas both validates and documents:
14150
+
14151
+ ```ts
14152
+ import { apiDoc } from "@shaferllc/keel/openapi";
14153
+ import { validateRequest } from "@shaferllc/keel/core";
14154
+ import { z } from "zod";
14155
+
14156
+ const NewUser = z.object({ email: z.string().email(), age: z.number().min(18) });
14157
+
14158
+ router
14159
+ .post("/users", [Users, "store"])
14160
+ .config(apiDoc({
14161
+ summary: "Create a user",
14162
+ tags: ["users"],
14163
+ request: { body: NewUser },
14164
+ responses: { 201: { description: "The created user", schema: UserShape } },
14165
+ }))
14166
+ .middleware([validateRequest({ body: NewUser })]);
14167
+ ```
14168
+
14169
+ What the generator does with it:
14170
+
14171
+ - **Path params** — `/users/:id` becomes `/users/{id}`; a `.where("id", /\d+/)`
14172
+ constraint becomes a `pattern`.
14173
+ - **Query params** — a `request.query` schema's fields expand into query
14174
+ parameters (each `required` per the schema).
14175
+ - **Request body** — a `request.body` schema becomes a JSON request body
14176
+ (Zod → JSON Schema via Zod 4's `z.toJSONSchema`).
14177
+ - **Responses** — your documented responses, plus an automatic `422` when the
14178
+ route validates input. Undocumented routes get a default `200`.
14179
+ - **Tags** — `tags`, or the first path segment.
14180
+ - **operationId** — the route's `.name()`, else `method_path`.
14181
+
14182
+ Fields on `apiDoc`: `summary`, `description`, `tags`, `operationId`,
14183
+ `deprecated`, `request`, `responses`, and `hidden` (leave the route out entirely).
14184
+ Response and request schemas accept a Zod schema **or** a plain JSON Schema
14185
+ object.
14186
+
14187
+ ## Configuration
14188
+
14189
+ `config/openapi.ts` (publish with `keel vendor:publish --tag openapi-config`):
14190
+
14191
+ ```ts
14192
+ export default {
14193
+ enabled: true,
14194
+ path: "docs", // /docs and /docs/openapi.json
14195
+ title: "", // defaults to config("app.name")
14196
+ version: "1.0.0",
14197
+ servers: [], // e.g. ["https://api.example.com"]
14198
+ public: false, // serve in production too
14199
+ cdn: "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.17.14",
14200
+ ignorePaths: ["/watch"], // route prefixes to leave out
14201
+ };
14202
+ ```
14203
+
14204
+ ## Access
14205
+
14206
+ Like [Watch](./watch.md), the docs are gated shut in production by default (open
14207
+ only when `app.debug` is on or the app isn't in production). Set `public: true` to
14208
+ serve them everywhere, or plug in your own check:
14209
+
14210
+ ```ts
14211
+ import { OpenApi } from "@shaferllc/keel/openapi";
14212
+ OpenApi.auth((c) => auth().check());
14213
+ ```
14214
+
14215
+ The gate guards the spec endpoint too.
14216
+
14217
+ ## Exporting the spec
14218
+
14219
+ Write the spec to a file — for CI, client generation, or committing it:
14220
+
14221
+ ```bash
14222
+ keel openapi:export --out openapi.json
14223
+ ```
14224
+
14225
+ ## On the UI dependency
14226
+
14227
+ The spec (`/docs/openapi.json`) is generated with **zero dependencies** and runs
14228
+ anywhere Keel does, including the edge. The Swagger **UI** loads its assets from
14229
+ the configured `cdn` — the one external dependency, confined to the browser. Pin
14230
+ the version (the default is pinned) or point `cdn` at a copy you host if you need
14231
+ a fully self-contained deployment.
14232
+
14233
+
14234
+
14235
+ ---
14236
+
14237
+ <!-- source: docs/packages.md -->
14238
+
14239
+ # Packages
14240
+
14241
+ A **package** is a redistributable slice of a Keel app — routes, a UI, config,
14242
+ migrations, console commands — that installs with a single `app.register(...)`.
14243
+ Keel's `ServiceProvider` is already the unit of composition; `PackageProvider`
14244
+ adds the conventions a *shippable* package needs so it can carry its own schema
14245
+ and assets instead of asking the app to wire them by hand.
14246
+
14247
+ [Keel Watch](./watch.md) — the debug dashboard — is a first-party package and the
14248
+ reference implementation of everything below.
14249
+
14250
+ ## The shape of a package
14251
+
14252
+ ```ts
14253
+ import { PackageProvider, type Router } from "@shaferllc/keel/core";
14254
+ import { fileURLToPath } from "node:url";
14255
+ import { dirname, join } from "node:path";
14256
+
14257
+ const here = dirname(fileURLToPath(import.meta.url));
14258
+
14259
+ export class BillingServiceProvider extends PackageProvider {
14260
+ readonly name = "billing"; // used for publish grouping and diagnostics
14261
+
14262
+ register(): void {
14263
+ this.mergeConfig("billing", { enabled: true, path: "billing" });
14264
+ this.migrations([createInvoicesTable]);
14265
+ this.publishes({ [join(here, "config.stub")]: "config/billing.ts" }, "billing-config");
14266
+ this.commands([syncInvoicesCommand]);
14267
+ }
14268
+
14269
+ boot(): void {
14270
+ this.assets("billing/assets", join(here, "ui/dist"), { maxAge: 3600 });
14271
+ this.routes((r: Router) => registerBillingRoutes(r), { prefix: "billing", as: "billing" });
14272
+ }
14273
+ }
14274
+ ```
14275
+
14276
+ Scaffold that skeleton with `keel make:package billing`.
14277
+
14278
+ ## The helpers
14279
+
14280
+ Each is a thin wrapper over an existing Keel primitive — the value is the
14281
+ convention, not new machinery.
14282
+
14283
+ | Helper | What it does |
14284
+ |--------|--------------|
14285
+ | `mergeConfig(key, defaults)` | Set config defaults under `key`. The app's `config/<key>.ts` deep-merges **over** them, so the app always wins. |
14286
+ | `routes(register, { prefix, middleware, as })` | Register a route group (the callback gets the `Router`), already prefixed/guarded/name-prefixed. |
14287
+ | `assets(urlPrefix, dir, { maxAge, immutable })` | Serve a directory of built files (a bundled UI) under a URL prefix. Node-only. |
14288
+ | `migrations(list)` | Contribute migrations, run by `keel migrate` alongside the app's own. |
14289
+ | `commands(list)` | Add `keel` console commands (e.g. `billing:sync`). |
14290
+ | `publishes(map, tag?)` | Declare files a consuming app can copy in with `keel vendor:publish`. |
14291
+
14292
+ ## Lifecycle: mind the kernel
14293
+
14294
+ `register()` and `boot()` run **before** the app's HTTP kernel is bound (see
14295
+ `bootstrap/app.ts`). So a package must not reach for `HttpKernel`. That's why
14296
+ `routes()` and `assets()` go through the `Router` (bound in the Application
14297
+ constructor) — routes are compiled onto the kernel later, at build time. Use
14298
+ `register()` for config/bindings and `boot()` for wiring; both are safe for the
14299
+ helpers above.
14300
+
14301
+ ## Migrations
14302
+
14303
+ Package migrations join the app's under one command:
14304
+
14305
+ ```bash
14306
+ keel migrate # run pending (app + package) migrations
14307
+ keel migrate:status # show which have run
14308
+ keel migrate:rollback # roll back the last batch
14309
+ ```
14310
+
14311
+ App migrations are discovered from `database/migrations/*.ts` (each file
14312
+ default-exports a `Migration` or `Migration[]`); package migrations come from
14313
+ `this.migrations(...)`. Both run against the default connection.
14314
+
14315
+ ## Publishing files
14316
+
14317
+ `publishes()` declares source→destination copies; `keel vendor:publish` performs
14318
+ them (skipping files that already exist unless `--force`):
14319
+
14320
+ ```bash
14321
+ keel vendor:publish # everything
14322
+ keel vendor:publish --tag billing-config # just one tagged group
14323
+ ```
14324
+
14325
+ This is how a package ships an overridable config stub, or copies a starter view
14326
+ into the consuming app.
14327
+
14328
+ ## Observing the framework
14329
+
14330
+ A package often wants to *see* what the app is doing — every query, request, or
14331
+ job — without patching anything. The framework emits a typed **instrumentation
14332
+ event stream** for exactly this; subscribe with `listen()`:
14333
+
14334
+ ```ts
14335
+ import { listen, type QueryEvent } from "@shaferllc/keel/core";
14336
+
14337
+ listen<QueryEvent>("db.query", (e) => metrics.timing("db", e.durationMs));
14338
+ ```
14339
+
14340
+ | Event | Fired when |
14341
+ |-------|-----------|
14342
+ | `db.query` | a query runs (sql, bindings, durationMs, connection, kind) |
14343
+ | `request.handled` | a request finishes (method, path, status, durationMs, headers) |
14344
+ | `exception` | an error reaches the HTTP kernel |
14345
+ | `job.processing` / `job.processed` / `job.failed` | a queued job's lifecycle |
14346
+ | `cache.hit` / `cache.miss` | a cache lookup |
14347
+ | `notification.sent` | a notification is delivered |
14348
+ | `schedule.task.run` | a scheduled task runs |
14349
+ | `mail.sending` / `mail.sent` | mail lifecycle |
14350
+
14351
+ Every request opens a scope with a **request id** that flows through async work,
14352
+ so anything emitted inside a request can attribute itself to it via
14353
+ `currentRequestId()` — that's what lets [Watch](./watch.md) tie a request to the
14354
+ queries and logs it produced. Emitting is fire-and-forget: a broken listener can
14355
+ never break the work it observes.
14356
+ ```
14357
+
14358
+
14359
+
14360
+ ---
14361
+
14362
+ <!-- source: docs/pages.md -->
14363
+
14364
+ # Pages
14365
+
14366
+ Page-based routing — **a file is a route**.
14367
+
14368
+ ```
14369
+ resources/pages/index.tsx → /
14370
+ resources/pages/about.tsx → /about
14371
+ resources/pages/users/index.tsx → /users
14372
+ resources/pages/users/[id].tsx → /users/:id
14373
+ resources/pages/docs/[...slug].tsx → /docs/* (catch-all)
14374
+ ```
14375
+
14376
+ ```tsx
14377
+ // resources/pages/users/[id].tsx
14378
+ import { db, type Ctx, type PageProps } from "@shaferllc/keel/core";
14379
+
14380
+ export const loader = (c: Ctx) => db("users").where("id", c.req.param("id")).first();
14381
+
14382
+ export default function UserPage({ params, data }: PageProps<{ id: string }, User>) {
14383
+ return (
14384
+ <Layout>
14385
+ <h1>{data.name}</h1>
14386
+ <p>User #{params.id}</p>
14387
+ </Layout>
14388
+ );
14389
+ }
14390
+ ```
14391
+
14392
+ That's the whole page. No route file to keep in sync, no controller, no wiring.
14393
+
14394
+ ## It doesn't replace the router — it drives it
14395
+
14396
+ Every page becomes an **ordinary named route**. `url()` finds it, route middleware
14397
+ applies to it, and `keel routes` lists it. You can mix pages and hand-written
14398
+ routes freely, and reach for a controller the moment a page outgrows a file.
14399
+
14400
+ That matters, because file-based routing is a lovely default and a bad prison.
14401
+ Here it's a *convenience over* the router, not a replacement for it.
14402
+
14403
+ ## Registering them
14404
+
14405
+ In a service provider's `boot()`:
14406
+
14407
+ ```ts
14408
+ import { pages } from "@shaferllc/keel/core";
14409
+
14410
+ export class PageServiceProvider extends ServiceProvider {
14411
+ async boot(): Promise<void> {
14412
+ await pages(); // scans resources/pages
14413
+ }
14414
+ }
14415
+ ```
14416
+
14417
+ `pages()` reads the filesystem, so it's **Node-only**. On the edge, hand
14418
+ `definePages()` a build-time manifest instead — Vite's `import.meta.glob` produces
14419
+ exactly the map it wants:
14420
+
14421
+ ```ts
14422
+ definePages(import.meta.glob("./pages/**/*.tsx", { eager: true }));
14423
+ ```
14424
+
14425
+ Same behavior, no filesystem.
14426
+
14427
+ ## The file conventions
14428
+
14429
+ | File | URL |
14430
+ |------|-----|
14431
+ | `index.tsx` | `/` |
14432
+ | `about.tsx` | `/about` |
14433
+ | `users/index.tsx` | `/users` |
14434
+ | `users/[id].tsx` | `/users/:id` |
14435
+ | `users/[id]/edit.tsx` | `/users/:id/edit` |
14436
+ | `teams/[team]/users/[id].tsx` | `/teams/:team/users/:id` |
14437
+ | `docs/[...slug].tsx` | `/docs/*` — a catch-all; `params.slug` is the whole rest |
14438
+ | `_layout.tsx` | **not a route** — a leading `_` keeps a file private |
14439
+
14440
+ A trailing `index` names its directory rather than a child of it. A leading
14441
+ underscore is how layouts, partials, and helpers live *beside* your pages without
14442
+ becoming URLs.
14443
+
14444
+ ## Specificity is decided for you
14445
+
14446
+ This is the part file-based routing usually gets wrong:
14447
+
14448
+ ```
14449
+ users/[id].tsx → /users/:id
14450
+ users/new.tsx → /users/new
14451
+ ```
14452
+
14453
+ Register `:id` first and `/users/new` is **unreachable forever** — `:id` happily
14454
+ matches `"new"`. Whether your app works would come down to the order the
14455
+ filesystem happened to hand back.
14456
+
14457
+ So pages are **sorted before they're registered**: literal segments beat
14458
+ parameters, parameters beat catch-alls, and a catch-all is always the last resort.
14459
+ `/users/new` wins, and the file layout stops being a trap.
14460
+
14461
+ ## Loading data
14462
+
14463
+ `loader` runs before the page renders; whatever it returns arrives as `data`.
14464
+
14465
+ ```tsx
14466
+ export const loader = async (c: Ctx) => {
14467
+ const post = await db("posts").where("slug", c.req.param("slug")).first();
14468
+ if (!post) throw new NotFoundException();
14469
+ return post;
14470
+ };
14471
+
14472
+ export default function PostPage({ data }: PageProps<{ slug: string }, Post>) {
14473
+ return <article>{data.body}</article>;
14474
+ }
14475
+ ```
14476
+
14477
+ It takes the request context, so it can read params, query, headers, or the
14478
+ session. A page with no `loader` simply gets `data: undefined`.
14479
+
14480
+ ## Middleware
14481
+
14482
+ Per page:
14483
+
14484
+ ```tsx
14485
+ export const middleware = [authGuard()];
14486
+ ```
14487
+
14488
+ Or for every page at once:
12966
14489
 
12967
14490
  ```ts
12968
- new DatabaseChannel(); // → "notifications"
12969
- new DatabaseChannel("alerts"); // → "alerts"
14491
+ await pages({ middleware: [authGuard()] });
12970
14492
  ```
12971
14493
 
12972
- **Notes:** defaults to the `notifications` table.
12973
-
12974
- #### `send(notifiable, notification)`
14494
+ Both run before the `loader`, so a page that's refused never loads its data.
12975
14495
 
12976
- `send(notifiable: Notifiable, notification: Notification): Promise<void>`
14496
+ ## Names and URLs
12977
14497
 
12978
- Inserts one row: `type` (the notification's class name), `notifiable_id`
12979
- (`routeFor(notifiable, "database")`, or `null`), and `data` (JSON of `toArray`).
14498
+ Each page gets a route name derived from its path — `users/[id].tsx` becomes
14499
+ `users.id` so URL generation works without you naming anything:
12980
14500
 
12981
14501
  ```ts
12982
- await new DatabaseChannel().send(user, new InvoicePaid(4200));
14502
+ router.url("users.id", { id: 5 }); // "/users/5"
12983
14503
  ```
12984
14504
 
12985
- **Notes:** stores `"{}"` for `data` when the notification has no `toArray`. The
12986
- target table must exist — create it in a migration.
14505
+ Override it when the derived name is ugly:
12987
14506
 
12988
- ### `ArrayChannel`
14507
+ ```tsx
14508
+ export const name = "users.show";
14509
+ ```
12989
14510
 
12990
- An in-memory channel for tests — records deliveries instead of sending them.
14511
+ ## Escape hatches
12991
14512
 
12992
- #### `sent`
14513
+ Move a page's URL without moving the file:
12993
14514
 
12994
- `readonly sent: { notifiable: Notifiable; notification: Notification }[]`
14515
+ ```tsx
14516
+ export const path = "/pricing"; // even though the file is at marketing/plans.tsx
14517
+ ```
12995
14518
 
12996
- The log of everything this channel received, in delivery order.
14519
+ Mount every page under a prefix:
12997
14520
 
12998
14521
  ```ts
12999
- const array = new ArrayChannel();
13000
- // after notify
13001
- array.sent[0].notification; // the Notification instance
14522
+ await pages({ prefix: "/app" }); // index.tsx is now /app
14523
+ await pages({ dir: "app/pages" }); // ...or keep them somewhere else
13002
14524
  ```
13003
14525
 
13004
- **Notes:** it keeps the notification *instance*, so you can `instanceof`-check it
13005
- or read its fields — no serialization through `toArray`.
14526
+ ---
13006
14527
 
13007
- #### `send(notifiable, notification)`
14528
+ ## API reference
13008
14529
 
13009
- `send(notifiable: Notifiable, notification: Notification): Promise<void>`
14530
+ ### `pages(options?)`
13010
14531
 
13011
- Pushes `{ notifiable, notification }` onto `sent`. Never touches the network.
14532
+ `pages(options?: PagesOptions): Promise<RegisteredPage[]>`
13012
14533
 
13013
- ```ts
13014
- new Notifier().channel("array", new ArrayChannel());
13015
- ```
14534
+ Scan a directory and register every page in it. **Node only** — it reads the
14535
+ filesystem (`node:fs` is imported dynamically, so the core still loads on the
14536
+ edge).
13016
14537
 
13017
- ### Interfaces & types
14538
+ ### `definePages(modules, options?)`
13018
14539
 
13019
- #### `Notifiable`
14540
+ `definePages(modules: Record<string, PageModule>, options?: PagesOptions): RegisteredPage[]`
13020
14541
 
13021
- ```ts
13022
- interface Notifiable {
13023
- routeNotificationFor?(channel: string): string | number | undefined;
13024
- [key: string]: unknown;
13025
- }
13026
- ```
14542
+ Register pages from a `file path → module` map. The edge-safe half — pair it with
14543
+ `import.meta.glob`.
13027
14544
 
13028
- A recipient — anything with routing info, most often a `User` model. Implement
13029
- `routeNotificationFor` to steer specific channels; otherwise `routeFor` reads
13030
- `email`/`id` off the index signature.
14545
+ ### `PagesOptions`
13031
14546
 
13032
- ```ts
13033
- class User extends Model {
13034
- routeNotificationFor(channel: string) {
13035
- return channel === "mail" ? this.billing_email : undefined;
13036
- }
13037
- }
13038
- ```
14547
+ | Option | Meaning |
14548
+ |--------|---------|
14549
+ | `dir` | Where the pages live. Default `"resources/pages"` |
14550
+ | `prefix` | Prefix every page's URL |
14551
+ | `middleware` | Middleware applied to every page |
14552
+ | `router` | The router to register on. Defaults to the application's |
13039
14553
 
13040
- #### `MailContent`
14554
+ ### `PageModule`
13041
14555
 
13042
- ```ts
13043
- interface MailContent {
13044
- subject: string;
13045
- text?: string;
13046
- html?: string;
13047
- from?: string;
13048
- to?: string;
13049
- }
13050
- ```
14556
+ What a page file exports.
13051
14557
 
13052
- What `toMail()` returns and the `mail` channel consumes. `subject` is required;
13053
- supply `text`, `html`, or both. `to` overrides the resolved recipient; `from`
13054
- overrides the mailer default.
14558
+ | Export | Meaning |
14559
+ |--------|---------|
14560
+ | `default` | **Required.** The component. May be async |
14561
+ | `loader` | `(ctx) => data` — runs before the page renders |
14562
+ | `middleware` | Middleware for this page alone |
14563
+ | `name` | The route name. Defaults to one derived from the path |
14564
+ | `path` | Override the URL entirely |
13055
14565
 
13056
- ```ts
13057
- toMail(): MailContent {
13058
- return { subject: "Welcome", html: "<h1>Hi</h1>", to: "override@app.com" };
13059
- }
13060
- ```
14566
+ ### `PageProps<P, D>`
13061
14567
 
13062
- #### `Channel`
14568
+ What the component receives: `params` (typed by `P`), `data` (whatever `loader`
14569
+ returned, typed by `D`), and `ctx`.
13063
14570
 
13064
- ```ts
13065
- interface Channel {
13066
- send(notifiable: Notifiable, notification: Notification): Promise<void>;
13067
- }
13068
- ```
14571
+ ### `RegisteredPage`
13069
14572
 
13070
- The seam a custom transport implements SMS, Slack, push, anything. One method:
13071
- `send`. Register your implementation with `Notifier.channel(name, channel)`.
14573
+ `{ file, pattern, name }`what `pages()` / `definePages()` return, so you can
14574
+ see exactly what got mounted.
13072
14575
 
13073
- ```ts
13074
- const slack: Channel = {
13075
- async send(notifiable, notification) {
13076
- const payload = notification.toArray?.(notifiable) ?? {};
13077
- // POST payload to a Slack webhook via fetch…
13078
- },
13079
- };
13080
- ```
14576
+ ### `routePattern(file)` / `routeName(file)`
14577
+
14578
+ The two pure functions behind the conventions, exported so you can test or reuse
14579
+ them. `routePattern("users/[id].tsx")` `"/users/:id"`;
14580
+ `routeName("users/[id].tsx")` `"users.id"`.
13081
14581
 
13082
14582
 
13083
14583
 
@@ -15199,9 +16699,11 @@ file.
15199
16699
 
15200
16700
  > **The disk's `url()` prefix and `basePath` must agree.** `signedUrl()` signs the
15201
16701
  > path the *disk* reports, so if the disk hands out `/storage/…` while
15202
- > `serveStorage` listens on `/private`, every signed URL will 403. Give the disk
15203
- > the matching base`new MemoryDisk("/private")`, or `localDisk("./storage",
15204
- > "/private")`or keep both on the default `/storage`.
16702
+ > `serveStorage` listens on `/private`, no signature could ever match. Rather than
16703
+ > 403 every requestwhich reads as "your link expired" and sends you hunting in
16704
+ > the wrong place `serveStorage` **throws** with the two paths and how to line
16705
+ > them up. Give the disk the matching base (`new MemoryDisk("/private")`, or
16706
+ > `localDisk("./storage", "/private")`), or keep both on the default `/storage`.
15205
16707
 
15206
16708
  ## Direct browser uploads
15207
16709
 
@@ -15435,108 +16937,378 @@ const s3Disk = (options: {
15435
16937
  },
15436
16938
  url: (path) => `${options.baseUrl}/${path}`,
15437
16939
 
15438
- // The capabilities that matter: the bucket signs, so the bytes skip your app.
15439
- signedUrl: (path, o) => presign(path, "GET", o?.expiresIn ?? 3600),
15440
- signedUploadUrl: (path, o) => presign(path, "PUT", o?.expiresIn ?? 3600, o?.contentType),
15441
- };
15442
- };
16940
+ // The capabilities that matter: the bucket signs, so the bytes skip your app.
16941
+ signedUrl: (path, o) => presign(path, "GET", o?.expiresIn ?? 3600),
16942
+ signedUploadUrl: (path, o) => presign(path, "PUT", o?.expiresIn ?? 3600, o?.contentType),
16943
+ };
16944
+ };
16945
+ ```
16946
+
16947
+ ## API reference
16948
+
16949
+ ### `storage(name?)`
16950
+
16951
+ `storage(name?: string): Storage`
16952
+
16953
+ The default disk, or a named one registered with `setDisk(disk, name)`. Throws
16954
+ for an unknown name.
16955
+
16956
+ ### `setDisk(disk, name?)`
16957
+
16958
+ `setDisk(disk: Disk, name?: string): Storage`
16959
+
16960
+ Registers a disk (default name `"default"`) and returns the wrapping `Storage`.
16961
+
16962
+ ### `Storage`
16963
+
16964
+ Wraps a `Disk`.
16965
+
16966
+ | Method | Signature |
16967
+ |--------|-----------|
16968
+ | `put` | `(path, contents: string \| Uint8Array \| ArrayBuffer, options?: WriteOptions) => Promise<void>` |
16969
+ | `get` | `(path) => Promise<Uint8Array \| null>` |
16970
+ | `getText` | `(path) => Promise<string \| null>` |
16971
+ | `exists` / `delete` | `(path) => Promise<boolean>` / `Promise<void>` |
16972
+ | `list` | `(prefix?) => Promise<string[]>` |
16973
+ | `metadata` | `(path) => Promise<FileMetadata \| null>` |
16974
+ | `size` | `(path) => Promise<number \| null>` |
16975
+ | `copy` / `move` | `(from, to) => Promise<void>` |
16976
+ | `url` | `(path) => string` — the public URL |
16977
+ | `signedUrl` | `(path, options?: SignedFileOptions) => Promise<string>` |
16978
+ | `signedUploadUrl` | `(path, options?: SignedUploadOptions) => Promise<string>` |
16979
+ | `driver` | the underlying `Disk` |
16980
+
16981
+ ### `fakeDisk(name?)` / `restoreDisk(name?)`
16982
+
16983
+ `fakeDisk(name?: string): FakeStorage` swaps a disk for an in-memory
16984
+ `FakeStorage`. `restoreDisk(name?)` puts the real one back — with no name, every
16985
+ faked disk.
16986
+
16987
+ `FakeStorage` is a `Storage` plus `assertExists(path)`, `assertMissing(path)`,
16988
+ `assertContents(path, text)`, and `assertCount(n, prefix?)`.
16989
+
16990
+ ### `serveStorage(options?)`
16991
+
16992
+ `serveStorage(options?: ServeStorageOptions): MiddlewareHandler`
16993
+
16994
+ Serves a disk's files over HTTP. Options: `disk` (name, default `"default"`),
16995
+ `basePath` (default `"/storage"`), `signed` (require a valid signature, 403
16996
+ otherwise), `maxAge` (`Cache-Control` seconds).
16997
+
16998
+ ### `signStorageUrl(url, expiresIn?)` / `verifyStorageUrl(url)`
16999
+
17000
+ `signStorageUrl(url: string, expiresIn?: number): Promise<string>` adds `expires`
17001
+ and `signature` params, signed with `config('app.key')` (default one hour).
17002
+ `verifyStorageUrl(url: string): Promise<boolean>` checks them. Signing covers the
17003
+ path and query, not the host.
17004
+
17005
+ ### `contentTypeFor(path)`
17006
+
17007
+ `contentTypeFor(path: string): string` — the MIME type for a path's extension, or
17008
+ `application/octet-stream`.
17009
+
17010
+ ### `MemoryDisk`
17011
+
17012
+ `class MemoryDisk implements Disk` — in-memory, the default and ideal for tests.
17013
+ `new MemoryDisk(baseUrl?)` sets the `url()` prefix. Not shared across processes.
17014
+
17015
+ ### Interfaces & types
17016
+
17017
+ #### `Disk`
17018
+
17019
+ The driver seam. Required: `put` / `get` / `exists` / `delete` / `list` / `url`.
17020
+ Optional capabilities: `metadata` / `copy` / `move` / `signedUrl` /
17021
+ `signedUploadUrl`.
17022
+
17023
+ #### `WriteOptions`
17024
+
17025
+ `{ contentType?, cacheControl?, visibility?, metadata? }` — passed to `put`.
17026
+
17027
+ #### `FileMetadata`
17028
+
17029
+ `{ size, contentType?, cacheControl?, visibility?, lastModified?, metadata? }`.
17030
+
17031
+ #### `SignedFileOptions` / `SignedUploadOptions`
17032
+
17033
+ `{ expiresIn? }` (seconds, default 3600), plus `contentType?` for uploads.
17034
+
17035
+ #### `FileVisibility`
17036
+
17037
+ `type FileVisibility = "public" | "private"`.
17038
+
17039
+ #### `Contents`
17040
+
17041
+ `type Contents = string | Uint8Array | ArrayBuffer` — accepted by `put`.
17042
+
17043
+
17044
+
17045
+ ---
17046
+
17047
+ <!-- source: docs/telemetry.md -->
17048
+
17049
+ # Telemetry
17050
+
17051
+ Distributed tracing — spans, W3C trace context, and an OTLP exporter — with **no
17052
+ SDK**.
17053
+
17054
+ ```ts
17055
+ import { setTelemetry, Tracer, otlpExporter, tracing, trace } from "@shaferllc/keel/core";
17056
+
17057
+ setTelemetry(
17058
+ new Tracer({
17059
+ serviceName: "api",
17060
+ exporter: otlpExporter({ url: "http://localhost:4318/v1/traces" }),
17061
+ sampleRatio: 0.1, // 10% of traces in production
17062
+ }),
17063
+ );
17064
+
17065
+ // in your HTTP kernel
17066
+ this.use(tracing()); // a server span per request
17067
+ ```
17068
+
17069
+ ```ts
17070
+ // your own spans, anywhere
17071
+ await trace("charge", async (span) => {
17072
+ span.setAttributes({ "order.id": order.id });
17073
+ await stripe.charge(order);
17074
+ });
17075
+ ```
17076
+
17077
+ ## Why there's no SDK here
17078
+
17079
+ The OpenTelemetry Node SDK is a large tree of packages that assumes a Node
17080
+ process. What a trace actually **is**, though, is small: an id, a parent, a start
17081
+ and an end, some attributes — and a documented JSON shape to POST them in.
17082
+
17083
+ That's what this is. It speaks **OTLP/HTTP over `fetch`**, so it runs on Workers as
17084
+ happily as on Node, and any OTLP collector accepts it — Jaeger, Tempo, Honeycomb,
17085
+ Grafana, Datadog. You don't get the SDK's auto-instrumentation of every library
17086
+ under the sun; you get the part that matters, in about 400 lines you can read.
17087
+
17088
+ ## Spans
17089
+
17090
+ `trace(name, fn)` opens a span, runs your function inside it, and closes it — even
17091
+ if the function throws, in which case the error is recorded on the span and
17092
+ rethrown.
17093
+
17094
+ ```ts
17095
+ const receipt = await trace("charge", async (span) => {
17096
+ span.setAttributes({ "order.id": id, currency: "USD" });
17097
+ span.addEvent("calling stripe");
17098
+
17099
+ return stripe.charge(id); // a throw here marks the span failed, then propagates
17100
+ });
17101
+ ```
17102
+
17103
+ **Spans nest automatically.** A `trace()` inside another `trace()` becomes its
17104
+ child, sharing the trace id — you don't thread anything through:
17105
+
17106
+ ```ts
17107
+ await trace("checkout", async () => {
17108
+ await trace("reserve-stock", async () => { … }); // child
17109
+ await trace("charge", async () => { … }); // sibling of the above
17110
+ });
17111
+ ```
17112
+
17113
+ That works across `await` boundaries, and across **concurrent** traces, because
17114
+ the current span is tracked in `AsyncLocalStorage` rather than a global. Two
17115
+ requests in flight at once don't get tangled.
17116
+
17117
+ From anywhere inside a span:
17118
+
17119
+ ```ts
17120
+ setAttributes({ tenant: "acme" }); // add to the current span
17121
+ addEvent("cache miss", { key }); // a timestamped annotation
17122
+ currentSpan(); // the Span itself, or undefined
17123
+ ```
17124
+
17125
+ All of these are **no-ops outside a span**, so instrumented code stays safe to call
17126
+ from a script or a test.
17127
+
17128
+ ## HTTP requests
17129
+
17130
+ `tracing()` opens a **server span** per request, records the method, path, and
17131
+ status, and closes it when the response is sent.
17132
+
17133
+ ```ts
17134
+ this.use(tracing());
17135
+ ```
17136
+
17137
+ A 5xx marks the span failed; a 404 doesn't — that's a valid answer, not a fault.
17138
+
17139
+ It also writes a `traceparent` header onto the **response**, so when a user says
17140
+ "this page was slow", you can look up their exact trace.
17141
+
17142
+ `/health/*`, `/metrics`, and `/favicon.ico` are ignored by default — they're noise.
17143
+ Change that with `ignore`, and name spans yourself with `name`:
17144
+
17145
+ ```ts
17146
+ this.use(
17147
+ tracing({
17148
+ ignore: (path) => path.startsWith("/internal"),
17149
+ name: (method, path) => `${method} ${path.replace(/\/\d+/, "/:id")}`,
17150
+ }),
17151
+ );
17152
+ ```
17153
+
17154
+ ## Following a trace between services
17155
+
17156
+ This is the point of tracing: one id spanning every service a request touches.
17157
+
17158
+ **Incoming.** `tracing()` reads the caller's `traceparent` header and makes your
17159
+ span a **child of theirs**, so both land in the same trace. A missing or malformed
17160
+ header just starts a fresh trace — never an error.
17161
+
17162
+ **Outgoing.** `injectTraceContext()` puts the current context on your request
17163
+ headers, so the service you call joins this trace instead of starting its own:
17164
+
17165
+ ```ts
17166
+ await fetch(url, {
17167
+ headers: injectTraceContext({ accept: "application/json" }),
17168
+ });
17169
+ ```
17170
+
17171
+ `parseTraceparent()` and `traceparent()` are the two halves on their own, if you
17172
+ need to carry the context somewhere odd — a queue payload, say.
17173
+
17174
+ ## Connecting logs to traces
17175
+
17176
+ `traceIds()` returns the current `trace_id` and `span_id`. Bind them to your logger
17177
+ and every line becomes a jumping-off point into the trace it came from:
17178
+
17179
+ ```ts
17180
+ const log = logger().child(traceIds());
17181
+ log.info("charging card", { orderId }); // carries trace_id + span_id
15443
17182
  ```
15444
17183
 
15445
- ## API reference
17184
+ ## Sampling
15446
17185
 
15447
- ### `storage(name?)`
17186
+ Recording every trace in production is expensive. `sampleRatio` records a fraction:
15448
17187
 
15449
- `storage(name?: string): Storage`
17188
+ ```ts
17189
+ new Tracer({ sampleRatio: 0.1 }); // 10%
17190
+ ```
15450
17191
 
15451
- The default disk, or a named one registered with `setDisk(disk, name)`. Throws
15452
- for an unknown name.
17192
+ The decision is made **once, at the root span, and inherited by every child** —
17193
+ because half a trace is worse than no trace. An unsampled span still runs (your
17194
+ code is unaffected); it just isn't exported.
15453
17195
 
15454
- ### `setDisk(disk, name?)`
17196
+ ## Exporters
15455
17197
 
15456
- `setDisk(disk: Disk, name?: string): Storage`
17198
+ | Exporter | Use |
17199
+ |----------|-----|
17200
+ | `otlpExporter({ url, headers, resource })` | Any OTLP/HTTP collector. Production. |
17201
+ | `consoleExporter()` | Prints each span. Local development, no collector needed. |
17202
+ | `MemoryExporter` | Collects spans in memory. Tests. |
15457
17203
 
15458
- Registers a disk (default name `"default"`) and returns the wrapping `Storage`.
17204
+ ```ts
17205
+ otlpExporter({
17206
+ url: "https://api.honeycomb.io/v1/traces",
17207
+ headers: { "x-honeycomb-team": env("HONEYCOMB_KEY") },
17208
+ resource: { "service.name": "api", "deployment.environment": "prod" },
17209
+ });
17210
+ ```
15459
17211
 
15460
- ### `Storage`
17212
+ Spans are **buffered** and sent in batches (100 by default, via `batchSize`).
15461
17213
 
15462
- Wraps a `Disk`.
17214
+ **Flush before the process — or the isolate — goes away**, or the last few spans
17215
+ die with it:
15463
17216
 
15464
- | Method | Signature |
15465
- |--------|-----------|
15466
- | `put` | `(path, contents: string \| Uint8Array \| ArrayBuffer, options?: WriteOptions) => Promise<void>` |
15467
- | `get` | `(path) => Promise<Uint8Array \| null>` |
15468
- | `getText` | `(path) => Promise<string \| null>` |
15469
- | `exists` / `delete` | `(path) => Promise<boolean>` / `Promise<void>` |
15470
- | `list` | `(prefix?) => Promise<string[]>` |
15471
- | `metadata` | `(path) => Promise<FileMetadata \| null>` |
15472
- | `size` | `(path) => Promise<number \| null>` |
15473
- | `copy` / `move` | `(from, to) => Promise<void>` |
15474
- | `url` | `(path) => string` — the public URL |
15475
- | `signedUrl` | `(path, options?: SignedFileOptions) => Promise<string>` |
15476
- | `signedUploadUrl` | `(path, options?: SignedUploadOptions) => Promise<string>` |
15477
- | `driver` | the underlying `Disk` |
17217
+ ```ts
17218
+ onShutdown(() => flushTelemetry());
17219
+ ```
15478
17220
 
15479
- ### `fakeDisk(name?)` / `restoreDisk(name?)`
17221
+ On Workers, call it at the end of a request (ideally inside `waitUntil`).
15480
17222
 
15481
- `fakeDisk(name?: string): FakeStorage` swaps a disk for an in-memory
15482
- `FakeStorage`. `restoreDisk(name?)` puts the real one back — with no name, every
15483
- faked disk.
17223
+ ## Testing
15484
17224
 
15485
- `FakeStorage` is a `Storage` plus `assertExists(path)`, `assertMissing(path)`,
15486
- `assertContents(path, text)`, and `assertCount(n, prefix?)`.
17225
+ `MemoryExporter` collects spans so you can assert on them:
15487
17226
 
15488
- ### `serveStorage(options?)`
17227
+ ```ts
17228
+ import { Tracer, MemoryExporter, setTelemetry, trace } from "@shaferllc/keel/core";
15489
17229
 
15490
- `serveStorage(options?: ServeStorageOptions): MiddlewareHandler`
17230
+ const exporter = new MemoryExporter();
17231
+ setTelemetry(new Tracer({ exporter, batchSize: 1 })); // batchSize 1 = export immediately
15491
17232
 
15492
- Serves a disk's files over HTTP. Options: `disk` (name, default `"default"`),
15493
- `basePath` (default `"/storage"`), `signed` (require a valid signature, 403
15494
- otherwise), `maxAge` (`Cache-Control` seconds).
17233
+ await trace("charge", async (span) => span.setAttributes({ "order.id": 1 }));
15495
17234
 
15496
- ### `signStorageUrl(url, expiresIn?)` / `verifyStorageUrl(url)`
17235
+ const span = exporter.named("charge")[0];
17236
+ assert.equal(span.status, "unset");
17237
+ assert.equal(span.attributes["order.id"], 1);
17238
+ ```
15497
17239
 
15498
- `signStorageUrl(url: string, expiresIn?: number): Promise<string>` adds `expires`
15499
- and `signature` params, signed with `config('app.key')` (default one hour).
15500
- `verifyStorageUrl(url: string): Promise<boolean>` checks them. Signing covers the
15501
- path and query, not the host.
17240
+ `exporter.trace(traceId)` returns every span in one trace; `exporter.clear()`
17241
+ empties it between tests.
15502
17242
 
15503
- ### `contentTypeFor(path)`
17243
+ ---
15504
17244
 
15505
- `contentTypeFor(path: string): string` — the MIME type for a path's extension, or
15506
- `application/octet-stream`.
17245
+ ## API reference
15507
17246
 
15508
- ### `MemoryDisk`
17247
+ ### `trace(name, fn, options?)`
15509
17248
 
15510
- `class MemoryDisk implements Disk` in-memory, the default and ideal for tests.
15511
- `new MemoryDisk(baseUrl?)` sets the `url()` prefix. Not shared across processes.
17249
+ `trace<T>(name: string, fn: (span: Span) => Promise<T> | T, options?: SpanOptions): Promise<T>`
15512
17250
 
15513
- ### Interfaces & types
17251
+ Run `fn` inside a span. The span is current for the duration, ends when `fn`
17252
+ settles, and records a throw before rethrowing it.
15514
17253
 
15515
- #### `Disk`
17254
+ ### `currentSpan()` / `setAttributes(attrs)` / `addEvent(name, attrs?)`
15516
17255
 
15517
- The driver seam. Required: `put` / `get` / `exists` / `delete` / `list` / `url`.
15518
- Optional capabilities: `metadata` / `copy` / `move` / `signedUrl` /
15519
- `signedUploadUrl`.
17256
+ Reach the span in scope. All no-ops outside one.
15520
17257
 
15521
- #### `WriteOptions`
17258
+ ### `traceIds()`
15522
17259
 
15523
- `{ contentType?, cacheControl?, visibility?, metadata? }` — passed to `put`.
17260
+ `traceIds(): { trace_id?: string; span_id?: string }` — the ids to hang on a log
17261
+ line.
15524
17262
 
15525
- #### `FileMetadata`
17263
+ ### `tracing(options?)`
15526
17264
 
15527
- `{ size, contentType?, cacheControl?, visibility?, lastModified?, metadata? }`.
17265
+ `tracing(options?: TracingOptions): MiddlewareHandler`
15528
17266
 
15529
- #### `SignedFileOptions` / `SignedUploadOptions`
17267
+ A server span per request. Options: `ignore(path)` (default: `/health`, `/metrics`,
17268
+ `/favicon.ico`), `name(method, path)`.
15530
17269
 
15531
- `{ expiresIn? }` (seconds, default 3600), plus `contentType?` for uploads.
17270
+ ### `Tracer`
15532
17271
 
15533
- #### `FileVisibility`
17272
+ `new Tracer(options: TracerOptions)`
15534
17273
 
15535
- `type FileVisibility = "public" | "private"`.
17274
+ | Option | Meaning |
17275
+ |--------|---------|
17276
+ | `serviceName` | Added to every span as `service.name` |
17277
+ | `exporter` | Where finished spans go. Omit and nothing is exported |
17278
+ | `sampleRatio` | 0–1, decided once at the root. Default 1 |
17279
+ | `enabled` | `false` turns tracing off |
17280
+ | `resource` | Attributes describing the service, sent with each batch |
17281
+ | `batchSize` | Export once this many spans are buffered. Default 100 |
15536
17282
 
15537
- #### `Contents`
17283
+ Methods: `startSpan(name, options?)`, `trace(name, fn, options?)`, `flush()`.
15538
17284
 
15539
- `type Contents = string | Uint8Array | ArrayBuffer` — accepted by `put`.
17285
+ ### `Span`
17286
+
17287
+ `setAttribute(k, v)` / `setAttributes(attrs)` / `addEvent(name, attrs?)` /
17288
+ `setStatus(status, message?)` / `recordException(error)` / `end()`, plus a
17289
+ `context` (`{ traceId, spanId, sampled }`).
17290
+
17291
+ ### `setTelemetry(tracer)` / `telemetry()` / `flushTelemetry()`
17292
+
17293
+ Register the active tracer, read it, and drain its buffer.
17294
+
17295
+ ### Trace context
17296
+
17297
+ `parseTraceparent(header)` → `SpanContext | null` (null on anything malformed).
17298
+ `traceparent(context)` → the header string.
17299
+ `injectTraceContext(headers?)` → headers with the current context added.
17300
+
17301
+ ### Exporters
17302
+
17303
+ `otlpExporter({ url, headers?, resource? })`, `consoleExporter()`, and
17304
+ `MemoryExporter` (`.spans`, `.named(name)`, `.trace(traceId)`, `.clear()`).
17305
+
17306
+ ### Interfaces & types
17307
+
17308
+ `SpanData`, `SpanContext`, `SpanEvent`, `SpanKind`
17309
+ (`internal | server | client | producer | consumer`), `SpanStatus`
17310
+ (`unset | ok | error`), `SpanAttributes`, `SpanExporter`, `TracerOptions`,
17311
+ `TracingOptions`, `OtlpOptions`.
15540
17312
 
15541
17313
 
15542
17314
 
@@ -15546,10 +17318,9 @@ Optional capabilities: `metadata` / `copy` / `move` / `signedUrl` /
15546
17318
 
15547
17319
  # Templates
15548
17320
 
15549
- A string templating engine in the spirit of AdonisJS **Edge** — `{{ }}`
15550
- interpolation and `@`-prefixed tags for logic, includes, layouts, and
15551
- components. Reach for it when you want plain-text templates instead of (or
15552
- alongside) [JSX views](./views.md).
17321
+ A string templating engine — `{{ }}` interpolation and `@`-prefixed tags for
17322
+ logic, includes, layouts, and components. Reach for it when you want plain-text
17323
+ templates instead of (or alongside) [JSX views](./views.md).
15553
17324
 
15554
17325
  Unlike engines that compile a template to a function with `eval` /
15555
17326
  `new Function`, Keel **interprets** templates against a small, safe expression
@@ -15958,6 +17729,258 @@ kernel.use(requestLogger());
15958
17729
  const client = testClient(kernel);
15959
17730
  ```
15960
17731
 
17732
+ ## Authenticated requests
17733
+
17734
+ The client's `withX` methods return a **copy**, so a client configured once can be
17735
+ reused without leaking into other tests:
17736
+
17737
+ ```ts
17738
+ const authed = client.withToken("tok_123"); // Authorization: Bearer tok_123
17739
+
17740
+ await authed.get("/me");
17741
+ await client.get("/me"); // still anonymous
17742
+ ```
17743
+
17744
+ | Method | Sends |
17745
+ |--------|-------|
17746
+ | `withToken(token)` | `Authorization: Bearer <token>` |
17747
+ | `withBasicAuth(user, pass)` | `Authorization: Basic <base64>` |
17748
+ | `withHeader(name, value)` / `withHeaders({…})` | any header |
17749
+ | `withCookie(name, value)` / `withCookies({…})` | a `Cookie` header |
17750
+ | `acceptJson()` | `Accept: application/json` |
17751
+
17752
+ ## Forms and uploads
17753
+
17754
+ ```ts
17755
+ await client.form("/login", { email: "a@b.com", password: "s3cret" }); // url-encoded
17756
+ await client.multipart("/avatar", { file: new Blob([png]), name: "ada" }); // file upload
17757
+ ```
17758
+
17759
+ ## More response assertions
17760
+
17761
+ ```ts
17762
+ res.assertOk(); // 2xx
17763
+ res.assertCreated(); // 201
17764
+ res.assertNoContent(); // 204
17765
+ res.assertUnauthorized(); // 401
17766
+ res.assertForbidden(); // 403
17767
+ res.assertNotFound(); // 404
17768
+ res.assertUnprocessable(); // 422
17769
+ res.assertServerError(); // 5xx
17770
+ ```
17771
+
17772
+ **`assertJsonContains` is a subset match** — the one you usually want. It pins the
17773
+ fields the test is about and ignores the rest, so adding a field to a response
17774
+ doesn't break twenty tests:
17775
+
17776
+ ```ts
17777
+ res.assertJsonContains({ user: { email: "a@b.com" } });
17778
+ ```
17779
+
17780
+ `assertJson` still deep-equals the whole body, when that's what you mean.
17781
+
17782
+ ```ts
17783
+ res.assertSee("Welcome back"); // body contains
17784
+ res.assertDontSee("Sign up");
17785
+
17786
+ res.assertHeader("content-type", "application/json");
17787
+ res.assertHeaderMissing("x-debug");
17788
+
17789
+ res.assertCookie("session"); // was set
17790
+ res.assertCookie("session", "abc123"); // ...with this value
17791
+ res.assertCookieMissing("admin");
17792
+
17793
+ res.dump(); // print status, headers, body — when you're stuck
17794
+ ```
17795
+
17796
+ ### Validation
17797
+
17798
+ A failed `validate()` returns a 422 with per-field errors, so a test can assert on
17799
+ the field rather than the message:
17800
+
17801
+ ```ts
17802
+ const res = await client.post("/users", { email: "nope" });
17803
+
17804
+ res.assertValidationErrors("email", "password");
17805
+ res.assertNoValidationError("name");
17806
+ ```
17807
+
17808
+ ## Test doubles
17809
+
17810
+ Keel's fakes swap out a real backend for a recording one, so a test can assert
17811
+ that something *would* have happened without it actually happening — no email
17812
+ sent, no card charged, no file uploaded.
17813
+
17814
+ | Fake | Replaces | Assertions |
17815
+ |------|----------|------------|
17816
+ | [`fakeMail()`](./mail.md#in-tests) | the mailer | `assertSent`, `assertQueued`, … |
17817
+ | [`fakeQueue()`](./queues.md#in-tests) | the queue | `assertPushed`, `assertNothingPushed`, … |
17818
+ | [`fakeDisk()`](./storage.md#testing) | a storage disk | `assertExists`, `assertContents`, … |
17819
+ | [`events().fake()`](./events.md#testing) | the emitter | `assertEmitted`, `assertNotEmitted`, … |
17820
+ | [`hash.fake()`](./hashing.md) | PBKDF2 | — (just makes it fast) |
17821
+
17822
+ ```ts
17823
+ const mailer = fakeMail();
17824
+ const queue = fakeQueue();
17825
+
17826
+ await registerUser({ email: "ada@example.com" });
17827
+
17828
+ mailer.assertQueued((m) => m.subject === "Welcome");
17829
+ queue.assertPushed(SendWelcome);
17830
+ ```
17831
+
17832
+ For anything else, `swap()` replaces a container binding:
17833
+
17834
+ ```ts
17835
+ swap(PaymentGateway, () => new FakeGateway());
17836
+ ```
17837
+
17838
+ ### Spies
17839
+
17840
+ The smallest double: a function that records how it was called.
17841
+
17842
+ ```ts
17843
+ import { spy, spyOn, restoreSpies } from "@shaferllc/keel/core";
17844
+
17845
+ const send = spy<[string], void>();
17846
+ notify(send);
17847
+
17848
+ assert.equal(send.callCount, 1);
17849
+ assert.ok(send.calledWith("hello"));
17850
+ ```
17851
+
17852
+ `spyOn` replaces a method on an object. It **calls through** by default — so you're
17853
+ observing, not stubbing — until you tell it otherwise:
17854
+
17855
+ ```ts
17856
+ const charge = spyOn(gateway, "charge"); // still really charges
17857
+ charge.returns(receipt); // now it doesn't
17858
+
17859
+ restoreSpies(); // put every spied method back
17860
+ ```
17861
+
17862
+ ## Controlling time
17863
+
17864
+ Testing "this token expires in an hour" shouldn't take an hour.
17865
+
17866
+ ```ts
17867
+ import { freezeTime, timeTravel, restoreTime } from "@shaferllc/keel/core";
17868
+
17869
+ freezeTime("2026-07-11T12:00:00Z");
17870
+
17871
+ const token = await jwt.sign({ sub: "1" }, { expiresIn: "1h" });
17872
+ assert.ok(await jwt.verify(token)); // valid now
17873
+
17874
+ timeTravel(61 * 60 * 1000); // an hour and a minute later
17875
+ assert.equal(await jwt.verify(token), null); // expired
17876
+
17877
+ restoreTime();
17878
+ ```
17879
+
17880
+ `freezeTime()` mocks `Date` and `Date.now()`. It does **not** mock timers — a
17881
+ `setTimeout` still fires on the real clock — and `new Date("2020-01-01")` still
17882
+ parses normally. Only "what time is it *now*" is frozen.
17883
+
17884
+ ## Resetting state between tests
17885
+
17886
+ Keel's fakes, disks, queues, and cache are process-global, so one test can leak
17887
+ into the next. `resetState()` puts it all back:
17888
+
17889
+ ```ts
17890
+ import { resetState } from "@shaferllc/keel/core";
17891
+
17892
+ afterEach(() => resetState());
17893
+ ```
17894
+
17895
+ It restores every fake (mail, queue, disk, hash), unfreezes the clock, drops event
17896
+ listeners, empties the cache, and gives you a fresh lock store. It does **not**
17897
+ touch the database.
17898
+
17899
+ For that, `truncate()`:
17900
+
17901
+ ```ts
17902
+ afterEach(() => truncate("comments", "posts", "users")); // children before parents
17903
+ ```
17904
+
17905
+ It deletes rows rather than rolling back a transaction, so it works on every driver
17906
+ (D1, Postgres, libSQL) instead of only the ones with savepoints.
17907
+
17908
+ ## Database assertions
17909
+
17910
+ Assert against the database directly, rather than through an endpoint:
17911
+
17912
+ ```ts
17913
+ import { assertDatabaseHas, assertDatabaseMissing, assertDatabaseCount } from "@shaferllc/keel/core";
17914
+
17915
+ await client.post("/users", { email: "ada@example.com" });
17916
+
17917
+ await assertDatabaseHas("users", { email: "ada@example.com" });
17918
+ await assertDatabaseHas("users", { active: 1 }, 1); // exactly one match
17919
+ await assertDatabaseMissing("users", { email: "deleted@example.com" });
17920
+ await assertDatabaseCount("users", 1);
17921
+ await assertDatabaseEmpty("sessions");
17922
+ ```
17923
+
17924
+ A failure tells you what it looked for and how many rows the table actually holds.
17925
+
17926
+ ## Console tests
17927
+
17928
+ Run a command in-process — no subprocess, so it's fast and you can assert on it:
17929
+
17930
+ ```ts
17931
+ import { runCommand } from "@shaferllc/keel/core";
17932
+ import { run } from "@shaferllc/keel/cli";
17933
+ import { createApplication } from "../bootstrap/app.js";
17934
+
17935
+ const result = await runCommand(() => run(["node", "keel", "routes"], { createApplication }));
17936
+
17937
+ result
17938
+ .assertSucceeded() // exit code 0
17939
+ .assertOutputContains("GET /users")
17940
+ .assertOutputMatches(/POST\s+\/users/);
17941
+ ```
17942
+
17943
+ You pass the command **in**, because a command needs an *application*, and only your
17944
+ app knows how to build one. That's also why `run()` takes a `createApplication`
17945
+ factory rather than importing one. Anything that prints and sets an exit code works,
17946
+ so this is equally good for testing a function you wrote yourself.
17947
+
17948
+ `console.log`/`warn` are captured as stdout and `console.error` as stderr; a
17949
+ command that *throws* is recorded as a failure rather than blowing up the test.
17950
+
17951
+ `assertFailed()`, `assertExitCode(n)`, and `assertErrorContains(text)` cover the
17952
+ rest. `result.stdout`, `result.stderr`, and `result.exitCode` are there if you'd
17953
+ rather assert by hand.
17954
+
17955
+ ## Browser tests
17956
+
17957
+ Keel doesn't ship a browser driver — that's [Playwright](https://playwright.dev)'s
17958
+ job, and wrapping it would only put a thinner API in front of a better one.
17959
+
17960
+ The test client injects requests *without a server*, which is what makes it fast;
17961
+ a browser needs a real one. Start the app on a port, point Playwright at it, and
17962
+ tear it down:
17963
+
17964
+ ```ts
17965
+ import { serve } from "@hono/node-server";
17966
+ import { chromium } from "playwright";
17967
+
17968
+ const server = serve({ fetch: new HttpKernel(app).build().fetch, port: 3001 });
17969
+ const browser = await chromium.launch();
17970
+
17971
+ const page = await browser.newPage();
17972
+ await page.goto("http://localhost:3001/login");
17973
+ await page.fill("[name=email]", "ada@example.com");
17974
+ await page.click("button[type=submit]");
17975
+ await page.waitForURL("**/dashboard");
17976
+
17977
+ await browser.close();
17978
+ server.close();
17979
+ ```
17980
+
17981
+ Everything else on this page — the fakes, `freezeTime`, `resetState`, the database
17982
+ assertions — works the same in a browser test, because it's the same process.
17983
+
15961
17984
  ## API reference
15962
17985
 
15963
17986
  ### `testClient(target)`
@@ -17692,3 +19715,128 @@ interface ManifestChunk {
17692
19715
  Vite's `manifest.json`, as produced by the build. You rarely touch it directly —
17693
19716
  `generateEntryPointsTags` and `assetPath` read it for you.
17694
19717
 
19718
+
19719
+
19720
+ ---
19721
+
19722
+ <!-- source: docs/watch.md -->
19723
+
19724
+ # Watch
19725
+
19726
+ Keel Watch is a debug dashboard — a Telescope for Keel. It records the requests,
19727
+ queries, exceptions, logs, jobs, mail, notifications, cache lookups, events, and
19728
+ scheduled tasks flowing through your app, and shows them in a single-page UI at
19729
+ `/watch`, with every request linked to the queries and logs it produced.
19730
+
19731
+ It ships as a Keel [package](./packages.md) and is its reference implementation:
19732
+ one `register()` turns it on, and its watchers observe the framework's
19733
+ instrumentation event stream — they patch nothing, so installing Watch changes no
19734
+ behaviour, only visibility.
19735
+
19736
+ ## Install
19737
+
19738
+ ```ts
19739
+ // bootstrap/providers.ts
19740
+ import { WatchServiceProvider } from "@shaferllc/keel/watch";
19741
+
19742
+ export const providers = [AppServiceProvider, WatchServiceProvider];
19743
+ ```
19744
+
19745
+ Publish the config and create the table:
19746
+
19747
+ ```bash
19748
+ keel vendor:publish --tag watch-config # writes config/watch.ts
19749
+ keel migrate # creates watch_entries
19750
+ ```
19751
+
19752
+ Then open `http://localhost:3000/watch`.
19753
+
19754
+ > Watch exposes requests, payloads, and stack traces. It is gated shut in
19755
+ > production by default (see [Access](#access)) — keep it that way, or lock it
19756
+ > down with your own check.
19757
+
19758
+ ## What it records
19759
+
19760
+ A tab per type, each behind an on/off switch:
19761
+
19762
+ | Watcher | Records |
19763
+ |---------|---------|
19764
+ | Requests | method, path, status, duration, headers (auth/cookies redacted) |
19765
+ | Queries | SQL, bindings, duration, connection; slow queries are tagged |
19766
+ | Exceptions | class, message, stack, the request that threw |
19767
+ | Logs | every log line, at any level |
19768
+ | Mail | sent messages (subject, recipients, body) |
19769
+ | Jobs | queued jobs as they complete or fail |
19770
+ | Notifications | deliveries and their channels |
19771
+ | Cache | hits and misses *(off by default — noisy)* |
19772
+ | Events | app events *(off by default — noisy)* |
19773
+ | Schedule | scheduled tasks as they run |
19774
+
19775
+ Clicking any entry shows its full detail and **everything else in its batch** —
19776
+ the request it belonged to and every query, log, and exception that request
19777
+ produced.
19778
+
19779
+ ## Configuration
19780
+
19781
+ `config/watch.ts` (publish it, then edit):
19782
+
19783
+ ```ts
19784
+ export default {
19785
+ enabled: env("WATCH_ENABLED", true),
19786
+ path: "watch", // the dashboard mounts at /watch
19787
+ storage: "database", // "database" persists; "memory" is a per-process ring
19788
+ connection: undefined, // which DB connection (default when omitted)
19789
+ table: "watch_entries",
19790
+ limit: 100, // API page size; the memory ring is 10× this
19791
+ sampling: 1, // record this fraction of entries (0–1)
19792
+ slowQueryMs: 100, // queries at/above this are tagged "slow"
19793
+ ignorePaths: [], // request paths to skip
19794
+ retentionHours: 24, // keel watch:prune deletes entries older than this
19795
+ watchers: { cache: false, event: false /* … the rest default on */ },
19796
+ };
19797
+ ```
19798
+
19799
+ ### Storage
19800
+
19801
+ - **`database`** (default) — a `watch_entries` table via any registered
19802
+ connection. Survives restarts, shared across processes; needs `keel migrate`.
19803
+ - **`memory`** — a per-process ring buffer. Zero setup, great for a single dev
19804
+ process or the edge; entries vanish on restart and aren't shared.
19805
+
19806
+ ## Access
19807
+
19808
+ By default the dashboard is open only when `app.debug` is on or the app isn't in
19809
+ production. Anywhere else, lock it to your own check:
19810
+
19811
+ ```ts
19812
+ import { Watch } from "@shaferllc/keel/watch";
19813
+ import { auth } from "@shaferllc/keel/core";
19814
+
19815
+ Watch.auth(() => auth().check() && Boolean(auth().user()?.isAdmin));
19816
+ ```
19817
+
19818
+ The gate guards the JSON API too, so entries can't be read by anyone the
19819
+ dashboard wouldn't show them to.
19820
+
19821
+ ## Pruning
19822
+
19823
+ With database storage, keep the table small:
19824
+
19825
+ ```bash
19826
+ keel watch:prune # delete entries older than retentionHours
19827
+ keel watch:prune --hours=6
19828
+ ```
19829
+
19830
+ Schedule it (see [Scheduling](./scheduling.md)) to run it automatically.
19831
+
19832
+ ## How it works
19833
+
19834
+ Watch never wraps or monkey-patches the framework. The core emits a typed
19835
+ [instrumentation event stream](./packages.md#observing-the-framework) —
19836
+ `db.query`, `request.handled`, `exception`, `job.*`, and so on — and each watcher
19837
+ is just a listener that turns an event into a stored entry, stamped with the
19838
+ current request id so related entries group into one batch. Recording is
19839
+ fire-and-forget: it never blocks or breaks the request it's watching, and the
19840
+ store's own queries are filtered out so it never records itself.
19841
+ ```
19842
+