@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.
- package/AGENTS.md +2 -0
- package/README.md +14 -5
- package/dist/api/api.config.stub +9 -0
- package/dist/api/config.d.ts +13 -0
- package/dist/api/config.js +14 -0
- package/dist/api/index.d.ts +16 -0
- package/dist/api/index.js +13 -0
- package/dist/api/provider.d.ts +10 -0
- package/dist/api/provider.js +17 -0
- package/dist/api/query.d.ts +35 -0
- package/dist/api/query.js +42 -0
- package/dist/api/resource.d.ts +91 -0
- package/dist/api/resource.js +188 -0
- package/dist/core/application.js +6 -0
- package/dist/core/cache.d.ts +1 -2
- package/dist/core/cache.js +9 -2
- package/dist/core/cli/stubs.d.ts +14 -0
- package/dist/core/cli/stubs.js +105 -0
- package/dist/core/console-prompt.d.ts +79 -0
- package/dist/core/console-prompt.js +239 -0
- package/dist/core/console-ui.d.ts +96 -0
- package/dist/core/console-ui.js +187 -0
- package/dist/core/console.d.ts +188 -0
- package/dist/core/console.js +395 -0
- package/dist/core/database.d.ts +70 -1
- package/dist/core/database.js +174 -15
- package/dist/core/env.d.ts +96 -0
- package/dist/core/env.js +140 -0
- package/dist/core/health.d.ts +2 -2
- package/dist/core/health.js +2 -2
- package/dist/core/http/kernel.d.ts +2 -0
- package/dist/core/http/kernel.js +48 -0
- package/dist/core/http/router.d.ts +5 -5
- package/dist/core/http/router.js +5 -5
- package/dist/core/i18n.d.ts +162 -0
- package/dist/core/i18n.js +472 -0
- package/dist/core/index.d.ts +25 -4
- package/dist/core/index.js +13 -3
- package/dist/core/instrumentation.d.ts +113 -0
- package/dist/core/instrumentation.js +52 -0
- package/dist/core/logger.d.ts +7 -0
- package/dist/core/logger.js +28 -1
- package/dist/core/notification.js +10 -1
- package/dist/core/package.d.ts +120 -0
- package/dist/core/package.js +169 -0
- package/dist/core/pages.d.ts +108 -0
- package/dist/core/pages.js +199 -0
- package/dist/core/queue.js +26 -5
- package/dist/core/repl.d.ts +33 -0
- package/dist/core/repl.js +88 -0
- package/dist/core/scheduler.js +6 -0
- package/dist/core/social.d.ts +4 -4
- package/dist/core/social.js +4 -4
- package/dist/core/storage.js +15 -3
- package/dist/core/telemetry.d.ts +208 -0
- package/dist/core/telemetry.js +383 -0
- package/dist/core/template.d.ts +2 -3
- package/dist/core/template.js +2 -3
- package/dist/core/testing.d.ts +170 -1
- package/dist/core/testing.js +504 -2
- package/dist/db/d1.js +13 -0
- package/dist/db/pg.d.ts +13 -0
- package/dist/db/pg.js +46 -4
- package/dist/openapi/config.d.ts +28 -0
- package/dist/openapi/config.js +25 -0
- package/dist/openapi/doc.d.ts +40 -0
- package/dist/openapi/doc.js +20 -0
- package/dist/openapi/export.d.ts +8 -0
- package/dist/openapi/export.js +19 -0
- package/dist/openapi/gate.d.ts +15 -0
- package/dist/openapi/gate.js +27 -0
- package/dist/openapi/index.d.ts +19 -0
- package/dist/openapi/index.js +15 -0
- package/dist/openapi/openapi.config.stub +29 -0
- package/dist/openapi/provider.d.ts +18 -0
- package/dist/openapi/provider.js +35 -0
- package/dist/openapi/routes.d.ts +9 -0
- package/dist/openapi/routes.js +23 -0
- package/dist/openapi/spec.d.ts +23 -0
- package/dist/openapi/spec.js +132 -0
- package/dist/openapi/ui.d.ts +8 -0
- package/dist/openapi/ui.js +31 -0
- package/dist/openapi/zod.d.ts +12 -0
- package/dist/openapi/zod.js +46 -0
- package/dist/watch/config.d.ts +33 -0
- package/dist/watch/config.js +38 -0
- package/dist/watch/entry.d.ts +53 -0
- package/dist/watch/entry.js +105 -0
- package/dist/watch/gate.d.ts +20 -0
- package/dist/watch/gate.js +32 -0
- package/dist/watch/index.d.ts +21 -0
- package/dist/watch/index.js +17 -0
- package/dist/watch/migration.d.ts +7 -0
- package/dist/watch/migration.js +20 -0
- package/dist/watch/provider.d.ts +22 -0
- package/dist/watch/provider.js +58 -0
- package/dist/watch/prune.d.ts +11 -0
- package/dist/watch/prune.js +20 -0
- package/dist/watch/recorder.d.ts +24 -0
- package/dist/watch/recorder.js +39 -0
- package/dist/watch/routes.d.ts +13 -0
- package/dist/watch/routes.js +55 -0
- package/dist/watch/store.d.ts +54 -0
- package/dist/watch/store.js +158 -0
- package/dist/watch/ui/dist/watch.css +1 -0
- package/dist/watch/ui/dist/watch.js +555 -0
- package/dist/watch/ui-shell.d.ts +12 -0
- package/dist/watch/ui-shell.js +24 -0
- package/dist/watch/watch.config.stub +47 -0
- package/dist/watch/watchers.d.ts +12 -0
- package/dist/watch/watchers.js +156 -0
- package/docs/ai-manifest.json +737 -3
- package/docs/api-resources.md +118 -0
- package/docs/configuration.md +74 -0
- package/docs/console.md +193 -13
- package/docs/database.md +101 -0
- package/docs/examples/configuration.ts +40 -0
- package/docs/examples/console.ts +134 -0
- package/docs/examples/database.ts +90 -0
- package/docs/examples/i18n.ts +117 -0
- package/docs/examples/logger.ts +74 -0
- package/docs/examples/mail.ts +103 -0
- package/docs/examples/pages.ts +82 -0
- package/docs/examples/telemetry.ts +127 -0
- package/docs/examples/testing.ts +149 -0
- package/docs/health.md +4 -4
- package/docs/i18n.md +302 -0
- package/docs/logger.md +156 -10
- package/docs/mail.md +73 -0
- package/docs/openapi.md +111 -0
- package/docs/packages.md +118 -0
- package/docs/pages.md +217 -0
- package/docs/storage.md +5 -3
- package/docs/telemetry.md +263 -0
- package/docs/templates.md +3 -4
- package/docs/testing.md +251 -0
- package/docs/watch.md +118 -0
- package/llms-full.txt +2370 -248
- package/llms.txt +12 -1
- 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
|
-
##
|
|
6808
|
+
## Writing your own commands
|
|
6610
6809
|
|
|
6611
|
-
|
|
6612
|
-
|
|
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
|
-
|
|
6617
|
-
|
|
6618
|
-
|
|
6619
|
-
|
|
6620
|
-
|
|
6621
|
-
|
|
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
|
-
|
|
6626
|
-
|
|
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**:
|
|
8909
|
-
|
|
8910
|
-
|
|
8911
|
-
|
|
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/
|
|
10225
|
+
<!-- source: docs/i18n.md -->
|
|
9746
10226
|
|
|
9747
|
-
#
|
|
10227
|
+
# Internationalization
|
|
9748
10228
|
|
|
9749
|
-
|
|
9750
|
-
|
|
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
|
-
|
|
10232
|
+
```ts
|
|
10233
|
+
import { setTranslations, t } from "@shaferllc/keel/core";
|
|
9755
10234
|
|
|
9756
|
-
|
|
9757
|
-
|
|
9758
|
-
|
|
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 {
|
|
10254
|
+
import { setI18n, I18nManager, setTranslations, detectLocale, HttpKernel } from "@shaferllc/keel/core";
|
|
9762
10255
|
|
|
9763
|
-
export class
|
|
9764
|
-
|
|
9765
|
-
|
|
9766
|
-
|
|
9767
|
-
|
|
9768
|
-
|
|
9769
|
-
|
|
9770
|
-
|
|
9771
|
-
|
|
9772
|
-
|
|
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
|
-
`
|
|
9781
|
-
|
|
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
|
-
|
|
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
|
-
|
|
10275
|
+
Nested objects and flat dot-keys are the same thing, and you can mix them:
|
|
9789
10276
|
|
|
9790
|
-
|
|
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
|
-
|
|
9793
|
-
import { inertia } from "@shaferllc/keel/core";
|
|
10287
|
+
Both `t("cart.items")` and `t("checkout.title")` resolve.
|
|
9794
10288
|
|
|
9795
|
-
|
|
9796
|
-
|
|
9797
|
-
|
|
9798
|
-
|
|
10289
|
+
## The message format
|
|
10290
|
+
|
|
10291
|
+
The supported ICU subset is the part people actually use.
|
|
10292
|
+
|
|
10293
|
+
### Interpolation
|
|
9799
10294
|
|
|
9800
|
-
|
|
9801
|
-
|
|
10295
|
+
```
|
|
10296
|
+
Hello {name}!
|
|
9802
10297
|
```
|
|
9803
10298
|
|
|
9804
|
-
|
|
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
|
-
|
|
9810
|
-
|
|
9811
|
-
|
|
10301
|
+
```
|
|
10302
|
+
{count, plural, =0 {Your cart is empty} one {# item} other {# items}}
|
|
10303
|
+
```
|
|
9812
10304
|
|
|
9813
|
-
|
|
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
|
-
|
|
9816
|
-
|
|
9817
|
-
|
|
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
|
-
|
|
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
|
|
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 (`
|
|
10365
|
-
`error`
|
|
10366
|
-
default threshold is `"info"`, so `debug`
|
|
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`,
|
|
10372
|
-
to `console.log`. In JSON mode every level is written to
|
|
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
|
-
|
|
12243
|
+
`send(email: BaseMail, name?: string): Promise<Message>` — build a class-based mail
|
|
12244
|
+
and send it. `sendLater` queues it instead.
|
|
11306
12245
|
|
|
11307
|
-
|
|
12246
|
+
### `BaseMail`
|
|
11308
12247
|
|
|
11309
|
-
|
|
11310
|
-
|
|
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
|
-
|
|
12251
|
+
### `PendingMail.sendLater()`
|
|
11315
12252
|
|
|
11316
|
-
|
|
11317
|
-
import type { Migration } from "@shaferllc/keel/core";
|
|
12253
|
+
`sendLater(): Promise<void>` — validate now, then put the message on the queue.
|
|
11318
12254
|
|
|
11319
|
-
|
|
11320
|
-
|
|
11321
|
-
|
|
11322
|
-
|
|
11323
|
-
|
|
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
|
-
|
|
12969
|
-
new DatabaseChannel("alerts"); // → "alerts"
|
|
14466
|
+
await pages({ middleware: [authGuard()] });
|
|
12970
14467
|
```
|
|
12971
14468
|
|
|
12972
|
-
|
|
12973
|
-
|
|
12974
|
-
#### `send(notifiable, notification)`
|
|
14469
|
+
Both run before the `loader`, so a page that's refused never loads its data.
|
|
12975
14470
|
|
|
12976
|
-
|
|
14471
|
+
## Names and URLs
|
|
12977
14472
|
|
|
12978
|
-
|
|
12979
|
-
|
|
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
|
-
|
|
14477
|
+
router.url("users.id", { id: 5 }); // "/users/5"
|
|
12983
14478
|
```
|
|
12984
14479
|
|
|
12985
|
-
|
|
12986
|
-
target table must exist — create it in a migration.
|
|
14480
|
+
Override it when the derived name is ugly:
|
|
12987
14481
|
|
|
12988
|
-
|
|
14482
|
+
```tsx
|
|
14483
|
+
export const name = "users.show";
|
|
14484
|
+
```
|
|
12989
14485
|
|
|
12990
|
-
|
|
14486
|
+
## Escape hatches
|
|
12991
14487
|
|
|
12992
|
-
|
|
14488
|
+
Move a page's URL without moving the file:
|
|
12993
14489
|
|
|
12994
|
-
|
|
14490
|
+
```tsx
|
|
14491
|
+
export const path = "/pricing"; // even though the file is at marketing/plans.tsx
|
|
14492
|
+
```
|
|
12995
14493
|
|
|
12996
|
-
|
|
14494
|
+
Mount every page under a prefix:
|
|
12997
14495
|
|
|
12998
14496
|
```ts
|
|
12999
|
-
|
|
13000
|
-
//
|
|
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
|
-
|
|
13005
|
-
or read its fields — no serialization through `toArray`.
|
|
14501
|
+
---
|
|
13006
14502
|
|
|
13007
|
-
|
|
14503
|
+
## API reference
|
|
13008
14504
|
|
|
13009
|
-
`
|
|
14505
|
+
### `pages(options?)`
|
|
13010
14506
|
|
|
13011
|
-
|
|
14507
|
+
`pages(options?: PagesOptions): Promise<RegisteredPage[]>`
|
|
13012
14508
|
|
|
13013
|
-
|
|
13014
|
-
|
|
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
|
-
###
|
|
14513
|
+
### `definePages(modules, options?)`
|
|
13018
14514
|
|
|
13019
|
-
|
|
14515
|
+
`definePages(modules: Record<string, PageModule>, options?: PagesOptions): RegisteredPage[]`
|
|
13020
14516
|
|
|
13021
|
-
|
|
13022
|
-
|
|
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
|
-
|
|
13029
|
-
`routeNotificationFor` to steer specific channels; otherwise `routeFor` reads
|
|
13030
|
-
`email`/`id` off the index signature.
|
|
14520
|
+
### `PagesOptions`
|
|
13031
14521
|
|
|
13032
|
-
|
|
13033
|
-
|
|
13034
|
-
|
|
13035
|
-
|
|
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
|
-
|
|
14529
|
+
### `PageModule`
|
|
13041
14530
|
|
|
13042
|
-
|
|
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
|
-
|
|
13053
|
-
|
|
13054
|
-
|
|
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
|
-
|
|
13057
|
-
toMail(): MailContent {
|
|
13058
|
-
return { subject: "Welcome", html: "<h1>Hi</h1>", to: "override@app.com" };
|
|
13059
|
-
}
|
|
13060
|
-
```
|
|
14541
|
+
### `PageProps<P, D>`
|
|
13061
14542
|
|
|
13062
|
-
|
|
14543
|
+
What the component receives: `params` (typed by `P`), `data` (whatever `loader`
|
|
14544
|
+
returned, typed by `D`), and `ctx`.
|
|
13063
14545
|
|
|
13064
|
-
|
|
13065
|
-
interface Channel {
|
|
13066
|
-
send(notifiable: Notifiable, notification: Notification): Promise<void>;
|
|
13067
|
-
}
|
|
13068
|
-
```
|
|
14546
|
+
### `RegisteredPage`
|
|
13069
14547
|
|
|
13070
|
-
|
|
13071
|
-
|
|
14548
|
+
`{ file, pattern, name }` — what `pages()` / `definePages()` return, so you can
|
|
14549
|
+
see exactly what got mounted.
|
|
13072
14550
|
|
|
13073
|
-
|
|
13074
|
-
|
|
13075
|
-
|
|
13076
|
-
|
|
13077
|
-
|
|
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`,
|
|
15203
|
-
>
|
|
15204
|
-
>
|
|
16677
|
+
> `serveStorage` listens on `/private`, no signature could ever match. Rather than
|
|
16678
|
+
> 403 every request — which 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
|
-
##
|
|
17159
|
+
## Sampling
|
|
15446
17160
|
|
|
15447
|
-
|
|
17161
|
+
Recording every trace in production is expensive. `sampleRatio` records a fraction:
|
|
15448
17162
|
|
|
15449
|
-
|
|
17163
|
+
```ts
|
|
17164
|
+
new Tracer({ sampleRatio: 0.1 }); // 10%
|
|
17165
|
+
```
|
|
15450
17166
|
|
|
15451
|
-
The
|
|
15452
|
-
|
|
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
|
-
|
|
17171
|
+
## Exporters
|
|
15455
17172
|
|
|
15456
|
-
|
|
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
|
-
|
|
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
|
-
|
|
17187
|
+
Spans are **buffered** and sent in batches (100 by default, via `batchSize`).
|
|
15461
17188
|
|
|
15462
|
-
|
|
17189
|
+
**Flush before the process — or the isolate — goes away**, or the last few spans
|
|
17190
|
+
die with it:
|
|
15463
17191
|
|
|
15464
|
-
|
|
15465
|
-
|
|
15466
|
-
|
|
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
|
-
|
|
17196
|
+
On Workers, call it at the end of a request (ideally inside `waitUntil`).
|
|
15480
17197
|
|
|
15481
|
-
|
|
15482
|
-
`FakeStorage`. `restoreDisk(name?)` puts the real one back — with no name, every
|
|
15483
|
-
faked disk.
|
|
17198
|
+
## Testing
|
|
15484
17199
|
|
|
15485
|
-
`
|
|
15486
|
-
`assertContents(path, text)`, and `assertCount(n, prefix?)`.
|
|
17200
|
+
`MemoryExporter` collects spans so you can assert on them:
|
|
15487
17201
|
|
|
15488
|
-
|
|
17202
|
+
```ts
|
|
17203
|
+
import { Tracer, MemoryExporter, setTelemetry, trace } from "@shaferllc/keel/core";
|
|
15489
17204
|
|
|
15490
|
-
|
|
17205
|
+
const exporter = new MemoryExporter();
|
|
17206
|
+
setTelemetry(new Tracer({ exporter, batchSize: 1 })); // batchSize 1 = export immediately
|
|
15491
17207
|
|
|
15492
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
15499
|
-
|
|
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
|
-
|
|
17218
|
+
---
|
|
15504
17219
|
|
|
15505
|
-
|
|
15506
|
-
`application/octet-stream`.
|
|
17220
|
+
## API reference
|
|
15507
17221
|
|
|
15508
|
-
### `
|
|
17222
|
+
### `trace(name, fn, options?)`
|
|
15509
17223
|
|
|
15510
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
17229
|
+
### `currentSpan()` / `setAttributes(attrs)` / `addEvent(name, attrs?)`
|
|
15516
17230
|
|
|
15517
|
-
|
|
15518
|
-
Optional capabilities: `metadata` / `copy` / `move` / `signedUrl` /
|
|
15519
|
-
`signedUploadUrl`.
|
|
17231
|
+
Reach the span in scope. All no-ops outside one.
|
|
15520
17232
|
|
|
15521
|
-
|
|
17233
|
+
### `traceIds()`
|
|
15522
17234
|
|
|
15523
|
-
`{
|
|
17235
|
+
`traceIds(): { trace_id?: string; span_id?: string }` — the ids to hang on a log
|
|
17236
|
+
line.
|
|
15524
17237
|
|
|
15525
|
-
|
|
17238
|
+
### `tracing(options?)`
|
|
15526
17239
|
|
|
15527
|
-
`
|
|
17240
|
+
`tracing(options?: TracingOptions): MiddlewareHandler`
|
|
15528
17241
|
|
|
15529
|
-
|
|
17242
|
+
A server span per request. Options: `ignore(path)` (default: `/health`, `/metrics`,
|
|
17243
|
+
`/favicon.ico`), `name(method, path)`.
|
|
15530
17244
|
|
|
15531
|
-
|
|
17245
|
+
### `Tracer`
|
|
15532
17246
|
|
|
15533
|
-
|
|
17247
|
+
`new Tracer(options: TracerOptions)`
|
|
15534
17248
|
|
|
15535
|
-
|
|
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
|
-
|
|
17258
|
+
Methods: `startSpan(name, options?)`, `trace(name, fn, options?)`, `flush()`.
|
|
15538
17259
|
|
|
15539
|
-
|
|
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
|
|
15550
|
-
|
|
15551
|
-
|
|
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
|
+
|