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