@shaferllc/keel 0.68.0 → 0.74.0

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