# Keel — Full Documentation > The house framework for Node.js — a service container, providers, routing, JSX views, and a code-generating console. This file concatenates every Keel guide for AI context windows. Source lives at https://github.com/shaferllc/keel/blob/main/docs. Generated by `npm run build:ai`. --- # From install to deploy One path from zero to a live Keel app — locally, on Cloudflare yourself, or on **Keel Cloud** with an AI agent. Pick the track that matches how you want to ship; everything else is optional. ```text create-keeljs → npm run dev → (optional MCP) → deploy ↘ Keel Cloud (optional) ``` ## Requirements - Node.js **≥ 22** - npm - For self-hosted edge deploys: a [Cloudflare](https://dash.cloudflare.com) account and [Wrangler](https://developers.cloudflare.com/workers/wrangler/) (ships with the kits as a devDependency) - For Keel Cloud: an invite / allowlisted email at [app.keeljs.cloud](https://app.keeljs.cloud) during private alpha ## 1. Create an app ```bash npm create keeljs@latest my-app # full-stack "app" preset (default) # npm create keeljs@latest my-api -- --preset api # npm create keeljs@latest my-saas -- --preset saas # npm create keeljs@latest bare -- --preset minimal cd my-app npm install cp .env.example .env # if the kit didn't already ``` | Preset | Use when | |--------|----------| | `minimal` | Hello-world / learning — routes, a view, Tailwind. No database. | | `api` | JSON API — models, migrations, token auth, OpenAPI, tests. | | `app` *(default)* | Product with views, sessions, register/login, password reset, 2FA. | | `saas` | Multi-tenant product — teams, roles, invitations, billing. | Templates live **inside** `@shaferllc/keel`, so the kit version matches the framework version you just installed. Details: [Starter kits](./starter-kits.md). ## 2. Run it locally ```bash npm run migrate # if the preset has a database (api / app / saas) npm run dev # http://localhost:3000 — Node + local SQLite ``` Useful next commands: ```bash npm run keel -- routes # what is mounted npm run keel -- make:controller Post # scaffold, then wire a route npm test npm run typecheck ``` Local tip: `DB_CONNECTION` defaults to a SQLite file. Switching drivers later is config only — see [Database](./database.md) and [Starter kits](./starter-kits.md). For a guided first hour inside the codebase (routes, controllers, views, config), read [Getting Started](./getting-started.md). ## 3. Optional — AI agents (local) Keel is designed to be written with an agent. Install the MCP server config in whatever project you're in: ```bash curl -fsSL https://keeljs.com/install.sh | bash ``` Same thing via npx (after `keel-mcp` is on npm) or pinned to the framework package: ```bash npx -y keel-mcp@latest init # or, always works today: npx -y --package=@shaferllc/keel keel-mcp init ``` Flags (work with either command — pass after `bash -s --` for curl): ```bash curl -fsSL https://keeljs.com/install.sh | bash -s -- --all npx -y keel-mcp@latest init --all # .cursor/mcp.json + Claude Code npx -y keel-mcp@latest init --claude npx -y keel-mcp@latest init --token "$KEEL_CLOUD_TOKEN" ``` That writes a merge-safe `.mcp.json`. Or paste by hand: ```json { "mcpServers": { "keel": { "command": "npx", "args": ["-y", "--package=@shaferllc/keel", "keel-mcp"] } } } ``` Then have the agent call `keel_overview` first. It can search docs, look up the public API, and scaffold controllers/jobs/… without inventing imports. Full map: [Building with AI](./ai.md). ## 4. Deploy yourself (Cloudflare Workers) Every kit includes `wrangler.jsonc`, a `worker.ts` entry, and `npm run deploy`. You own the Cloudflare account and the hostname. ```bash # one-time npx wrangler login npx wrangler d1 create my-app # paste database_id into wrangler.jsonc # ship npm run deploy # css:build + wrangler deploy ``` Migrations against remote D1 use the HTTP driver from your laptop / CI — the binding only exists inside the Worker. Set Cloudflare API credentials as documented in [Database](./database.md) (D1 HTTP) and your kit’s README. Edge preview without deploying: ```bash npm run dev:edge # wrangler + local D1 ``` Hosting helpers (hostname utils, SQL dump, secrets encryption) live in [`@shaferllc/keel/hosting`](./hosting.md) if you build your own control plane. ## 5. Optional — Keel Cloud (`*.keeljs.cloud`) Ship without owning a Cloudflare account: **Keel Cloud** creates the site, runs preview/production Workers on `*.keeljs.cloud`, vaults secrets, and lets you export git + SQL anytime — driven from the **same `keel-mcp`** you already use for docs. **Dedicated guide:** [Keel Cloud (deploy from MCP)](./keel-cloud.md). Quick path: 1. Sign up at [app.keeljs.cloud](https://app.keeljs.cloud) → mint a token at `/tokens` 2. Add `KEEL_CLOUD_TOKEN` (+ `KEEL_CLOUD_URL`) to your MCP config 3. Agent: `keel_cloud_create_site` → edit `storage_path` → `keel_cloud_preview` → `keel_cloud_publish { confirm: true }` Use Cloud when you want the platform to own deploys and hostnames. Skip it when you already have Cloudflare / your own pipeline (§4). Don’t mix Cloud and self-host for the same app. ## Which path should I pick? | Goal | Path | |------|------| | Learn Keel / ship a side project on your CF account | §§1–4 | | Build with an agent in your IDE, deploy yourself | §§1–4 + §3 | | Let the platform host preview/prod on `*.keeljs.cloud` via MCP | [Keel Cloud](./keel-cloud.md) | | Multi-tenant SaaS with billing | Preset `saas`, then §4 or §5 | Cloud **create_site** scaffolds a kit the same way `create-keeljs` does — you do not need both for the same app. Use `create-keeljs` for apps you own end-to-end; use Cloud when you want hosted preview/publish under `*.keeljs.cloud`. ## Where next - [Keel Cloud (deploy from MCP)](./keel-cloud.md) — create / preview / publish on `*.keeljs.cloud` from `keel-mcp` - [Getting Started](./getting-started.md) — first route, controller, view - [Starter kits](./starter-kits.md) — presets and the Node/edge seam - [Building with AI](./ai.md) — MCP tools (local + Cloud) - [Hosting](./hosting.md) — Cloudflare / dump / secrets primitives - [Accounts](./accounts.md) · [Teams](./teams.md) · [Billing](./billing.md) — what `app` / `saas` already mount --- # Keel Cloud (deploy from MCP) **Keel Cloud** hosts your Keel apps on `*.keeljs.cloud` — preview and production Workers, D1, secrets vault, and full export (git + SQL). You build in your IDE with an AI agent; the same `keel-mcp` binary that knows the framework also **creates sites and deploys them** when you set a Cloud token. | Host | Role | |------|------| | [app.keeljs.cloud](https://app.keeljs.cloud) | Control plane (dashboard + `/api/v1`) | | `preview-{slug}.keeljs.cloud` | Preview Worker | | `{slug}.keeljs.cloud` | Production Worker | | [keeljs.com](https://keeljs.com) | Framework docs (this site) | Private alpha: registration needs an invite code or allowlisted email. Free tier is limited (typically one site); Pro adds more sites and custom domains. > Prefer owning Cloudflare yourself? Use > [From install to deploy](./from-install-to-deploy.md) §4 (`create-keeljs` + > `wrangler deploy`). Cloud and self-host are **alternate** paths — don’t mix > them for the same app. ## Why deploy from MCP Agents already use `keel-mcp` for docs and scaffolding. With a token they also get `keel_cloud_*` tools against the control plane — no separate CLI, no copy-pasting wrangler credentials into the agent. The loop is: ```text create_site → edit storage_path → set_secret → preview → publish (confirm) ``` ## 1. Sign up and mint a token 1. Open [app.keeljs.cloud](https://app.keeljs.cloud) and register (invite / allowlist during alpha). 2. Go to **`/tokens`** → create a personal access token. 3. Copy the plaintext once — it looks like `keel_.`. The token binds to your **first team**. Switch teams in the dashboard before minting if you need a different team context. ## 2. Wire `keel-mcp` for Cloud Same server as local docs/API — add env so Cloud tools register: **Cursor / `.mcp.json` / Windsurf:** ```json { "mcpServers": { "keel": { "command": "npx", "args": ["-y", "--package=@shaferllc/keel", "keel-mcp"], "env": { "KEEL_CLOUD_TOKEN": "keel_….…", "KEEL_CLOUD_URL": "https://app.keeljs.cloud" } } } } ``` **Claude Code:** ```bash claude mcp add keel -e KEEL_CLOUD_TOKEN=keel_….… -e KEEL_CLOUD_URL=https://app.keeljs.cloud -- npx -y --package=@shaferllc/keel keel-mcp ``` Reload the MCP client. Stderr should say `Cloud tools enabled`. Call `keel_overview` — it lists the Cloud loop when a token is present. Local-only (docs + scaffold, no deploy): omit the env vars. [Building with AI](./ai.md) covers that surface. ## 3. Deploy a site from the agent Tell your agent something like: *“Create an app preset site named Acme on Keel Cloud, then preview it.”* Or drive the tools yourself: ### Create ```text keel_cloud_create_site { "name": "Acme", "preset": "app" } ``` Presets: `minimal` | `api` | `app` | `saas` (same kits as [`create-keeljs`](./starter-kits.md)). Response includes `storage_path` (real Keel app on disk) and hostnames. Open that path in your IDE and edit like any Keel project. ### Secrets (optional, before deploy) ```text keel_cloud_set_secret { "site_id": 1, "key": "STRIPE_SECRET_KEY", "value": "sk_…" } keel_cloud_list_secrets { "site_id": 1 } # keys only — values never returned ``` Secrets are vaulted (not in git) and injected on the next preview/publish. ### Preview (safe to repeat) ```text keel_cloud_preview { "site_id": 1 } ``` Deploys the preview Worker → `preview-{slug}.keeljs.cloud`. Iterate freely. ### Publish production (confirm required) ```text keel_cloud_publish { "site_id": 1, "confirm": true } ``` Agents must get your explicit approval before `confirm: true`. Production lands on `{slug}.keeljs.cloud`. ### Check status ```text keel_cloud_get_site { "site_id": 1 } keel_cloud_deploys { "site_id": 1 } # logs + preview/production history keel_cloud_me # plan, site_limit, team ``` ### Custom domain (Pro) ```text keel_cloud_set_custom_domain { "site_id": 1, "hostname": "app.example.com", "attach": true } ``` Returns CNAME instructions (point at `{slug}.keeljs.cloud`). The customer zone must live on the same Cloudflare account as Keel Cloud. Clear with `keel_cloud_clear_custom_domain`. ### Escape hatch ```text keel_cloud_export { "site_id": 1 } keel_cloud_export_sql { "site_id": 1, "env": "production" } ``` Always yours: clone `storage_path` / `git_url`, restore the `.sql` dump on a self-hosted Keel app anytime. ## Tool cheat sheet | Tool | Deploy role | |------|-------------| | `keel_cloud_create_site` | Scaffold kit under Cloud storage | | `keel_cloud_preview` | Deploy preview Worker | | `keel_cloud_publish` | Deploy production (`confirm: true`) | | `keel_cloud_set_secret` / `_list_secrets` / `_delete_secret` | Runtime env for Workers | | `keel_cloud_set_custom_domain` / `_clear_custom_domain` | Pro hostname | | `keel_cloud_deploys` / `_get_site` | Status and logs | | `keel_cloud_billing` / `_checkout` / `_portal` | Plan / upgrade (owner) | | `keel_cloud_export` / `_export_sql` | Leave with code + data | | `keel_cloud_delete_site` / `_restore_site` | Soft-delete / restore | Full API table and local-docs tools: [Building with AI](./ai.md). ## Dashboard parity Everything above is also available in the browser at [app.keeljs.cloud](https://app.keeljs.cloud) (`/sites`, `/billing`, `/tokens`). MCP is the agent-first path; the UI is the same control plane. ## Related - [From install to deploy](./from-install-to-deploy.md) — full journey including self-hosted Cloudflare - [Building with AI](./ai.md) — MCP docs + complete `keel_cloud_*` list - [Starter kits](./starter-kits.md) — what each preset contains - [Hosting](./hosting.md) — primitives Cloud uses under the hood - [Gates](./gates.md) — invite / allowlist signup gating --- # Getting Started Keel is a house framework for Node.js — a small, legible MVC layer over [Hono](./hono.md). This guide is a guided first hour: install it, stand up a route, a controller, and a view, read some config, drive the console, and know where to go next. ## Requirements - Node.js **≥ 22** - npm (ships with Node) Keel targets modern Node and web-standard APIs, so a current runtime matters — `22` is the floor. ## Install The fastest path to a running app is the generator — it copies a curated kit from the same `@shaferllc/keel` version you install, so the template cannot lag the framework: ```bash npm create keeljs@latest my-app cd my-app npm install npm run dev # http://localhost:3000 ``` For the full journey (presets, Cloudflare deploy, optional Keel Cloud + MCP), see **[From install to deploy](./from-install-to-deploy.md)**. Kit details: [Starter kits](./starter-kits.md). ### Into an existing app Already have a Node project? Add the package: ```bash npm install @shaferllc/keel ``` Everything Keel exposes comes from one entry point: ```ts import { Application, Router, config } from "@shaferllc/keel/core"; ``` You supply the four convention folders yourself — `app/`, `config/`, `routes/`, `bootstrap/` — plus an entry that calls `createApplication()`. A generated kit’s `bootstrap/app.ts` is the reference; copy it and trim to taste. ### Hacking on the framework itself To work on Keel proper, clone the framework repo: ```bash git clone https://github.com/shaferllc/keel.git cd keel npm install npm test npm run typecheck ``` Generate a disposable app against your checkout with `npm create keeljs@latest …` and point its dependency at `file:../keel`. ## Run the server ```bash npm run dev # tsx watch — restarts on change # or npm run serve # one-shot ``` You should see: ``` ⚓ Keel listening on http://localhost:3000 ``` Hit the sample routes the starter ships with: ```bash curl localhost:3000/ # {"framework":"Keel", ...} curl localhost:3000/ping # {"pong":true} curl localhost:3000/hello/Tom # Hello, Tom! ``` ## Your first route Routes live in `routes/web.ts`. The simplest is a **closure** — a function that takes the request context `c` and returns a response: ```ts router.get("/status", (c) => c.json({ ok: true, time: Date.now() })); ``` Save — `tsx watch` reloads — and visit `http://localhost:3000/status`. A route handler can be a closure, a `[Controller, method]` tuple, or even a ready-made `Response`. Closures are perfect for one-liners; reach for a controller once there's real logic to house. Parameters come off the path with a leading colon: ```ts router.get("/greet/:name", (c) => c.text(`Ahoy, ${c.req.param("name")}!`)); ``` You don't have to thread `c` everywhere, either — Keel's [request helpers](./request-response.md) reach the active request from anywhere, so the same route reads: ```ts import { text, param } from "@shaferllc/keel/core"; router.get("/greet/:name", () => text(`Ahoy, ${param("name")}!`)); ``` See [Routing](./routing.md) for names, groups, resource routes, param constraints, and URL generation. ## Your first controller Once a handler grows past a line or two, move it into a controller. Generate one with the console: ```bash npm run keel make:controller Task ``` That writes `app/Controllers/TaskController.ts`: ```ts import type { Ctx } from "@shaferllc/keel/core"; export class TaskController { index(c: Ctx) { return c.json({ controller: "TaskController", action: "index" }); } } ``` Wire it up in `routes/web.ts` with a `[Controller, method]` tuple. Keel resolves the controller **out of the container**, so its constructor gets dependency injection for free: ```ts import { TaskController } from "../app/Controllers/TaskController.js"; router.get("/tasks", [TaskController, "index"]); ``` Confirm it's registered: ```bash npm run keel routes ``` ``` GET /tasks TaskController@index ``` Add more actions as plain methods, and give related routes their REST shape in one call with `router.resource("tasks", TaskController)`. [Controllers](./controllers.md) covers single-action controllers, lazy-loaded controllers, and how DI reaches the constructor. ## Your first view Keel views are [Hono JSX](./hono.md) components — plain functions that return markup. They live by convention in `resources/views/`. Create `resources/views/tasks.tsx`: ```tsx // @jsxImportSource hono/jsx import type { FC } from "hono/jsx"; export const TasksPage: FC<{ count: number }> = ({ count }) => (

⚓ Tasks

You have {count} task(s) aboard.

); ``` Render it from the controller with the `view()` helper — it renders the component to a full HTML document and type-checks the props against the component: ```ts import type { Ctx } from "@shaferllc/keel/core"; import { view } from "@shaferllc/keel/core"; import { TasksPage } from "../../resources/views/tasks.js"; export class TaskController { index(c: Ctx) { return view(TasksPage, { count: 3 }); } } ``` Note the `.js` import specifier for a `.tsx` file — that's the Node ESM convention, and it's correct even though the file on disk is TypeScript. See [Views](./views.md) for layouts, async components, and streaming. ## Configuration Config files live in `config/` and each exports a default object. They're loaded at boot under their filename, so `config/app.ts` is reachable as `config('app.*')`: ```ts // config/app.ts import { env } from "@shaferllc/keel/core"; export default { name: env("APP_NAME", "Keel"), env: env("APP_ENV", "local"), debug: env("APP_DEBUG", true), url: env("APP_URL", "http://localhost:3000"), port: env("APP_PORT", 3000), }; ``` `env()` reads a variable from `.env` (loaded at boot) with a typed fallback — it coerces `"true"`/`"false"` to booleans and numeric strings to numbers when the fallback is a number. Read config anywhere with the `config()` helper, using dot notation and an optional fallback: ```ts import { config } from "@shaferllc/keel/core"; config("app.name"); // "Keel" config("app.port", 3000); // number, with a fallback ``` Add a new config file by dropping it in `config/` — `config/mail.ts` becomes `config('mail.*')` with no wiring. [Configuration](./configuration.md) has the full story. ## The console The `keel` console drives the app from the command line. In the starter, run it through npm: ```bash npm run keel routes # list every registered route npm run keel serve --port 8080 # start the server on a chosen port npm run keel make:controller Post # -> app/Controllers/PostController.ts npm run keel make:provider Billing # -> app/Providers/BillingServiceProvider.ts npm run keel make:middleware Auth # -> app/Http/Middleware/authMiddleware.ts ``` The `make:*` generators scaffold from the same stubs the framework uses, so generated files are wired to the right folders and import from `@shaferllc/keel/core`. `keel routes` is your map — run it whenever you're unsure what's mounted. [The Console](./console.md) lists every command. ## Where to go next You now have the shape of a Keel app: routes point at controllers, controllers render views and read config, and the console scaffolds the pieces. - **[From install to deploy](./from-install-to-deploy.md)** — presets, Cloudflare, optional Keel Cloud + MCP - [Architecture](./architecture.md) — how boot, the container, and the request lifecycle fit together - [The Service Container](./container.md) — how dependency injection works - [Service Providers](./providers.md) — where you register your own services - [Routing](./routing.md) — parameters, names, groups, resources, URLs - [Controllers](./controllers.md) — actions, DI, single-action controllers - [Views](./views.md) — JSX components, layouts, streaming - [Middleware](./middleware.md) — global and per-route request filters - [Request & Response](./request-response.md) — the helpers that reach the active request - [Database](./database.md) and [Models](./models.md) — the query builder and the active-record layer on top of it - [Configuration](./configuration.md) and [The Console](./console.md) — settings and commands - [Building with AI](./ai.md) — MCP docs + Cloud tools When something isn't documented, open the source — the whole framework is a few hundred readable lines in `src/core/`, and [Built on Hono](./hono.md) explains what you inherit from the layer underneath. --- # Starter kits ```bash npm create keeljs@latest my-app -- --preset saas ``` Four curated applications. Each is a complete, working app — not a scaffold you have to finish. For the full path from this command through Cloudflare or Keel Cloud, see [From install to deploy](./from-install-to-deploy.md). | Preset | What you get | | --- | --- | | `minimal` | Routes, a controller, JSX + [Keel UI](./ui.md) + Tailwind, `/health`. No database. | | `api` | JSON API via `apiResource`, OpenAPI at `/docs`, Watch at `/watch`, migrations, tests. | | `app` *(default)* | Full-stack auth: register/login, password reset form, email verification, 2FA setup + confirm, Watch. | | `saas` | `app` plus teams, role gates, invitation revoke, Stripe-ready **team** billing (pricing / checkout / portal, FakeGateway when Stripe keys are absent), social login, a background queue + scheduler, and a team-scoped REST API with OpenAPI at `/docs`. | UI chrome (buttons, fields, panels, hero) comes from `@shaferllc/keel/ui` — see [UI](./ui.md). Kits import the stylesheet in `resources/css/app.css` and use the JSX components in `resources/views/`. Edge deploy is **cross-cutting** — every DB kit ships `worker.ts` + Wrangler. There is no separate `edge` preset. ## Pick a kit ```bash npm create keeljs@latest my-app # app (default) npm create keeljs@latest my-api -- --preset api npm create keeljs@latest my-saas -- --preset saas npm create keeljs@latest bare -- --preset minimal cd my-app && npm install && npm run dev ``` Then open `http://localhost:3000`. The SaaS kit already has a team switcher, invites, role-gated admin actions, and team billing wired through [teams](./teams.md) and [billing](./billing.md) — start by editing `app/Models` and `routes/web.ts`. ## Refreshing an existing kit `create-keeljs` writes `.keel/kit.json` with content hashes of every stock file. After you bump `@shaferllc/keel`, pull new kit files without clobbering your edits: ```bash npm install @shaferllc/keel@latest npx keel kit:sync # uses preset from .keel/kit.json # npx keel kit:sync --preset saas # if you have no lockfile yet # npx keel kit:sync --force # overwrite customized kit files too # npx keel kit:sync --dry-run # preview ``` - **Missing** kit files are always added (new views, configs, …). - **Untouched** files (hash still matches the lockfile) are updated in place. - **Customized** files are skipped unless you pass `--force`. - `.env` is never overwritten. Apps generated before kit lockfiles existed: pass `--preset` once; sync writes `.keel/kit.json` so later runs are smart. Without `--force`, only missing files are added until the lockfile knows what "stock" looked like. ## Every database, Cloudflare first Each kit with a database ships with all four drivers wired. Switching is `DB_CONNECTION` and nothing else — no model or query changes, because they talk to a `Connection`, not a driver. | | | | --- | --- | | **D1** | The default for deploys. Inside the Worker Keel uses the binding; migrations and scripts reach the same database over [the HTTP API](./database.md), so `keel migrate` works from your laptop and from CI. | | **SQLite** (libSQL) | A local file. What `npm run dev` uses — no account, no wrangler. | | **Turso** | libSQL over the network. | | **Postgres** | For when you want it. | Local and production are both SQLite dialects, so one schema and one set of migrations serve both. ```bash npm run dev # Node, SQLite file, no setup npm run dev:edge # wrangler, local D1 npm run deploy # wrangler deploy ``` To deploy: ```bash wrangler d1 create my-app # paste the id into wrangler.jsonc npm run deploy ``` ## What's in the box `app` and `saas` mount [accounts](./accounts.md), so password reset, email verification, and two-factor already work — the flows live in the framework, tested once, rather than being copy-pasted into each new app. HTML controllers own the UI; JSON `/auth/*` routes are disabled via `config/accounts.ts`. `api` mounts [`apiResource`](./api-resources.md) and [OpenAPI](./openapi.md) so the posts demo is declarative CRUD with a live `/docs` UI. `api`, `app`, and `saas` also mount [Watch](./watch.md) at `/watch` for local debugging. `saas` also mounts [teams](./teams.md) and [billing](./billing.md). The **team** is the Stripe customer (`billableTable: "teams"`). Without Stripe keys, FakeGateway runs so subscribe still redirects to a checkout URL in development and tests. In `saas`, a tenant-owned model is one word: ```ts import { TenantModel } from "@shaferllc/keel/teams"; class Project extends TenantModel { static table = "projects"; } await Project.all(); // only the current team's. Always. await Project.create({ name: "Hi" }); // stamped with the current team ``` Another team's project isn't merely hidden from a list — `Project.find(id)` returns `null`. You never write `.where("team_id", …)`, which is what makes it impossible to forget. ### The REST API is the same tenancy, for free `saas` also generates a REST API over that model — `GET/POST /api/projects`, `GET/PUT/DELETE /api/projects/:id` — documented at `/docs`: ```ts apiResource(router, Project, { body: ProjectBody, access: { read: () => !auth().guest(), /* … */ }, }); ``` There is no `scope:` option and no `where` clause, because `Project` is a `TenantModel`: the generated queries are *already* constrained to the caller's team. `GET /api/projects/1` on another team's project is a **404**, not a leak. Access is deny-by-default, so a guest gets a 403 rather than a 500 — they have no team, and a tenant query without one throws, by design. It lives under `/api/projects` because the HTML form owns `POST /projects`; two handlers on one method+path is a silent shadowing bug. ### Background work Registration doesn't wait on SMTP. The verification email is a [queued job](./queues.md): ```ts await dispatch(new SendVerificationEmailJob(user.id)); ``` Under Node, `BackgroundServiceProvider` runs a `MemoryDriver` and drains it on an interval. On Cloudflare it stays `SyncDriver` — a Worker may not hold a timer between requests — and the cron trigger in `wrangler.jsonc` drives the [scheduler](./scheduling.md) through the Worker's `scheduled` handler. Recurring work (`prune-invitations`, daily) is declared in `ScheduleServiceProvider`, so one trigger serves any number of tasks. ### Social login "Sign in with GitHub / Google", off by default. Leave the client id blank and the provider simply isn't offered — no button, and its route 403s — the same bargain billing makes with Stripe. Set `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` in `.env` to turn it on. Accounts are matched on the **provider's id first, email second**, and email only links an account when the provider says the address is verified. The reverse order is an account-takeover bug: anyone can put your address on their GitHub profile. ## Why a generator, and not a template repo Because a second repo rots. The old starter sat pinned to `0.78.2` while the framework was on `0.79.0`, and nothing noticed. The templates live **inside the framework package**, so the version a kit is generated from is, by construction, the version it was written for. And CI generates all four on every push, then typechecks, migrates, boots, serves a request, bundles the Worker, and runs their tests — so a breaking change fails in the pull request that caused it, not in your `npm create` three weeks later. ## The Node/edge seam Each kit has two provider lists. `bootstrap/providers.ts` runs under Node; `bootstrap/providers.edge.ts` runs in the Worker and deliberately **omits the database provider** — it reaches for `pg`, which needs `net`/`tls`, and wrangler cannot bundle a TCP driver for the edge. `worker.ts` binds D1 before the app boots, so nothing on the edge needs to open a connection. If you add a provider that touches a Node-only module, add it to the Node list only. --- # Architecture Keel is small on purpose. This page maps the pieces and traces a request from socket to response. Nothing here is magic — every layer is a short, readable file in `src/core/`, and this guide is mostly a reading order for it. ## The layers ``` ┌─────────────────────────────────────────────────────────┐ │ bin/keel.ts console entry (serve, make:*, …) │ ├─────────────────────────────────────────────────────────┤ │ bootstrap/app.ts createApplication() │ │ └─ boots providers, binds HTTP kernel, loads routes │ ├─────────────────────────────────────────────────────────┤ │ Application (extends Container) │ │ ├─ loads .env + config/*.ts │ │ └─ provider register() → boot() lifecycle │ ├─────────────────────────────────────────────────────────┤ │ Container bind / singleton / instance / make │ ├─────────────────────────────────────────────────────────┤ │ HttpKernel global middleware → compiles routes → Hono │ ├─────────────────────────────────────────────────────────┤ │ @hono/node-server the actual HTTP server │ └─────────────────────────────────────────────────────────┘ ``` Read it top to bottom as the flow of control at boot, and bottom to top as the flow of a request. The console and the server both enter through `createApplication()` — the difference is only what they do with the app once it's booted (serve it, or run a command against it). ## Core building blocks - **`Container`** ([container.ts](../src/core/container.ts)) — the dependency registry. Two maps (bindings, cached instances) and a `make()` resolver. - **`Application`** ([application.ts](../src/core/application.ts)) — a `Container` with a lifecycle: load env, auto-load config, register and boot providers. - **`Config`** ([config.ts](../src/core/config.ts)) — a dot-notation repository plus the `env()` coercion helper. - **`ServiceProvider`** ([provider.ts](../src/core/provider.ts)) — the `register()` / `boot()` contract. - **`Router`** ([http/router.ts](../src/core/http/router.ts)) — collects route definitions; resolves controller tuples out of the container. - **`HttpKernel`** ([http/kernel.ts](../src/core/http/kernel.ts)) — holds global middleware and compiles the router onto a Hono instance. ## The container is the center Everything else hangs off the container. `Application` **is** a `Container` — it extends it — so the same `bind` / `singleton` / `instance` / `make` surface that registers a service also holds `Config`, `Router`, `View`, the `Logger`, and your own controllers. ```ts app.singleton(Router, (a) => new Router(a)); // registered at construction const router = app.make(Router); // resolved anywhere later ``` Two properties make this the spine of the framework: - **Everything resolves through one place.** A controller doesn't `new` its dependencies — it receives the container in its constructor and pulls what it needs, so tests can swap any binding for a fake without touching the code under test. - **Classes auto-resolve.** `make(SomeClass)` builds `SomeClass` even with no explicit binding, handing its constructor the container. You only register a binding when construction needs configuration or should be shared. `bind` gives a fresh value each resolve; `singleton` caches after the first; `instance` stores an already-built value. The global helpers (`make()`, `bind()`, `config()`, `app()`) are thin wrappers that resolve against the active application, so you rarely thread the container by hand. See [The Service Container](./container.md) for the full API and resolution rules. ## Service providers wire it up Providers are the seams where your services enter the container. Each has two phases, and the split matters: ```ts export class AppServiceProvider extends ServiceProvider { register(): void { // Bind only. Nothing else is guaranteed registered yet. bind("clock", () => new Date().toISOString()); } boot(): void { // Every provider has registered — safe to resolve and wire things. } } ``` `Application.boot()` runs **all** providers' `register()` before **any** provider's `boot()`. That ordering is the whole point: `register()` may only add bindings, so `boot()` can safely depend on anything any provider bound, regardless of order. Reaching for another service inside `register()` is the classic bug — the binding may not exist yet. [Service Providers](./providers.md) goes deeper. ## Boot sequence When `keel serve` runs: 1. **`createApplication()`** constructs the `Application` with the project root. 2. The constructor registers the active application (for global helpers) and binds core services (`Config`, `Router`, `View`, `Events`, `Cache`, `Logger`). 3. **`app.boot(providers)`**: - loads `.env`, then every `config/*.ts` file into the `Config` repository, - runs each provider's **`register()`** (bind-only phase), - runs each provider's **`boot()`** (wire-up phase). 4. The HTTP kernel (`app/Http/Kernel.ts`) is bound as a singleton. 5. `routes/web.ts` registers routes on the `Router`. 6. `HttpKernel.build()` returns a Hono app; `@hono/node-server` serves it. Steps 1–3 are identical whether you're serving or running a console command — `createApplication()` is the single door in. Only steps 4–6 are HTTP-specific. ## The application object `Application` is Keel's central object — the [container](./container.md) plus a lifecycle. Beyond `register()`/`boot()`, it carries a small ergonomic surface modelled on the classic service-app pattern (Feathers' `app`): a settings store, an inline plugin hook, and app-level events. **Configure with a plain function.** `register(Provider)` gives you the two-phase register/boot lifecycle; `configure(fn)` is the one-shot alternative for inline setup — call a function with the app, chain the next: ```ts app .configure((a) => a.set("mail.from", "hi@keel.dev")) .configure(installBilling); // (app) => { … } ``` **Store app-wide values.** `set`/`get` are a thin façade over the `Config` repository, so `app.set("db.url", …)` and `config().get("db.url")` read the same store — no second bag to keep in sync: ```ts app.set("db.url", process.env.DATABASE_URL); const url = app.get("db.url"); const port = app.get("port", 3000); // typed fallback ``` **Emit and listen at the app level.** `on`/`once`/`off`/`emit` delegate to the [`Events`](./events.md) singleton, so `app.on(...)` and the global `listen()` helper share one emitter. Listeners may be async; `emit` awaits them in order: ```ts const off = app.on("user.registered", (user) => sendWelcome(user)); await app.emit("user.registered", user); off(); // unsubscribe ``` ### API reference #### `app.configure(fn)` Run a `Configurator` — `(app) => unknown` — against the app and return the app for chaining. The lightweight alternative to a `ServiceProvider` when you don't need the register/boot split. ```ts app.configure((a) => a.router().get("/health", () => "ok")); ``` Notes: runs immediately and synchronously in call order. Its return value is ignored (return-for-chaining is the app, not the fn's result). For anything that must bind before another service boots, use a provider instead. #### `app.set(key, value)` / `app.get(key, fallback?)` Write and read an app-wide value. Both use dot-notation and are backed by `Config`, so values set here are visible to `config()` and vice-versa. `set` returns the app (chainable); `get` takes an optional typed fallback. ```ts app.set("app.name", "Keel"); config().get("app.name"); // "Keel" app.get("app.name"); // "Keel" app.get("app.locale", "en"); // fallback when unset ``` Notes: because the store is shared with `Config`, prefer namespaced keys (`"mail.from"`, not `"from"`) to avoid collisions with `config/*.ts` files. #### `app.on(event, listener)` / `app.once(event, listener)` Subscribe to an app event; `once` auto-unsubscribes after the first emission. Both return an **unsubscribe function**. Delegates to the `Events` singleton. ```ts const off = app.on("order.paid", (o) => fulfil(o)); ``` Notes: the listener signature is `(payload) => void | Promise`. Identical to `app.make(Events).on(...)` — the method is sugar so you rarely resolve `Events` by hand. #### `app.off(event, listener)` Remove a listener registered with `on`/`once`. Returns the app (chainable). Pass the *same* function reference used to subscribe. #### `app.emit(event, payload?)` Emit an app event, awaiting every listener in registration order. Returns a `Promise`. An async listener that rejects propagates out of `emit`. ```ts await app.emit("cache.cleared", { at: Date.now() }); ``` ## Request lifecycle For each incoming request: ``` request → Hono matches the route → contextStorage() stashes the context for the request helpers → context middleware sets c.get("app") = the container → global middleware stack (e.g. requestLogger) runs, in order → the route handler runs: • a closure → called with (c) • a [Controller, m] → controller resolved from the container, then method(c) is called (DI in the ctor) → the handler's return value becomes the response (a string is wrapped as HTML; a Response passes through) → middleware unwinds on the way back out response ``` A few details worth knowing: - **The context is stashed per request.** Before your middleware runs, the kernel enables Hono's `contextStorage()`. That's what lets the [request helpers](./request-response.md) (`request`, `param()`, `json()`) reach the current request without you passing `c` around. - **Controllers are resolved lazily, per request.** The router turns a `[Controller, method]` tuple into a function that resolves the controller from the container when the route fires — so constructor DI runs against the live app. Tuples may also be `() => import(...)` loaders for code-splitting. - **Errors funnel through the kernel.** A thrown `HttpException` renders at its status; anything else is a 500. The kernel content-negotiates — HTML for browsers, JSON otherwise — and hides internals unless `app.debug` is on. Unmatched routes go through the same path as a `NotFoundException`. [Middleware](./middleware.md) covers the stack in detail, and [Routing](./routing.md) covers how handlers are declared and matched. ## Edge-safe by design Keel's core imports **no** Node built-ins at module load. `fs`, `path`, `url`, and `dotenv` are pulled in **dynamically**, and only when filesystem discovery is enabled: ```ts // application.ts — dynamic, guarded, optional const { readdir } = await import("node:fs/promises"); ``` The payoff is that the same `Application`, `Router`, `View`, and query builder run unchanged on Cloudflare Workers, Deno, and Bun — anywhere with web-standard `fetch`, `Request`, `Response`, and Web Crypto. On Workers, where there's no filesystem to scan, you skip discovery and pass config inline: ```ts await app.boot(providers, { discoverConfig: false, config: { app: { name: "Keel" } } }); ``` The second argument is `BootOptions`: `discoverConfig` (skip filesystem config discovery — the default on the edge) and `config` (an inline config object merged in). Together they let the app boot with no filesystem at all. Everything that would normally reach for a platform API is designed around this seam: the [database](./database.md) layer talks to a `Connection` you provide rather than importing a driver; signed URLs use Web Crypto's `crypto.subtle`; views render to strings with no filesystem. The rule is simple — the core owns logic, the platform owns I/O, and the two meet at an interface you supply. ## Two repos: library and starter Keel is distributed like most frameworks — a **library** you install plus an **app** that depends on it: | Repo | Role | |------|------| | `shaferllc/keel` | The framework. Published as `@shaferllc/keel`; userland imports `@shaferllc/keel/core`. | | `shaferllc/keel-app` | The starter app — clone it to build something. Picks up core updates via `npm update`. | The split mirrors the classic application-vs-library separation. Your code lives in `app/` (controllers, providers, middleware); the framework lives behind the package boundary. Because the two are versioned separately, a framework upgrade is an ordinary dependency bump — your `app/` doesn't move. [Getting Started](./getting-started.md#install) walks through both install paths. ## Design principles - **One container, resolved everywhere.** Testability and composition follow from routing every dependency through it. - **Convention over configuration.** Fixed folders (`app/`, `config/`, `routes/`, `bootstrap/`) mean no manual wiring for the common case. - **Thin over clever.** The framework is a few hundred readable lines. When in doubt, open the source — there is no hidden magic. - **Wrap the best, own the surface.** Hono does HTTP; Keel owns the developer- facing API so the underlying library can change without breaking your app. [Built on Hono](./hono.md) draws that line precisely. - **Edge-safe, driver-agnostic.** The core imports no database driver, no mail SDK, no socket. Every backend — [database](./database.md), [mail](./mail.md), [redis](./redis.md), [queues](./queues.md), [storage](./storage.md), [broadcasting](./broadcasting.md) — plugs in behind a small interface, so the same app runs on Node and on the edge. You bring the driver; Keel brings the ergonomics. - **Explicit over implicit.** No hidden runtime magic: body parsing is a method you call, [templates](./templates.md) interpret rather than `eval`, and providers register into one predictable global scope. Boring and predictable beats clever and surprising. ## Extending Keel The MVP core is deliberately small. Natural extension points: - **New services** → a service provider that binds them into the container. - **New console commands** → add to [cli/index.ts](../src/core/cli/index.ts). - **New subsystems** (ORM, queues, mail) → a provider that registers the subsystem plus a `config/*.ts` file for its settings. This is exactly how the roadmap items will land. --- # The Service Container The container is the backbone of Keel. Every service — config, the router, controllers, and anything you write — is registered in it and resolved out of it. It is the single registry every service resolves out of. ## Why a container? Instead of `import`ing concrete classes everywhere and `new`ing them by hand, you _bind_ how a service is built once, then _resolve_ it wherever you need it. That gives you a single place to swap implementations (real vs. fake in tests), share singletons, and inject dependencies. ## Binding Bindings live in a service provider's `register()` method (see [Providers](./providers.md)). A binding is keyed by a **token** — a string, symbol, or class — and a **factory** that receives the container. ```ts import { ServiceProvider } from "@shaferllc/keel/core"; export class AppServiceProvider extends ServiceProvider { register(): void { // Transient: a fresh value every time it is resolved. this.app.bind("clock", () => new Date()); // Singleton: built once, then cached. this.app.singleton(Mailer, (app) => new Mailer(app.make("config"))); // Instance: register an already-constructed value. this.app.instance("version", "0.1.0"); } } ``` ## The short way: global helpers You don't need `this.app` at all. The same operations exist as global helpers that resolve against the active application — bind and resolve from anywhere: ```ts import { bind, singleton, instance, make, bound } from "@shaferllc/keel/core"; bind("clock", () => new Date()); // transient singleton(Mailer, (app) => new Mailer(app)); // shared instance("version", "0.6.0"); // pre-built value const mailer = make(Mailer); const version = make("version"); if (bound("clock")) { /* … */ } ``` Both styles work everywhere; the helpers are just less to type. ## Aliases Point a second token at an existing binding with `alias(name, target)`. The target owns the binding (and its singleton, if any); the alias just resolves through to it: ```ts import { alias, make } from "@shaferllc/keel/core"; alias("router", Router); // make("router") === make(Router) ``` ## Swapping bindings in tests `swap(token, factory)` temporarily replaces a binding with a fake, and `restore(token)` puts the original back — the container remembers the real one (binding *and* any resolved instance) so your test doesn't have to. `restore()` with no argument undoes every swap, which makes a tidy `afterEach`: ```ts import { swap, restore } from "@shaferllc/keel/core"; swap(Mailer, () => fakeMailer); // resolved once, shared for the test // … exercise code that make()s Mailer … restore(Mailer); // or restore() to undo all swaps ``` This is Keel's answer to mocking a service without reaching into module internals: the code under test resolves `Mailer` the same way it always does, and gets the fake. (For request-scoped fakes, prefer plain [dependency injection](#dependency-injection-in-controllers) — pass the collaborator in — so there's no global to restore.) ## Resolving Use `make()` (or `this.app.make()`) to pull something out: ```ts const mailer = make(Mailer); const version = make("version"); ``` If a token is bound, its factory runs (once, for singletons). If you pass an **unbound class**, the container auto-constructs it, passing itself to the constructor: ```ts class ReportService { constructor(private app: Container) {} } const report = app.make(ReportService); // works with no explicit binding ``` Auto-resolution is transient: an unbound class is rebuilt every `make()` — it is never cached, because nothing marked it shared. Bind it with `singleton` if you want one instance. ### When nothing is bound `make()` only auto-constructs **class** tokens. A string or symbol that was never bound has nothing to build, so it throws: ```ts make("nope"); // Error: Nothing bound in the container for [nope]. ``` Guard with `bound()` when a token might be missing: ```ts const clock = bound("clock") ? make("clock") : new Date(); ``` ## Dependency injection in controllers Controllers are resolved through the container, so their constructor receives it. Pull whatever you need: ```ts import type { Ctx } from "@shaferllc/keel/core"; import { Application, type Container } from "@shaferllc/keel/core"; export class InvoiceController { constructor(private app: Container) {} index(c: Ctx) { const config = this.app.make(Application).config(); return c.json({ currency: config.get("app.currency", "USD") }); } } ``` ## The API | Method | Purpose | |--------|---------| | `bind(token, factory)` | Transient binding — fresh value each resolve | | `singleton(token, factory)` | Shared binding — resolved once, then cached | | `instance(token, value)` | Register a pre-built value as a shared instance | | `make(token)` / `get(token)` | Resolve a token | | `bound(token)` | Whether a token is bound or cached | | `build(ctor)` | Instantiate a class, passing it the container | ## Tokens - **Strings/symbols** — good for values and interfaces: `"config"`, `"clock"`. - **Classes** — good for services; the class doubles as its own token and can be auto-resolved when unbound. ## Under the hood The whole container is about 90 lines in [`src/core/container.ts`](../src/core/container.ts). Two maps — one for bindings, one for cached instances — and a `make()` that checks the cache, runs the factory, and caches shared results. Read it; there's no magic. ## Related Bindings usually live in a [service provider](./providers.md)'s `register()` method; the global helpers resolve against the active [`Application`](./application.md), which registers itself on construction. --- ## API reference ### Container The registry itself. You rarely construct it — the `Application` is a `Container`, and you reach it as `this.app` inside providers/controllers or via the global helpers below. Registration methods (`bind`, `singleton`) return `this` and so chain; resolution methods return the value. #### `bind(token, factory)` `bind(token: Token, factory: Factory): this` Registers a transient binding — the factory runs on every resolve, yielding a fresh value each time. ```ts app.bind("clock", () => new Date()); app.bind(Mailer, (c) => new Mailer(c.make("config"))); ``` **Notes:** returns `this`, so calls chain. Re-binding the same token overwrites the prior binding. The factory receives the container, so it can resolve its own dependencies. #### `singleton(token, factory)` `singleton(token: Token, factory: Factory): this` Registers a shared binding — the factory runs at most once; the result is cached and returned on every later resolve. ```ts app.singleton(Mailer, (c) => new Mailer(c.make("config"))); ``` **Notes:** returns `this`. The factory is lazy — it doesn't run until the first `make()`. The cached value lives in the instance map, so a later `bound()` is `true` even before first resolve (the binding is registered immediately). #### `instance(token, value)` `instance(token: Token, value: T): T` Registers an already-constructed value as a shared instance, skipping any factory. ```ts const version = app.instance("version", "0.30.0"); // returns "0.30.0" ``` **Notes:** returns the value you passed (not `this`), so it reads well inline. Overrides any cached instance for the token. Because it writes the instance map, `make()` returns it directly without ever consulting a binding. #### `make(token)` `make(token: Token): T` Resolves a token: returns a cached instance if present, else runs its binding (caching the result for singletons), else auto-constructs an unbound class. ```ts const mailer = app.make(Mailer); const version = app.make("version"); ``` **Notes:** resolution order is instance cache → binding → class auto-build. An unbound **class** token is built via `build()` and **not** cached. An unbound string/symbol token throws `Nothing bound in the container for [token].` #### `get(token)` `get(token: Token): T` Alias for `make()` — identical behavior, sugar for an `app(token)`-style read. ```ts const mailer = app.get(Mailer); ``` **Notes:** delegates straight to `make()`; use whichever name reads better. #### `build(ctor)` `build(ctor: Constructor): T` Instantiates a class directly, passing the container to its constructor. Bypasses bindings and the instance cache entirely. ```ts const report = app.build(ReportService); // new ReportService(app) ``` **Notes:** always constructs a new instance (never cached), and ignores any binding registered for the class. `make()` calls this under the hood when it auto-resolves an unbound class token. #### `bound(token)` `bound(token: Token): boolean` Reports whether the token has a binding **or** a cached instance. ```ts if (app.bound("clock")) app.make("clock"); ``` **Notes:** `true` for singletons even before first resolve (the binding exists). #### `alias(alias, target)` `alias(alias: Token, target: Token): this` Registers a token that resolves through to another token. ```ts app.singleton(Router, () => new Router(app)); app.alias("router", Router); app.make("router") === app.make(Router); // true — same singleton ``` **Notes:** returns `this` (chainable). The alias is a thin transient wrapper that calls `make(target)`, so the target keeps ownership of its own sharing — aliasing a singleton still yields the one shared instance. #### `swap(token, factory)` `swap(token: Token, factory: Factory): this` Temporarily replaces a binding with a fake, for tests. The replacement is shared (resolved once), and the original binding **and** any resolved instance are remembered for `restore()`. ```ts app.swap(Mailer, () => fakeMailer); app.make(Mailer); // fakeMailer ``` **Notes:** returns `this`. Idempotent per token — the first `swap` saves the original, later swaps just change the fake. Clears the cached instance so the next `make()` goes through the fake. #### `restore(token?)` `restore(token?: Token): this` Undoes a `swap()`, restoring the original binding (and instance). Called with no token, it restores **every** swap — handy in an `afterEach`. ```ts app.restore(Mailer); // one token app.restore(); // all swaps ``` **Notes:** returns `this`. A no-op for a token that wasn't swapped. If the token had no binding before the swap, `restore` removes it, leaving `bound()` false again. Does not consider auto-resolvable classes — an unbound class is `bound() === false` yet still `make()`-able. ### Global helpers Free functions in [`src/core/helpers.ts`](../src/core/helpers.ts) that proxy to the active application's container, so you can bind and resolve from anywhere without threading `this.app` through. Each calls `app()` internally, which throws if no `Application` has been bootstrapped. #### `bind(token, factory)` `bind(token: Token, factory: Factory): void` Transient binding on the active application. ```ts import { bind } from "@shaferllc/keel/core"; bind("clock", () => new Date()); ``` **Notes:** returns `void` — unlike `Container.bind`, it does **not** return the container, so these helpers don't chain. #### `singleton(token, factory)` `singleton(token: Token, factory: Factory): void` Shared binding on the active application. ```ts import { singleton } from "@shaferllc/keel/core"; singleton(Mailer, (app) => new Mailer(app)); ``` **Notes:** returns `void`. Same lazy, resolve-once semantics as `Container.singleton`. #### `instance(token, value)` `instance(token: Token, value: T): T` Registers a pre-built value on the active application; returns the value. ```ts import { instance } from "@shaferllc/keel/core"; const version = instance("version", "0.30.0"); ``` **Notes:** the one container helper that returns its value (mirrors `Container.instance`), so it composes inline. #### `make(token)` `make(token: Token): T` Resolves a token out of the active application's container. ```ts import { make } from "@shaferllc/keel/core"; const mailer = make(Mailer); const version = make("version"); ``` **Notes:** same resolution rules and throw-on-missing behavior as `Container.make`. #### `bound(token)` `bound(token: Token): boolean` Whether the token is bound or cached on the active application. ```ts import { bound } from "@shaferllc/keel/core"; if (bound("clock")) { /* … */ } ``` **Notes:** proxies `Container.bound`. #### `alias(alias, target)` · `swap(token, factory)` · `restore(token?)` Global-helper forms of the container methods, resolving against the active application. ```ts import { alias, swap, restore } from "@shaferllc/keel/core"; alias("router", Router); swap(Mailer, () => fakeMailer); restore(); // undo every swap ``` **Notes:** `alias`/`swap` proxy `Container.alias`/`Container.swap`; `restore` proxies `Container.restore` (no token restores all). `swap`/`restore` are for tests — see [Swapping bindings in tests](#swapping-bindings-in-tests). ### Interfaces & types #### `Token` `type Token = string | symbol | Constructor` What every binding is keyed by. Use a string/symbol for values and interfaces, or a class — which doubles as its own token and can be auto-resolved when unbound. ```ts const nameKey: Token = "app.name"; const svcKey: Token = Mailer; // the class is the token ``` #### `Constructor` `type Constructor = new (...args: any[]) => T` Any newable class. A class token is a `Constructor`; `build()` and auto-resolution call `new ctor(container)` on it, so the constructor's first parameter receives the container. ```ts const ctor: Constructor = ReportService; ``` #### `Factory` `type Factory = (app: Container) => T` The builder function you hand to `bind`/`singleton`. It receives the container, so it can resolve dependencies while constructing the value. ```ts const mailerFactory: Factory = (app) => new Mailer(app.make("config")); ``` --- # Service Providers Service providers are the central place to configure your application. Nearly everything Keel boots — config, routing, your own services — is wired up in a provider. ## The lifecycle A provider has four hooks, run across the application's lifecycle. Only `register()` and `boot()` are needed day-to-day; `ready()` and `shutdown()` are there for work that must happen once the whole app is live, or as it stops: ```ts import { ServiceProvider } from "@shaferllc/keel/core"; export class AppServiceProvider extends ServiceProvider { register(): void { // Phase 1. Bind things into the container. // Do NOT resolve other services here — nothing is guaranteed // to be registered yet. } boot(): void { // Phase 2. Runs after EVERY provider has registered. // Safe to resolve services and wire them together. } ready(): void { // Phase 3. Runs after every provider has booted and the app is fully up. // For work that needs a live app — warm a cache, attach to the server. } shutdown(): void { // Phase 4. Runs on graceful termination, in reverse registration order. // Close connections, flush queues, cancel timers. } } ``` The `Application` runs **all** `register()` methods first, then **all** `boot()` methods, then **all** `ready()` methods — that ordering is what lets providers depend on each other without worrying about load order. On `app.terminate()` (wired to SIGINT/SIGTERM by `keel serve`), every `shutdown()` runs in **reverse** registration order (LIFO), so a provider tears down before the ones it depended on. All four hooks may be `async` — the application awaits them. `ready()` and `shutdown()` are optional and map onto the app's `onReady()` / `onShutdown()` hooks, so a provider's `shutdown()` runs alongside any it registered by hand. ## The `app` reference Every provider is handed the `Application` at construction and holds it as `this.app` (a `protected` field). The `Application` **is** the service container, so `this.app` gives you `bind`, `singleton`, `instance`, `make`, and `bound` directly, plus the framework accessors `config()`, `router()`, and `view()`: ```ts import { ServiceProvider } from "@shaferllc/keel/core"; import { SearchIndex } from "../Services/SearchIndex.js"; export class SearchServiceProvider extends ServiceProvider { register(): void { this.app.singleton("search", () => new SearchIndex()); } boot(): void { const debug = this.app.config().get("app.debug", false); this.app.router().get("/health", () => "ok"); } } ``` You never construct a provider yourself — the `Application` does it for you when you register the class. It calls `new Provider(app)`, so `this.app` is always the live application instance. ## Registering a provider Add your provider class to `bootstrap/providers.ts`: ```ts import type { ProviderClass } from "@shaferllc/keel/core"; import { AppServiceProvider } from "../app/Providers/AppServiceProvider.js"; import { BillingServiceProvider } from "../app/Providers/BillingServiceProvider.js"; export const providers: ProviderClass[] = [ AppServiceProvider, BillingServiceProvider, ]; ``` Providers boot in array order. Under the hood, `app.boot(providers)` calls `app.register(Provider)` for each class — which does `new Provider(app)` and stashes the instance — then runs the two phases across the whole set. ## Providers are Keel's plugin system A service provider is Keel's answer to a **plugin**: a self-contained slice of functionality you register into the app. To make one **reusable**, register it with **options** — they arrive as `this.options`, typed via the generic: ```ts class RateLimitProvider extends ServiceProvider<{ max: number }> { boot() { this.app.make(HttpKernel).use(rateLimiter({ max: this.options.max })); } } app.register(RateLimitProvider, { max: 100 }); // parameterized, like a plugin ``` The same provider class can be registered more than once with different options. Without options, `this.options` is an empty object. > Keel providers are **not encapsulated** — bindings, decorators, and routes are > registered into the one global container. That's a deliberate simplification: > there's a single, predictable scope, and no plugin-boundary rules to reason > about. For per-request behavior in the HTTP pipeline (auth, logging, etc.), > reach for [middleware](./middleware.md), which *is* scoped to the routes you > attach it to. ## Generating a provider ```bash npm run keel make:provider Billing ``` Writes `app/Providers/BillingServiceProvider.ts`. Remember to add it to `bootstrap/providers.ts`. ## A realistic example ```ts import { ServiceProvider, Config } from "@shaferllc/keel/core"; import { StripeClient } from "../Services/StripeClient.js"; export class BillingServiceProvider extends ServiceProvider { register(): void { this.app.singleton(StripeClient, (app) => { const key = app.make(Config).get("services.stripe.key"); return new StripeClient(key); }); } boot(): void { // e.g. register webhooks, warm a cache, etc. } } ``` Now any controller or service can `this.app.make(StripeClient)` and get the same configured instance. ## Async providers Both phases may return a promise, and the application `await`s each one before moving on. Reach for this when a binding needs to open a connection or fetch a remote manifest: ```ts import { ServiceProvider } from "@shaferllc/keel/core"; import { SearchClient } from "../Services/SearchClient.js"; export class SearchServiceProvider extends ServiceProvider { async register(): Promise { const client = await SearchClient.connect(process.env.SEARCH_URL!); this.app.instance(SearchClient, client); } async boot(): Promise { await this.app.make(SearchClient).warm(); } } ``` Because every `register()` is awaited before any `boot()` runs, an async `register()` in one provider still completes before another provider's `boot()` tries to resolve what it bound. ## Error behavior `register()` and `boot()` run inside `app.boot()`, which awaits each call in sequence. If any provider throws (or rejects), `app.boot()` rejects and the remaining providers never run — so a bad binding fails the whole boot loudly rather than leaving a half-wired container. Booting is also idempotent: once `app.boot()` has completed, calling it again returns immediately without re-running any provider. ## Rules of thumb - **`register()` binds. `boot()` uses.** Resolving a service in `register()` is the most common mistake — the thing you need may not be bound yet. - **Keep providers focused.** One provider per concern (billing, auth, search) reads better than one giant `AppServiceProvider`. - **Order matters only for `boot()` side effects**, since all registration happens before any booting. ## Related Providers wire services into the [container](./container.md); the [Application](./architecture.md) kernel constructs and boots them. See [configuration](./configuration.md) for what `this.app.config()` reads. --- ## API reference ### `ServiceProvider` The abstract base class every provider extends. You never instantiate it directly — you subclass it and register the subclass (via `bootstrap/providers.ts` or `app.register()`), and the `Application` constructs it for you. Override `register()` and/or `boot()`; both are no-ops by default. ```ts import { ServiceProvider } from "@shaferllc/keel/core"; export class AppServiceProvider extends ServiceProvider { register(): void { this.app.bind("clock", () => new Date().toISOString()); } } ``` **Notes:** `abstract`, so it can't be `new`ed on its own. A subclass needn't override both methods — leave one off to inherit the empty default. #### `constructor(app, options?)` `constructor(app: Application, options?: O)` (on `ServiceProvider`) Receives the live `Application` (stored as `protected this.app`) and the options passed to `register` (stored as `protected this.options`, `{}` if none). The `Application` invokes this for you; you don't call it. ```ts class RateLimitProvider extends ServiceProvider<{ max: number }> { boot() { this.app.make(HttpKernel).use(rateLimiter({ max: this.options.max })); } } app.register(RateLimitProvider, { max: 100 }); ``` **Notes:** `app` and `options` are `protected` — reachable from subclass methods, not from outside. Type the options via the class generic `ServiceProvider`. #### `app.register(Provider, options?)` `register(Provider: ProviderClass, options?: unknown): this` Registers a provider, optionally with options handed to its constructor. Chainable. `app.boot([Providers])` registers each without options. #### `register()` `register(): void | Promise` Phase-one hook: bind services into the container. Called for every provider before any `boot()` runs. Default implementation is empty. ```ts register(): void { this.app.singleton(StripeClient, (app) => new StripeClient(app.make(Config).get("services.stripe.key")), ); } ``` **Notes:** do **not** resolve other services here — another provider may not have bound them yet. May be `async`; the application awaits it. Throwing rejects `app.boot()`. #### `boot()` `boot(): void | Promise` Phase-two hook: runs after **every** provider has registered, so it's safe to resolve services and wire them together. Default implementation is empty. ```ts async boot(): Promise { await this.app.make(SearchClient).warm(); } ``` **Notes:** providers boot in registration (array) order — the only place order matters, since all `register()` calls finish first. May be `async`; the application awaits it. Throwing rejects `app.boot()`. #### `ready()` `ready(): void | Promise` Phase-three hook: runs after **every** provider has booted and the app is fully up (after the app's own `onReady` hooks). For work that needs a live app — warming a cache, attaching to the running server. Default implementation is empty. ```ts async ready(): Promise { await this.app.make(Cache).warm(["home", "pricing"]); } ``` **Notes:** runs in registration order, once, at the end of `app.boot()`. Optional — omit it and nothing runs. May be `async`; the application awaits it. #### `shutdown()` `shutdown(): void | Promise` Cleanup hook: runs on `app.terminate()` (which `keel serve` wires to SIGINT and SIGTERM) in **reverse** registration order. Close database/Redis connections, flush logs, cancel timers. Default implementation is empty. ```ts async shutdown(): Promise { await this.app.make(Redis).quit(); } ``` **Notes:** LIFO — the last provider registered shuts down first, so a provider tears down before the ones it depends on. It joins the app's `onShutdown` hooks, so a hook a provider registered by hand and its `shutdown()` both run. A throw doesn't stop the others; the first error is re-thrown after all have run. #### `app` (protected property) `protected app: Application` The application/container this provider configures. Use it in `register()` to bind and in `boot()` to resolve. ```ts boot(): void { const level = this.app.config().get("logger.level", "info"); } ``` **Notes:** it's the same `Application` instance across every provider, exposing the container methods (`bind`, `singleton`, `instance`, `make`, `bound`) plus `config()`, `router()`, and `view()`. Being `protected`, it's only visible to subclass code. ### Interfaces & types #### `ProviderClass` `type ProviderClass = new (app: Application) => ServiceProvider` The constructor type of a provider — a class (not an instance) that takes an `Application` and yields a `ServiceProvider`. Use it to type the array you export from `bootstrap/providers.ts` and anywhere you pass provider classes around. ```ts import type { ProviderClass } from "@shaferllc/keel/core"; import { AppServiceProvider } from "../app/Providers/AppServiceProvider.js"; export const providers: ProviderClass[] = [AppServiceProvider]; ``` **Notes:** it references the class itself, so entries are the class name with no `new` and no parentheses. `app.register()` and `app.boot()` both accept these. --- # Configuration Keel loads configuration from two sources: environment variables (`.env`) and config files (`config/*.ts`). Config files read env vars; your app reads config. ## Environment variables `.env` holds environment-specific values and secrets. It ships with: ``` APP_NAME=Keel APP_ENV=local APP_DEBUG=true APP_URL=http://localhost:3000 APP_PORT=3000 ``` `.env` is git-ignored. Commit a `.env.example` with safe defaults so teammates know what to set. ### The `env()` helper Read env vars with `env()`, which coerces obvious types: ```ts import { env } from "@shaferllc/keel/core"; env("APP_NAME"); // "Keel" env("APP_DEBUG", false); // true (string "true" -> boolean) env("APP_PORT", 3000); // 3000 (coerced to number when the fallback is a number) env("MISSING", "default"); // "default" ``` Coercion follows two rules, and they don't behave the same way: - **Booleans are always coerced.** The literal strings `"true"` and `"false"` become `true` / `false` regardless of the fallback — even with no fallback at all. So `env("APP_DEBUG")` on `APP_DEBUG=true` returns the boolean `true`, not the string `"true"`. - **Numbers are coerced only when the fallback is a number.** `env("APP_PORT", 3000)` returns the number `3000`, but `env("APP_PORT")` returns the *string* `"3000"` — without a numeric fallback there's nothing to signal that a number was wanted. An empty string is never treated as a number. ```ts env("APP_PORT"); // "3000" (string — no numeric fallback) env("APP_PORT", 0); // 3000 (number — fallback is numeric) env("APP_DEBUG"); // true (boolean, even with no fallback) ``` The generic defaults to `string`, but the return is asserted to `T` at the boundary — the runtime value can be a boolean or number even where the type says string. Pass a fallback of the type you expect and the type follows it. Use `env()` **only inside config files**, not scattered through your app. That keeps all environment coupling in one layer. ## Validating the environment `env("DATABASE_URL")` hands back whatever is — or isn't — in `process.env`. A missing variable is `undefined`, the app boots looking perfectly healthy, and then dies on the first request that actually needs it. In production. At night. `defineEnv()` checks the whole environment **at boot** and refuses to start otherwise: ```ts // config/env.ts import { defineEnv, envVar } from "@shaferllc/keel/core"; export const env = defineEnv({ APP_KEY: envVar.string({ required: true, description: "32+ random characters" }), PORT: envVar.number({ default: 3000 }), NODE_ENV: envVar.enum(["development", "test", "production"], { default: "development" }), DATABASE_URL: envVar.url({ required: true }), SENTRY_DSN: envVar.string(), // optional }); ``` ```ts env.PORT; // number — not "3000" env.NODE_ENV; // "development" | "test" | "production" — not string env.SENTRY_DSN; // string | undefined ``` The types are **inferred from the rules**. A `number` rule gives you a `number`; an `enum` gives you the union, not `string`; anything optional without a default is `| undefined`, so you can't forget to handle it. ### It reports every problem at once ``` The environment is not valid: • APP_KEY is required but not set (32+ random characters). • PORT must be a number, got "eighty". • NODE_ENV must be one of development, test, production, got "staging". • DATABASE_URL must be a valid URL, got "not a url". Set these in your .env (or your host's environment) and start again. ``` Not the first problem — **all** of them. Fixing a deploy one missing variable per restart is its own small hell. ### Rules | Rule | Value | Notes | |------|-------|-------| | `envVar.string()` | `string` | | | `envVar.number()` | `number` | rejects `"eighty"` | | `envVar.boolean()` | `boolean` | accepts `true/false/1/0/yes/no/on/off` | | `envVar.enum([...])` | the union | typed as the literal union | | `envVar.url()` | `string` | must parse as a URL — catches a truncated connection string | Each takes `required`, `default`, `description` (shown in the error, so they know what to set), and `validate` for anything else: ```ts APP_KEY: envVar.string({ required: true, validate: (value) => (value.length >= 32 ? true : "must be at least 32 characters"), }); ``` **An empty string counts as absent.** `PORT=` in a `.env` file is a typo, not a deliberate empty port. The returned object is frozen, so nothing can quietly reassign your config at runtime. ## Config files Each file in `config/` exports a default object and is loaded under its filename. `config/app.ts` becomes the `app` namespace: ```ts // config/app.ts import { env } from "@shaferllc/keel/core"; export default { name: env("APP_NAME", "Keel"), env: env("APP_ENV", "local"), debug: env("APP_DEBUG", true), url: env("APP_URL", "http://localhost:3000"), port: env("APP_PORT", 3000), }; ``` Add more files freely — `config/services.ts`, `config/mail.ts` — and they're auto-loaded at boot. No registration needed. ## Reading config The quickest way is the global `config()` helper — no container needed: ```ts import { config } from "@shaferllc/keel/core"; config("app.name"); // "Keel" config("app.port", 3000); // with a fallback config("services.stripe.key"); // nested access ``` It resolves against the active application (registered automatically when the `Application` is created). There is a matching `app()` helper that returns the container: ```ts import { app } from "@shaferllc/keel/core"; app().make(SomeService); ``` Both `config()` and `app()` throw if no application has been bootstrapped yet — `config()` reaches the repository *through* `app()`, so the error is the same `No Keel application has been bootstrapped…`. In a normal single-app process the `Application` constructor registers itself, so this only bites in tests or scripts that skip the bootstrap. ### The long form Under the hood, `config()` is sugar for resolving the `Config` repository and reading with dot notation. You can still do that explicitly: ```ts import { Config, app } from "@shaferllc/keel/core"; const config = app().make(Config); config.get("app.name"); // "Keel" config.get("app.port", 3000); // with a fallback config.get("services.stripe.key"); // nested access config.set("app.debug", false); // override at runtime config.all(); // the whole tree ``` Note `app()` is a function — call it, then reach into the container (`app().make(...)`), not `app.make(...)`. From within the `Application` there's a shortcut: ```ts app().config().get("app.name"); ``` There is **no `has()` method** on `Config`. To check for a key, read it with a sentinel fallback and compare, or pass the fallback you'd want anyway: ```ts const config = app().make(Config); if (config.get("services.stripe.key") !== undefined) { // configured } ``` ### Missing keys and fallbacks `get()` walks the key segment by segment. If any segment is missing — or a segment isn't an object it can descend into — it returns the fallback (or `undefined` when you gave none) rather than throwing: ```ts config("services.stripe.key", ""); // "" when unset — never throws config("nope.at.all"); // undefined ``` `set()` creates intermediate objects as it goes, so you can write a deep key into an empty tree; and `all()` returns the repository's live object by reference — mutating it mutates the config. Treat `all()` as read-only. ## How loading works At boot, `Application`: 1. Loads `.env` via `dotenv`. 2. Reads every `*.ts` / `*.js` file in `config/`. 3. Registers each under its filename in the `Config` repository. So `config/mail.ts` is reachable at `config('mail.*')` with zero wiring. On Workers (no filesystem) skip discovery and pass a config object inline — `boot(providers, { discoverConfig: false, config })` — and it's merged under its top-level keys the same way. See [`src/core/application.ts`](../src/core/application.ts) (`loadConfig`) and [`src/core/config.ts`](../src/core/config.ts). --- ## API reference ### `env(key, fallback?)` `env(key: string, fallback?: T): T` Reads `process.env[key]`, coercing `"true"`/`"false"` to booleans and numeric strings to numbers, with a typed fallback when the variable is unset. ```ts const debug = env("APP_DEBUG", false); // boolean const port = env("APP_PORT", 3000); // number const name = env("APP_NAME", "Keel"); // string ``` **Notes:** returns the fallback (or `undefined`) when the var is not set. Boolean coercion always happens; number coercion happens **only when `fallback` is a number** and the raw value is non-empty and numeric. Otherwise the raw string is returned. The result is asserted to `T`, so at runtime the value may not match the declared type unless your fallback matches the intended type. ### `config(key, fallback?)` `config(key: string, fallback?: T): T` Global helper: resolves the `Config` repository from the active application and reads `key` with dot notation. ```ts config("app.name"); // unknown -> narrow or cast config("app.port", 3000); config("services.stripe.key", ""); ``` **Notes:** thin sugar for `app().make(Config).get(key, fallback)`. Throws `No Keel application has been bootstrapped…` if there is no active application. Returns the fallback (or `undefined`) for any missing key; never throws on a missing key. ### `app()` `app(): Application` Returns the active application container — the one registered by the most recent `Application` constructor. ```ts app().make(Config); app().config().get("app.name"); ``` **Notes:** throws `No Keel application has been bootstrapped…` when no `Application` has been created. `app` is a function; call it before reaching into the container. In a single-app process the current application is set automatically at construction, so you rarely register it by hand. ### `Config` The dot-notation config repository. You normally resolve it from the container (`app().make(Config)`) rather than constructing it, but the constructor is public for tests and standalone use. #### `new Config(items?)` `new Config(items?: ConfigData)` Creates a repository over the given data (default `{}`). ```ts const repo = new Config({ app: { name: "Keel", port: 3000 } }); repo.get("app.port"); // 3000 ``` **Notes:** the object is held by reference, not cloned — later `set()` calls and `all()` operate on the same object you passed in. #### `get(key, fallback?)` `get(key: string, fallback?: T): T` Reads a value by dot-notation key, descending one segment at a time. ```ts repo.get("app.name"); // value repo.get("app.port", 3000); // fallback if unset repo.get("services.key"); // typed read ``` **Notes:** returns `fallback` (or `undefined`) if any segment is missing or a segment isn't an object it can descend into. Never throws for a missing key. The value is asserted to `T` — no runtime validation. #### `set(key, value)` `set(key: string, value: unknown): void` Writes a value at a dot-notation key, creating intermediate objects as needed. ```ts repo.set("app.debug", false); repo.set("services.stripe.key", "sk_test_…"); // creates `services` on the way ``` **Notes:** mutates the repository in place. If an intermediate segment exists but isn't an object (or is `null`), it's overwritten with a fresh object. #### `all()` `all(): ConfigData` Returns the entire config tree. ```ts const tree = repo.all(); ``` **Notes:** returns the live internal object **by reference**, not a copy — mutating the result mutates the repository. Treat it as read-only. > There is no `has()` method. Check presence with `get(key) !== undefined`, or > pass the fallback you want when the key is absent. ### Interfaces & types #### `ConfigData` `type ConfigData = Record` The shape of the config tree: a plain string-keyed object, nested arbitrarily. Use it to type a config object you build and merge in yourself (for example, the inline config passed to `boot({ discoverConfig: false, config })` on Workers). ```ts const data: ConfigData = { app: { name: "Keel", port: 3000 }, }; ``` --- # Routing Routes live in `routes/web.ts`. The default export receives the `Router` and registers routes on it. The HTTP kernel later compiles them onto Hono. ```ts import type { Router } from "@shaferllc/keel/core"; import { json, text, param } from "@shaferllc/keel/core"; import { HomeController } from "../app/Controllers/HomeController.js"; export default function routes(router: Router): void { router.get("/", [HomeController, "index"]); // controller router.get("/health", json({ status: "ok" })); // static response router.get("/hi/:name", () => text(`Hi ${param("name")}`)); // dynamic } ``` ## HTTP verbs ```ts router.get(path, handler); router.post(path, handler); router.put(path, handler); router.patch(path, handler); router.delete(path, handler); ``` Each returns the router, so calls chain. ## Three kinds of handler **Controller actions** — a `[Controller, method]` tuple, resolved from the [container](./container.md) with dependency injection: ```ts router.get("/users/:id", [UserController, "show"]); ``` **Static responses** — pass a ready-made response directly, no closure: ```ts router.get("/health", json({ status: "ok" })); router.get("/robots.txt", text("User-agent: *\nAllow: /")); ``` **Closures** — a function that runs per request. Use this whenever the response depends on the request (route params, query, body), because those must be read at request time: ```ts router.get("/users/:id", () => json({ id: param("id") })); ``` > Rule of thumb: response is the same every time → pass it directly. Response > depends on the request → wrap it in `() =>`. ## Reading the request The `request` accessor (or the standalone shortcuts) read the current request — no `c` needed: ```ts request.param("id"); // route parameter request.query("q"); // query string request.header("authorization"); await request.json(); // parse a JSON body // standalone equivalents param("id"); query("q"); header("authorization"); await body(); ``` `request` also exposes `request.method`, `request.path`, `request.url`, `request.status`, and `request.raw` (the underlying web `Request`). ## Writing the response Build responses with the standalone helpers or the `response` accessor — they're the same thing: ```ts json({ ok: true }); // JSON response text("hello"); // plain text html("

Hi

"); // HTML redirect("/login"); // redirect response.json({ ok: true }); response.status(201).json(created); // set status, chainable response.header("x-total", "42").json(rows); ``` Returning a **string** from a handler is shorthand — Keel wraps it as HTML. ## The full helper set | Read (`request.*` or standalone) | Write (`response.*` or standalone) | |----------------------------------|------------------------------------| | `param(name)` · `query(name)` · `header(name)` | `json(data, status?)` · `text()` · `html()` | | `body()` (parse JSON body) | `redirect(location, status?)` | | `request.method` · `.path` · `.status` · `.raw` | `response.status(code)` · `response.header(k, v)` | All of these are powered by async-context storage the HTTP kernel enables for every request, so they only work inside a request. You can always still take `c` explicitly — both styles work. ## Named routes & URL generation Name a route, then build its URL by name — no hardcoded paths: ```ts router.get("/users/:id", [UserController, "show"]).name("users.show"); router.url("users.show", { id: 42 }); // "/users/42" ``` Building URLs from names — plain and tamper-proof signed URLs — is its own topic. See the [URL builder](./url-builder.md) for `router.url()`, `router.signedUrl()`, and `router.hasValidSignature()`. ## Route groups Share a prefix, middleware, and/or name prefix across many routes: ```ts router .group(() => { router.get("/status", json({ up: true })).name("status"); router.get("/me", [MeController, "show"]).name("me"); }) .prefix("/api") // -> /api/status, /api/me .middleware([auth]) // runs before each route in the group .as("api"); // -> names "api.status", "api.me" ``` Groups nest — inner prefixes and middleware compose with the outer group's. ## Resource routes Generate RESTful routes for a controller in one line: ```ts router.resource("posts", PostController); ``` | Verb | Path | Action | |------|------|--------| | GET | `/posts` | `index` | | GET | `/posts/create` | `create` | | POST | `/posts` | `store` | | GET | `/posts/:id` | `show` | | GET | `/posts/:id/edit` | `edit` | | PUT/PATCH | `/posts/:id` | `update` | | DELETE | `/posts/:id` | `destroy` | Trim the set with `.only([...])`, `.except([...])`, or `.apiOnly()` (drops the HTML-form `create`/`edit` actions). ## Param constraints Constrain a parameter with a regex, a matcher, or a `{ match }` object — non-matching requests fall through to a 404: ```ts router.get("/users/:id", [UserController, "show"]).where("id", /\d+/); // built-in matchers router.get("/u/:id", handler).where("id", router.matchers.number()); router.get("/a/:id", handler).where("id", router.matchers.uuid()); router.get("/s/:slug", handler).where("slug", router.matchers.slug()); // a global constraint applied to every matching :id router.where("id", router.matchers.number()); ``` Groups take constraints too: `group(...).where("id", router.matchers.uuid())`. ## Per-route middleware ```ts router.get("/dashboard", [DashboardController, "index"]).middleware([auth]); ``` ## Brisk routes: redirects, views & Inertia `on()` is a shortcut for routes with no controller: ```ts router.on("/old").redirect("/new"); // path/URL redirect router.on("/ext").redirectToPath("https://x.com"); // alias of redirect router.on("/posts").redirectToRoute("articles.index", {}, { qs: { page: 1 } }); router.on("/about").render(AboutPage, { title: "About" }); // render a view router.on("/dashboard").renderInertia("Dashboard", { user }); // Inertia page ``` See [Inertia](./inertia.md) for the full Inertia adapter. ## Domain & subdomain routing Bind routes (or a group) to a host pattern. `:segments` capture subdomain params, readable with `request.subdomain()`: ```ts router .group(() => { router.get("/", () => json({ tenant: request.subdomain("tenant") })); }) .domain(":tenant.example.com"); router.get("/", [BlogController, "index"]).domain("blog.example.com"); ``` Requests are dispatched by their `Host` header; non-matching hosts fall through to your default (undomained) routes. ## Route config Attach arbitrary metadata to a route (or a whole group) with `.config()`, then read it in the handler or route middleware via `request.route.config` — for per-route flags like an auth scope, a rate tier, or a layout choice: ```ts router.get("/admin", [Admin, "index"]).config({ scope: "admin", rateTier: "high" }); router .group(() => { router.get("/billing", [Billing, "index"]); // inherits { area: "billing" } router.get("/billing/export", [Billing, "export"]).config({ heavy: true }); }) .config({ area: "billing" }); // a route's own config wins on conflict ``` ```ts // in a guard middleware attached to the route/group: if (request.route?.config.scope === "admin") await authorize("access-admin"); ``` Group config is merged into every route in the group, with a route's own keys winning. Route config is available to **route/group middleware** and the handler (not global middleware, which runs before route matching). ## The current route `request.route` exposes the matched route, and `request.routeIs()` checks it: ```ts request.route; // { name, pattern, methods, config } request.routeIs("posts.show"); // boolean ``` ## More verbs ```ts router.any("/webhook", [HookController, "handle"]); // every verb router.route(["GET", "POST"], "/search", handler); // a specific set ``` ## Route model binding A `:post` in the path can arrive as a **`Post`**, not a string: ```ts import { bindModel, boundModel } from "@shaferllc/keel/core"; bindModel("post", Post); // once, in a provider router.get("/posts/:post", (c) => { const post = boundModel(Post); // already fetched. Not a string, not null. return c.json(post); }); ``` The row is looked up **before your handler runs**, and a miss is a 404 there and then. That's the whole value: the handler never sees a `null`, so it never has to remember to check for one — **"forgot the 404" stops being a bug you can write.** Compare what you'd otherwise type in every handler: ```ts router.get("/posts/:id", async (c) => { const post = await Post.find(c.req.param("id")); if (!post) throw new NotFoundException(); // ...every time, forever return c.json(post); }); ``` ### By another column When the URL isn't the id: ```ts bindModel("post", Post, { key: "slug" }); // /posts/hello-world ``` ### `scope` — this is security, not a filter ```ts bindModel("post", Post, { scope: (query, c) => query.where("authorId", currentUserId(c)), }); ``` A row outside the scope is a **404**, not a 403 and not a filtered list — so it cannot be reached by *guessing its id*. That's the difference between row-level security and decoration. `/posts/2` doesn't 403 (which would confirm the row exists); it simply isn't there. The scope gets the request, so it can depend on who's asking. ### Middleware sees the model Binding runs **before** route middleware, so a policy can read the model rather than re-fetching it: ```ts const mustOwn: MiddlewareHandler = async (c, next) => { if (boundModel(Post).authorId !== currentUserId(c)) throw new ForbiddenException(); await next(); }; router.get("/posts/:post/edit", edit).middleware(mustOwn); ``` ### Anything that isn't a model ```ts bindRoute("tenant", (slug) => tenants.get(slug)); // undefined ⇒ 404 router.get("/t/:tenant", () => { const tenant = boundValue("tenant"); }); ``` ### Notes - An **unbound** param is untouched — still just a string via `c.req.param()`. - Two params bound to the same model? Say which: `boundModel(Post, "original")`. Guessing would be worse than asking. - `missing()` substitutes a value instead of 404ing, if you'd rather. - Only routes with parameters pay for any of this. ## Inspecting routes ```bash npm run keel routes ``` ``` GET / HomeController@index GET /health Static GET /users/:id Closure ``` ## Adding more route files `bootstrap/app.ts` loads `routes/web.ts`. To split routes (e.g. an `api.ts`), import and call it there: ```ts import registerWebRoutes from "../routes/web.js"; import registerApiRoutes from "../routes/api.js"; registerWebRoutes(app.make(Router)); registerApiRoutes(app.make(Router)); ``` --- ## API reference Registration methods live on `Router`. Each verb method hands back a `Route` you chain to name, guard, or constrain it; `group()`, `resource()`, and `on()` hand back `RouteGroup`, `RouteResource`, and a brisk-route matcher respectively. You never construct these classes — the framework builds the `Router` and passes it to your routes file, and the rest come back from its methods. URL generation (`url`, `signedUrl`, `hasValidSignature`) also lives on `Router` but is documented separately in the [URL builder](./url-builder.md). ### `matchers` `matchers: { number(): RegExp; uuid(): RegExp; slug(): RegExp; alpha(): RegExp }` The built-in parameter matchers, also reachable as `router.matchers`. Each returns a fresh (un-anchored) `RegExp` to hand to `.where()`. ```ts import { matchers } from "@shaferllc/keel/core"; router.get("/u/:id", handler).where("id", matchers.number()); ``` **Notes:** `number` → `\d+`, `uuid` → a canonical UUID, `slug` → `a-z0-9` words joined by `-`, `alpha` → letters only. They are plain regexes, so you can also pass your own `/.../ ` or a `{ match }` object. ### Router The route registrar. Injected into your routes file; resolve it elsewhere with `app.make(Router)`. #### `get(path, handler)` · `post` · `put` · `patch` · `delete` `get(path: string, handler: RouteHandler): Route` Registers a route for the one HTTP verb and returns the `Route` for chaining. ```ts router.get("/users/:id", [UserController, "show"]); router.post("/users", [UserController, "store"]); router.delete("/users/:id", [UserController, "destroy"]); ``` **Notes:** `post`, `put`, `patch`, and `delete` share the identical signature. `handler` is a closure, a `[Controller, "method"]` tuple, or a ready-made `Response` — see [`RouteHandler`](#routehandler). Paths are normalized (a trailing slash is trimmed; `"/"` stays `"/"`). #### `any(path, handler)` `any(path: string, handler: RouteHandler): Route` Registers the route for every HTTP verb (`GET POST PUT PATCH DELETE OPTIONS HEAD`). ```ts router.any("/webhook", [HookController, "handle"]); ``` #### `route(methods, path, handler)` `route(methods: Method[], path: string, handler: RouteHandler): Route` Registers the route for a specific set of verbs. ```ts router.route(["GET", "POST"], "/search", handler); ``` **Notes:** `Method` is the uppercase verb union — pass them exactly (`"GET"`, not `"get"`). #### `on(path)` `on(path: string): RouteMatcher` Opens a brisk-route matcher for controller-less routes (redirects, views, Inertia pages). See [`RouteMatcher`](#routematcher). ```ts router.on("/old").redirect("/new"); router.on("/about").render(AboutPage, { title: "About" }); ``` #### `group(callback)` `group(callback: () => void): RouteGroup` Runs `callback` (which registers routes on the router) and returns a [`RouteGroup`](#routegroup) wrapping exactly the routes it added, so you can apply a shared prefix / middleware / name prefix to them. ```ts router .group(() => { router.get("/status", json({ up: true })).name("status"); router.get("/me", [MeController, "show"]).name("me"); }) .prefix("/api") .middleware([auth]) .as("api"); ``` **Notes:** the grouping (prefix, middleware, name prefix) is applied *after* registration by the returned `RouteGroup` — the callback itself sees no prefix. Nest by calling `.prefix()` on the inner group before the outer group's; the outer prefix is prepended, so `/api` + `/v1/...` composes correctly. #### `resource(name, controller)` `resource(name: string, controller: ControllerRef): RouteResource` Registers the seven RESTful routes (`index create store show edit update destroy`) for `controller` and returns a [`RouteResource`](#routeresource) to trim or rename them. ```ts router.resource("posts", PostController); router.resource("posts.comments", CommentController); // nested ``` **Notes:** each route is auto-named `${name}.${action}`. A dotted `name` nests resources — `"posts.comments"` yields `/posts/:post_id/comments/:id`. The controller may be a class or a lazy `() => import(...)` loader. #### `where(param, matcher)` `where(param: string, matcher: Matcher): this` Registers a **global** parameter constraint, applied at `all()` time to every route whose path contains `:param` and that doesn't already constrain it. ```ts router.where("id", matchers.number()); ``` **Notes:** per-route and group `.where()` win over a global one. Returns the router for chaining. #### `named(map)` `named(map: Record): this` Registers named middleware you can later reference by string in `.middleware()` / `.use()`. ```ts router.named({ auth, admin }); router.get("/panel", handler).use("auth"); ``` **Notes:** merges into any previously named middleware. Referencing an unregistered name throws at resolve time (see `resolveMiddleware`). #### `resolveMiddleware(ref)` `resolveMiddleware(ref: MiddlewareRef): MiddlewareHandler` Resolves a middleware reference — a handler passes through; a string is looked up in the `named()` registry. ```ts const mw = router.resolveMiddleware("auth"); ``` **Notes:** throws `No named middleware [name]…` if a string isn't registered. Mostly used by the HTTP kernel; handy in tests. #### `all()` `all(): RouteDefinition[]` Returns every live route definition, after folding in global `where()` constraints and dropping routes trimmed to zero methods (by `only`/`except`). ```ts for (const r of router.all()) console.log(r.methods, r.path, r.name); ``` **Notes:** this is the list the HTTP kernel compiles onto Hono. Trimmed resource actions are excluded here, but `url()` can still find them by name. #### `resolve(handler)` `resolve(handler: RouteHandler): HandlerFn` Turns a `RouteHandler` into a callable `(c: Ctx) => …`, resolving controller tuples through the container and lazy loaders. ```ts const fn = router.resolve([UserController, "show"]); ``` **Notes:** a bare `[Controller]` tuple calls the controller's `handle` method. A `Response` handler is cloned per call. Throws if the named controller method doesn't exist. Called by the kernel; you rarely call it directly. ### Route Returned by every verb method (`get`/`post`/…). Chain to name, guard, or constrain a single route. Exposes a readonly `def: RouteDefinition`. #### `name(name)` · `as(name)` `name(name: string): this` Names the route for URL generation. `as()` is an alias. ```ts router.get("/users/:id", handler).name("users.show"); router.get("/users/:id", handler).as("users.show"); ``` #### `middleware(mw)` · `use(mw)` `middleware(mw: MiddlewareRef | MiddlewareRef[]): this` Attaches middleware that runs only for this route, after any group middleware. `use()` is an alias. ```ts router.get("/dashboard", handler).middleware([auth]); router.get("/admin", handler).use(["auth", "admin"]); ``` **Notes:** accepts a single ref or an array; appends (order preserved). A string ref is resolved against `named()`. #### `where(param, matcher)` `where(param: string, matcher: Matcher): this` Constrains a route parameter; non-matching requests fall through to a 404. ```ts router.get("/users/:id", handler).where("id", /\d+/); ``` #### `domain(pattern)` `domain(pattern: string): this` Binds the route to a host pattern; `:segments` capture subdomain params. ```ts router.get("/", [BlogController, "index"]).domain("blog.example.com"); ``` ### RouteGroup Returned by `group()`. Its fluent methods apply across every route the group callback registered. All return `this`. #### `prefix(prefix)` `prefix(prefix: string): this` Prepends a path prefix to every route in the group. ```ts router.group(() => { /* … */ }).prefix("/api"); ``` **Notes:** leading/trailing slashes are normalized. Applying to the group's root route (`"/"`) yields just the prefix. #### `middleware(mw)` · `use(mw)` `middleware(mw: MiddlewareRef | MiddlewareRef[]): this` Prepends middleware to every route in the group, so group middleware runs before each route's own. `use()` is an alias. ```ts router.group(() => { /* … */ }).middleware([auth]); ``` #### `where(param, matcher)` `where(param: string, matcher: Matcher): this` Constrains a parameter across the group, skipping routes that already constrain it themselves. ```ts router.group(() => { /* … */ }).where("id", matchers.uuid()); ``` #### `as(namePrefix)` `as(namePrefix: string): this` Prefixes the name of every *already-named* route in the group. ```ts router.group(() => { /* named routes */ }).as("api"); // status -> api.status ``` **Notes:** routes without a `name()` are left untouched — name them inside the callback for `as()` to reach them. #### `domain(pattern)` `domain(pattern: string): this` Binds every route in the group to a host pattern. ```ts router.group(() => { /* … */ }).domain(":tenant.example.com"); ``` ### RouteResource Returned by `resource()`. Chain to trim, rename, or guard the generated actions. All return `this`. #### `only(actions)` · `except(actions)` `only(actions: string[]): this` `except(actions: string[]): this` Keep only the listed actions, or drop the listed actions. ```ts router.resource("posts", PostController).only(["index", "show"]); router.resource("posts", PostController).except(["destroy"]); ``` **Notes:** trimming empties a route's `methods`; `all()` then filters it out. The route name still exists, so `url()` can resolve it even when it won't be served. #### `apiOnly()` `apiOnly(): this` Drops the HTML-form actions (`create`, `edit`) — the shorthand for `.except(["create", "edit"])`. ```ts router.resource("posts", PostController).apiOnly(); ``` #### `as(name)` `as(name: string): this` Renames the route-name prefix for every action. ```ts router.resource("posts", PostController).as("articles"); // articles.index, … ``` #### `params(map)` `params(map: Record): this` Renames route parameters. Maps a resource segment to a new param name. ```ts router.resource("posts", PostController).params({ posts: "post" }); // :id -> :post ``` **Notes:** for the resource's own segment the underlying param is `:id`; for a parent segment in a nested resource it's `:{singular}_id`. Only the first matching occurrence in each path is renamed. #### `use(actions, mw)` `use(actions: string[] | "*", mw: MiddlewareRef | MiddlewareRef[]): this` Attaches middleware to specific actions, or to all with `"*"`. ```ts router.resource("posts", PostController) .use(["store", "update", "destroy"], "auth") .use("*", logRequests); ``` ### RouteMatcher Returned by `router.on(path)` — a builder for controller-less `GET` routes. Each method registers the route and returns the underlying `Route`. #### `redirect(to, status?)` · `redirectToPath(to, status?)` `redirect(to: string, status?: number): Route` Registers a route that redirects to a path or URL (default status `302`). `redirectToPath` is an alias. ```ts router.on("/old").redirect("/new"); router.on("/ext").redirectToPath("https://example.com", 301); ``` #### `redirectToRoute(name, params?, options?)` `redirectToRoute(name: string, params?: Record, options?: { qs?: Record; status?: number }): Route` Registers a route that redirects to a named route, resolving its URL (and optional query string). ```ts router.on("/posts").redirectToRoute("articles.index", {}, { qs: { page: 1 } }); ``` #### `render(component, props?)` `render(component: (props?: any) => unknown, props?: any): Route` Registers a route that renders a view component directly. ```ts router.on("/about").render(AboutPage, { title: "About" }); ``` #### `renderInertia(component, props?)` `renderInertia(component: string, props?: Record): Route` Registers a route that renders an Inertia page component by name. See [Inertia](./inertia.md). ```ts router.on("/dashboard").renderInertia("Dashboard", { user }); ``` ### Interfaces & types #### `Ctx` `type Ctx = Context` (Hono's request context) The context handed to every handler and middleware. Every closure handler receives it, though the `request`/`response` accessors mean you rarely read from it directly. ```ts router.get("/", (c: Ctx) => c.text("hi")); ``` #### `RouteHandler` `type RouteHandler = HandlerFn | ControllerAction | Response` What you pass as the second argument to a verb method. One of three shapes: - **`HandlerFn`** — `(c: Ctx) => Response | string | Promise`; returning a bare string is wrapped as HTML. - **`ControllerAction`** — `[Controller]` (calls `handle`) or `[Controller, "method"]`; the controller may be a lazy `() => import(...)`. - **`Response`** — a ready-made response, cloned per request. ```ts router.get("/a", (c) => c.json({ ok: true })); // HandlerFn router.get("/b", [UserController, "show"]); // ControllerAction router.get("/c", json({ up: true })); // Response ``` #### `Matcher` `type Matcher = RegExp | string | { match: RegExp }` A route-parameter constraint accepted by `.where()`. A regex, a regex-source string, or a `{ match }` wrapper (the shape the built-in `matchers` conform to). ```ts const a: Matcher = /\d+/; const b: Matcher = "\\d+"; const c: Matcher = { match: /[a-z]+/ }; ``` #### `Method` `type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS" | "HEAD"` The HTTP verbs. Passed to `route()`; also the type of `RouteDefinition.methods`. ```ts router.route(["GET", "POST"] as Method[], "/search", handler); ``` #### `MiddlewareRef` `type MiddlewareRef = MiddlewareHandler | string` A middleware handler, or the name of one registered with `router.named()`. Accepted by every `.middleware()` / `.use()`. ```ts router.get("/a", handler).use("auth"); // named router.get("/b", handler).use(rateLimiterMw); // handler ``` #### `RouteDefinition` ```ts interface RouteDefinition { methods: Method[]; path: string; handler: RouteHandler; name?: string; middleware: MiddlewareRef[]; wheres: Record; domain?: string; } ``` The compiled record for one route — what `all()` returns and the kernel reads. You inspect these (e.g. to print a route table); you don't build them by hand. `wheres` holds each param's regex *source* string, keyed by param name. ```ts for (const def of router.all()) { console.log(def.methods.join("|"), def.path, def.name ?? ""); } ``` > `UrlOptions` and `SignedUrlOptions` are exported from this module too, but they > belong to URL generation — see the [URL builder](./url-builder.md). --- # Controllers Controllers are plain classes in `app/Controllers/`. Each public method is an action bound to a route. They're resolved from the [container](./container.md), so they get dependency injection and a fresh instance per request. ```ts import type { Ctx } from "@shaferllc/keel/core"; import { json, param } from "@shaferllc/keel/core"; export class PostController { index() { return json({ posts: [] }); } show() { return json({ id: param("id") }); } } ``` Bind actions in your routes with a `[Controller, method]` tuple: ```ts router.get("/posts", [PostController, "index"]); router.get("/posts/:id", [PostController, "show"]); ``` A controller action is just one of the [three handler forms](./routing.md#three-kinds-of-handler) the router accepts — the array form. The other two (a closure, or a ready-made `Response`) live inline on the route; controllers are for anything with enough weight to earn its own class. ## The request context Every action is called with the request [context](./request-response.md) as its first argument — the `Ctx` type, which is Hono's `Context`. You can read from it directly, or ignore it and reach for the ambient request helpers (`param`, `query`, `body`) that resolve the current request from async-context storage. Both styles work; pick whichever reads better. ```ts import type { Ctx } from "@shaferllc/keel/core"; import { param } from "@shaferllc/keel/core"; export class PostController { // Take the context explicitly… show(c: Ctx) { return c.json({ id: c.req.param("id") }); } // …or lean on the ambient helpers and drop the argument. edit() { return json({ id: param("id") }); } } ``` The context is what Keel hands the action under the hood — the router resolves your controller from the container, then calls `action.call(controller, c)`, so `this` is the controller instance and the sole argument is the `Ctx`. ## Dependency injection A controller's constructor receives the container, so it can resolve anything: ```ts import type { Container, Ctx } from "@shaferllc/keel/core"; import { Mailer } from "../Services/Mailer.js"; export class UserController { constructor(private app: Container) {} store() { const mailer = this.app.make(Mailer); // … } } ``` The container instantiates the controller with `new Controller(container)` — an unbound class is auto-built, no registration needed. That happens **per request**: each hit resolves a fresh instance, so it's safe to stash request-scoped state on `this` without leaking it across requests. If you'd rather inject specific services than the whole container, give the controller a constructor that takes them and bind it in a [service provider](./providers.md), pulling each dependency out of the container: ```ts // InvoiceController's constructor takes a Mailer, not the container. app.bind(InvoiceController, (c) => new InvoiceController(c.make(Mailer))); ``` ## Single-action controllers For a controller that does one thing, define a `handle` method and reference the class with no method name: ```ts export class PublishPost { handle() { return json({ published: true }); } } router.post("/posts/:id/publish", [PublishPost]); // calls handle() ``` `[Controller]` and `[Controller, "handle"]` are equivalent — the method name defaults to `"handle"` when the tuple has just one element. Referencing a method the controller doesn't define throws at request time: `Controller [PublishPost] has no method [handle].` ## Lazy-loaded controllers Pass a `() => import(...)` loader instead of the class, and the controller is only imported when its route is first hit — handy for large apps and cold starts: ```ts router.get("/reports", [() => import("../Controllers/ReportController.js"), "index"]); ``` The loader may resolve to a default export or the class itself — Keel unwraps `.default` if present, otherwise uses the module value directly. Both work: ```ts // default export export default class ReportController { index() { /* … */ } } // named export — point the loader at the property router.get("/reports", [ () => import("../Controllers/ReportController.js").then((m) => m.ReportController), "index", ]); ``` The loader must be an **arrow function** (or any function with no `prototype`). Keel distinguishes an eager controller from a lazy loader by checking for a `prototype` — classes have one, arrow functions don't — so a lazy controller written as a `function` declaration would be mistaken for a class. Stick to `() => import(...)`. ## Resource controllers Generate a RESTful controller with all seven actions: ```bash npm run keel make:controller Post --resource ``` That writes `app/Controllers/PostController.ts` with the conventional set — `index`, `create`, `store`, `show`, `edit`, `update`, `destroy`: ```ts import type { Ctx } from "@shaferllc/keel/core"; export class PostController { index(c: Ctx) { return c.json({ action: "index" }); } create(c: Ctx) { return c.json({ action: "create" }); } store(c: Ctx) { return c.json({ action: "store" }); } show(c: Ctx) { return c.json({ action: "show" }); } edit(c: Ctx) { return c.json({ action: "edit" }); } update(c: Ctx) { return c.json({ action: "update" }); } destroy(c: Ctx) { return c.json({ action: "destroy" }); } } ``` Drop `--resource` (or `-r`) for a bare controller with a single `index` action. Then wire the whole set up in one line (see [Routing → Resource routes](./routing.md#resource-routes)): ```ts router.resource("posts", PostController); router.resource("posts.comments", CommentController); // nested router.resource("posts", PostController) .apiOnly() .as("articles") .params({ posts: "post" }) .use(["store", "update", "destroy"], auth); ``` `router.resource` maps each of the seven route entries onto the matching controller method by name — so a resource controller just needs methods with those names. Trim the set with `.only()` / `.except()` / `.apiOnly()` when you don't implement all seven. ## Related - [Routing](./routing.md) — the `Router` methods (`get`, `post`, `resource`, …) that bind these controllers, plus closures and static-response handlers. - [Container](./container.md) — how controllers (and their dependencies) are resolved and constructed. - [Request & response](./request-response.md) — the `Ctx` object and the ambient `param` / `query` / `json` helpers actions use. --- ## API reference Controllers are a usage pattern, not an exported API — you write the classes, and the [`Router`](./routing.md) binds them. The two exported types you touch when typing an action or a route are `Ctx` and `RouteHandler`, both from `@shaferllc/keel/core`. ### Types #### `Ctx` `type Ctx = Context` (Hono's request `Context`) The request context passed as the first argument to every route handler and controller action. Read params/headers/body off it and build responses with it — or ignore it and use the ambient request helpers. ```ts import type { Ctx } from "@shaferllc/keel/core"; export class UserController { show(c: Ctx) { const id = c.req.param("id"); // route param const q = c.req.query("expand"); // query string return c.json({ id, expand: q }); // JSON response } } ``` **Notes:** it's an alias for Hono's `Context`, so anything in Hono's context API (`c.req`, `c.json`, `c.html`, `c.header`, `c.get`/`c.set`, `c.env`) is available. An action may also take no argument and use `param()`/`query()`/`json()` instead, which resolve the current request from async-context storage — see [request & response](./request-response.md). #### `RouteHandler` `type RouteHandler = HandlerFn | ControllerAction | Response` The union every `Router` verb accepts as its handler. A controller action is the `ControllerAction` arm: `[Controller]`, `[Controller, "method"]`, or a lazy `[() => import(...), "method"]`. ```ts import type { RouteHandler } from "@shaferllc/keel/core"; import { json } from "@shaferllc/keel/core"; // each arm of the union is a valid handler const closure: RouteHandler = () => json({ ok: true }); const staticResp: RouteHandler = json({ status: "ok" }); const action: RouteHandler = [UserController, "show"]; const single: RouteHandler = [PublishPost]; // calls handle() const lazy: RouteHandler = [ () => import("../Controllers/ReportController.js"), "index", ]; ``` **Notes:** the constituent types (`HandlerFn`, `ControllerAction`, `ControllerRef`, `LazyController`) are internal to the router and not exported — annotate values as `RouteHandler` when you need an explicit type. The router turns any of these into an executable function at boot; a controller arm is resolved from the container per request. For the verbs that consume a `RouteHandler` (`get`, `post`, `put`, `patch`, `delete`, `any`, `route`, `resource`, …), see [Routing → API reference](./routing.md#api-reference). --- # Request & Response Beyond the terse `param()` / `json()` shortcuts, the `request` and `response` accessors give you the full input/output surface — no context threading. They resolve the active Hono context from async-context storage, which the HTTP kernel enables for every request, so they only work **inside a request**. Call one outside a handler and it throws (`json`/`text`/`html`/`redirect` are the exception — see below). ## Reading input ```ts import { request } from "@shaferllc/keel/core"; request.param("id"); // route parameter request.query("q"); // query-string value request.header("authorization"); // merged query + parsed body (async) await request.all(); // { …query, …body } await request.input("email"); // one value from query or body await request.input("page", 1); // with a fallback await request.only(["email", "name"]); // a subset await request.except(["password"]); // everything but these ``` `request.all()` reads the query string and, if the request carries a body, merges the parsed body over it — JSON bodies via `req.json()`, form bodies (`multipart/form-data` or `application/x-www-form-urlencoded`) via cached `FormData`. File fields are dropped from `all()` (reach for `file()`/`files()`). A missing or malformed body is swallowed — you just get the query values. `input`/`only`/`except` all build on `all()`, so they're async too. `request` also exposes `request.method`, `request.path`, `request.url`, `request.status`, `request.ip()`, `request.ips()`, `request.hasBody()`, `request.headers()`, and `request.raw` (the underlying web `Request`). For the raw parsed JSON body without the query merge, use `request.json()`. For URL and connection introspection there's `request.protocol`, `request.secure`, `request.host`, `request.hostname`, `request.origin`, `request.fullUrl`, and `request.querystring`. These are **proxy-aware**: `X-Forwarded-Proto` / `X-Forwarded-Host` win over the raw request URL, so an app behind a TLS-terminating proxy or load balancer sees the client's real scheme and host (handy for building absolute links or forcing HTTPS). > `input`/`only`/`except`/`all` are object methods that lean on `this`. Call > them off `request` (`request.input(…)`), not destructured (`const { input } = > request`), or `this` is lost. ### Other content types `all()` understands JSON and form bodies. For anything else — XML, CSV, a binary payload, a custom format — read the raw body and parse it yourself. There's no content-type parser registry to configure: parsing is explicit, so you call the accessor you want. ```ts await request.text(); // the body as a string (XML, CSV, …) await request.arrayBuffer(); // the body as bytes (protobuf, msgpack, …) await request.blob(); // the body as a Blob ``` ```ts r.post("/webhook", async () => { const xml = await request.text(); return response.json(parseXml(xml)); }); ``` These read from the same underlying `Request` as `request.raw`, so a middleware can equally parse a custom type once and stash it with `ctx().set("body", …)`. ## Route info The kernel stashes the matched route on the context, so you can branch on it: ```ts request.route; // { name, pattern, methods } | undefined request.routeIs("users.show"); // true if the matched route is named that request.subdomain("tenant"); // a param captured from a domain-bound route ``` ## File uploads Uploaded files come back as web-standard `File` objects (works on Node and the edge — no temp directory, no streaming to disk): ```ts const avatar = await request.file("avatar"); // File | undefined const docs = await request.files("docs"); // File[] const all = await request.allFiles(); // { field: File | File[] } if (avatar) { avatar.name; // "photo.png" avatar.size; // bytes avatar.type; // "image/png" (client-supplied) const bytes = await avatar.arrayBuffer(); // persist via R2/S3/fs yourself } ``` The `FormData` is parsed once per request and cached, so calling `file()`, `files()`, `allFiles()`, and `all()` in one handler doesn't re-read the body. `allFiles()` groups repeated field names into an array and single fields into a lone `File`. Validate a file with your schema (Keel stays schema-agnostic): ```ts const Upload = z.object({ avatar: z.instanceof(File).refine((f) => f.size < 2_000_000, "Too large"), }); ``` ## Content negotiation ```ts request.accepts(["application/json", "text/html"]); // best match, or null request.types(); // accepted types, ordered request.language(["en", "fr"]); // best language, or null request.languages(); request.encoding(["br", "gzip"]); // best content encoding request.charset(["utf-8"]); // best charset ``` `accepts`/`language`/`encoding`/`charset` parse the relevant `Accept*` header by q-weight and return the highest-preference offered value, honoring `*/*` (or `*`) as "anything" — which resolves to the first thing you offer. No match returns `null`. Each has a plural list form (`types()`, `languages()`, `encodings()`, `charsets()`) returning everything the client accepts, ordered by preference. ## Cookies ```ts request.cookie("session"); // one cookie, or undefined request.cookie(); // all cookies as an object response.cookie("session", token, { httpOnly: true, maxAge: 3600 }); response.clearCookie("session"); ``` ## Writing output ```ts import { response } from "@shaferllc/keel/core"; response.json({ ok: true }); response.text("hello"); response.html("

Hi

"); response.redirect("/login"); response.back("/"); // back to the Referer, else the fallback response.send(anything); // objects → JSON, else text response.status(201).json(created); // chainable response.header("x-total", "42").json(rows); response.type("text/csv").append("vary", "accept"); response.attachment("report.csv").text(csv); // Content-Disposition download response.removeHeader("x-powered-by"); response.cookie("flash", "saved").redirect("/"); ``` Every mutator (`status`, `header`, `type`, `attachment`, `append`, `removeHeader`, `cookie`, `clearCookie`) returns `response`, so they chain; the terminal `json`/`text`/`html`/`redirect`/`back`/`send` produce the `Response`. `send` inspects its argument — a non-null object becomes JSON, anything else is stringified to text. `back` bounces to the `Referer` header (falling back to the argument, default `"/"`); `redirect("back")` is the same shortcut. ## Aborting with guards ```ts response.abort("Not found", 404); // always response.abortIf(!user, "Not found", 404); // if truthy response.abortUnless(user.isAdmin, "Forbidden", 403); ``` `abort()` throws an `HttpException` (default status `400`), which the kernel renders (see [Errors](./errors.md)). `abortIf`/`abortUnless` throw the same, conditionally — handy as one-line guards at the top of a handler. ## Standalone shortcuts Every reader/writer also exists as a flat helper for terse handlers: `json()`, `text()`, `html()`, `redirect()`, `param()`, `query()`, `header()`, `body()`. Use whichever reads best — they resolve the same request. The response builders (`json`/`text`/`html`/`redirect`) are special: they work **both** inside a handler and standalone. Inside a request they build on the context (so status and queued headers/cookies apply); outside one they fall back to a plain web `Response`. That's what lets you hand one straight to the router as a static route value — `router.get("/ping", json({ ok: true }))` — and have it cloned per request. --- ## API reference ### `ctx()` `ctx(): Context` Returns the current Hono `Context` from async-context storage — the escape hatch when you need something the accessors don't wrap. ```ts import { ctx } from "@shaferllc/keel/core"; ctx().req.raw; // the web Request ctx().executionCtx; // waitUntil, passThroughOnException, … ``` **Notes:** throws if called outside a request (nothing has set up the context). Everything else in this module is built on it. ### `json(data, status?)` `json(data: unknown, status?: number): Response` Serializes `data` to a JSON `Response`. ```ts import { json } from "@shaferllc/keel/core"; json({ ok: true }); json({ error: "nope" }, 422); ``` **Notes:** works inside a handler (builds on the context, applying any queued status/headers) and standalone (a plain `Response.json`). Safe as a static route value. ### `text(body, status?)` `text(body: string, status?: number): Response` Returns a `text/plain; charset=UTF-8` response. ```ts import { text } from "@shaferllc/keel/core"; text("pong"); text("rate limited", 429); ``` **Notes:** dual-mode like `json`. Standalone, it sets the content-type header itself. ### `html(body, status?)` `html(body: string, status?: number): Response` Returns a `text/html; charset=UTF-8` response. ```ts import { html } from "@shaferllc/keel/core"; html("

Hi

"); ``` **Notes:** does not escape `body` — you're responsible for the markup. ### `redirect(location, status?)` `redirect(location: string, status?: number): Response` Returns a redirect to `location`. ```ts import { redirect } from "@shaferllc/keel/core"; redirect("/login"); redirect("/", 301); ``` **Notes:** default status is `302` in standalone mode. Sets the `Location` header. ### `request` The flat request accessor. You import it as-is (it's a singleton object, not a class) and read off it — every access resolves the current context, so it's always about the in-flight request. #### `request.method` `get method(): string` The HTTP method (`"GET"`, `"POST"`, …). ```ts if (request.method === "POST") { /* … */ } ``` #### `request.path` `get path(): string` The request path, without query string. ```ts request.path; // "/users/1" ``` #### `request.url` `get url(): string` The full request URL, including query string. ```ts request.url; // "https://api.example.com/users/1?tab=posts" ``` #### `request.protocol` · `request.secure` · `request.host` · `request.hostname` · `request.origin` · `request.fullUrl` · `request.querystring` Proxy-aware URL and connection accessors — `X-Forwarded-Proto` and `X-Forwarded-Host` take precedence over the raw request URL, so an app behind a TLS-terminating proxy sees the client's real scheme and host. ```ts request.protocol; // "https" (get protocol(): string) request.secure; // true (get secure(): boolean — protocol === "https") request.host; // "example.com:443" (host with port) request.hostname; // "example.com" (host without port) request.origin; // "https://example.com" request.fullUrl; // "https://example.com/users/1?tab=posts" request.querystring; // "tab=posts" (no leading "?", "" when none) ``` **Notes:** `fullUrl` is rebuilt from the (forwarded) origin plus the path and query, so it reflects the client-facing URL rather than the internal one the proxy dialed. Use `origin` to build absolute links and `secure` to gate or redirect insecure requests. #### `request.status` `get status(): number` The current response status — useful in middleware after `await next()`. ```ts await next(); if (request.status >= 500) log(request.path); ``` #### `request.header(name)` `header(name: string): string | undefined` A single request header (case-insensitive), or `undefined`. ```ts request.header("authorization"); ``` #### `request.param(name?)` `param(name?: string): string | Record` One route parameter by name, or all of them as an object when called with no argument. ```ts request.param("id"); // "42" request.param(); // { id: "42" } ``` **Notes:** the return type is a union — narrow it, or prefer the overloaded standalone `param()` helper when you want a precise `string`. #### `request.query(name?)` `query(name?: string): string | undefined | Record` One query-string value, or the whole query object with no argument. ```ts request.query("q"); // "keel" | undefined request.query(); // { q: "keel", page: "2" } ``` #### `request.json()` `json(): Promise` The parsed JSON body, typed as `T`. ```ts const body = await request.json<{ email: string }>(); ``` **Notes:** rejects if the body isn't valid JSON. For a query+body merge instead, use `request.all()`. #### `request.text()` · `request.arrayBuffer()` · `request.blob()` `text(): Promise` · `arrayBuffer(): Promise` · `blob(): Promise` The raw request body, for content types `json()`/`all()` don't handle — parse it yourself. ```ts const xml = await request.text(); // XML, CSV, plain text const bytes = await request.arrayBuffer(); // protobuf, msgpack, binary ``` **Notes:** thin passes to the underlying Hono request, which caches the body, so these compose with each other (the body isn't re-read). #### `request.raw` `get raw(): Request` The underlying web `Request`. ```ts request.raw.signal; // AbortSignal, streaming body, etc. ``` #### `request.route` `get route(): { name?: string; pattern?: string; methods?: string[] } | undefined` The matched route descriptor the kernel stashed on the context. ```ts request.route?.name; // "users.show" ``` **Notes:** `undefined` if no named route matched (or the kernel didn't set it). #### `request.routeIs(name)` `routeIs(name: string): boolean` Whether the matched route has the given name. ```ts if (request.routeIs("users.show")) highlightNav(); ``` #### `request.subdomain(name)` `subdomain(name: string): string | undefined` A subdomain parameter captured from a domain-bound route. ```ts request.subdomain("tenant"); // "acme" for acme.example.com ``` #### `request.cookie(name?)` `cookie(name?: string): string | undefined | Record` One request cookie by name, or all cookies with no argument. ```ts request.cookie("session"); // "abc123" | undefined request.cookie(); // { session: "abc123" } ``` #### `request.ip()` `ip(): string | undefined` The client IP, from `X-Forwarded-For` (first hop) then `X-Real-IP`. ```ts request.ip(); // "203.0.113.7" ``` **Notes:** trusts proxy headers — only reliable behind a proxy you control. #### `request.ips()` `ips(): string[]` The full `X-Forwarded-For` chain, client first. ```ts request.ips(); // ["203.0.113.7", "10.0.0.1"] ``` **Notes:** empty array when there's no `X-Forwarded-For`. #### `request.hasBody()` `hasBody(): boolean` True if the request declares a body (has `Content-Length` or `Transfer-Encoding`). ```ts if (request.hasBody()) await request.all(); ``` #### `request.headers()` `headers(): Record` All request headers as a plain object (names lower-cased by the runtime). ```ts request.headers(); // { "content-type": "application/json", … } ``` #### `request.all()` `all(): Promise>` The query string merged with the parsed body (body wins on key collisions). ```ts const input = await request.all(); // { …query, …body } ``` **Notes:** async. Handles JSON and form bodies; drops file fields; swallows a missing/invalid body. Backs `input`/`only`/`except`. #### `request.input(key, fallback?)` `input(key: string, fallback?: T): Promise` A single value from `all()`, with an optional fallback when the key is absent. ```ts const email = await request.input("email"); const page = await request.input("page", 1); // T inferred as number ``` **Notes:** the fallback only applies when the key is missing entirely — a present-but-empty value is returned as-is. #### `request.only(keys)` `only(keys: string[]): Promise>` Just the named inputs from `all()`. ```ts await request.only(["email", "name"]); ``` **Notes:** keys not present are omitted (not set to `undefined`). #### `request.except(keys)` `except(keys: string[]): Promise>` Every input except the named ones. ```ts await request.except(["password", "_csrf"]); ``` #### `request.file(name)` `file(name: string): Promise` One uploaded file by field name, as a web `File`. ```ts const avatar = await request.file("avatar"); if (avatar) await store(await avatar.arrayBuffer()); ``` **Notes:** `undefined` if the field is absent or wasn't a file. #### `request.files(name)` `files(name: string): Promise` All uploaded files for a repeated field name. ```ts const docs = await request.files("docs"); // File[] ``` **Notes:** empty array when there are none; non-file values are filtered out. #### `request.allFiles()` `allFiles(): Promise>` Every uploaded file, grouped by field name. ```ts const files = await request.allFiles(); // { avatar: File, docs: File[] } ``` **Notes:** a field with one file maps to a lone `File`; repeated fields map to `File[]`. #### `request.accepts(types)` `accepts(types: string[]): string | null` The best of the offered content types per the `Accept` header, or `null`. ```ts switch (request.accepts(["application/json", "text/html"])) { case "application/json": return json(data); case "text/html": return html(page); default: return response.abort("Not acceptable", 406); } ``` **Notes:** honors `*/*`/`*` (returns the first offered). `null` when nothing matches. #### `request.types()` `types(): string[]` Accepted content types, ordered by q-weight preference. ```ts request.types(); // ["text/html", "application/json"] ``` #### `request.language(languages)` `language(languages: string[]): string | null` The best of the offered languages per `Accept-Language`, or `null`. ```ts request.language(["en", "fr"]); // "fr" ``` #### `request.languages()` `languages(): string[]` Accepted languages, ordered by preference. ```ts request.languages(); // ["fr", "en"] ``` #### `request.encoding(encodings)` · `request.encodings()` `encoding(encodings: string[]): string | null` · `encodings(): string[]` Negotiate the response's content encoding against `Accept-Encoding` — same q-weight and `*` rules as `accepts`. ```ts request.encoding(["br", "gzip"]); // "br" request.encodings(); // ["br", "gzip", "identity"] ``` #### `request.charset(charsets)` · `request.charsets()` `charset(charsets: string[]): string | null` · `charsets(): string[]` Negotiate the response charset against `Accept-Charset`. ```ts request.charset(["utf-8", "iso-8859-1"]); // "utf-8" request.charsets(); // ["utf-8"] ``` ### `response` The flat response accessor — a singleton object mirroring `request`. Mutators return `response` (chainable); terminals return a `Response`. #### `response.json(data, status?)` `json(data: unknown, status?: number): Response` Same as the standalone `json()`, but reads nicely after chained mutators. ```ts response.status(201).json(created); ``` #### `response.text(body, status?)` `text(body: string, status?: number): Response` A plain-text response. ```ts response.text("pong"); ``` #### `response.html(body, status?)` `html(body: string, status?: number): Response` An HTML response. ```ts response.html("

Hi

"); ``` #### `response.redirect(location, status?)` `redirect(location: string, status?: number): Response` A redirect response. ```ts response.cookie("flash", "saved").redirect("/"); ``` Passing `"back"` as the location bounces to the `Referer` header, or `"/"` when there isn't one — see `response.back` for a version with a custom fallback. #### `response.back(fallback?, status?)` `back(fallback = "/", status?: number): Response` Redirects to the request's `Referer` header, falling back to `fallback` (default `"/"`) when the header is absent. Handy for "return to where you came from" flows after a form post. ```ts response.abortUnless(ok, "Nope"); return response.back("/dashboard"); ``` #### `response.send(data, status?)` `send(data: unknown, status?: number): Response` Sends a value — a non-null object becomes JSON, everything else becomes text. ```ts response.send({ ok: true }); // JSON response.send("pong"); // text ``` **Notes:** `null` is treated as non-object, so it's stringified to text (`"null"`); wrap it in an object if you want JSON `null`. #### `response.status(code)` `status(code: number): ResponseHelper` Sets the response status. Chainable. ```ts response.status(202).json({ queued: true }); ``` #### `response.header(name, value)` `header(name: string, value: string): ResponseHelper` Sets a response header. Chainable. ```ts response.header("x-total", "42").json(rows); ``` #### `response.headers(map)` `headers(map: Record): ResponseHelper` Sets several response headers at once. Chainable. ```ts response.headers({ "x-total": "42", "cache-control": "no-store" }); ``` #### `response.getHeader(name)` / `response.hasHeader(name)` `getHeader(name: string): string | null` `hasHeader(name: string): boolean` Read a response header set so far — useful in middleware after `await next()`, to inspect what a handler set. ```ts kernel.use(async (c, next) => { await next(); if (!response.hasHeader("cache-control")) response.header("cache-control", "no-store"); }); ``` #### `response.type(mime)` `type(mime: string): ResponseHelper` Sets the `Content-Type`. Chainable. ```ts response.type("text/csv").send(csv); ``` #### `response.attachment(filename?)` `attachment(filename?: string): ResponseHelper` Marks the response as a downloadable attachment via `Content-Disposition`. Chainable. With no argument it sets a bare `attachment`; with a filename it adds both a quoted ASCII `filename` and an RFC 5987 `filename*` so non-ASCII names survive. Pair it with `type()` to set the download's content type. ```ts response.attachment("report.csv").type("text/csv").send(csv); // Content-Disposition: attachment; filename="report.csv"; filename*=UTF-8''report.csv ``` #### `response.append(name, value)` `append(name: string, value: string): ResponseHelper` Appends to a (possibly multi-value) header rather than replacing it. Chainable. ```ts response.append("vary", "accept").append("vary", "accept-language"); ``` #### `response.removeHeader(name)` `removeHeader(name: string): ResponseHelper` Removes a response header. Chainable. ```ts response.removeHeader("x-powered-by"); ``` #### `response.cookie(name, value, options?)` `cookie(name: string, value: string, options?: CookieOptions): ResponseHelper` Queues a `Set-Cookie`. Chainable. ```ts response.cookie("session", token, { httpOnly: true, maxAge: 3600 }); ``` **Notes:** `options` is Hono's cookie option bag (`httpOnly`, `secure`, `sameSite`, `maxAge`, `path`, `domain`, …). #### `response.clearCookie(name, options?)` `clearCookie(name: string, options?: CookieOptions): ResponseHelper` Clears a cookie (queues an expired `Set-Cookie`). Chainable. ```ts response.clearCookie("session"); ``` **Notes:** pass the same `path`/`domain` you set the cookie with, or the browser won't match it. #### `response.abort(message, status?)` `abort(message: string, status?: number): never` Throws an `HttpException` to end the request. ```ts response.abort("Not found", 404); ``` **Notes:** default status `400`. Return type is `never`, so TypeScript treats everything after it as unreachable. Rendered by the kernel (see [Errors](./errors.md)). #### `response.abortIf(condition, message, status?)` `abortIf(condition: unknown, message: string, status?: number): void` Aborts only if `condition` is truthy. ```ts response.abortIf(!user, "Not found", 404); ``` **Notes:** default status `400`. Doesn't narrow types (return is `void`, not a type guard). #### `response.abortUnless(condition, message, status?)` `abortUnless(condition: unknown, message: string, status?: number): void` Aborts unless `condition` is truthy. ```ts response.abortUnless(user?.isAdmin, "Forbidden", 403); ``` **Notes:** default status `400`. ### Standalone shortcuts Flat helpers for terse handlers — they resolve the same request as `request`. #### `param(name?)` `param(): Record` `param(name: string): string` One route parameter (typed `string`) or all of them. ```ts import { param } from "@shaferllc/keel/core"; param("id"); // string param(); // Record ``` **Notes:** overloaded, so `param("id")` is precisely `string` — unlike `request.param`, which returns the union. #### `query(name?)` `query(): Record` `query(name: string): string | undefined` One query value or the whole query object. ```ts import { query } from "@shaferllc/keel/core"; query("q"); // string | undefined query(); // Record ``` #### `header(name)` `header(name: string): string | undefined` A single request header. ```ts import { header } from "@shaferllc/keel/core"; header("authorization"); ``` #### `body()` `body(): Promise` The parsed JSON body — the standalone twin of `request.json()`. ```ts import { body } from "@shaferllc/keel/core"; const data = await body<{ email: string }>(); ``` **Notes:** rejects on invalid JSON. --- # Middleware Middleware wraps every request, running code before and after your route handler. Keel uses Hono's middleware signature, so a middleware is just an async function of `(c, next)` — the same shape you'd write for a bare Hono app. There are two ways a middleware runs: **globally**, on every request (registered in the HTTP kernel), or **per route/group**, attached where you declare the route. Global middleware is for cross-cutting concerns (logging, CORS, request IDs); route middleware is for guards that only some routes need (auth, admin). ## The HTTP kernel Global middleware is registered in `app/Http/Kernel.ts`, which extends the framework's `HttpKernel`: ```ts import { HttpKernel, Application } from "@shaferllc/keel/core"; import { requestLogger } from "./Middleware/requestLogger.js"; export class Kernel extends HttpKernel { constructor(app: Application) { super(app); this.use(requestLogger); // runs on every request, in order } } ``` Call `this.use(...)` once per middleware. They run in the order added. The kernel wires a few internal middleware first — context storage and container binding — so by the time your global middleware runs, `c.get("app")` (the container) is already set. Stack several by chaining or calling `use` repeatedly: ```ts export class Kernel extends HttpKernel { constructor(app: Application) { super(app); this.use(requestLogger).use(cors).use(requestId); } } ``` The kernel also compiles the router's routes onto a Hono instance (`build()`, called by `keel serve` — you never call it yourself) and turns thrown exceptions and unmatched routes into responses. To replace that default rendering, register a custom error handler: ```ts export class Kernel extends HttpKernel { constructor(app: Application) { super(app); this.use(requestLogger); this.onError((err, c) => c.json({ oops: String(err) }, 500)); } } ``` See [errors](./errors.md) for what the default handler does and how reportable exceptions hook in. ## Writing middleware A middleware is an async function of `(c, next)`. Do work, `await next()` to pass control down the stack, then optionally do work on the way back up: ```ts import type { MiddlewareHandler } from "hono"; export const requestLogger: MiddlewareHandler = async (c, next) => { const start = performance.now(); await next(); // run the rest of the stack const ms = (performance.now() - start).toFixed(1); console.log(` ${c.req.method} ${c.req.path} → ${c.res.status} (${ms}ms)`); }; ``` `c` is Hono's `Context` (Keel re-exports it as [`Ctx`](./routing.md)); `next` advances to the next middleware, and eventually the route handler. Everything before `await next()` runs on the way in; everything after runs on the way out, in reverse order. Skip `next()` entirely to short-circuit (see below). Generate a stub with: ```bash npm run keel make:middleware Auth # -> app/Http/Middleware/authMiddleware.ts ``` The generator strips a trailing `Middleware` from the name, PascalCases it, then lower-cases the first letter for the filename and the exported const — so `make:middleware Auth` writes `authMiddleware.ts` exporting `authMiddleware`, and `make:middleware RateLimit` writes `rateLimitMiddleware.ts`. The stub is a ready `MiddlewareHandler` with `before`/`after` markers around `await next()`. ## Named middleware Register middleware by name once, then reference it by name on routes and groups — no importing the function everywhere: ```ts // routes/web.ts (or a service provider) — anywhere you hold the router. router.named({ auth: authMiddleware, admin: adminMiddleware, }); router.get("/dashboard", [DashboardController, "index"]).use("auth"); router.group(() => { /* … */ }).use(["auth", "admin"]); router.resource("posts", PostController).use(["store", "update"], "auth"); ``` `router.named()` takes a map of names to **handlers** (functions, not other names), and merges into any previously registered names — call it as many times as you like. The `Router` is a singleton, so names registered on the instance passed to your route file are the same ones the kernel resolves at build time. You can still pass raw functions anywhere a name is accepted — the `.use()` / `.middleware()` argument is a `MiddlewareRef` (`MiddlewareHandler | string`), so you mix and match: ```ts router.get("/reports", [ReportController, "index"]).use(["auth", auditLog]); ``` Referencing an unregistered name throws when the app builds — `No named middleware [auth]. Register it with router.named({ auth: … }).` — so typos surface immediately at boot, not on the first matching request. For **parameterized** middleware, use a factory that returns a handler: ```ts const role = (name: string): MiddlewareHandler => async (c, next) => { // check role === name … await next(); }; router.get("/admin", handler).use(role("admin")); ``` You can register the *result* of a factory as a name, too, if a fixed configuration recurs: ```ts router.named({ admin: role("admin"), editor: role("editor") }); router.get("/admin", handler).use("admin"); ``` ## Short-circuiting Return a response _without_ calling `next()` to stop the request early — handy for auth guards: ```ts export const requireApiKey: MiddlewareHandler = async (c, next) => { if (c.req.header("x-api-key") !== process.env.API_KEY) { return c.json({ error: "Unauthorized" }, 401); } await next(); }; ``` Because the handler and every inner middleware never run, short-circuiting is how guards enforce access. You can also `throw` an [HTTP exception](./errors.md) instead of returning — the kernel's error handler renders it: ```ts import { UnauthorizedException } from "@shaferllc/keel/core"; export const requireAuth: MiddlewareHandler = async (c, next) => { if (!c.get("app").make(Auth).check()) throw new UnauthorizedException(); await next(); }; ``` ## Sharing data with handlers Stash values on the context; downstream handlers read them back. Keel already does this to expose the container as `c.get("app")`: ```ts export const withUser: MiddlewareHandler = async (c, next) => { c.set("user", await lookupUser(c.req.header("authorization"))); await next(); }; // later, in a handler: const user = c.get("user"); ``` To get type safety on custom context variables, augment Hono's `ContextVariableMap` (see [`src/core/hono.d.ts`](../src/core/hono.d.ts) for how Keel does it for `app`, `route`, `subdomains`, and `session`): ```ts declare module "hono" { interface ContextVariableMap { user?: { id: number; name: string }; } } ``` With that in place `c.set("user", …)` and `c.get("user")` are fully typed everywhere. ## Order of execution ``` requestLogger ┐ ┌ requestLogger ▼ │ requireApiKey ─┼─► handler ─┘ ``` Middleware added first is outermost: it runs first on the way in and last on the way out. The full order for any request is: 1. Keel's internal middleware (context storage, container/subdomain binding). 2. Global middleware, in the order you `use()`d them in the kernel. 3. Group middleware, outermost group first. 4. Per-route middleware, in the order attached. 5. The route handler. Group middleware always runs *before* the route's own middleware — a group prepends its middleware to each contained route. So this: ```ts router.group(() => { router.get("/posts/:id/edit", handler).use("owns-post"); }).use("auth"); ``` runs `auth` (group), then `owns-post` (route), then `handler` — a natural "authenticated, *and* owns this post" gate. ## Related Attaching middleware to routes, groups, and resources is part of the [routing](./routing.md) API — this page covers writing and registering the handlers; routing covers where they hang. --- ## API reference ### `HttpKernel` The base class your `app/Http/Kernel.ts` extends. It owns the global middleware stack, compiles routes onto Hono, and renders errors. You construct your subclass (the bootstrap does, via `app.singleton(HttpKernel, …)`); you don't construct `HttpKernel` directly. #### `use(mw)` `use(mw: MiddlewareHandler): this` Appends a middleware to the global stack — it runs on every request, in the order added. ```ts export class Kernel extends HttpKernel { constructor(app: Application) { super(app); this.use(requestLogger).use(cors); } } ``` **Notes:** returns `this`, so calls chain. Runs after Keel's internal setup middleware, so `c.get("app")` is available. Only accepts a `MiddlewareHandler` (a function) — global middleware isn't named, so there's no string form here. #### `onError(handler)` `onError(handler: (err: unknown, c: Context) => Response | Promise): this` Registers a custom error handler that takes precedence over the default HTML/JSON exception rendering. ```ts this.onError((err, c) => c.json({ error: String(err) }, 500)); ``` **Notes:** returns `this`. Reportable exceptions still get their `report()` hook called before your handler runs; your handler fully replaces the default `renderException` output. Last call wins. #### `build()` `build(): Hono` Compiles the router's collected routes onto a fresh Hono instance, mounting the global middleware, per-route middleware, domain dispatch, and the not-found / error handlers. ```ts const hono = app.make(HttpKernel).build(); // done for you by `keel serve` ``` **Notes:** called by the framework at boot — you rarely call it yourself. Routes bound to a `domain(...)` are compiled into per-host sub-apps and dispatched by the `Host` header; everything else lands on the default app. ### `Router` (middleware methods) The router (a container singleton — `app.make(Router)`) is where named middleware lives. Its routing methods are documented in [routing](./routing.md); the middleware-related surface is below. #### `named(map)` `named(map: Record): this` Registers named middleware, referenceable by name in `.use()` / `.middleware()` on routes, groups, and resources. ```ts router.named({ auth: authMiddleware, admin: adminMiddleware }); router.get("/dashboard", handler).use("auth"); ``` **Notes:** merges into previously registered names (call it repeatedly). Values must be handlers, not other names. Returns `this`. #### `resolveMiddleware(ref)` `resolveMiddleware(ref: MiddlewareRef): MiddlewareHandler` Resolves a `MiddlewareRef` — a handler passes through unchanged; a string is looked up in the named registry. ```ts const mw = router.resolveMiddleware("auth"); // the registered authMiddleware ``` **Notes:** called by the kernel while compiling each route; you rarely call it directly. Throws `No named middleware [name]. Register it with router.named({ name: … }).` for an unknown name — which surfaces at build time, catching typos at boot. ### Applying middleware to routes These live on the route builders returned by the router. Full signatures and examples are in [routing](./routing.md); the middleware-relevant ones: #### `Route.middleware(mw)` / `Route.use(mw)` `middleware(mw: MiddlewareRef | MiddlewareRef[]): this` `use(mw: MiddlewareRef | MiddlewareRef[]): this` Attaches middleware that runs only for this route (after any group middleware). `use` is an alias for `middleware`. ```ts router.get("/dashboard", handler).use("auth"); router.get("/reports", handler).middleware(["auth", auditLog]); ``` **Notes:** appends — call repeatedly or pass an array to add several. Accepts names or raw handlers (`MiddlewareRef`). #### `RouteGroup.middleware(mw)` / `RouteGroup.use(mw)` `middleware(mw: MiddlewareRef | MiddlewareRef[]): this` `use(mw: MiddlewareRef | MiddlewareRef[]): this` Attaches middleware to every route in the group. `use` is an alias. ```ts router.group(() => { router.get("/dashboard", handler); router.get("/settings", handler); }).use(["auth", "admin"]); ``` **Notes:** *prepends* to each route's middleware, so group middleware runs before per-route middleware. Groups nest: an outer group's middleware wraps an inner group's. #### `RouteResource.use(actions, mw)` `use(actions: string[] | "*", mw: MiddlewareRef | MiddlewareRef[]): this` Attaches middleware to specific resource actions (`index`, `store`, `show`, …), or `"*"` for all of them. ```ts router.resource("posts", PostController).use(["store", "update", "destroy"], "auth"); router.resource("admin", AdminController).use("*", "admin"); ``` **Notes:** the action names are the RESTful set (`index`, `create`, `store`, `show`, `edit`, `update`, `destroy`). Non-matching actions are left untouched. ### Interfaces & types #### `MiddlewareHandler` (from `hono`) ```ts type MiddlewareHandler = (c: Context, next: Next) => Promise; ``` The shape every middleware implements — imported from `hono`, not Keel. Do work, `await next()` to continue the stack, return a `Response` (or nothing). Return early without calling `next()` to short-circuit. ```ts import type { MiddlewareHandler } from "hono"; export const noCache: MiddlewareHandler = async (c, next) => { await next(); c.header("Cache-Control", "no-store"); }; ``` #### `MiddlewareRef` `type MiddlewareRef = MiddlewareHandler | string` What `.use()` / `.middleware()` accept: either a middleware handler, or the name of one registered with `router.named()`. ```ts import type { MiddlewareRef } from "@shaferllc/keel/core"; const guards: MiddlewareRef[] = ["auth", auditLog]; // names and functions mix router.get("/reports", handler).use(guards); ``` --- # Accounts Password reset, email verification, and two-factor authentication — the flows every app with a login needs, built on primitives already in core (`hash`, `encryption`, `mail`, `rate-limit`). They live in the framework, tested once, rather than being copy-pasted into each new app. A password-reset flow written five times is four copies that quietly rot. ```bash npm install @shaferllc/keel ``` ```ts // bootstrap/providers.ts import { AccountsServiceProvider } from "@shaferllc/keel/accounts"; app.register(AccountsServiceProvider); ``` That merges config, adds four columns to your `users` table via a migration, and mounts the JSON endpoints. **Views stay yours** — these are functions and JSON endpoints; your controllers render the forms. ## Login `attempt()` checks a password. What comes back depends on whether the user has 2FA. ```ts const result = await attempt(email, password); if (result.status === "failed") { return { error: "Those credentials don't match." }; } if (result.status === "two-factor") { // Nothing is logged in yet. Hold the challenge, ask for a code. return { twoFactor: true, challenge: result.challenge }; } auth().login(result.user.id); ``` A wrong email and a wrong password give the same answer, and take the same time — `attempt()` hashes against `hash.dummy` when no user is found, because a fast "no such user" tells an attacker which addresses are registered. ## Two-factor ### The challenge is not a session When 2FA is on, a correct password yields a **challenge**, not a login. Nothing is authenticated until the code verifies. This matters more than it looks. The usual implementation logs the user in and sets a `needs_2fa` flag for middleware to check — which means they are holding a real authenticated session *before* the second factor. Every route that forgets the middleware, and every `auth()` call that only asks "is anyone logged in?", is then bypassable with just a password. The second factor becomes advisory. Here there is no half-authenticated state to forget about, because there is no session. The challenge is a short-lived token bound to a single purpose, so it cannot be swapped for a session cookie or spent as a password-reset link. ```ts const user = await completeTwoFactor(challenge, code); if (!user) return { error: "That code isn't valid." }; auth().login(user.id); ``` `completeTwoFactor()` accepts an authenticator code **or** a recovery code. ### Turning it on takes two steps ```ts // Step one: a secret and recovery codes. 2FA is NOT on yet. const setup = await enableTwoFactor(user, { issuer: "Acme" }); ``` `setup.uri` is an `otpauth://` URI to render as a QR code. `setup.secret` is for manual entry. `setup.recoveryCodes` are shown **once**. > Render the QR **locally**. The URI contains the shared secret, so posting it to a > QR-image service hands your users' second factor to a third party. ```ts // Step two: a working code turns it on. const ok = await confirmTwoFactor(user, code); ``` The two-step dance is deliberate. A one-step "enable" locks out every user who scans the QR wrong or whose phone clock is off — and what's broken is the very thing they'd need to get back in. Until `confirmTwoFactor()` succeeds, `hasTwoFactor()` is false and login works as before. ### Recovery codes Eight by default, hashed at rest, single-use — redeeming one burns it, so a code read over your shoulder is one you have already spent. ```ts await recoveryCodesRemaining(user); // worth surfacing when it gets low await regenerateRecoveryCodes(user); // invalidates the old set await disableTwoFactor(user); // destroys the secret and the codes ``` ### What's stored The TOTP secret is **encrypted** at rest and the recovery codes are **hashed**, so a leaked database hands over neither the second factor nor the backdoor. TOTP itself is RFC 6238, verified against the RFC's published test vectors, and built on WebCrypto with no dependencies — so it runs unchanged on the edge. ## Password reset ```ts await requestPasswordReset(email); // emails a link, or quietly does nothing ``` The answer is the same whether or not that address has an account. "No account with that address" is a free enumeration oracle on an unauthenticated endpoint that anyone can ask about anyone. ```ts const ok = await resetPassword(token, password); ``` **A reset link works exactly once**, and there is no `password_resets` table. The token carries its own purpose and expiry inside the ciphertext, and it is bound to a fingerprint of the current password hash — so the moment the password changes, every token minted against the old one is dead. Nothing to store, nothing to clean up, and no window where a stale row is still redeemable because a cron job didn't run. ## Email verification ```ts await sendVerificationEmail(user); const user = await verifyEmail(token); // idempotent ``` The token is bound to the address it was sent to. A link mailed to an old address cannot verify a new one — otherwise changing your email to someone else's and clicking an older link would mark *their* address as proven. ## The endpoints Mounted at `auth` unless you turn them off (`routes.enabled: false`) and call the functions from your own controllers instead. | Method | Path | Notes | | --- | --- | --- | | POST | `/auth/login` | `{ email, password }` → a user, or `{ twoFactor, challenge }` | | POST | `/auth/two-factor` | `{ challenge, code }` — code or recovery code | | POST | `/auth/password/forgot` | Always `202`. Never says who exists. | | POST | `/auth/password/reset` | `{ token, password }` | | POST | `/auth/email/verify` | `{ token }` | | POST | `/auth/email/resend` | Always `202` | Every one is unauthenticated and touches credentials, so the group is rate-limited (5 per minute by default). Without a throttle, a six-digit code inside a 30-second window is guessable, and forgot-password is an email cannon pointed at whoever the caller names. ## Configuration ```bash keel vendor:publish --tag accounts-config ``` ```ts export default { userTable: "users", routes: { enabled: true, prefix: "auth" }, passwordReset: { expiresIn: "60m", url: "/reset-password?token=:token" }, verification: { expiresIn: "24h", url: "/verify-email?token=:token" }, twoFactor: { issuer: env("APP_NAME", "Keel"), window: 1, // ±30s of clock drift challengeExpiresIn: "5m", recoveryCodes: 8, }, rateLimit: { max: 5, window: 60 }, }; ``` `twoFactor.challengeExpiresIn` is the window in which a stolen password alone is enough. Keep it short. ## A different users table Accounts talks to a table through the query builder rather than assuming a `Model`. If your users live somewhere else — an auth service, a legacy schema — replace the store: ```ts setAccountStore({ async findById(id) { /* … */ }, async findByEmail(email) { /* … */ }, async update(id, values) { /* … */ }, }); ``` ## The schema Four columns on your users table, and no tokens table: | Column | | | --- | --- | | `email_verified_at` | null until proven | | `two_factor_secret` | encrypted at rest | | `two_factor_recovery_codes` | hashed, then encrypted | | `two_factor_confirmed_at` | null until a working code proves it | --- # Building Keel apps with AI Keel is built to be **written with an AI agent**. This page is the map of the AI-facing surface: an MCP server, machine-readable docs (`llms.txt` / `llms-full.txt`), an agent playbook (`AGENTS.md`), and code generators an agent can drive directly. If you only read one thing: follow [From install to deploy](./from-install-to-deploy.md). To **deploy on `*.keeljs.cloud` from your IDE**, see [Keel Cloud (deploy from MCP)](./keel-cloud.md). Then point your agent at the [MCP server](#the-mcp-server) and call `keel_overview` first. ## Why this exists An agent working in a Keel app needs three things: to know the **conventions** (where things live, what imports to use), to **look up APIs and guides** without hallucinating, and to **generate correct boilerplate**. Keel provides each as a first-class, always-current surface — generated from the same source as the human docs, so they never drift. ## The MCP server Keel ships an [MCP](https://modelcontextprotocol.io) server that exposes its documentation, the full public API surface (380+ exports), the generators, and its conventions to any MCP-capable client. ### Connect it Easiest — curl this in your app (or any folder you want the config): ```bash curl -fsSL https://keeljs.com/install.sh | bash ``` Or via npx: ```bash npx -y keel-mcp@latest init # or: npx -y --package=@shaferllc/keel keel-mcp init ``` Both write a merge-safe `.mcp.json`. Add `--all` for `.cursor/mcp.json` plus Claude Code registration, or `--token keel_….…` to bake in Cloud credentials (`bash -s -- --all` when using curl). **Claude Code** only: ```bash claude mcp add keel -- npx -y --package=@shaferllc/keel keel-mcp # or: npx -y keel-mcp@latest init --claude ``` Hacking on the framework repo itself: ```bash claude mcp add keel -- npm --prefix /path/to/keel run mcp ``` **`.mcp.json`, Cursor, Windsurf, or any client** that reads the standard config (what `init` / `install.sh` writes): ```json { "mcpServers": { "keel": { "command": "npx", "args": ["-y", "--package=@shaferllc/keel", "keel-mcp"] } } } ``` The server speaks over stdio and prints its banner to **stderr** (stdout is the protocol channel). It resolves docs from the installed `@shaferllc/keel` package, so it always matches your installed version. ### Tools | Tool | What it does | |------|--------------| | `keel_overview` | Version, conventions, folder layout, every doc topic, and the generators. **Call this first.** | | `keel_search_docs` | Full-text search across all guides; returns snippets + slugs. | | `keel_read_doc` | A full guide by slug, optionally with its runnable example appended. | | `keel_search_api` | Search the public export surface; returns each symbol's module and its guide. | | `keel_list_generators` | The `keel make:*` generators, what they produce, and their flags. | | `keel_scaffold` | Generate a controller/provider/middleware/factory/seeder/job/notification/transformer stub. Returns code + target path — it does **not** write to disk. | When `KEEL_CLOUD_TOKEN` (and optional `KEEL_CLOUD_URL`) is set, Cloud tools are also registered — **create, preview, and publish sites on `*.keeljs.cloud`** from the same MCP server. Step-by-step: **[Keel Cloud (deploy from MCP)](./keel-cloud.md)**. Create a token at `/tokens` on [app.keeljs.cloud](https://app.keeljs.cloud) — the plaintext looks like `keel_.`. | Tool | What it does | |------|--------------| | `keel_cloud_me` | Authenticated Cloud user (plan, site limit, team) | | `keel_cloud_billing` | Team plan / limits / owner flag | | `keel_cloud_billing_checkout` / `_portal` | Stripe Checkout or Customer Portal URL (owner) | | `keel_cloud_list_sites` / `keel_cloud_get_site` | List or fetch a site (`storage_path`, hostnames, git) | | `keel_cloud_create_site` | Create from preset (`minimal` \| `api` \| `app` \| `saas`) | | `keel_cloud_delete_site` / `keel_cloud_restore_site` | Soft-delete (confirm) / restore | | `keel_cloud_preview` | Deploy preview Worker | | `keel_cloud_publish` | Publish production — requires `confirm: true` | | `keel_cloud_deploys` | Deploy history + logs | | `keel_cloud_list_secrets` / `keel_cloud_set_secret` / `keel_cloud_delete_secret` | Vault keys (values never returned) | | `keel_cloud_set_custom_domain` / `keel_cloud_clear_custom_domain` | Pro custom hostname (+ optional attach) | | `keel_cloud_export` / `keel_cloud_export_sql` | Export manifest / portable `.sql` dump | ```json { "mcpServers": { "keel": { "command": "npx", "args": ["-y", "--package=@shaferllc/keel", "keel-mcp"], "env": { "KEEL_CLOUD_TOKEN": "keel_….…", "KEEL_CLOUD_URL": "https://app.keeljs.cloud" } } } } ``` The token binds to the user's **first team**. Switch teams in the dashboard before minting a token if you need a different team context. ### Resources - `keel://overview` — the same orientation text as `keel_overview` - `keel://llms-full` — every guide concatenated (drop into a fresh context) - `keel://docs/` — one resource per guide (e.g. `keel://docs/routing`) ### A typical agent loop 1. `keel_overview` → learn the conventions and topic list. 2. `keel_search_docs { query: "belongsToMany pivot" }` → find the right guide. 3. `keel_read_doc { slug: "models", include_example: true }` → read it in full. 4. `keel_scaffold { kind: "controller", name: "Post", resource: true }` → get the stub. 5. Write the file, add the route, run `npm run typecheck`. ### A typical Keel Cloud loop Full walkthrough: [Keel Cloud (deploy from MCP)](./keel-cloud.md). 1. `keel_cloud_create_site { name: "Acme", preset: "app" }` 2. Edit the returned `storage_path` (real Keel app + git) 3. `keel_cloud_set_secret` for env the Worker needs 4. `keel_cloud_preview { site_id }` → `preview-{slug}.keeljs.cloud` 5. `keel_cloud_publish { site_id, confirm: true }` → `{slug}.keeljs.cloud` 6. Optional Pro: `keel_cloud_set_custom_domain { hostname, attach: true }` 7. Escape hatch anytime: `keel_cloud_export` + `keel_cloud_export_sql` ## `llms.txt` and `llms-full.txt` At the package root: - **[`llms.txt`](../llms.txt)** — the [llms.txt-spec](https://llmstxt.org) index: a titled, summarized, linked list of every guide and example. Good for AI crawlers and "add this URL as context" flows. - **[`llms-full.txt`](../llms-full.txt)** — every guide concatenated into one file (~17k lines), ordered from getting-started outward. Paste it into a fresh context window when you want the agent to have all of Keel at once. Both are generated by `npm run build:ai` from `docs/` and shipped in the npm package, so they're available at `node_modules/@shaferllc/keel/llms-full.txt`. ## `AGENTS.md` [`AGENTS.md`](../AGENTS.md) at the repo root is the agent playbook: the one import rule, the folder map, the container/provider mental model, a "how to add X" table, the commands, and the guardrails (typecheck before finishing, rerun `build:ai` after doc changes). It follows the emerging cross-tool `AGENTS.md` convention and is shipped in the package. `CLAUDE.md` points to it. ## Generators Everything scaffoldable is available three ways, all emitting the same stubs: - **MCP:** `keel_scaffold` (returns code; you write it) - **Console:** `keel make:controller Post -r` (writes the file, won't overwrite) - **By hand:** copy from `keel_read_doc` examples See [the console guide](./console.md) for the full command list. ## Keeping it current The AI surface is generated, not hand-maintained: ```bash npm run build:ai # regenerates llms.txt, llms-full.txt, docs/ai-manifest.json ``` `npm run build` runs it automatically before compiling. After you add or edit a doc, or change the public exports in `src/core/index.ts`, run `build:ai` so the manifest the MCP server reads stays in sync. `docs/ai-manifest.json` is the single machine-readable index (docs + API + generators); treat it as generated output. ## See also - [Getting Started](./getting-started.md) — the human first-hour guide - [Architecture](./architecture.md) — how a request flows through Keel - [The Console](./console.md) — the `keel` command and generators --- # API Resources `apiResource(router, Model, options)` generates a full CRUD REST API from a Keel [model](./models.md) — explicit, server-side, and composed from pieces you already have. It's imported from `@shaferllc/keel/api`. ```ts import { apiResource } from "@shaferllc/keel/api"; import { Post } from "../app/Models/Post.js"; import { z } from "zod"; export default function routes(router) { apiResource(router, Post, { filter: ["status", "authorId"], sort: ["createdAt", "title"], body: z.object({ title: z.string(), body: z.string(), status: z.string() }), access: { read: true, write: (c) => isEditor(c) }, }); } ``` That registers five routes: | Method | Path | Action | |--------|------|--------| | `GET` | `/posts` | list (filtered, sorted, paginated) | | `GET` | `/posts/:id` | read one | | `POST` | `/posts` | create | | `PUT` / `PATCH` | `/posts/:id` | update | | `DELETE` | `/posts/:id` | delete | They're real routes, so [`@shaferllc/keel/openapi`](./openapi.md) documents them automatically, and writes go through the model's mass-assignment guard and your Zod schema. ## Access is deny-by-default An auto-generated API that's open by default is a footgun. Every action whose access you don't declare returns **403**. You opt routes open — never shut. ```ts access: { read: true, // list + read: anyone write: (c) => auth().check(), // create + update + delete: signed-in only } ``` Rules resolve per action: the action's own key (`list`, `get`, `create`, `update`, `delete`), then the `read` / `write` shorthand, then `all`, then denied. Each rule is a boolean or a `(c) => boolean | Promise` predicate. ## Filtering, sorting, pagination — allow-listed The list endpoint reads the query string, but **only** columns you allow-list: - `filter: ["status"]` → `GET /posts?status=published` filters; `?secret=x` is ignored. Nothing reaches SQL unless it's on the list. - `sort: ["title", "createdAt"]` → `GET /posts?sort=title,-createdAt` (a `-` prefix is descending); unknown columns are dropped. - `GET /posts?page=2&perPage=20` paginates. `perPage` is clamped to `maxPerPage` (default 100) — the guard against "give me everything". The response is a paginated envelope: ```json { "data": [ … ], "meta": { "total": 42, "perPage": 20, "currentPage": 2, "lastPage": 3 } } ``` ## Row-level security with `scope` `scope` constrains the base query for **every** row operation — list, read, update, delete. A row outside the scope reads as 404, so it can't be fetched, changed, or removed: ```ts apiResource(router, Post, { access: { read: true, write: true }, scope: (q, c) => q.where("authorId", currentUserId(c)), // only your own posts }); ``` ## Shaping input and output - **`body` / `createBody` / `updateBody`** — a Zod schema validating writes (a failure is a 422). It also becomes the request-body schema in the OpenAPI docs. - **`beforeWrite(data, c, action)`** — mutate the write payload (stamp an owner id, a timestamp) before it's saved. - **`transform`** — shape the output: a `(model, c) => …` function, or a Keel [Transformer](./transformers.md) (its `item`/`collection` are used). ## Options reference | Option | Purpose | |--------|---------| | `path` | Base path (default: the model's table). | | `name` | Route-name prefix (default: the path). | | `only` / `except` | Restrict which of the five actions are generated. | | `filter` / `sort` | Allow-listed columns for `?filter` and `?sort`. | | `perPage` / `maxPerPage` | Page-size default and ceiling. | | `body` / `createBody` / `updateBody` | Write validation schemas. | | `access` | Per-action access rules (deny by default). | | `scope` | Row-level query constraint for every operation. | | `transform` | Output shaping. | | `beforeWrite` | Mutate the payload before save. | | `tags` | OpenAPI tags for the routes. | | `label` | Singular name in doc summaries (default: the model's class name). | Global pagination defaults live in `config/api.ts` (register the optional `ApiServiceProvider`, then `keel vendor:publish --tag api-config`). ## What this isn't There's no isomorphic frontend client here — no shared model object that runs on both sides of the wire. Keel deliberately stops at the server boundary: this generates a plain REST API you call however you like (fetch, your Inertia pages, a mobile app). That keeps the model server-only and the wire contract explicit — and it's exactly the contract the OpenAPI docs describe. --- # Authentication Session-based auth built on the pieces you already have: [sessions](./sessions.md) hold the login, [hashing](./hashing.md) checks passwords. `auth()` ties them together. Requires [`sessionMiddleware()`](./sessions.md) in your HTTP kernel — every `auth()` call reaches through the session, so without the middleware the first call throws `Session is not available…`. ## Tell Keel how to load a user Register a **user provider** once (in a service provider) — a function that returns a user for an id. Keel stays database-agnostic, so this is wherever your users live: ```ts import { setUserProvider } from "@shaferllc/keel/core"; setUserProvider((id) => db.users.find(id)); ``` The id handed to your provider is always a **string** — `login()` normalizes whatever you pass (number or string) with `String(id)` before stashing it. If your ids are numeric, coerce inside the provider (`db.users.find(Number(id))`). ## Logging in Verify the password yourself with `hash`, then `login()` the user's id: ```ts import { auth, hash, response } from "@shaferllc/keel/core"; async login() { const { email, password } = await request.only(["email", "password"]); const user = await db.users.findByEmail(email); if (!user || !(await hash.verify(user.password, password))) { return response.abort("Invalid credentials", 401); } auth().login(user.id); return response.redirect("/dashboard"); } ``` `login()` only writes the id to the session — it does no lookup and no password check. Verifying credentials is your job (above); `login()` is the "trust this id from now on" step. **Verify in constant time.** The snippet above skips the password check when no user is found, so a missing account answers faster than a wrong password — a timing signal that leaks which emails are registered. Compare against `hash.dummy` (a valid hash that never matches) so both paths cost the same: ```ts const user = await db.users.findByEmail(email); const ok = await hash.verify(user?.password ?? hash.dummy, password); if (ok && user) auth().login(user.id); // `user &&` so the dummy never logs anyone in ``` ## Reading the current user ```ts auth().check(); // is someone logged in? auth().guest(); // …or not? auth().id(); // the user id (string), or null await auth().user(); // the full user (via your provider), or null ``` `user()` reads the id from the session and runs it back through your provider on every call — there's no request-level cache, so if you need it twice in one handler, hold onto the result. Type the row it returns with the generic: ```ts type User = { id: number; email: string }; const user = await auth().user(); // User | null ``` `user()` returns `null` when nobody is logged in. But if someone *is* logged in and you never called `setUserProvider`, it throws — Keel has no way to turn the id back into a user: ``` Error: No user provider. Call setUserProvider((id) => findUser(id)). ``` ## Logging out ```ts auth().logout(); return response.redirect("/"); ``` `logout()` forgets the id from the session; it doesn't destroy the whole session, so anything else you flashed or stored survives. Clear the session yourself if you want a clean slate on sign-out. ## Protecting routes `authGuard()` rejects unauthenticated requests. Register it as [named middleware](./middleware.md) and apply it wherever you need: ```ts import { authGuard } from "@shaferllc/keel/core"; router.named({ auth: authGuard({ redirectTo: "/login" }) }); router.get("/dashboard", [DashboardController, "index"]).use("auth"); router.group(() => { /* … */ }).use("auth"); ``` Without `redirectTo`, the guard returns `401 Unauthenticated` (ideal for APIs): ```ts router.named({ auth: authGuard() }); // 401 JSON on failure, no redirect ``` The guard only checks that *someone* is logged in — it runs no provider lookup and loads no user. It gates on `guest()`, so it's cheap; load the user inside the handler with `auth().user()` when you actually need it. ## Token (API) authentication Sessions ride on a cookie — great for a server-rendered app, awkward for an SPA, a mobile client, or another service. For those, issue a **stateless bearer token**: an HS256 JWT signed with `config('app.key')`, built on the Web Crypto API so it works the same on Node and the edge (no `jsonwebtoken`, no native bindings). This is the Cloudflare-Workers-friendly path — nothing to store server-side. Issue a token in your login handler instead of (or alongside) `auth().login()`: ```ts import { jwt, hash, response } from "@shaferllc/keel/core"; async login() { const { email, password } = await request.only(["email", "password"]); const user = await db.users.findByEmail(email); if (!user || !(await hash.verify(user.password, password))) { return response.abort("Invalid credentials", 401); } const token = await jwt.sign({ sub: String(user.id) }, { expiresIn: "1h" }); return response.json({ token }); } ``` Protect API routes with `bearerAuth()`. It reads `Authorization: Bearer `, verifies it, and makes the token's `sub` the authenticated id — so `auth()` works downstream exactly as it does for sessions, provider lookup and all: ```ts import { bearerAuth, auth } from "@shaferllc/keel/core"; router.get("/api/me", async () => response.json(await auth().user())).use(bearerAuth()); ``` A missing or invalid token gets `401 Unauthenticated`. Pass `{ optional: true }` to let the request through unauthenticated (`auth().check()` is then `false`). A token verified this way takes precedence over any session cookie on the same request, and — unlike sessions — needs no session store, so `bearerAuth()` works without `sessionMiddleware()`. `jwt` is a standalone primitive if you need tokens outside the guard: ```ts const token = await jwt.sign({ sub: "42", role: "admin" }, { expiresIn: "7d" }); const payload = await jwt.verify(token); // { sub, role, iat, exp } | null ``` `verify()` returns `null` — never throws — for a token that's malformed, tampered, expired, not-yet-valid, or fails an `issuer`/`audience` check. Only HS256 is accepted: `alg: none` and asymmetric algorithms are refused, closing the classic JWT algorithm-confusion hole. `sign()` accepts `expiresIn` (seconds, or a duration string like `"30s"`, `"15m"`, `"1h"`, `"7d"`), plus `subject`, `issuer`, `audience`, and a `secret` override. ## Opaque access tokens A JWT is stateless — you can't revoke one without extra machinery. When you need **revocable, scoped** API tokens (a "personal access tokens" screen, per-token abilities, "log out this device"), use the database-backed token store instead. A token is a row you can delete, so revocation is instant. Store them in a `personal_access_tokens` table (all timestamps epoch-ms): ```ts selector TEXT UNIQUE, hash TEXT, tokenable_id TEXT, name TEXT, abilities TEXT, last_used_at INTEGER, expires_at INTEGER, created_at INTEGER ``` Mint a token after verifying credentials — the plaintext is shown **once**: ```ts import { createToken } from "@shaferllc/keel/core"; const { token } = await createToken(user.id, { abilities: ["posts:read", "posts:write"], // or ["*"] for everything expiresIn: "30d", // omit for no expiry name: "CLI token", }); return response.json({ token }); // "keel_." ``` Protect routes with `tokenAuth()` — it verifies the `Bearer` token, makes its owner the authenticated user, and can require abilities: ```ts import { tokenAuth, auth, token, tokenCan } from "@shaferllc/keel/core"; router.get("/api/posts", async () => response.json(await auth().user())) .use(tokenAuth({ abilities: ["posts:read"] })); // inside a handler, inspect the verified token: token(); // { tokenableId, abilities, expiresAt, … } | null tokenCan("posts:write"); // boolean ``` The token splits into a public **selector** (indexed, for lookup) and a secret **verifier** (stored only as a SHA-256 hash), so a leaked database can't mint working tokens — and verification needs no `RETURNING`, so it's portable across every driver. Manage tokens with `listTokens(userId)`, `revokeToken(selector)`, and `revokeTokens(userId)` (log out everywhere). Verifying an expired token deletes it in passing, so the table self-prunes. **JWT vs. opaque:** reach for `jwt` when you want zero-lookup, stateless tokens (and don't need revocation); reach for `createToken`/`tokenAuth` when you need revocation, per-token scopes, or last-used tracking. ## Basic authentication For internal tools and quick gates, `basicAuth()` implements HTTP Basic auth — the browser's native `username` / `password` prompt. Always behind HTTPS, since the credentials ride on every request: ```ts import { basicAuth, auth, hash } from "@shaferllc/keel/core"; router.get("/admin", () => response.json(auth().id())).use( basicAuth(async (username, password) => { const user = await db.users.findByEmail(username); const ok = await hash.verify(user?.password ?? hash.dummy, password); return ok && user ? user.id : false; // return the id to log them in, or false }, { realm: "Admin" }), ); ``` The verifier returns the user's id (logs them in for the request), `true` (allow without an identity), or a falsy value (reject). On rejection `basicAuth` answers `401` with a `WWW-Authenticate` challenge so the browser re-prompts. ## Social sign-in "Sign in with GitHub/Google/Discord" lives in its own guide — [Social authentication](./social-auth.md). ## Registration Registration is the same flow in reverse — hash the password on the way in: ```ts const user = await db.users.create({ email, password: await hash.make(password), }); auth().login(user.id); ``` ## Working with `Auth` directly `auth()` is a thin accessor — it returns a fresh, stateless `Auth` bound to the current request's session. You can construct one yourself if you prefer; it reads the same session, so the two are interchangeable: ```ts import { Auth } from "@shaferllc/keel/core"; if (new Auth().check()) { /* … */ } ``` There's nothing to share between instances — all state lives in the session — so `auth()` and `new Auth()` behave identically. --- ## API reference ### `auth()` `auth(): Auth` Returns an `Auth` accessor bound to the current request's session. ```ts import { auth } from "@shaferllc/keel/core"; auth().login(userId); await auth().user(); ``` **Notes:** constructs a fresh `Auth` each call — it's stateless, so there's no cost to calling it repeatedly. Every method underneath reaches through `session()`, which throws if `sessionMiddleware()` isn't installed. ### `setUserProvider(fn)` `setUserProvider(fn: UserProvider): void` Registers the function Keel uses to turn a stored id back into a user. ```ts import { setUserProvider } from "@shaferllc/keel/core"; setUserProvider((id) => db.users.find(id)); ``` **Notes:** global — the last call wins. Register it once in a service provider. Until it's set, `auth().user()` throws for a logged-in request (but still returns `null` for a guest). ### `authGuard(options?)` `authGuard(options?: { redirectTo?: string }): MiddlewareHandler` Builds a middleware that blocks unauthenticated requests. ```ts import { authGuard } from "@shaferllc/keel/core"; router.named({ auth: authGuard({ redirectTo: "/login" }), api: authGuard(), // 401 instead }); ``` **Notes:** with `redirectTo`, guests get a redirect; without it, a `401 { error: "Unauthenticated", status: 401 }` JSON response. Authenticated requests pass straight through to the next handler. The check is `guest()` only — no user is loaded. ### `Auth` The accessor returned by `auth()`. Stateless — all its state lives in the session — so you rarely construct it directly, though `new Auth()` works and is equivalent to `auth()`. #### `login(id)` `login(id: string | number): void` Marks the given id as the authenticated user by storing it in the session. ```ts auth().login(user.id); ``` **Notes:** does no lookup or password check — verify credentials before calling. The id is coerced with `String(id)`, so `id()` and your provider always receive a string. #### `logout()` `logout(): void` Forgets the authenticated id from the session. ```ts auth().logout(); ``` **Notes:** only removes the auth key — other session data (flashes, cart, etc.) survives. Call `session().clear()` yourself for a full reset. #### `id()` `id(): string | null` The authenticated user's id, or `null` if nobody is logged in. ```ts const uid = auth().id(); // "42" | null ``` **Notes:** always a string (see `login`). Returns `null`, not `undefined`, for a guest. #### `check()` `check(): boolean` `true` when a user is authenticated. ```ts if (auth().check()) { /* logged in */ } ``` **Notes:** a pure `id() != null` test — reads the session, runs no provider. #### `guest()` `guest(): boolean` `true` when the request is unauthenticated — the inverse of `check()`. ```ts if (auth().guest()) return response.redirect("/login"); ``` #### `user(...)` `user(): Promise` Loads the full authenticated user by running the session id through the registered provider. ```ts type User = { id: number; email: string }; const user = await auth().user(); // User | null ``` **Notes:** returns `null` when nobody is logged in. Throws `No user provider…` if a user *is* logged in but `setUserProvider` was never called. No caching — each call re-invokes the provider. The generic only types the result; it does not validate the row's shape at runtime. ### Interfaces & types #### `UserProvider` ```ts type UserProvider = (id: string) => unknown | Promise; ``` The seam between Keel and your user store. Implement it once and register it with `setUserProvider` — Keel calls it with the string id from the session whenever `auth().user()` runs, and treats the return value as the authenticated user. ```ts import { setUserProvider, type UserProvider } from "@shaferllc/keel/core"; const provider: UserProvider = async (id) => { // `id` is always a string; coerce if your keys are numeric return db.users.find(Number(id)); }; setUserProvider(provider); ``` **Notes:** may be sync or async — `user()` awaits it either way. Return the user object (any shape) when found, or a nullish value when not; that value flows back out of `auth().user()`. --- # Authorization Where [authentication](./authentication.md) answers *who you are*, authorization answers *what you're allowed to do*. Keel gives you **gates** (ad-hoc abilities) and **policies** (abilities grouped per model) — a compact authorization layer. The current user is resolved from `auth().user()` by default, so authorization composes with the session auth you already have. ## Gates A gate is a named ability with a callback that receives the user and whatever you pass to the check: ```ts import { define, can, authorize } from "@shaferllc/keel/core"; // register once, at boot (e.g. in a service provider): define("update-post", (user, post) => post.authorId === user.id); define("access-admin", (user) => user.role === "admin"); // check anywhere: if (await can("update-post", post)) { // … } await authorize("update-post", post); // throws a 403 ForbiddenException if denied ``` `can(ability, ...args)` returns a boolean; `cannot(...)` is its negation; `authorize(...)` throws a `403` when denied (the HTTP kernel renders it). ## Policies For a model with several abilities, group them in a **policy** class — one method per ability — and register it. `can("update", post)` then routes to `PostPolicy.update(user, post)` automatically, by the argument's class: ```ts import { policy, can } from "@shaferllc/keel/core"; class PostPolicy { view(user, post) { return post.published || post.authorId === user.id; } update(user, post) { return post.authorId === user.id; } delete(user, post) { return user.admin || post.authorId === user.id; } } policy(Post, PostPolicy); // register the class (or an instance) await can("view", post); // → PostPolicy.view(user, post) await authorize("delete", post); // → PostPolicy.delete(user, post) or 403 ``` A policy is a plain class — no base class, no framework glue. The method name is the ability; the first argument to the check is the model. ## Admin bypass (before hooks) Register a `gateBefore` callback to decide checks up front — return a boolean to short-circuit, or `undefined` to fall through to the gate/policy. Perfect for a super-admin: ```ts import { gateBefore } from "@shaferllc/keel/core"; gateBefore((user) => (user.role === "superadmin" ? true : undefined)); ``` A `gateAfter` callback brackets the other end: it runs *after* the gate/policy and receives the result, returning a boolean to override it or `undefined` to keep it. Use it to audit every decision, or to veto late: ```ts import { gateAfter } from "@shaferllc/keel/core"; gateAfter((user, ability, args, result) => { log.info("authz", { user: user.id, ability, result }); return undefined; // keep the original decision }); ``` ## In a controller ```ts export class PostController { async update(c: Ctx) { const post = await Post.findOrFail(param("id")); await authorize("update", post); // 403 unless allowed // … safe to proceed } } ``` ## Checking a specific user `can`/`authorize` use the current user. To check someone else (background jobs, tests, impersonation), use the `For` variants: ```ts import { canFor, authorizeFor } from "@shaferllc/keel/core"; await canFor(otherUser, "update-post", post); await authorizeFor(otherUser, "update-post", post); ``` Resolving the current user differently (token auth instead of session)? `setUserResolver(() => currentUserSomehow())`. ## API reference ### `define(ability, callback)` `define(ability: string, callback: (user, ...args) => boolean | Promise): void` Registers a gate. The callback receives the resolved user and the check arguments. ### `policy(model, impl)` `policy(model: Constructor, impl: Policy | (new () => Policy)): void` Registers a policy (class or instance) for a model. `can(ability, instance)` routes to `impl[ability](user, instance)` when the ability matches a method. ### `can(ability, ...args)` / `cannot(...)` `can(ability: string, ...args): Promise` Whether the current user is allowed. `cannot` is the negation. Policy (matching model argument) is tried first, then a gate; unknown abilities **deny**. ### `authorize(ability, ...args)` `authorize(ability: string, ...args): Promise` Throws a `403` `ForbiddenException` unless allowed. ### `canFor(user, ...)` / `authorizeFor(user, ...)` The `can` / `authorize` pair for an explicit user rather than the current one. ### `gateBefore(callback)` `gateBefore(callback: (user, ability, args) => boolean | undefined | Promise<…>): void` Runs before every check; a boolean short-circuits, `undefined` falls through. ### `setUserResolver(resolver)` / `clearAuthorization()` Override how the current user is resolved (default `auth().user()`), and reset all gates/policies/hooks (a test helper). ### Interfaces & types #### `GateCallback` `type GateCallback = (user: unknown, ...args: unknown[]) => boolean | Promise` #### `BeforeCallback` `type BeforeCallback = (user, ability: string, args: unknown[]) => boolean | undefined | Promise` --- # Billing Keel Billing is a subscription-billing layer for charging customers, managing subscriptions, and reconciling gateway state through webhooks. It ships as a Keel [package](./packages.md) and supports two gateways behind one API: **Stripe** and **Paddle**. It attaches to a model with a mixin. Your `User` becomes billable, gains a gateway customer, and can create subscriptions and charges: ```ts import { Model } from "@shaferllc/keel/core"; import { Billable } from "@shaferllc/keel/billing"; export class User extends Billable(Model) { static table = "users"; declare email: string; } ``` To charge **teams** instead, set `billableModel: "Team"` and `billableTable: "teams"` in `config/billing.ts` (the provider's migration adds billing columns to that table). The saas starter kit does this. ## Install ```ts // bootstrap/providers.ts import { BillingServiceProvider } from "@shaferllc/keel/billing"; export const providers = [AppServiceProvider, BillingServiceProvider]; ``` Publish the config and create the tables: ```bash keel vendor:publish --tag billing-config # writes config/billing.ts keel migrate # creates subscriptions + subscription_items, # and adds billing columns to users ``` Set your keys in `.env`: ```ini BILLING_GATEWAY=stripe # or "paddle" STRIPE_SECRET_KEY=sk_... STRIPE_WEBHOOK_SECRET=whsec_... # Paddle: PADDLE_API_KEY=... PADDLE_WEBHOOK_SECRET=... PADDLE_CLIENT_TOKEN=... PADDLE_SANDBOX=true ``` ## One API, two gateways Everything you call goes through a gateway-neutral interface, so switching from Stripe to Paddle is a config change. The active gateway comes from `config("billing.default")`; a billable can also carry its own in `billing_gateway`. Money is always an integer in the smallest currency unit (cents). See [Gateway differences](#gateway-differences) for where Paddle's merchant-of-record model diverges. ## Customers A gateway customer is created lazily the first time you need one, but you can create it up front: ```ts await user.createAsCustomer(); // creates the customer, stores its id user.hasBillingId(); // true await user.getCustomerId(); // the id (creates if missing) ``` Override what gets synced by defining `billingName()` / `billingEmail()` on your model. By default they read `name` / `email`. ## Subscriptions Build a subscription with the fluent builder: ```ts await user .newSubscription("default", "price_pro") .trialDays(14) .quantity(3) .create(paymentMethodId); // paymentMethodId optional if a default is on file ``` Multiple prices (add-ons) are an array; `withMetadata`, `trialUntil`, and `skipTrial` are also available. To send the customer to a hosted checkout instead of charging now, swap `.create()` for `.checkout()`: ```ts const session = await user .newSubscription("default", "price_pro") .checkout({ successUrl: "...", cancelUrl: "..." }); // Stripe: redirect to session.url. Paddle: open the overlay with session.clientToken. ``` ### Status Status questions are answered from local columns — no gateway round-trip: ```ts await user.subscribed(); // valid (active | trial | grace) await user.subscribedToPrice("price_pro"); await user.onTrial(); const sub = await user.subscription(); // the "default" subscription, or null sub.active(); sub.onTrial(); sub.recurring(); sub.canceled(); sub.onGracePeriod(); sub.ended(); sub.paused(); sub.valid(); sub.hasIncompletePayment(); ``` ### Changing a subscription ```ts await sub.swap("price_enterprise"); // change price(s) await sub.updateQuantity(10); await sub.incrementQuantity(2); await sub.decrementQuantity(); ``` Each of these calls the gateway and syncs the result back into the local row. ### Cancelling ```ts await sub.cancel(); // at period end — access continues through the grace period await sub.onGracePeriod();// true await sub.resume(); // revive a subscription still in its grace period await sub.cancelNow(); // immediately; sub.ended() becomes true ``` ### Trials ```ts await sub.endTrial(); await sub.extendTrial(new Date("2026-01-01")); user.onGenericTrial(); // a trial_ends_at on the user, before any subscription ``` ## Single charges ```ts const charge = await user.charge(2000, { paymentMethod: "pm_1", description: "Credits" }); await user.refund(charge.id); // full refund await user.refund(charge.id, 500); // partial const session = await user.checkout("price_onetime", { successUrl, cancelUrl }); ``` ## Payment methods (Stripe) Collect a card with a SetupIntent, then create the subscription with the resulting payment method: ```ts const intent = await user.createSetupIntent(); // return intent.clientSecret to the front end const methods = await user.paymentMethods(); ``` ### Customer portal (Stripe) Send the customer to Stripe's hosted portal to update their card or cancel: ```ts const portal = await user.billingPortal("https://app.example.com/billing"); // redirect to portal.url ``` These are Stripe-only capabilities; calling them on the Paddle gateway throws a `BillingError` (Paddle collects cards in its own hosted checkout). ## Invoices ```ts const invoices = await user.invoices(); // GatewayInvoice[] — total, currency, status, url ``` ## Webhooks The package mounts one webhook endpoint per gateway at `config("billing.webhook.path")`: ``` POST /billing/webhook/stripe POST /billing/webhook/paddle ``` Point your gateway dashboard at the matching URL. Each request is verified against the gateway's signing secret (HMAC-SHA256 over the raw body), the local subscription is synced, and typed events fire: ```ts import { listen } from "@shaferllc/keel/core"; listen("billing.subscription.updated", (e) => { // e.gateway, e.subscriptionId, e.providerId, e.status }); listen("billing.webhook.received", (e) => { /* e.gateway, e.type, e.id */ }); ``` Events: `billing.webhook.received`, `billing.subscription.created` / `.updated` / `.deleted`. An update to a subscription already in your database is always synced. A brand new subscription born from a Paddle checkout has no local row yet — register a resolver so the handler can create it: ```ts import { resolveBillableUsing } from "@shaferllc/keel/billing"; resolveBillableUsing(async (customerId) => { const user = (await User.query().where("billing_customer_id", customerId).first()); return user ? { id: user.id, type: "User" } : null; }); ``` ## A complete flow From "user signs up" to "they're subscribed", with the fake gateway for tests: ```ts import { Model } from "@shaferllc/keel/core"; import { Billable, BillingManager, FakeGateway, setBilling, } from "@shaferllc/keel/billing"; class User extends Billable(Model) { static table = "users"; declare email: string; } // In a test bootstrap: const fake = new FakeGateway(); const manager = new BillingManager({ default: "fake", currency: "usd", billableModel: "User", billableTable: "users", webhook: { path: "billing/webhook" }, gateways: { stripe: { key: "", webhookSecret: "" }, paddle: { key: "", webhookSecret: "" }, fake: {} }, }); manager.register("fake", () => fake); setBilling(manager); const user = await User.create({ email: "ada@example.com" }); await user.newSubscription("default", "price_pro").trialDays(14).create(); await user.subscribed(); // true fake.calls.some((c) => c.method === "createSubscription"); // true ``` In production you skip the fake manager — `BillingServiceProvider` wires the real gateway from `config/billing.ts` and `.env`. ## Gateway differences | Concern | Stripe | Paddle | |---------|--------|--------| | Create a subscription server-side | `create(pmId)` | Not supported — use `checkout()`; the webhook creates the local row | | One-off `charge()` | Confirms a PaymentIntent | Not supported — use `checkout({ mode })` / transactions | | SetupIntent / `paymentMethods()` / `billingPortal()` | Supported | Throws `BillingError` (hosted checkout) | | Checkout handle | `session.url` (redirect) | `session.clientToken` (overlay/inline) | | Webhook signature | `Stripe-Signature: t=…,v1=…` | `Paddle-Signature: ts=…;h1=…` | ## Schema The migration is gateway-neutral: `subscriptions` (with `gateway`, `provider_id`, `provider_status`, `provider_price`, trial/grace timestamps), `subscription_items`, and columns on `users` (`billing_gateway`, `billing_customer_id`, `pm_type`, `pm_last_four`, `trial_ends_at`). The default migration targets the standard `users` billable table. ## Testing The package ships a `FakeGateway` — an in-memory gateway that records every call — so you can drive billing without touching a network: ```ts import { BillingManager, setBilling, FakeGateway } from "@shaferllc/keel/billing"; const fake = new FakeGateway(); const manager = new BillingManager(config); // config.default = "fake" manager.register("fake", () => fake); setBilling(manager); await user.newSubscription("default", "price_pro").create(); fake.calls.filter((c) => c.method === "createSubscription"); // assert what was asked ``` --- # Broadcasting Push events to clients in real time over named **channels**. Like the database and mail layers, broadcasting rides a pluggable `Broadcaster`, so the core owns no socket — point it at Pusher/Ably (`fetch`), a Cloudflare Durable Object, or the built-in `MemoryBroadcaster` for tests and single-instance workers. ## Broadcasting an event ```ts import { broadcast } from "@shaferllc/keel/core"; await broadcast("orders.42", "status", { state: "shipped" }); await broadcast(["team.7", "admins"], "deploy", { sha }); // several channels ``` `broadcast(channels, event, payload)` hands the event to the registered broadcaster. Register one at boot: ```ts import { setBroadcaster } from "@shaferllc/keel/core"; setBroadcaster(pusher(env.PUSHER_KEY, env.PUSHER_SECRET)); ``` ## Channel authorization Public channels need nothing. **Private** and **presence** channels are gated: register who may subscribe with `channelAuth`, then have your socket endpoint call `authorizeChannel`. `{param}` segments are captured from the channel name: ```ts import { channelAuth, authorizeChannel } from "@shaferllc/keel/core"; // only the order's owner may subscribe: channelAuth("orders.{orderId}", (user, params) => user.id === Number(params.orderId)); // presence: return member data to join channelAuth("presence.room.{room}", (user, params) => ({ id: user.id, name: user.name })); ``` At the subscription endpoint (the URL your client hits to authorize a channel): ```ts router.post("/broadcasting/auth", async () => { const { channel } = await request.all(); const ok = await authorizeChannel(channel, await auth().user()); if (!ok) response.abort("Forbidden", 403); return json(ok); // `true`, or member data for presence }); ``` Return `false` to deny, `true` to allow, or an object of **member data** to allow *and* join a presence channel. It composes with [`auth()`](./authentication.md) and [authorization](./authorization.md). ## Same-process fan-out `MemoryBroadcaster` also lets you `subscribe` in-process — useful inside a Cloudflare Durable Object (the WebSocket owner) or an SSE loop: ```ts import { MemoryBroadcaster } from "@shaferllc/keel/core"; const bus = new MemoryBroadcaster(); setBroadcaster(bus); const off = bus.subscribe("orders.42", (event, payload) => socket.send(JSON.stringify({ event, payload }))); // … later off(); ``` ## Writing a driver A broadcaster is one method — `publish`. Here's the shape for a Pusher-style HTTP provider over `fetch` (edge-safe): ```ts import type { Broadcaster } from "@shaferllc/keel/core"; const pusher = (url: string, auth: string): Broadcaster => ({ async publish(channels, event, payload) { await fetch(url, { method: "POST", headers: { "content-type": "application/json", authorization: auth }, body: JSON.stringify({ channels, name: event, data: JSON.stringify(payload) }), }); }, }); ``` For Cloudflare, the driver forwards to a Durable Object that owns the WebSockets; the DO uses a `MemoryBroadcaster` internally to fan out to its connected sockets. ## API reference ### `broadcast(channels, event, payload?)` `broadcast(channels: string | string[], event: string, payload?: unknown): Promise` Publish an event to one or more channels via the registered broadcaster. ### `setBroadcaster(instance)` / `getBroadcaster()` Register / read the default `Broadcaster`. ### `MemoryBroadcaster` `class MemoryBroadcaster implements Broadcaster` — in-process pub/sub; the default. `subscribe(channel, cb)` returns an unsubscribe function. ### `channelAuth(pattern, authorizer)` `channelAuth(pattern: string, authorizer: (user, params) => boolean | object | Promise<…>): void` Register an authorizer for a channel pattern. `{param}` segments are captured into `params`. ### `authorizeChannel(channel, user)` `authorizeChannel(channel: string, user: unknown): Promise>` Run the first matching rule (a channel with no rule is public → `true`). Returns `false` (deny), `true` (allow), or member data (presence). ### Interfaces & types #### `Broadcaster` `interface Broadcaster { publish(channels: string[], event: string, payload: unknown): Promise }` #### `ChannelAuthorizer` `type ChannelAuthorizer = (user: unknown, params: Record) => boolean | Record | Promise<…>` #### `Subscriber` `type Subscriber = (event: string, payload: unknown, channel: string) => void` --- # Service Broker Structure an application as **services** that talk to each other by name instead of by import. You register a service — a name plus a bag of `actions` and `events` — with a **broker**, then reach it anywhere with `broker.call("users.get", { id })` or fan an event out with `broker.emit("user.created", user)`. It's a [Moleculer](https://moleculer.services/docs/0.15/broker)-style backbone: actions receive a `Context` and can call *other* actions through it, so a request threads its `meta` (auth, trace ids) all the way down. Like the queue and Redis layers, clustering lives behind a pluggable seam. The default `LocalTransporter` is a single-node no-op, so the core imports no network client and runs on Node and the edge. Swap in a real `Transporter` to span processes — the `call` / `emit` API never changes. ## Defining a service A service is a schema object. Handlers and lifecycle hooks run with `this` bound to the live service, so they can reach `this.settings`, `this.metadata`, `this.broker`, `this.logger`, and any `methods` you define. ```ts import { broker, type Context } from "@shaferllc/keel/core"; broker().createService({ name: "users", settings: { defaultRole: "member" }, metadata: { region: "us-east" }, // descriptive; travels with discovery actions: { async get(this: any, ctx: Context<{ id: number }>) { return { id: ctx.params.id, role: this.settings.defaultRole }; }, async create(ctx: Context<{ email: string }>) { const user = { id: 1, email: ctx.params.email }; await ctx.emit("user.created", user); // inherits ctx.meta return user; }, }, events: { "user.created": (ctx: Context) => { // ctx.params is the event payload }, }, }); ``` ## Calling actions An action is addressed as `"."`. Give a service a `version` and its actions namespace under a `v`-prefix (`v2.users.get`). ```ts const user = await broker().call("users.create", { email: "ada@keel.dev" }); // pass metadata that flows down through nested ctx.call()s await broker().call("users.get", { id: 1 }, { meta: { locale: "en" } }); // bound a call with a timeout (ms); rejects with RequestTimeoutError await broker().call("reports.build", {}, { timeout: 5000 }); ``` Inside an action, use `ctx.call(...)` rather than `broker().call(...)` — it carries the current `meta` (and `requestID`) into the child call automatically. Call several actions at once with `mcall` — pass an array or a keyed map and get the same shape back. With `settled: true` you get a per-call `{ status, value | reason }` instead of failing on the first rejection. ```ts const [a, b] = await broker().mcall([ { action: "users.get", params: { id: 1 } }, { action: "users.get", params: { id: 2 } }, ]); const { profile, posts } = await broker().mcall({ profile: { action: "users.get", params: { id: 1 } }, posts: { action: "posts.byUser", params: { id: 1 } }, }); ``` ### The call context Every handler receives a `Context`. Beyond `params`, `meta`, and `call`, it carries a few request-scoped slots: - **`ctx.meta`** — flows *down* into nested `ctx.call()`s. Put auth, locale, and trace data here. - **`ctx.headers`** — per-call and **transient**: available to this handler and its hooks, but *not* propagated to nested calls. - **`ctx.locals`** — scratch space shared between a call's hooks and its handler (e.g. a hook looks up the current user, the handler reads it back). - **`ctx.requestID`** — one correlation id for the whole request tree; generated once and threaded through every nested call. Pass your own to stitch a call into an existing trace. ```ts await broker().call("reports.build", {}, { headers: { "x-trace": "abc" }, requestID: "req-42", }); ``` Each `ctx.call()` builds a child context linked to its parent, so a handler can see where it sits in the request tree: - **`ctx.id`** — unique per context; **`ctx.parentID`** — the caller's `id` (`null` at the root). - **`ctx.level`** — call depth, `1` at the root and `+1` per nested call. - **`ctx.caller`** — the full name of the service that invoked this call (`null` at the root). - **`ctx.action`** — `{ name }` of the running action (absent in event handlers). - **`ctx.toJSON()`** — a serializable snapshot (ids, level, caller, name, meta) — no functions or live `broker`/`service` refs, so it is safe to log. In an **event** handler the context instead carries **`ctx.eventName`**, **`ctx.eventType`** (`"emit"` or `"broadcast"`), and **`ctx.eventGroups`**. ## Full action definitions An action is a bare handler by default. Swap in an object to attach per-action options — `visibility`, a `timeout`, and `hooks`: ```ts broker().createService({ name: "billing", actions: { // shorthand — a plain handler quote: (ctx: Context) => ({ cents: 999 }), // full form charge: { visibility: "private", // hidden from broker.call — internal only timeout: 3000, // per-action; the call option still overrides it hooks: { before: (ctx) => { /* validate */ }, after: (ctx, res) => res, }, handler: (ctx: Context<{ cents: number }>) => ctx.params.cents, }, }, }); ``` ### Visibility `visibility` controls how far an action reaches: | Value | Reachable via `broker.call` / `ctx.call` | Internally (`this.actions.x`) | | ------------------------ | ---------------------------------------- | ----------------------------- | | `published` *(default)* | yes | yes | | `public` | yes | yes | | `protected` | yes (same node) | yes | | `private` | **no** — throws `ServiceNotFoundError` | yes | A `private` action is invisible to `call`, but a service can still invoke its own private actions through `this.actions.(params)`, which runs the full pipeline (hooks and timeout) while skipping the visibility gate. ```ts actions: { charge: { visibility: "private", handler: (ctx) => /* ... */ }, checkout(this: any, ctx: Context) { return this.actions.charge({ cents: ctx.params.cents }); // ok — internal }, } ``` ## Hooks Hooks wrap action handlers to keep validation, sanitisation, and response shaping out of the handler body. Declare them at the **service** level (keyed by action name) or inline on a single **action**. ```ts broker().createService({ name: "users", hooks: { before: { "*": (ctx) => { /* runs before every action */ }, "create|update": (ctx) => { /* pipe list */ }, remove: (ctx) => { /* exact name */ }, }, after: { get: (ctx, res) => ({ ...res, fetchedAt: Date.now() }), // must return res }, error: { "*": (ctx, err) => { throw err; }, // return a fallback, or re-throw }, }, actions: { /* ... */ }, }); ``` - **before** hooks receive `ctx` and may mutate `ctx.params`, `ctx.meta`, and `ctx.locals`. Their return value is ignored — they can't skip the handler. - **after** hooks receive `(ctx, res)` and **must return** the (possibly transformed) response. - **error** hooks receive `(ctx, err)`. Return a value to recover, or throw to propagate. If several match, each re-throw feeds the next. Keys may be `"*"` (all actions), an exact name, a `"a|b"` pipe list, or a `*` glob (`"get*"`). Ordering matches Moleculer — **before** runs service-wildcard → service-named → action, and **after**/**error** run in reverse (action → service-named → service-wildcard): ``` before: hooks.before["*"] → hooks.before[name] → action.hooks.before → handler after: action.hooks.after → hooks.after[name] → hooks.after["*"] ``` ## Mixins `mixins` fold reusable schemas into a service. Every field is merged by type — `settings`/`metadata` deep-merge, `actions`/`events`/`methods`/`hooks` merge by key, and lifecycle hooks (`created`/`started`/`stopped`/`merged`) *chain* so all of them run (mixins first, then the service). **The service's own schema always wins on conflict.** ```ts const Timestamps = { name: "timestamps", settings: { softDelete: false }, methods: { touch(this: any) { /* ... */ } }, }; broker().createService({ mixins: [Timestamps], name: "articles", settings: { perPage: 10 }, // → { softDelete: false, perPage: 10 } actions: { list: () => [] }, merged(schema) { // fires once, after mixins merge, before the instance is built }, }); ``` When several mixins collide, the **first** in the array wins. ## Dependencies List services a service needs with `dependencies`. During `broker.start()`, a service's `started` hook waits until every dependency is registered. You can also await readiness directly with `waitForServices` (from the broker or `this`): ```ts broker().createService({ name: "api", dependencies: ["db", "cache"], // started() waits for both async started() { await this.waitForServices("mailer", 5000); // optional explicit wait (ms) }, }); ``` ## Events `emit` sends a **balanced** event — each listening *group* receives it once. In a cluster only one instance per group is chosen; locally, with one instance per service, that's every listener. `broadcast` always reaches every listener, and `broadcastLocal` reaches every listener on this node (identical to `broadcast` until a real transporter would otherwise relay across nodes). ```ts await broker().emit("user.created", user); // balanced await broker().broadcast("cache.flush"); // everyone await broker().broadcastLocal("cache.warm"); // everyone on this node broker().hasEventListener("user.created"); // boolean ``` ### Groups Every listener belongs to a **group** — its service name by default, or whatever `group` you set on the listener. `emit` delivers to one listener per group; pass `groups` to target specific ones: ```ts broker().createService({ name: "mailer", events: { "user.created": { group: "notify", handler: (ctx) => {} } }, }); await broker().emit("user.created", user, { groups: ["notify"] }); ``` ### Patterns Subscription keys may glob: `*` matches one segment (`user.*`), `**` any depth (`user.**`), and `?` a single non-dot character (`user.??eated`). ### Internal events The broker emits its own lifecycle events, which any service can subscribe to: - **`$broker.started`** / **`$broker.stopped`** — after `start()` / before `stop()`. - **`$services.changed`** — when a service is created or destroyed; the payload is `{ service }`. ```ts broker().createService({ name: "registry", events: { "$services.changed": (ctx) => { // ctx.params.service changed }, }, }); ``` ## Lifecycle Nothing runs actions across the network until you `start()`. Hooks fire in order: `created` when the service is registered, `started` on `broker.start()`, and `stopped` on `broker.stop()` (reverse order). ```ts const b = broker(); b.createService({ name: "clock", async started() { this.timer = setInterval(() => this.broker.broadcast("tick"), 1000); }, async stopped() { clearInterval(this.timer); }, }); await b.start(); // ... await b.stop(); ``` ## Clustering The default broker is single-node. To span processes, implement `Transporter` and pass it in — the broker calls `connect` on start and `disconnect` on stop, and a real transporter registers remote services and relays calls/events: ```ts import { Broker, setBroker, type Transporter } from "@shaferllc/keel/core"; const nats: Transporter = { async connect(broker) { /* subscribe, register remote endpoints */ }, async disconnect() { /* close */ }, }; setBroker(new Broker({ nodeID: "api-1", transporter: nats, requestTimeout: 10_000 })); ``` `broker()` returns the default instance (a fresh single-node `Broker`); `setBroker()` replaces it, exactly as `redis()` / `setRedis()` work. ## Middlewares Broker middlewares wrap every action call and tap broker lifecycle — the place for cross-cutting concerns (logging, metrics, caching, auth) that apply to all services. A middleware's `localAction` receives the next handler and returns a replacement, so they compose (the first in the array is the outermost): ```ts import { Broker, type BrokerMiddleware } from "@shaferllc/keel/core"; const timing: BrokerMiddleware = { name: "timing", localAction(next, action) { return async (ctx) => { const start = performance.now(); try { return await next(ctx); } finally { logger().debug("action", { action, ms: performance.now() - start }); } }; }, started(broker) { logger().info("broker up", { nodeID: broker.nodeID }); }, stopped() { /* flush metrics, close connections */ }, }; const broker = new Broker({ middlewares: [timing] }); ``` `localAction(next, action)` wraps the handler (action = the full action name); `started(broker)` / `stopped(broker)` run during `broker.start()` / `stop()` (stopped in reverse order). A middleware that omits `localAction` leaves calls untouched — handy for a lifecycle-only middleware. ## Fault tolerance A call can be made resilient with per-call options (or broker-wide defaults): ```ts // retry up to 3 times, then fall back to a cached value await broker.call("orders.get", { id }, { retries: 3, fallback: { id, status: "unknown" }, }); // timeout + a computed fallback await broker.call("pricing.quote", cart, { timeout: 500, fallback: (err: Error) => ({ error: err.message, price: null }), }); const broker = new Broker({ requestTimeout: 1000, retries: 2 }); // defaults for every call ``` - **`retries`** — total attempts are `retries + 1`; the whole call re-runs on failure. Defaults to `BrokerOptions.retries`. - **`fallback`** — a value, or `(err, ctx) => value`, returned once every attempt (and any `error` hooks) has failed — instead of throwing. - **`timeout`** — ms before a `RequestTimeoutError` (per call, per action, or the broker default). Order: retry → error hooks → fallback → throw. ## Registry introspection The broker's registry is queryable: ```ts broker.hasAction("users.find"); // boolean (private actions read as absent) broker.listActions(); // ["orders.get", "users.find", …] (public, sorted) broker.listServices(); // ["orders", "users", …] broker.getService("users"); // the Service instance, or undefined ``` ## Networking & balancing The broker is **single-node** by default (`LocalTransporter`). Clustering across nodes is the `Transporter` seam — implement `Transporter` for NATS, Redis, or TCP and pass it as `transporter`. With a single node there's one endpoint per action, so cross-node **load balancing** doesn't apply; event **group** balancing (one listener per group) works today via `emit(event, payload, { groups })`. ## Validating params Give an action a `params` schema and it's validated (and coerced) before the handler runs — a bad call rejects with a `ValidationException`, so the handler only ever sees valid input: ```ts import { z } from "zod"; broker.createService({ name: "users", actions: { create: { params: z.object({ email: z.string().email(), age: z.coerce.number().min(18) }), handler: (ctx) => createUser(ctx.params), // params typed + validated }, }, }); ``` Any [Zod-style schema](./validation.md) works — the broker bundles no validator. ## Caching action results Mark an action `cache` and give the broker a `cacher` (any Keel [`Cache`](./cache.md) — memory, or Redis via `redisStore()`), and results are memoized by action + params: ```ts import { Cache } from "@shaferllc/keel/core"; const broker = new Broker({ cacher: new Cache() }); broker.createService({ name: "stats", actions: { daily: { cache: { ttl: 300, keys: ["day"] }, // 5 min; key on the `day` param only handler: (ctx) => computeDaily(ctx.params.day), }, }, }); ``` `cache: true` caches forever keyed on all params; `{ ttl }` sets a TTL (seconds); `{ keys }` limits the cache key to those params. With no `cacher`, `cache` is a no-op. ## Metrics, tracing, errors & runner - **Metrics & tracing** — the [middleware](#middlewares) `localAction` seam is the hook: wrap every call to time it, count it, or open a span. Every context already carries the trace fields (`ctx.requestID`, `ctx.parentID`, `ctx.level`, `ctx.caller`) a span exporter needs. - **Errors** — the broker throws typed errors (`ServiceNotFoundError`, `RequestTimeoutError`, and `ValidationException` from `params`); define your own with [`createError`](./errors.md). - **Runner** — no separate runner binary: register services with `createService()` (loop over a folder of schemas) and call `broker.start()` from your app's boot or a [service provider](./providers.md). --- # Cache A small cache with TTLs and the `remember` pattern. Memory-backed by default (per-process, or per-isolate on the edge), with a pluggable store so you can swap in Redis, KV, or anything else. Reach it with the global `cache()` helper. ## Basics ```ts import { cache } from "@shaferllc/keel/core"; await cache().put("user:1", user); // forever await cache().put("otp", code, 300); // expires in 300s await cache().add("otp", code, 300); // write only if absent → boolean await cache().get("user:1"); await cache().get("missing", fallback); await cache().has("otp"); await cache().missing("otp"); // the inverse of has await cache().forget("otp"); await cache().forgetMany(["otp", "user:1"]); // forget several await cache().pull("otp"); // get + forget await cache().flush(); // clear everything ``` `put` takes a TTL in **seconds** (converted to milliseconds for the store); omit it to cache forever. `get` returns `undefined` on a miss unless you pass a fallback, in which case the fallback comes back instead — it's only returned, never written to the cache. `add` writes only when the key is absent and returns whether it did — a lightweight "claim this key" for one-shot work. ## remember The common pattern — return the cached value, or compute, cache, and return it: ```ts const stats = await cache().remember("dashboard.stats", 60, async () => { return computeExpensiveStats(); // runs only on a cache miss }); const config = await cache().rememberForever("app.config", () => loadConfig()); ``` The factory runs **only on a miss**. On a hit the cached value is returned and the factory is never called, so it's the right place for an expensive query, an upstream API call, or anything you'd rather do once per TTL window. The factory may be sync or async — both are awaited. ### Stampede protection When a hot key expires, many requests can hit the miss at once and each run the factory — a "cache stampede" that hammers the thing you were trying to protect. `remember` guards against this automatically: **concurrent calls for the same key share a single factory run** and all receive its result. You don't opt in; it's just how `remember` and `rememberForever` behave. ```ts // 100 concurrent requests, one cold key → the query runs ONCE. await Promise.all( requests.map(() => cache().remember("report", 300, runExpensiveReport)), ); ``` This is per-isolate (no cross-node lock), which matches keel's single-store model — it collapses the dog-pile within a process/worker, the case that actually melts a server. ### Grace: serve stale on error Pass a `grace` window (seconds) and an expired value is **retained past its TTL** and served if the refreshing factory throws. A flaky upstream then degrades to slightly-stale data instead of a hard error: ```ts const rates = await cache().remember("fx.rates", 60, fetchRates, { grace: 3600 }); // For up to an hour after the 60s TTL lapses, if fetchRates() throws the last // good rates are returned. A successful refresh replaces them and resets the window. ``` Grace only rescues a *failing* refresh — a normal `get()` on an expired key still reports a miss, so stale data never leaks through the plain read path. If the factory succeeds, the fresh value is cached and the grace window restarts. ## Read-through, then invalidate `pull` reads and forgets in one step — handy for one-shot values like a password-reset token or a flash message you want to survive exactly one read: ```ts const token = await cache().pull("reset:jane", ""); // read, then delete ``` Pair `remember` with `forget` to invalidate a derived value when its inputs change: ```ts await db("users").where("id", id).update({ name }); await cache().forget(`user:${id}`); // next read recomputes ``` ## Tags When one change should invalidate a *group* of unrelated keys, tag them and drop the whole group with `deleteByTag`. Pass `tags` on any write: ```ts await cache().put("post:1", post, 600, { tags: ["posts"] }); await cache().remember("feed:home", 300, buildFeed, { tags: ["posts"] }); await cache().put("post:2", post, 600, { tags: ["posts", "featured"] }); // A new post lands — blow away everything tagged "posts" in one call: await cache().deleteByTag(["posts"]); ``` `deleteByTag` is **O(number of tags)**, not O(number of keys): each tag carries a version counter, every entry records the counter it was written at, and `deleteByTag` just bumps it — so any entry on the old version reads as a miss on its next access. There's no key scan and nothing to clean up; invalidated entries fall out on their own TTL. Because it's a hard invalidation, a tag-dropped entry is **not** grace-eligible — `remember` recomputes it rather than serving it stale. ## Namespaces `namespace(prefix)` returns a cache scoped under a key prefix. Keys written through it live at `prefix:key`, so two namespaces can reuse the same logical key without colliding — and `flush()` on a namespace clears **only** that namespace, leaving the rest of the store intact: ```ts const users = cache().namespace("users"); const posts = cache().namespace("posts"); await users.put("1", user); // stored at "users:1" await posts.put("1", post); // stored at "posts:1" — no collision await users.flush(); // clears the users namespace only await posts.get("1"); // still there ``` Namespaces nest (`cache().namespace("org").namespace("team")`) and carry the full API — `remember`, `grace`, `tags`, everything. Scoped `flush()` uses the same version-stamp trick as tags (a namespace is an implicit tag), so it's O(1) and needs no key scanning — the deliberate trade-off is that flushed entries are invalidated rather than physically removed, and expire on their TTL. ## TTLs and expiry TTLs are lazy in the memory store: an expired entry isn't purged on a timer, it's dropped the next time you `get` (or `has`) it. So an untouched expired key still occupies memory until it's read again or you `flush()`. A `ttlSeconds` of `0` (or omitted) means no expiry — the entry lives until it's forgotten or flushed. ```ts await cache().put("otp", code, 300); // gone 300s after this write await cache().put("app.config", cfg); // no TTL — lives until forgotten ``` ## Shipped stores The default is in-memory — per process, per isolate, gone on restart. Three shared stores come in the box; bind the `Cache` you want in a provider: ```ts import { Cache, singleton, DatabaseStore, cacheMigration, kvStore, redisStore } from "@shaferllc/keel/core"; // Rows — works anywhere a Connection does. Add cacheMigration() to your migrations. singleton(Cache, () => new Cache(new DatabaseStore())); // Cloudflare KV — the shared cache for Workers. singleton(Cache, () => new Cache(kvStore(env.CACHE))); // Redis — over the redis() client (see the redis guide). singleton(Cache, () => new Cache(redisStore())); ``` The database store skips (and drops) expired rows on read; call its `prune()` from a [scheduled task](./scheduling.md) to sweep the ones nothing reads again. KV enforces a 60-second minimum TTL — shorter-lived entries carry their real expiry inside the envelope, so rounding up never serves a stale value, it only delays garbage collection. ## Custom stores To persist elsewhere, implement `CacheStore` and bind your own `Cache` in a provider: ```ts import { Cache, singleton, type CacheStore } from "@shaferllc/keel/core"; class MyStore implements CacheStore { async get(key: string) { /* … */ } async set(key: string, value: unknown, ttlMs?: number) { /* … */ } async delete(key: string) { /* … */ } async clear() { /* … */ } } singleton(Cache, () => new Cache(new MyStore())); ``` The store speaks **milliseconds** (`ttlMs`), while the `Cache` façade takes seconds — `Cache` does the conversion, so your store never sees the seconds unit. Every `CacheStore` method may return a value or a promise; `Cache` awaits both, so a synchronous in-memory store and an async network store are interchangeable behind the same API. ## Notes - The in-memory store is ephemeral: it clears on restart and isn't shared across processes or Worker isolates. Use a custom store for anything durable or shared. - Every `Cache` method is async, so the same code works whether the store is in-memory or over the network. - Cache keys are plain strings — namespace them yourself (`user:1`, `dashboard.stats`) to avoid collisions. ## Related `cache()` resolves the `Cache` singleton out of the application container, the same way `config()` and `logger()` reach their services. --- ## API reference ### `cache()` `cache(): Cache` Resolves the application's `Cache` singleton from the container — the global entry point used everywhere else on this page. ```ts import { cache } from "@shaferllc/keel/core"; await cache().put("user:1", user); ``` **Notes:** throws if no `Application` has been bootstrapped (it goes through `app()` internally). The instance is a singleton, so every call returns the same `Cache` — bind a replacement with `singleton(Cache, …)` to swap the store. ### `Cache` The cache façade. Construct it with a `CacheStore` (defaults to `MemoryStore`), or reach the app-bound instance with `cache()`. Every method is async and awaits the underlying store. ```ts import { Cache, MemoryStore } from "@shaferllc/keel/core"; const c = new Cache(); // MemoryStore by default const r = new Cache(new MemoryStore()); // explicit store ``` #### `get(key, fallback?)` `get(key: string, fallback?: T): Promise` Reads a value, returning `fallback` (or `undefined`) when the key is missing. ```ts const user = await cache().get("user:1"); const port = await cache().get("app.port", 3000); // 3000 on a miss ``` **Notes:** a miss is detected by `=== undefined`, so a stored `null`, `0`, `""`, or `false` counts as a hit and is returned as-is. The `fallback` is only returned, never written back to the cache. The type parameter `T` is a compile-time convenience — the value isn't validated at runtime. #### `put(key, value, ttlSeconds?, options?)` `put(key: string, value: unknown, ttlSeconds?: number, options?: PutOptions): Promise` Stores a value, optionally expiring it after `ttlSeconds` and joining it to `options.tags`. ```ts await cache().put("otp", code, 300); // expires in 5 minutes await cache().put("user:1", user); // no TTL — cached forever await cache().put("post:1", post, 600, { tags: ["posts"] }); // tagged ``` **Notes:** `ttlSeconds` is **seconds** and is converted to milliseconds for the store. Omitting it (or passing `0`) means no expiry. Overwrites any existing value at `key`. `options.tags` associates the entry with those tags for `deleteByTag`. #### `add(key, value, ttlSeconds?, options?)` `add(key: string, value: unknown, ttlSeconds?: number, options?: PutOptions): Promise` Stores a value **only if the key is absent**, returning `true` when it wrote and `false` when the key already existed. ```ts if (await cache().add("job:lock", 1, 30)) { await runJobOnce(); // we claimed the key } ``` **Notes:** a best-effort "claim" — a read-then-write, not an atomic compare-and-set (keel has no lock driver), so treat it as coordination within one isolate, not a distributed mutex. Accepts the same `{ tags }` option as `put`. #### `has(key)` `has(key: string): Promise` `true` when a live (non-expired) value exists at `key`. ```ts if (await cache().has("otp")) { /* still valid */ } ``` **Notes:** reads through the store, so in the memory store it also triggers the lazy purge of an expired entry. A stored `undefined` reads as absent. #### `missing(key)` `missing(key: string): Promise` The inverse of `has` — `true` when the key is absent or expired. ```ts if (await cache().missing("profile:1")) await warmProfile(1); ``` #### `forget(key)` `forget(key: string): Promise` Removes a single key. ```ts await cache().forget("user:1"); // next read recomputes ``` **Notes:** a no-op if the key isn't present — never throws on a miss. #### `forgetMany(keys)` `forgetMany(keys: string[]): Promise` Removes several keys at once. ```ts await cache().forgetMany(["user:1", "user:1:posts", "user:1:stats"]); ``` **Notes:** deletes run concurrently; missing keys are skipped harmlessly. #### `pull(key, fallback?)` `pull(key: string, fallback?: T): Promise` Reads a value and forgets it in one step — a `get` followed by a `forget`. ```ts const token = await cache().pull("reset:jane", ""); ``` **Notes:** returns `fallback` (or `undefined`) on a miss, then still calls `forget` (harmless). Use it for single-use values like one-time tokens or flash messages. #### `flush()` `flush(): Promise` Clears the cache. On the root cache this wipes the whole store; on a [namespace](#namespacename) it clears only that namespace. ```ts await cache().flush(); // everything await cache().namespace("users").flush(); // just the users namespace ``` **Notes:** the root delegates to the store's `clear()` — wipes every key, not just the ones you set through this `Cache`. In a shared store that's every consumer's keys. A namespace flush is a scoped invalidation (version bump), so entries are logically gone but reclaimed on their TTL. #### `deleteByTag(tags)` `deleteByTag(tags: string[]): Promise` Invalidates every entry tagged with any of `tags` (via `put`/`add`/`remember`'s `{ tags }` option). ```ts await cache().put("post:1", post, 600, { tags: ["posts"] }); await cache().deleteByTag(["posts"]); // post:1 (and any other "posts" entry) gone ``` **Notes:** O(number of tags) — bumps a per-tag version counter, so entries on the old version read as a miss; no key scan. A hard invalidation, so tag-dropped entries are **not** grace-eligible. Invalidated entries occupy space until their TTL evicts them. #### `namespace(name)` `namespace(name: string): Cache` Returns a cache scoped under the `name:` key prefix, sharing the same store. ```ts const users = cache().namespace("users"); await users.put("1", user); // stored at "users:1" await users.flush(); // clears only this namespace ``` **Notes:** carries the full `Cache` API (`remember`, `grace`, `tags`, …) and nests (`namespace("a").namespace("b")` → prefix `a:b:`). Scoped `flush()` uses the same version-stamp mechanism as tags, so it's O(1) with no key scan. #### `remember(key, ttlSeconds, factory, options?)` `remember(key: string, ttlSeconds: number, factory: () => T | Promise, options?: RememberOptions): Promise` Returns the cached value, or runs `factory`, caches its result for `ttlSeconds`, and returns it. **Stampede-protected**: concurrent calls for the same cold key share one factory run. ```ts const stats = await cache().remember("dashboard.stats", 60, () => computeExpensiveStats(), ); // With grace: serve the last good value for up to an hour if a refresh throws. const rates = await cache().remember("fx.rates", 60, fetchRates, { grace: 3600 }); // With tags: invalidate later via deleteByTag(["feeds"]). const feed = await cache().remember("feed:home", 300, buildFeed, { tags: ["feeds"] }); ``` **Notes:** `factory` runs **only on a miss** and may be sync or async (both are awaited). A stored `undefined` is treated as a miss, so `factory` re-runs. The `ttlSeconds` argument is required here (unlike `put`); use `rememberForever` for no expiry. `options.grace` (seconds) retains an expired value that much longer and returns it if the refreshing `factory` throws — a normal `get` still reports the expired key as a miss, so stale data never leaks through the plain read path. `options.tags` joins the cached value to those tags for `deleteByTag`. A failing factory **without** grace rejects and is not cached. #### `rememberForever(key, factory, options?)` `rememberForever(key: string, factory: () => T | Promise, options?: PutOptions): Promise` Like `remember`, but caches with no TTL. Also stampede-protected, and accepts `{ tags }`. ```ts const config = await cache().rememberForever("app.config", () => loadConfig()); ``` **Notes:** same miss semantics as `remember` — `factory` runs once, then the value is served until it's forgotten or flushed. No TTL means grace doesn't apply (there's nothing to expire). ### `MemoryStore` The default `CacheStore` — an in-process `Map` with lazy TTL expiry. Used automatically when you construct a `Cache` with no store; construct it directly only to pass it explicitly or to inspect it in tests. ```ts import { Cache, MemoryStore } from "@shaferllc/keel/core"; const c = new Cache(new MemoryStore()); ``` #### `get(key)` `get(key: string): unknown` Returns the stored value, or `undefined` if absent or expired. ```ts const store = new MemoryStore(); store.set("k", 1, 1000); store.get("k"); // 1 ``` **Notes:** synchronous. Expiry is checked on read — an expired entry is deleted in-line and returns `undefined`, so `get` is what actually purges stale keys. #### `set(key, value, ttlMs?)` `set(key: string, value: unknown, ttlMs?: number): void` Stores a value with an optional TTL in **milliseconds**. ```ts store.set("otp", code, 300_000); // 5 minutes store.set("cfg", config); // no expiry ``` **Notes:** synchronous, and takes `ttlMs` (milliseconds), not seconds — the `Cache` façade does the seconds→ms conversion before calling this. Omitting `ttlMs` (or `0`) stores with `expires: 0`, meaning no expiry. #### `delete(key)` `delete(key: string): void` Removes a single key. Synchronous; a no-op if absent. ```ts store.delete("otp"); ``` #### `clear()` `clear(): void` Empties the whole map. Synchronous. ```ts store.clear(); ``` ### Interfaces & types #### `CacheStore` ```ts interface CacheStore { get(key: string): Promise | unknown; set(key: string, value: unknown, ttlMs?: number): Promise | void; delete(key: string): Promise | void; clear(): Promise | void; } ``` The seam between `Cache` and its backing store. Implement it to persist elsewhere (Redis, Cloudflare KV, a database) and bind a `Cache` around it. Each method may return synchronously or as a promise — `Cache` awaits either, so a plain in-memory map and an async network client satisfy the same interface. ```ts import { Cache, singleton, type CacheStore } from "@shaferllc/keel/core"; class KVStore implements CacheStore { constructor(private kv: KV) {} async get(key: string) { return (await this.kv.get(key)) ?? undefined; } async set(key: string, value: unknown, ttlMs?: number) { await this.kv.put(key, JSON.stringify(value), ttlMs); } async delete(key: string) { await this.kv.delete(key); } async clear() { /* KV has no bulk clear — list + delete, or skip */ } } singleton(Cache, () => new Cache(new KVStore(kv))); ``` **Notes:** TTLs reach your store in **milliseconds** (`ttlMs`). A missing key must resolve to `undefined` — that's how `Cache` distinguishes a miss from a stored value in `get`, `has`, `pull`, and the `remember` family. `Cache` writes an opaque envelope (value + expiry + tag stamps) as the store value — treat stored values as blobs to round-trip, not to read directly. #### `PutOptions` / `RememberOptions` ```ts interface PutOptions { tags?: string[]; // associate the entry with tags, for deleteByTag } interface RememberOptions extends PutOptions { grace?: number; // seconds to retain an expired value for stale-on-error } ``` `PutOptions` is the trailing options bag on `put`/`add`/`rememberForever`; `RememberOptions` adds `grace` for `remember`. Both are optional. ```ts await cache().put("post:1", post, 600, { tags: ["posts"] }); await cache().remember("feed", 300, build, { grace: 60, tags: ["posts"] }); ``` --- # The Console Keel ships with a console for running the server and generating code. The binary is `bin/keel.ts`; npm scripts wrap it with `tsx`. ```bash npm run keel [args] # e.g. npm run keel routes ``` You can also invoke it directly: `npx tsx bin/keel.ts `. Every command boots the full application first — the same container, config, and providers your HTTP requests get. The `serve` and `routes` commands use that booted app; the `make:*` generators don't need it, they just stamp files onto disk. Commands are wired up with [commander](https://github.com/tj/commander.js) in [`src/core/cli/index.ts`](../src/core/cli/index.ts), and the code-generation templates live in [`src/core/cli/stubs.ts`](../src/core/cli/stubs.ts). ## Command reference | Command | Argument | Generates / does | | --- | --- | --- | | `serve` | `--port ` | Start the HTTP server | | `routes` | — | List every registered route | | `make:controller` | `` `[-r]` | `app/Controllers/Controller.ts` | | `make:model` | `` `[-m] [-f] [-c]` | `app/Models/.ts` (+ migration, factory, controller) | | `make:migration` | `` `[--create ] [--table ]` | `database/migrations/_.ts` | | `make:provider` | `` | `app/Providers/ServiceProvider.ts` | | `make:middleware` | `` | `app/Http/Middleware/Middleware.ts` | | `make:factory` | `` | `database/factories/Factory.ts` | | `make:seeder` | `` | `database/seeders/Seeder.ts` | | `make:job` | `` | `app/Jobs/Job.ts` | | `make:notification` | `` | `app/Notifications/Notification.ts` | | `make:transformer` | `` `[-m ]` | `app/Transformers/Transformer.ts` | | `queue:work` | `[--once] [--sleep ]` | Process jobs on the default queue | | `queue:failed` | — | List jobs that exhausted their retries | | `queue:retry` | `` | Put a failed job back on the queue | | `queue:flush` | `[id]` | Delete failed jobs — one, or all of them | | `migrate` | `[--seed]` | Run pending [migrations](./migrations.md) | | `migrate:status` | — | Show which migrations have run and which are pending | | `migrate:rollback` | — | Roll back the most recent batch | | `migrate:reset` | `[--force]` | Roll back every migration | | `migrate:refresh` | `[--seed] [--force]` | Roll everything back, then migrate again | | `migrate:fresh` | `[--seed] [--force]` | Drop every table, then migrate from scratch | | `db:seed` | `[-c ]` | Run a [seeder](./factories.md#seeders) (default `DatabaseSeeder`) | | `search:index` | `` `[--chunk ]` | Rebuild a model's [search index](./search.md) | | `search:flush` | `` | Empty a model's search index | | `vendor:publish` | `[--tag ] [--force]` | Copy package-published files into the app | | `kit:sync` | `[-p ] [--force]` | Refresh untouched starter-kit files | | `mcp` | — | Start the [MCP server](./ai.md) for AI agents (stdio) | Every generator normalizes the name you pass and refuses to overwrite an existing file (see [Generator safety](#generator-safety)). ## Runtime commands ### `serve` Start the HTTP server. ```bash npm run keel serve npm run keel serve --port 8080 # override the port ``` `serve` builds the [`HttpKernel`](./controllers.md) (reusing a container-bound one if you've registered your own, otherwise constructing a fresh one), hands its Hono app to `@hono/node-server`, and listens. On boot it prints: ``` ⚓ Keel listening on http://localhost:3000 ``` The port resolves in this order: the `--port` flag, then `config('app.port')` (from the `APP_PORT` env var), then `3000`. The app name in the banner comes from `config('app.name')`, defaulting to `Keel`. For a watch-mode dev server that restarts on change, use `npm run dev` (which is `serve` under `tsx watch`). ### `routes` List every registered route, its method(s), and its handler. ```bash npm run keel routes ``` ``` GET / HomeController@index GET /users/:id HomeController@show (users.show) GET|POST /form Closure GET /favicon.ico Static ``` Each row is `methods`, `path`, then the resolved handler. The handler column reflects how the route was registered: - **`Controller@method`** — a `[Controller, "method"]` handler tuple. - **`Closure`** — an inline function handler. - **`Static`** — a pre-built `Response` served directly. A trailing `(name)` appears for [named routes](./routing.md). Multiple verbs on one path are joined with `|`. If nothing is registered, it prints `No routes registered.` instead. ### `mcp` Start the Model Context Protocol server over stdio, exposing Keel's docs, public API, and generators to AI agents: ```bash npm run keel mcp # or the shipped `keel-mcp` bin in a consuming app ``` See [Building with AI](./ai.md) for how to connect it to Claude Code, Cursor, or any MCP client, and the tools it provides. ### Queue commands Run and operate the [queue](./queues.md) from the console: ```bash npm run keel queue:work # poll for jobs every 3s (Ctrl-C to stop) npm run keel queue:work -- --once # drain what's due and exit (cron-friendly) npm run keel queue:failed # list jobs that exhausted their retries npm run keel queue:retry 42 # back on the queue (or: queue:retry all) npm run keel queue:flush # delete failed jobs (or one: queue:flush 42) ``` `queue:work` drives whatever driver `setQueue()` registered at boot; `queue:failed` / `queue:retry` / `queue:flush` need a driver that persists failures (`DatabaseDriver`, `RedisDriver`, or anything implementing `FailedJobStore`) — with the in-memory drivers, `queue:failed` still lists what the process has seen, but there is nothing durable to retry. ### Database commands Run [migrations](./migrations.md) and [seeders](./factories.md#seeders): ```bash npm run keel migrate # run what's pending npm run keel migrate -- --seed # …then run DatabaseSeeder npm run keel migrate:status # which have run, which haven't npm run keel migrate:rollback # undo the last batch npm run keel migrate:reset # undo every batch npm run keel migrate:refresh --seed # reset, migrate, seed npm run keel migrate:fresh --seed # drop every table, migrate, seed npm run keel db:seed # run database/seeders/DatabaseSeeder.ts npm run keel db:seed -- -c User # run UserSeeder instead ``` They all need a registered connection (`setConnection()`), and pick up migrations from `database/migrations/` plus any a [package](./packages.md) contributed. `db:seed` finds a seeder by class name — `-c User` resolves `UserSeeder` from whichever module in `database/seeders/` exports it, so the file doesn't have to be named for the class. > **The destructive ones are guarded.** `migrate:reset`, `migrate:refresh`, and > `migrate:fresh` refuse to run when `NODE_ENV` (or `APP_ENV`) is `production` > unless you pass `--force`. Wiping a development database is the point; wiping > production never is. `migrate:refresh` rolls back through your `down()` methods and migrates up again. `migrate:fresh` doesn't call them at all — it drops every table and starts over, which is what you want when a `down()` is wrong, missing, or refers to a table a half-applied migration never created. ### Search commands Rebuild a model's [search index](./search.md) from its table: ```bash npm run keel search:index Post # flush, then index every row npm run keel search:index Post -- --chunk 1000 npm run keel search:flush Post # empty the index ``` `search:index` is a rebuild, not a top-up — it flushes first, so rows deleted behind the index's back don't linger. Models are found by class name in `app/Models/`, whichever module exports them. ## Generators Each `make:*` command normalizes the name you give it and writes a single file. Name normalization is suffix-aware and case-insensitive: it strips a trailing suffix if present, PascalCases the base, then re-appends the canonical suffix. So `Post`, `post`, and `PostController` all yield `PostController` — you can pass whichever form reads naturally. Generated stubs import their base classes and types from `@shaferllc/keel/core` — the published package's core entry point — so they resolve out of the box in a project that has `@shaferllc/keel` installed. ### `make:controller` Generate a controller in `app/Controllers/`. ```bash npm run keel make:controller Post # -> app/Controllers/PostController.ts ``` The name is normalized: `Post`, `post`, and `PostController` all produce `PostController`. The default stub is a single `index` action: ```ts import type { Ctx } from "@shaferllc/keel/core"; export class PostController { index(c: Ctx) { return c.json({ controller: "PostController", action: "index" }); } } ``` Pass `-r` / `--resource` for a full RESTful resource controller with the seven standard actions (`index`, `create`, `store`, `show`, `edit`, `update`, `destroy`): ```bash npm run keel make:controller Post --resource # -> app/Controllers/PostController.ts ``` ```ts import type { Ctx } from "@shaferllc/keel/core"; export class PostController { index(c: Ctx) { return c.json({ action: "index" }); } create(c: Ctx) { return c.json({ action: "create" }); } // ...store, show, edit, update, destroy } ``` Wire it up with `Route.resource(...)` — see [Controllers](./controllers.md). ### `make:model` Generate an active-record model in `app/Models/`. The table name follows from the class: `Post` → `posts`, `UserProfile` → `user_profiles`. ```bash npm run keel make:model Post # -> app/Models/Post.ts ``` ```ts import { Model } from "@shaferllc/keel/core"; export class Post extends Model { static override table = "posts"; static override fillable = []; static override timestamps = true; declare id: number; } ``` A model rarely travels alone — flags scaffold its companions in one command: ```bash npm run keel make:model Post -- -m -f -c # -> app/Models/Post.ts # -> database/migrations/0004_create_posts.ts (-m: a create-table migration) # -> database/factories/PostFactory.ts (-f: a factory) # -> app/Controllers/PostController.ts (-c: a resource controller) ``` ### `make:migration` Generate a migration in `database/migrations/`, numbered to continue whatever sequence is already there (`0001_…`, `0002_…`). ```bash npm run keel make:migration create_posts # -> database/migrations/0004_create_posts.ts ``` The stub is shaped by what the name says it does: `create_posts` gets a full `createTable`/`dropTable` pair for `posts`; `add_slug_to_posts` gets `alterTable` both ways; anything else is left open. The `--create ` and `--table
` flags override the inference. ### `make:provider` Generate a service provider in `app/Providers/`. ```bash npm run keel make:provider Billing # -> app/Providers/BillingServiceProvider.ts ``` ```ts import { ServiceProvider } from "@shaferllc/keel/core"; export class BillingServiceProvider extends ServiceProvider { register(): void { // Bind services into the container here. } boot(): void { // Resolve and wire things up here. } } ``` Remember to add it to `bootstrap/providers.ts` — generation doesn't register it for you. See [Service Providers](./providers.md). ### `make:middleware` Generate an HTTP middleware in `app/Http/Middleware/`. ```bash npm run keel make:middleware Auth # -> app/Http/Middleware/authMiddleware.ts ``` The class name is normalized to `AuthMiddleware`, but the **file** and the exported constant are camelCased (`authMiddleware`). The stub is a Hono `MiddlewareHandler` with before/after seams around `next()`: ```ts import type { MiddlewareHandler } from "hono"; export const authMiddleware: MiddlewareHandler = async (c, next) => { // ...before await next(); // ...after }; ``` This is the one stub that imports from `hono` rather than the Keel core. See [Middleware](./middleware.md). ### `make:factory` Generate a model factory in `database/factories/`. ```bash npm run keel make:factory User # -> database/factories/UserFactory.ts ``` `make:factory` takes a **model** name (no suffix stripped) and generates a `Factory.ts`. The stub imports the model and exports a lowercase-named factory built with the `factory()` helper: ```ts import { factory } from "@shaferllc/keel/core"; import { User } from "../../app/Models/User.js"; export const userFactory = factory(User, (f) => ({ // Describe one User's attributes; `f` is a Faker. name: f.name(), email: f.email(), })); ``` It assumes a matching model at `app/Models/.ts` — create that first. See [Factories & Seeders](./factories.md). ### `make:seeder` Generate a database seeder in `database/seeders/`. ```bash npm run keel make:seeder Database # -> database/seeders/DatabaseSeeder.ts ``` ```ts import { Seeder } from "@shaferllc/keel/core"; export class DatabaseSeeder extends Seeder { async run(): Promise { // Populate the database, e.g.: // await userFactory.count(10).create(); } } ``` See [Factories & Seeders](./factories.md) for running them. ### `make:job` Generate a queued job in `app/Jobs/`. ```bash npm run keel make:job SendWelcome # -> app/Jobs/SendWelcomeJob.ts ``` ```ts import { Job } from "@shaferllc/keel/core"; export class SendWelcomeJob extends Job { constructor(/* pass the data this job needs */) { super(); } async handle(): Promise { // Do the background work here. } } ``` The `handle()` method holds the work; the constructor takes whatever data the job needs to carry onto the queue. See [Queues & Jobs](./queues.md) for dispatching them. ### `make:notification` Generate a notification in `app/Notifications/`. ```bash npm run keel make:notification InvoicePaid # -> app/Notifications/InvoicePaidNotification.ts ``` ```ts import { Notification, type Notifiable, type MailContent } from "@shaferllc/keel/core"; export class InvoicePaidNotification extends Notification { via(_notifiable: Notifiable): string[] { return ["mail"]; } toMail(_notifiable: Notifiable): MailContent { return { subject: "InvoicePaidNotification", text: "Notification body.", }; } } ``` `via()` returns the channels to deliver on; `toMail()` builds the message for the mail channel. See [Notifications](./notifications.md) for sending them. ## Generator safety Generators never clobber your work. Before writing, each one checks whether the target file already exists; if it does, it prints an error, sets a non-zero exit code, and writes nothing: ``` ✗ Controller already exists: app/Controllers/PostController.ts ``` Only when the path is free does it create any missing parent directories and write the stub, confirming with: ``` ✓ Created Controller: app/Controllers/PostController.ts ``` Delete the existing file first if you truly mean to regenerate it. ## Your console entry point The console ships in the package, and takes your application factory: ```ts #!/usr/bin/env tsx // bin/keel.ts import { run } from "@shaferllc/keel/cli"; import { createApplication } from "../bootstrap/app.js"; run(process.argv, { createApplication }).catch((error) => { console.error(error); process.exit(1); }); ``` It's handed `createApplication` rather than importing it, because a framework that imports an *application* has its dependency pointing the wrong way — and that one import is what kept the console out of the published build until now. Commands that need the app (`serve`, `routes`, `migrate`) boot it once and share it. Scaffolding commands (`make:*`) don't, so a boot failure isn't fatal — it's surfaced only when a command that actually needs the app runs. ## Writing your own commands `keel make:command greet` scaffolds `app/Commands/greet.ts`. Everything in `app/Commands` is discovered automatically — no registration step. ```ts import { defineCommand, arg, flag } from "@shaferllc/keel/core"; export const greet = defineCommand({ name: "greet", description: "Greet someone", args: { name: arg.string({ description: "who to greet" }) }, flags: { loud: flag.boolean({ alias: "l", description: "SHOUT IT" }) }, async run({ args, flags, ui }) { const message = `Hello, ${args.name}!`; ui.success(flags.loud ? message.toUpperCase() : message); }, }); ``` ```bash keel greet Ada --loud # ✔ HELLO, ADA! keel greet --help # generated usage, args, and options ``` **`args.name` is a `string` and `flags.loud` is a `boolean` — inferred, not cast.** That's the point of declaring them: the parsing is generated from the types, so the two can't drift apart. Make an arg optional and its type becomes `string | undefined`; give it a default and it's a `string` again. Commands run with the application booted, so they get the same container, config, and providers your HTTP requests do. ### Arguments Positional, in declaration order. Required by default. | Builder | Value | |---------|-------| | `arg.string()` | `string` | | `arg.number()` | `number` — rejected with a clear error if it isn't one | | `arg.spread()` | `string[]` — swallows the rest; must be last | Options: `description`, `required: false`, `default`, `parse`. ### Flags | Builder | Value | |---------|-------| | `flag.boolean()` | `boolean` — defaults to `false`, so it's never `undefined` | | `flag.string()` | `string \| undefined` | | `flag.number()` | `number \| undefined` | | `flag.array()` | `string[]` — repeatable, defaults to `[]` | Options: `description`, `alias` (a single letter), `required`, `default`, `parse`. The parser understands `--flag value`, `--flag=value`, `--no-flag`, `-f value`, bundled shorthands (`-lt 5`), and `--`, after which everything is passed through untouched in `rest`. An **unknown flag is an error**, not a shrug — a typo'd `--forse` should tell you, not silently do nothing. Set `allowUnknownFlags: true` if a command genuinely needs to pass flags on to something else. ### Exit codes Return a number to set the exit code; return nothing for `0`. A thrown error is caught, reported, and exits `1` — a console is a bad place to show a user a stack trace because they mistyped a flag. A **usage** error (missing arg, bad flag) prints what's wrong *and the command's help*. ## Terminal UI Every command gets a `ui`: ```ts ui.info("Checking…"); ui.success("Migrated 3 tables"); ui.warning("Nothing to do"); ui.error("Failed"); // stderr ui.debug("verbose detail"); ui.action("create", "app/Models/User.ts"); // CREATE app/Models/User.ts ui.action("skip", "app/Models/Post.ts", "skipped"); ui.table(["Name", "Rows"]).row(["users", "42"]).row(["orgs", "7"]).render(); ui.sticker(["http://localhost:3000"], "Server running"); ui.instructions(["cd my-app", "npm install", "keel serve"], "Next steps"); ui.colors("green", "done"); // paint a string yourself ``` ### Tasks For a command that does several things in a row: ```ts await ui .tasks() .add("Install dependencies", async (task) => { task.update("resolving…"); return "42 packages"; }) .add("Run migrations", async () => "3 tables") .run(); ``` It **stops at the first failure**, because the tasks after it almost certainly depended on it and a cascade of red tells you nothing new. `run()` resolves to `false` if anything failed. ## Prompts ```ts const name = await prompt.ask("Project name?", { default: "my-app" }); const secret = await prompt.secure("API key?"); const ok = await prompt.confirm("Delete everything?"); const driver = await prompt.choice("Database?", ["sqlite", "postgres"]); const features = await prompt.multiple("Features?", ["auth", "queue", "mail"]); ``` `ask` re-asks on a failed `validate` rather than dying — a typo shouldn't cost someone the whole command. Every prompt takes `default`, `hint`, `validate`, and `result`. ## Testing a command A command that asks questions is normally a command you can't test. So prompts can be **trapped**: script the answers up front, and nothing touches the terminal. ```ts import { ConsoleKernel, createUi, createPrompt } from "@shaferllc/keel/core"; const ui = createUi({ raw: true }); // buffer the output, drop the colors const prompt = createPrompt({ trap: true }); const kernel = new ConsoleKernel({ ui, prompt }).register(setup); prompt.trap("Project name?").replyWith("keel-app"); prompt.trap("Database?").chooseOption(1); prompt.trap("Write the config?").accept(); const code = await kernel.run(["setup"]); assert.equal(code, 0); assert.match(ui.logs.join("\n"), /keel-app on postgres/); prompt.assertAllTrapsUsed(); // every scripted question was actually asked ``` An **untrapped prompt throws** instead of hanging. That matters more than it sounds: without it, the test would block forever on stdin no test will ever provide, and your suite would simply stop — with no failure to read. A trap can also assert the prompt's own validation: ```ts prompt .trap("Email?") .assertFails("", "Email is required") .assertPasses("ada@example.com") .replyWith("ada@example.com"); ``` `ui.logs` and `ui.errors` hold every line written, colorless, so you can assert on exactly what the command said. ## The REPL ```bash keel repl ``` An interactive shell with the **application booted** — the container is up, the providers have run, and the helpers are in scope: ``` keel > await db("users").where("active", 1).get() keel > make(Router).all() keel > await cache().get("stats") keel > .ls # what's in scope keel > .exit ``` Poking at a model in a REPL is the fastest debugging loop there is, and it shouldn't cost you a throwaway script to get one. History persists in `.keel_repl_history`. --- ## A note on the built-ins The commands *above* (`serve`, `routes`, `make:*`, `migrate:*`) still run through Keel's original console wrapper, and package-contributed commands do too. Your commands — anything in `app/Commands` — run on the system documented here, and take precedence over a built-in of the same name. Migrating the built-ins across is mechanical and will happen; nothing about the API here changes when it does. --- # CORS Cross-Origin Resource Sharing lets browsers on other origins call your API. The `cors()` middleware sets the `Access-Control-*` headers and answers preflight `OPTIONS` requests for you. ## Enabling Register it in your [HTTP kernel](./middleware.md) (app-wide) or on a route group: ```ts import { cors } from "@shaferllc/keel/core"; // In the kernel — applies to every route this.use(cors()); // Or scoped to an API group router.group(() => { /* … */ }).use(cors({ origin: ["https://app.example.com"] })); ``` With no options, `cors()` reflects the caller's origin — convenient in development. **Lock it down in production** with an explicit allowlist. ## A production API group Typical setup for a JSON API served from `api.example.com` and called from a SPA on `app.example.com`: ```ts // app/Http/Kernel.ts import { cors } from "@shaferllc/keel/core"; this.use( cors({ origin: ["https://app.example.com"], credentials: true, // cookies / Authorization exposeHeaders: ["X-Request-Id"], }), ); ``` During local development, allow any localhost port with a predicate: ```ts cors({ origin: (origin) => origin.startsWith("http://localhost:") || origin === "https://app.example.com", credentials: true, }); ``` ## Options ```ts cors({ origin: ["https://app.example.com"], // true (reflect) | false | "*" | string[] | (origin, c) => … methods: ["GET", "POST", "PUT", "PATCH", "DELETE"], headers: true, // true (reflect requested) | string[] exposeHeaders: ["X-Request-Id"], // response headers JS may read credentials: true, // send Access-Control-Allow-Credentials maxAge: 86400, // preflight cache seconds; null to omit }); ``` - **`origin`** — `true` reflects the request origin, `false` blocks everything, `"*"` allows any, an array is an allowlist, and a `(origin, c) => …` predicate returns `true`/`false`/a specific origin for dynamic decisions (e.g. any `localhost` port in dev). - **`credentials`** — when on, the spec forbids `"*"`, so `cors()` automatically reflects the concrete origin and adds `Vary: Origin`. - **`headers`** — `true` echoes whatever the browser asks for in the preflight; an array pins an explicit allowlist. ## Preflight Browsers send an `OPTIONS` request with `Access-Control-Request-Method` before certain cross-origin calls. `cors()` detects these and responds `204` with the allow headers directly — your route never runs. Everything else falls through to your handler with the CORS response headers attached. --- # Database Keel ships a small, **driver-agnostic query builder**. It generates parameterized SQL and runs it through a `Connection` you provide — so it works with any driver (Cloudflare D1, Neon/Postgres, PlanetScale, Turso, better-sqlite3, `pg`). The core never imports a database driver, so it stays edge-safe. ## Connect Register a connection once, in a service provider. The `Connection` interface is two methods — adapt them to your driver: ```ts import { setConnection, type Connection } from "@shaferllc/keel/core"; const connection: Connection = { select: (sql, bindings) => d1.prepare(sql).bind(...bindings).all().then((r) => r.results), write: async (sql, bindings) => { const r = await d1.prepare(sql).bind(...bindings).run(); return { rowsAffected: r.meta.changes, insertId: r.meta.last_row_id }; }, }; setConnection(connection, "sqlite"); // "sqlite" | "mysql" | "postgres" ``` The dialect only affects placeholder style (`?` vs Postgres `$1`). `select` returns the rows (`Row[]`); `write` returns a `WriteResult` (`rowsAffected`, optional `insertId`). Everything the builder does bottoms out in these two methods, which is why the same app runs on Node and the edge — only the connection changes. The type parameter on `db()` types the *results*; the `Connection` itself just deals in `Row`s. ### Ready-made adapters You don't have to hand-write the bridge for the common drivers — Keel ships `Connection` adapters as optional subpath imports. Each takes your driver instance and returns a `Connection`. They import no driver themselves (the client is duck-typed), so Keel's core stays dependency-free and nothing is bundled until you import it: ```ts // Cloudflare D1 (sqlite) import { d1Connection } from "@shaferllc/keel/db/d1"; setConnection(d1Connection(env.DB), "sqlite"); // Postgres — pg (Node) or @neondatabase/serverless (edge) import { pgConnection } from "@shaferllc/keel/db/pg"; import { Pool } from "pg"; setConnection(pgConnection(new Pool({ connectionString })), "postgres"); // libSQL / Turso (sqlite, Node + edge) import { libsqlConnection } from "@shaferllc/keel/db/libsql"; import { createClient } from "@libsql/client"; setConnection(libsqlConnection(createClient({ url, authToken })), "sqlite"); ``` Install only the driver you use — it's a peer, not a Keel dependency. **Postgres note:** `INSERT` returns an id only with a `RETURNING` clause, so `insertGetId()` needs `RETURNING id` on Postgres; the D1 and libSQL adapters return the last insert id natively. ## Multiple databases `setConnection` registers the *default* connection. To talk to more than one database at once — a Postgres primary and a SQLite/D1 cache, a separate reporting warehouse, a per-tenant shard — register each by name with `addConnection`, and each keeps its own dialect: ```ts import { setConnection, addConnection } from "@shaferllc/keel/core"; setConnection(primary, "postgres"); // the default addConnection("reporting", warehouse, "postgres"); addConnection("cache", d1Cache, "sqlite"); ``` Route a single query with a second argument to `db()`: ```ts await db("users").where("active", true).get(); // default await db("events", "reporting").where("kind", "signup").count(); ``` Or grab a reusable handle with `connection(name)` — it exposes `table()` plus the raw `select`/`write` bridge, all dialect-adjusted for that database: ```ts import { connection } from "@shaferllc/keel/core"; const reporting = connection("reporting"); await reporting.table("events").latest().limit(100).get(); await reporting.write("REFRESH MATERIALIZED VIEW daily_signups", []); ``` A whole [model](./models.md) can live on a connection — set `static connection` and every query, save, and relation for that model routes there: ```ts class Event extends Model { static table = "events"; static connection = "reporting"; // reads, writes, and relations use "reporting" } ``` `setDefaultConnection(name)` switches which registered connection the unnamed `db(table)` (and any model without a `static connection`) uses — handy for request-scoped tenant selection. `connectionNames()` lists what's registered. An unregistered connection name doesn't fail when you *build* a query, only when it runs — so a misconfigured name surfaces as a rejected read/write, not a construction-time throw. ## Queries & models Building and running queries — `where`, joins, aggregates, inserts, updates, pagination — lives in the [Query Builder](./query-builder.md) guide. The [ORM](./orm.md) adds an active-record layer (models, relationships, events, scopes) on top of the same builder. ## Transactions Two related writes should either both land or neither should. `transaction()` commits when your callback returns and **rolls back if it throws**: ```ts import { transaction, db } from "@shaferllc/keel/core"; await transaction(async () => { await db("orders").insert(order); await db("stock").where("id", id).decrement("count"); // a throw here undoes the insert }); ``` The error still reaches you — it's rethrown after the rollback. Nothing is swallowed. ### Queries inside are ambient You don't have to thread a transaction object through your code. `db()`, models, and relations all pick up the open transaction automatically: ```ts await transaction(async () => { const user = await User.create({ email }); // the model is in the transaction await user.related("posts").create({ title }); // so is the relation await db("audit").insert({ userId: user.id }); // and the raw builder }); ``` That works because the transaction lives in `AsyncLocalStorage`, not a module global — so two requests running transactions at the same time can't steal each other's connection. If you'd rather be explicit, the callback gets a handle: ```ts await transaction(async (tx) => { await tx.table("orders").insert(order); await tx.write("UPDATE stock SET count = count - 1 WHERE id = ?", [id]); }); ``` `tx.rollback()` abandons the transaction without committing. `inTransaction()` tells you whether one is open. ### Nesting uses savepoints A `transaction()` inside another doesn't open a second one — databases don't have those. It takes a **savepoint**, so an inner failure rolls back only the inner work and the outer transaction carries on: ```ts await transaction(async () => { await db("orders").insert(order); // survives try { await transaction(async () => { await db("items").insert(item); throw new Error("out of stock"); // only this is rolled back }); } catch { // handle it } await db("audit").insert(entry); // still in the outer transaction }); // the outer transaction commits: the order and the audit row are both saved ``` Without savepoints, a nested helper's failure would silently abandon its caller's writes too — which is the sort of bug you find in production, months later. ### Drivers and the pooling trap A transaction needs every statement to run on **one** connection. A connection *pool* hands each statement to whichever connection is free — so issuing `BEGIN` through a pool wraps nothing: the `INSERT` after it can land on a different connection entirely, the `COMMIT` commits nothing, and a failure half-writes. It looks like it works. It doesn't. So a pooled driver implements `begin()` on its `Connection`, checking one connection out and running the whole transaction on it. Keel's Postgres adapter does this automatically when you hand it a `Pool` (it checks for `connect()`), and releases the connection afterwards even if the `COMMIT` throws. | Driver | Transactions | |--------|--------------| | Postgres (`Pool`) | ✅ a dedicated connection is checked out | | Postgres (`Client`), SQLite, libSQL | ✅ `BEGIN` / `COMMIT` on the one connection they have | | **Cloudflare D1** | ❌ — no interactive transactions; use `database.batch([...])` | D1 can't hold a transaction open across awaits, so `transaction()` on it **throws a clear error** rather than letting a `BEGIN` fail cryptically. A transaction that quietly isn't one is far worse than one that refuses to start. Writing your own driver? Implement `begin(): Promise` if it pools. If it owns a single connection, you can leave it out and Keel will use `BEGIN`/`COMMIT`/`ROLLBACK`. ## Typed rows Pass a row type for typed results — it flows through to `get()` and `first()`: ```ts type User = { id: number; email: string; }; const user = await db("users").where("id", 1).first(); // User | null const all = await db("users").get(); // User[] ``` The type is a compile-time convenience; it doesn't validate the shape at runtime. > Use a `type` alias, not an `interface`, for the row type. The builder's type > parameter is constrained to `Row` (`Record`), which a `type` > satisfies via an implicit index signature but an `interface` does not. ## Related An active-record [`Model`](./models.md) layer and [migrations](./migrations.md) build on this builder — reach for them for CRUD and schema work, and drop back to `db()` for anything they don't cover. --- ## API reference ### `db(table)` `db(table: string, connectionName?: string): QueryBuilder` Starts a new query against `table`, on the default connection or a named one. The optional type parameter types the rows returned by `get()`/`first()`. ```ts db("users"); // QueryBuilder, default connection db<{ id: number }>("users"); // typed rows db("events", "reporting"); // the "reporting" connection ``` **Notes:** returns a fresh builder each call — nothing is shared between queries. No SQL runs until a terminal method (`get`/`first`/`count`/`exists`) or a write (`insert`/`update`/`delete`) is awaited. ### `setConnection(conn, dialect?)` `setConnection(conn: Connection, driverDialect?: Dialect): void` Registers the connection every `db()` query runs through, plus the dialect (default `"sqlite"`). ```ts setConnection(connection, "postgres"); ``` **Notes:** registers the `"default"` connection — the last call wins. Calling `db()` before any connection is registered throws `No database connection…` on the first query. The dialect only changes placeholder rendering (`?` → `$1, $2` for Postgres). ### `addConnection(name, conn, dialect?)` `addConnection(name: string, conn: Connection, driverDialect?: Dialect): void` Registers a *named* connection alongside the default and any others — the way to use more than one database. Reach it with `db(table, name)`, `connection(name)`, or a model's `static connection = name`. ```ts addConnection("reporting", warehouse, "postgres"); ``` ### `connection(name?)` `connection(name?: string): ConnectionHandle` Returns a handle to a registered connection (or the default): `table(name)` to start a query, `select`/`write` for raw SQL (dialect-adjusted, `?` placeholders), and `dialect`. ```ts const reporting = connection("reporting"); await reporting.table("events").count(); await reporting.select("SELECT 1", []); ``` ### `setDefaultConnection(name)` · `connectionNames()` · `clearConnections()` `setDefaultConnection(name: string)` picks which registered connection the unnamed `db(table)` and connectionless models use (throws if `name` isn't registered). `connectionNames()` returns the registered names. `clearConnections()` unregisters everything — a test helper. ### Interfaces & types #### `Connection` ```ts interface Connection { select(sql: string, bindings: unknown[]): Promise; write(sql: string, bindings: unknown[]): Promise; } ``` The seam between the builder and your driver. `select` runs any row-returning query and resolves to the rows; `write` runs an INSERT/UPDATE/DELETE and resolves to a `WriteResult`. Implement it once per driver — the two-method surface is deliberately tiny so any driver (or a mock in tests) fits. ```ts const mock: Connection = { select: async () => [{ id: 1 }], write: async () => ({ rowsAffected: 1, insertId: 1 }), }; ``` #### `WriteResult` ```ts interface WriteResult { rowsAffected: number; insertId?: number | string; } ``` Returned by `write` (and thus `insert`/`update`/`delete`). `insertId` is optional because not every driver or statement produces one. #### `Row` `type Row = Record` A database row — the default shape for query results and write payloads. #### `Dialect` `type Dialect = "sqlite" | "mysql" | "postgres"` Selects placeholder rendering. Only Postgres differs (`$1, $2, …`); the others use `?`. #### `Operator` `type Operator = "=" | "!=" | "<" | "<=" | ">" | ">=" | "like"` The comparison operators accepted by the three-argument `where`/`orWhere`. --- # Debugging Two helpers for the moments you'd otherwise reach for `console.log`. Both are edge-safe — `dump()` is a plain `console.log`, and `dd()` throws a self-rendering exception, so neither needs any runtime-specific support. ## dump `dump(...values)` prints to the console and **returns its first argument**, so you can drop it inline without restructuring code: ```ts import { dump } from "@shaferllc/keel/core"; dump(user, order); // logs both, execution continues const total = dump(computeTotal()); // logs the total AND uses it ``` Every log is prefixed with `⚓ dump →` so your probes are easy to spot (and easy to grep out later). Because it returns the first value unchanged, you can wrap it around any expression — an argument, a return value, a link in a chain — without changing what the code does: ```ts return dump(await user.save()); // inspect the saved model, still return it ``` `dump()` hands your values straight to `console.log`, so the runtime's own formatter renders them — objects stay inspectable, not flattened to a string. (The safe JSON rendering below is `dd()`'s job, not `dump()`'s.) > `dump()` requires at least one argument — its signature is > `(...values: [T, ...unknown[]])`. Calling `dump()` with no arguments is a type > error, which stops you from leaving a probe that prints nothing. ## dd — dump and die `dd(...values)` dumps to the **browser** and halts the request — a readable HTML page with each value pretty-printed. Perfect for inspecting state mid-request: ```ts import { dd } from "@shaferllc/keel/core"; store() { dd(await request.all(), request.headers()); // never reached } ``` Its return type is `never`: `dd()` throws, so nothing after it runs and TypeScript knows the following code is unreachable. Each value is serialized with a **safe** JSON stringifier before it hits the page, so the usual `JSON.stringify` hazards don't crash the dump: - **circular references** render as `[Circular]` instead of throwing; - **functions** render as `[Function: name]` (or `[Function: anonymous]`); - **bigints** render as `123n` instead of throwing; - **`undefined`** renders as `[undefined]` instead of vanishing. Values are HTML-escaped before rendering, so dumping a string full of `<`, `>`, or `&` shows the literal text rather than injecting markup. Under the hood `dd()` throws a self-rendering exception (see [Errors](./errors.md)), so it works the same on Node and the edge — no special runtime support needed. The exception carries a **200** status, so the dump page returns `200 OK`, not an error status — this is a deliberate inspection tool, not an error path. > **Shared references, not just cycles.** The safe stringifier tracks every > object it has seen and never forgets one, so the *same* object appearing twice > in unrelated places (siblings, not an actual cycle) renders as `[Circular]` on > its second appearance. If a dump shows an unexpected `[Circular]`, that's why — > the value isn't necessarily cyclic, just repeated. ## Turning on framework debug output Set `APP_DEBUG=true` (i.e. `config('app.debug')`) to get full error pages with stack traces from the kernel. Turn it off in production so internals stay hidden. See [Errors](./errors.md) for how responses change between debug and production. --- ## API reference Both functions are top-level exports — there are no classes to construct or interfaces to implement. The `DumpException` that `dd()` throws is internal; you never reference it directly. ### `dump(...values)` `dump(...values: [T, ...unknown[]]): T` Logs all values to the console (prefixed `⚓ dump →`) and returns the first one, so it can be dropped inline. ```ts import { dump } from "@shaferllc/keel/core"; const total = dump(computeTotal()); // logs, then flows the value onward dump(user, order, request); // logs all three, returns `user` ``` **Notes:** requires at least one argument (the tuple type `[T, ...unknown[]]` enforces it). Returns `values[0]` unchanged — never a copy — so it's safe to wrap around any expression. Uses `console.log` directly, so formatting is the runtime's, not the safe stringifier; it does not halt execution. ### `dd(...values)` `dd(...values: unknown[]): never` Dumps every value to a self-rendering HTML page and halts the request — "dump and die". ```ts import { dd } from "@shaferllc/keel/core"; dd(await request.all(), request.headers()); // unreachable — dd() throws ``` **Notes:** returns `never` — it throws an internal `DumpException` (a self-handling `HttpException`) rather than returning, so any code after it is unreachable. The rendered page returns status **200**, not an error code. Values are serialized with the safe stringifier (circular refs → `[Circular]`, functions → `[Function: …]`, bigints → `123n`, `undefined` → `[undefined]`) and HTML-escaped before rendering. Accepts zero or more arguments; `dd()` with no arguments still halts the request and renders an empty page. --- # Request Decorators Attach named, computed values to the current request — `request.user`, `request.tenant`, `request.locale` — resolved **lazily** and **memoized for the life of the request**. You register a resolver once, and Keel computes it on first access and caches it per request. No null-placeholder declaration, no shared-state leak between requests. > Decorating the **application** is already the [service container's](./container.md) > job — `bind` / `singleton` / `instance` / `make`, with `bound()` as > `hasDecorator`. Decorators here are the per-*request* counterpart. ## Registering Register decorators once at boot (typically in a service provider). A resolver receives the request context and returns a value — sync or async: ```ts import { decorateRequest } from "@shaferllc/keel/core"; decorateRequest("locale", (c) => c.req.header("accept-language") ?? "en"); decorateRequest("user", async (c) => findUser(c.req.header("authorization"))); ``` ## Accessing Read a decorator anywhere in the request with `decorated()`. It runs the resolver on first access and caches the result for the rest of that request: ```ts import { decorated } from "@shaferllc/keel/core"; const locale = await decorated("locale"); const user = await decorated("user"); // computed once, then cached ``` `decorated()` always returns a promise (resolvers may be async). A second request starts with a fresh cache — nothing leaks between requests. ## Setting a value directly When something upstream already resolved a value — say an auth middleware — set it imperatively so downstream `decorated()` calls skip the resolver: ```ts import { setRequestValue, decorated } from "@shaferllc/keel/core"; // in middleware, after verifying the session: setRequestValue("user", theAuthenticatedUser); // later, in a controller: const user = await decorated("user"); // returns the value set above, no re-lookup ``` ## Why lazy + memoized Resolving the current user (or tenant, or locale, or a feature-flag set) is the kind of thing every handler needs but nothing should compute twice. Registering a resolver once and letting the framework memoize it per request means: - handlers that never touch `request.user` never pay for the lookup; - handlers that touch it repeatedly pay exactly once; - there's no per-request wiring to forget. ## API reference ### `decorateRequest(name, resolver)` `decorateRequest(name: string, resolver: (c: Context) => T | Promise): void` Registers a request decorator. The resolver is called at most once per request, on first access. ```ts decorateRequest("tenant", (c) => c.req.header("x-tenant") ?? "public"); ``` **Notes:** throws if `name` is already registered (a collision guard). Register at boot, not per request. ### `decorated(name)` `decorated(name: string): Promise` The memoized value of a decorator for the current request. ```ts const tenant = await decorated("tenant"); ``` **Notes:** computes via the resolver on first access, caches for the rest of the request. Throws if `name` was never registered. Must run inside a request (it reads the current context). ### `setRequestValue(name, value)` `setRequestValue(name: string, value: T): void` Sets a decorator's value for the current request, overriding the resolver. **Notes:** later `decorated(name)` calls return this value without invoking the resolver. Useful from middleware. ### `hasRequestDecorator(name)` `hasRequestDecorator(name: string): boolean` Whether a decorator has been registered. ### `clearRequestDecorators()` `clearRequestDecorators(): void` Unregisters all decorators — a test helper. ### Interfaces & types #### `RequestResolver` `type RequestResolver = (c: Context) => T | Promise` The function registered with `decorateRequest`; receives the Hono `Context` and returns the value (sync or async). --- # Errors & Exceptions Throw an exception anywhere — a handler, middleware, or a service deep in the container — and Keel's HTTP kernel turns it into the right response. No try/catch in every controller. ## HTTP exceptions `HttpException` carries a status code and message. Throw it (or one of its subclasses) to short-circuit a request with a specific status: ```ts import { HttpException, NotFoundException, UnauthorizedException, ForbiddenException, } from "@shaferllc/keel/core"; throw new NotFoundException("Widget not found"); // 404 throw new UnauthorizedException(); // 401 throw new ForbiddenException(); // 403 throw new HttpException(429, "Slow down"); // any status throw new HttpException(503, "Down for maintenance", { "Retry-After": "120" }); ``` ### The full HTTP error family Every common HTTP status has a named subclass with a fixed `status` and a stable machine `code`. Each takes an optional message and an optional `data` bag (see [below](#attaching-structured-data)): | Class | Status | `code` | |-------|--------|--------| | `BadRequestException` | 400 | `E_BAD_REQUEST` | | `UnauthorizedException` | 401 | `E_UNAUTHORIZED` | | `PaymentRequiredException` | 402 | `E_PAYMENT_REQUIRED` | | `ForbiddenException` | 403 | `E_FORBIDDEN` | | `NotFoundException` | 404 | `E_NOT_FOUND` | | `MethodNotAllowedException` | 405 | `E_METHOD_NOT_ALLOWED` | | `NotAcceptableException` | 406 | `E_NOT_ACCEPTABLE` | | `RequestTimeoutException` | 408 | `E_REQUEST_TIMEOUT` | | `ConflictException` | 409 | `E_CONFLICT` | | `LengthRequiredException` | 411 | `E_LENGTH_REQUIRED` | | `ValidationException` | 422 | `E_VALIDATION` | | `TooManyRequestsException` | 429 | `E_TOO_MANY_REQUESTS` | | `ServerErrorException` | 500 | `E_SERVER_ERROR` | | `NotImplementedException` | 501 | `E_NOT_IMPLEMENTED` | | `BadGatewayException` | 502 | `E_BAD_GATEWAY` | | `ServiceUnavailableException` | 503 | `E_SERVICE_UNAVAILABLE` | `ValidationException` is special — it takes a per-field error map first (see [Validation errors](#validation-errors)); the rest take `(message?, data?)`. ### Attaching structured data Any exception can carry a `data` bag that lands in the JSON body under `data`: ```ts throw new ConflictException("Email already registered", { email: "a@b.com" }); // -> 409 { "error": "Email already registered", "status": 409, // "code": "E_CONFLICT", "data": { "email": "a@b.com" } } ``` Every exception also has a `toJSON()` that returns exactly this body shape (`{ error, status, code?, data? }`, plus `errors` for `ValidationException`), so you can serialize one yourself outside the HTTP kernel — logging, a queue payload, a websocket frame. A controller that always throws can be typed `: never`: ```ts show(c: Ctx): never { throw new NotFoundException(); } ``` The third `headers` argument is emitted on the response — handy for a `503` with `Retry-After`, or a `429` with rate-limit headers: ```ts throw new HttpException(429, "Slow down", { "Retry-After": "30" }); ``` For terse, inline guards you don't need to construct an exception at all — the [request](./request-response.md) object's `abort`, `abortIf`, and `abortUnless` helpers throw a plain `HttpException` for you: ```ts request.abortUnless(user.isAdmin, "Forbidden", 403); ``` ## How responses are rendered The kernel negotiates the response by `Accept` and by your `app.debug` config: | Situation | Response | |-----------|----------| | Client accepts JSON | `{ "error": "...", "status": 404 }` | | Client accepts HTML | A rendered error page | | `app.debug = true`, unexpected error | Full message + **stack trace** (page + JSON) | | `app.debug = false`, unexpected 500 | Generic `Internal Server Error`, internals hidden | | Thrown `HttpException` | Its status + message (shown even in production) | Unexpected errors (anything that isn't an `HttpException`) become `500`. In production their message and stack are hidden so you never leak internals; the intentional message on an `HttpException` is always shown. A subclass `code` and any `data` bag are added to the JSON body (`{ error, status, code, data }`), and any `headers` you passed are set on the response. The title on both the JSON and HTML paths comes from [`STATUS_TEXT`](#status_text) — the kernel looks the status up there (`STATUS_TEXT[status] ?? "Error"`), so a custom status still gets a sensible label as long as it's in the map. ## Unmatched routes Any request that doesn't match a route is turned into a `404` automatically — same rendering as a thrown `NotFoundException`: ``` GET /does-not-exist → 404 { "error": "No route for GET /does-not-exist", "status": 404, "code": "E_NOT_FOUND" } ``` ## The debug error page When `app.debug` is on and the client is a browser, the kernel renders a readable error page with the status, message, request line, and a formatted stack trace — so you see what broke without digging through logs. Turn debug off (via `APP_DEBUG=false`) in production. ## Validation errors `ValidationException` is a `422` that carries per-field messages, which appear in the JSON body under `errors`: ```ts import { ValidationException } from "@shaferllc/keel/core"; throw new ValidationException({ email: ["The email is invalid."] }); // -> 422 { "error": "The given data was invalid.", "status": 422, // "code": "E_VALIDATION", "errors": { "email": ["The email is invalid."] } } ``` ## Custom exceptions Extend `HttpException` to model your domain errors. Add a `code` (surfaced in the JSON body), and optionally make the exception render or report itself: ```ts import { HttpException } from "@shaferllc/keel/core"; import type { Context } from "hono"; export class PaymentRequiredException extends HttpException { code = "E_PAYMENT_REQUIRED"; constructor() { super(402, "Payment is required to continue."); } // Optional: render this exception itself. handle(c: Context) { return c.json({ error: this.message, code: this.code, upgrade: "/billing" }, this.status); } // Optional: called before rendering — log/report it. report() { metrics.increment("payment_required"); } } throw new PaymentRequiredException(); ``` - **`code`** → added to the JSON error body (`{ error, status, code }`). - **`handle(c)`** → if it returns a `Response`, the kernel uses it verbatim. If it returns anything else, the kernel falls back to the default rendering. - **`report()`** → always called (and awaited) first; failures there never mask the original error. Both hooks are duck-typed, not tied to a base class: the kernel calls any thrown value that happens to have a `report` and/or `handle` method. The built-in subclasses don't define either — they render through the default path. ## Coded errors with `createError` When all you want is a coded error class — a stable `code`, a message, a status — skip the boilerplate and mint one with `createError`. It's the ergonomic shortcut for the common case: ```ts import { createError } from "@shaferllc/keel/core"; const InsufficientFunds = createError("E_FUNDS", "Balance too low: need %s", 402); throw new InsufficientFunds("$40"); // -> 402 { "error": "Balance too low: need $40", "status": 402, "code": "E_FUNDS" } ``` `%s` placeholders in the message are filled, in order, from the constructor arguments. The result is a real `HttpException` subclass, so it renders through the same path (the `code` lands in the JSON body) and passes `instanceof HttpException`. Define your app's errors once and throw them anywhere: ```ts export const TenantSuspended = createError("E_TENANT_SUSPENDED", "Tenant %s is suspended.", 403); export const RateExceeded = createError("E_RATE", "Slow down.", 429); ``` The **built-in** exceptions carry stable codes too, so `code` shows up without any work: `NotFoundException` → `E_NOT_FOUND`, `UnauthorizedException` → `E_UNAUTHORIZED`, `ForbiddenException` → `E_FORBIDDEN`, `ValidationException` → `E_VALIDATION`. Reach for a hand-written subclass (above) only when you need behavior — a `handle(c)` renderer or a `report()` hook. For a plain coded error, `createError` is all you need. ## Customizing the handler Override the whole thing from your app's HTTP kernel with `onError()`: ```ts // app/Http/Kernel.ts export class Kernel extends HttpKernel { constructor(app: Application) { super(app); this.onError((err, c) => { // report to your logging service, then render however you like return c.json({ oops: true }, 500); }); } } ``` Or override the protected `renderException(err, c)` method to keep the default routing but change the presentation. > A registered `onError` handler takes precedence over an exception's own > `handle(c)` — the custom handler wins, and self-handling is skipped. `report()` > still runs first, either way. --- ## API reference Every exception below is a subclass of `HttpException`, which extends the native `Error`. You throw them; you never catch them yourself — the kernel does. All are exported from `@shaferllc/keel/core`. ### `HttpException` The base semantic HTTP error: a status code, a message, and optional response headers. Throw it directly for any status that doesn't have a dedicated subclass. #### `new HttpException(status, message?, headers?)` `new HttpException(status: number, message?: string, headers?: Record): HttpException` Constructs an error carrying `status` and `message`. Omit `message` to fall back to the status text. ```ts throw new HttpException(409, "That email is taken"); throw new HttpException(503, "Down for maintenance", { "Retry-After": "120" }); throw new HttpException(429); // message defaults to "Too Many Requests" ``` **Notes:** the message defaults to `STATUS_TEXT[status]`, then `"Error"` if the status isn't in the map. Exposes three readonly-ish fields the kernel reads: `status` (number), `headers` (optional, emitted on the response), and `code` (optional `string`, added to the JSON body when set). It also sets `name` to `"HttpException"`. There's no built-in `handle`/`report` — add those on a subclass to self-render or self-report (see [Custom exceptions](#custom-exceptions)). ### `NotFoundException` A `404`. Thrown automatically for unmatched routes, and by `Model.findOrFail`. #### `new NotFoundException(message?)` `new NotFoundException(message?: string): NotFoundException` Constructs a `404`. Message defaults to `"Not Found"`. ```ts throw new NotFoundException(); // "Not Found" throw new NotFoundException("Widget 42 not found"); ``` **Notes:** `status` is fixed at `404`; `name` is `"NotFoundException"`. The kernel also throws this for any request that matches no route. ### `UnauthorizedException` A `401` — the request isn't authenticated. Reach for it when there's no valid session or credentials; use `ForbiddenException` when the user is known but not allowed. #### `new UnauthorizedException(message?)` `new UnauthorizedException(message?: string): UnauthorizedException` Constructs a `401`. Message defaults to `"Unauthorized"`. ```ts throw new UnauthorizedException(); throw new UnauthorizedException("Session expired"); ``` **Notes:** `status` is fixed at `401`; `name` is `"UnauthorizedException"`. ### `ForbiddenException` A `403` — the request is authenticated but not permitted. #### `new ForbiddenException(message?)` `new ForbiddenException(message?: string): ForbiddenException` Constructs a `403`. Message defaults to `"Forbidden"`. ```ts throw new ForbiddenException(); throw new ForbiddenException("You can't edit this post"); ``` **Notes:** `status` is fixed at `403`; `name` is `"ForbiddenException"`. ### `ValidationException` A `422` carrying per-field error messages. The kernel adds them to the JSON body under `errors`. #### `new ValidationException(errors, message?)` `new ValidationException(errors: Record, message?: string): ValidationException` Constructs a `422` from a map of field name → messages. ```ts throw new ValidationException({ email: ["The email is invalid."], password: ["Too short.", "Must contain a number."], }); // -> 422 { "error": "The given data was invalid.", "status": 422, // "code": "E_VALIDATION", "errors": { "email": [...], "password": [...] } } ``` **Notes:** `status` is fixed at `422`; `name` is `"ValidationException"`. Message defaults to `"The given data was invalid."`. The field map is exposed as the readonly `errors` property, which the kernel serializes into the response body. Keel's [`validate()`](./validation.md) helper throws this for you on a failed parse. `code` is `"E_VALIDATION"`. ### `createError(code, message, status?)` `createError(code: string, message: string, status?: number): new (...args: (string | number)[]) => HttpException` Mints a reusable, coded `HttpException` subclass. `message` may contain `%s` placeholders, filled in order from the constructor arguments; `status` defaults to `500`. ```ts const InsufficientFunds = createError("E_FUNDS", "Balance too low: need %s", 402); throw new InsufficientFunds("$40"); // -> 402 { "error": "Balance too low: need $40", "status": 402, "code": "E_FUNDS" } ``` **Notes:** the returned class extends `HttpException`, so it renders through the default path (with `code` in the JSON body) and passes `instanceof HttpException`; its `name` is the `code`. A missing argument leaves its `%s` in place. For an error that needs a custom `handle(c)`/`report()`, subclass `HttpException` directly instead. ### Constants #### `STATUS_TEXT` `const STATUS_TEXT: Record` Maps HTTP status codes to their reason phrases. Used to title error pages/bodies and to supply the default message for `HttpException`. ```ts import { STATUS_TEXT } from "@shaferllc/keel/core"; STATUS_TEXT[404]; // "Not Found" STATUS_TEXT[419]; // "Page Expired" STATUS_TEXT[418] ?? "Error"; // not in the map ``` **Notes:** covers the statuses Keel uses (`400`, `401`, `403`, `404`, `405`, `409`, `419`, `422`, `429`, `500`, `503`). Lookups for anything else are `undefined`, which the kernel falls back to `"Error"` for. It's a plain mutable object — you can add entries for custom statuses so they render with a label. --- # Events A tiny event emitter for decoupling — fire an event in one place, handle it in another. The emitter is a container singleton, reachable through the global `emit()` / `listen()` helpers. ## Listen and emit ```ts import { emit, listen } from "@shaferllc/keel/core"; // register a listener (usually in a provider's boot()) listen("user.registered", (user) => { sendWelcomeEmail(user); }); // fire it from anywhere — emit awaits every listener await emit("user.registered", user); ``` `listen()` returns an unsubscribe function: ```ts const off = listen("tick", () => {}); off(); // stop listening ``` ## Typed payloads Both `emit` and `listen` take a payload type parameter, so the value you fire and the value your listener receives line up at compile time: ```ts type OrderPaid = { id: number; total: number }; listen("order.paid", (order) => { order.total; // number }); await emit("order.paid", { id: 1, total: 4200 }); ``` This is a convenience for the caller: you're passing the same type on both sides by hand, and nothing checks that you did. Get it wrong in one place and the two drift apart silently. ## The event registry Declare an event once in `EventsList` and the emitter checks **both** sides of it for you — no type argument to remember, and no way for the emitter and the listener to disagree: ```ts declare module "@shaferllc/keel/core" { interface EventsList { "order.paid": { id: number; total: number }; "user.registered": User; } } ``` Put that in a `types/events.ts` (anywhere the compiler sees it). From then on: ```ts listen("order.paid", (order) => { order.total; // number — inferred from the registry }); await emit("order.paid", { id: 1, total: 4200 }); // ✅ await emit("order.paid", { id: 1, total: "4200" }); // ❌ total must be a number await emit("order.paid"); // ❌ this event requires a payload listen("order.paid", (o: { nope: boolean }) => {}); // ❌ wrong listener payload ``` Declaring events is **opt-in and incremental**. An event you haven't declared behaves exactly as it always has — the payload type comes from the listener or an explicit `listen`, and falls back to `unknown` — so you can add entries to `EventsList` one at a time. Nothing validates the payload at *runtime*; this is a compile-time contract. ## Where to register listeners Register listeners in a service provider's `boot()` method, so they're wired up once when the app starts: ```ts export class EventServiceProvider extends ServiceProvider { boot(): void { listen("order.paid", (order) => this.app.make(Fulfillment).ship(order)); } } ``` `boot()` runs after every provider has registered, so it's safe to resolve other services there. Don't register listeners in `register()` — the services they reach may not be bound yet. ## The full API Reach the emitter directly with `events()` for `once`, `off`, and more: ```ts import { events } from "@shaferllc/keel/core"; events().once("boot", () => {}); // fire once, then auto-unsubscribe events().off("tick", listener); events().listenerCount("tick"); events().clear("tick"); // or clear() for everything ``` ## Ordering and awaiting Listeners for an event run in registration order, and `emit()` **awaits** each one before starting the next — so a slow async listener finishes before the following listener begins, and before `emit()` resolves: ```ts listen("deploy", async () => { await step1(); }); listen("deploy", async () => { await step2(); }); // runs after step1 settles await emit("deploy"); // resolves only after both steps finish ``` `emit()` snapshots the listener set before iterating, so a listener that subscribes (or unsubscribes) another during the same `emit()` doesn't change who runs this time around — the change takes effect on the next emission. ## Error behavior **A listener that throws does not stop the others.** `emit()` runs every listener, then reports what broke. That's the whole point of an emitter: the analytics listener blowing up shouldn't silently cancel the welcome email. ```ts listen("user.registered", () => sendWelcomeEmail(user)); // runs listen("user.registered", () => { throw new Error("boom"); }); // throws listen("user.registered", () => trackSignup(user)); // still runs try { await emit("user.registered", user); } catch (err) { // every listener ran; err is the failure ("boom") } ``` Failures are never swallowed. With one failed listener `emit()` rejects with that error; with several it rejects with an `AggregateError` whose `.errors` holds them all. ### Handling failures centrally Register an `onError` handler and `emit()` stops rejecting — each failure goes to your handler instead, with the event name and the payload that triggered it. This is how you keep a background listener's bug from taking down the request that happened to fire the event: ```ts events().onError((event, error, payload) => { logger().error("listener failed", { event, error, payload }); }); ``` Firing an event that has no listeners is a no-op — `emit()` resolves immediately. ## Observing every event `onAny` subscribes to *all* events — for logging, metrics, and other cross-cutting concerns. It runs before the event's own listeners and returns an unsubscribe function: ```ts const off = events().onAny((event, payload) => { logger().debug(`event: ${event}`, { payload }); }); ``` ## Testing `events().fake()` records emissions **instead of running listeners**, so a test can assert an event fired without triggering its side effects — no welcome email, no queued job. It returns a buffer to assert against; `restore()` puts the real emitter back. ```ts const buffer = events().fake(); await registerUser({ email: "a@b.com" }); buffer.assertEmitted("user.registered"); buffer.assertEmitted("order.paid", (o) => o.total === 4200); // with a predicate buffer.assertEmittedCount("user.registered", 1); buffer.assertNotEmitted("user.deleted"); buffer.assertNoneEmitted(); // nothing at all fired events().restore(); ``` Pass event names to fake only those — everything else dispatches for real: ```ts const buffer = events().fake("user.registered"); // or ["a", "b"] ``` `buffer.all()` returns every recorded `{ event, payload }` in order, and `buffer.payloadsFor("order.paid")` returns just that event's payloads (typed, if the event is declared in `EventsList`). ## Notes - A listener is stored once per event (the backing store is a `Set`), so registering the exact same function twice for the same event only fires it once. Register a fresh function each time if you want it to run twice. - Events are in-process. For cross-process or durable events, have a listener publish to your queue/broker of choice. --- ## API reference ### `Events` The emitter itself — a container singleton. You rarely construct it; resolve it with `events()` (or `app().make(Events)`). Events are keyed by string; each key holds a `Set` of listeners. #### `on(event, listener)` `on(event: string, listener: Listener): () => void` Subscribes `listener` to `event` and returns an unsubscribe function. ```ts const off = events().on<{ id: number }>("user.deleted", (u) => audit(u.id)); off(); // later: stop listening ``` **Notes:** the returned function removes exactly this listener. The same function reference is de-duplicated per event (Set-backed), so subscribing it twice still only registers — and fires — it once. The global `listen()` helper is a thin wrapper over this. #### `once(event, listener)` `once(event: string, listener: Listener): () => void` Subscribes for a single emission: the listener runs on the next `emit`, then auto-unsubscribes. Also returns an unsubscribe function to cancel it beforehand. ```ts events().once("boot", () => console.log("started")); ``` **Notes:** it unsubscribes itself *before* awaiting your callback, so a listener that re-emits the same event won't re-trigger this one. The returned unsubscribe removes the internal wrapper, so it works even if the event never fires. #### `off(event, listener)` `off(event: string, listener: Listener): void` Removes `listener` from `event`. No-op if it wasn't subscribed. ```ts events().off("tick", handler); ``` **Notes:** you must pass the same function reference you subscribed with — anonymous inline functions can't be removed this way, so keep a reference (or use the unsubscribe function `on`/`once` return). Removing the last listener leaves an empty `Set` behind under that key; use `clear(event)` to drop the key entirely. #### `emit(event, payload?)` `emit(event: string, payload?: T): Promise` Fires `event`, awaiting every listener in registration order with the given payload. For an event declared in `EventsList`, the payload is required and type-checked against the registry. ```ts await events().emit("order.paid", { id: 1, total: 4200 }); ``` **Notes:** listeners run sequentially, each awaited before the next. A listener that throws or rejects **does not** skip the rest — they all run, and `emit` rejects afterwards with that error (or an `AggregateError` if more than one failed), unless an `onError` handler is registered. No listeners means an immediate resolve. Both listener sets are snapshotted up front, so subscriptions made mid-emit apply only to later emissions. #### `onAny(listener)` `onAny(listener: AnyListener): () => void` Subscribes to every event. The listener receives `(event, payload)`. Returns an unsubscribe function. ```ts const off = events().onAny((event, payload) => log(event, payload)); ``` **Notes:** any-listeners run *before* the event's own listeners, so a logger sees the event even if a listener later throws. They're subject to the same error handling as ordinary listeners. #### `onError(handler)` `onError(handler: ErrorHandler): void` Handles listener failures instead of letting `emit` reject. The handler receives `(event, error, payload)`. ```ts events().onError((event, error) => logger().error("listener failed", { event, error })); ``` **Notes:** only one handler is active — registering again replaces it. Without one, failures surface by rejecting `emit`; they are never silently dropped. #### `fake(only?)` / `restore()` `fake(only?: EventName | EventName[]): EventBuffer` — record emissions instead of running listeners, and return a buffer to assert against. `restore()` undoes it. ```ts const buffer = events().fake(); await register(user); buffer.assertEmitted("user.registered"); events().restore(); ``` **Notes:** with no argument every event is faked; pass names to fake only those, leaving the rest to dispatch for real. Each `fake()` returns a **fresh** buffer. #### `clearAll()` `clearAll(): void` Drops every listener, every `onAny` listener, and the error handler. `clear()` only drops ordinary listeners. #### `listenerCount(event)` `listenerCount(event: string): number` Returns how many listeners are currently subscribed to `event`. ```ts if (events().listenerCount("tick") === 0) startClock(); ``` **Notes:** returns `0` for an unknown event. #### `clear(event?)` `clear(event?: string): void` Removes all listeners for `event`, or — with no argument — every listener for every event. ```ts events().clear("tick"); // drop one event's listeners events().clear(); // wipe everything (handy between tests) ``` **Notes:** unlike `off`, this deletes the event's key outright, so it also cleans up the empty-`Set` residue `off` can leave behind. ### Global helpers Convenience functions that resolve the active application's `Events` singleton — no need to thread the container around. #### `events()` `events(): Events` Returns the application's `Events` instance. ```ts import { events } from "@shaferllc/keel/core"; events().once("boot", () => {}); ``` **Notes:** resolves `Events` from the active application container, so every call returns the same singleton. Throws `No Keel application has been bootstrapped…` if no `Application` has been created yet. #### `emit(event, payload?)` `emit(event: string, payload?: T): Promise` Shorthand for `events().emit(event, payload)` — fire an event from anywhere. ```ts import { emit } from "@shaferllc/keel/core"; await emit("user.registered", user); ``` **Notes:** same awaiting/error semantics as `Events.emit`. Requires a bootstrapped application. #### `listen(event, listener)` `listen(event: string, listener: Listener): () => void` Shorthand for `events().on(event, listener)` — subscribe from anywhere; returns an unsubscribe function. ```ts import { listen } from "@shaferllc/keel/core"; const off = listen<{ id: number }>("user.deleted", (u) => audit(u.id)); ``` **Notes:** wraps `on`, not `once`, so the listener stays subscribed until you call the returned function. Requires a bootstrapped application. ### Interfaces & types #### `Listener` `type Listener = (payload: T) => void | Promise` The shape of an event handler: a function taking the payload, optionally async. Use it to type a handler you store or pass around before subscribing. ```ts import { type Listener } from "@shaferllc/keel/core"; const onPaid: Listener<{ id: number }> = async (order) => { await fulfill(order.id); }; listen("order.paid", onPaid); ``` **Notes:** async listeners are fully awaited by `emit`. A listener that returns a rejected promise is treated exactly like a synchronous throw — the other listeners still run, and the failure is reported afterwards. #### `EventsList` `interface EventsList {}` The registry of declared events, keyed by name. Empty by default; augment it from your app to type an event's payload (see [The event registry](#the-event-registry)). ```ts declare module "@shaferllc/keel/core" { interface EventsList { "order.paid": { id: number; total: number }; } } ``` #### `EventBuffer` What `fake()` returns. Records every intercepted emission and asserts over them. | Method | Signature | |--------|-----------| | `assertEmitted` | `(event, predicate?) => void` | | `assertNotEmitted` | `(event) => void` | | `assertEmittedCount` | `(event, count) => void` | | `assertNoneEmitted` | `() => void` | | `all` | `() => RecordedEvent[]` | | `payloadsFor` | `(event) => PayloadOf[]` | Failed assertions throw with what actually fired. #### `AnyListener` `type AnyListener = (event: string, payload: unknown) => void | Promise` The shape of an `onAny` handler. #### `ErrorHandler` `type ErrorHandler = (event: string, error: unknown, payload: unknown) => void | Promise` The shape of an `onError` handler. #### `RecordedEvent` `interface RecordedEvent { event: string; payload: unknown }` One emission captured by a fake. #### `EventName` / `PayloadOf` `type EventName = keyof EventsList | (string & {})` — a declared event name, or any other string. `type PayloadOf` — the declared payload for an event, or `unknown` if it isn't in `EventsList`. --- # Factories & Seeders Populate the database with realistic fixtures for tests and demos. A **factory** describes how to build a model's attributes; a **seeder** orchestrates factories (and raw writes) into a repeatable dataset. Both are edge-safe and dependency-free — the built-in `Faker` needs no external library. ## Factories Define a factory with the model and a definition function. The function receives a `Faker` and the instance index, and returns the attributes: ```ts import { factory } from "@shaferllc/keel/core"; const users = factory(User, (f, i) => ({ name: f.name(), email: f.email(), })); ``` Generate one with `keel make:factory User` (→ `database/factories/UserFactory.ts`). ### Building vs. persisting ```ts users.make(); // a User instance, not saved users.make({ name: "Ada" }); // with an override await users.create(); // saved via Model.create, id back-filled await users.create({ role: "admin" }); ``` ### Batches `count(n)` sets how many the next call produces (an array): ```ts const ten = await users.count(10).create(); // User[] const drafts = users.count(3).make(); // User[] (unsaved) // the index lets each row differ factory(User, (f, i) => ({ email: `user${i}@x.com` })).count(5); ``` ## Faker A small, seedable generator — enough for believable data without a dependency. ```ts const f = new Faker(); // random const f = new Faker(42); // seeded — reproducible runs f.name(); // "Grace Hopper" f.firstName(); f.lastName(); f.email(); // "grace.hopper.1234@example.com" f.word(); f.words(3); f.sentence(); f.paragraph(); f.number(1, 100); f.boolean(); f.pick(["a", "b", "c"]); f.slug(); f.uuid(); ``` Seed a factory's faker for deterministic fixtures: ```ts import { Faker } from "@shaferllc/keel/core"; users.usingFaker(new Faker(42)); ``` `Faker` uses an xorshift32 PRNG — fast, edge-safe, and seedable. (Its `uuid()` is for fixtures, not security; use `crypto` for real identifiers.) ## Seeders A seeder is a class with a `run()` method. Generate one with `keel make:seeder Database`: ```ts import { Seeder } from "@shaferllc/keel/core"; class UserSeeder extends Seeder { async run() { await factory(User, (f) => ({ name: f.name(), email: f.email() })) .count(10) .create(); } } class DatabaseSeeder extends Seeder { async run() { await this.call([UserSeeder]); // compose seeders in order } } ``` Run one from the console: ```bash npm run keel db:seed # runs DatabaseSeeder npm run keel db:seed -- -c User # runs UserSeeder instead ``` `db:seed` boots your app first, so models and the connection are wired up. It finds the class by name in `database/seeders/`, whichever module exports it. You can also fold seeding into a migration run — `keel migrate --seed`, `keel migrate:fresh --seed` — which is the usual way to reset a development database. To run one in-process (a test, a script), use the `seed` helper after your connection is registered: ```ts import { seed, setConnection } from "@shaferllc/keel/core"; setConnection(myConnection, "postgres"); await seed(DatabaseSeeder); ``` ## In tests Factories shine in tests — register a connection (or a mock), then build fixtures inline: ```ts setConnection(testConnection, "sqlite"); const [author] = await factory(User, (f) => ({ name: f.name() })).count(1).create(); const post = await factory(Post, (f) => ({ title: f.sentence() })).create({ user_id: author.id, }); ``` ## API reference ### `factory(model, definition)` `factory(model, definition: (f: Faker, i: number) => Row): ModelFactory` Start a factory for `model`. The definition receives a `Faker` and the instance index, and returns the attributes. ### `ModelFactory` Returned by `factory()` (the `Factory` class, exported as `ModelFactory`). | Method | Signature | Notes | |--------|-----------|-------| | `make` | `(overrides?) => T \| T[]` | build instance(s), unsaved | | `create` | `(overrides?) => Promise` | persist via `Model.create` | | `count` | `(n) => this` | how many the next `make`/`create` yields | | `usingFaker` | `(faker) => this` | use a seeded `Faker` for reproducible data | `count(1)` still returns an array; `count()` unset returns a single model. ### `Faker` `new Faker(seed?)` — seedable (deterministic) xorshift32 generator. | Method | Returns | |--------|---------| | `name` / `firstName` / `lastName` | string | | `email` / `slug` / `uuid` | string | | `word` / `words(n?)` / `sentence(n?)` / `paragraph(n?)` | string | | `number(min?, max?)` | number | | `boolean` | boolean | | `pick(items)` | one element of `items` | `uuid()` is for fixtures, not security — use `crypto` for real ids. ### `Seeder` / `seed(SeederClass)` Abstract `Seeder` with an `async run()`; `protected call([...Seeders])` composes others in order. `seed(DatabaseSeeder)` instantiates and runs one. ### Interfaces & types #### `Definition` `type Definition = (faker: Faker, index: number) => Row` — the factory definition function. --- # Feature Flags Ship the code dark, turn it on when you're ready — per user, per team, or for everyone. A **flag** is defined once (a value, or a resolver that decides per scope), asked anywhere with `feature()`, and overridden explicitly when support needs to force it on for one account. ```ts import { features, feature } from "@shaferllc/keel/core"; // a provider's boot() features().define("new-billing", (user) => (user as User).plan === "pro"); features().define("dark-mode", true); // anywhere if (await feature("new-billing", user)) { return newBillingFlow(); } ``` ## The first answer sticks The first time a flag is resolved for a scope, the answer is **persisted**. A user who saw the new thing keeps seeing it while you ramp up — a resolver edit doesn't flap experiences on the next request. Changing a decision is explicit: ```ts await features().activate("new-billing", user); // force on, this user await features().deactivate("new-billing", team); // force off, this team await features().deactivate("new-billing"); // force off, globally await features().forget("new-billing", user); // back to the resolver await features().purge("new-billing"); // every stored decision, gone ``` An **undefined flag is off**, not an error — code behind a flag nobody defined is simply dark. That means you can merge the `feature()` check before the `define()`, and delete the definition before the last check. ## Scopes A scope is `null` (global), a primitive (an id, an email), or an object with an `id` — a `User`, a `Team`, any model. Objects are keyed as `ClassName:id`, so `User#7` today matches `User#7` tomorrow. An object *without* an `id` is refused: it has no stable identity, and silently keying every request differently would make each check a fresh resolution. ```ts await feature("new-billing", user); // per user await feature("beta-api", team); // per team await feature("maintenance-banner"); // global ``` ## Rich values A flag's value is JSON, not just a boolean — it can carry a variant or a limit. `active()` is simply "truthy"; `value()` returns the payload: ```ts features().define("search", (user) => (isBetaTester(user) ? { engine: "meili" } : false)); const search = await features().value("search", user); // { engine: "meili" } | false await features().activate("search", user, { engine: "typesense" }); // override with a value ``` ## Storage Like the queue and the cache: in-memory by default (per process — right for dev and tests), a database store when a rollout must be shared and survive a deploy. Add `flagsMigration()` to your migrations and swap the store in a provider: ```ts import { setFeatures, DatabaseFlagStore, flagsMigration } from "@shaferllc/keel/core"; // database/migrations/0006_features.ts export default flagsMigration(); // a provider's register() setFeatures(new DatabaseFlagStore()); ``` Any backend is four methods — implement `FlagStore` (`get` / `set` / `delete` / `purge`, keyed by feature name and scope key) to store decisions in Redis, KV, or a vendor's flag service. ## In tests The default in-memory store makes flags deterministic already; give a test its own instance to avoid cross-test bleed: ```ts import { setFeatures, MemoryFlagStore } from "@shaferllc/keel/core"; const flags = setFeatures(new MemoryFlagStore()); flags.define("new-billing", true); ``` --- ## API reference ### `feature(name, scope?)` `feature(name: string, scope: FlagScope = null): Promise` Shorthand for `features().active(name, scope)` — is the flag on for this scope? ### `features()` / `setFeatures(storeOrInstance)` `features(): Features` returns the process-wide instance. `setFeatures(storeOrInstance: FlagStore | Features): Features` swaps the store (or the whole instance) behind it, and returns the active `Features`. ### `Features` | Method | Signature | |--------|-----------| | `define` | `(name, valueOrResolver?) => this` — a fixed value or a `(scope) => value` resolver; default `true` | | `defined` | `() => string[]` | | `value` | `(name, scope?) => Promise` — stored value, else resolve + persist | | `active` | `(name, scope?) => Promise` — `value` is truthy | | `inactive` | `(name, scope?) => Promise` | | `activate` | `(name, scope?, value?) => Promise` — force on (default `true`) | | `deactivate` | `(name, scope?) => Promise` — force off | | `forget` | `(name, scope?) => Promise` — drop the stored value; the resolver decides afresh | | `purge` | `(name?) => Promise` — drop every stored value for a flag, or all of them | ### Stores `MemoryFlagStore` (the default) and `DatabaseFlagStore({ table?, connection? })` — rows in `features` (name, scope, JSON value), unique per `(name, scope)`. `flagsMigration(table?)` is the schema. `FlagStore` is the seam: ```ts interface FlagStore { get(feature: string, scopeKey: string): Promise | unknown; set(feature: string, scopeKey: string, value: unknown): Promise | void; delete(feature: string, scopeKey: string): Promise | void; purge(feature?: string): Promise | void; } ``` `flagScopeKey(scope)` is the exported keying function — `"__global"` for null, `String(primitive)`, `ClassName:id` for objects. --- # Gates Keel Gates is a **signup gate** for private alpha / waitlist apps: an email allowlist, invite codes with use limits and expiry, and a single check that answers "may this person register?". It ships as `@shaferllc/keel/gates`. This is **not** authorization (`can` / policies in [authorization](./authorization.md)) and **not** team invitations ([teams](./teams.md)). Those answer different questions. Gates answers only: *is this email allowed to create an account?* ## Install ```ts // bootstrap/providers.ts import { GatesServiceProvider } from "@shaferllc/keel/gates"; export const providers = [AppServiceProvider, GatesServiceProvider]; ``` Then migrate — the provider contributes `invite_codes` and `email_allowlist` tables (`CREATE TABLE IF NOT EXISTS`, so existing apps stay safe): ```bash keel migrate ``` ## Checking registration ```ts import { canRegister, redeemInvite } from "@shaferllc/keel/gates"; const gate = await canRegister(email, inviteCode); if (!gate.ok) { return json({ error: gate.reason }, 403); } // …create the user… if (gate.via === "code" && gate.invite) { await redeemInvite(gate.invite); // increments uses } ``` `canRegister` returns: | Result | Meaning | |--------|---------| | `{ ok: true, via: "allowlist" }` | Email is on `email_allowlist` | | `{ ok: true, via: "code", invite }` | Valid invite code (not expired, uses left) | | `{ ok: false, reason }` | Rejected — show `reason` to the user | Allowlist wins over codes: if the email is allowlisted, the code is ignored. ## Managing codes and allowlist The models are ordinary Keel models — create rows from an admin UI or a seeder: ```ts import { InviteCode, EmailAllowlist } from "@shaferllc/keel/gates"; await InviteCode.create({ code: "ALPHA-42", max_uses: 10, uses: 0, expires_at: null, }); await EmailAllowlist.create({ email: "ada@example.com" }); ``` ## Related - [Accounts](./accounts.md) — register / login flows that call `canRegister` first - [Teams](./teams.md) — invitations *into* a team, after the user already exists - [Authorization](./authorization.md) — ability checks once they're signed in --- # Hashing & Encryption Password hashing and value encryption, both built on the Web Crypto API — so they run identically on Node and the edge, with no native bindings (no `bcrypt` to compile). Every operation is `async` (Web Crypto is promise-based), and the core never reaches for a Node-only module, so the same code ships to a Worker or a server unchanged. ## Hashing passwords `hash.make()` produces a self-describing PBKDF2-SHA256 hash (algorithm, iterations, salt, and digest are all encoded in the string): ```ts import { hash } from "@shaferllc/keel/core"; const hashed = await hash.make(password); // store this await hash.verify(hashed, password); // → true / false ``` The stored string is `pbkdf2_sha256$$$` — four `$`-joined fields. Because the salt and work factor travel with the digest, `verify()` needs nothing but the stored string and the candidate password; there is no separate salt column to manage. Each call to `make()` draws a fresh 16-byte random salt, so hashing the same password twice yields two different strings — both verify: ```ts const a = await hash.make("hunter2"); const b = await hash.make("hunter2"); a === b; // false — different salts await hash.verify(a, "hunter2"); // true await hash.verify(b, "hunter2"); // true ``` Note the argument order: `verify(hashed, password)` — the **stored hash comes first**, the plaintext second. ### Rotating the work factor Rotate work factors over time — bump the iteration count and rehash on next login, while the user's plaintext is in hand: ```ts if (await hash.verify(user.password, plain)) { if (hash.needsRehash(user.password)) { user.password = await hash.make(plain); await user.save(); } } ``` The default is 100,000 iterations. To raise the bar, pass a higher count to `make()` and check against the same number with `needsRehash()`: ```ts const hashed = await hash.make(password, 200_000); hash.needsRehash(hashed, 200_000); // false — already at target hash.needsRehash(hashed, 300_000); // true — below the new default ``` Verification is timing-safe (a constant-time compare of the derived digests), and `verify()` returns `false` — never throws — for the common malformed cases: wrong algorithm prefix or a missing field. A hash whose iteration field or salt is unparsable is a corrupt store, not a wrong password (see the note on `verify` in the reference). ### Faster tests PBKDF2 is deliberately slow, which makes a test suite that creates lots of users crawl. `hash.fake()` swaps in a trivial, **insecure** scheme (`make` returns `fake$`, `verify` just compares) so hashing is near-instant; `restore()` brings back real PBKDF2. Call them in your test setup/teardown: ```ts beforeEach(() => hash.fake()); afterEach(() => hash.restore()); ``` Never call `fake()` outside tests. ## Encrypting values `encryption` encrypts any JSON-serializable value with AES-GCM (a 256-bit key derived by SHA-256 from `config('app.key')`, a fresh 12-byte IV per call). Use it for tokens, opaque cookies, or anything you hand to a client and get back: ```ts import { encryption } from "@shaferllc/keel/core"; const token = await encryption.encrypt({ userId: 1, scope: "reset" }); const data = await encryption.decrypt<{ userId: number }>(token); // data → { userId: 1, scope: "reset" } or null if tampered / invalid ``` AES-GCM is authenticated: the tag is verified on decrypt, so any tampering with the ciphertext (or a payload produced under a different key) fails the authentication check. `decrypt()` turns that failure into `null` rather than throwing — so a bad token is just an unauthenticated request, not a crash: ```ts const value = await encryption.decrypt(untrustedInput); if (value === null) return unauthorized(); // tampered, truncated, or wrong key ``` `encrypt()` round-trips anything `JSON.stringify` accepts — objects, arrays, strings, numbers, booleans. It is not deterministic: the random IV means the same input encrypts to a different string every time, so you can't use the ciphertext as a lookup key. ### Expiring and purpose-bound tokens `encrypt()` takes options that make the ciphertext self-expire and bind it to a context — ideal for one-shot links like password resets or email confirmations: ```ts const token = await encryption.encrypt( { userId: 1 }, { expiresIn: "1h", purpose: "password-reset" }, ); // later — decrypt with the SAME purpose, or you get null const data = await encryption.decrypt(token, { purpose: "password-reset" }); ``` `expiresIn` is seconds or a duration string (`"30m"`, `"1h"`, `"7d"`); an expired token decrypts to `null`. `purpose` binds the token to a use — decrypting with a different purpose (or none) returns `null`, so a reset token can't be replayed as, say, a login token. Both travel inside the ciphertext, so they can't be tampered with. Tokens made without these options keep decrypting as before. ## The app key Both encryption and [signed URLs](./url-builder.md) use `config('app.key')`. Set a long, random `APP_KEY` and keep it secret: ``` APP_KEY=a-long-random-secret-value ``` If `app.key` is unset, `encrypt()` **throws** (`Encryption requires config('app.key'). Set APP_KEY.`) — encryption can't proceed without a key. On the read side `decrypt()` still returns `null` (the missing-key error is caught alongside every other decrypt failure), so a misconfigured key surfaces as failed decryption, not an exception. Changing the key invalidates every previously encrypted value and signed URL — old ciphertext no longer authenticates under the new key, so every prior token decrypts to `null`. --- ## API reference Two objects are exported: `hash` (password hashing) and `encryption` (reversible value encryption). Both are plain objects — import and call their methods directly; there's nothing to construct. ### `hash` PBKDF2-SHA256 password hashing. All three methods work on the self-describing string format `pbkdf2_sha256$$$`. #### `hash.make(password, iterations?)` `make(password: string, iterations?: number): Promise` Hashes a password with PBKDF2-SHA256 and a fresh random 16-byte salt, returning the self-describing hash string. ```ts const hashed = await hash.make(password); // 100,000 iterations const stronger = await hash.make(password, 200_000); ``` **Notes:** `iterations` defaults to `100_000`. The salt is drawn from `crypto.getRandomValues`, so the output differs on every call; the digest is 256-bit. Async because Web Crypto's `deriveBits` is. Store the whole returned string — it carries everything `verify` needs. #### `hash.verify(hashed, password)` `verify(hashed: string, password: string): Promise` Re-derives the digest from `password` using the salt and iteration count embedded in `hashed`, and compares it constant-time. **Stored hash first, plaintext second.** ```ts if (await hash.verify(user.password, submitted)) { // authenticated } ``` **Notes:** the compare is timing-safe. Returns `false` (never throws) when the algorithm prefix isn't `pbkdf2_sha256` or any of the four fields is missing. Caveat: a hash with the right prefix but a *non-numeric* iteration field or an *invalid-base64* salt slips past those guards and makes the underlying Web Crypto call throw — so "never throws" holds for genuine wrong-password and simple malformed cases, but not for a corrupted store. Treat your hash column as trusted. #### `hash.needsRehash(hashed, iterations?)` `needsRehash(hashed: string, iterations?: number): boolean` Returns `true` when `hashed` was made with fewer iterations than the given target — your cue to re-hash the plaintext at the current work factor. ```ts if (hash.needsRehash(user.password)) { user.password = await hash.make(plain); } ``` **Notes:** synchronous (it just reads the iteration field). `iterations` defaults to `100_000`. Returns `true` if the iteration field is absent or unparsable (a `0`/`NaN` count reads as "below target"). Only meaningful right after a successful `verify`, when you still hold the plaintext to re-hash. ### `encryption` Authenticated (AES-GCM) encryption of JSON-serializable values, keyed by `config('app.key')`. #### `encryption.encrypt(value)` `encrypt(value: unknown): Promise` JSON-serializes `value`, encrypts it with AES-GCM under a key derived from `config('app.key')`, and returns a base64 string (IV prepended to the ciphertext). ```ts const token = await encryption.encrypt({ userId: 1, scope: "reset" }); ``` **Notes:** each call uses a fresh random 12-byte IV, so the output is non-deterministic — never use the ciphertext as a cache/lookup key. Throws `Encryption requires config('app.key'). Set APP_KEY.` if the app key is unset. `value` must survive `JSON.stringify` (no `undefined`, functions, or `BigInt`). #### `encryption.decrypt(payload)` `decrypt(payload: string): Promise` Reverses `encrypt`: verifies the AES-GCM tag, decrypts, and `JSON.parse`s the result. The type parameter types the resolved value. ```ts const data = await encryption.decrypt<{ userId: number }>(token); if (data === null) return unauthorized(); ``` **Notes:** returns `null` — never throws — for any failure: tampered or truncated ciphertext, a payload encrypted under a different key, malformed base64, or an unset app key (all caught internally). `T` is a compile-time convenience; it does not validate the decrypted shape at runtime. --- # Health Checks Two endpoints, answering the two questions an orchestrator — Kubernetes, Fly, Railway, a load balancer — actually asks: | Endpoint | Question | Checks | |----------|----------|--------| | `/health/live` | Is the process up? | **nothing** — answers instantly | | `/health/ready` | Can it serve requests? | every registered check | The split matters. A liveness probe that touched the database would get a perfectly healthy app **restarted** during a database blip. So liveness checks nothing: if it answered, the process is alive. Readiness is where dependencies are checked — a 503 there pulls the instance out of the load-balancer pool without killing it, and it rejoins when the dependency recovers. ## Using it Register your checks once, then install the middleware: ```ts import { health, healthCheck, HttpKernel, DatabaseCheck, RedisCheck } from "@shaferllc/keel/core"; export class HealthServiceProvider extends ServiceProvider { boot(): void { health().register([ new DatabaseCheck(), new RedisCheck().cacheFor(30), ]); this.app.make(HttpKernel).use(healthCheck()); } } ``` `GET /health/ready` now returns: ```json { "isHealthy": true, "status": "ok", "finishedAt": "2026-07-11T18:04:22.014Z", "checks": [ { "name": "database", "status": "ok", "message": "Database is reachable", "isCached": false, "finishedAt": "2026-07-11T18:04:22.011Z", "meta": { "durationMs": 3 } } ] } ``` with **200** while `isHealthy` is true and **503** the moment a check fails. ## Built-in checks | Check | What it does | |-------|--------------| | `DatabaseCheck` | `SELECT 1` on the default connection, or `new DatabaseCheck("reporting")` for a named one | | `RedisCheck` | Reads a key — a failed read means a broken connection | | `CacheCheck` | Writes a key, reads it back, deletes it | Notably **absent**: disk-space, heap, and RSS checks. Those measure a Node process, and on Workers there isn't one — a memory threshold you can't observe is worse than no check at all. If you're on Node and want them, `check()` below takes ten lines. ## Your own checks `check(name, fn)` builds one from a function. Return `Result.ok`, `Result.warning`, or `Result.failed`: ```ts import { check, Result, health } from "@shaferllc/keel/core"; health().register([ check("stripe", async () => { const res = await fetch("https://api.stripe.com/healthcheck"); return res.ok ? Result.ok("Stripe is reachable") : Result.failed(`Stripe returned ${res.status}`); }), check("queue-depth", async () => { const depth = await queue().size(); if (depth > 10_000) return Result.failed(`Queue is backed up (${depth})`); if (depth > 1_000) return Result.warning(`Queue is deep (${depth})`); return Result.ok("Queue is keeping up").withMeta({ depth }); }), ]); ``` **A warning is still healthy.** It shows up in the report and moves the overall `status` to `"warning"`, but readiness stays **200** — degraded is not the same as unable to serve, and you don't want a slow-but-working queue to evict every instance you have. Only `Result.failed` returns a 503. `withMeta()` attaches arbitrary detail (latencies, counts, versions) to the check's entry in the report. For a check with dependencies or state, extend `BaseCheck` instead: ```ts class QueueDepthCheck extends BaseCheck { readonly name = "queue-depth"; constructor(private limit = 10_000) { super(); } async run(): Promise { const depth = await queue().size(); return depth > this.limit ? Result.failed(`Queue is backed up (${depth})`) : Result.ok("Queue is keeping up").withMeta({ depth }); } } ``` **A check that throws becomes a failure**, not an exception — one broken check never takes down the whole report, and the other checks still run and report. ## Caching a check A probe every few seconds shouldn't hammer the thing it's probing. `cacheFor(seconds)` reuses the last result inside that window, and the report marks it `isCached: true`: ```ts health().register([ new DatabaseCheck(), // run every time — it's cheap new RedisCheck().cacheFor(30), // at most once every 30s ]); ``` ## Protecting the endpoint The readiness report names your infrastructure, so don't publish it. Pass a `secret` and readiness requires `Authorization: Bearer `: ```ts this.app.make(HttpKernel).use(healthCheck({ secret: env.HEALTH_SECRET })); ``` Liveness stays open — the orchestrator's restart probe shouldn't need a key, and it reveals nothing. --- ## API reference ### `health()` `health(): HealthChecks` The application's health-check registry — a singleton. ### `healthCheck(options?)` `healthCheck(options?: HealthCheckOptions): MiddlewareHandler` Serves `/health/live` and `/health/ready`. Anything else falls through to your routes. **Options:** `basePath` (default `"/health"`), `secret` (bearer token required on readiness), `checks` (a `HealthChecks` to run instead of the global registry — handy in tests). ### `HealthChecks` | Method | Signature | |--------|-----------| | `register` | `(checks: BaseCheck[]) => this` | | `run` | `() => Promise` — runs every check concurrently | | `all` | `() => BaseCheck[]` | | `clear` | `() => this` | ### `check(name, fn)` `check(name: string, run: () => Promise | Result): BaseCheck` Builds a check from a function. ### `BaseCheck` Abstract. Implement `name` and `run(): Promise`. `cacheFor(seconds)` reuses the last result for a window. ### `Result` `Result.ok(message)` / `Result.warning(message)` / `Result.failed(message, error?)`, plus `withMeta(data)` to attach detail. A warning keeps readiness at 200; a failure takes it to 503. ### Interfaces & types #### `HealthReport` ```ts { isHealthy: boolean; // false only if a check failed status: HealthStatus; // the worst status any check reported finishedAt: string; // ISO 8601 checks: CheckReport[]; } ``` #### `CheckReport` ```ts { name: string; status: HealthStatus; message: string; isCached: boolean; // reused from a cacheFor() window finishedAt: string; meta?: Record; } ``` #### `HealthStatus` `type HealthStatus = "ok" | "warning" | "error"`. --- # Helpers Keel gives you a handful of **global helper functions** so you can reach the running application from anywhere — a route handler, a model, a plain function — without threading a container reference through every call. `config('app.name')`, `cache().get(…)`, `emit('user.registered', user)`: no `this.app`, no imports of the container. They all resolve against the **active application**, which registers itself the moment an `Application` is constructed. In a normal single-app process — one Node server, or one Worker isolate — that's exactly the app you mean, so the globals just work. ```ts import { config, cache, emit, logger, view } from "@shaferllc/keel/core"; const name = config("app.name", "Keel"); const stats = await cache().remember("stats", 60, () => computeStats()); await emit("user.registered", user); logger().info("welcome sent", { userId: user.id }); ``` ## How they resolve Every helper is sugar over `app()` — the one helper that returns the active `Application`. `config()` is `app().make(Config).get(…)`; `cache()` is `app().make(Cache)`; `make()` is `app().make(…)`. So the whole set shares one precondition: **an application must exist first.** Call any helper before bootstrapping and `app()` throws: ``` No Keel application has been bootstrapped. Create an Application first. ``` In practice the application is created at boot, long before any request runs, so you never see this outside of a bare unit test that forgot to construct one. ## The map The helpers fall into groups, most of which have a dedicated guide. This page is the quick index — reach for the deep doc when you need the full surface. | Helper(s) | What it reaches | Deep dive | | --- | --- | --- | | `app` | the active `Application` | this page | | `config` | configuration values | [configuration](./configuration.md) | | `bind` `singleton` `instance` `make` `bound` | the service container | [container](./container.md) | | `events` `emit` `listen` | the event emitter | [events](./events.md) | | `cache` | the cache | [cache](./cache.md) | | `logger` | the logger | [logger](./logger.md) | | `view` | the view renderer | [views](./views.md) | ## Container helpers, up close The five container helpers let you register and resolve services from anywhere, exactly as `app().bind(…)` would — handy inside a factory or a helper function that has no container reference of its own: ```ts import { singleton, make, bound } from "@shaferllc/keel/core"; singleton(Mailer, (app) => new Mailer(app.make(Config))); const mailer = make(Mailer); if (bound("clock")) { /* someone registered it */ } ``` The factory you pass to `bind`/`singleton` receives the container, so a service can resolve its own dependencies. Unlike the `Container` methods (which return `this` to chain), the `bind`/`singleton` **helpers return `void`** — there's no builder to chain off of at the global level. See [container](./container.md) for the binding lifecycle, auto-resolution, and tokens. ## Events, cache, logger `events()`, `cache()`, and `logger()` each return the singleton service, so you call methods on the result: ```ts import { events, cache, logger, listen, emit } from "@shaferllc/keel/core"; listen("order.paid", (order) => fulfil(order)); // subscribe await emit("order.paid", order); // fan out, awaiting listeners events().listenerCount("order.paid"); // the emitter itself await cache().put("otp", code, 300); logger().warn("retrying", { attempt: 2 }); ``` `emit` and `listen` are shortcuts over `events().emit` / `events().on`, so you rarely need `events()` directly — reach for it when you want `once`, `off`, `listenerCount`, or `clear`. Full surface in [events](./events.md), [cache](./cache.md), and [logger](./logger.md). ## Rendering a view `view()` renders a component to a complete HTML document in one call — return it straight from a handler. Props are type-checked against the component: ```ts import { view } from "@shaferllc/keel/core"; function Welcome({ appName }: { appName: string }) { return `

Welcome to ${appName}

`; } return view(Welcome, { appName: "Keel" }); // Promise return view(HomePage); // no props ``` See [views](./views.md) for the component contract and async (Suspense) rendering. ## Related These globals are the front door to services documented in depth elsewhere: [configuration](./configuration.md), the [container](./container.md), [events](./events.md), [cache](./cache.md), [logger](./logger.md), and [views](./views.md). Everything here is a thin, typed shortcut into one of those. --- ## API reference Every helper below is exported from `@shaferllc/keel/core`. All of them resolve against the active application and therefore throw `No Keel application has been bootstrapped…` if called before one is created. ### `app()` `app(): Application` Returns the active `Application` — the container everything else resolves out of. ```ts import { app } from "@shaferllc/keel/core"; const port = app().config().get("app.port", 3000); ``` **Notes:** throws if no application has been constructed yet. Every other helper on this page is built on `app()`, so this is the single point where a "no application" error can originate. ### `config(key, fallback?)` `config(key: string, fallback?: T): T` Reads a configuration value by dot-path, returning `fallback` when the path is missing. ```ts config("app.name"); config("app.port", 3000); // 3000 if unset ``` **Notes:** shorthand for `app().make(Config).get(key, fallback)`. Read-only — use `app().make(Config).set(…)` to write. See [configuration](./configuration.md). ### `view(component, props?)` `view

(component: (props: P, ...rest: any[]) => Renderable, props: P): Promise` `view(component: (...rest: any[]) => Renderable): Promise` Renders a component (with optional props) to a complete HTML document. ```ts return view(Welcome, { appName: "Keel" }); return view(HomePage); ``` **Notes:** props are type-checked against the component's parameter. Resolves to a `Promise` (a full HTML document, doctype included) — return it directly from a route handler. Sugar over `app().make(View).render(component(props))`. See [views](./views.md). ### `bind(token, factory)` `bind(token: Token, factory: Factory): void` Registers a **transient** binding — the factory runs on every `make`. ```ts bind("clock", () => new Date()); ``` **Notes:** the factory receives the container. Returns `void` (the underlying `Container.bind` returns `this`, but the helper does not). See [container](./container.md). ### `singleton(token, factory)` `singleton(token: Token, factory: Factory): void` Registers a **shared** binding — the factory runs once, then the value is cached. ```ts singleton(Mailer, (app) => new Mailer(app.make(Config))); ``` **Notes:** the cached value lives for the life of the application. Returns `void`. See [container](./container.md). ### `instance(token, value)` `instance(token: Token, value: T): T` Registers an already-constructed value as a shared instance, and returns it. ```ts const version = instance("app.version", "0.30.0"); ``` **Notes:** unlike `bind`/`singleton`, this returns the value you passed in, so you can register-and-use in one expression. See [container](./container.md). ### `make(token)` `make(token: Token): T` Resolves a token out of the container. ```ts const mailer = make(Mailer); const version = make("app.version"); ``` **Notes:** a zero-arg class token resolves even without an explicit binding (the container builds it); an unbound string/symbol token throws `Nothing bound in the container for […]`. See [container](./container.md). ### `bound(token)` `bound(token: Token): boolean` `true` if the token has a binding or a cached instance. ```ts if (bound("clock")) make("clock"); ``` **Notes:** a guard for optional services. Note a class token that `make` could auto-build still reports `false` here until it's explicitly bound. See [container](./container.md). ### `events()` `events(): Events` Returns the application's event emitter singleton. ```ts events().listenerCount("order.paid"); events().clear("order.paid"); ``` **Notes:** use for `once`, `off`, `listenerCount`, and `clear`; for the common subscribe/emit pair prefer `listen`/`emit` below. See [events](./events.md). ### `emit(event, payload?)` `emit(event: string, payload?: T): Promise` Emits an event, awaiting every listener in registration order. ```ts await emit("user.registered", user); ``` **Notes:** shorthand for `events().emit(…)`. The returned promise resolves once all listeners (including async ones) have run. No listeners → resolves immediately. See [events](./events.md). ### `listen(event, listener)` `listen(event: string, listener: Listener): () => void` Subscribes to an event; returns an unsubscribe function. ```ts const off = listen("user.registered", (user) => sendWelcome(user)); off(); // stop listening ``` **Notes:** shorthand for `events().on(…)`. The listener may be sync or async. Call the returned function to remove it. See [events](./events.md). ### `cache()` `cache(): Cache` Returns the application's cache singleton. ```ts const stats = await cache().remember("stats", 60, () => computeStats()); await cache().put("otp", code, 300); ``` **Notes:** memory-backed per process/isolate by default; swap the store via a `singleton(Cache, …)` binding. See [cache](./cache.md). ### `logger()` `logger(): Logger` Returns the application's logger singleton. ```ts logger().info("user registered", { userId: user.id }); logger().error("payment failed", { orderId }); ``` **Notes:** structured JSON by default. `logger().child({ … })` returns a logger with bound fields (e.g. a request id). See [logger](./logger.md). ### Interfaces & types The helpers surface a few types from the services they front. You implement or pass these; you rarely construct them here. #### `Listener` `type Listener = (payload: T) => void | Promise` The shape of a function passed to `listen`. Sync or async; the payload type flows from `listen`. ```ts const onOrder: Listener<{ id: number }> = async (order) => fulfil(order.id); listen("order.paid", onOrder); ``` #### `Token` / `Factory` `type Token = string | symbol | Constructor` `type Factory = (app: Container) => T` The key and the factory used by `bind`/`singleton`/`instance`/`make`/`bound`. A token is a string, symbol, or class constructor; a factory receives the container so it can resolve its own dependencies. Documented in full under [container](./container.md). #### `Renderable` `type Renderable = string | Promise | { toString(): string | Promise } | null | undefined` What a component passed to `view()` may return — a string, a JSX node, a promise of either, or nullish (renders empty). Documented in full under [views](./views.md). --- # Built on Hono Keel's HTTP layer **is** [Hono](https://hono.dev) — an ultrafast, web-standard router that runs on Node, Cloudflare Workers, Deno, Bun, and more. Keel adds the container, providers, routing sugar, and helpers on top; everything Hono can do is available to you underneath. Keel's convenience helpers (`json()`, `param()`, `request`, `response`, `view()`) are thin wrappers over Hono's context. You never have to use them — you can always take the context (`c`) directly and use the full Hono API. ## What Hono provides, what Keel adds The division of labor is worth holding in your head, because it tells you which docs to reach for: | Concern | Owned by | |---------|----------| | The `fetch` handler, request matching, method routing | **Hono** | | `Context` — `c.req`, `c.json`, `c.html`, cookies, headers | **Hono** | | JSX rendering (`hono/jsx`), streaming, SSE, WebSockets | **Hono** | | Runtime adapters (Node, Workers, Deno, Bun, Lambda) | **Hono** | | The service container, providers, config, the console | **Keel** | | Fluent routing: names, groups, resources, param matchers, URL generation | **Keel** ([routing](./routing.md)) | | `[Controller, method]` handlers resolved from the container | **Keel** ([controllers](./controllers.md)) | | Request/response helpers that reach `c` without threading it | **Keel** ([request & response](./request-response.md)) | | `view()`, error rendering, exceptions, validation | **Keel** | Hono is the engine; Keel is the wheelhouse. Keel never hides Hono — it sits beside it. When Keel wraps a Hono feature it's for ergonomics (fluent routes, container DI, ambient helpers), and the raw feature is always one `c` away. ## The context (`c`) Every closure handler receives Hono's `Context`, and controller methods can too: ```ts router.get("/users/:id", (c) => { c.req.param("id"); // route param c.req.query("q"); // query string c.req.header("authorization"); await c.req.json(); // parse a JSON body return c.json({ ok: true }); // c.text() · c.html() · c.body() · c.redirect() }); ``` Keel's `Ctx` type is exactly Hono's `Context` — it's a re-export, not a wrapper: ```ts import type { Ctx } from "@shaferllc/keel/core"; // type Ctx = import("hono").Context ``` So a Keel handler and a Hono handler have the identical signature. Anything that accepts a Hono `Context` accepts a Keel `Ctx`, and vice versa — there is no adapter, boxing, or conversion between the two. When a guide says "the request context," it means this object. Common context surface: `c.req.{param, query, header, json, parseBody, valid, path, method, url, raw}`, `c.{json, text, html, body, redirect, status, header, notFound}`, `c.set/c.get` for request-scoped variables, and on Workers `c.env` (bindings like D1/KV/R2) and `c.executionCtx` (`waitUntil`). Full reference: [hono.dev/docs/api/context](https://hono.dev/docs/api/context). Keel does set a few request-scoped variables of its own on the context, which you can read with `c.get(...)`: - `c.get("app")` — the service container for this request. - `c.get("route")` — the matched route (`{ name, pattern, methods }`). - `c.get("subdomains")` — captured subdomain params on domain-bound routes. These are exactly what the ambient [request helpers](./request-response.md) read under the hood. That's the trade: the helpers are terse and don't need `c` passed around, but they only work inside a request; `c` is explicit and works anywhere you're handed it. ## Hono middleware works as-is Any Hono middleware — built-in or third-party — drops straight into Keel's kernel or onto a route, because Keel middleware **is** Hono middleware: ```ts import { cors } from "hono/cors"; import { secureHeaders } from "hono/secure-headers"; import { compress } from "hono/compress"; // app/Http/Kernel.ts this.use(cors()); this.use(secureHeaders()); this.use(compress()); ``` Hono ships CORS, Secure Headers, Body Limit, Cache, Compress, ETag, Basic/Bearer Auth, JWT, Logger, and more — see [hono.dev/docs/middleware/builtin](https://hono.dev/docs/middleware/builtin). Keel just gives you nicer places to attach them: global in the kernel, or per-route and per-group via the fluent router. [Middleware](./middleware.md) covers the ordering and named-middleware conveniences Keel layers on top. ## What else you get from Hono Because Keel is Hono underneath, these are all available directly: | Hono feature | Use it in Keel | |--------------|----------------| | **JSX** | Keel [views](./views.md) are Hono JSX (`hono/jsx`) | | **Cookies** | `hono/cookie`; Keel wraps common cases in `request.cookie` / `response.cookie` | | **Streaming / SSE** | `hono/streaming` — return a streamed `Response` from a handler | | **WebSockets** | Hono's upgrade helpers on supported runtimes | | **Testing** | `hono.request(path, init)` — exactly what Keel's own test suite uses | | **Validators / RPC** | `hono/validator`, the `hc` typed client | | **Runtime adapters** | Node (`@hono/node-server`), Workers, Deno, Bun, Lambda | [Views](./views.md) are the clearest example of Keel building on a Hono primitive: a Keel view is a Hono JSX function component, and `view()` just renders it to a full HTML document through the `View` service. Drop the helper and `return c.html()` and you get the same result — Keel's version only adds the doctype and props typing. ## When to drop to raw Hono Reach for `c` and the Hono API directly when: - You need a context feature Keel doesn't wrap — `c.executionCtx.waitUntil`, streaming responses, content negotiation beyond the helpers, Workers bindings on `c.env`. - You're pulling in a Hono (or Hono-ecosystem) middleware — it already speaks the native `Context`, so hand it `c` unchanged. - You want the typed RPC client (`hc`) or `hono/validator`'s `c.req.valid(...)`. Reach for Keel's helpers and fluent router when you want named routes, groups, resource routes, container-resolved controllers, or the ambient `request`/`response` accessors. The two mix freely in the same handler — start with Keel's ergonomics and drop to `c` for the exact spot that needs it. Nothing you do at the Hono level is "off the map"; it's the same object either way. ## Reaching the Hono app The HTTP kernel compiles your routes onto a Hono instance and returns it — that's the `fetch` handler you serve (Node) or export (Workers). If you need to attach something at the Hono level, do it where you build the kernel: ```ts const hono = new Kernel(app).build(); // a Hono instance // hono.get(...), hono.use(...), export default hono, serve({ fetch: hono.fetch }) ``` Because `build()` hands back a plain Hono app, the same kernel serves every runtime — `serve({ fetch: hono.fetch })` under Node, `export default hono` on Workers. That single return value is the seam that keeps Keel edge-portable (see [Architecture](./architecture.md#edge-safe-by-design)). For anything HTTP-layer that Keel doesn't wrap yet, drop down to Hono — the docs at [hono.dev](https://hono.dev/docs/) apply directly. ## Serving over HTTP/2 There's nothing to configure in Keel for HTTP/2 — it's a transport concern, not a framework one. Keel's handlers are Hono's fetch-based `Request`/`Response`, which are HTTP-version-agnostic, so the protocol is decided entirely by whatever serves the `fetch` handler: - **On the edge** (Cloudflare Workers — the headline target), the platform terminates HTTP/2 *and* HTTP/3 for you. Nothing to do, nothing to control from app code. - **In Node production**, HTTP/2 is almost always terminated at a reverse proxy or CDN (nginx, Cloudflare, ALB) in front of the process — the usual setup. - **In-process h2**, if you really want it, is a `@hono/node-server` option — hand `serve()` a `node:http2` server. No Keel change: ```ts import { serve } from "@hono/node-server"; import { createSecureServer } from "node:http2"; import { readFileSync } from "node:fs"; const hono = new Kernel(app).build(); serve({ fetch: hono.fetch, createServer: createSecureServer, serverOptions: { key: readFileSync("key.pem"), cert: readFileSync("cert.pem") }, }); ``` So HTTP/2 is available on every Keel deployment without the framework implementing anything — the same `fetch` handler just gets served over it. --- # Lifecycle Hooks Tap into the **application lifecycle** — run code once the app is ready, clean up on shutdown, and observe route registration. > **Request-lifecycle hooks** (before/after a request, on error) are > [middleware](./middleware.md) in Keel — `HttpKernel.use()`, route/group > `.middleware()`, and `onError()`. This page is the *application* lifecycle: > ready, shutdown, and route registration. ## onReady Run a callback once the application has finished booting (all providers registered and booted). Register it before boot; if the app is already booted, it runs immediately. ```ts import { onReady } from "@shaferllc/keel/core"; onReady(async (app) => { await warmCaches(); logger().info("app ready"); }); ``` ## Graceful shutdown `onShutdown` registers cleanup — closing database/Redis connections, flushing queues, draining work. Hooks run **newest-first (LIFO)** when you call `terminate()`, so teardown unwinds in the reverse order things were set up: ```ts import { onShutdown, terminate } from "@shaferllc/keel/core"; onShutdown(async () => { await db().close?.(); await redis().flushAll(); }); ``` **`keel serve` already traps SIGINT and SIGTERM**, stops accepting connections, and calls `terminate()` for you — so the hook above just runs. You only wire signals by hand in a **custom entrypoint** that doesn't go through `keel serve`: ```ts for (const signal of ["SIGINT", "SIGTERM"] as const) { process.on(signal, async () => { await terminate(); // runs every shutdown hook process.exit(0); }); } ``` A [service provider](./providers.md)'s `shutdown()` method joins the same queue, so provider teardown and hand-registered `onShutdown` hooks unwind together. `terminate()` is **idempotent** — a second call does nothing. A hook that throws doesn't stop the others; the first error is re-thrown after all have run, so one failing cleanup can't strand the rest. ## onRoute Observe routes as they're registered — for request logging, an API map, or metrics. The hook is called for each route added *after* registration, and replayed for routes already registered, so you see them all regardless of order: ```ts const router = app.make(Router); router.onRoute((def) => { logger().debug("route", { methods: def.methods, path: def.path, name: def.name }); }); ``` The `def` is the live route definition, so reading it later reflects fluent config applied after `add()` — `.name()`, `.middleware()`, and so on. ## API reference ### `onReady(hook)` `onReady(hook: (app: Application) => void | Promise): void` Global helper — registers a ready hook on the active application. Runs after boot (or immediately if already booted). Also available as `app.onReady(hook)`. ### `onShutdown(hook)` `onShutdown(hook: (app: Application) => void | Promise): void` Registers a shutdown hook on the active application. Also `app.onShutdown(hook)`. ### `terminate()` `terminate(): Promise` Gracefully shuts the active application down — runs every shutdown hook LIFO. Idempotent. Re-throws the first hook error after running all. Also `app.terminate()`. ### `Application.onReady` / `onShutdown` / `terminate` The same three as methods on the `Application`, returning `this` (chainable) for `onReady`/`onShutdown`. `app.isTerminated` reports whether `terminate()` has run. ### `Router.onRoute(hook)` `onRoute(hook: (def: RouteDefinition) => void): this` Called with each route's definition as it's registered, and replayed for existing routes. Chainable. ### `LifecycleHook` `type LifecycleHook = (app: Application) => void | Promise` The signature of `onReady` / `onShutdown` hooks. --- # Hosting Keel Hosting is a small toolkit for **hosted Workers / D1 apps**: a Cloudflare REST client, hostname helpers, a SQLite-compatible SQL dump, and purpose-scoped secret encryption. It ships as `@shaferllc/keel/hosting`. This is infrastructure — not a control plane. Site orchestration, plans, and deploy loops live in your app (for example Keel Cloud). ## Install ```ts import { CloudflareClient, cloudflareConfigured, normalizeHostname, isValidHostname, zoneCandidates, dumpConnection, normalizeSecretKey, encryptSecretValue, decryptSecretValue, resolveSecretRows, } from "@shaferllc/keel/hosting"; ``` No service provider — import what you need. ## Cloudflare ```ts const creds = { accountId: process.env.CF_ACCOUNT_ID!, apiToken: process.env.CF_API_TOKEN!, }; if (!cloudflareConfigured(creds)) { throw new Error("Cloudflare credentials missing"); } const cf = new CloudflareClient(creds); const db = await cf.createD1Database("kc-acme"); ``` Credentials are constructor args — no app config coupling. Optional `pinnedZoneId` / `pinnedZoneName` skip a zone lookup when you already know the primary zone. ## Hostnames ```ts const host = normalizeHostname("https://App.Example.com/"); // "app.example.com" isValidHostname(host); // true zoneCandidates(host); // ["app.example.com", "example.com"] ``` `zoneCandidates` walks from most-specific to apex — useful when attaching a Workers Custom Domain and you need to find which zone owns the name. ## SQL dump Dump any SQLite-compatible `Connection` to a portable `.sql` script (schema + data). Useful for export / escape hatches: ```ts import { db } from "@shaferllc/keel/core"; import { dumpConnection } from "@shaferllc/keel/hosting"; const sql = await dumpConnection(db(), "Acme local D1", { generatedBy: "Keel Cloud" }); // write sql to a .sql file; restore with sqlite3 / D1 import ``` ## Secrets Encrypt vault values with Keel's purpose-scoped encryption (`config('app.key')` must be set). Keys are normalized to `ENV_STYLE` identifiers: ```ts const key = normalizeSecretKey("stripe-secret-key"); // "STRIPE_SECRET_KEY" const encrypted = await encryptSecretValue(secret, "app-secret"); const plain = await decryptSecretValue(encrypted, "app-secret"); const env = await resolveSecretRows( [{ key: "STRIPE_SECRET_KEY", value_encrypted: encrypted }], "app-secret", ); // { STRIPE_SECRET_KEY: "…" } ``` Your app owns the table of rows (`owner_id`, `key`, `value_encrypted`); hosting only encrypts and decrypts. ## Related - [Gates](./gates.md) — private-alpha signup gating used by hosted control planes - [Starter kits](./starter-kits.md) — presets Cloud scaffolds from - [Building with AI](./ai.md) — MCP Cloud tools (`keel_cloud_*`) that drive hosting --- # Internationalization Translations with ICU message formatting, plus the `Intl` formatters that go with them. ```ts import { setTranslations, t } from "@shaferllc/keel/core"; setTranslations({ en: { "cart.items": "{count, plural, =0 {Your cart is empty} one {# item} other {# items}}" }, fr: { "cart.items": "{count, plural, =0 {Panier vide} one {# article} other {# articles}}" }, }); t("cart.items", { count: 3 }); // "3 items" — in the request's locale ``` There is **no dependency here, and there doesn't need to be.** `Intl` ships with every modern runtime — Node and Cloudflare Workers both carry the full ICU data — so plurals, currencies, dates, and relative times are the platform's job. What Keel adds is the message parser on top, which is the part `Intl` doesn't do. ## Setting it up Register translations once (in a service provider), and add the middleware that works out each request's locale: ```ts import { setI18n, I18nManager, setTranslations, detectLocale, HttpKernel } from "@shaferllc/keel/core"; export class I18nServiceProvider extends ServiceProvider { boot(): void { setI18n(new I18nManager({ defaultLocale: "en" })); setTranslations({ en: await import("../resources/lang/en.json", { with: { type: "json" } }).then((m) => m.default), fr: await import("../resources/lang/fr.json", { with: { type: "json" } }).then((m) => m.default), }); this.app.make(HttpKernel).use(detectLocale()); } } ``` Now `t()` works anywhere in the request — a controller, a view, a transformer — without threading a locale through every call. ## Translation files Nested objects and flat dot-keys are the same thing, and you can mix them: ```json { "cart": { "items": "{count, plural, one {# item} other {# items}}", "empty": "Your cart is empty" }, "checkout.title": "Checkout" } ``` Both `t("cart.items")` and `t("checkout.title")` resolve. ## The message format The supported ICU subset is the part people actually use. ### Interpolation ``` Hello {name}! ``` ### Plurals ``` {count, plural, =0 {Your cart is empty} one {# item} other {# items}} ``` `#` becomes the count, formatted for the locale (`1,234 items`). An exact `=N` branch beats the plural category — which is the whole point of `=0`, because "Your cart is empty" reads better than "0 items". **Categories are the locale's, not English's.** French treats 0 and 1 as singular; Polish has `one`/`few`/`many`/`other`. That's exactly why you write a message rather than `count === 1 ? "item" : "items"` — that ternary is a bug in most of the world. ### Ordinals ``` {n, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} ``` → 1st, 2nd, 3rd, 4th, 11th. ### Select ``` {gender, select, male {He} female {She} other {They}} replied ``` An unmatched value (or a missing one) takes the `other` branch. ### Numbers, dates, times ``` {n, number} 1,234.5 {n, number, percent} 25% {n, number, integer} 4 {n, number, ::currency/USD} $9.50 {d, date, medium} Jul 11, 2026 {d, time, short} 3:30 PM ``` ### Nesting Branches are themselves messages, so they nest as deep as you need: ``` {count, plural, =0 {No messages for {name}} one {{name} has # message} other {{name} has # messages}} ``` ### Literal braces `'{'` and `'}'` render as literal braces. ## Formatters `Intl`, bound to the request's locale: ```ts const l = i18n(); l.formatNumber(1234.5); // "1,234.5" (de-DE: "1.234,5") l.formatCurrency(9.5, "USD"); // "$9.50" l.formatDate(order.createdAt); // "Jul 11, 2026" l.formatTime(order.createdAt); // "3:30:00 PM" l.formatRelativeTime(post.publishedAt); // "3 days ago" l.formatList(["a", "b", "c"]); // "a, b, and c" l.formatList(names, { type: "disjunction" }); // "a, b, or c" l.formatPlural(5); // "other" l.formatDisplayName("fr"); // "French" ``` `formatRelativeTime` picks a sensible unit from the distance on its own — seconds, hours, days — or takes one: `formatRelativeTime(date, "hour")`. These are worth using even in a **single-locale** app: they're the correct way to render money and dates, and they cost nothing. ## Locale detection `detectLocale()` works out the request's locale and stashes it, in this order: 1. a custom `resolve(c)` you supply 2. a query param — `detectLocale({ query: "lang" })` → `?lang=fr` 3. a cookie — `detectLocale({ cookie: "locale" })` 4. the `Accept-Language` header (turn it off with `header: false`) 5. the default locale **Only supported locales are honored**, so `?lang=xx` can't push the app into a locale you have no translations for — it falls through to the next source. `negotiateLocale()` is the header parser on its own, if you want it: ```ts negotiateLocale("fr-CA,fr;q=0.9,en;q=0.8", ["en", "fr"], "en"); // "fr" ``` It honors `q` weights and matches `fr-CA` against a supported `fr`. ## Fallbacks A key with no translation in the active locale falls back — down a chain, not off a cliff: 1. the locale itself (`es-MX`) 2. its configured fallback (`fallbackLocales: { "es-MX": "es" }`) 3. its **base language** (`es`) 4. the default locale (`en`) So you can ship `es` fully and `es-MX` as a handful of regional overrides, and everything else still resolves: ```ts setTranslations({ es: { greeting: "Hola", chair: "silla" }, "es-MX": { chair: "banca" }, // just the override }); i18n("es-MX").t("chair"); // "banca" i18n("es-MX").t("greeting"); // "Hola" — from `es` ``` ## Missing keys A missing key **does not throw**. It renders as the key itself (`cart.items`), so the page still works and the gap is obvious rather than blank. It also fires an `i18n.missing` event, which is how you find them in production: ```ts listen("i18n.missing", ({ key, locale }) => { logger().warn("missing translation", { key, locale }); }); ``` Override what's rendered with the `missing` option: ```ts new I18nManager({ missing: (key, locale) => `[${locale}:${key}]` }); ``` --- ## API reference ### `t(key, data?)` `t(key: string, data?: Record): string` Translate a key in the **current request's** locale (or the default, outside a request), formatting its ICU message with `data`. ### `i18n(locale?)` `i18n(locale?: string): I18n` An `I18n` for a locale — or, with no argument, the current request's. ### `I18n` | Method | Signature | |--------|-----------| | `t` | `(key, data?) => string` | | `has` | `(key) => boolean` | | `formatNumber` | `(value, options?: Intl.NumberFormatOptions) => string` | | `formatCurrency` | `(value, currency, options?) => string` | | `formatDate` | `(value, options?: Intl.DateTimeFormatOptions) => string` | | `formatTime` | `(value, options?) => string` | | `formatRelativeTime` | `(value, unit?, options?) => string` | | `formatList` | `(items, options?: Intl.ListFormatOptions) => string` | | `formatPlural` | `(count, options?) => Intl.LDMLPluralRule` | | `formatDisplayName` | `(code, type?) => string` | | `locale` | the locale code | ### `I18nManager` | Method | Signature | |--------|-----------| | `add` | `(data: TranslationsByLocale) => this` | | `load` | `(...loaders: TranslationLoader[]) => Promise` | | `locale` | `(code?) => I18n` | | `supported` | `() => string[]` | | `defaultLocale` | the default locale code | `new I18nManager(options)` — see `I18nOptions`. ### `setI18n(manager)` / `getI18n()` / `setTranslations(data)` Replace the active manager, read it, or add translations to it. ### `detectLocale(options?)` `detectLocale(options?: DetectLocaleOptions): MiddlewareHandler` Work out the request's locale and stash it for `t()` / `i18n()`. ### `negotiateLocale(header, supported, defaultLocale)` `negotiateLocale(header: string | null | undefined, supported: string[], defaultLocale: string): string` The `Accept-Language` parser, standalone. ### `formatMessage(message, data?, locale?)` `formatMessage(message: string, data?: Record, locale?: string): string` Format an ICU message directly, without a translation lookup. ### `objectLoader(data)` `objectLoader(data: TranslationsByLocale): TranslationLoader` — the simplest loader. ### Interfaces & types #### `I18nOptions` `{ defaultLocale?, supportedLocales?, fallbackLocales?, missing? }`. #### `DetectLocaleOptions` `{ query?, cookie?, header?, resolve? }`. #### `Translations` / `TranslationsByLocale` A locale's messages (nested or flat), and those keyed by locale. #### `TranslationLoader` `{ load(): Promise | TranslationsByLocale }`. --- # Inertia Keel ships a server-side [Inertia.js](https://inertiajs.com) adapter. Pair Keel's routing with an Inertia client (React, Vue, or Svelte) and render page components from the server without building an API — `inertia("Page", props)` returns the right response automatically. ## Configure it Bind an `Inertia` instance in a service provider. You supply the **root view** (the HTML shell that embeds the page data and loads your client bundle) and an optional asset **version**: ```ts import { ServiceProvider, singleton, Inertia, inertiaPageAttr } from "@shaferllc/keel/core"; export class InertiaServiceProvider extends ServiceProvider { register(): void { singleton( Inertia, () => new Inertia({ version: "1", rootView: (page) => `` + `

` + ``, }), ); } } ``` `inertiaPageAttr(page)` serializes and HTML-escapes the page object for the `data-page` attribute. > **The version defaults to `"1"`.** Omit it and every deploy reports the same > asset version — fine until you ship new assets, at which point stale clients > won't be told to hard-reload. Bump it (a build hash, a timestamp) whenever your > bundle changes so the adapter can force a full reload on mismatch. ## Render a page From a controller, or straight from a route: ```ts import { inertia } from "@shaferllc/keel/core"; // controller show() { return inertia("Users/Show", { user: getUser(param("id")) }); } // brisk route router.on("/dashboard").renderInertia("Dashboard", { title: "Welcome" }); ``` `inertia()` looks up the bound `Inertia` instance and delegates to its `render`. The component name is the client-side path Inertia resolves (e.g. `Users/Show` maps to your `Pages/Users/Show` component); `props` is any JSON-serializable object. > **Configure the adapter before you render.** `inertia()` throws > `Inertia is not configured…` if no `Inertia` instance is bound in the > container. Register the provider (above) during boot, before any route runs. ## What the adapter does It implements the Inertia protocol for you. Every branch below is decided from the incoming request headers — you call `inertia("Page", props)` once and the adapter picks the response: | Situation | Response | |-----------|----------| | First visit (no `X-Inertia` header) | The full HTML document from your `rootView` (a `string`) | | Inertia navigation (`X-Inertia: true`) | `{ component, props, url, version }` JSON + `X-Inertia: true` and `Vary: X-Inertia` headers | | Asset version changed (GET) | `409` + `X-Inertia-Location` so the client hard-reloads | | Partial reload (`X-Inertia-Partial-Data`) | Only the requested props, for the matching component | The `url` embedded in the page object is the request's `pathname + search` — Inertia uses it to keep the browser history in sync. ### Version mismatches The version check only fires on a **GET** Inertia request whose `X-Inertia-Version` header differs from the adapter's configured version. On a mismatch the adapter returns an empty `409` with `X-Inertia-Location` set to the current URL; the Inertia client sees the `409` and does a full page reload to pull fresh assets. Non-GET requests (a form POST, say) skip the check and render normally. ### Partial reloads When the client asks for a partial reload it sends two headers: `X-Inertia-Partial-Component` (the component it already has mounted) and `X-Inertia-Partial-Data` (a comma-separated list of prop keys it wants). The adapter only trims props when the partial component **matches** the component you're rendering — so a partial reload of `Users/Index` won't accidentally strip props when you render `Users/Show`. Matching props are filtered down to the requested keys; everything else is dropped from the payload. ```ts // Client requests only `notifications` for the already-mounted Dashboard. // The adapter sends { component: "Dashboard", props: { notifications }, ... }. inertia("Dashboard", { stats, notifications, activity }); ``` > Partial reloads are an **allow-list** (the `only` mechanism). The adapter does > not implement Inertia's `except` variant — every listed key is kept, all others > are dropped. ## The client The adapter is the server half. On the client, set up Inertia as usual (`@inertiajs/react` / `-vue` / `-svelte`) pointing at `#app`, and build your `app.js` bundle referenced by the root view. See [inertiajs.com](https://inertiajs.com) for the client setup. ## Related `inertia()` resolves the `Inertia` instance from the [container](./container.md), so it's bound like any other [service provider](./providers.md) singleton. The `renderInertia` brisk-route helper lives on the [router](./routing.md). --- ## API reference ### `inertia(component, props?)` `inertia(component: string, props?: Record): Response | string` Renders an Inertia response for the current request using the `Inertia` instance bound in the container. ```ts import { inertia } from "@shaferllc/keel/core"; return inertia("Users/Show", { user }); ``` **Notes:** `props` defaults to `{}`. Throws `Inertia is not configured…` if no `Inertia` instance is bound — bind one in a provider first. Returns a `string` (the `rootView` HTML) on a first load and a `Response` (JSON, or a `409`) on an Inertia navigation, so it fits anywhere a route handler can return either. ### `inertiaPageAttr(page)` `inertiaPageAttr(page: InertiaPage): string` HTML-escapes a JSON-serialized page object for embedding in the `data-page` attribute of your root element. ```ts `
`; ``` **Notes:** escapes `&`, `"`, `'`, `<`, and `>` (in that order, so `&` isn't double-escaped). Use it only inside a double-quoted attribute in your `rootView` — it is the escaping counterpart the Inertia client reads back off `#app`. ### `Inertia` The adapter itself. Construct one and bind it as a container singleton; the `inertia()` helper resolves it per request. You rarely call its methods directly — `inertia()` and `renderInertia()` do. #### `new Inertia(options)` `new Inertia(options: InertiaOptions)` Creates an adapter with a root view and an optional asset version. ```ts new Inertia({ version: "1", rootView: (page) => `
`, }); ``` **Notes:** `options.version` defaults to `"1"` when omitted; `options.rootView` is required. #### `render(component, props?)` `render(component: string, props?: Record): Response | string` Produces the correct response for the current request: the full HTML document on a first visit, the page JSON on an Inertia navigation, a `409` on a version mismatch, or a trimmed payload for a partial reload. ```ts const html = new Inertia({ rootView }).render("Dashboard", { title: "Welcome" }); ``` **Notes:** reads the active request from `ctx()`, so call it inside a request (the `inertia()` helper does this for you). `props` defaults to `{}`. The JSON branch sets `X-Inertia: true` and `Vary: X-Inertia`; the `409` branch sets `X-Inertia-Location`. ### Interfaces & types #### `InertiaOptions` ```ts interface InertiaOptions { version?: string; rootView: (page: InertiaPage) => string; } ``` The constructor argument. `version` is the asset version (default `"1"`); a mismatch against the client's `X-Inertia-Version` forces a full reload. `rootView` renders the HTML shell for a first, non-XHR load — it receives the `InertiaPage` and must embed it (typically via `inertiaPageAttr`) so the client can boot. ```ts const options: InertiaOptions = { version: "abc123", rootView: (page) => `
`, }; ``` #### `InertiaPage` ```ts interface InertiaPage { component: string; props: Record; url: string; version: string; } ``` The Inertia page object — the payload both the JSON response and the `rootView` receive. `component` is the page name, `props` its (possibly partial-reload filtered) data, `url` the request's `pathname + search`, and `version` the adapter's asset version. You consume it inside `rootView`; you don't build it yourself. --- # Locks "Only one of you may do this at a time" — across processes, across nodes. ```ts import { lock } from "@shaferllc/keel/core"; const [ran] = await lock("invoice:42").run(async () => { await charge(invoice); }); if (!ran) return; // another worker is already charging it ``` This is the counterpart to the [cache](./cache.md)'s stampede protection. That collapses concurrent work **inside one isolate**; a lock coordinates work **between** them. Reach for a lock when doing the thing twice would be *wrong* — charging a card, sending an invoice, running a migration — not merely wasteful. Like every other backend in Keel, the store is a small pluggable seam and the core imports no driver. `MemoryLockStore` is the default; it's per-isolate, so it coordinates within one process and nothing more — fine for tests and single-process apps, useless across a cluster. Point it at Redis for the real thing. ## `run()` — the form you want ```ts const [ran, result] = await lock("invoice:42").run(() => charge(invoice)); ``` It acquires, runs, and **always** releases — the `finally` is what stops a throwing callback from leaving the lock held until its TTL runs out. It returns `[ran, result]`; `ran` is `false` if someone else holds the lock, in which case your callback never ran and `result` is `undefined`. By default it doesn't wait: if the lock is taken, it gives up immediately. To wait for it, pass a timeout: ```ts // Wait up to 5 seconds for the lock, checking every 100ms. const [ran] = await lock("report").run(() => rebuild(), { timeout: 5_000, retryDelay: 100, }); ``` `runImmediately()` is the explicit "never wait" spelling. ## TTL and expiry **A lock always expires.** There's no hold-forever mode, because a holder that crashes would keep the lock forever and nothing would ever run again. The default TTL is 30 seconds: ```ts lock("invoice:42"); // held for 30s once acquired lock("nightly-report", 300_000); // 5 minutes ``` Pick a TTL comfortably longer than the work. If the work might outrun it, `extend()` from inside: ```ts await lock("import", 60_000).run(async () => { for (const batch of batches) { await process(batch); await l.extend(); // push the expiry out another 60s } }); ``` `extend()` **throws** `LockNotHeldError` if you've already lost the lock. That's deliberate: the alternative — silently doing nothing — would let you carry on believing you hold a lock you don't. ## Ownership, and why it matters Every acquisition mints a random owner token, and `release()`/`extend()` only succeed for the owner. This is not bookkeeping — it's the property that makes the lock correct: 1. Process **A** takes the lock with a 30s TTL. 2. A's work takes longer than 30s. The lock **expires**. 3. Process **B** takes the now-free lock and starts working. 4. A finishes and calls `release()`. Without ownership, A's release deletes **B's** lock, and a third process walks straight in while B is still working. With it, A's release is a no-op that returns `false`. A store must *compare-and-delete*, not just delete. ## Manual acquisition `acquire()` / `release()` are there when the lock's lifetime doesn't fit a single callback. You own the `try/finally`: ```ts const l = lock("invoice:42"); if (!(await l.acquire({ timeout: 2_000 }))) return; try { await charge(invoice); } finally { await l.release(); // without this, the lock leaks for the rest of its TTL } ``` Prefer `run()`. It exists so you can't forget the `finally`. ## Handing a lock to another process `serialize()` freezes the key, TTL, and **owner token** to a string. `restoreLock()` rebuilds it elsewhere — so one process can take the lock and another can release or extend the *same* lock: ```ts // worker A const l = lock("import:99", 60_000); await l.acquire(); await queue.dispatch(new FinishImport(l.serialize())); // worker B, later const l = restoreLock(serialized); await l.extend(60_000); // ...finish the work... await l.release(); ``` ## Inspecting a lock ```ts await l.isLocked(); // does anyone hold this key? (not necessarily you) await l.isExpired(); // did we hold it and lose it? await l.getRemainingTime(); // ms until expiry, or null if unheld ``` ## Testing The default `MemoryLockStore` needs no setup. Give each test a clean one so keys can't leak between them: ```ts import { setLockStore, MemoryLockStore } from "@shaferllc/keel/core"; beforeEach(() => setLockStore(new MemoryLockStore())); ``` ## Writing a store A store is the `LockStore` interface. Implementations **must** make `acquire` atomic (set-if-absent) and make `release`/`extend` conditional on the owner matching — a store that can't do both isn't a lock, it's a suggestion. ### Redis Redis gives you both: `SET key owner PX ttl NX` is an atomic set-if-absent, and a small Lua script makes release and extend compare-and-act. This example uses [ioredis](https://github.com/redis/ioredis); any client with `set` and `eval` works the same way. ```ts import type { LockStore } from "@shaferllc/keel/core"; import type { Redis } from "ioredis"; // Compare-and-delete: only delete if the value still matches our owner token. const RELEASE = ` if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) end return 0 `; // Compare-and-extend, same idea. const EXTEND = ` if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("pexpire", KEYS[1], ARGV[2]) end return 0 `; export const redisLockStore = (client: Redis): LockStore => ({ async acquire(key, owner, ttlMs) { // NX = only if absent. This is the atomic bit. const res = await client.set(key, owner, "PX", ttlMs, "NX"); return res === "OK"; }, async release(key, owner) { return (await client.eval(RELEASE, 1, key, owner)) === 1; }, async extend(key, owner, ttlMs) { return (await client.eval(EXTEND, 1, key, owner, String(ttlMs))) === 1; }, async isLocked(key) { return (await client.exists(key)) === 1; }, async remainingTime(key) { const ms = await client.pttl(key); return ms < 0 ? null : ms; // -1 = no expiry, -2 = no key }, }); ``` ```ts setLockStore(redisLockStore(client)); ``` ### Database A unique primary key on `key` gives you the atomicity — the insert fails if someone already holds it. Expired rows are treated as free. ```sql CREATE TABLE locks ( key TEXT PRIMARY KEY, owner TEXT NOT NULL, expires_at BIGINT NOT NULL ); ``` ```ts import { connection } from "@shaferllc/keel/core"; export const databaseLockStore = (table = "locks"): LockStore => ({ async acquire(key, owner, ttlMs) { const now = Date.now(); // Clear the row first if it has expired, then insert. The PK makes the // insert the atomic step: two racing writers, one unique-violation. await connection().write(`DELETE FROM ${table} WHERE key = ? AND expires_at <= ?`, [key, now]); try { await connection().write( `INSERT INTO ${table} (key, owner, expires_at) VALUES (?, ?, ?)`, [key, owner, now + ttlMs], ); return true; } catch { return false; // someone else holds it } }, async release(key, owner) { const res = await connection().write(`DELETE FROM ${table} WHERE key = ? AND owner = ?`, [key, owner]); return res.changes > 0; }, async extend(key, owner, ttlMs) { const res = await connection().write( `UPDATE ${table} SET expires_at = ? WHERE key = ? AND owner = ? AND expires_at > ?`, [Date.now() + ttlMs, key, owner, Date.now()], ); return res.changes > 0; }, async isLocked(key) { const rows = await connection().select( `SELECT 1 FROM ${table} WHERE key = ? AND expires_at > ?`, [key, Date.now()], ); return rows.length > 0; }, async remainingTime(key) { const rows = await connection().select(`SELECT expires_at FROM ${table} WHERE key = ?`, [key]); const row = rows[0] as { expires_at: number } | undefined; if (!row) return null; const left = Number(row.expires_at) - Date.now(); return left > 0 ? left : null; }, }); ``` Redis is the better fit if you have it — the database store pays a round trip per operation and needs the expired rows cleaned up. --- ## API reference ### `lock(key, ttlMs?)` `lock(key: string, ttlMs?: number): Lock` A lock on `key`, held for `ttlMs` once acquired (default `30_000`). ### `Lock` | Method | Signature | |--------|-----------| | `run` | `(fn, options?: AcquireOptions) => Promise<[boolean, T \| undefined]>` — acquire, run, always release | | `runImmediately` | `(fn) => Promise<[boolean, T \| undefined]>` — never waits | | `acquire` | `(options?: AcquireOptions) => Promise` | | `acquireImmediately` | `() => Promise` | | `release` | `() => Promise` — false if we no longer hold it | | `extend` | `(ttlMs?) => Promise` — throws `LockNotHeldError` if lost | | `isLocked` | `() => Promise` — does *anyone* hold it | | `isExpired` | `() => Promise` — did *we* hold it and lose it | | `getRemainingTime` | `() => Promise` — ms until expiry | | `serialize` | `() => string` — key + TTL + owner token | ### `restoreLock(serialized)` `restoreLock(serialized: string): Lock` — rebuild a lock from `serialize()`, owner token and all, so another process can release or extend it. ### `setLockStore(store)` / `getLockStore()` Register the store `lock()` uses, and read it back. ### Interfaces & types #### `LockStore` `acquire(key, owner, ttlMs)` / `release(key, owner)` / `extend(key, owner, ttlMs)` / `isLocked(key)` / `remainingTime(key)`. `acquire` must be atomic; `release` and `extend` must be conditional on the owner. #### `AcquireOptions` `{ timeout?: number, retryDelay?: number }` — how long to wait for a held lock (default `0`, don't wait) and how often to retry (default `50`ms). #### `MemoryLockStore` The default. Per-isolate — for tests and single-process apps. #### `LockNotHeldError` Thrown by `extend()` when the lock has expired or was never acquired. --- # Logger A small leveled logger. It writes **structured JSON** by default — one line per event, ready for log aggregators — and pretty single-line output in debug. Reach it with the global `logger()` helper. ## Logging ```ts import { logger } from "@shaferllc/keel/core"; logger().info("user registered", { userId: user.id }); logger().warn("cache miss", { key }); logger().error("payment failed", { orderId, error: String(err) }); logger().debug("query", { sql, ms }); ``` The second argument is structured context — it's merged into the log line, not string-concatenated, so it stays queryable: ```json {"level":"info","time":"2026-07-10T…","msg":"user registered","userId":42} ``` Every line carries three reserved keys — `level`, `time` (an ISO-8601 stamp), and `msg` — followed by any bound fields and then the call's `context`. Context is spread last, so a context key of `level`, `time`, or `msg` overwrites the reserved field; steer clear of those names in your payloads. ## Levels `trace` < `debug` < `info` < `warn` < `error` < `fatal`. Only events at or above the configured level are emitted. Set the threshold via config: ```ts // config/logger.ts export default { level: env("LOG_LEVEL", "info") }; ``` Under the hood the levels are ordinal (`trace` 10, `debug` 20, `info` 30, `warn` 40, `error` 50, `fatal` 60); a line is dropped when its level sits below the threshold. The default threshold is `"info"`, so `debug` and `trace` stay silent until you lower it. `log(level, message, context?)` takes the level at runtime, when it isn't known statically. Pretty output turns on automatically when `app.debug` is true. In pretty mode each event is a single human-readable line — `[2026-07-10T…] INFO user registered {"userId":42}` — and the writer routes by level: `warn` goes to `console.warn`, `error` and `fatal` to `console.error`, everything else to `console.log`. In JSON mode every level is written to `console.log`. `enabled: false` silences a logger entirely, at every level. ### Don't pay for lines you won't emit The threshold drops the *line*, but the **context object is built either way** — so an expensive snapshot costs you even when nobody sees it. Gate it: ```ts if (logger().isLevelEnabled("debug")) { logger().debug("state", { snapshot: expensiveSnapshot() }); } // ...or the callback form logger().ifLevelEnabled("debug", (log) => log.debug("state", { snapshot: expensiveSnapshot() })); ``` ## Where the lines go A **sink** is where log records land. The default writes to the console (JSON, or pretty), but it's just a function, so logs can go anywhere — a file, an HTTP collector, a buffer: ```ts import { Logger, type Sink } from "@shaferllc/keel/core"; const httpSink: Sink = (record) => { void fetch("https://logs.example.com", { method: "POST", body: JSON.stringify(record) }); }; new Logger({ sink: httpSink }); ``` A sink receives the structured `LogRecord` — `{ level, time, msg, fields }` — not a formatted string, so it can do what it likes with the shape. `fields` is already redacted. `MemorySink` collects records in memory, which is what you want in a test: ```ts import { Logger, MemorySink } from "@shaferllc/keel/core"; const sink = new MemorySink(); const log = new Logger({ level: "trace", sink: sink.sink }); log.info("hello", { userId: 1 }); sink.messages(); // ["hello"] sink.at("info"); // the records at one level sink.records[0].fields; // { userId: 1 } sink.clear(); ``` ## Named loggers Give a subsystem its own level or destination: ```ts import { setLogger, namedLogger, Logger } from "@shaferllc/keel/core"; setLogger(new Logger({ level: "trace", sink: auditSink }), "audit"); namedLogger("audit").trace("permission granted", { userId }); ``` The application's own logger stays where it is — reach that with `logger()`. ## Child loggers Bind fields once (a request id, a job name) and they appear on every line: ```ts const log = logger().child({ requestId: request.header("x-request-id") }); log.info("handling"); // includes requestId log.info("done"); // includes requestId ``` A child inherits its parent's `level` and `pretty` settings and *merges* its bindings on top of the parent's — so you can nest them, and a child's field wins over a parent's field of the same name. The parent is untouched; `child()` returns a fresh `Logger`. ```ts const base = logger().child({ service: "billing" }); const job = base.child({ jobId }); // { service, jobId } on every line ``` ## Standing up a logger yourself The framework binds one `Logger` for you, but the class is a plain object you can construct directly — handy in a script or a test: ```ts import { Logger } from "@shaferllc/keel/core"; const log = new Logger({ level: "debug", pretty: true, bindings: { env: "dev" } }); log.debug("boot", { pid: 1 }); ``` With no options it defaults to `level: "info"`, `pretty: false`, and no bindings. ## Per-request logging `requestLogger()` is a built-in middleware that binds a **child logger with a generated `reqId` to each request**, so every log line within a request correlates. Install it in your HTTP kernel, then reach the request's logger anywhere with `requestLog()`: ```ts import { requestLogger, requestLog } from "@shaferllc/keel/core"; // app/Http/Kernel.ts kernel.use(requestLogger()); // anywhere in the request — the line carries this request's reqId: requestLog().info("charging card", { orderId }); ``` By default it also logs the request start and completion: ```json {"level":"info","time":"…","msg":"request","reqId":"…","method":"GET","path":"/orders"} {"level":"info","time":"…","msg":"request completed","reqId":"…","status":200,"ms":12.4} ``` Options: `genReqId(c)` to control id generation, `idHeader` to reuse an incoming id (e.g. `"x-request-id"` for distributed tracing), and `logRequests: false` to skip the automatic start/completion lines. Outside a request (or without the middleware), `requestLog()` falls back to the base `logger()`. ## Redaction Keep secrets out of your logs with `redact` — top-level keys or dot paths. Matched values are replaced with `"[redacted]"`; **the original object is never mutated**, so redacting doesn't corrupt the data you're still using: ```ts const log = new Logger({ redact: ["password", "req.headers.authorization"], }); log.info("login", { user: "ada", password: "s3cret", req: { headers: { authorization: "Bearer x" } } }); // {"level":"info",…,"user":"ada","password":"[redacted]","req":{"headers":{"authorization":"[redacted]"}}} ``` A `*` segment matches every key at that level — which is how you catch a secret that appears under a key you don't know in advance: ```ts new Logger({ redact: ["*.password", "creds.*.token"] }); log.info("audit", { alice: { password: "a", name: "Alice" }, bob: { password: "b", name: "Bob" }, }); // both passwords redacted; both names kept ``` Pass an object instead of an array to change the placeholder, or drop the key outright: ```ts new Logger({ redact: { paths: ["password"], censor: "***" } }); new Logger({ redact: { paths: ["password"], remove: true } }); // the key disappears ``` Redaction is inherited by child loggers, so a redacting base logger keeps per-request loggers safe too, and it runs **before** the sink — a custom sink can never see the unredacted values. --- ## API reference ### `logger()` `logger(): Logger` Resolves the application's shared `Logger` from the container. ```ts import { logger } from "@shaferllc/keel/core"; logger().info("ready"); ``` **Notes:** a global helper — no need to thread the logger through your call stack. Throws `No Keel application has been bootstrapped…` if called before an `Application` exists. Returns the same singleton every call, so `child()` off it when you want per-request bindings rather than mutating the shared instance. ### `Logger` The logger itself. The framework binds one for you (reach it with `logger()`), but you can also `new Logger(options)` directly. #### `new Logger(options?)` `new Logger(options?: LoggerOptions): Logger` Creates a logger with the given level, format, and bound fields. ```ts const log = new Logger({ level: "warn", pretty: true }); ``` **Notes:** `options` defaults to `{}`, which resolves to `level: "info"`, `pretty: false`, no bindings. The level is captured at construction — there is no setter, so change it by creating a new logger. #### `debug(message, context?)` `debug(message: string, context?: Record): void` Logs at the `debug` level — the noisiest, off by default. ```ts log.debug("cache lookup", { key }); ``` **Notes:** suppressed unless the threshold is `"debug"`. `context` is optional and merged into the line after the bound fields. #### `info(message, context?)` `info(message: string, context?: Record): void` Logs at the `info` level — the default threshold. ```ts log.info("user registered", { userId: 42 }); ``` #### `warn(message, context?)` `warn(message: string, context?: Record): void` Logs at the `warn` level. ```ts log.warn("cache miss", { key }); ``` **Notes:** in pretty mode this routes to `console.warn`; in JSON mode, like every level, to `console.log`. #### `error(message, context?)` `error(message: string, context?: Record): void` Logs at the `error` level — the highest, always emitted. ```ts log.error("payment failed", { orderId, error: String(err) }); ``` **Notes:** in pretty mode this routes to `console.error`. It does not throw or capture stack traces for you — serialize an `Error` into `context` yourself (e.g. `error: String(err)` or `err.stack`). #### `child(bindings)` `child(bindings: Record): Logger` Returns a new logger that carries `bindings` on every line, in addition to the parent's. ```ts const reqLog = logger().child({ requestId }); reqLog.info("handling"); // line includes requestId ``` **Notes:** inherits the parent's `level` and `pretty`; merges bindings over the parent's (child wins on key collisions). Chainable — call `child()` on a child. The parent is not modified. ### `requestLogger(options?)` `requestLogger(options?: RequestLoggerOptions): MiddlewareHandler` Middleware that binds a `reqId` child logger to each request and (by default) logs the request start and completion. ```ts kernel.use(requestLogger({ idHeader: "x-request-id" })); ``` **Notes:** options — `genReqId(c)` (default `crypto.randomUUID()`), `idHeader` (reuse an incoming id), `logRequests` (default `true`). ### `requestLog()` `requestLog(): Logger` The current request's child logger (carrying its `reqId`), or the base `logger()` outside a request / without the middleware installed. ```ts requestLog().info("charging card"); // line carries reqId ``` ### Interfaces & types #### `LoggerOptions` ```ts interface LoggerOptions { level?: LogLevel; // minimum level to emit; default "info" pretty?: boolean; // single-line human output; default false bindings?: Record; // fields merged into every line } ``` Passed to `new Logger()` (and carried forward by `child()`). Use it to set the threshold, switch to pretty output, and attach ambient fields. ```ts const log = new Logger({ level: "debug", pretty: true, bindings: { app: "api" } }); ``` #### `LogLevel` `type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal"` The four severity levels, in ascending order. Used for the `level` option and selected implicitly by each method. ```ts const threshold: LogLevel = "warn"; new Logger({ level: threshold }); ``` ### `isLevelEnabled(level)` / `ifLevelEnabled(level, fn)` `isLevelEnabled(level: LogLevel): boolean` — whether a level would be emitted. Check it before building an expensive context object. `ifLevelEnabled(level: LogLevel, fn: (log: Logger) => void): void` — the callback form. ### `log(level, message, context?)` `log(level: LogLevel, message: string, context?: Record): void` — log at a level chosen at runtime. ### `consoleSink(pretty?)` `consoleSink(pretty = false): Sink` — the default sink. JSON to stdout, or a pretty single line. ### `MemorySink` Collects records in memory — for tests. | Member | Signature | |--------|-----------| | `sink` | `Sink` — hand this to `LoggerOptions.sink` | | `records` | `LogRecord[]` | | `at` | `(level) => LogRecord[]` | | `messages` | `() => string[]` | | `clear` | `() => void` | ### `setLogger(logger, name)` / `namedLogger(name)` Register a logger under a name, and resolve it. `namedLogger` throws for an unknown name. ### Interfaces & types (added) #### `Sink` `type Sink = (record: LogRecord) => void` — where log lines go. #### `LogRecord` `{ level: LogLevel; time: string; msg: string; fields: Record }` — `fields` is already redacted. #### `RedactOptions` `{ paths: string[]; censor?: string; remove?: boolean }` — a `*` path segment matches every key at that level. `LoggerOptions.redact` also accepts a bare `string[]`. --- # Mail Send email through a pluggable **transport**. Compose a message with a fluent builder and dispatch it — the API mirrors the database layer (`setMailer` / `mail()` are to mail what `setConnection` / `db()` are to the database). The core imports no SDK: the built-in transports use `fetch`, `console`, or memory, so it runs on Node and the edge. ## Sending ```ts import { mail } from "@shaferllc/keel/core"; await mail() .to("ada@example.com") .subject("Welcome aboard") .html("

Hi Ada

") .send(); ``` Every setter is chainable, and several accept multiple values: ```ts await mail() .to("a@x.com", "b@x.com") .cc("team@x.com") .bcc("audit@x.com") .replyTo("support@x.com") .from("hello@x.com") // optional if a default is configured .subject("Report") .text("Plain-text body") .html("

HTML body

") .header("X-Campaign", "weekly") .send(); ``` Seed several fields at once with `fill()`: ```ts await mail().fill({ to: "a@x.com", subject: "Hi", text: "body" }).send(); ``` `send()` resolves to the **finalized message** — the same object the transport received, with the default `from` already applied. Handy for logging or assertions: ```ts const sent = await mail().to("ada@example.com").subject("Hi").text("hey").send(); sent.from; // the resolved from address sent.to; // ["ada@example.com"] ``` ## Validation & error behavior A message needs at least one recipient, a subject, a body (`text` or `html`), and a `from` — `send()` throws a clear `Error` otherwise, before the transport is ever called. The checks run in this order: | Missing | Message | |---------|---------| | `to` (empty) | `Mail: at least one recipient (to) is required.` | | `subject` | `Mail: a subject is required.` | | `text` **and** `html` | `Mail: a text or html body is required.` | | `from` (and no default) | `Mail: a from address is required (set one or a default).` | The `from` default from `setMailer(..., { from })` is applied first, so a configured default satisfies the last check without any per-message `from`. ## Configuring the transport Register a default transport once (typically in a service provider): ```ts import { setMailer, fetchTransport } from "@shaferllc/keel/core"; setMailer( fetchTransport({ url: "https://api.resend.com/emails", headers: { Authorization: `Bearer ${env("RESEND_API_KEY")}` }, body: (m) => ({ from: m.from, to: m.to, subject: m.subject, html: m.html }), }), { from: "hello@myapp.com" }, // default `from` for messages that omit one ); ``` `fetchTransport` POSTs JSON to any provider API (Resend, Postmark, Mailgun, …). The optional `body` mapper shapes the request for that provider; without it the message is sent as-is. A non-2xx response throws `Mail: transport responded `. ## Built-in transports | Transport | Use | |-----------|-----| | `ArrayTransport` | Collects messages in `.sent` — the default, and ideal for tests | | `LogTransport` | Logs each message via the logger instead of delivering — local dev | | `fetchTransport(opts)` | POSTs to a provider HTTP API via `fetch` — production | Until you call `setMailer`, the default mailer is a fresh `ArrayTransport` — so `mail()` never throws for want of a transport, it just buffers in memory. ## Writing your own transport A transport is one method: ```ts import type { Transport } from "@shaferllc/keel/core"; const transport: Transport = { async send(message) { // hand `message` to any SDK or API you like }, }; setMailer(transport, { from: "hello@myapp.com" }); ``` The `message` your `send` receives is already validated and has `from` resolved, so a transport can trust every required field is present. ## Queueing: `sendLater()` Sending is slow and it fails. Holding a request open for an SMTP round trip means the user waits on your provider, and a provider hiccup turns "sign up" into an error page. Put the message on the [queue](./queues.md) instead: ```ts await mail().to(user.email).subject("Welcome").html(body).sendLater(); ``` The request returns immediately, and a failed send **retries on the queue** rather than failing the user's action. Everything else is identical — same builder, same transport. The message is **validated at the call site**, not on the worker: a missing recipient throws where you composed it, where the stack trace means something, rather than surfacing in a worker log an hour later. With the default `SyncDriver` this still sends inline (nothing is deferred until you register a real driver), so `sendLater()` is safe to adopt before you have a queue. ## Attachments ```ts await mail() .to("ada@example.com") .subject("Your invoice") .html('

Attached.

') .attach("invoice.pdf", pdfBytes) // content type inferred: application/pdf .attach("data.csv", "a,b,c", "text/csv") // ...or set it .embed("logo", logoBytes, "logo.png") // inline, referenced as cid:logo .send(); ``` `attach(filename, content, contentType?)` takes a string or `Uint8Array`; the content type is inferred from the extension when you don't give one. `embed(cid, content, filename?, contentType?)` is the same thing with a **content id**, so the HTML body can display it inline via `` instead of linking out to a hosted image. ## Class-based mails A one-liner is fine until the email has real content. `BaseMail` is to mail what `Job` is to the queue — a reusable, testable class: ```ts import { BaseMail, type PendingMail } from "@shaferllc/keel/core"; export class WelcomeEmail extends BaseMail { constructor(private user: User) { super(); } build(message: PendingMail) { message .to(this.user.email) .subject(`Welcome, ${this.user.name}`) .html(`

Hi ${this.user.name}

`); } } ``` ```ts import { send, sendLater } from "@shaferllc/keel/core"; await send(new WelcomeEmail(user)); await sendLater(new WelcomeEmail(user)); // ...or queue it ``` `build()` may be async, so it can render a template or fetch what it needs. ## Multiple mailers Register mailers by name — a transactional provider and a marketing one, say — and pick one with `mail(name)`: ```ts setMailer(postmark, { from: "hi@app.com" }); // the default setMailer(resend, { from: "news@app.com" }, "marketing"); await mail().to(user.email).subject("Receipt").text(body).send(); await mail("marketing").to(user.email).subject("This month").html(body).send(); ``` `send(email, name)` and `sendLater(email, name)` take a mailer name too. ## In tests `fakeMail()` swaps the mailer for one that **records instead of delivering**, so tests never talk to a provider. `restoreMail()` puts the real one back. ```ts import { fakeMail, restoreMail } from "@shaferllc/keel/core"; const mailer = fakeMail(); await registerUser(); mailer.assertSent(); mailer.assertSent((m) => m.subject === "Welcome"); mailer.assertSentCount(1); mailer.assertQueued((m) => m.to.includes("ada@example.com")); // sent with sendLater() mailer.assertNotSent((m) => m.subject === "Password reset"); mailer.assertNothingSent(); restoreMail(); ``` The fake keeps **sent** and **queued** separate — `assertSent` only matches `send()`, `assertQueued` only `sendLater()` — so a test can tell "we emailed them" from "we queued an email". A faked `sendLater()` doesn't touch the real queue either; recording the intent is the point. It still **validates** the message, so a fake can't paper over a message the real mailer would reject. `mailer.sent()` and `mailer.queued()` return the raw messages if you'd rather assert by hand. If you want the transport-level view instead, `ArrayTransport` still works: ```ts const transport = new ArrayTransport(); setMailer(transport, { from: "hi@app.com" }); await mail().to("ada@example.com").subject("Welcome").text("hi").send(); assert.equal(transport.sent[0].subject, "Welcome"); ``` You can also hold your own `Mailer` instead of the global one — construct it with a transport and reuse it, leaving the process-wide `mail()` untouched: ```ts import { Mailer, ArrayTransport } from "@shaferllc/keel/core"; const mailer = new Mailer(new ArrayTransport(), { from: "hi@app.com" }); await mailer.message().to("ada@example.com").subject("Hi").text("hey").send(); ``` ## Events Every send fires [events](./events.md), so logging, metrics, and auditing can hang off mail without touching the mailer: | Event | When | |-------|------| | `mail.sending` | before the transport is called | | `mail.sent` | after it returns | | `mail.queued` | a `sendLater()` message reached the queue | Each carries the final `Message` — after defaults are applied. ```ts listen("mail.sent", (message) => logger().info("mail sent", { subject: message.subject })); ``` ## Related The mail layer stands alone, but the [database](./database.md) builder shares its shape (`setConnection`/`db` mirror `setMailer`/`mail`) — the same register-once, call-anywhere pattern. --- ## API reference ### `mail()` `mail(): PendingMail` Starts composing a message on the default (global) mailer. ```ts await mail().to("ada@example.com").subject("Hi").text("hey").send(); ``` **Notes:** a thin shortcut for `getMailer().message()`. Uses whatever transport and options were last passed to `setMailer` (an in-memory `ArrayTransport` if you never called it). ### `setMailer(transport, options?)` `setMailer(transport: Transport, options?: MailerOptions): Mailer` Replaces the global mailer with a new one built from `transport` and `options`, and returns it. ```ts setMailer(fetchTransport({ url }), { from: "hello@myapp.com" }); ``` **Notes:** global — the last call wins. Returns the constructed `Mailer` if you want a direct handle. `options` defaults to `{}` (no default `from`). ### `getMailer()` `getMailer(): Mailer` Returns the current global `Mailer` instance. ```ts const mailer = getMailer(); await mailer.message().to("ada@example.com").subject("Hi").text("hey").send(); ``` **Notes:** before any `setMailer` call this is a `Mailer` wrapping a fresh `ArrayTransport`. ### `fetchTransport(options)` `fetchTransport(options: FetchTransportOptions): Transport` Builds a `Transport` that POSTs each message as JSON to a provider HTTP API via `fetch`. ```ts const transport = fetchTransport({ url: "https://api.resend.com/emails", headers: { Authorization: `Bearer ${apiKey}` }, body: (m) => ({ from: m.from, to: m.to, subject: m.subject, html: m.html }), }); ``` **Notes:** always sets `Content-Type: application/json`; your `headers` merge on top. Without a `body` mapper the raw `Message` is serialized. Throws `Mail: transport responded ` on any non-`ok` response. ### `Mailer` The engine that validates a message, applies defaults, and hands it to the transport. Construct one directly (`new Mailer(transport, options?)`) for a scoped mailer, or reach the global one via `getMailer()` / `setMailer()`. #### `new Mailer(transport, options?)` `new Mailer(transport: Transport, options?: MailerOptions)` Wraps a transport and its options. ```ts const mailer = new Mailer(new ArrayTransport(), { from: "hi@app.com" }); ``` **Notes:** `options` defaults to `{}`. The transport is fixed for this instance — build a new `Mailer` to swap it. #### `message()` `message(): PendingMail` Starts a new `PendingMail` bound to this mailer. ```ts const pending = mailer.message(); ``` **Notes:** each call returns a fresh builder; nothing is shared between messages. #### `send(message)` `send(message: Message): Promise` Applies the default `from`, validates the message, dispatches it through the transport, and resolves to the finalized message. ```ts const sent = await mailer.send({ to: ["ada@x.com"], subject: "Hi", text: "hey" }); ``` **Notes:** throws (before touching the transport) if `to` is empty, or `subject`, a body, or `from` is missing — see [Validation](#validation--error-behavior). `PendingMail.send()` funnels through here. The returned object is a shallow copy with `from` resolved. ### `PendingMail` The fluent builder. You get one from `mail()` or `mailer.message()`, never `new`. Every setter returns `this`, so calls chain in any order; nothing is sent until `send()`. #### `to(...addresses)` `to(...addresses: string[]): this` Appends one or more recipients. ```ts mail().to("a@x.com", "b@x.com"); ``` **Notes:** additive — repeated calls accumulate recipients rather than replace. #### `from(address)` `from(address: string): this` Sets the sender, overriding the mailer's default `from`. ```ts mail().from("hello@x.com"); ``` **Notes:** a single value (not variadic). Optional when a default `from` is configured on the mailer. #### `cc(...addresses)` / `bcc(...addresses)` `cc(...addresses: string[]): this` `bcc(...addresses: string[]): this` Append carbon-copy / blind-carbon-copy recipients. ```ts mail().cc("team@x.com").bcc("audit@x.com"); ``` **Notes:** both additive, like `to`. The underlying arrays are created lazily on first use. #### `replyTo(address)` `replyTo(address: string): this` Sets the `Reply-To` address. ```ts mail().replyTo("support@x.com"); ``` **Notes:** a single value; a later call replaces the prior one. #### `subject(subject)` `subject(subject: string): this` Sets the subject line. ```ts mail().subject("Welcome aboard"); ``` **Notes:** required — `send()` throws if it's empty. A later call replaces it. #### `text(text)` / `html(html)` `text(text: string): this` `html(html: string): this` Set the plain-text / HTML body. At least one is required. ```ts mail().text("Plain body").html("

Rich body

"); ``` **Notes:** you can set both (a multipart message); `send()` throws only if *neither* is present. Each later call replaces its body. #### `header(name, value)` `header(name: string, value: string): this` Adds a custom header. ```ts mail().header("X-Campaign", "weekly"); ``` **Notes:** additive per name — repeated calls with distinct names accumulate; the same name overwrites. The `headers` object is created lazily. #### `fill(partial)` `fill(partial: Partial<{ to: string | string[]; cc: string | string[]; bcc: string | string[] } & Omit>): this` Seeds several fields at once, merging into whatever's been chained. ```ts mail().fill({ to: ["a@x.com", "b@x.com"], subject: "Hi", text: "body" }); ``` **Notes:** `to`/`cc`/`bcc` accept a single string or an array and are **appended** to any existing recipients. The other fields (`from`, `subject`, `text`, `html`, `replyTo`, `headers`) are assigned, **replacing** prior values — passing `headers` here overwrites the whole header map rather than merging. #### `send()` `send(): Promise` Hands the composed message to the mailer and resolves to the finalized message. ```ts const sent = await mail().to("ada@x.com").subject("Hi").text("hey").send(); ``` **Notes:** delegates to `Mailer.send`, so the same validation and default-`from` handling apply; it throws on a missing required field. ### `ArrayTransport` An in-memory transport that records every message. The default transport, and the one to use in tests. #### `new ArrayTransport()` `new ArrayTransport()` Creates a transport with an empty `sent` array. ```ts const transport = new ArrayTransport(); ``` #### `sent` `readonly sent: Message[]` The messages this transport has received, in order. ```ts const transport = new ArrayTransport(); setMailer(transport); // ...after sending... transport.sent.length; // number of messages queued transport.sent[0]?.subject; // first message's subject ``` **Notes:** `readonly` binding but the array is mutated on each `send` — assert on `.length` and elements. #### `send(message)` `send(message: Message): Promise` Pushes the message onto `sent`. ```ts await new ArrayTransport().send(message); ``` **Notes:** never throws; delivers nothing. Called for you by `Mailer.send`. ### `LogTransport` A transport that logs each message (to, from, subject) via the framework logger instead of delivering it — for local development. #### `new LogTransport()` `new LogTransport()` Creates the transport. ```ts setMailer(new LogTransport(), { from: "dev@localhost" }); ``` #### `send(message)` `send(message: Message): Promise` Logs `to`, `from`, and `subject` at info level; sends nothing. ```ts await new LogTransport().send(message); ``` **Notes:** the body is not logged, only the envelope fields. ### Interfaces & types #### `Message` ```ts interface Message { to: string[]; from?: string; cc?: string[]; bcc?: string[]; replyTo?: string; subject: string; text?: string; html?: string; headers?: Record; } ``` The normalized, ready-to-send message. The builder produces one; a `Transport` receives one (already validated, with `from` resolved). You can also build one by hand and pass it to `Mailer.send`. ```ts const message: Message = { to: ["ada@x.com"], from: "hi@app.com", subject: "Hi", text: "hey", }; ``` #### `Transport` ```ts interface Transport { send(message: Message): Promise; } ``` The seam between the mailer and your email provider — one method. Implement it to bridge any SDK or API; register it with `setMailer`. ```ts const transport: Transport = { async send(message) { await myProviderSdk.emails.send(message); }, }; setMailer(transport, { from: "hi@app.com" }); ``` #### `MailerOptions` ```ts interface MailerOptions { from?: string; } ``` Options for a `Mailer`. Currently just a default `from` applied to messages that don't set one. ```ts setMailer(transport, { from: "hello@myapp.com" }); ``` #### `FetchTransportOptions` ```ts interface FetchTransportOptions { url: string; headers?: Record; body?: (message: Message) => unknown; } ``` Configuration for `fetchTransport`. `url` is the provider endpoint; `headers` merge over the automatic `Content-Type: application/json`; `body` maps a `Message` to the provider's request shape (defaults to the message itself). ```ts const opts: FetchTransportOptions = { url: "https://api.resend.com/emails", headers: { Authorization: `Bearer ${apiKey}` }, body: (m) => ({ from: m.from, to: m.to, subject: m.subject, html: m.html }), }; ``` ### `mailer(name?)` `mailer(name?: string): Mailer` — the default mailer, or a named one. Throws for an unknown name. ### `send(email, name?)` / `sendLater(email, name?)` `send(email: BaseMail, name?: string): Promise` — build a class-based mail and send it. `sendLater` queues it instead. ### `BaseMail` Abstract. Implement `build(message: PendingMail): void | Promise` to compose the message. ### `PendingMail.sendLater()` `sendLater(): Promise` — validate now, then put the message on the queue. ### `PendingMail.attach()` / `.embed()` `attach(filename, content: string | Uint8Array, contentType?): this` — content type inferred from the extension when omitted. `embed(cid, content, filename?, contentType?): this` — an inline attachment, referenced from the HTML as `cid:`. ### `PendingMail.toMessage()` `toMessage(): Message` — the message as composed, before the mailer applies its defaults. ### Testing #### `fakeMail(name?)` / `restoreMail(name?)` `fakeMail(name?): FakeMailer` swaps a mailer for one that records instead of delivering. `restoreMail(name?)` puts the real one back — with no name, every faked mailer. `FakeMailer`: | Method | Signature | |--------|-----------| | `assertSent` | `(where?) => void` | | `assertNotSent` | `(where?) => void` | | `assertSentCount` | `(count) => void` | | `assertQueued` | `(where?) => void` | | `assertNotQueued` | `(where?) => void` | | `assertQueuedCount` | `(count) => void` | | `assertNothingSent` | `() => void` — nothing sent *and* nothing queued | | `sent()` / `queued()` | `() => Message[]` | ### Interfaces & types #### `Attachment` `{ filename, content: string | Uint8Array, contentType?, cid? }` — a `cid` makes it an inline attachment. #### `MailerOptions` `{ from?, replyTo? }` — defaults applied to messages that don't set their own. #### `RecordedMail` `{ message: Message, queued: boolean }` — what a `FakeMailer` records. #### `SendMailJob` The `Job` that carries a queued message. Exported so a custom queue driver can recognize it. --- # Migrations Version your database schema. A migration is a `{ name, up, down }` object; a fluent **schema builder** describes tables, and the **migrator** runs them against your [connection](./database.md), tracking what's applied. The SQL is dialect-aware (sqlite / mysql / postgres) and the core imports no driver. ## Define migrations ```ts import type { Migration } from "@shaferllc/keel/core"; export const migrations: Migration[] = [ { name: "01_create_users", up: (schema) => schema.createTable("users", (t) => { t.id(); t.string("email").unique(); t.string("name"); t.boolean("active").default(true); t.timestamps(); // created_at + updated_at }), down: (schema) => schema.dropTable("users"), }, ]; ``` A migration's `name` is its identity — the migrator records it verbatim in the bookkeeping table and skips it on re-runs. Keep names stable and ordered (an `NN_` prefix sorts them); the migrator runs the array in the order you give it. `up`/`down` may be sync or async (`void | Promise`) — return the `schema.createTable(...)` promise, or `await` several statements. ### Column types `t.id()` · `t.string(name, length?)` · `t.text(name)` · `t.integer(name)` · `t.bigInteger(name)` · `t.boolean(name)` · `t.timestamp(name)` · `t.json(name)` · `t.timestamps()`. Every column method except `timestamps()` returns a [`Column`](#column) you can chain modifiers on: `.nullable()`, `.unique()`, `.default(value)`. `timestamps()` returns `void` — it adds nullable `created_at` and `updated_at` for you, so there's nothing to chain. ```ts schema.createTable("posts", (t) => { t.id(); t.integer("user_id"); t.string("slug", 120).unique(); t.text("body").nullable(); t.json("meta").nullable(); t.boolean("published").default(false); t.timestamps(); }); ``` Columns are emitted in the order you declare them. `t.id()` is special: it maps to the dialect's auto-increment primary key (`SERIAL PRIMARY KEY`, `INT AUTO_INCREMENT PRIMARY KEY`, or `INTEGER PRIMARY KEY AUTOINCREMENT`) and is never marked `NOT NULL` — modifiers on it are redundant. ### Defaults and nullability By default every column is `NOT NULL`; `.nullable()` drops that. `.default(v)` renders the literal inline: strings are single-quoted, booleans become `1`/`0` on sqlite and `true`/`false` elsewhere, numbers pass through. Because the default is inlined (not a binding), keep it to dev-authored constants. ```ts t.string("role").default("member"); // ... DEFAULT 'member' t.boolean("active").default(true); // sqlite: DEFAULT 1, else DEFAULT true t.integer("retries").default(0); // ... DEFAULT 0 ``` ### Indexes and foreign keys `createTable` builds indexes and foreign keys alongside the columns: ```ts schema.createTable("members", (t) => { t.id(); t.integer("team_id"); t.string("email"); t.uniqueIndex("email"); // or t.index(["a", "b"]) for composite t.foreign("team_id").references("id").on("teams").onDelete("cascade"); }); ``` ### Altering a table `schema.alterTable(name, build)` adds, renames, and drops columns and indexes on an existing table (dialect-aware SQL). Drop an index before the column it covers: ```ts up: (schema) => schema.alterTable("users", (t) => { t.string("phone").nullable(); // ADD COLUMN t.renameColumn("name", "full_name"); t.index("phone"); t.dropIndex("users_legacy_index"); t.dropColumn("legacy"); }), ``` For anything the builder still doesn't cover, `schema.raw(sql, bindings?)` runs arbitrary SQL: ```ts up: (schema) => schema.raw("CREATE INDEX idx_posts_user ON posts (user_id)"), ``` > `raw()` takes `?` placeholders on every dialect and rewrites them to `$1, $2` > on `postgres`, so the same migration runs unchanged against either database. ## Run and roll back ```ts import { Migrator } from "@shaferllc/keel/core"; const migrator = new Migrator(connection, "postgres"); await migrator.up(migrations); // runs pending migrations (idempotent) await migrator.down(migrations); // rolls back the last batch await migrator.reset(migrations); // rolls back every batch await migrator.ran(); // names already applied await migrator.dropAllTables(); // drops every table, bookkeeping included ``` `up()` records each applied migration in a `migrations` table (`name` PRIMARY KEY, `batch`), so re-running only applies new ones. Every migration applied in a single `up()` call shares one batch number — the previous max plus one. `down()` reverses just the most recent batch, in reverse declaration order, calling each migration's `down()`. Both `up()` and `down()` return the list of names they touched, so you can report progress: ```ts const applied = await migrator.up(migrations); // ["02_add_posts"] const rolled = await migrator.down(migrations); // ["02_add_posts"] ``` The bookkeeping table is created on demand — `up()`, `down()`, and `ran()` each ensure `migrations` exists before touching it, so a fresh database Just Works. ### Edge cases - **Nothing pending:** `up()` returns `[]` and writes nothing new. - **Nothing to roll back:** `down()` returns `[]` when no batch exists. - **A recorded migration missing from the array:** `down()` still deletes its bookkeeping row but has no `down()` to call, so the schema change is *not* reversed — keep old migrations in the array until they're fully retired. - **Dialect default:** the `Migrator` constructor defaults to `"sqlite"` if you omit the second argument. ### Starting over `reset()` calls `down()` until no batch is left, so it only unwinds as far as your `down()` methods actually reach. `dropAllTables()` doesn't call them at all — it finds every table in the current schema and drops it — which is the escape hatch for when a `down()` is wrong, missing, or refers to a table a half-applied migration never created. Postgres gets one `DROP TABLE ... CASCADE`, so the drop order and any foreign keys between them stop mattering; SQLite has no `CASCADE`, so foreign-key enforcement is suspended for the duration instead. Both are destructive by design. The console commands that call them refuse to run when `NODE_ENV=production` unless you pass `--force`. ## From the console You rarely call the `Migrator` yourself — the console has the commands: ```bash npm run keel migrate # run what's pending npm run keel migrate -- --seed # …then run DatabaseSeeder npm run keel migrate:status # which have run, which haven't npm run keel migrate:rollback # undo the last batch npm run keel migrate:reset # undo every batch npm run keel migrate:refresh --seed # reset, migrate, seed npm run keel migrate:fresh --seed # drop every table, migrate, seed ``` `migrate:refresh` unwinds through your `down()` methods; `migrate:fresh` ignores them and drops the tables outright. Reach for `fresh` when `refresh` can't get you back to empty. Both take `--force` to override the production guard. ## Dialect notes The builder emits the right primary-key syntax per dialect — `SERIAL`/`INTEGER PRIMARY KEY AUTOINCREMENT`/`INT AUTO_INCREMENT` — and maps `boolean`/`timestamp`/`json` to each dialect's types (`BOOLEAN` vs `INTEGER`, `TIMESTAMP` vs `DATETIME`, `JSONB` vs `TEXT`). Pass the dialect that matches your connection — it must be the same one you gave [`setConnection`](./database.md). --- ## API reference ### `Migrator` Runs migrations against a [`Connection`](./database.md#connection) and tracks what's applied in a `migrations` table. You construct it directly. #### `new Migrator(conn, dialect?)` `new Migrator(conn: Connection, dialect?: Dialect)` Creates a migrator bound to a connection and dialect (default `"sqlite"`). ```ts const migrator = new Migrator(connection, "postgres"); ``` **Notes:** the dialect drives both the generated DDL and the `?`→`$n` rewrite of the migrator's own bookkeeping writes. It should match the connection you registered with `setConnection`. #### `up(migrations)` `up(migrations: Migration[]): Promise` Runs every migration not yet recorded, in array order, under one new batch; returns the names applied. ```ts const applied = await migrator.up(migrations); ``` **Notes:** idempotent — already-run migrations (matched by `name`) are skipped. Ensures the `migrations` table exists first. Not wrapped in a transaction: if one migration throws, earlier ones in the same call stay applied. #### `down(migrations)` `down(migrations: Migration[]): Promise` Rolls back the most recent batch, calling each migration's `down()` in reverse order; returns the names rolled back. ```ts const rolled = await migrator.down(migrations); ``` **Notes:** returns `[]` when there's no batch to reverse. A recorded name absent from `migrations` has its bookkeeping row deleted but no `down()` invoked, so its schema change is not undone. #### `reset(migrations)` `reset(migrations: Migration[]): Promise` Rolls back every batch, newest first, by calling `down()` until nothing is left; returns the names rolled back in the order they came off. ```ts const rolled = await migrator.reset(migrations); // ["03_x", "02_y", "01_z"] ``` **Notes:** what `migrate:reset` and the first half of `migrate:refresh` run. It only unwinds as far as your `down()` methods reach — for a guaranteed empty database use `dropAllTables()`. #### `dropAllTables()` `dropAllTables(): Promise` Drops every table in the current schema, the `migrations` bookkeeping table included; returns the names dropped. ```ts await migrator.dropAllTables(); await migrator.up(migrations); // …what `migrate:fresh` does ``` **Notes:** never calls a migration's `down()`, which is the point — it's the way back to empty when a `down()` is wrong or missing. Postgres uses a single `DROP TABLE … CASCADE`; SQLite suspends foreign-key enforcement for the duration. Destructive: the console command guards it behind `--force` in production. #### `ran()` `ran(): Promise` Returns the names of all migrations already applied. ```ts const names = await migrator.ran(); const pending = migrations.filter((m) => !names.includes(m.name)); ``` **Notes:** ensures the `migrations` table exists first, so it's safe to call on a brand-new database (returns `[]`). ### `SchemaBuilder` The object passed to a migration's `up`/`down`. **You don't construct it in migrations** — the migrator creates one and hands it to your callbacks — though it is exported and constructible (`new SchemaBuilder(conn, dialect)`) for one-off scripts. #### `createTable(name, build)` `createTable(name: string, build: (table: TableBuilder) => void): Promise` Creates a table, using the `build` callback to describe its columns via a [`TableBuilder`](#tablebuilder). ```ts await schema.createTable("users", (t) => { t.id(); t.string("email").unique(); t.timestamps(); }); ``` **Notes:** emits a single `CREATE TABLE` — it does not add `IF NOT EXISTS`, so re-creating an existing table errors at the driver. Columns appear in declaration order. #### `dropTable(name)` `dropTable(name: string): Promise` Drops a table if it exists. ```ts await schema.dropTable("users"); ``` **Notes:** uses `DROP TABLE IF EXISTS`, so it's safe to run when the table is already gone — the typical `down()` for a `createTable`. #### `raw(sql, bindings?)` `raw(sql: string, bindings?: unknown[]): Promise` Runs arbitrary SQL through the connection — the escape hatch for anything the builders don't cover. ```ts await schema.raw("UPDATE users SET active = ? WHERE active IS NULL", [true]); ``` **Notes:** `bindings` defaults to `[]`. Placeholders are `?` on every dialect and are rewritten to `$1, $2, …` on `postgres`, the same as the rest of Keel — so a migration with bindings behaves identically whichever database it runs against. #### `alterTable(name, build)` `alterTable(name: string, build: (table: AlterTableBuilder) => void): Promise` Alter an existing table — the callback gets an [`AlterTableBuilder`](#altertablebuilder) for adding, renaming, and dropping columns and indexes. Emits one dialect-aware statement per operation, ordered so a dropped index precedes its column. ```ts await schema.alterTable("users", (t) => { t.string("phone").nullable(); t.renameColumn("name", "full_name"); t.dropColumn("legacy"); }); ``` ### `TableBuilder` Describes a table's columns. **You get one from the `createTable` callback** — it is not constructed in migrations. Each column method (except `timestamps`) returns a [`Column`](#column) for chaining modifiers. #### `id(name?)` `id(name?: string): Column` Adds an auto-increment primary-key column (default name `"id"`). ```ts t.id(); // "id" t.id("uuid"); // custom name ``` **Notes:** maps to `SERIAL PRIMARY KEY` (postgres), `INT AUTO_INCREMENT PRIMARY KEY` (mysql), or `INTEGER PRIMARY KEY AUTOINCREMENT` (sqlite). Never emitted as `NOT NULL`; chaining modifiers on it is redundant. #### `string(name, length?)` `string(name: string, length?: number): Column` Adds a `VARCHAR(length)` column (default length `255`). ```ts t.string("email"); t.string("slug", 120); ``` #### `text(name)` `text(name: string): Column` Adds a `TEXT` column (unbounded string). ```ts t.text("body"); ``` #### `integer(name)` / `bigInteger(name)` `integer(name: string): Column` `bigInteger(name: string): Column` Add an `INTEGER` / `BIGINT` column. ```ts t.integer("user_id"); t.bigInteger("view_count"); ``` #### `boolean(name)` `boolean(name: string): Column` Adds a boolean column — `BOOLEAN` on mysql/postgres, `INTEGER` on sqlite. ```ts t.boolean("active").default(true); ``` #### `timestamp(name)` `timestamp(name: string): Column` Adds a timestamp column — `TIMESTAMP` on mysql/postgres, `DATETIME` on sqlite. ```ts t.timestamp("published_at").nullable(); ``` #### `json(name)` `json(name: string): Column` Adds a JSON column — `JSONB` on postgres, `TEXT` elsewhere. ```ts t.json("meta").nullable(); ``` **Notes:** on sqlite/mysql the value is stored as text; serialize/deserialize in your app or [`Model`](./models.md) layer. #### `timestamps()` `timestamps(): void` Adds nullable `created_at` and `updated_at` timestamp columns. ```ts t.timestamps(); ``` **Notes:** returns `void`, not a `Column` — there's nothing to chain. Both columns are `nullable()`. #### `toCreateSql(table, dialect)` `toCreateSql(table: string, dialect: Dialect): string` Renders the accumulated columns into a `CREATE TABLE` statement. Called internally by `SchemaBuilder.createTable`; useful directly only if you're generating DDL by hand. ```ts const t = new TableBuilder(); t.id(); t.string("email"); t.toCreateSql("users", "postgres"); // CREATE TABLE users (id SERIAL PRIMARY KEY, email VARCHAR(255) NOT NULL) ``` #### `index(columns, name?)` / `uniqueIndex(columns, name?)` Add a (possibly composite) index or unique index; `columns` is a name or array. Emitted as `CREATE [UNIQUE] INDEX` after the table. Auto-named unless `name` is given. ```ts t.index("email"); t.uniqueIndex(["team_id", "slug"]); ``` #### `foreign(column)` `foreign(column: string): ForeignKeyBuilder` Add a foreign key, built fluently and emitted inline in the `CREATE TABLE`. ```ts t.foreign("team_id").references("id").on("teams").onDelete("cascade"); ``` #### `columns` `readonly columns: Column[]` The `Column` instances added so far, in declaration order. Read-only inspection seam; you rarely touch it. ### `AlterTableBuilder` From the `alterTable` callback. Column methods (`string`, `integer`, …) **add** columns; plus: - `dropColumn(name)` — drop a column. - `renameColumn(from, to)` — rename a column. - `index(columns, name?)` / `uniqueIndex(columns, name?)` — add an index. - `dropIndex(name)` — drop an index (runs before column drops). ### `ForeignKeyBuilder` From `TableBuilder.foreign(column)`. Chainable: `references(column)`, `on(table)`, `onDelete(action)`, `onUpdate(action)`. ### `Column` A single column definition. **You get one from a `TableBuilder` method** (`t.string(...)` etc.) — you don't construct it in migrations. Modifier methods return `this`, so they chain. #### `nullable()` `nullable(): this` Marks the column nullable (drops the default `NOT NULL`). ```ts t.text("bio").nullable(); ``` #### `unique()` `unique(): this` Adds a `UNIQUE` constraint. ```ts t.string("email").unique(); ``` #### `default(value)` `default(value: unknown): this` Sets a default, rendered inline into the DDL. ```ts t.boolean("active").default(true); t.string("role").default("member"); ``` **Notes:** strings are single-quoted (with no escaping — keep them constant), booleans render as `1`/`0` on sqlite and `true`/`false` elsewhere, numbers pass through via `String(value)`. #### `toSql(dialect)` `toSql(dialect: Dialect): string` Renders this one column's DDL fragment (`name TYPE [NOT NULL] [UNIQUE] [DEFAULT …]`). Called internally by `TableBuilder.toCreateSql`. ```ts new Column("email", "string").unique().toSql("sqlite"); // email VARCHAR(255) NOT NULL UNIQUE ``` ### Interfaces & types #### `Migration` ```ts interface Migration { name: string; up(schema: SchemaBuilder): void | Promise; down(schema: SchemaBuilder): void | Promise; } ``` One schema change and its reversal. `name` is the identity recorded in the `migrations` table (make it unique and sortable); `up` applies the change, `down` reverses it. Both receive a [`SchemaBuilder`](#schemabuilder) and may be sync or async. Implement it as a plain object literal: ```ts const m: Migration = { name: "03_add_index", up: (s) => s.raw("CREATE INDEX idx_users_email ON users (email)"), down: (s) => s.raw("DROP INDEX idx_users_email"), }; ``` #### `Connection` / `Dialect` Re-used from the [database](./database.md) layer. `Migrator` and `SchemaBuilder` take a `Connection` (the driver seam) and a `Dialect` (`"sqlite" | "mysql" | "postgres"`). See [Database → Interfaces & types](./database.md#interfaces--types). --- # Models `Model` is a tiny active-record layer over the [query builder](./database.md). Subclass it, point it at a table, and you get `find` / `all` / `create` / `save` / `delete` — no ORM to configure. It runs through whatever [connection](./database.md) you registered, so it works on Node and the edge. ## Define a model ```ts import { Model } from "@shaferllc/keel/core"; export class User extends Model { static table = "users"; static primaryKey = "id"; // default declare id: number; declare email: string; declare name: string; } ``` Use `declare` for columns — it types the properties without emitting fields that would shadow the row values the model is hydrated with. ## Reading ```ts await User.all(); // User[] await User.find(1); // User | null await User.findOrFail(1); // User (throws NotFoundException if missing) await User.first(); // User | null await User.where("active", true); // User[] ``` For anything richer, `User.query()` returns the underlying query builder: ```ts const rows = await User.query().where("age", ">", 18).orderBy("name").limit(10).get(); ``` ## Writing ```ts // create const user = await User.create({ email: "a@b.com", name: "Ada" }); // update — change attributes, then save user.name = "Grace"; await user.save(); // new instance — save() inserts and back-fills the primary key const draft = new User({ email: "new@x.com" }); await draft.save(); draft.id; // now set // delete await user.delete(); ``` `save()` inserts when there's no primary key and updates when there is — one method for both. `update(attrs)` is `fill` + `save`, and `refresh()` reloads a model's columns from the database: ```ts await user.update({ name: "Grace" }); // mass-assign + save await user.refresh(); // re-read the row ``` ### Find-or-create ```ts // Return the first matching row, or create it from { ...match, ...values }. const tag = await Tag.firstOrCreate({ slug: "keel" }, { name: "Keel" }); // Update the match if it exists, otherwise create it. const sub = await Subscription.updateOrCreate({ user_id: 1 }, { plan: "pro" }); ``` ## Timestamps Set `static timestamps = true` and Keel manages `created_at` / `updated_at` — both on insert, just `updated_at` on update: ```ts class Post extends Model { static table = "posts"; static timestamps = true; // override the column names if yours differ: // static createdAtColumn = "inserted_at"; // static updatedAtColumn = "modified_at"; } const post = await Post.create({ title: "Hi" }); post.created_at; // set post.updated_at; // set (same instant) ``` ## Pagination `Model.paginate(page, perPage)` returns a page of models plus metadata: ```ts const { data, total, currentPage, lastPage, perPage } = await Post.paginate(2, 15); ``` `data` is `Post[]`; the rest is pagination state (defaults: page `1`, `15` per page). The query builder has the same `paginate()` if you're not using models. ## Attribute casts By default columns are whatever the driver returns (SQLite hands back `1`/`0` for booleans, strings for JSON). Declare `static casts` and values round-trip as real JS types — cast when read (from the database or `fill`) and back to storable primitives when written: ```ts class Post extends Model { static table = "posts"; static casts = { published: "boolean", // 1/0 <-> true/false views: "int", // "10" -> 10 meta: "json", // '{"a":1}' <-> { a: 1 } (also "array") posted_at: "date", // ISO string <-> Date } as const; } const post = await Post.find(1); post.published; // true (a real boolean) post.meta; // { … } (a real object) post.published = false; await post.save(); // stored as 0; meta re-serialized to a JSON string ``` The `as const` keeps the string literals from widening to `string` so the map still satisfies `Casts` (`Record`) — without it TypeScript infers `string` values and the assignment to the base `static casts` fails. Casts are what let a `boolean` or `json` column bind cleanly on real drivers, which reject JS booleans and objects as parameters. Supported types: `int` (alias `integer`), `float` (alias `number`), `boolean` (alias `bool`), `string`, `json` / `array`, `date`. Both directions are null-safe — `null`/`undefined` pass through uncast — and reads are tolerant of already-cast input, so hydrating a row twice or casting a value that's already a `Date` is a no-op. ## Mass assignment `create()` and `fill()` take untrusted input (often a request body), so they're guarded. Whitelist columns with `static fillable`, or blacklist with `static guarded` — columns outside the allowance are silently dropped: ```ts class Post extends Model { static table = "posts"; static fillable = ["title", "body"]; // only these are mass-assignable // — or — static guarded = ["is_admin"]; // everything except these } await Post.create({ title: "Hi", is_admin: true }); // is_admin dropped post.fill(request.all()); // safe from over-posting post.forceFill({ is_admin: true }); // explicit bypass ``` With neither declared, all attributes are assignable (the default). Direct property assignment (`post.is_admin = true`) is never guarded — guarding is only about *mass* assignment from untrusted data. ## Serializing ```ts user.toJSON(); // a plain object of the (cast) attributes + loaded relations return json(user); // works directly — json() serializes it user.fill({ name: "X" }); // merge mass-assignable attributes without saving ``` Control what `toJSON()` exposes with three statics. `hidden` strips columns; `visible` is an allowlist that wins over everything; `appends` adds computed attributes — a getter or a zero-arg method on the model: ```ts class User extends Model { static table = "users"; static hidden = ["password"]; // never serialized static appends = ["fullName"]; // added to the output get fullName() { return `${this.first} ${this.last}`; } } ``` ## Lifecycle events A model fires events as it is retrieved, saved, and deleted. Hook onto them to slug a title, bust a cache, or cascade — without touching every call site. The `*ing` events are **cancelable**: a hook returning `false` aborts the write. ```ts User.creating((user) => { user.uuid = crypto.randomUUID(); }); User.saved((user) => cache().forget(`user:${user.id}`)); User.deleting((user) => (user.isRoot ? false : undefined)); // veto // Or group them in an observer: User.observe({ creating: (u) => { u.uuid = crypto.randomUUID(); }, deleted: (u) => audit(`deleted ${u.id}`), }); ``` Events: `retrieved`, `creating`/`created`, `updating`/`updated`, `saving`/`saved`, `deleting`/`deleted`, `restoring`/`restored`. Hooks **inherit**, ancestors first: a hook on a base class fires for every model that extends it. That's what lets a base class do real work — a `creating` hook that stamps a tenant id is useless if subclasses never fire it. ## Query scopes A **global scope** constrains every query a model builds — the base for multi-tenancy, published-only reads, and soft deletes: ```ts Post.addGlobalScope("published", (q) => q.where("published", true)); await Post.all(); // only published await Post.query().where("author_id", 1).get(); // still only published ``` Scopes **inherit**. A scope declared on a base class constrains every model that extends it — which is what makes a single tenant-scoped base class possible: ```ts class TenantModel extends Model {} TenantModel.addGlobalScope("tenant", (q) => q.where("teamId", currentTeamId())); class Post extends TenantModel {} // scoped, without repeating yourself ``` Scopes from several levels all apply, and a subclass overrides an ancestor's scope by reusing its name — the nearest declaration wins. ### Escaping a scope ```ts await Post.withoutGlobalScope("tenant").get(); // one named scope await Post.withoutGlobalScopes().get(); // all of them ``` Escaping is deliberately explicit, and worth keeping that way. A query that steps outside a tenancy scope is exactly the thing you want to be able to *find* — so it should be typed out and greppable, never something you arrive at by forgetting a `where`. A **local scope** is just a static method returning a query — no framework feature needed: ```ts class Post extends Model { static popular() { return this.query().where("views", ">", 1000); } } await Post.popular().orderBy("views", "desc").get(); ``` ## Soft deletes Opt in with `static softDeletes = true` and a `deleted_at` column. `delete()` then sets the timestamp instead of removing the row, and a global scope hides soft-deleted rows from every query. ```ts class User extends Model { static table = "users"; static softDeletes = true; static casts = { deleted_at: "date" }; } await user.delete(); // sets deleted_at; row stays in the table user.trashed(); // true await User.find(user.id); // null — hidden by the scope await User.withTrashed().get(); // include soft-deleted await User.onlyTrashed().get(); // only soft-deleted await user.restore(); // clear deleted_at await user.forceDelete(); // remove the row for good ``` ## Relationships Define a relationship as a method that returns one of `hasMany` / `hasOne` / `belongsTo` / `belongsToMany`. Keys follow conventions (the owning model's name plus its primary key — `user_id`) but every one is overridable. ```ts class User extends Model { static table = "users"; posts() { return this.hasMany(Post); } // posts.user_id = users.id profile() { return this.hasOne(Profile); } // profiles.user_id = users.id roles() { return this.belongsToMany(Role); } // role_user pivot } class Post extends Model { static table = "posts"; author() { return this.belongsTo(User); } // posts.user_id -> users.id } ``` Relations are **awaitable** — read them lazily with `await`: ```ts const posts = await user.posts(); // Post[] const author = await post.author(); // User | null ``` Need to constrain or sort? `.query()` hands back the underlying query builder: ```ts const recent = await user.posts().query().orderBy("created_at", "desc").limit(5).get(); ``` ### Eager loading (avoiding N+1) Loading a relation per model in a loop is N+1 queries. `Model.load()` fetches them all with one extra query per relation, using `whereIn`: ```ts const users = await User.all(); await User.load(users, "posts", "roles"); // 2 extra queries total, not 2×N users[0].getRelation("posts"); // Post[] users[0].toJSON(); // includes `posts` and `roles` ``` Loaded relations are stored off the model, so they never leak into `save()`, and `toJSON()` serializes them (nested models included). ### Querying relationships (`with`, `withCount`, `whereHas`) `Model.query()` returns a model-aware builder with the relationship operations a raw query can't express. `with()` eager-loads (dotted paths nest), `withCount()` adds a `_count`, and `has`/`whereHas`/`doesntHave` filter by whether a related row exists: ```ts const users = await User.query() .where("active", true) .with("posts.comments") // nested eager load .withCount("posts") // users[i].posts_count .whereHas("posts", (q) => q.where("published", true)) .get(); await User.has("posts").get(); // users with at least one post await User.doesntHave("posts").get(); // users with none ``` `with`/`withCount`/`whereHas`/`has`/`doesntHave` are also static shortcuts (`User.with(...)`, `User.whereHas(...)`). Existence filters use the same driver-agnostic two-query strategy as the relations themselves — no JOIN. ### Many-to-many `belongsToMany` reads through a pivot table (default name: the two table names sorted and joined, e.g. `role_user`) and can write it too: ```ts await user.roles().attach(roleId); // insert a pivot row await user.roles().detach(roleId); // remove one (or all, with no argument) await user.roles().sync([1, 2, 3]); // make the pivot contain exactly these ``` Every relation runs on the driver-agnostic query builder — no JOINs, no driver imports — so relationships stay edge-safe. Overriding keys: ```ts this.hasMany(Post, "authored_by", "id"); this.belongsTo(User, "owner_id", "id"); this.belongsToMany(Role, "user_roles", "user_id", "role_id"); ``` ### Polymorphic A polymorphic relation lets one model belong to more than one type. The related rows carry `_id` + `_type`; register each owner type so `morphTo` can resolve it: ```ts class Post extends Model { comments() { return this.morphMany(Comment, "commentable"); } } class Video extends Model { comments() { return this.morphMany(Comment, "commentable"); } } class Comment extends Model { commentable() { return this.morphTo("commentable"); } // resolves back to Post or Video } registerMorphType("Post", Post); registerMorphType("Video", Video); await post.comments().create({ body: "nice" }); // sets commentable_id/_type const owner = await comment.commentable(); // Post | Video | null ``` `morphOne` is the one-to-one variant. Eager loading (`Model.load` / `with`) works across mixed types. ## What this is (and isn't) This is a compact active-record — CRUD, lifecycle events, scopes, soft deletes, serialization control, eager loading (including nested `with("posts.comments")`), relationship queries (`whereHas`/`withCount`), and polymorphic relations — all on a driver-agnostic query builder, no ORM dependency. For complex one-off queries you can always drop to `db()` or your driver directly. --- ## API reference Everything below imports from `@shaferllc/keel/core`. ### `Model` — static methods You call these on your subclass (`User.find(1)`), not on `Model` itself. Each read hydrates rows into instances of the class it was called on. #### `Model.query()` `static query(): QueryBuilder` Returns a raw [query builder](./database.md) scoped to the model's table — the escape hatch for anything the finders don't cover. ```ts const rows = await User.query().where("age", ">", 18).orderBy("name").limit(10).get(); ``` **Notes:** returns plain `Row`s, not hydrated models — map them through `new User(row)` yourself if you need instances. #### `Model.all()` `static all(this: ModelClass): Promise` Fetches every row in the table as hydrated models. ```ts const users = await User.all(); // User[] ``` **Notes:** no `where`, no `limit` — it reads the whole table. Reach for `query()` when that's too much. #### `Model.find(id)` `static find(this: ModelClass, id: unknown): Promise` Looks a model up by primary key. Resolves to `null` when nothing matches. ```ts const user = await User.find(1); // User | null ``` **Notes:** matches on `static primaryKey` (default `"id"`). Returns `null`, not `undefined`. #### `Model.findOrFail(id)` `static findOrFail(this: ModelClass, id: unknown): Promise` Like `find`, but throws instead of returning `null`. ```ts const user = await User.findOrFail(1); // User (or throws) ``` **Notes:** throws `NotFoundException` with message `" not found"`. `NotFoundException` is an `HttpException` (status 404), so an HTTP handler surfaces it as a 404 without extra work. #### `Model.first()` `static first(this: ModelClass): Promise` Returns the first row in the table (no ordering), or `null`. ```ts const anyUser = await User.first(); // User | null ``` **Notes:** unordered — the "first" row is whatever the driver returns first. Add your own `orderBy` via `query().first()` when order matters. #### `Model.where(column, value)` `static where(this: ModelClass, column: string, value: unknown): Promise` A convenience finder for a single equality condition. Runs immediately and returns hydrated models. ```ts const active = await User.where("active", true); // User[] ``` **Notes:** equality only, and it's a terminal call — it returns a `Promise`, not a builder, so you can't chain more constraints onto it. Use `query()` for operators, `OR`, ordering, or limits. #### `Model.create(attributes)` `static create(this: ModelClass, attributes: Row): Promise` Mass-assigns `attributes` (filtered through `fillable`/`guarded`), inserts one row, and returns the hydrated model with its new primary key set. ```ts const user = await User.create({ email: "a@b.com", name: "Ada" }); user.id; // populated from insertId ``` **Notes:** attributes outside the mass-assignment allowance are silently dropped before the insert. Values are cast to storable primitives on the way in. If the driver doesn't report an `insertId`, the primary key stays unset. #### `Model.load(models, ...names)` `static load(models: T[], ...names: string[]): Promise` Eager-loads one or more relationships onto an array of already-fetched models — one extra query per relation, the fix for N+1. Returns the same array. ```ts const users = await User.all(); await User.load(users, "posts", "roles"); // 2 extra queries, not 2×N users[0].getRelation("posts"); // Post[] ``` **Notes:** each name must be a relationship method on the model; an unknown name throws `" has no relation """`. An empty `models` array is returned untouched (no queries). Loaded results are stored off the model (see `getRelation`) and never leak into `save()`. #### `Model.filterFillable(attributes)` `static filterFillable(attributes: Row): Row` Returns a copy of `attributes` keeping only what mass-assignment allows — the guard `create`/`fill` apply. Rarely called directly. ```ts const safe = Post.filterFillable(request.all()); ``` **Notes:** if `fillable` is non-empty it's an allowlist; else if `guarded` is non-empty it's a denylist; with neither, everything passes. `fillable` wins when both are set. #### `Model.toDatabase(attributes)` `static toDatabase(attributes: Row): Row` Casts `attributes` to their storable primitives (via `castSet`) for a write. Rarely called directly — `create`/`save` use it internally. ```ts const storable = Post.toDatabase({ published: true }); // { published: 1 } ``` #### `Model.with(...names)` · `Model.withCount(...names)` Start a [`ModelQuery`](#modelquery) that eager-loads the named relations (dotted paths nest: `"posts.comments"`) or counts them into `_count`. #### `Model.has(name)` · `Model.whereHas(name, constrain?)` · `Model.doesntHave(name, constrain?)` Start a `ModelQuery` filtered by relationship existence — has at least one related row, has one matching `constrain(query)`, or has none. `constrain` receives the related-table query builder. #### `Model.newQuery()` `static newQuery(): ModelQuery` The model-aware query behind the sugar above — hydrates rows to models and adds `with`/`withCount`/`whereHas`. #### `Model.addGlobalScope(name, scope)` `static addGlobalScope(name: string, scope: (query: QueryBuilder) => void): void` Register a constraint applied to every query the model builds. Inherited by subclasses; a subclass re-using a name overrides it. #### `Model.withTrashed()` · `Model.onlyTrashed()` · `Model.withoutGlobalScope(...names)` · `Model.withoutGlobalScopes()` Escape hatches returning a `QueryBuilder`: include (or only) soft-deleted rows, or drop named / all global scopes. Deliberately explicit so an unscoped query is greppable at audit time. ### `Model` — lifecycle events Register per-class hooks (keyed by the exact class). The `*ing` events are cancelable — a hook returning `false` aborts the operation. #### `Model.creating` · `created` · `updating` · `updated` · `saving` · `saved` · `deleting` · `deleted` · `restoring` · `restored` · `retrieved` `static (hook: (model: T) => void | boolean | Promise): void` Add a hook for that lifecycle event. `create()` fires `saving`→`creating`→write→ `created`→`saved`; a save that updates fires the `updating`/`updated` pair. #### `Model.observe(observer)` `static observe(observer: Partial>>): void` Attach an observer object — each method named after an event becomes a hook. ### `Model` — configuration statics Set these on the subclass to configure it. All have defaults. #### `static table` `static table: string` The table the model reads and writes. Required — defaults to `""`. ```ts class User extends Model { static table = "users"; } ``` #### `static primaryKey` `static primaryKey: string` The primary-key column used by `find`, `save`, and `delete`. Defaults to `"id"`. ```ts class Session extends Model { static table = "sessions"; static primaryKey = "token"; } ``` #### `static fillable` `static fillable: string[]` Allowlist of mass-assignable columns. Defaults to `[]` (meaning "not an allowlist" — see `filterFillable`). ```ts class Post extends Model { static table = "posts"; static fillable = ["title", "body"]; } ``` #### `static guarded` `static guarded: string[]` Denylist of columns that mass-assignment must never set. Ignored when `fillable` is non-empty. Defaults to `[]`. ```ts class Post extends Model { static table = "posts"; static guarded = ["is_admin"]; } ``` #### `static casts` `static casts: Casts` Maps columns to cast types so values round-trip as real JS types. Declare it `as const` so the literals don't widen to `string`. Defaults to `{}`. ```ts class Post extends Model { static table = "posts"; static casts = { published: "boolean", meta: "json" } as const; } ``` #### `static hidden` / `static visible` / `static appends` `static hidden: string[]` · `static visible: string[]` · `static appends: string[]` Shape `toJSON()`: `hidden` strips columns, `visible` is an allowlist that wins, `appends` adds computed attributes (a getter or zero-arg method). All default `[]`. #### `static softDeletes` / `static deletedAtColumn` `static softDeletes: boolean` (default `false`) · `static deletedAtColumn: string` (default `"deleted_at"`) Turn on soft deletes: `delete()` sets the timestamp and a global scope hides trashed rows. ### `Model` — instance methods #### `new Model(attributes?)` `constructor(attributes?: Row)` Hydrates a model from a row. Assignment is unguarded (rows come from the database) but every column named in `casts` is cast on the way in. ```ts const draft = new User({ email: "new@x.com" }); ``` **Notes:** hydration bypasses `fillable`/`guarded` — it's for trusted rows, not request bodies. Use `create`/`fill` for untrusted input. #### `save()` `save(): Promise` Inserts when the primary key is absent, updates when it's present — one method for both. Back-fills the primary key after an insert. ```ts const u = new User({ email: "a@b.com" }); await u.save(); // INSERT; u.id now set u.name = "Grace"; await u.save(); // UPDATE where id = u.id ``` **Notes:** writes every own column (cast to storable primitives); loaded relations live off-instance and never leak in. An update with no changed columns still issues the query. #### `delete()` `delete(): Promise` Deletes the row matching this model's primary key — or, with `static softDeletes` on, sets `deleted_at` instead. Fires `deleting`/`deleted`. ```ts await user.delete(); ``` **Notes:** keys off the current `primaryKey` value. See `forceDelete`/`restore` for the soft-delete variants. #### `forceDelete()` · `restore()` · `trashed()` `forceDelete(): Promise` · `restore(): Promise` · `trashed(): boolean` For soft-deletable models: permanently remove the row, clear `deleted_at` (fires `restoring`/`restored`), or test whether it's currently trashed. #### `fill(attributes)` `fill(attributes: Row): this` Merges mass-assignable attributes into the model (filtered + cast), without saving. Returns `this` for chaining. ```ts user.fill(request.all()).save(); ``` **Notes:** respects `fillable`/`guarded` — safe for request bodies. Doesn't touch the database until you call `save()`. #### `forceFill(attributes)` `forceFill(attributes: Row): this` Like `fill`, but bypasses mass-assignment guarding. Still casts. ```ts user.forceFill({ is_admin: true }); // deliberate over-post ``` **Notes:** the explicit escape hatch — only use it with trusted data. #### `toJSON()` `toJSON(): Row` Returns a plain object of the model's (cast) attributes plus any loaded relations, nested models included. `JSON.stringify` and `json()` call it automatically. ```ts return json(user); // toJSON() runs under the hood user.toJSON(); // { id, email, …, posts: [...] } if `posts` was loaded ``` **Notes:** only *loaded* relations appear — unloaded relationship methods are not invoked. Relations serialize recursively via each nested model's `toJSON`. #### `getRelation(name)` `getRelation(name: string): T | undefined` Reads a relation previously loaded by `Model.load` (or `setRelation`). Returns `undefined` if it was never loaded. ```ts const posts = users[0].getRelation("posts"); ``` **Notes:** does not trigger a query — it only reads what's already cached. Awaiting the relationship method (`await user.posts()`) is the lazy alternative. #### `setRelation(name, value)` `setRelation(name: string, value: unknown): this` Stores a relation result under `name` (what eager loading uses under the hood). Returns `this`. ```ts user.setRelation("posts", await user.posts()); ``` **Notes:** the store is keyed off the instance (a `WeakMap`), so it never leaks into `save()`; `toJSON()` picks it up. #### `hasMany(related, foreignKey?, localKey?)` `hasMany(related: ModelClass, foreignKey?: string, localKey?: string): HasMany` Declares a one-to-many: this model has many `related` rows joined by a foreign key on the related table. Call it from a relationship method. ```ts posts() { return this.hasMany(Post); } // posts.user_id = users.id authored() { return this.hasMany(Post, "authored_by", "id"); } ``` **Notes:** `foreignKey` defaults to `_` (e.g. `user_id`); `localKey` defaults to this model's primary key. #### `hasOne(related, foreignKey?, localKey?)` `hasOne(related: ModelClass, foreignKey?: string, localKey?: string): HasOne` Declares a one-to-one, same key conventions as `hasMany`. ```ts profile() { return this.hasOne(Profile); } // profiles.user_id = users.id ``` **Notes:** resolves to a single model or `null` (the first matching row). #### `belongsTo(related, foreignKey?, ownerKey?)` `belongsTo(related: ModelClass, foreignKey?: string, ownerKey?: string): BelongsTo` Declares the inverse: this model carries the foreign key pointing at `related`. ```ts author() { return this.belongsTo(User); } // posts.user_id -> users.id owner() { return this.belongsTo(User, "owner_id", "id"); } ``` **Notes:** `foreignKey` defaults to `_` (a column on *this* table); `ownerKey` defaults to the related model's primary key. Resolves to `null` when the foreign key is null. #### `belongsToMany(related, pivotTable?, foreignPivotKey?, relatedPivotKey?, parentKey?, relatedKey?)` `belongsToMany(related: ModelClass, pivotTable?: string, foreignPivotKey?: string, relatedPivotKey?: string, parentKey?: string, relatedKey?: string): BelongsToMany` Declares a many-to-many through a pivot table. ```ts roles() { return this.belongsToMany(Role); } // role_user pivot roles() { return this.belongsToMany(Role, "user_roles", "user_id", "role_id"); } ``` **Notes:** `pivotTable` defaults to the two model names lowercased, sorted, and joined with `_` (User + Role → `role_user`). The pivot keys default to `_`. Reads as two `whereIn` queries (no JOIN), so it stays edge-safe. #### `morphMany(related, name, localKey?)` · `morphOne(related, name, localKey?)` `morphMany(related: ModelClass, name: string, localKey?: string): MorphMany` The parent side of a polymorphic relation. Related rows carry `_id` + `_type` (the type stored is this model's class name). `MorphMany` also has `.create(attributes)`, which fills the morph keys. ```ts comments() { return this.morphMany(Comment, "commentable"); } ``` #### `morphTo(name, idColumn?, typeColumn?)` `morphTo(name: string, idColumn?: string, typeColumn?: string): MorphTo` The owning side — resolves the parent from the stored `_type` (via [`registerMorphType`](#registermorphtypetype-model)) and `_id`. Awaitable; returns the parent model or `null`. ```ts commentable() { return this.morphTo("commentable"); } ``` #### `registerMorphType(type, model)` `registerMorphType(type: string, related: ModelClass): void` Register a model under a morph-type string (usually its class name) so `morphTo` can resolve it. Call once at boot for each owner type. ### `ModelQuery` The model-aware builder returned by `Model.query()`, `Model.newQuery()`, and the `with`/`whereHas`/`withCount` shortcuts. It proxies the query-builder constraint methods (`where`, `orderBy`, `limit`, …) and hydrates results to models, adding: - `with(...names)` — eager-load relations; dotted paths nest (`"posts.comments"`). - `withCount(...names)` — add `_count` to each result. - `has(name)` / `whereHas(name, constrain?)` / `doesntHave(name, constrain?)` — filter by relationship existence. - Terminals `get()`, `first()`, `count()`, `exists()`, `paginate(page?, perPage?)`. Existence filters and counts use the same driver-agnostic two-query strategy as the relations (no JOIN). `toBase()` returns the underlying `QueryBuilder`. ### Relations You never `new` these — a relationship method (`user.posts()`) returns one. Each is **awaitable**: `await`ing it runs the query and resolves to the result. All four share the `Relation` base contract (`query`, `get`, `eager`, `then`); `BelongsToMany` adds pivot writes. #### `Relation` (abstract base) `abstract class Relation implements PromiseLike` The shared base. Because it's `PromiseLike`, a relation resolves through `await` or `.then()` without calling `get()` explicitly. ```ts const posts = await user.posts(); // then() → get() const post = await user.posts().get(); // same thing, explicit ``` ##### `query()` `query(): QueryBuilder` Returns the underlying query builder with the relationship constraint applied — constrain, sort, or paginate before fetching. ```ts const recent = await user.posts().query().orderBy("created_at", "desc").limit(5).get(); ``` **Notes:** for `belongsToMany`, `query()` is the related-table builder *without* the pivot filter — prefer `get()`/`await` for the full pivot-aware read. ##### `get()` `get(): Promise` Runs the relationship and returns its result — the type depends on the subclass (see below). ##### `eager(models, name)` `eager(models: Model[], name: string): Promise` Batch-loads this relationship onto many parents and stores each result via `setRelation`. Called by `Model.load` — you rarely call it directly. ##### `then(onFulfilled?, onRejected?)` `then(onFulfilled?, onRejected?): PromiseLike` The `PromiseLike` hook that makes a relation awaitable; it delegates to `get()`. #### `HasMany.get()` `get(): Promise` Returns all related rows as hydrated models (empty array when none). ```ts const posts: Post[] = await user.posts(); ``` #### `HasOne.get()` `get(): Promise` Returns the single related model, or `null`. ```ts const profile = await user.profile(); // Profile | null ``` #### `BelongsTo.get()` `get(): Promise` Returns the owner model, or `null` when this model's foreign key is null. ```ts const author = await post.author(); // User | null ``` #### `BelongsToMany.get()` `get(): Promise` Reads the pivot rows, then the related rows they point at, as hydrated models. ```ts const roles: Role[] = await user.roles(); ``` **Notes:** related ids are de-duplicated, so a row linked twice through the pivot appears once. #### `BelongsToMany.attach(id, extra?)` `attach(id: unknown, extra?: Row): Promise` Inserts one pivot row linking the parent to `id`, plus any `extra` pivot columns. ```ts await user.roles().attach(roleId); await user.roles().attach(roleId, { assigned_at: now }); ``` **Notes:** no uniqueness check — attaching the same id twice inserts two pivot rows unless the table constrains it. #### `BelongsToMany.detach(id?)` `detach(id?: unknown): Promise` Removes the pivot row for `id`, or **all** the parent's pivot rows when called with no argument. ```ts await user.roles().detach(roleId); // one link await user.roles().detach(); // every link for this user ``` #### `BelongsToMany.sync(ids)` `sync(ids: unknown[]): Promise` Makes the pivot contain exactly `ids` — detaches everything, then attaches each. ```ts await user.roles().sync([1, 2, 3]); ``` **Notes:** not diff-based — it detaches all then re-attaches, so passing `[]` clears every link. Runs one delete plus one insert per id (not a transaction). ### Interfaces & types #### `CastType` ```ts type CastType = | "int" | "integer" | "float" | "number" | "boolean" | "bool" | "string" | "json" | "array" | "date"; ``` The supported cast kinds — the values in a `casts` map. Aliases pair up (`int`/`integer`, `float`/`number`, `boolean`/`bool`, `json`/`array`). ```ts const kind: CastType = "boolean"; ``` #### `Casts` `type Casts = Record` A column-to-cast-type map — the shape of `static casts`. Declare literal maps `as const` so the string values don't widen past `CastType`. ```ts const casts: Casts = { published: "boolean", meta: "json" }; ``` ### Casting internals `castGet`, `castSet`, and `applyCasts` (in `src/core/casts.ts`) are the functions that power casting — `castGet` maps storage → JS, `castSet` maps JS → storage, and `applyCasts` runs one of them over the keys named in a `Casts` map. They're internal plumbing: the `Model` uses them for you and they aren't re-exported from `@shaferllc/keel/core`, so declaring `static casts` is all you need. --- # Notifications Send a message to a recipient over one or more **channels** — mail, database, or your own — inline or through the queue. This is where the mail and queue layers compose: a notification declares *what* to say and *which channels* carry it, and each channel decides *how*. Edge-safe, like everything under it. ## Defining a notification Subclass `Notification`. `via()` lists the channels; each channel reads from a matching method (`toMail`, `toArray`): ```ts import { Notification, type Notifiable, type MailContent } from "@shaferllc/keel/core"; export class InvoicePaid extends Notification { constructor(private amount: number) { super(); } via(_notifiable: Notifiable) { return ["mail", "database"]; } toMail(): MailContent { return { subject: "Payment received", text: `Thanks for $${this.amount}.` }; } toArray() { return { amount: this.amount }; } } ``` Generate one with `keel make:notification InvoicePaid` (→ `app/Notifications/InvoicePaidNotification.ts`). ## Sending ```ts import { notify } from "@shaferllc/keel/core"; await notify(user, new InvoicePaid(4200)); // one recipient await notify([alice, bob], new InvoicePaid(4200)); // many ``` A recipient is any object with routing info — usually a `User` model. The mail channel routes to `notifiable.email`; override per channel with `routeNotificationFor`: ```ts class User extends Model { static table = "users"; routeNotificationFor(channel: string) { return channel === "mail" ? this.billing_email : undefined; } } ``` ## Routing Each channel needs to know *where* a recipient receives it. `routeFor` resolves that: it calls the notifiable's `routeNotificationFor(channel)` first, and if that returns nothing it falls back to `notifiable.email` for the `mail` channel or `notifiable.id` for everything else. ```ts import { routeFor } from "@shaferllc/keel/core"; routeFor(user, "mail"); // user.email, unless routeNotificationFor overrides it routeFor(user, "database"); // user.id ``` So the common case needs no routing method at all — a `User` with `email` and `id` columns just works. Override `routeNotificationFor` only when a channel addresses the recipient differently (a billing address, a Slack id, a phone number). Return `undefined` from it to fall back to the default. The mail channel throws `Notification: no mail route …` if it can't resolve an address — set `email`, implement `routeNotificationFor`, or put a `to` on the `MailContent`. ## Channels Register channels on the notifier (typically in a service provider). The `mail` channel is registered by default: ```ts import { setNotifier, Notifier, DatabaseChannel } from "@shaferllc/keel/core"; setNotifier(new Notifier().channel("database", new DatabaseChannel())); ``` | Channel | Delivers by | |---------|-------------| | `MailChannel` (`mail`, default) | The mailer, using the notification's `toMail`. Routes to `email`. | | `DatabaseChannel` (`database`) | Inserting `toArray` into a table (`type`, `notifiable_id`, `data`). | | `ArrayChannel` (`array`) | Collecting deliveries in `.sent` — for tests. | The database channel expects a table (default `notifications`) with `type`, `notifiable_id`, and a `data` (JSON) column — create it in a migration. Point it at another table by passing the name: `new DatabaseChannel("alerts")`. Delivery walks the channels named by `via()` in order, and each is looked up by name. If `via()` names a channel that was never registered, the notifier throws `No notification channel "…" registered.` — so register a channel before a notification routes to it. Likewise the mail channel throws if the notification has no `toMail()`. ## Queued notifications Set `shouldQueue = true` and delivery happens from a queued job instead of on the request path — every channel runs inside the job: ```ts export class InvoicePaid extends Notification { shouldQueue = true; // … } await notify(user, new InvoicePaid(4200)); // returns immediately; runs on the worker ``` With the `SyncDriver` (the default queue) it still runs immediately; with a `MemoryDriver` or a real broker it's deferred until a worker drains it. ## A custom channel A channel is one method — `send`. That's the seam for SMS, Slack, push, or any provider: ```ts import type { Channel, Notifiable, Notification } from "@shaferllc/keel/core"; const slack: Channel = { async send(notifiable, notification) { const payload = notification.toArray?.(notifiable) ?? {}; // POST payload to a Slack webhook via fetch… }, }; setNotifier(new Notifier().channel("slack", slack)); ``` ## In tests Register an `ArrayChannel` (or assert on the mail `ArrayTransport`) and check what was delivered — no network: ```ts import { setNotifier, Notifier, ArrayChannel, notify } from "@shaferllc/keel/core"; const array = new ArrayChannel(); setNotifier(new Notifier().channel("array", array)); await notify(user, new InvoicePaid(4200)); // a notification whose via() returns ["array"] assert.equal(array.sent.length, 1); assert.ok(array.sent[0].notification instanceof InvoicePaid); ``` ## Related Notifications compose the [mail](./mail.md) and [queue](./queues.md) layers, and the database channel writes through the [query builder](./database.md). Reach for those directly when you need a one-off email or an ad-hoc queued job. --- ## API reference ### `notify(notifiables, notification)` `notify(notifiables: Notifiable | Notifiable[], notification: Notification): Promise` Sends a notification to one or many recipients through the default notifier. ```ts await notify(user, new InvoicePaid(4200)); await notify([alice, bob], new InvoicePaid(4200)); ``` **Notes:** a thin wrapper over `getNotifier().send(...)`. If the notification's `shouldQueue` is `true`, it resolves once the job is *dispatched*, not once delivery finishes; otherwise it awaits every channel inline. A single recipient is normalized to a one-element list. ### `setNotifier(instance)` `setNotifier(instance: Notifier): Notifier` Replaces the global notifier used by `notify()` and returns it. ```ts setNotifier(new Notifier().channel("database", new DatabaseChannel())); ``` **Notes:** global — the last call wins. Because the default notifier only has the `mail` channel, this is how you register `database`, `array`, or custom channels app-wide (usually in a service provider). ### `getNotifier()` `getNotifier(): Notifier` Returns the current global notifier — handy for registering a channel without swapping the instance. ```ts getNotifier().channel("slack", slack); ``` **Notes:** returns the same instance every call until `setNotifier` replaces it. ### `routeFor(notifiable, channel)` `routeFor(notifiable: Notifiable, channel: string): string | number | undefined` Resolves the address/id a notifiable receives a given channel at. ```ts routeFor(user, "mail"); // user.email (string) routeFor(user, "database"); // user.id (number) ``` **Notes:** tries `notifiable.routeNotificationFor(channel)` first (skipped if it returns `null`/`undefined`), then falls back to `email` for `"mail"` and `id` for any other channel. Returns `undefined` when nothing resolves — channels decide whether that's an error. ### `Notification` The abstract base for a notification. Subclass it, list channels from `via()`, and add a `to()` builder per channel. #### `shouldQueue` `shouldQueue: boolean` Instance flag — set `true` to deliver from a queued job instead of inline. ```ts class InvoicePaid extends Notification { shouldQueue = true; } ``` **Notes:** defaults to `false`. Read by `Notifier.send`; when `true`, all channels run inside the dispatched job. #### `via(notifiable)` `via(notifiable: Notifiable): string[]` Returns the channel names to deliver on for this recipient. ```ts via(notifiable: Notifiable) { return notifiable.email ? ["mail", "database"] : ["database"]; } ``` **Notes:** defaults to `["mail"]`. Called once per recipient, so you can branch on the notifiable. Every name it returns must be a registered channel or delivery throws. #### `toMail(notifiable)` `toMail?(notifiable: Notifiable): MailContent` Optional. Builds the content the `mail` channel sends. Required if `via()` includes `"mail"`. ```ts toMail(): MailContent { return { subject: "Payment received", text: "Thanks!" }; } ``` **Notes:** the mail channel throws if `via()` names `"mail"` but this is undefined. Set `to` on the returned `MailContent` to override the resolved recipient address. #### `toArray(notifiable)` `toArray?(notifiable: Notifiable): Record` Optional. Builds the payload the `database` and `array` channels serialize/store. ```ts toArray() { return { amount: this.amount }; } ``` **Notes:** the database channel stores `{}` when it's undefined; the array channel keeps the whole notification, not this payload, so a missing `toArray` still works in tests. ### `Notifier` Holds the channel registry and drives delivery. `notify()` uses a global one, but you can construct your own. #### `channel(name, channel)` `channel(name: string, channel: Channel): this` Registers (or replaces) a channel under a name; returns `this` to chain. ```ts new Notifier() .channel("database", new DatabaseChannel()) .channel("array", new ArrayChannel()); ``` **Notes:** a fresh `Notifier` already has `mail` → `MailChannel`. Registering the same name again replaces it. #### `send(notifiables, notification)` `send(notifiables: Notifiable | Notifiable[], notification: Notification): Promise` Delivers a notification to one or many recipients across the channels its `via()` returns. ```ts await new Notifier().send(user, new InvoicePaid(4200)); ``` **Notes:** normalizes a single recipient to a list, then delivers to each in order. Honors `notification.shouldQueue` (dispatches to the queue when set). Throws on the first unregistered channel name. ### `MailChannel` The default `mail` channel. Registered on every `Notifier`; you rarely construct it yourself. #### `send(notifiable, notification)` `send(notifiable: Notifiable, notification: Notification): Promise` Builds a message from `notification.toMail()` and sends it through the mailer. ```ts await new MailChannel().send(user, new InvoicePaid(4200)); ``` **Notes:** throws `… has no toMail()` if the notification lacks one, and `Notification: no mail route …` if it can't resolve an address (from `MailContent.to` or `routeFor(notifiable, "mail")`). Applies `from`, `text`, and `html` only when present. ### `DatabaseChannel` The `database` channel. Persists the `toArray` payload through the query builder. #### `new DatabaseChannel(table?)` `new DatabaseChannel(table?: string)` Creates a channel that writes to `table`. ```ts new DatabaseChannel(); // → "notifications" new DatabaseChannel("alerts"); // → "alerts" ``` **Notes:** defaults to the `notifications` table. #### `send(notifiable, notification)` `send(notifiable: Notifiable, notification: Notification): Promise` Inserts one row: `type` (the notification's class name), `notifiable_id` (`routeFor(notifiable, "database")`, or `null`), and `data` (JSON of `toArray`). ```ts await new DatabaseChannel().send(user, new InvoicePaid(4200)); ``` **Notes:** stores `"{}"` for `data` when the notification has no `toArray`. The target table must exist — create it in a migration. ### `ArrayChannel` An in-memory channel for tests — records deliveries instead of sending them. #### `sent` `readonly sent: { notifiable: Notifiable; notification: Notification }[]` The log of everything this channel received, in delivery order. ```ts const array = new ArrayChannel(); // … after notify … array.sent[0].notification; // the Notification instance ``` **Notes:** it keeps the notification *instance*, so you can `instanceof`-check it or read its fields — no serialization through `toArray`. #### `send(notifiable, notification)` `send(notifiable: Notifiable, notification: Notification): Promise` Pushes `{ notifiable, notification }` onto `sent`. Never touches the network. ```ts new Notifier().channel("array", new ArrayChannel()); ``` ### Interfaces & types #### `Notifiable` ```ts interface Notifiable { routeNotificationFor?(channel: string): string | number | undefined; [key: string]: unknown; } ``` A recipient — anything with routing info, most often a `User` model. Implement `routeNotificationFor` to steer specific channels; otherwise `routeFor` reads `email`/`id` off the index signature. ```ts class User extends Model { routeNotificationFor(channel: string) { return channel === "mail" ? this.billing_email : undefined; } } ``` #### `MailContent` ```ts interface MailContent { subject: string; text?: string; html?: string; from?: string; to?: string; } ``` What `toMail()` returns and the `mail` channel consumes. `subject` is required; supply `text`, `html`, or both. `to` overrides the resolved recipient; `from` overrides the mailer default. ```ts toMail(): MailContent { return { subject: "Welcome", html: "

Hi

", to: "override@app.com" }; } ``` #### `Channel` ```ts interface Channel { send(notifiable: Notifiable, notification: Notification): Promise; } ``` The seam a custom transport implements — SMS, Slack, push, anything. One method: `send`. Register your implementation with `Notifier.channel(name, channel)`. ```ts const slack: Channel = { async send(notifiable, notification) { const payload = notification.toArray?.(notifiable) ?? {}; // POST payload to a Slack webhook via fetch… }, }; ``` --- # OpenAPI Keel OpenAPI generates an [OpenAPI 3](https://spec.openapis.org/oas/v3.0.3) spec from your routes and serves [Swagger UI](https://swagger.io/tools/swagger-ui/) to explore it. It's a Keel [package](./packages.md): one `register()` mounts the docs at `/docs` and the spec at `/docs/openapi.json`. Nothing is scraped or guessed. The generator reads Keel's own route table — methods, paths, names, and param constraints are always correct — and enriches each operation with whatever the route attaches via `.config(apiDoc(...))`. ## Install ```ts // bootstrap/providers.ts import { OpenApiServiceProvider } from "@shaferllc/keel/openapi"; export const providers = [AppServiceProvider, OpenApiServiceProvider]; ``` Open `http://localhost:3000/docs`. That's enough for a spec of every route (paths, methods, path params). To add summaries, request/response schemas, and tags, document the routes. ## Documenting a route `apiDoc()` returns route config the generator understands. Its `request` field is the same `{ body, query, params }` shape you hand `validateRequest`, so one set of Zod schemas both validates and documents: ```ts import { apiDoc } from "@shaferllc/keel/openapi"; import { validateRequest } from "@shaferllc/keel/core"; import { z } from "zod"; const NewUser = z.object({ email: z.string().email(), age: z.number().min(18) }); router .post("/users", [Users, "store"]) .config(apiDoc({ summary: "Create a user", tags: ["users"], request: { body: NewUser }, responses: { 201: { description: "The created user", schema: UserShape } }, })) .middleware([validateRequest({ body: NewUser })]); ``` What the generator does with it: - **Path params** — `/users/:id` becomes `/users/{id}`; a `.where("id", /\d+/)` constraint becomes a `pattern`. - **Query params** — a `request.query` schema's fields expand into query parameters (each `required` per the schema). - **Request body** — a `request.body` schema becomes a JSON request body (Zod → JSON Schema via Zod 4's `z.toJSONSchema`). - **Responses** — your documented responses, plus an automatic `422` when the route validates input. Undocumented routes get a default `200`. - **Tags** — `tags`, or the first path segment. - **operationId** — the route's `.name()`, else `method_path`. Fields on `apiDoc`: `summary`, `description`, `tags`, `operationId`, `deprecated`, `request`, `responses`, and `hidden` (leave the route out entirely). Response and request schemas accept a Zod schema **or** a plain JSON Schema object. ## Configuration `config/openapi.ts` (publish with `keel vendor:publish --tag openapi-config`): ```ts export default { enabled: true, path: "docs", // /docs and /docs/openapi.json title: "", // defaults to config("app.name") version: "1.0.0", servers: [], // e.g. ["https://api.example.com"] public: false, // serve in production too cdn: "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.17.14", ignorePaths: ["/watch"], // route prefixes to leave out }; ``` ## Access Like [Watch](./watch.md), the docs are gated shut in production by default (open only when `app.debug` is on or the app isn't in production). Set `public: true` to serve them everywhere, or plug in your own check: ```ts import { OpenApi } from "@shaferllc/keel/openapi"; OpenApi.auth((c) => auth().check()); ``` The gate guards the spec endpoint too. ## Exporting the spec Write the spec to a file — for CI, client generation, or committing it: ```bash keel openapi:export --out openapi.json ``` ## On the UI dependency The spec (`/docs/openapi.json`) is generated with **zero dependencies** and runs anywhere Keel does, including the edge. The Swagger **UI** loads its assets from the configured `cdn` — the one external dependency, confined to the browser. Pin the version (the default is pinned) or point `cdn` at a copy you host if you need a fully self-contained deployment. --- # ORM Keel's ORM is a compact **active record** over the [query builder](./query-builder.md): a model is a class pointed at a table, and its rows come back as typed objects with methods. There's no mapper to configure and no separate schema layer — a model *is* the row plus behaviour. It runs on whatever [connection](./database.md) you registered, so the same code works on Node and the edge. ```ts import { Model } from "@shaferllc/keel/core"; class User extends Model { static table = "users"; declare id: number; declare email: string; posts() { return this.hasMany(Post); } } const user = await User.find(1); await user.posts(); // relations are awaitable if (await user.subscribed()) { /* … */ } ``` This page is the map; each capability has a deep-dive in **[Models](./models.md)**. ## What the ORM gives you | Area | What you get | Guide | |------|--------------|-------| | **CRUD** | `find` / `all` / `create` / `save` / `update` / `delete`, `firstOrCreate`, `updateOrCreate` | [Models → Reading/Writing](./models.md#reading) | | **Casts** | `boolean` / `int` / `json` / `date` … columns round-trip as real JS types | [Models → Attribute casts](./models.md#attribute-casts) | | **Mass assignment** | `fillable` / `guarded` allow/deny lists guard untrusted input | [Models → Mass assignment](./models.md#mass-assignment) | | **Serialization** | `hidden` / `visible` / `appends` shape `toJSON()` | [Models → Serializing](./models.md#serializing) | | **Relationships** | `hasOne` / `hasMany` / `belongsTo` / `belongsToMany` + polymorphic `morphOne` / `morphMany` / `morphTo` | [Models → Relationships](./models.md#relationships) | | **Eager loading** | `with("posts.comments")` (nested), `withCount`, `Model.load` — no N+1 | [Models → Eager loading](./models.md#eager-loading-avoiding-n1) | | **Relationship queries** | `whereHas` / `has` / `doesntHave` | [Models → Querying relationships](./models.md#querying-relationships-with-withcount-wherehas) | | **Lifecycle events** | `creating`/`saved`/`deleting`/… hooks and observers, inherited by subclasses | [Models → Lifecycle events](./models.md#lifecycle-events) | | **Scopes** | global scopes (tenancy, published-only) + local scope methods | [Models → Query scopes](./models.md#query-scopes) | | **Soft deletes** | `deleted_at`, `withTrashed` / `onlyTrashed` / `restore` / `forceDelete` | [Models → Soft deletes](./models.md#soft-deletes) | ## How it relates to the rest - The **[query builder](./query-builder.md)** is the layer underneath. `Model.query()` returns a model-aware builder, and everything an ORM query can't express (raw joins, aggregates, bulk writes) is one `db()` call away. - **[Migrations](./migrations.md)** define the tables models read and write. - **[Factories & seeders](./factories.md)** generate model rows for tests and demos. - **[API resources](./api-resources.md)** turn models into a REST API; **[transformers](./transformers.md)** control their serialized shape at the boundary. ## When to drop down The ORM is deliberately small — enough for CRUD, relationships, and the common query shapes without an ORM dependency. For a gnarly one-off report, reach for the [query builder](./query-builder.md) or a raw `connection().select(sql)`; the model layer never gets in the way. ## A worked example A blog with authors and posts — enough to see CRUD, a relation, and eager loading together: ```ts import { Model } from "@shaferllc/keel/core"; class Post extends Model { static table = "posts"; static fillable = ["title", "body"]; declare id: number; declare title: string; declare user_id: number; author() { return this.belongsTo(User); } } class User extends Model { static table = "users"; declare id: number; declare email: string; posts() { return this.hasMany(Post); } } const ada = await User.create({ email: "ada@example.com" }); await ada.posts().create({ title: "Notes on engines", body: "…" }); const withPosts = await User.with("posts").where("id", ada.id).first(); for (const post of (withPosts as User & { posts: Post[] }).posts) { console.log(post.title); } ``` For the full surface — casts, soft deletes, scopes, polymorphic relations — see [Models](./models.md). --- # Packages A **package** is a redistributable slice of a Keel app — routes, a UI, config, migrations, console commands — that installs with a single `app.register(...)`. Keel's `ServiceProvider` is already the unit of composition; `PackageProvider` adds the conventions a *shippable* package needs so it can carry its own schema and assets instead of asking the app to wire them by hand. [Keel Watch](./watch.md) — the debug dashboard — is a first-party package and the reference implementation of everything below. [Billing](./billing.md) (Stripe and Paddle subscriptions) is another, and shows a package contributing models, a schema migration, gateway drivers, and verified webhook routes. ## The shape of a package ```ts import { PackageProvider, type Router } from "@shaferllc/keel/core"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; const here = dirname(fileURLToPath(import.meta.url)); export class BillingServiceProvider extends PackageProvider { readonly name = "billing"; // used for publish grouping and diagnostics register(): void { this.mergeConfig("billing", { enabled: true, path: "billing" }); this.migrations([createInvoicesTable]); this.publishes({ [join(here, "config.stub")]: "config/billing.ts" }, "billing-config"); this.commands([syncInvoicesCommand]); } boot(): void { this.assets("billing/assets", join(here, "ui/dist"), { maxAge: 3600 }); this.routes((r: Router) => registerBillingRoutes(r), { prefix: "billing", as: "billing" }); } } ``` Scaffold that skeleton with `keel make:package billing`. ## The helpers Each is a thin wrapper over an existing Keel primitive — the value is the convention, not new machinery. | Helper | What it does | |--------|--------------| | `mergeConfig(key, defaults)` | Set config defaults under `key`. The app's `config/.ts` deep-merges **over** them, so the app always wins. | | `routes(register, { prefix, middleware, as })` | Register a route group (the callback gets the `Router`), already prefixed/guarded/name-prefixed. | | `assets(urlPrefix, dir, { maxAge, immutable })` | Serve a directory of built files (a bundled UI) under a URL prefix. Node-only. | | `migrations(list)` | Contribute migrations, run by `keel migrate` alongside the app's own. | | `commands(list)` | Add `keel` console commands (e.g. `billing:sync`). | | `publishes(map, tag?)` | Declare files a consuming app can copy in with `keel vendor:publish`. | ## Lifecycle: mind the kernel `register()` and `boot()` run **before** the app's HTTP kernel is bound (see `bootstrap/app.ts`). So a package must not reach for `HttpKernel`. That's why `routes()` and `assets()` go through the `Router` (bound in the Application constructor) — routes are compiled onto the kernel later, at build time. Use `register()` for config/bindings and `boot()` for wiring; both are safe for the helpers above. ## Migrations Package migrations join the app's under one command: ```bash keel migrate # run pending (app + package) migrations keel migrate:status # show which have run keel migrate:rollback # roll back the last batch ``` App migrations are discovered from `database/migrations/*.ts` (each file default-exports a `Migration` or `Migration[]`); package migrations come from `this.migrations(...)`. Both run against the default connection. ## Publishing files `publishes()` declares source→destination copies; `keel vendor:publish` performs them (skipping files that already exist unless `--force`): ```bash keel vendor:publish # everything keel vendor:publish --tag billing-config # just one tagged group ``` This is how a package ships an overridable config stub, or copies a starter view into the consuming app. ## Observing the framework A package often wants to *see* what the app is doing — every query, request, or job — without patching anything. The framework emits a typed **instrumentation event stream** for exactly this; subscribe with `listen()`: ```ts import { listen, type QueryEvent } from "@shaferllc/keel/core"; listen("db.query", (e) => metrics.timing("db", e.durationMs)); ``` | Event | Fired when | |-------|-----------| | `db.query` | a query runs (sql, bindings, durationMs, connection, kind) | | `request.handled` | a request finishes (method, path, status, durationMs, headers) | | `exception` | an error reaches the HTTP kernel | | `job.processing` / `job.processed` / `job.failed` | a queued job's lifecycle | | `cache.hit` / `cache.miss` | a cache lookup | | `notification.sent` | a notification is delivered | | `schedule.task.run` | a scheduled task runs | | `mail.sending` / `mail.sent` | mail lifecycle | Every request opens a scope with a **request id** that flows through async work, so anything emitted inside a request can attribute itself to it via `currentRequestId()` — that's what lets [Watch](./watch.md) tie a request to the queries and logs it produced. Emitting is fire-and-forget: a broken listener can never break the work it observes. ``` --- # Pages Page-based routing — **a file is a route**. ``` resources/pages/index.tsx → / resources/pages/about.tsx → /about resources/pages/users/index.tsx → /users resources/pages/users/[id].tsx → /users/:id resources/pages/docs/[...slug].tsx → /docs/* (catch-all) ``` ```tsx // resources/pages/users/[id].tsx import { db, type Ctx, type PageProps } from "@shaferllc/keel/core"; export const loader = (c: Ctx) => db("users").where("id", c.req.param("id")).first(); export default function UserPage({ params, data }: PageProps<{ id: string }, User>) { return (

{data.name}

User #{params.id}

); } ``` That's the whole page. No route file to keep in sync, no controller, no wiring. ## It doesn't replace the router — it drives it Every page becomes an **ordinary named route**. `url()` finds it, route middleware applies to it, and `keel routes` lists it. You can mix pages and hand-written routes freely, and reach for a controller the moment a page outgrows a file. That matters, because file-based routing is a lovely default and a bad prison. Here it's a *convenience over* the router, not a replacement for it. ## Registering them In a service provider's `boot()`: ```ts import { pages } from "@shaferllc/keel/core"; export class PageServiceProvider extends ServiceProvider { async boot(): Promise { await pages(); // scans resources/pages } } ``` `pages()` reads the filesystem, so it's **Node-only**. On the edge, hand `definePages()` a build-time manifest instead — Vite's `import.meta.glob` produces exactly the map it wants: ```ts definePages(import.meta.glob("./pages/**/*.tsx", { eager: true })); ``` Same behavior, no filesystem. ## The file conventions | File | URL | |------|-----| | `index.tsx` | `/` | | `about.tsx` | `/about` | | `users/index.tsx` | `/users` | | `users/[id].tsx` | `/users/:id` | | `users/[id]/edit.tsx` | `/users/:id/edit` | | `teams/[team]/users/[id].tsx` | `/teams/:team/users/:id` | | `docs/[...slug].tsx` | `/docs/*` — a catch-all; `params.slug` is the whole rest | | `_layout.tsx` | **not a route** — a leading `_` keeps a file private | A trailing `index` names its directory rather than a child of it. A leading underscore is how layouts, partials, and helpers live *beside* your pages without becoming URLs. ## Specificity is decided for you This is the part file-based routing usually gets wrong: ``` users/[id].tsx → /users/:id users/new.tsx → /users/new ``` Register `:id` first and `/users/new` is **unreachable forever** — `:id` happily matches `"new"`. Whether your app works would come down to the order the filesystem happened to hand back. So pages are **sorted before they're registered**: literal segments beat parameters, parameters beat catch-alls, and a catch-all is always the last resort. `/users/new` wins, and the file layout stops being a trap. ## Loading data `loader` runs before the page renders; whatever it returns arrives as `data`. ```tsx export const loader = async (c: Ctx) => { const post = await db("posts").where("slug", c.req.param("slug")).first(); if (!post) throw new NotFoundException(); return post; }; export default function PostPage({ data }: PageProps<{ slug: string }, Post>) { return
{data.body}
; } ``` It takes the request context, so it can read params, query, headers, or the session. A page with no `loader` simply gets `data: undefined`. ## Middleware Per page: ```tsx export const middleware = [authGuard()]; ``` Or for every page at once: ```ts await pages({ middleware: [authGuard()] }); ``` Both run before the `loader`, so a page that's refused never loads its data. ## Names and URLs Each page gets a route name derived from its path — `users/[id].tsx` becomes `users.id` — so URL generation works without you naming anything: ```ts router.url("users.id", { id: 5 }); // "/users/5" ``` Override it when the derived name is ugly: ```tsx export const name = "users.show"; ``` ## Escape hatches Move a page's URL without moving the file: ```tsx export const path = "/pricing"; // even though the file is at marketing/plans.tsx ``` Mount every page under a prefix: ```ts await pages({ prefix: "/app" }); // index.tsx is now /app await pages({ dir: "app/pages" }); // ...or keep them somewhere else ``` --- ## API reference ### `pages(options?)` `pages(options?: PagesOptions): Promise` Scan a directory and register every page in it. **Node only** — it reads the filesystem (`node:fs` is imported dynamically, so the core still loads on the edge). ### `definePages(modules, options?)` `definePages(modules: Record, options?: PagesOptions): RegisteredPage[]` Register pages from a `file path → module` map. The edge-safe half — pair it with `import.meta.glob`. ### `PagesOptions` | Option | Meaning | |--------|---------| | `dir` | Where the pages live. Default `"resources/pages"` | | `prefix` | Prefix every page's URL | | `middleware` | Middleware applied to every page | | `router` | The router to register on. Defaults to the application's | ### `PageModule` What a page file exports. | Export | Meaning | |--------|---------| | `default` | **Required.** The component. May be async | | `loader` | `(ctx) => data` — runs before the page renders | | `middleware` | Middleware for this page alone | | `name` | The route name. Defaults to one derived from the path | | `path` | Override the URL entirely | ### `PageProps` What the component receives: `params` (typed by `P`), `data` (whatever `loader` returned, typed by `D`), and `ctx`. ### `RegisteredPage` `{ file, pattern, name }` — what `pages()` / `definePages()` return, so you can see exactly what got mounted. ### `routePattern(file)` / `routeName(file)` The two pure functions behind the conventions, exported so you can test or reuse them. `routePattern("users/[id].tsx")` → `"/users/:id"`; `routeName("users/[id].tsx")` → `"users.id"`. --- # Query Builder Keel's **driver-agnostic query builder** — build and run SQL by chaining methods off `db(table)`. Nothing hits the database until a terminal method runs, every value is a parameterized binding (injection-safe), and the same chain compiles for sqlite, MySQL, and Postgres. Models add an active-record layer on top — see the [ORM](./orm.md). Start a query with `db(table)`, chain constraints (they return the builder, so order doesn't matter), and finish with a terminal method. Nothing hits the database until a terminal runs. Every value becomes a **binding**, never string-interpolated SQL — the builder is injection-safe by construction. It's driver-agnostic and edge-safe: the same chain compiles for sqlite, MySQL, and Postgres. ```ts import { db } from "@shaferllc/keel/core"; const active = await db("users") .where("active", true) .where("age", ">", 18) .orderBy("name") .limit(20) .get(); ``` ## Retrieving results ```ts await db("users").get(); // Row[] await db("users").where("id", 1).first(); // Row | null await db("users").where("id", 1).firstOrFail(); // Row, or throws NotFoundException await db("users").find(1); // by primary key (default "id") await db("users").where("email", e).sole(); // exactly one, else throws await db("users").where("id", 1).value("email"); // one column of the first row await db("posts").pluck("title"); // string[] of one column await db("tags").orderBy("name").implode("name", ", "); // "a, b, c" ``` For large sets, `chunk` pages through without loading everything (return `false` to stop early): ```ts await db("users").orderBy("id").chunk(500, async (rows) => { for (const row of rows) await process(row); }); ``` ## Aggregates ```ts await db("orders").count(); await db("orders").where("paid", true).sum("total"); await db("orders").avg("total"); // also min(col), max(col) await db("users").where("email", e).exists(); // boolean await db("users").where("banned", true).doesntExist(); ``` ## Selects ```ts db("users").select("id", "email"); db("users").select("id").addSelect("email"); // append, don't replace db("orders").selectRaw("SUM(total) AS revenue"); db("users").distinct().select("country"); ``` ## Where clauses ```ts db("users").where("votes", 100); // = is the default operator db("users").where("votes", ">=", 100); db("users").where("name", "like", "T%"); db("users").where("votes", 100).orWhere("name", "John"); db("users").whereNot("status", "cancelled"); db("users").whereIn("id", [1, 2, 3]).whereNotIn("id", [4]); db("users").whereNull("deleted_at").whereNotNull("email_verified_at"); db("products").whereBetween("price", [10, 100]).whereNotBetween("stock", [0, 5]); db("posts").whereLike("title", "%keel%"); db("events").whereColumn("updated_at", ">", "created_at"); // column vs column db("users").whereRaw("score >= ? AND score <= ?", [10, 90]); ``` Every clause has an `orWhere…` twin — `orWhereIn`, `orWhereNull`, `orWhereNotNull`, `orWhereBetween`, `orWhereColumn`, `orWhereLike`, `orWhereRaw`, `orWhereNotIn`. **Grouped clauses.** Pass a callback to `where`/`orWhere` to parenthesize a set of conditions — the way to express `A AND (B OR C)`: ```ts await db("users") .where("active", true) .where((q) => q.where("role", "admin").orWhere("role", "owner")) .get(); // … WHERE active = ? AND (role = ? OR role = ?) ``` ## Ordering, grouping, limit & offset ```ts db("users").orderBy("name").orderByDesc("created_at"); db("posts").latest(); // ORDER BY created_at DESC (oldest() for ASC) db("posts").orderByRaw("LENGTH(title) DESC"); db("users").inRandomOrder(); // dialect-aware RANDOM()/RAND() db("users").reorder("name"); // clear existing ordering, then set db("orders") .select("user_id") .selectRaw("SUM(total) AS spent") .groupBy("user_id") .having("spent", ">", 1000) // also havingRaw(...), havingBetween(...) .get(); db("users").limit(10).offset(20); // take(10)/skip(20) are aliases db("users").forPage(3, 15); // page 3, 15 per page ``` ## Joins ```ts await db("posts") .join("users", "posts.user_id", "users.id") // INNER JOIN on equality .leftJoin("images", "images.post_id", "posts.id") .select("posts.title", "users.name") .get(); ``` `rightJoin` and `crossJoin` round out the set. Joins with several `ON` conditions aren't modelled — use `whereRaw` or a view. ## Conditional clauses `when` / `unless` apply a callback based on a runtime value, so you build a query without breaking the chain into `if`s. The callback receives the value: ```ts await db("users") .when(search, (q, term) => q.whereLike("name", `%${term}%`)) .unless(includeArchived, (q) => q.whereNull("archived_at")) .get(); ``` ## Inserts ```ts await db("users").insert({ email, name }); const id = await db("users").insertGetId({ email, name }); // new primary key await db("logs").insertOrIgnore({ key, value }); // skip unique conflicts await db("users").upsert([{ id: 1, name: "Ada" }], ["id"], ["name"]); // insert/update ``` `upsert(rows, uniqueBy, update?)` inserts, updating the `update` columns (default: everything not in `uniqueBy`) on a conflict — dialect-aware (`ON CONFLICT` / `ON DUPLICATE KEY UPDATE`). ## Updates ```ts await db("users").where("id", id).update({ name: "Grace" }); await db("users").updateOrInsert({ email }, { name }); // update match, else insert await db("posts").where("id", id).increment("views"); // += 1 await db("posts").where("id", id).decrement("stock", 3, { updated_at: now }); await db("counters").incrementEach({ hits: 1, misses: 2 }); // several columns at once ``` ## Deletes ```ts await db("sessions").where("expires_at", "<", now).delete(); await db("cache").truncate(); // empty the table (DELETE on sqlite) ``` > **Guard your writes.** `update()`, `delete()`, and the increments apply to > every row matching the current `where` clause — with none, that's the whole > table. Scope every write unless you truly mean to touch every row. ## Pagination ```ts const page = await db("posts").latest().paginate(2, 15); // { data, total, perPage, currentPage, lastPage } — a COUNT plus a page query const feed = await db("posts").latest().simplePaginate(2, 15); // { data, perPage, currentPage, hasMore } — no COUNT; one extra row tells hasMore ``` ## Pessimistic locking Inside a [transaction](./database.md#transactions), lock the selected rows against concurrent writes. No-ops on sqlite (which locks the whole database anyway): ```ts await transaction(async () => { const row = await db("accounts").where("id", id).lockForUpdate().first(); // FOR UPDATE await db("accounts").where("id", id).update({ balance: row.balance - 10 }); }); // sharedLock() takes a read lock (FOR SHARE) instead. ``` ## Debugging ```ts db("users").where("active", true).toSql(); // "SELECT * FROM users WHERE active = ?" db("users").where("active", true).getBindings(); // [true] db("users").where("active", true).dump(); // logs SQL + bindings, returns the builder db("users").where("active", true).dd(); // logs and throws (dump-and-die) ``` ## Not (yet) modelled Kept out on purpose, to stay driver-agnostic and honest about what compiles everywhere: unions, subquery `where`/join builders (`whereExists`, `joinSub`), the `whereDate`/`whereMonth`/… date-function family (no portable form across dialects), and `cursor`/`lazy` streaming. Reach for `whereRaw`, a raw `connection().select(sql)`, or a database view when you need them. ## `QueryBuilder` — method reference Returned by `db()`. Constraint methods return `this` (chainable); terminal methods return a promise. You never construct it directly. #### `select(...columns)` `select(...columns: string[]): this` Restricts the selected columns. With no arguments, selects `*`. ```ts db("users").select("id", "email").get(); ``` **Notes:** column names are interpolated as-is (they are not parameterized), so never pass user input as a column name. Calling it again replaces the prior selection. #### `where(column, value)` / `where(column, operator, value)` `where(column: string, value: unknown): this` `where(column: string, operator: Operator, value: unknown): this` Adds an `AND` condition. The two-argument form uses `=`; the three-argument form takes an explicit operator. ```ts db("users").where("active", true); db("users").where("age", ">", 18); db("users").where("email", "like", "%@example.com"); ``` **Notes:** `Operator` is `"=" | "!=" | "<" | "<=" | ">" | ">=" | "like"`. Values are always parameterized. Chaining multiple `where`s combines them with `AND`. #### `orWhere(column, value)` / `orWhere(column, operator, value)` `orWhere(column: string, value: unknown): this` `orWhere(column: string, operator: Operator, value: unknown): this` Same as `where`, but joins the condition with `OR`. ```ts db("orders").where("status", "paid").orWhere("status", "shipped").get(); ``` **Notes:** conditions are combined left-to-right without grouping parentheses, so mixing `where` and `orWhere` follows SQL's `AND`/`OR` precedence — group complex logic in separate queries if you need explicit parenthesization. #### `whereIn(column, values)` `whereIn(column: string, values: unknown[]): this` Matches rows where `column` is any of `values` (`AND`-joined). ```ts db("posts").whereIn("id", [1, 2, 3]).get(); ``` **Notes:** each value becomes its own placeholder. An empty array produces `IN ()`, which most engines reject — guard against empty lists yourself. #### `whereNull(column)` / `whereNotNull(column)` `whereNull(column: string): this` `whereNotNull(column: string): this` Adds an `AND` `IS NULL` / `IS NOT NULL` condition — no binding. ```ts db("posts").whereNull("deleted_at").get(); db("users").whereNotNull("verified_at").get(); ``` #### `orderBy(column, direction?)` `orderBy(column: string, direction?: "asc" | "desc"): this` Adds an `ORDER BY` clause (default `"asc"`). Call it repeatedly for multiple sort keys, applied in call order. ```ts db("users").orderBy("last_name").orderBy("created_at", "desc").get(); ``` **Notes:** the column is interpolated, not parameterized — don't pass user input. #### `limit(n)` / `offset(n)` `limit(n: number): this` `offset(n: number): this` Caps the number of rows / skips the first `n`. Together they paginate. ```ts db("posts").limit(20).offset(40).get(); // page 3, 20 per page ``` **Notes:** `first()` sets `limit(1)` internally, overriding any prior `limit`. #### `get()` `get(): Promise` Runs the SELECT and returns all matching rows. ```ts const rows = await db("users").where("active", true).get(); ``` #### `first()` `first(): Promise` Runs the SELECT with `LIMIT 1` and returns the first row, or `null`. ```ts const user = await db("users").where("email", email).first(); ``` **Notes:** overrides any `limit` you set. Returns `null` (not `undefined`) when nothing matches. #### `count()` `count(): Promise` Returns `COUNT(*)` for the current `where` clause. ```ts const active = await db("users").where("active", true).count(); ``` **Notes:** ignores `select`, `orderBy`, `limit`, and `offset` — it counts matching rows, not the paginated slice. #### `exists()` `exists(): Promise` `true` when at least one row matches — a `count() > 0` shorthand. ```ts if (await db("users").where("email", email).exists()) { /* taken */ } ``` #### `insert(data)` `insert(data: Row): Promise` Inserts one row and returns write metadata. ```ts const result = await db("users").insert({ email, name }); result.rowsAffected; // 1 result.insertId; // driver-dependent ``` **Notes:** column order follows `Object.keys(data)`. `insertId` is only populated if the driver reports it in `WriteResult`. #### `insertGetId(data)` `insertGetId(data: Row): Promise` Inserts one row and returns just its new id (`insert` unwrapped). ```ts const id = await db("users").insertGetId({ email, name }); ``` **Notes:** returns `undefined` when the driver doesn't report an `insertId`. #### `update(data)` `update(data: Row): Promise` Updates every row matching the `where` clause, setting the given columns. ```ts const r = await db("users").where("id", 1).update({ name: "Grace" }); r.rowsAffected; // rows changed ``` **Notes:** with no `where`, updates the entire table. Bindings are the new values followed by the where-clause values. #### `delete()` `delete(): Promise` Deletes every row matching the `where` clause. ```ts await db("sessions").where("expires_at", "<", now).delete(); ``` **Notes:** with no `where`, empties the table. There's no soft-delete here — pair with a `deleted_at` column and `whereNull` if you want one. #### `whereColumn(first, operator?, second)` · `whereRaw(sql, bindings?)` Compare two columns (no binding) or add a raw WHERE fragment with its own bindings. `whereColumn("updated_at", ">", "created_at")`; `whereRaw("score >= ?", [10])`. #### `join(table, first, operator?, second)` · `leftJoin(...)` Add an `INNER JOIN` / `LEFT JOIN` on an equality (or the given operator). Included in `get`, `count`, and aggregates. Qualify ambiguous columns (`"posts.user_id"`). #### `groupBy(...columns)` · `having(column, operator?, value)` · `distinct()` `GROUP BY`, a bound `HAVING` predicate, and `SELECT DISTINCT`. #### `orderByRaw(sql)` · `when(condition, then, otherwise?)` A raw `ORDER BY` fragment; and conditional building — `then(query, value)` runs only when `condition` is truthy, else `otherwise`. #### `increment(column, amount?, extra?)` · `decrement(column, amount?, extra?)` `increment(column: string, amount = 1, extra: Row = {}): Promise` Atomically `column = column ± amount` on matching rows, optionally setting other columns in the same statement. Scope with `where`. #### `upsert(rows, uniqueBy, update?)` `upsert(rows: Row | Row[], uniqueBy: string[], update?: string[]): Promise` Insert rows, updating `update` columns (default: all non-unique) on a conflict against `uniqueBy`. Dialect-aware: `ON CONFLICT … DO UPDATE` (sqlite/postgres) or `ON DUPLICATE KEY UPDATE` (mysql). #### `insertOrIgnore(rows)` Insert one or more rows, skipping any that violate a unique constraint (`INSERT OR IGNORE` / `INSERT IGNORE` / `ON CONFLICT DO NOTHING`). #### `chunk(size, callback)` `chunk(size: number, callback: (rows: T[]) => void | boolean | Promise): Promise` Process results a page at a time so a large table never loads at once. Return `false` from the callback to stop early. Pair with `orderBy` for a stable order. #### `addSelect(...columns)` · `selectRaw(sql)` Append columns to the SELECT list without replacing it; `selectRaw` appends a raw expression (`selectRaw("SUM(total) AS revenue")`). #### `orWhere` family · `whereNot(...)` · `whereNotBetween(column, [min, max])` Every `where…` clause has an `orWhere…` twin joined with `OR` — `orWhereIn`, `orWhereNotIn`, `orWhereNull`, `orWhereNotNull`, `orWhereBetween`, `orWhereColumn`, `orWhereLike`, `orWhereRaw`. `whereNot` negates a comparison; `whereNotBetween` is the inverse of `whereBetween`. Passing a **callback** to `where`/`orWhere` groups its conditions in parentheses. #### `orderByDesc(column)` · `reorder(column?, direction?)` · `inRandomOrder()` Descending order; clear existing ordering (optionally setting a new one); random order (dialect-aware `RANDOM()`/`RAND()`). #### `groupByRaw(sql)` · `havingRaw(sql, bindings?)` · `havingBetween(column, [min, max])` Raw `GROUP BY`, a raw/bound `HAVING`, and a `HAVING … BETWEEN`. #### `take(n)` · `skip(n)` · `forPage(page, perPage?)` Aliases for `limit`/`offset`, and limit+offset for a 1-based page. #### `rightJoin(...)` · `crossJoin(table)` `RIGHT JOIN` on an equality; `CROSS JOIN`. #### `unless(condition, then, otherwise?)` The inverse of `when` — runs `then` only when `condition` is falsy. #### `find(id, key?)` · `firstOrFail()` · `sole()` · `doesntExist()` · `implode(column, glue?)` Find by key (default `"id"`); first-or-throw; exactly-one-or-throw; the negation of `exists`; and join one column's values into a string. #### `simplePaginate(page?, perPage?)` `simplePaginate(page = 1, perPage = 15): Promise>` A page without a `COUNT` — fetches one extra row to set `hasMore`. Cheaper than `paginate` for "load more" UIs. #### `lockForUpdate()` · `sharedLock()` Add `FOR UPDATE` / `FOR SHARE` to the SELECT (inside a transaction). Ignored on sqlite. #### `updateOrInsert(match, values?)` · `truncate()` · `incrementEach(cols, extra?)` · `decrementEach(cols, extra?)` Update the first match or insert `{ ...match, ...values }`; empty the table (`DELETE` on sqlite); and step several numeric columns in one statement (`cols` is an array — each by 1 — or a `{ column: amount }` map). #### `toSql()` · `getBindings()` · `dump()` · `dd()` The compiled `?`-placeholder SQL and its bindings, without executing; `dump` logs them and returns the builder; `dd` logs and throws. --- # Queues & Jobs Move slow work — sending mail, calling an API, processing an upload — off the request path. You `dispatch` a **job** and a pluggable **driver** decides when it runs: immediately (the default), held in memory for a worker to drain, or handed to a real broker. The API mirrors the database and mail layers (`setQueue` / `dispatch` are to queues what `setConnection` / `db()` are to the database), and the core imports no broker, so it stays edge-safe. ## Defining a job A job is a class with a `handle()` method. Pass whatever data it needs through the constructor: ```ts import { Job, mail } from "@shaferllc/keel/core"; export class SendWelcome extends Job { constructor(private email: string) { super(); } async handle() { await mail().to(this.email).subject("Welcome").text("Glad you're here").send(); } } ``` Generate one with `keel make:job SendWelcome` (→ `app/Jobs/SendWelcomeJob.ts`). ## Dispatching ```ts import { dispatch } from "@shaferllc/keel/core"; await dispatch(new SendWelcome(user.email)); // options: delay (seconds) and a named lane — honored by drivers that support them await dispatch(new SendWelcome(user.email), { delay: 60, queue: "emails" }); // a plain function works too, for one-off work await dispatch(() => rebuildSearchIndex()); ``` Out of the box `dispatch` runs against a `SyncDriver`, so a fresh app executes jobs inline — no setup, no worker. Call `setQueue` once to defer instead. `dispatch` returns a promise that resolves when the driver has accepted the job (for `SyncDriver` that means *after* the job has run; for a deferring driver, as soon as it's enqueued). `MemoryDriver` honors `delay` and `priority`; `SyncDriver` runs inline and so ignores both. `queue` is a lane label that a real broker driver acts on. ## Drivers Register the default driver once (typically in a service provider): ```ts import { setQueue, SyncDriver, MemoryDriver } from "@shaferllc/keel/core"; setQueue(new SyncDriver()); // the default — runs jobs immediately setQueue(new MemoryDriver()); // holds jobs; a worker drains them ``` | Driver | Behavior | |--------|----------| | `SyncDriver` | Runs each job the instant it's dispatched. The default; great for dev and tests. | | `MemoryDriver` | Enqueues jobs in memory; `work()` runs them. Inspect `.jobs` / `.size`. | | `DatabaseDriver` | Jobs are rows — they survive a restart, workers claim them atomically, failures persist. | | `RedisDriver` | The same durability contract in Redis — sorted sets and a failed hash, claims via atomic `ZREM`. | With the sync driver, a job that throws surfaces the error to whoever called `dispatch` — so failures are visible in development. ## The database driver Memory empties on every restart. When a queued job must *survive* — a deploy, a crash, a Worker eviction — make it a row. The driver is built on the `db()` layer, so it runs anywhere a `Connection` does (Postgres, D1, libSQL, SQLite): ```ts import { setQueue, DatabaseDriver, registerJobs, queueMigration } from "@shaferllc/keel/core"; // database/migrations/0005_queue_tables.ts — the jobs + failed_jobs tables export default queueMigration(); // a provider's register(), in BOTH the web process and the worker registerJobs(SendWelcome, ChargeCard); setQueue(new DatabaseDriver()); ``` Run the worker with `keel queue:work` (poll forever) or `keel queue:work --once` (drain what's due and exit — the right shape for a cron trigger or a scheduled task). Several workers can share the table: a job is claimed with an atomic conditional update, so exactly one gets it, and a claim held past `staleAfter` seconds (default 300) is released — the escape hatch for a worker that died mid-job. Two constraints follow from jobs being rows: - **Only `Job` subclasses can be dispatched** — a closure can't be serialized, and the driver says so rather than storing something it can't run. A job's payload is its constructor state (its own enumerable properties), rebuilt on the worker via `registerJobs()` — which is why registration must happen in the worker process too. - **A per-dispatch `backoff` override can't cross the process boundary** (a function isn't data). Backoff comes from the job class; `maxRetries` overrides are stored and honored. Failed jobs land in the `failed_jobs` table, where the console can see them: ```bash keel queue:failed # list them keel queue:retry 42 # back on the queue (keel queue:retry all for every one) keel queue:flush # delete them (or one: keel queue:flush 42) ``` [Keel Watch](./watch.md) shows the same list under **Failed jobs**, with retry and delete buttons. Options: `new DatabaseDriver({ table, failedTable, connection, staleAfter })` — all optional; the defaults are `jobs`, `failed_jobs`, the default connection, and 300 seconds. ## The redis driver The same durability contract as the database driver — jobs survive a restart, several workers share the backlog, exhausted jobs are retryable — with Redis's latency instead of a SQL round-trip. No migration; just a client: ```ts import { setQueue, RedisDriver, registerJobs, setRedis } from "@shaferllc/keel/core"; setRedis(myAdapter); // ioredis, node-redis, Upstash… (see the redis guide) registerJobs(SendWelcome); // in BOTH the web process and the worker setQueue(new RedisDriver()); // or new RedisDriver({ client, prefix, staleAfter }) ``` How it's laid out: pending jobs live in a sorted set (`queue:jobs`) scored by when they become due — delays and backoffs are just future scores. A claim is `ZREM`: atomic per command, so exactly one worker removes any member, and no Lua script is required — which keeps HTTP adapters like Upstash in play. A claimed job sits in `queue:reserved` scored by its deadline; a worker that dies mid-job leaves a member behind, and the next drain re-queues anything past `staleAfter` (default 300 seconds). Failures land in the `queue:failed` hash, so `queue:failed` / `queue:retry` / `queue:flush` and the Watch panel work exactly as they do for the database driver. The driver needs eight commands beyond the basic `RedisConnection` set — `zadd`, `zrangebyscore`, `zrem`, `zcard`, `hset`, `hget`, `hgetall`, `hdel` — each a passthrough to one standard Redis command. The built-in `MemoryRedis` implements them (so tests run against the real driver); a custom adapter that lacks any of them is refused at first use with the missing ones named. The serialization rules are the database driver's: **`Job` subclasses only** (a closure can't cross a process boundary), classes rebuilt via `registerJobs()`, class-level backoff, stored `maxRetries` overrides. **Which one?** Same durability, different trade: the database driver needs no extra infrastructure and joins your existing backups and transactions; the redis driver keeps queue chatter off your database and polls cheaper under load. If you already run Redis for cache or rate limiting, the queue can share it — the keys are prefixed. ## Running queued jobs When a driver defers work, drain it with `work()`: ```ts import { dispatch, work } from "@shaferllc/keel/core"; setQueue(new MemoryDriver()); await dispatch(new SendWelcome("a@x.com")); await dispatch(new SendWelcome("b@x.com")); const ran = await work(); // runs both; returns 2 ``` `work()` is a no-op (returns `0`) for immediate drivers like `SyncDriver` — it only drains drivers that hold jobs locally (those implementing `Drainable`). Jobs run one at a time, highest priority first and otherwise in dispatch order. `work()` drains what's **due**: a job still waiting out a `delay` or a retry backoff is left on the queue for a later drain, so a second `work()` isn't necessarily a no-op. A job that throws is **retried** and, once it runs out of retries, **failed** — `work()` records it and keeps going rather than propagating the error. See [Retries and backoff](#retries-and-backoff) and [When a job finally fails](#when-a-job-finally-fails). ## A custom / edge driver A driver is one method — `push`. That's the seam for a real broker. On Cloudflare, forward to a Queue binding and reconstruct the job in the consumer: ```ts import type { QueueDriver } from "@shaferllc/keel/core"; const cloudflareQueue = (binding: Queue): QueueDriver => ({ async push(job, options) { await binding.send({ job: serialize(job), options }); }, }); setQueue(cloudflareQueue(env.MY_QUEUE)); // In the queue consumer, rebuild the job from the payload and call handle(). ``` (`Queue` and `env` above are Cloudflare's binding types — illustrative, not Keel exports.) The only method Keel requires is `push(job, options)`; return a promise that resolves once the job is safely handed off. Because a `Dispatchable` can be a class instance or a closure, a broker driver has to decide how to serialize it — typically dispatch only plain-data jobs across the wire and reconstruct them in the consumer. Drivers that hold jobs locally can implement the `Drainable` interface (`size` + `work()`) so `Queue.work()` can drive them — that's how `MemoryDriver` works. ## Retries and backoff Background work fails for boring reasons — a provider hiccups, a connection drops. A job **retries** before it gives up, with a growing delay between attempts. Declare the policy on the job class: ```ts class ChargeCard extends Job { static maxRetries = 5; static backoff = exponentialBackoff(1_000); // 1s, 2s, 4s, 8s, 16s async handle() { await stripe.charge(this.amount); } } ``` `maxRetries` defaults to **0** — a job that doesn't opt in fails on its first throw, which is the safe default for work that isn't idempotent. The strategies: | Backoff | Delays | |---------|--------| | `exponentialBackoff(baseMs?, maxMs?)` | 1s, 2s, 4s, 8s… (the default) | | `linearBackoff(stepMs?, maxMs?)` | 5s, 10s, 15s… | | `fixedBackoff(delayMs?)` | the same delay every time | | `noBackoff` | retry immediately | Both cap at `maxMs` (default 60s) so a long-lived job can't back off into next week. Per-dispatch overrides win over the class: ```ts await dispatch(new ChargeCard(id), { maxRetries: 1, backoff: noBackoff }); ``` **A retry's delay is honored, not slept through.** `work()` drains what's *due*; a job waiting out its backoff stays on the queue for a later drain. The `SyncDriver` is the exception — it runs inline, so it retries immediately and ignores the delay, because blocking a request for a 30-second backoff would be worse than useless. ## When a job finally fails Once the retries are exhausted the job is **failed**. Three things happen, in order: 1. It's **logged** at `error` level, with the job name, id, and attempt count. 2. Its `failed(error)` hook runs — the last chance to alert or compensate. 3. It lands in the driver's **dead-letter list** (`driver.failed`) rather than vanishing. ```ts class ChargeCard extends Job { static maxRetries = 3; async handle() { … } async failed(error: unknown) { await notifyBilling(this.orderId, error); } } ``` **A failed job does not take down the worker.** `work()` records it and carries on with the rest of the queue — one bad job can't stop the others. That's why the failure is logged loudly: a worker that keeps running past a failure must not do so silently. ```ts await work(); for (const failure of getQueue().failed) { console.error(failure.id, failure.attempts, failure.error); } ``` A throw inside `failed()` is logged and swallowed too — failing to handle a failure must not itself crash the worker. The `SyncDriver` is again the exception: it ran the job *inline*, so the caller is right there and gets the error thrown at them. ## Priority Lower numbers run first. Default is `0`, so a negative priority jumps the queue: ```ts await dispatch(new SendReceipt(id), { priority: -10 }); // ahead of normal work await dispatch(new RebuildSearchIndex(), { priority: 10 }); // whenever ``` A job class can declare its own default lane and priority: ```ts class ChargeCard extends Job { static queue = "billing"; static priority = -5; } ``` ## What a job knows about itself While `handle()` runs, `this.context` carries the job's id, which attempt this is, and the lane it's on — useful for logging, and for making a retry behave differently from a first run: ```ts class ImportFile extends Job { static maxRetries = 3; async handle() { const { jobId, attempt, queue } = this.context!; if (attempt > 1) logger().warn("retrying import", { jobId, attempt }); } } ``` ## In tests `fakeQueue()` records dispatches **without running them**, so a test can assert a job was queued without paying for it to run — no email sent, no card charged. `restoreQueue()` puts the real queue back. ```ts import { fakeQueue, restoreQueue } from "@shaferllc/keel/core"; const queue = fakeQueue(); await registerUser(); // internally dispatches SendWelcome queue.assertPushed(SendWelcome); queue.assertPushed(SendWelcome, (job) => job.userId === user.id); // with a predicate queue.assertPushedCount(1, SendWelcome); queue.assertNotPushed(ChargeCard); queue.assertNothingPushed(); restoreQueue(); ``` `queue.pushedJobs(SendWelcome)` returns the queued entries, so you can assert on a dispatch's `delay`, `queue`, or `priority`. When you want the job to actually *run*, use the `MemoryDriver` instead and drain it: ```ts const driver = new MemoryDriver(); setQueue(driver); await registerUser(); assert.equal(driver.size, 1); await work(); // now run it and assert on the side effects assert.equal(driver.failed.length, 0); ``` --- ## API reference ### Top-level functions The module keeps one process-wide default `Queue`. These four functions are the everyday surface — you rarely touch `Queue` or a driver directly. #### `dispatch(job, options?)` `dispatch(job: Dispatchable, options?: JobOptions): Promise` Places a job (a `Job` instance or a plain function) on the default queue. ```ts await dispatch(new SendWelcome("a@x.com")); await dispatch(() => rebuildSearchIndex(), { delay: 60, queue: "emails" }); ``` **Notes:** delegates to `getQueue().dispatch`. With the default `SyncDriver` the returned promise resolves *after* the job has run, and a throwing job rejects it. `options` defaults to `{}`; `delay`/`queue` are honored only by drivers that support them. #### `work()` `work(): Promise` Drains the default queue's pending jobs and resolves with how many ran. ```ts const ran = await work(); ``` **Notes:** returns `0` for drivers that don't hold jobs locally (e.g. `SyncDriver`). Runs jobs FIFO; a throwing job propagates and halts the drain. #### `setQueue(driver)` `setQueue(driver: QueueDriver): Queue` Replaces the default queue with a new `Queue` wrapping `driver`, and returns it. ```ts setQueue(new MemoryDriver()); const q = setQueue(new SyncDriver()); // returns the new Queue ``` **Notes:** global — the last call wins, and it rebinds what `dispatch`/`work`/ `getQueue` operate on. Before the first `setQueue`, the default is a `SyncDriver`. #### `getQueue()` `getQueue(): Queue` Returns the current default `Queue` instance. ```ts const driver = getQueue().driver; // inspect the active driver ``` **Notes:** the instance changes identity after each `setQueue` call. ### `Job` `abstract class Job` The base class for a unit of background work. Subclass it, pass data through the constructor, and implement `handle()`. ```ts class SendWelcome extends Job { constructor(private email: string) { super(); } async handle() { await mail().to(this.email).subject("Hi").text("Welcome").send(); } } ``` **Notes:** dispatching a non-`Job` function is also allowed (see `Dispatchable`) — `Job` exists for jobs that carry state or that you want to assert on by type in tests (`job instanceof SendWelcome`). #### `handle()` `abstract handle(): void | Promise` The work the job performs. Called once when the driver runs the job. ```ts async handle() { await rebuildSearchIndex(); } ``` **Notes:** may be sync or async — the runner wraps the result in `Promise.resolve`, so either is awaited. Throwing surfaces the error to whoever drains the queue. ### `Queue` `class Queue` Pairs a `QueueDriver` with the `dispatch`/`work` API. The module manages a default instance for you; construct one yourself only if you want a second, independent queue. ```ts const q = new Queue(new MemoryDriver()); await q.dispatch(new SendWelcome("a@x.com")); await q.work(); ``` #### `new Queue(driver)` `constructor(driver: QueueDriver)` Wraps a driver. The driver is exposed as the readonly `driver` property. ```ts const q = new Queue(new SyncDriver()); q.driver; // the SyncDriver you passed ``` #### `dispatch(job, options?)` `dispatch(job: Dispatchable, options?: JobOptions): Promise` Hands the job to the driver's `push`. The driver decides when it runs. ```ts await q.dispatch(new SendWelcome("a@x.com"), { queue: "emails" }); ``` **Notes:** `options` defaults to `{}`. Resolves when `push` resolves. #### `work()` `work(): Promise` Drains the driver if it holds jobs locally, returning how many ran. ```ts const ran = await q.work(); ``` **Notes:** feature-detects `Drainable` — if the driver has no `work` method, this returns `0` without touching it. That's why the same call is safe against any driver. #### `driver` `readonly driver: QueueDriver` The driver this queue wraps. Useful for inspection (e.g. casting to `MemoryDriver` to read `.jobs` in a test). ### Drivers Both built-in drivers implement `QueueDriver`. You register one with `setQueue`; you don't usually call their methods directly. #### `SyncDriver` `class SyncDriver implements QueueDriver` Runs each job the instant it's pushed. The default driver — ideal for dev and tests where you want failures to surface immediately. ```ts setQueue(new SyncDriver()); ``` ##### `push(job, options)` `push(job: Dispatchable, options: JobOptions): Promise` Runs the job right away and resolves when it finishes. ```ts await new SyncDriver().push(() => doWork(), {}); ``` **Notes:** ignores `options` entirely (no deferral). A throwing job rejects the promise. It is *not* `Drainable`, so `work()` against it returns `0`. #### `MemoryDriver` `class MemoryDriver implements QueueDriver, Drainable` Holds pushed jobs in an in-memory array until you `work()` them. The go-to driver for tests: dispatch, assert on `.jobs`/`.size`, then drain. ```ts const driver = new MemoryDriver(); setQueue(driver); await dispatch(new SendWelcome("a@x.com")); driver.size; // 1 driver.jobs[0].job; // the queued Dispatchable await work(); // runs it; size back to 0 ``` ##### `push(job, options)` `push(job: Dispatchable, options: JobOptions): Promise` Appends `{ job, options }` to `.jobs` without running anything. ```ts await driver.push(() => doWork(), { queue: "default" }); ``` ##### `work()` `work(): Promise` Runs every pending job in FIFO order and returns how many ran. ```ts const ran = await driver.work(); ``` **Notes:** removes each job before running it, so a throwing job halts the drain and is not retried. After a successful drain `.jobs` is empty and a repeat call returns `0`. ##### `size` `get size(): number` The number of jobs currently waiting — `this.jobs.length`. ##### `jobs` `readonly jobs: QueuedJob[]` The live backlog of `{ job, options }` entries, in insertion order. Read it in tests to assert what was queued and with which options. ### Interfaces & types #### `Dispatchable` `type Dispatchable = Job | (() => void | Promise)` What `dispatch`/`push` accept: a `Job` subclass instance or a zero-arg function. Use a function for quick one-offs, a `Job` when the work carries data or you want to identify it by type. ```ts const a: Dispatchable = new SendWelcome("a@x.com"); const b: Dispatchable = () => rebuildSearchIndex(); ``` #### `JobOptions` ```ts interface JobOptions { delay?: number; // seconds before the job becomes available queue?: string; // named lane to place the job on } ``` Per-dispatch hints. Both are optional and advisory — the built-in drivers ignore them; a broker driver interprets them. #### `QueueDriver` ```ts interface QueueDriver { push(job: Dispatchable, options: JobOptions): Promise; } ``` The seam to your backend — implement it to target any broker. `push` is the only required method: accept the job and resolve once it's safely handed off. ```ts const logDriver: QueueDriver = { async push(job, options) { console.log("queued", options.queue ?? "default"); // forward `job` to your broker here }, }; setQueue(logDriver); ``` #### `Drainable` ```ts interface Drainable { readonly size: number; work(): Promise; } ``` Implement this *in addition to* `QueueDriver` when your driver holds jobs locally and can run them on demand — that's what lets `Queue.work()` drive it. Feature detection is by the presence of `work`, so a driver that omits `Drainable` is simply never drained. ```ts class ArrayDriver implements QueueDriver, Drainable { private q: Dispatchable[] = []; get size() { return this.q.length; } async push(job: Dispatchable) { this.q.push(job); } async work() { let n = 0; for (const job of this.q.splice(0)) { await (job instanceof Job ? job.handle() : job()); n++; } return n; } } ``` #### `QueuedJob` ```ts interface QueuedJob { job: Dispatchable; options: JobOptions; } ``` An entry in `MemoryDriver.jobs`: the dispatched job paired with the options it was dispatched with. What you assert on in tests. ```ts const entry: QueuedJob = driver.jobs[0]; entry.options.queue; // string | undefined ``` ### Retries & backoff #### `Job.maxRetries` `static maxRetries: number` — retries after the first failure. Default `0`. #### `Job.backoff` `static backoff: Backoff` — how long to wait before each retry. Default `exponentialBackoff(1_000)`. #### `Job.failed(error)` `failed(error: unknown): void | Promise` — runs once the retries are exhausted. A throw in here is logged and swallowed. #### `Job.context` `context?: JobContext` — `{ jobId, attempt, queue }`, set by the driver before `handle()` runs. `attempt` is 1 on the first run. #### Backoff strategies | Function | Signature | |----------|-----------| | `exponentialBackoff` | `(baseMs = 1000, maxMs = 60000) => Backoff` — 1s, 2s, 4s… | | `linearBackoff` | `(stepMs = 1000, maxMs = 60000) => Backoff` — 1s, 2s, 3s… | | `fixedBackoff` | `(delayMs = 1000) => Backoff` | | `noBackoff` | `Backoff` — retry immediately | `type Backoff = (attempt: number) => number` — milliseconds before `attempt` (1 = the first retry). ### Testing #### `fakeQueue()` / `restoreQueue()` `fakeQueue(): FakeQueue` swaps the queue for one that records dispatches without running them. `restoreQueue()` puts the real one back. `FakeQueue`: | Method | Signature | |--------|-----------| | `assertPushed` | `(type, where?) => void` | | `assertNotPushed` | `(type, where?) => void` | | `assertPushedCount` | `(count, type?) => void` | | `assertNothingPushed` | `() => void` | | `pushedJobs` | `(type) => QueuedJob[]` — to assert on delay/lane/priority | ### Interfaces & types #### `JobOptions` `{ delay?, queue?, priority?, maxRetries?, backoff? }` — `delay` in seconds; `priority` lower runs first; `maxRetries`/`backoff` override the job class. #### `JobContext` `{ jobId: string; attempt: number; queue: string }`. #### `QueuedJob` `{ id, job, options, attempts, availableAt }` — a job sitting on a queue. `availableAt` is the epoch-ms before which it must not run (delay or backoff). #### `FailedJob` `{ id, job, options, attempts, error }` — a job that exhausted its retries. Read them from `driver.failed` or `getQueue().failed`. ### The database driver #### `DatabaseDriver` `class DatabaseDriver implements QueueDriver, FailedJobStore` Jobs as rows; see [The database driver](#the-database-driver) above. `new DatabaseDriver(options?: DatabaseDriverOptions)` with `{ table?, failedTable?, connection?, staleAfter? }`. | Method | Signature | |--------|-----------| | `push` | `(job, options) => Promise` — inserts a row; refuses closures | | `work` | `() => Promise` — releases stale claims, then runs every due job | | `pending` | `() => Promise` — waiting (unreserved) jobs | | `failedJobs` | `() => Promise` | | `retryFailed` | `(id) => Promise` — move a failed job back onto the queue | | `flushFailed` | `(id?) => Promise` — delete one failed job, or all of them | #### `registerJobs(...classes)` `registerJobs(...classes: JobClass[]): void` Registers job classes by name so a worker can rebuild them from their stored payload. Call at boot in every process that runs `queue:work`. #### `queueMigration(table?, failedTable?)` `queueMigration(table = "jobs", failedTable = "failed_jobs"): Migration` The schema for the driver's two tables — add it to your migrations. #### `RedisDriver` `class RedisDriver implements QueueDriver, FailedJobStore` Jobs in Redis; see [The redis driver](#the-redis-driver) above. `new RedisDriver(options?: RedisDriverOptions)` with `{ client?, prefix?, staleAfter? }` — defaults: the `redis()` global, `"queue"`, 300 seconds. Same method surface as `DatabaseDriver` (`push` / `work` / `pending` / `failedJobs` / `retryFailed` / `flushFailed`). #### `FailedJobRecord` / `FailedJobStore` `FailedJobRecord` is `{ id, queue, job, payload, attempts, error, failedAt }` — `job` is the class name, `failedAt` epoch ms. `FailedJobStore` is the interface (`failedJobs` / `retryFailed` / `flushFailed`) the console's `queue:failed`, `queue:retry`, and `queue:flush` commands drive; implement it on a custom driver to get those commands (and the Watch panel's buttons) for free. --- # Rate Limiting `rateLimiter()` is a middleware that caps how many requests a client can make in a window. It sets the standard `X-RateLimit-*` and `Retry-After` headers, and returns `429 Too Many Requests` when the limit is exceeded. It's a **fixed-window** limiter: each key gets a bucket that counts requests until a fixed reset time, then starts over. Simple, cheap, and edge-safe — the default store is a plain in-memory `Map`, so nothing is imported that can't run on a Worker. ## Global or per-route ```ts import { rateLimiter } from "@shaferllc/keel/core"; // every request: 60 per minute per IP export class Kernel extends HttpKernel { constructor(app: Application) { super(app); this.use(rateLimiter({ max: 60, window: 60 })); } } // a stricter limit on a sensitive route router.post("/login", [AuthController, "login"]).use(rateLimiter({ max: 5, window: 60 })); ``` Each call to `rateLimiter()` owns its own bucket store, so a global limiter and a per-route limiter count independently — a request to `/login` ticks both, but in separate buckets. Stack as many as you like. ## Options ```ts rateLimiter({ max: 60, // requests per window (default 60) window: 60, // window seconds (default 60) key: (c) => c.req.header("x-api-key") ?? "anon", // bucket key (default: IP) message: "Slow down!", // 429 body message }); ``` The `key` function decides what to limit by — per IP (default), per API key, per user id, etc. Different keys get independent buckets. Return the same string to share a bucket; return `"global"` (or any constant) to limit everyone together. ### The default key With no `key`, the bucket is derived from the client IP, tried in this order: 1. the first entry of `X-Forwarded-For` (trimmed), 2. `X-Real-IP`, 3. the literal `"global"` if neither header is present. That fallback means that behind a proxy that strips those headers, *every* client shares the `"global"` bucket — so set an explicit `key` if your platform doesn't surface the client IP. ## Response headers Every response carries: | Header | Meaning | |--------|---------| | `X-RateLimit-Limit` | the ceiling for the window | | `X-RateLimit-Remaining` | requests left in the window | | `Retry-After` | (on 429) seconds until the window resets | On an allowed request the two `X-RateLimit-*` headers are written *after* the handler runs. On a rejected request all three are set on the `429` itself. ## What happens at the limit The counter increments on every request. The request that pushes the count *past* `max` is the one that's rejected — so `max: 5` lets five requests through and blocks the sixth within the window. The `429` body is: ```json { "error": "Too Many Requests", "status": 429 } ``` `error` is your `message` if you passed one. `Retry-After` reports the whole seconds until the bucket resets. Once the window elapses the next request starts a fresh bucket at count 1. ## Storage The default store is **in-memory** — per process, and per isolate on the edge. That's fine for a single instance, but limits aren't shared across instances. The store self-prunes: once it holds more than 10,000 keys it sweeps expired buckets on the next request, so a large key space (e.g. per-IP) won't grow unbounded. On anything horizontal, share the tally by passing a `store`: ```ts import { rateLimiter, redisRateLimitStore, cacheRateLimitStore } from "@shaferllc/keel/core"; // Redis: counts with INCR — atomic, so a burst across nodes can't slip past. this.use(rateLimiter({ max: 60, window: 60, store: redisRateLimitStore() })); // Any Cache (database, KV): read-modify-write — simultaneous hits can // under-count by a request or two. Fine for traffic shaping, not for billing. this.use(rateLimiter({ max: 60, window: 60, store: cacheRateLimitStore() })); ``` Pass the *same* store instance to several `rateLimiter()` calls and they share one tally; the backends namespace their keys under `ratelimit:`, so a shared Redis or cache doesn't collide with your own entries. A custom backend is one method — implement `RateLimitStore.hit(key, windowMs)` and return the running `{ count, reset }` for the window. --- ## API reference ### `rateLimiter(options?)` `rateLimiter(options?: RateLimiterOptions): MiddlewareHandler` Builds a fixed-window rate-limiting middleware. Each returned handler keeps its own private bucket store, counts requests per key within the window, sets the `X-RateLimit-*` headers, and short-circuits with `429` past the limit. ```ts import { rateLimiter } from "@shaferllc/keel/core"; const limit = rateLimiter({ max: 100, window: 60 }); // register it: this.use(limit) globally, or .use(limit) on a route ``` **Notes:** all options are optional — `rateLimiter()` with no arguments is 60 requests per 60 seconds, keyed by client IP. The returned handler is a Hono `MiddlewareHandler`, so it works anywhere middleware is accepted (`use`, per route, per group). Buckets live in a closure over the single call, so reusing the *same* handler shares state while a second `rateLimiter(...)` call does not. The `429` is returned before `next()`, so downstream handlers never run for a throttled request. ### Interfaces & types #### `RateLimiterOptions` ```ts interface RateLimiterOptions { max?: number; // requests per window; default 60 window?: number; // window length in seconds; default 60 key?: (c: Context) => string; // bucket key; default: client IP message?: string; // 429 body message; default "Too Many Requests" store?: RateLimitStore; // where counters live; default: in-memory } ``` The shape you pass to `rateLimiter()`. Every field is optional; `key` receives the Hono `Context` and returns the string that identifies the bucket. ```ts import { rateLimiter, type RateLimiterOptions } from "@shaferllc/keel/core"; const perUser: RateLimiterOptions = { max: 30, window: 60, key: (c) => c.req.header("authorization") ?? "anon", message: "Easy there — try again shortly.", }; rateLimiter(perUser); ``` **Notes:** `window` is **seconds**, not milliseconds (it's multiplied by 1000 internally). `key` is called on every request, so keep it cheap and pure. Returning a constant string collapses all clients into one shared bucket. #### `RateLimitStore` / `RateLimitBucket` ```ts interface RateLimitStore { hit(key: string, windowMs: number): Promise | RateLimitBucket; } interface RateLimitBucket { count: number; // hits so far in the current window, including this one reset: number; // epoch ms when the window rolls over } ``` The storage seam. `hit` records one request against `key`, rotating the window if it lapsed, and returns the running tally. Shipped implementations: `MemoryRateLimitStore` (the default), `redisRateLimitStore(client?)` (atomic), and `cacheRateLimitStore(cache?)` (best-effort over any `Cache`) — the latter two default to the app's `redis()` client and `cache()` singleton. --- # Redis A Redis integration built on a small pluggable driver — like the database and mail layers, the core imports no client, so it runs on Node and on the edge. Point it at Upstash (HTTP/`fetch`), ioredis, node-redis, or the built-in `MemoryRedis` for tests and local dev. ## Using it Register a driver once (in a service provider), then reach Redis anywhere with `redis()`: ```ts import { redis, setRedis, MemoryRedis } from "@shaferllc/keel/core"; setRedis(new MemoryRedis()); // swap for an Upstash / ioredis adapter in production await redis().set("views", "1"); await redis().incr("views"); // 2 await redis().get("views"); // "2" await redis().set("token", "abc", { ex: 60 }); // expire in 60s await redis().del("token"); ``` The default client is a `MemoryRedis`, so `redis()` works out of the box in tests without any setup. ## Commands ```ts const r = redis(); await r.get(key); // string | null await r.set(key, value, { ex }); // { ex: seconds } or { px: ms } await r.del(...keys); // number removed await r.exists(...keys); // number present await r.has(key); // boolean await r.incr(key); // +1 await r.decr(key); // -1 await r.incrBy(key, 5); await r.expire(key, 60); // set a TTL (seconds) await r.ttl(key); // seconds left, -1 (no expiry), -2 (no key) await r.keys("user:*"); // glob match await r.flushAll(); // clear everything ``` ### JSON & remember `getJson` / `setJson` handle serialization, and `remember` is the read-through cache pattern: ```ts await redis().setJson("user:1", { id: 1, name: "Ada" }); const user = await redis().getJson<{ id: number; name: string }>("user:1"); // Compute once, cache for 300s, serve from cache after: const stats = await redis().remember("stats", 300, () => computeStats()); ``` ## As a cache store `redisStore()` adapts the Redis client into a [`CacheStore`](./cache.md), so the cache can be Redis-backed — shared across instances instead of per-process: ```ts import { Cache, redisStore, redis } from "@shaferllc/keel/core"; const cache = new Cache(redisStore(redis())); await cache.remember("home", 60, () => renderHome()); ``` ## Writing a driver A driver is the `RedisConnection` interface. Here's the shape for an Upstash REST client over `fetch` (edge-safe): ```ts import type { RedisConnection } from "@shaferllc/keel/core"; const upstash = (url: string, token: string): RedisConnection => { const call = (...args: (string | number)[]) => fetch(url, { method: "POST", headers: { Authorization: `Bearer ${token}` }, body: JSON.stringify(args), }).then((r) => r.json()); return { async get(key) { return (await call("GET", key)).result ?? null; }, async set(key, value, o) { await (o?.ex ? call("SET", key, value, "EX", o.ex) : call("SET", key, value)); }, async del(...keys) { return (await call("DEL", ...keys)).result; }, async exists(...keys) { return (await call("EXISTS", ...keys)).result; }, async incrBy(key, n) { return (await call("INCRBY", key, n)).result; }, async expire(key, s) { return (await call("EXPIRE", key, s)).result === 1; }, async ttl(key) { return (await call("TTL", key)).result; }, async keys(pattern) { return (await call("KEYS", pattern)).result; }, async flushAll() { await call("FLUSHALL"); }, }; }; setRedis(upstash(env("UPSTASH_URL"), env("UPSTASH_TOKEN"))); ``` ### The optional queue commands `RedisConnection` also declares eight **optional** methods — `zadd`, `zrangebyscore`, `zrem`, `zcard`, `hset`, `hget`, `hgetall`, `hdel` — the sorted-set and hash commands the queue's [`RedisDriver`](./queues.md#the-redis-driver) runs on. A minimal adapter that skips them still works everywhere else; the queue refuses it at first use with the missing commands named. Each maps 1:1 onto a standard Redis command, so extending the Upstash adapter above is one `call(...)` line apiece: ```ts async zadd(key, score, member) { return (await call("ZADD", key, score, member)).result; }, async zrangebyscore(key, min, max, limit) { const args = limit === undefined ? [] : ["LIMIT", 0, limit]; return (await call("ZRANGEBYSCORE", key, min, max, ...args)).result; }, async zrem(key, member) { return (await call("ZREM", key, member)).result; }, // …and so on for zcard, hset, hget, hgetall, hdel ``` (For `hgetall`, mind your client's return shape — the queue expects a `Record`; Upstash's REST API returns a flat `[field, value, …]` array you'll need to fold.) ## In tests `MemoryRedis` is a full in-memory implementation with TTL support — no server: ```ts import { setRedis, MemoryRedis, redis } from "@shaferllc/keel/core"; setRedis(new MemoryRedis()); await redis().incr("signups"); assert.equal(await redis().get("signups"), "1"); ``` ## API reference ### `redis()` `redis(): Redis` The default client. Register a driver with `setRedis` first; defaults to a `MemoryRedis`. ### `setRedis(conn)` `setRedis(conn: RedisConnection): Redis` Registers the driver behind `redis()` and returns the wrapping client. Last call wins. ### `Redis` Wraps a `RedisConnection` with conveniences. | Method | Signature | Notes | |--------|-----------|-------| | `get` | `(key) => Promise` | raw string value | | `set` | `(key, value, options?) => Promise` | `{ ex: seconds }` / `{ px: ms }` | | `del` / `exists` | `(...keys) => Promise` | count removed / present | | `has` | `(key) => Promise` | `exists(key) > 0` | | `incr` / `decr` | `(key) => Promise` | ±1 | | `incrBy` | `(key, amount) => Promise` | atomic add | | `expire` | `(key, seconds) => Promise` | false if the key is gone | | `ttl` | `(key) => Promise` | seconds, `-1` no expiry, `-2` no key | | `keys` | `(pattern?) => Promise` | glob; default `"*"` | | `flushAll` | `() => Promise` | clear all | | `getJson` / `setJson` | JSON convenience over `get`/`set` | | | `remember` | `(key, seconds, factory) => Promise` | read-through cache | ### `MemoryRedis` `class MemoryRedis implements RedisConnection` An in-memory driver with TTL support — the default and ideal for tests. Not shared across processes. ### `redisStore(client?)` `redisStore(client?: Redis): CacheStore` Adapts a `Redis` client into a `CacheStore` for the cache layer. Defaults to the global `redis()`. ### Interfaces & types #### `RedisConnection` The driver seam — implement it to back Redis with any client. Methods: `get`, `set`, `del`, `exists`, `incrBy`, `expire`, `ttl`, `keys`, `flushAll`. #### `SetOptions` `interface SetOptions { ex?: number; px?: number }` Expiry for `set` — `ex` in seconds, `px` in milliseconds. --- # Task Scheduling Declare recurring work with a fluent cadence, then let a **single cron trigger** drive it — a code-defined scheduler, edge-first. Instead of a crontab entry per job, you register tasks in code and run the scheduler once a minute; it runs whatever's due. ## Scheduling tasks A task is a [`Job`](./queues.md) or a plain function. Register it, then set the cadence: ```ts import { schedule } from "@shaferllc/keel/core"; schedule(new PruneSessions()).daily(); schedule(() => syncInventory()).everyFiveMinutes(); schedule(new SendDigest()).cron("0 9 * * 1"); // 9am Mondays ``` ### Cadences ```ts task.everyMinute(); task.everyFiveMinutes(); // and Ten / Fifteen / Thirty task.hourly(); task.hourlyAt(15); // :15 past every hour task.daily(); task.dailyAt("13:30"); task.weekly(1); // Monday (0 = Sunday) at midnight task.monthly(15); // the 15th at midnight task.cron("*/10 9-17 * * 1-5"); // any 5-field cron expression ``` ## Running the scheduler Run the scheduler once a minute from a cron trigger; `runDue(now)` runs every task whose expression matches `now` (to the minute). ### Cloudflare (Cron Triggers) Add a trigger to `wrangler.jsonc` (`"triggers": { "crons": ["* * * * *"] }`) and call the scheduler from the Worker's `scheduled` handler: ```ts export default { async scheduled(event, env, ctx) { ctx.waitUntil(scheduler().runDue(new Date(event.scheduledTime))); }, }; ``` ### Node ```ts setInterval(() => scheduler().runDue(new Date()), 60_000); ``` Because the scheduler decides what's due, you only ever wire **one** trigger, no matter how many tasks you schedule. ## Inspecting `scheduler().due(now)` returns the due tasks without running them, and every task has an `expression` and optional `name`: ```ts schedule(job).named("prune-sessions").daily(); for (const t of scheduler().due(new Date())) logger().info("due", { task: t.name }); ``` ## API reference ### `schedule(job)` `schedule(job: Job | (() => void | Promise)): ScheduledTask` Registers a task on the default scheduler and returns it for cadence configuration. ### `scheduler()` / `setScheduler(next)` The default `Scheduler`; `setScheduler` replaces it (reset between tests). ### `ScheduledTask` Returned by `schedule`. Cadence setters return `this` (chainable): `everyMinute` / `everyFiveMinutes` / `everyTenMinutes` / `everyFifteenMinutes` / `everyThirtyMinutes` / `hourly` / `hourlyAt(m)` / `daily` / `dailyAt("HH:MM")` / `weekly(weekday?)` / `monthly(day?)` / `cron(expr)` / `named(name)`. `isDue(now)` reports whether it matches a `Date`. ### `Scheduler` | Method | Notes | |--------|-------| | `schedule(job)` | register a task | | `due(now?)` | the tasks due at `now` (no run) | | `runDue(now?)` | run every due task; returns the count | | `tasks` | all registered tasks | ### `cronMatches(expression, date)` `cronMatches(expression: string, date: Date): boolean` Whether a 5-field cron expression (`min hour dom month dow`) matches `date` to the minute. Supports `*`, exact values, lists (`1,15`), ranges (`9-17`), and steps (`*/5`, `0-30/10`). Day-of-month and day-of-week follow standard cron: when both are restricted, either matching counts. Fields use the `Date`'s own values (UTC in a Worker, local under Node). --- # Search Full-text search over a pluggable **driver** — the same seam as the cache, queue, and storage layers, so the core imports no engine and runs on Node and the edge. `MemoryDriver` is the default; `DatabaseDriver` puts documents in a table and searches them with whatever full-text machinery your dialect actually has. ## Using it Declare which fields are searchable, register the model once, and search: ```ts import { Model, search, registerSearchable } from "@shaferllc/keel/core"; export class Post extends Model { static table = "posts"; static searchable = ["title", "body"]; } // in a service provider's boot() registerSearchable(Post); const posts = await search(Post, "edge runtime").get(); // Post[] ``` `registerSearchable` wires the model's `saved` and `deleted` events to the index, so writes stay in sync without you remembering to reindex. ## What comes back `search()` returns a builder, not a promise — chain, then resolve: ```ts await search(Post, "edge").get(); // Post[], best match first await search(Post, "edge").first(); // Post | null await search(Post, "edge").ids(); // string[], nothing loaded await search(Post, "edge").limit(10).offset(20).get(); ``` `get()` resolves ids back into models **through the model's own query builder**, so casts, global scopes, relations, and soft deletes all still apply — a search result is an ordinary model, not a second-class one. The rows are then re-sorted into the driver's order, because `WHERE id IN (…)` has no obligation to preserve it. A hit whose row has since been deleted is skipped rather than returned as a hole, so an index that has drifted degrades quietly instead of handing you `undefined`. ## How queries are interpreted Every driver agrees on three things, so swapping one doesn't change your results: - **Terms are AND-ed.** `"edge runtime"` matches documents containing both. - **Terms match on prefix.** `"config"` finds "configuration". - **An empty or punctuation-only query matches nothing**, rather than everything. Punctuation separates terms rather than being searched for, and the user's input is never treated as query syntax — see [Untrusted input](#untrusted-input). ## Choosing a driver ```ts import { setSearchDriver, DatabaseSearchDriver } from "@shaferllc/keel/core"; setSearchDriver(new DatabaseSearchDriver()); ``` | Driver | Where documents live | Ranking | | --- | --- | --- | | `MemorySearchDriver` | in the process | term frequency | | `DatabaseSearchDriver` | a `search_index` table | the dialect's own | ### The memory driver The default. Ideal for tests — no database, no migration, and its scoring is simple enough (how many query terms a document contains, then how often) to assert ordering against. Not shared across processes, so it is not a production driver. ### The database driver Add the migration once: ```ts import { searchMigration } from "@shaferllc/keel/core"; export const migrations = [searchMigration()]; ``` One `search_index` table serves every model — the `idx` column names which — so making another model searchable needs no migration of its own. The DDL is dialect-specific, because full-text support is: | Dialect | Index | Query | | --- | --- | --- | | `sqlite` | FTS5 virtual table | `MATCH`, ranked by `rank` | | `postgres` | generated `tsvector` + GIN | `@@ to_tsquery`, ranked by `ts_rank` | | `mysql` | `FULLTEXT` index | `LIKE` fallback | | anything else | plain table | `LIKE` fallback | The `LIKE` fallback returns the right rows but doesn't rank them, and scans. It's there so a dialect without full-text still *works*, not so you'd choose it. ## Keeping the index current `registerSearchable` handles ongoing writes. For a table that existed before it was searchable — or after a bulk import that bypassed the model — backfill from the console: ```bash npm run keel search:index Post # rebuild from the table npm run keel search:index Post -- --chunk 1000 npm run keel search:flush Post # empty the index ``` `search:index` flushes the index first, so it is a rebuild rather than a top-up and removed rows don't linger. That does mean an interrupted run leaves a partial index — re-run it. In code, the same thing is `reindex(Post)`, which returns how many documents it wrote and pages through the table in chunks so a large table doesn't arrive all at once. ## Untrusted input A search box is user input going into a query language, which is the shape of an injection bug. Keel's drivers don't interpolate it: - The **SQLite** driver quotes each term for FTS5, so `OR`, `NEAR`, `*`, and a stray `"` are words to search for, not operators that change what the query means. - The **Postgres** driver builds its `tsquery` from parsed terms rather than handing the raw string to `to_tsquery`, which would raise on malformed input. - Every driver parameterizes its SQL. So you can pass a raw search box straight through. It won't throw a syntax error and it can't widen the query. ## Testing The default driver is already in-memory, so tests need no setup. To start from a known state, set a fresh one: ```ts import { setSearchDriver, MemorySearchDriver, reindex } from "@shaferllc/keel/core"; setSearchDriver(new MemorySearchDriver()); await reindex(Post); assert.equal((await search(Post, "edge").first())?.title, "Edge runtime basics"); ``` Model hooks persist between tests, so if you register searchable models in a test suite, `clearModelHooks()` between cases. ## Writing a driver A driver is four methods. It stores and ranks documents and never loads your models — whatever it returns, `search()` resolves through the query builder. ```ts import type { SearchDriver } from "@shaferllc/keel/core"; const meiliDriver = (client: MeiliClient): SearchDriver => ({ async index(index, documents) { await client.index(index).addDocuments( documents.map((d) => ({ id: d.id, ...d.fields })), ); }, async delete(index, ids) { await client.index(index).deleteDocuments(ids); }, async search(index, query, options = {}) { const res = await client.index(index).search(query, { limit: options.limit ?? 50, offset: options.offset ?? 0, }); return res.hits.map((h) => ({ id: String(h.id), score: h._rankingScore })); }, async flush(index) { await client.index(index).deleteAllDocuments(); }, }); ``` Return hits **best first**, and use a bigger `score` for a better hit — that's the convention `MemoryDriver` and `DatabaseDriver` both follow (SQLite's `rank` is negated for exactly this reason, since FTS5 counts *down*). ## Related Search sits on top of [models](./models.md) and their [events](./hooks.md). The [console](./console.md) has `search:index` and `search:flush`. --- ## API reference ### `search(model, query)` `search(model: ModelClass, query: string): SearchQuery` Start a search against a model. Returns a builder; nothing runs until you call `get()`, `first()`, or `ids()`. ### `SearchQuery` #### `limit(n)` / `offset(n)` `limit(n: number): this` · `offset(n: number): this` Cap and page the hits. Default limit is 50. #### `get()` `get(): Promise` The matching models, best first. Resolves ids through the model's query builder, then restores the driver's order. Hits whose rows no longer exist are dropped. #### `first()` `first(): Promise` The best hit, or null. #### `ids()` `ids(): Promise` The matching ids, best first, without loading models. ### `registerSearchable(model)` `registerSearchable(model: ModelClass): void` Wire a model's `saved` and `deleted` events to its index. Call once at boot. **Notes:** throws if the model has no `static searchable` fields — indexing nothing is a mistake that otherwise surfaces much later as "search finds nothing". The index name is `static searchIndex` if set, else the model's table. ### `reindex(model, options?)` `reindex(model: ModelClass, options?: { chunk?: number }): Promise` Flush the index and rebuild it from the table. Returns the document count. **Notes:** `chunk` defaults to 500 rows per read. ### `setSearchDriver(driver)` / `searchDriver()` `setSearchDriver(driver: SearchDriver): void` · `searchDriver(): SearchDriver` Register the driver, and read it back. Defaults to `MemorySearchDriver`. ### `searchMigration(table?)` `searchMigration(table?: string): Migration` The `search_index` table `DatabaseSearchDriver` reads. Table name defaults to `"search_index"`; the DDL varies by dialect. ### `documentText(fields)` `documentText(fields: Record): string` Flatten a document's scalar fields into the text blob a text index stores. Objects and nullish values are skipped. ### Interfaces & types #### `SearchDriver` `{ index, delete, search, flush }` — the driver seam. #### `SearchDocument` `{ id: string, fields: Record }`. #### `SearchOptions` `{ limit?, offset? }` — limit defaults to 50, offset to 0. #### `SearchHit` `{ id: string, score?: number }` — bigger score, better hit. --- # Securing SSR apps Two middlewares harden server-rendered apps: `securityHeaders()` sets the defensive HTTP headers browsers act on, and `csrf()` blocks cross-site form submissions. (For cross-origin API access, see [CORS](./cors.md).) ## Security headers `securityHeaders()` sets a Content-Security-Policy, HSTS, and the clickjacking / MIME-sniffing / referrer guards in one place: ```ts import { securityHeaders } from "@shaferllc/keel/core"; this.use(securityHeaders()); // sensible defaults this.use( securityHeaders({ csp: { defaultSrc: ["'self'"], scriptSrc: ["'self'", "https://cdn.example.com"] }, hsts: { maxAge: 15552000, includeSubDomains: true }, frameGuard: "DENY", }), ); ``` | Option | Header | Default | |--------|--------|---------| | `csp` | `Content-Security-Policy` | off (opt-in) | | `hsts` | `Strict-Transport-Security` | off (opt-in) | | `frameGuard` | `X-Frame-Options` | `"SAMEORIGIN"` | | `noSniff` | `X-Content-Type-Options: nosniff` | on | | `referrerPolicy` | `Referrer-Policy` | `strict-origin-when-cross-origin` | Set any of these to `false` to omit the header. `csp` accepts a ready-made string or a **directives object** whose camelCase keys become the kebab-case spelling (`defaultSrc` → `default-src`, `scriptSrc` → `script-src`). **HSTS is sticky** — once a browser sees it, it refuses plain HTTP for `maxAge` seconds. Only enable it once HTTPS works everywhere, and start with a short `maxAge` (say a day) while you test. ## CSRF protection `csrf()` guards against forged form submissions. It keeps a token in the [session](./sessions.md) and rejects any `POST`/`PUT`/`PATCH`/`DELETE` that doesn't echo it back — with `419 Page Expired`. It needs `sessionMiddleware()` installed first: ```ts import { sessionMiddleware, csrf } from "@shaferllc/keel/core"; this.use(sessionMiddleware()); this.use(csrf()); ``` ### In forms Drop `csrfField()` into any form — it renders a hidden `_token` input: ```ts import { csrfField } from "@shaferllc/keel/core"; `
${csrfField()} ` ``` Or read the raw token with `csrfToken()` to place it yourself. ### In SPAs `csrf()` also writes a readable `XSRF-TOKEN` cookie. Axios and most fetch wrappers send it back automatically as the `X-XSRF-TOKEN` header, so AJAX requests are covered with no extra code. The token is accepted from the `X-CSRF-Token` / `X-XSRF-Token` header or a `_token` / `_csrf` body field. ### Exempting routes Webhooks and provider callbacks can't send your token — exempt them (a trailing `*` matches a prefix): ```ts this.use(csrf({ except: ["/webhooks/*", "/payments/callback"] })); ``` --- # Sessions Keel ships a cookie-backed session store. There's no external service to run, so it works the same on Node and on the edge. Session data lives in an HTTP-only cookie: the middleware reads it before your handler runs and writes it back afterward. > **The cookie is signed, but not encrypted.** Every cookie is > `payload.signature`, where the signature is an HMAC-SHA256 of the payload under > `config('app.key')` — so a payload that has been edited no longer verifies and > is discarded, and the request starts with an empty session. What it is *not* is > secret: the payload is still base64 JSON that the client can read. Store an id, > not a password hash or anything else you would not show the user. > > Sessions therefore **require `APP_KEY`**. Without one the middleware raises > rather than falling back to an unsigned cookie — see [Signing](#signing). ## Enable it Add the middleware to your HTTP kernel: ```ts import { HttpKernel, sessionMiddleware } from "@shaferllc/keel/core"; export class Kernel extends HttpKernel { constructor(app: Application) { super(app); this.use(sessionMiddleware()); // options: sessionMiddleware({ cookieName: "sid", cookie: { secure: true } }) } } ``` The cookie is named `keel_session` by default and is written `httpOnly`, `path: "/"`, `sameSite: "Lax"`, and `Secure` **whenever the request arrived over HTTPS** (either directly or via an `X-Forwarded-Proto: https` header from a proxy terminating TLS). That is inferred rather than configured, because defaulting it on breaks every `http://localhost` dev server and defaulting it off is how a session cookie ends up crossing the internet in the clear. Anything you pass in `cookie` is merged over those defaults, so `cookie: { secure: true }` forces the flag on even in development. ## Use it Reach the session anywhere with `session()`: ```ts import { session } from "@shaferllc/keel/core"; session().put("userId", user.id); const id = session().get("userId"); const id2 = session().get("userId", null); // with a fallback session().has("userId"); session().forget("userId"); session().pull("cart"); // read and remove session().increment("visits"); session().clear(); session().all(); ``` `session()` returns a fresh `Session` wrapper each call, but every wrapper points at the *same* underlying data object stashed on the request context — so writes made through one call are visible through the next. There's no need to hold onto the instance: ```ts session().put("step", 1); session().get("step"); // 1 — same request, same data ``` Calling `session()` without `sessionMiddleware()` installed throws `Session is not available. Add sessionMiddleware() to your HTTP kernel.` — the guard is there so a missing middleware fails loudly instead of silently dropping writes. ## Flash messages Flash data survives exactly one request — perfect for post-redirect messages: ```ts // during a request that redirects session().flash("status", "Profile saved!"); return redirect("/profile"); // on the next request session().flashed("status"); // "Profile saved!" session().flashed("status"); // still there this request… // …gone on the request after ``` Flash and the values you `put` live in separate compartments. `flash(key, …)` writes to a pending-flash bucket; `flashed(key)` reads the bucket that was flashed on the *previous* request. So within the request that calls `flash`, a `flashed` for the same key won't see it yet — it lands on the next request: ```ts session().flash("status", "Saved!"); session().flashed("status"); // undefined — not flashed until next request session().get("status"); // undefined — flash isn't a normal key ``` `all()` returns only your own keys — the internal flash and "old flash" buckets are filtered out, so they never leak into a template's session dump. ## Counters `increment` and `decrement` treat a missing key as `0` and step by `1` (or any amount you pass): ```ts session().increment("visits"); // 0 -> 1 session().increment("credits", 10); // += 10 session().decrement("credits", 3); // -= 3 ``` Both coerce the stored value to a number, so seeding a counter with a non-numeric value will produce `NaN` — keep counters numeric. ## How it works `sessionMiddleware()` reads the session cookie before your handler runs and writes it back afterward. Data is JSON, base64-encoded into the cookie. On each request it rotates flash: last request's pending flash becomes this request's "old" (what `flashed` reads), and a fresh empty flash bucket starts. After your handler returns, the "old" bucket is dropped and the cookie is rewritten — so a value only ever survives one hop. Because it's cookie-backed there's a ~4KB size budget — keep sessions small (an id, a few flags), not whole objects. For larger sessions, swap in your own middleware that persists to a store and stashes the data on the context under the `"session"` key the same way. ### Signing The whole session lives in the cookie, so the cookie is the only thing standing between a visitor and simply declaring who they are — `auth()` stores the logged-in user's id there. Base64 is an encoding, not a secret: on its own, `{"auth_id":"7"}` can be edited to `{"auth_id":"1"}` and the server cannot tell. So the cookie is `payload.signature`, the signature being HMAC-SHA256 of that exact payload string under `config('app.key')`, compared in constant time. A cookie whose signature does not verify is not repaired or partially trusted — it is dropped, and the request gets an empty session. Two consequences worth knowing: - **`APP_KEY` is required.** With no key there is nothing to sign with, so the middleware raises instead of writing an unsigned cookie. Every starter kit ships one in `.env.example`; set a real one in production. - **Changing `APP_KEY` logs everyone out.** Existing cookies no longer verify. That is also what makes it a working "log out every session" lever. Values are UTF-8 encoded, so emoji and non-Latin scripts round-trip fine. ## Related Sessions underpin [authentication](./authentication.md) — the auth layer stores the logged-in user's id in the session. Install `sessionMiddleware()` before any middleware that reads the session. --- ## API reference ### `session()` `session(): Session` Returns the current request's `Session`, wrapping the data on the request context. ```ts session().put("userId", 1); ``` **Notes:** throws `Session is not available…` if `sessionMiddleware()` isn't installed (no `"session"` on the context). Returns a new wrapper each call, but all wrappers share the same underlying data for the request, so writes persist across calls. ### `sessionMiddleware(options?)` `sessionMiddleware(options?: SessionOptions): MiddlewareHandler` Builds the middleware that loads the session from its cookie before the request and writes it back after. Register it in your HTTP kernel. ```ts this.use(sessionMiddleware({ cookieName: "sid", cookie: { secure: true } })); ``` **Notes:** cookie name defaults to `"keel_session"`. Requires `config('app.key')` — it raises without one rather than writing an unsigned cookie. A missing cookie, an unsigned one, or one whose signature does not verify all start an empty session. The write applies `httpOnly`, `path: "/"`, `sameSite: "Lax"`, and `secure` when the request came in over HTTPS (directly or via `X-Forwarded-Proto`), merged with (and overridable by) `options.cookie`. Rotates flash on every request. ### `Session` The wrapper `session()` returns. You don't construct it directly in app code (the middleware and `session()` build it), though its constructor takes the raw data object. Mutating methods return `this`, so they chain. #### `all()` `all(): SessionData` Returns every key you've stored, excluding the internal flash/old-flash buckets. ```ts const data = session().all(); // Record ``` **Notes:** `SessionData` is `Record`. A shallow copy of the public keys — mutating the result doesn't write back to the session. #### `get(key, fallback?)` `get(key: string, fallback?: T): T` Reads a value, returning `fallback` when the key is absent. ```ts const id = session().get("userId"); const theme = session().get("theme", "light"); ``` **Notes:** presence is checked with `key in data`, so a key explicitly set to `null` returns `null` (not the fallback). The `T` type parameter is an unchecked cast — it doesn't validate the runtime value. #### `put(key, value)` `put(key: string, value: unknown): this` Stores a value under `key`. ```ts session().put("userId", user.id).put("theme", "dark"); ``` **Notes:** chainable. Values must be JSON-serializable (they're `JSON.stringify`d into the cookie); any UTF-8 string is fine. #### `set(key, value)` `set(key: string, value: unknown): this` Alias for `put`. ```ts session().set("locale", "en"); ``` #### `has(key)` `has(key: string): boolean` `true` when the key is present *and* non-null. ```ts if (session().has("userId")) { /* logged in */ } ``` **Notes:** returns `false` for a key set to `null` or `undefined`, even though `get` would still return that stored `null`. #### `forget(key)` `forget(key: string): this` Deletes a key. ```ts session().forget("userId"); ``` #### `pull(key, fallback?)` `pull(key: string, fallback?: T): T` Reads a value and removes it in one step (`get` + `forget`). ```ts const cart = session().pull("cart", []); ``` **Notes:** returns `fallback` when absent, same rules as `get`. Useful for one-shot values you don't want lingering. #### `increment(key, by?)` `increment(key: string, by = 1): this` Adds `by` to a numeric value, treating a missing key as `0`. ```ts session().increment("visits"); session().increment("credits", 25); ``` **Notes:** coerces the current value with `as number` — non-numeric stored values yield `NaN`. Chainable. #### `decrement(key, by?)` `decrement(key: string, by = 1): this` Subtracts `by` from a numeric value (`increment` by `-by`). ```ts session().decrement("credits", 3); ``` #### `clear()` `clear(): this` Removes every key, including the flash buckets. ```ts session().clear(); ``` **Notes:** deletes all keys off the underlying data object, so it also wipes pending and old flash. Use to reset on logout. #### `flash(key, value)` `flash(key: string, value: unknown): this` Stores a value that survives only the *next* request. ```ts session().flash("status", "Profile saved!"); ``` **Notes:** writes to a separate flash bucket, not the normal keyspace — `get` and `all` won't see it. Read it next request with `flashed`. Chainable. #### `flashed(key, fallback?)` `flashed(key: string, fallback?: T): T` Reads a value flashed on the *previous* request. ```ts const status = session().flashed("status"); const msg = session().flashed("msg", ""); ``` **Notes:** reads the "old" bucket the middleware rotated in at the start of the request. A value flashed and read in the same request won't appear here (it's not yet rotated). Returns `fallback` when absent. ### Interfaces & types #### `SessionOptions` ```ts interface SessionOptions { cookieName?: string; cookie?: CookieOptions; // hono's setCookie options (Parameters[3]) } ``` Configures `sessionMiddleware()`. Use it to rename the cookie or set flags like `secure`, `maxAge`, or `domain`. ```ts sessionMiddleware({ cookieName: "sid", cookie: { secure: true, maxAge: 60 * 60 * 24 }, }); ``` **Notes:** `cookieName` defaults to `"keel_session"`. `cookie` is merged *over* the middleware's defaults (`httpOnly`, `path: "/"`, `sameSite: "Lax"`), so you can override any of them. --- # Social authentication "Sign in with GitHub / Google / Discord" — OAuth 2.0, without an SDK. Keel owns the OAuth handshake only: it hands you a normalized **social user**, and *you* find-or-create your own user and log them in (with a [session](./authentication.md), [`jwt`](./authentication.md#token-api-authentication), or an [access token](./authentication.md#opaque-access-tokens)). It stores nothing. Every driver is `fetch`-based — no dependencies, no native bindings — so it runs on Node and the edge alike. ## Configure a provider ```ts import { social } from "@shaferllc/keel/core"; const github = social.github({ clientId: config("services.github.id"), clientSecret: config("services.github.secret"), redirectUri: "https://app.example.com/auth/github/callback", }); ``` Presets: `social.github`, `social.google`, `social.discord`. Each defaults to the scopes needed for id + email + profile; override with `scopes` in the config or per-redirect. ## The two-step flow **1. Redirect** the user to the provider. Generate a `state` for CSRF and stash it (in the session) to check on the way back: ```ts import { social, session, redirect } from "@shaferllc/keel/core"; router.get("/auth/github", () => { const state = social.state(); session().put("oauth_state", state); return redirect(github.redirect({ state })); }); ``` **2. Handle the callback.** Verify `state`, then exchange the `code` for the user: ```ts router.get("/auth/github/callback", async () => { if (request.query("state") !== session().pull("oauth_state")) { throw new ForbiddenException("Invalid OAuth state"); } const gh = await github.user(request.query("code")); // exchange + fetch profile const user = await User.query() .where("github_id", gh.id) .first() ?? await User.create({ github_id: gh.id, email: gh.email, name: gh.name }); auth().login(user.id); return redirect("/dashboard"); }); ``` ## The social user `user()` returns a shape that's the same across every provider: ```ts { id: string; // the provider's stable id (always a string) email: string | null; name: string | null; nickname: string | null; // handle / username avatarUrl: string | null; token: OAuthToken; // { accessToken, refreshToken?, expiresIn?, … } raw: Record; // the untouched provider payload } ``` Reach `raw` for provider-specific fields not in the normalized shape. Use `token` to call the provider's API on the user's behalf. ## Issuing your own credential After you've found-or-created the user, log them in however your app authenticates — they're independent of the OAuth token: ```ts // server-rendered app → session auth().login(user.id); // SPA / mobile → an opaque access token const { token } = await createToken(user.id); return response.json({ token }); ``` ## Splitting the steps `user(code)` is `exchangeCode(code)` then `userFromToken(token)`. Call them apart when you already hold a token (e.g. a native mobile SDK did the OAuth dance): ```ts const token = await github.exchangeCode(code); // { accessToken, … } const gh = await github.userFromToken(token); // normalized user ``` A failed exchange or profile fetch throws `OAuthError` (with the `provider` name). ## OAuth 1.0a (Twitter/X) Some providers still speak the older, three-legged **OAuth 1.0a** — every request is HMAC-SHA1-signed (done here with Web Crypto, so it stays edge-native). The flow has an extra hop: get a temporary *request token*, send the user to authorize, then swap the returned `oauth_verifier` for the access token. Stash the request token's secret between the two steps. ```ts import { social, session, redirect } from "@shaferllc/keel/core"; const twitter = social.twitter({ clientId: config("services.twitter.key"), clientSecret: config("services.twitter.secret"), redirectUri: "https://app.example.com/auth/twitter/callback", }); // 1. request token → redirect router.get("/auth/twitter", async () => { const request = await twitter.requestToken(); session().put("twitter_secret", request.tokenSecret); // needed on the way back return redirect(twitter.redirect(request)); }); // 2. callback → access token → user router.get("/auth/twitter/callback", async () => { const tw = await twitter.user( request.query("oauth_token"), request.query("oauth_verifier"), session().pull("twitter_secret"), ); // tw is a SocialUser — same shape as OAuth2, but tw.token is an OAuth1Token const user = await User.firstOrCreate({ twitter_id: tw.id }, { name: tw.name }); auth().login(user.id); return redirect("/dashboard"); }); ``` For any other OAuth 1.0a provider, use `social.driver1(spec, config)` with `requestTokenUrl` / `authorizeUrl` / `accessTokenUrl` and a `fetchUser` that calls `driver.get(url, token)` (a signed request). The low-level `oauth1Signature()` is exported too, if you need to sign an arbitrary API call yourself. ## Any other OAuth2 provider Build a driver for anything OAuth2 with `social.driver(spec, config)` — supply the `authorizeUrl`, `tokenUrl`, default scopes, and a `fetchUser(token)` that returns a `SocialUser`: ```ts const gitlab = social.driver( { name: "gitlab", authorizeUrl: "https://gitlab.com/oauth/authorize", tokenUrl: "https://gitlab.com/oauth/token", defaultScopes: ["read_user"], async fetchUser(token) { const res = await fetch("https://gitlab.com/api/v4/user", { headers: { authorization: `Bearer ${token.accessToken}` }, }); const data = await res.json(); return { id: String(data.id), email: data.email, name: data.name, nickname: data.username, avatarUrl: data.avatar_url, token, raw: data }; }, }, { clientId, clientSecret, redirectUri }, ); ``` --- # Static Files `serveStatic()` serves files from a directory (default `public/`) **before** your routes run. If a file matches the request path it's sent with caching headers; otherwise the request falls through to your routes. ## Enable it Add the middleware to your HTTP kernel: ```ts import { HttpKernel, serveStatic } from "@shaferllc/keel/core"; export class Kernel extends HttpKernel { constructor(app: Application) { super(app); this.use(serveStatic()); // serves ./public } } ``` Now `./public/css/style.css` is served at `/css/style.css`, and `./public/index.html` at `/`. ## How a request is matched For each request the middleware: 1. **Skips non-reads.** Only `GET` and `HEAD` are handled; any other method falls straight through to your routes. 2. **Decodes and guards the path.** The URL pathname is `decodeURIComponent`'d, then any path containing `..` is rejected (falls through) — so percent-encoded traversal (`%2e%2e`) is caught too. 3. **Applies the dot-file policy** (see below). 4. **Resolves the file.** It looks for `root + urlPath` on disk. If that's a directory, it appends `index` (`index.html`) and looks again. If nothing resolves to a real file, the request falls through. 5. **Sends the file** with `Content-Type`, `Last-Modified`, `ETag`, and — when configured — `Cache-Control` headers. Because the middleware calls `next()` (rather than returning a 404) whenever it can't serve a file, a missing asset is handled by your routes, not by the static server. That's what lets a client-side app fall back to an SPA catch-all route. ## Options ```ts serveStatic({ root: "./public", // directory to serve index: "index.html", // directory index file dotFiles: "ignore", // "ignore" (404) · "deny" (403) · "allow" maxAge: 86400, // Cache-Control: public, max-age=… immutable: true, // add the immutable directive (hashed filenames) headers: (path) => // extra per-file headers path.endsWith(".html") ? { "X-Frame-Options": "DENY" } : undefined, }); ``` Every response carries an `ETag` and `Last-Modified`, and a matching `If-None-Match` returns a `304`. Dot-files (`.env`, `.git`) are 404'd by default so secrets aren't exposed. Path traversal (`..`) is blocked. ## Caching & conditional requests The `ETag` is **weak** — derived from the file's byte size and modified time (`W/"-"`) — so it changes whenever the file changes without hashing its contents. On a repeat request the browser echoes it back in `If-None-Match`; if it still matches, the middleware short-circuits with `304 Not Modified` and an empty body, saving the read and the transfer. `Cache-Control` is only sent when you set `maxAge`; omit it and the response has no `Cache-Control` header at all (the browser falls back to its heuristic freshness). Add `immutable: true` for content-hashed filenames so conditional revalidation is skipped entirely for the cache lifetime: ```ts // Long-lived, fingerprinted build assets: cache hard, never revalidate. this.use(serveStatic({ root: "./dist", maxAge: 31536000, immutable: true })); ``` `HEAD` requests get the full header set with an empty body, so clients can probe an asset's `ETag`/`Last-Modified` without downloading it. ## Per-file headers The `headers` callback runs for every file about to be served and merges its result into the response. It receives the **resolved filesystem path** (root included, e.g. `./public/app.js`), not the URL path — match on the extension or suffix rather than a leading slash: ```ts serveStatic({ headers: (path) => { if (path.endsWith(".html")) return { "X-Frame-Options": "DENY" }; if (path.endsWith(".wasm")) return { "Cross-Origin-Embedder-Policy": "require-corp" }; return undefined; // no extra headers }, }); ``` Returning `undefined` (or an empty object) adds nothing. These headers are set after the built-ins, so a `Cache-Control` you return here overrides the one derived from `maxAge`. ## Dot-files & traversal Any path segment that starts with `.` — not just the last one — is a "dot segment", so `/.git/config` and `/assets/.env` both trip the policy: - `"ignore"` (default) — falls through to your routes, so it reads as a 404. - `"deny"` — responds `403 Forbidden`. - `"allow"` — serves the file like any other. Separately, any decoded path containing `..` is always rejected regardless of the dot-file policy, so `../` traversal can't escape `root`. ## Error behavior The file-resolution block is wrapped in a `try/catch` that swallows errors by calling `next()`. A permission error, a mid-request delete, or a malformed path never becomes a `500` — it falls through to your routes exactly like a miss. The trade-off: genuine filesystem faults are invisible here, so don't rely on this middleware to surface disk problems. ## Edge note `serveStatic()` reads from the filesystem (via a dynamically-imported `node:fs`), so it's for **Node** apps. On Cloudflare Workers, serve assets through the platform's static-assets binding instead — the framework core still imports cleanly either way (the `node:fs` import is deferred until the first request the middleware actually handles). ## Production For high-traffic sites, prefer a CDN or reverse proxy (Nginx, Caddy, Cloudflare) in front of static assets rather than serving them from the Node process. --- ## API reference ### `serveStatic(options?)` `serveStatic(options?: StaticOptions): MiddlewareHandler` Builds a Hono middleware that serves files from `options.root` before the request reaches your routes, falling through to `next()` on any miss. ```ts import { serveStatic } from "@shaferllc/keel/core"; const assets = serveStatic({ root: "./public", maxAge: 86400 }); this.use(assets); ``` **Notes:** all options are optional (`serveStatic()` serves `./public`). Only `GET`/`HEAD` are handled — other methods pass through untouched. Returns the middleware synchronously; `node:fs/promises` is imported lazily on the first handled request, so importing this on a non-Node runtime is safe until a request hits it. Sends `Content-Type` (via Hono's `getMimeType`, defaulting to `application/octet-stream`), `Last-Modified`, and a weak `ETag`; honors `If-None-Match` with a `304`. A trailing slash on `root` is stripped. The `..` guard is a plain substring check, so a (rare) legitimate filename containing `..` is also rejected. ### Interfaces & types #### `StaticOptions` ```ts interface StaticOptions { root?: string; index?: string; dotFiles?: "ignore" | "deny" | "allow"; maxAge?: number; immutable?: boolean; headers?: (path: string) => Record | undefined; } ``` The configuration bag for `serveStatic()`. Pass it to tune the served directory, directory index, dot-file policy, and caching. Every field has a default, so an empty object (or no argument) is valid. ```ts const options: StaticOptions = { root: "./dist", index: "index.html", dotFiles: "deny", maxAge: 31536000, immutable: true, headers: (path) => (path.endsWith(".html") ? { "X-Frame-Options": "DENY" } : undefined), }; serveStatic(options); ``` **Field defaults & behavior:** - `root` — directory to serve from. Default `"./public"`; trailing slashes are stripped. - `index` — file served for a directory request. Default `"index.html"`. - `dotFiles` — policy for paths with a `.`-prefixed segment: `"ignore"` (fall through, reads as 404), `"deny"` (`403 Forbidden`), or `"allow"` (serve). Default `"ignore"`. - `maxAge` — `Cache-Control: public, max-age=`. Omit for no `Cache-Control` header at all. - `immutable` — appends `, immutable` to `Cache-Control` (only meaningful alongside `maxAge`). Default `false`. - `headers` — called with the resolved filesystem path (root included) for each served file; return extra headers to merge, or `undefined` for none. These are applied last, so they can override the built-in headers. --- # Storage File storage over a pluggable **disk** — like the database and mail layers, the core imports no filesystem or SDK, so it runs on Node and the edge. Point a disk at the local filesystem, S3, or a Cloudflare R2 binding; `MemoryDisk` is the built-in default for tests. ## Using it Register a disk once (in a service provider), then reach it anywhere with `storage()`: ```ts import { storage, setDisk, MemoryDisk } from "@shaferllc/keel/core"; setDisk(new MemoryDisk()); // swap for a local / S3 / R2 disk in production await storage().put("avatars/1.png", bytes); // string | Uint8Array | ArrayBuffer const bytes = await storage().get("avatars/1.png"); // Uint8Array | null const text = await storage().getText("notes/todo.md"); // string | null await storage().exists("avatars/1.png"); // boolean await storage().delete("avatars/1.png"); const files = await storage().list("avatars/"); // paths under a prefix const url = storage().url("avatars/1.png"); // a public URL for the object ``` The default disk is a `MemoryDisk`, so `storage()` works out of the box in tests. For production, see [the shipped disks](#the-shipped-disks) — the local filesystem, S3-compatible buckets, and Cloudflare R2. ## Writing files The **content type is inferred from the extension**, so a `.png` lands in your bucket as `image/png` rather than `application/octet-stream` — which is the difference between a browser rendering the file and downloading it. ```ts await storage().put("avatars/1.png", bytes); // stored as image/png ``` Pass `WriteOptions` to set it yourself, along with the rest of the object's metadata: ```ts await storage().put("exports/report.csv", csv, { contentType: "text/csv", cacheControl: "public, max-age=3600", visibility: "private", // needs a signed URL to read metadata: { uploadedBy: "42" }, // arbitrary user metadata }); ``` A disk that can't express one of these ignores it. ## Inspecting, copying, moving ```ts const meta = await storage().metadata("avatars/1.png"); // { size, contentType, cacheControl, visibility, lastModified, metadata } const size = await storage().size("avatars/1.png"); // number | null await storage().copy("avatars/1.png", "avatars/1-backup.png"); await storage().move("tmp/upload.png", "avatars/2.png"); ``` `copy` and `move` use the backend's server-side operation when the disk provides one, and fall back to read-then-write otherwise. `metadata` falls back to reading the file and measuring it. ## Signed URLs `url()` is the *public* URL. For a private file, hand out a **temporary** one instead: ```ts const url = await storage().signedUrl("invoices/42.pdf", { expiresIn: 300 }); // → /storage/invoices/42.pdf?expires=1752278400&signature=a3f1… ``` How it's signed depends on the disk: - **A disk with backend presigning** (`s3Disk`, and any disk you write with a `signedUrl` of its own) returns the backend's own presigned URL. The file is served straight from the bucket; your app isn't in the path at all. - **Any other disk** gets a URL signed with `config('app.key')`, pointing at your app. Serve those with `serveStorage({ signed: true })`. Either way the URL expires, and tampering with the path invalidates it. ### Serving files from a disk `serveStorage()` is the middleware that makes app-signed URLs real — it serves a disk's files over HTTP, verifying the signature when you ask it to. Requests that don't match `basePath`, or that name a file the disk doesn't have, fall through to your routes. ```ts // in a service provider's boot(), or your HTTP kernel's constructor const kernel = this.app.make(HttpKernel); kernel.use(serveStorage()); // public files under /storage kernel.use(serveStorage({ basePath: "/private", signed: true })); ``` In `signed` mode an unsigned or expired request gets a **403**. Files are sent with their stored content type, an `ETag` (so conditional requests get a 304), and their `Cache-Control`. The signature covers the **path and query, not the host** — so the same signed URL stays valid behind a CDN hostname, and you can't move a signature onto a different file. > **The disk's `url()` prefix and `basePath` must agree.** `signedUrl()` signs the > path the *disk* reports, so if the disk hands out `/storage/…` while > `serveStorage` listens on `/private`, no signature could ever match. Rather than > 403 every request — which reads as "your link expired" and sends you hunting in > the wrong place — `serveStorage` **throws** with the two paths and how to line > them up. Give the disk the matching base (`new MemoryDisk("/private")`, or > `localDisk({ root: "storage/app", baseUrl: "/private" })`), or keep both on the > default `/storage`. ## Direct browser uploads Proxying a large upload through your app is exactly what you don't want on the edge — a 50 MB video shouldn't stream through a Worker. A **signed upload URL** lets the browser `PUT` the file straight to the bucket: ```ts // server const url = await storage("r2").signedUploadUrl("uploads/clip.mp4", { expiresIn: 600, contentType: "video/mp4", }); ``` ```ts // browser await fetch(url, { method: "PUT", body: file, headers: { "Content-Type": "video/mp4" }, }); ``` Only the storage backend can accept such a write, so this needs a disk that implements `signedUploadUrl` — there is no generic fallback, and calling it on one that doesn't (the memory disk, say) throws a clear error rather than quietly handing you a URL that won't work. ## Testing `fakeDisk()` swaps a disk for an in-memory one, so tests never touch a real bucket, and gives you assertions over what was written. `restoreDisk()` puts the real one back. ```ts import { fakeDisk, restoreDisk } from "@shaferllc/keel/core"; const disk = fakeDisk(); // or fakeDisk("r2") for a named disk await request.post("/avatars", form); await disk.assertExists("avatars/1.png"); await disk.assertMissing("avatars/2.png"); await disk.assertContents("notes/todo.md", "buy milk"); await disk.assertCount(1, "avatars/"); // files under a prefix restoreDisk(); // no name → restore every faked disk ``` Failed assertions throw with the path and what was actually there. ## Multiple disks Register disks by name and pick one with `storage(name)`: ```ts setDisk(localDisk({ root: "storage/app" }), "local"); setDisk(s3Disk({ bucket: "uploads", ...credentials }), "s3"); await storage("local").put("cache/x", data); await storage("s3").put("public/logo.svg", svg); ``` ## The shipped disks Three disks come with Keel, each in its own entry point so nothing you don't use is imported. Pick by where the app runs and where the bytes should live: | Disk | Import | Runs on | Presigns? | | --- | --- | --- | --- | | `MemoryDisk` | `@shaferllc/keel/core` | anywhere | no (app-signed fallback) | | `localDisk` | `@shaferllc/keel/storage/local` | Node | no (app-signed fallback) | | `s3Disk` | `@shaferllc/keel/storage/s3` | Node + edge | **yes**, SigV4 | | `r2Disk` | `@shaferllc/keel/storage/r2` | Workers | no (app-signed fallback) | ### `localDisk` — the local filesystem ```ts import { localDisk } from "@shaferllc/keel/storage/local"; import { setDisk, serveStorage } from "@shaferllc/keel/core"; setDisk(localDisk({ root: "storage/app" })); this.use(serveStorage()); // hand the files out over HTTP ``` `root` resolves from the working directory. `baseUrl` (default `/storage`) is the prefix `url()` hands out — keep it in step with where you mount `serveStorage()`. The filesystem has nowhere to put an object's content type or custom metadata, so this disk stores what it can and infers the rest: `contentType` comes from the extension on read, `visibility` maps onto the file mode (`public` → 0644, `private` → 0600) and is read back from it, and `cacheControl` / `metadata` are accepted and ignored — set cache headers with `serveStorage({ maxAge })` instead. Paths that resolve outside `root` are refused, so a hostile upload filename can't walk up into the rest of the machine. ### `s3Disk` — S3, R2, MinIO, Spaces, B2 The one that presigns. It signs its own SigV4 requests over `fetch` and Web Crypto, imports no SDK, and runs unchanged on Node and the edge: ```ts import { s3Disk } from "@shaferllc/keel/storage/s3"; setDisk( s3Disk({ bucket: "uploads", region: "us-east-1", accessKeyId: env("AWS_ACCESS_KEY_ID"), secretAccessKey: env("AWS_SECRET_ACCESS_KEY"), }), ); ``` For R2, MinIO, or Spaces, give it the endpoint — the bucket then goes in the path rather than the hostname, which is what those expect: ```ts s3Disk({ bucket: "uploads", region: "auto", endpoint: `https://${accountId}.r2.cloudflarestorage.com`, accessKeyId: env("R2_ACCESS_KEY_ID"), secretAccessKey: env("R2_SECRET_ACCESS_KEY"), publicUrl: "https://cdn.example.com", // where `url()` points }); ``` Set `publicUrl` whenever you serve files directly: without it `url()` returns the signing endpoint, which is usually *not* publicly readable. `forcePathStyle` overrides the addressing choice, and `sessionToken` covers temporary STS credentials. Because the backend signs, `signedUrl()` and `signedUploadUrl()` are real presigned URLs rather than the app-key fallback — a browser `PUT`s straight to the bucket and the bytes never transit your app. An upload URL signs the content type too, so a URL minted for an image can't be reused to upload a script. `visibility` becomes a canned ACL (`public-read` / `private`), and only when you pass one — buckets with ACLs disabled (the modern S3 default, and R2 always) reject the header outright. ### `r2Disk` — a Cloudflare R2 binding When the app runs on Workers and the bucket is bound to it, the binding skips HTTP and auth entirely: ```jsonc // wrangler.jsonc "r2_buckets": [{ "binding": "BUCKET", "bucket_name": "uploads" }] ``` ```ts import { r2Disk } from "@shaferllc/keel/storage/r2"; setDisk(r2Disk(env.BUCKET, { publicUrl: "https://cdn.example.com" })); ``` The binding talks to R2 over Cloudflare's internal RPC, which has no notion of a presigned URL — so `signedUrl()` falls back to app-key signing (serve it with `serveStorage({ signed: true })`) and `signedUploadUrl()` throws. If you need direct browser uploads, use `s3Disk` against R2's S3 endpoint instead; it can presign because it signs its own requests. Nothing stops you registering both: ```ts setDisk(r2Disk(env.BUCKET), "r2"); // fast reads and writes from the Worker setDisk(s3Disk({ ... }), "uploads"); // presigned URLs for the browser ``` ## Writing a disk A disk is the `Disk` interface. Six methods are required — `put` / `get` / `exists` / `delete` / `list` / `url` — and the rest are **optional capabilities**: implement `metadata`, `copy`, `move`, `signedUrl`, or `signedUploadUrl` when your backend can do better than the generic fallback, and `Storage` will use them. ```ts import type { Disk } from "@shaferllc/keel/core"; const gcsDisk = (bucket: string): Disk => ({ async put(path, bytes, options) { /* … */ }, async get(path) { /* … return null when missing */ }, async exists(path) { /* … */ }, async delete(path) { /* … */ }, async list(prefix = "") { /* … */ }, url: (path) => `https://storage.googleapis.com/${bucket}/${path}`, }); ``` The three shipped disks are worth reading as worked examples — `src/storage/local.ts` for the filesystem shape, `src/storage/s3.ts` for a signing HTTP backend, and `src/storage/r2.ts` for a duck-typed platform binding. ## API reference ### `storage(name?)` `storage(name?: string): Storage` The default disk, or a named one registered with `setDisk(disk, name)`. Throws for an unknown name. ### `setDisk(disk, name?)` `setDisk(disk: Disk, name?: string): Storage` Registers a disk (default name `"default"`) and returns the wrapping `Storage`. ### `Storage` Wraps a `Disk`. | Method | Signature | |--------|-----------| | `put` | `(path, contents: string \| Uint8Array \| ArrayBuffer, options?: WriteOptions) => Promise` | | `get` | `(path) => Promise` | | `getText` | `(path) => Promise` | | `exists` / `delete` | `(path) => Promise` / `Promise` | | `list` | `(prefix?) => Promise` | | `metadata` | `(path) => Promise` | | `size` | `(path) => Promise` | | `copy` / `move` | `(from, to) => Promise` | | `url` | `(path) => string` — the public URL | | `signedUrl` | `(path, options?: SignedFileOptions) => Promise` | | `signedUploadUrl` | `(path, options?: SignedUploadOptions) => Promise` | | `driver` | the underlying `Disk` | ### `fakeDisk(name?)` / `restoreDisk(name?)` `fakeDisk(name?: string): FakeStorage` swaps a disk for an in-memory `FakeStorage`. `restoreDisk(name?)` puts the real one back — with no name, every faked disk. `FakeStorage` is a `Storage` plus `assertExists(path)`, `assertMissing(path)`, `assertContents(path, text)`, and `assertCount(n, prefix?)`. ### `serveStorage(options?)` `serveStorage(options?: ServeStorageOptions): MiddlewareHandler` Serves a disk's files over HTTP. Options: `disk` (name, default `"default"`), `basePath` (default `"/storage"`), `signed` (require a valid signature, 403 otherwise), `maxAge` (`Cache-Control` seconds). ### `signStorageUrl(url, expiresIn?)` / `verifyStorageUrl(url)` `signStorageUrl(url: string, expiresIn?: number): Promise` adds `expires` and `signature` params, signed with `config('app.key')` (default one hour). `verifyStorageUrl(url: string): Promise` checks them. Signing covers the path and query, not the host. ### `contentTypeFor(path)` `contentTypeFor(path: string): string` — the MIME type for a path's extension, or `application/octet-stream`. ### `MemoryDisk` `class MemoryDisk implements Disk` — in-memory, the default and ideal for tests. `new MemoryDisk(baseUrl?)` sets the `url()` prefix. Not shared across processes. ### `localDisk(options)` `localDisk(options: LocalDiskOptions): Disk` — from `@shaferllc/keel/storage/local`. `LocalDiskOptions` is `{ root, baseUrl?, publicMode?, privateMode? }`. `root` is required and resolves from the working directory; `baseUrl` defaults to `"/storage"`; the modes default to `0o644` and `0o600` and are how `visibility` is stored and read back. Implements `metadata`, `copy`, and `move`; no presigning. ### `s3Disk(options)` `s3Disk(options: S3DiskOptions): Disk` — from `@shaferllc/keel/storage/s3`. `S3DiskOptions` is `{ bucket, accessKeyId, secretAccessKey, region?, sessionToken?, endpoint?, forcePathStyle?, publicUrl?, fetch? }`. `region` defaults to `"auto"`; `endpoint` defaults to the AWS host for the region and, when set, flips `forcePathStyle` on. `publicUrl` is what `url()` returns. `fetch` overrides the global for tests or a Worker's bound fetcher. Implements every optional capability, presigning included. `list()` follows the ListObjectsV2 continuation token, so it doesn't truncate at 1000 keys. ### `r2Disk(bucket, options?)` `r2Disk(bucket: R2BucketLike, options?: R2DiskOptions): Disk` — from `@shaferllc/keel/storage/r2`. `bucket` is duck-typed against the R2 binding (`put` / `get` / `head` / `delete` / `list`), so no Cloudflare types are imported. `R2DiskOptions` is `{ publicUrl? }`, defaulting to `"/storage"`. Implements `metadata` and `copy`; `list()` follows the cursor. A binding can't presign, so `signedUrl()` falls back to app-key signing and `signedUploadUrl()` throws. ### Interfaces & types #### `Disk` The driver seam. Required: `put` / `get` / `exists` / `delete` / `list` / `url`. Optional capabilities: `metadata` / `copy` / `move` / `signedUrl` / `signedUploadUrl`. #### `WriteOptions` `{ contentType?, cacheControl?, visibility?, metadata? }` — passed to `put`. #### `FileMetadata` `{ size, contentType?, cacheControl?, visibility?, lastModified?, metadata? }`. #### `SignedFileOptions` / `SignedUploadOptions` `{ expiresIn? }` (seconds, default 3600), plus `contentType?` for uploads. #### `FileVisibility` `type FileVisibility = "public" | "private"`. #### `Contents` `type Contents = string | Uint8Array | ArrayBuffer` — accepted by `put`. --- # Teams Multi-tenancy, membership, roles, and invitations — where a row belongs to a team, and one team can never see another's. ```ts // bootstrap/providers.ts import { TeamsServiceProvider } from "@shaferllc/keel/teams"; app.register(TeamsServiceProvider); ``` ```ts // app/Http/Kernel.ts import { teamContext } from "@shaferllc/keel/teams"; protected middleware = [sessionMiddleware(), teamContext()]; ``` Then a tenant-owned model is one word: ```ts import { TenantModel } from "@shaferllc/keel/teams"; class Post extends TenantModel { static table = "posts"; } await Post.all(); // only the current team's posts await Post.create({ title: "Hi" }); // stamped with the current team ``` ## Isolation is the default, not a habit Two halves, and both matter. **Reads** are constrained by a global scope on `TenantModel`, so every query the model builds carries the team — including `find()`. Naming another team's row by its id returns `null`, not that row. This is the difference between tenancy and a list filter: a filter you forget on one endpoint is a leak; a scope you never write can't be forgotten. **Writes** are stamped by a `creating` hook, so a row cannot be born ownerless and end up visible to everyone (or to no one). ## No team means an error, not "everything" A queued job, a console command, a webhook, a seeder — none of them run inside a request, so none of them have a team. **A tenant query there throws.** ```ts await Post.all(); // Error: No team in context, so a tenant-scoped query can't be built safely. // Inside a request, add teamContext() to your middleware. // In a job, command, or seeder, wrap the work: runForTeam(team, () => …). // If it genuinely spans every team, say so: withoutTenant(() => …). ``` This is the security model, and the alternatives are worse: | If no team meant… | Then | | --- | --- | | *unscoped* | every background job sees every tenant's rows — this is how customer A's invoice reaches customer B | | `teamId = NULL` | jobs match nothing, "work" fine, and quietly do nothing for a month | | **an error** | a job that forgot **crashes in development** instead of leaking in production | So a job says which team it's for: ```ts await runForTeam(team, () => sendInvoices()); ``` ...or says, out loud, that it isn't for one: ```ts await withoutTenant(() => Post.withoutGlobalScope(TENANT_SCOPE).get()); ``` Both are named calls you can **grep for at audit time**. That's the point: crossing a tenant boundary should be something you typed, never something you arrived at by forgetting a `where`. > Your jobs will crash until each one is wrapped. That friction is the feature — it > is a loud failure in development in exchange for not having a silent one in > production. The context lives in `AsyncLocalStorage`, not a module global, so two concurrent requests can't see each other's team. ## Teams and membership ```ts const team = await createTeam("Acme", user.id); // creator becomes the owner await teamsFor(user.id); // the teams a user is in await roleOf(user.id, team.id); // "owner" | "admin" | "member" | null await memberOf(user.id, team.id, "admin"); await switchTeam(user.id, team.id); // false if they aren't a member ``` A user is in a team **if and only if a membership row says so**. `teams.owner_id` is a convenience, not an authorization source. `switchTeam()` verifies membership, and so does `teamContext()` on every request — `users.current_team_id` is just a number on a row the user can influence, so it is checked, never trusted. Without that, switching teams would be a matter of writing someone else's id onto your own row. `Team` and `Membership` are deliberately **not** tenant-scoped: "which teams am I in?" is a question you have to answer *before* you know which team you're in. ## Roles `owner` > `admin` > `member`, ordered — an owner can do anything an admin can. ```ts router.delete("/posts/:post", …).middleware(requireRole("admin")); ``` ```ts roleAtLeast("owner", "admin"); // true roleAtLeast("member", "admin"); // false ``` ## Invitations ```ts const { token } = await invite(team.id, "grace@example.com", "admin"); await acceptInvitation(token, user.id, user.email); await pendingInvitations(team.id); await revokeInvitation(id); ``` Unlike a password-reset link, an invitation **is** a database row — it has to be listable ("3 pending") and revocable, and you can't revoke a stateless token. Only the token's **hash** is stored, so a database leak doesn't open every pending team. The invited address is re-checked on accept, so a **forwarded link doesn't let someone else join** in the invitee's place — which is the interesting attack on an invitation system. Invitations are single-use, expire (72h by default), and re-inviting the same address replaces the outstanding invitation rather than stacking duplicates. ## Personal teams On by default: every new user gets a team of their own, and a solo user is simply a team of one. Worth leaving on even for an app that feels single-user. **Tenancy is not a feature you can add later** — bolting a `team_id` onto a schema that already has customer data means a backfill, a migration on every table, and rewriting every query. Ignoring a team you have costs one unused row. Needing a team you don't have costs a weekend. ## Configuration ```bash keel vendor:publish --tag teams-config ``` ```ts export default { userTable: "users", personalTeams: true, invitations: { expiresInHours: 72, url: "/invitations/:token" }, }; ``` ## The schema | Table | | | --- | --- | | `teams` | name, slug, owner_id | | `team_memberships` | team_id, user_id, role — **unique per (team, user)**, enforced by the database | | `team_invitations` | team_id, email, role, token **hash**, expires_at | Plus `current_team_id` on your users table. --- # Telemetry Distributed tracing — spans, W3C trace context, and an OTLP exporter — with **no SDK**. ```ts import { setTelemetry, Tracer, otlpExporter, tracing, trace } from "@shaferllc/keel/core"; setTelemetry( new Tracer({ serviceName: "api", exporter: otlpExporter({ url: "http://localhost:4318/v1/traces" }), sampleRatio: 0.1, // 10% of traces in production }), ); // in your HTTP kernel this.use(tracing()); // a server span per request ``` ```ts // your own spans, anywhere await trace("charge", async (span) => { span.setAttributes({ "order.id": order.id }); await stripe.charge(order); }); ``` ## Why there's no SDK here The OpenTelemetry Node SDK is a large tree of packages that assumes a Node process. What a trace actually **is**, though, is small: an id, a parent, a start and an end, some attributes — and a documented JSON shape to POST them in. That's what this is. It speaks **OTLP/HTTP over `fetch`**, so it runs on Workers as happily as on Node, and any OTLP collector accepts it — Jaeger, Tempo, Honeycomb, Grafana, Datadog. You don't get the SDK's auto-instrumentation of every library under the sun; you get the part that matters, in about 400 lines you can read. ## Spans `trace(name, fn)` opens a span, runs your function inside it, and closes it — even if the function throws, in which case the error is recorded on the span and rethrown. ```ts const receipt = await trace("charge", async (span) => { span.setAttributes({ "order.id": id, currency: "USD" }); span.addEvent("calling stripe"); return stripe.charge(id); // a throw here marks the span failed, then propagates }); ``` **Spans nest automatically.** A `trace()` inside another `trace()` becomes its child, sharing the trace id — you don't thread anything through: ```ts await trace("checkout", async () => { await trace("reserve-stock", async () => { … }); // child await trace("charge", async () => { … }); // sibling of the above }); ``` That works across `await` boundaries, and across **concurrent** traces, because the current span is tracked in `AsyncLocalStorage` rather than a global. Two requests in flight at once don't get tangled. From anywhere inside a span: ```ts setAttributes({ tenant: "acme" }); // add to the current span addEvent("cache miss", { key }); // a timestamped annotation currentSpan(); // the Span itself, or undefined ``` All of these are **no-ops outside a span**, so instrumented code stays safe to call from a script or a test. ## HTTP requests `tracing()` opens a **server span** per request, records the method, path, and status, and closes it when the response is sent. ```ts this.use(tracing()); ``` A 5xx marks the span failed; a 404 doesn't — that's a valid answer, not a fault. It also writes a `traceparent` header onto the **response**, so when a user says "this page was slow", you can look up their exact trace. `/health/*`, `/metrics`, and `/favicon.ico` are ignored by default — they're noise. Change that with `ignore`, and name spans yourself with `name`: ```ts this.use( tracing({ ignore: (path) => path.startsWith("/internal"), name: (method, path) => `${method} ${path.replace(/\/\d+/, "/:id")}`, }), ); ``` ## Following a trace between services This is the point of tracing: one id spanning every service a request touches. **Incoming.** `tracing()` reads the caller's `traceparent` header and makes your span a **child of theirs**, so both land in the same trace. A missing or malformed header just starts a fresh trace — never an error. **Outgoing.** `injectTraceContext()` puts the current context on your request headers, so the service you call joins this trace instead of starting its own: ```ts await fetch(url, { headers: injectTraceContext({ accept: "application/json" }), }); ``` `parseTraceparent()` and `traceparent()` are the two halves on their own, if you need to carry the context somewhere odd — a queue payload, say. ## Connecting logs to traces `traceIds()` returns the current `trace_id` and `span_id`. Bind them to your logger and every line becomes a jumping-off point into the trace it came from: ```ts const log = logger().child(traceIds()); log.info("charging card", { orderId }); // carries trace_id + span_id ``` ## Sampling Recording every trace in production is expensive. `sampleRatio` records a fraction: ```ts new Tracer({ sampleRatio: 0.1 }); // 10% ``` The decision is made **once, at the root span, and inherited by every child** — because half a trace is worse than no trace. An unsampled span still runs (your code is unaffected); it just isn't exported. ## Exporters | Exporter | Use | |----------|-----| | `otlpExporter({ url, headers, resource })` | Any OTLP/HTTP collector. Production. | | `consoleExporter()` | Prints each span. Local development, no collector needed. | | `MemoryExporter` | Collects spans in memory. Tests. | ```ts otlpExporter({ url: "https://api.honeycomb.io/v1/traces", headers: { "x-honeycomb-team": env("HONEYCOMB_KEY") }, resource: { "service.name": "api", "deployment.environment": "prod" }, }); ``` Spans are **buffered** and sent in batches (100 by default, via `batchSize`). **Flush before the process — or the isolate — goes away**, or the last few spans die with it: ```ts onShutdown(() => flushTelemetry()); ``` On Workers, call it at the end of a request (ideally inside `waitUntil`). ## Testing `MemoryExporter` collects spans so you can assert on them: ```ts import { Tracer, MemoryExporter, setTelemetry, trace } from "@shaferllc/keel/core"; const exporter = new MemoryExporter(); setTelemetry(new Tracer({ exporter, batchSize: 1 })); // batchSize 1 = export immediately await trace("charge", async (span) => span.setAttributes({ "order.id": 1 })); const span = exporter.named("charge")[0]; assert.equal(span.status, "unset"); assert.equal(span.attributes["order.id"], 1); ``` `exporter.trace(traceId)` returns every span in one trace; `exporter.clear()` empties it between tests. --- ## API reference ### `trace(name, fn, options?)` `trace(name: string, fn: (span: Span) => Promise | T, options?: SpanOptions): Promise` Run `fn` inside a span. The span is current for the duration, ends when `fn` settles, and records a throw before rethrowing it. ### `currentSpan()` / `setAttributes(attrs)` / `addEvent(name, attrs?)` Reach the span in scope. All no-ops outside one. ### `traceIds()` `traceIds(): { trace_id?: string; span_id?: string }` — the ids to hang on a log line. ### `tracing(options?)` `tracing(options?: TracingOptions): MiddlewareHandler` A server span per request. Options: `ignore(path)` (default: `/health`, `/metrics`, `/favicon.ico`), `name(method, path)`. ### `Tracer` `new Tracer(options: TracerOptions)` | Option | Meaning | |--------|---------| | `serviceName` | Added to every span as `service.name` | | `exporter` | Where finished spans go. Omit and nothing is exported | | `sampleRatio` | 0–1, decided once at the root. Default 1 | | `enabled` | `false` turns tracing off | | `resource` | Attributes describing the service, sent with each batch | | `batchSize` | Export once this many spans are buffered. Default 100 | Methods: `startSpan(name, options?)`, `trace(name, fn, options?)`, `flush()`. ### `Span` `setAttribute(k, v)` / `setAttributes(attrs)` / `addEvent(name, attrs?)` / `setStatus(status, message?)` / `recordException(error)` / `end()`, plus a `context` (`{ traceId, spanId, sampled }`). ### `setTelemetry(tracer)` / `telemetry()` / `flushTelemetry()` Register the active tracer, read it, and drain its buffer. ### Trace context `parseTraceparent(header)` → `SpanContext | null` (null on anything malformed). `traceparent(context)` → the header string. `injectTraceContext(headers?)` → headers with the current context added. ### Exporters `otlpExporter({ url, headers?, resource? })`, `consoleExporter()`, and `MemoryExporter` (`.spans`, `.named(name)`, `.trace(traceId)`, `.clear()`). ### Interfaces & types `SpanData`, `SpanContext`, `SpanEvent`, `SpanKind` (`internal | server | client | producer | consumer`), `SpanStatus` (`unset | ok | error`), `SpanAttributes`, `SpanExporter`, `TracerOptions`, `TracingOptions`, `OtlpOptions`. --- # Templates A string templating engine — `{{ }}` interpolation and `@`-prefixed tags for logic, includes, layouts, and components. Reach for it when you want plain-text templates instead of (or alongside) [JSX views](./views.md). Unlike engines that compile a template to a function with `eval` / `new Function`, Keel **interprets** templates against a small, safe expression evaluator. No dynamic code generation, so the same templates run on Node **and** on Cloudflare Workers (where `eval` is forbidden). ## Rendering Register a template by name, then render it with a state object: ```ts import { templates, render } from "@shaferllc/keel/core"; templates().register("greeting", "Hello, {{ name }}!"); await render("greeting", { name: "Ada" }); // "Hello, Ada!" ``` `render()` returns a `Promise` — hand it to a response or a [view](./views.md): ```ts import { html, render } from "@shaferllc/keel/core"; return html(await render("greeting", { name: "Ada" })); ``` Register many at once (e.g. a Node loader reads `.html` files and passes them in): ```ts templates().registerAll({ layout: await readFile("views/layout.html", "utf8"), home: await readFile("views/home.html", "utf8"), }); ``` ## Interpolation ```html {{ user.name }} {{-- escaped: HTML-safe --}} {{{ post.body }}} {{-- raw: unescaped, for trusted HTML --}} {{-- this is a comment; it renders nothing --}} ``` Escaped `{{ }}` is the default and encodes `& < > " '`. Use raw `{{{ }}}` only for HTML you trust. A `null`/`undefined` value renders as an empty string. ## Expressions Interpolation and tag conditions accept a practical subset of JavaScript — enough for real templates, without `eval`: ```html {{ user.name }} {{-- property + index access --}} {{ items[0] }} {{ title.toUpperCase() }} {{-- method calls --}} {{ items.join(", ") }} {{ price * qty }} {{-- + - * / % --}} {{ n > 3 && n < 10 }} {{-- comparisons, && || ! ?? --}} {{ admin ? "Admin" : "User" }} {{-- ternary --}} {{ [1, 2, 3].length }} {{-- array / object literals --}} {{ { role: "admin" }.role }} ``` Not supported: assignment, arrow functions, and other statement-level JS. Keep logic in your controller and pass results in as state. ### Filters Pipe a value through a filter with `|`: ```html {{ name | upper }} {{ name | capitalize }} {{ items | length }} {{ price | currency("USD") }} {{-- filters take arguments --}} ``` Built-in filters: `upper`, `lower`, `capitalize`, `json`, `length`. Register your own on the engine: ```ts templates().filter("currency", (v, code) => new Intl.NumberFormat("en-US", { style: "currency", currency: String(code) }).format(Number(v)), ); ``` ## Conditionals ```html @if(user.admin) Admin @elseif(user.member) Member @else Guest @end ``` ## Loops `@each` iterates arrays (or the values of an object). A `$loop` variable exposes positional info, and you can capture the index: ```html
    @each(post in posts)
  • {{ $loop.iteration }}. {{ post.title }}
  • @end
@each(item, i in items) {{ i }}: {{ item }} @end ``` `$loop` fields: `index` (0-based), `iteration` (1-based), `first`, `last`, `count`, `even`, `odd`. ## Partials Pull one template into another with `@include` — it shares the current state: ```html {{-- list.html --}}
    @each(item in items)@include("row")@end
{{-- row.html --}}
  • {{ item }}
  • ``` `@includeIf(condition, "name")` includes only when the condition is truthy. ## Layouts A page declares its layout and fills the layout's `@yield` slots with `@section`: ```html {{-- layout.html --}} @yield("title")Keel@end @yield("body")@end {{-- page.html --}} @layout("layout") @section("title"){{ page.title }} · Keel@end @section("body")

    {{ page.title }}

    @end ``` `@yield("name") … @end` renders the matching section, falling back to the content between `@yield` and `@end` when the page defines no such section. ## Components Components are reusable templates rendered with props and slots. The content between `@component` and its `@end` becomes the `main` slot; `@slot("name")` defines named slots. Inside the component, slots arrive as pre-rendered HTML strings on a `slots` object: ```html {{-- card.html --}}
    {{{ slots.header }}}
    {{{ slots.main }}}
    {{ title }}
    {{-- usage --}} @component("card", { title: "Welcome" }) @slot("header")

    Hi

    @end

    Body content goes to the main slot.

    @end ``` Props are any expression evaluating to an object; they become the component's state (merged with globals). ## Globals Expose values or helpers to every template: ```ts templates() .global("appName", "Keel") .global("asset", (path: string) => `/static/${path}`); ``` ```html {{ appName }} ``` ## Debugging `@dump(value)` renders a `
    ` of the value's JSON — handy while building a
    template.
    
    ```html
    @dump(user)
    ```
    
    ## Escaping & safety
    
    - Escaped `{{ }}` encodes HTML; only use raw `{{{ }}}` for trusted content.
    - The evaluator blocks access to `__proto__`, `constructor`, and `prototype`, so
      template state can't be used to reach the prototype chain.
    - There's no `eval`: a template can't execute arbitrary JavaScript, only the
      expression subset above.
    
    ## API reference
    
    ### `templates()`
    
    `templates(): TemplateEngine`
    
    Returns the default engine — register templates, globals, and filters on it.
    
    ```ts
    templates().register("home", "…");
    ```
    
    **Notes:** module-global and shared. Swap it with `setTemplateEngine()` (e.g. for
    an isolated engine in a test).
    
    ### `render(name, state?)`
    
    `render(name: string, state?: Record): Promise`
    
    Renders a registered template on the default engine.
    
    ```ts
    await render("home", { user });
    ```
    
    **Notes:** throws if `name` isn't registered. Async because includes, components,
    and layouts compose asynchronously.
    
    ### `setTemplateEngine(engine)`
    
    `setTemplateEngine(engine: TemplateEngine): TemplateEngine`
    
    Replaces the default engine and returns it.
    
    **Notes:** the last call wins; useful to reset state between tests.
    
    ### `escapeHtml(value)`
    
    `escapeHtml(value: unknown): string`
    
    HTML-escapes a value (`& < > " '`); `null`/`undefined` become `""`. This is what
    `{{ }}` uses internally.
    
    ### `TemplateEngine`
    
    The engine class. Construct your own for isolation, or use `templates()`.
    
    #### `register(name, source)`
    
    `register(name: string, source: string): this`
    
    Parses and registers a template. Chainable.
    
    **Notes:** parsing happens here, so a malformed template throws at registration,
    not at render.
    
    #### `registerAll(sources)`
    
    `registerAll(sources: Record): this`
    
    Registers many templates at once from a `name → source` map.
    
    #### `has(name)`
    
    `has(name: string): boolean`
    
    Whether a template is registered.
    
    #### `global(name, value)`
    
    `global(name: string, value: unknown): this`
    
    Exposes a value or function to every template as a global variable.
    
    #### `filter(name, fn)`
    
    `filter(name: string, fn: Filter): this`
    
    Registers a `{{ value | name }}` filter. `Filter` is
    `(value: unknown, ...args: unknown[]) => unknown`.
    
    #### `render(name, state?)`
    
    `render(name: string, state?: Record): Promise`
    
    Renders a registered template. Throws for an unknown template, tag, or filter.
    
    ### Interfaces & types
    
    #### `Filter`
    
    `type Filter = (value: unknown, ...args: unknown[]) => unknown`
    
    A pipe filter: receives the piped value plus any `filter(arg)` arguments, returns
    the transformed value.
    
    #### `RenderContext`
    
    `interface RenderContext { sections: Record; slots: Record }`
    
    Internal per-render state threaded through layouts and components — you won't
    construct it directly.
    
    
    
    ---
    
    
    
    # Testing
    
    Test your app by **injecting requests** — no server, no port, no network — and
    asserting on the response. `testClient()` wraps your app's Hono instance (which
    already does fetch-style injection) with verb helpers and fluent assertions.
    
    ## The client
    
    Build a client from an `Application` and fire requests:
    
    ```ts
    import { test } from "node:test";
    import { Application, Router, json, testClient } from "@shaferllc/keel/core";
    
    async function makeApp() {
      const app = new Application();
      await app.boot([], { discoverConfig: false, config: { app: {} } });
      app.make(Router).get("/health", () => json({ ok: true }));
      return app;
    }
    
    test("health check", async () => {
      const client = testClient(await makeApp());
      const res = await client.get("/health");
      res.assertStatus(200).assertJson({ ok: true });
    });
    ```
    
    `testClient()` accepts an **`Application`** (built through a fresh kernel), an
    **`HttpKernel`** (use this if you need global middleware registered with
    `kernel.use(...)`), or anything with a `request()` (a built Hono instance).
    
    ## Requests
    
    Verb helpers cover the common methods; `post` / `put` / `patch` take a body that's
    JSON-encoded automatically:
    
    ```ts
    await client.get("/users");
    await client.get("/users?active=true");
    await client.post("/users", { email: "a@b.com", name: "Ada" }); // sends JSON
    await client.put("/users/1", { name: "Grace" });
    await client.delete("/users/1");
    
    // full control — pass a RequestInit for headers, custom bodies, etc.
    await client.request("/users", { method: "POST", headers: { authorization: "Bearer x" }, body });
    ```
    
    ## The response
    
    Every call resolves to a `TestResponse`. The body is **pre-buffered**, so reads
    are synchronous and repeatable (no "body already consumed"):
    
    ```ts
    const res = await client.get("/user");
    res.status;            // 200
    res.header("content-type");
    res.text();            // the raw body
    res.json();      // parsed (sync — the body is already read)
    ```
    
    ## Assertions
    
    Assertions are chainable and throw a descriptive error (including the body) on
    mismatch:
    
    ```ts
    res.assertStatus(201);
    res.assertOk();                          // any 2xx
    res.assertJson({ id: 1, email });        // deep-equals the JSON body
    res.assertText("pong");
    res.assertHeader("content-type", "application/json");
    res.assertRedirect("/login");            // 3xx (+ optional Location)
    
    // chain them:
    (await client.post("/users", body)).assertStatus(201).assertJson({ id: 2, ...body });
    ```
    
    ## Testing with middleware
    
    When your test needs global middleware (sessions, request logging, auth), build
    the kernel yourself and hand it to `testClient`:
    
    ```ts
    const app = await makeApp();
    const kernel = new HttpKernel(app);
    kernel.use(sessionMiddleware());
    kernel.use(requestLogger());
    const client = testClient(kernel);
    ```
    
    ## Authenticated requests
    
    The client's `withX` methods return a **copy**, so a client configured once can be
    reused without leaking into other tests:
    
    ```ts
    const authed = client.withToken("tok_123"); // Authorization: Bearer tok_123
    
    await authed.get("/me");
    await client.get("/me"); // still anonymous
    ```
    
    | Method | Sends |
    |--------|-------|
    | `withToken(token)` | `Authorization: Bearer ` |
    | `withBasicAuth(user, pass)` | `Authorization: Basic ` |
    | `withHeader(name, value)` / `withHeaders({…})` | any header |
    | `withCookie(name, value)` / `withCookies({…})` | a `Cookie` header |
    | `acceptJson()` | `Accept: application/json` |
    
    ## Forms and uploads
    
    ```ts
    await client.form("/login", { email: "a@b.com", password: "s3cret" }); // url-encoded
    await client.multipart("/avatar", { file: new Blob([png]), name: "ada" }); // file upload
    ```
    
    ## More response assertions
    
    ```ts
    res.assertOk(); // 2xx
    res.assertCreated(); // 201
    res.assertNoContent(); // 204
    res.assertUnauthorized(); // 401
    res.assertForbidden(); // 403
    res.assertNotFound(); // 404
    res.assertUnprocessable(); // 422
    res.assertServerError(); // 5xx
    ```
    
    **`assertJsonContains` is a subset match** — the one you usually want. It pins the
    fields the test is about and ignores the rest, so adding a field to a response
    doesn't break twenty tests:
    
    ```ts
    res.assertJsonContains({ user: { email: "a@b.com" } });
    ```
    
    `assertJson` still deep-equals the whole body, when that's what you mean.
    
    ```ts
    res.assertSee("Welcome back"); // body contains
    res.assertDontSee("Sign up");
    
    res.assertHeader("content-type", "application/json");
    res.assertHeaderMissing("x-debug");
    
    res.assertCookie("session"); // was set
    res.assertCookie("session", "abc123"); // ...with this value
    res.assertCookieMissing("admin");
    
    res.dump(); // print status, headers, body — when you're stuck
    ```
    
    ### Validation
    
    A failed `validate()` returns a 422 with per-field errors, so a test can assert on
    the field rather than the message:
    
    ```ts
    const res = await client.post("/users", { email: "nope" });
    
    res.assertValidationErrors("email", "password");
    res.assertNoValidationError("name");
    ```
    
    ## Test doubles
    
    Keel's fakes swap out a real backend for a recording one, so a test can assert
    that something *would* have happened without it actually happening — no email
    sent, no card charged, no file uploaded.
    
    | Fake | Replaces | Assertions |
    |------|----------|------------|
    | [`fakeMail()`](./mail.md#in-tests) | the mailer | `assertSent`, `assertQueued`, … |
    | [`fakeQueue()`](./queues.md#in-tests) | the queue | `assertPushed`, `assertNothingPushed`, … |
    | [`fakeDisk()`](./storage.md#testing) | a storage disk | `assertExists`, `assertContents`, … |
    | [`events().fake()`](./events.md#testing) | the emitter | `assertEmitted`, `assertNotEmitted`, … |
    | [`hash.fake()`](./hashing.md) | PBKDF2 | — (just makes it fast) |
    
    ```ts
    const mailer = fakeMail();
    const queue = fakeQueue();
    
    await registerUser({ email: "ada@example.com" });
    
    mailer.assertQueued((m) => m.subject === "Welcome");
    queue.assertPushed(SendWelcome);
    ```
    
    For anything else, `swap()` replaces a container binding:
    
    ```ts
    swap(PaymentGateway, () => new FakeGateway());
    ```
    
    ### Spies
    
    The smallest double: a function that records how it was called.
    
    ```ts
    import { spy, spyOn, restoreSpies } from "@shaferllc/keel/core";
    
    const send = spy<[string], void>();
    notify(send);
    
    assert.equal(send.callCount, 1);
    assert.ok(send.calledWith("hello"));
    ```
    
    `spyOn` replaces a method on an object. It **calls through** by default — so you're
    observing, not stubbing — until you tell it otherwise:
    
    ```ts
    const charge = spyOn(gateway, "charge"); // still really charges
    charge.returns(receipt); // now it doesn't
    
    restoreSpies(); // put every spied method back
    ```
    
    ## Controlling time
    
    Testing "this token expires in an hour" shouldn't take an hour.
    
    ```ts
    import { freezeTime, timeTravel, restoreTime } from "@shaferllc/keel/core";
    
    freezeTime("2026-07-11T12:00:00Z");
    
    const token = await jwt.sign({ sub: "1" }, { expiresIn: "1h" });
    assert.ok(await jwt.verify(token)); // valid now
    
    timeTravel(61 * 60 * 1000); // an hour and a minute later
    assert.equal(await jwt.verify(token), null); // expired
    
    restoreTime();
    ```
    
    `freezeTime()` mocks `Date` and `Date.now()`. It does **not** mock timers — a
    `setTimeout` still fires on the real clock — and `new Date("2020-01-01")` still
    parses normally. Only "what time is it *now*" is frozen.
    
    ## Resetting state between tests
    
    Keel's fakes, disks, queues, and cache are process-global, so one test can leak
    into the next. `resetState()` puts it all back:
    
    ```ts
    import { resetState } from "@shaferllc/keel/core";
    
    afterEach(() => resetState());
    ```
    
    It restores every fake (mail, queue, disk, hash), unfreezes the clock, drops event
    listeners, empties the cache, and gives you a fresh lock store. It does **not**
    touch the database.
    
    For that, `truncate()`:
    
    ```ts
    afterEach(() => truncate("comments", "posts", "users")); // children before parents
    ```
    
    It deletes rows rather than rolling back a transaction, so it works on every driver
    (D1, Postgres, libSQL) instead of only the ones with savepoints.
    
    ## Database assertions
    
    Assert against the database directly, rather than through an endpoint:
    
    ```ts
    import { assertDatabaseHas, assertDatabaseMissing, assertDatabaseCount } from "@shaferllc/keel/core";
    
    await client.post("/users", { email: "ada@example.com" });
    
    await assertDatabaseHas("users", { email: "ada@example.com" });
    await assertDatabaseHas("users", { active: 1 }, 1); // exactly one match
    await assertDatabaseMissing("users", { email: "deleted@example.com" });
    await assertDatabaseCount("users", 1);
    await assertDatabaseEmpty("sessions");
    ```
    
    A failure tells you what it looked for and how many rows the table actually holds.
    
    ## Console tests
    
    Run a command in-process — no subprocess, so it's fast and you can assert on it:
    
    ```ts
    import { runCommand } from "@shaferllc/keel/core";
    import { run } from "@shaferllc/keel/cli";
    import { createApplication } from "../bootstrap/app.js";
    
    const result = await runCommand(() => run(["node", "keel", "routes"], { createApplication }));
    
    result
      .assertSucceeded() // exit code 0
      .assertOutputContains("GET  /users")
      .assertOutputMatches(/POST\s+\/users/);
    ```
    
    You pass the command **in**, because a command needs an *application*, and only your
    app knows how to build one. That's also why `run()` takes a `createApplication`
    factory rather than importing one. Anything that prints and sets an exit code works,
    so this is equally good for testing a function you wrote yourself.
    
    `console.log`/`warn` are captured as stdout and `console.error` as stderr; a
    command that *throws* is recorded as a failure rather than blowing up the test.
    
    `assertFailed()`, `assertExitCode(n)`, and `assertErrorContains(text)` cover the
    rest. `result.stdout`, `result.stderr`, and `result.exitCode` are there if you'd
    rather assert by hand.
    
    ## Browser tests
    
    Keel doesn't ship a browser driver — that's [Playwright](https://playwright.dev)'s
    job, and wrapping it would only put a thinner API in front of a better one.
    
    The test client injects requests *without a server*, which is what makes it fast;
    a browser needs a real one. Start the app on a port, point Playwright at it, and
    tear it down:
    
    ```ts
    import { serve } from "@hono/node-server";
    import { chromium } from "playwright";
    
    const server = serve({ fetch: new HttpKernel(app).build().fetch, port: 3001 });
    const browser = await chromium.launch();
    
    const page = await browser.newPage();
    await page.goto("http://localhost:3001/login");
    await page.fill("[name=email]", "ada@example.com");
    await page.click("button[type=submit]");
    await page.waitForURL("**/dashboard");
    
    await browser.close();
    server.close();
    ```
    
    Everything else on this page — the fakes, `freezeTime`, `resetState`, the database
    assertions — works the same in a browser test, because it's the same process.
    
    ## API reference
    
    ### `testClient(target)`
    
    `testClient(target: Application | HttpKernel | { request(...) }): TestClient`
    
    Builds a `TestClient`. An `Application` is built through a fresh `HttpKernel`; pass
    a kernel to register global middleware first.
    
    ### `TestClient`
    
    | Method | Signature |
    |--------|-----------|
    | `get` / `delete` | `(path, init?) => Promise` |
    | `post` / `put` / `patch` | `(path, body?, init?) => Promise` — body JSON-encoded |
    | `request` | `(path, init?) => Promise` — the low-level form |
    
    ### `TestResponse`
    
    Body pre-buffered; reads are synchronous.
    
    | Member | Notes |
    |--------|-------|
    | `status` | the response status |
    | `header(name)` | a response header, or `null` |
    | `text()` / `json()` | the body (raw / parsed) |
    | `assertStatus(n)` / `assertOk()` | status is `n` / any 2xx |
    | `assertJson(v)` | JSON body deep-equals `v` |
    | `assertText(s)` / `assertHeader(n, v)` | exact body / header match |
    | `assertRedirect(location?)` | 3xx, optionally to `location` |
    | `raw` | the underlying `Response` |
    
    All assertions return `this` (chainable) and throw on mismatch.
    
    
    
    ---
    
    
    
    # Transformers
    
    A model knows the database; a **transformer** knows the API. It's the
    presentation layer between the two — subclass `Transformer`, define one
    `transform()` that maps a value to the exact shape you expose, and get
    `item` / `collection` / `document` for free. No columns leak by accident, no
    relation triggers a surprise query, and the same shape renders everywhere.
    Edge-safe, like everything under it — a transformer leans on nothing but the
    value you hand it.
    
    ## Defining a transformer
    
    Subclass `Transformer` and implement `transform`. The generic is the type you
    map *from* (often a model); the return is a plain, JSON-ready object:
    
    ```ts
    import { Transformer, type Attributes } from "@shaferllc/keel/core";
    import { User } from "../app/Models/User.js";
    
    export class UserTransformer extends Transformer {
      transform(user: User): Attributes {
        return {
          id: user.id,
          name: user.name,
          joined: user.created_at,
        };
      }
    }
    ```
    
    Generate one with `keel make:transformer User` (→
    `app/Transformers/UserTransformer.ts`). Pass `--model Account` when the class name
    doesn't match the value it maps.
    
    ## Transforming
    
    Three methods cover every case — one, many, or a full response document:
    
    ```ts
    import { json } from "@shaferllc/keel/core";
    
    const users = new UserTransformer();
    
    json(users.item(user));        // one   → { id, name, joined } | null
    json(users.collection(list));  // many  → [{ … }, { … }]
    json(users.document(list, {    // wrapped, with meta
      meta: { total: list.length },
    }));                           // → { data: [{ … }], total }
    ```
    
    `item` returns `null` for a nullish value, so a not-found lookup passes straight
    through. `collection` maps each value through `transform`. `document` is what you
    usually hand back from a controller — it wraps the payload under a key (`data` by
    default) and merges any top-level `meta` (pagination, counts, links) beside it.
    
    ## Conditional fields
    
    `when` includes a key only when a condition holds — and *removes the key entirely*
    otherwise, so no `null` leaks into the payload:
    
    ```ts
    transform(user: User): Attributes {
      return {
        id: user.id,
        name: user.name,
        email: this.when(String(user.id) === this.viewerId, user.email), // only your own email
      };
    }
    ```
    
    For someone else's user, the response is simply `{ id, name }` — the `email` key
    is gone, not `null`. Pass a third argument to substitute a fallback instead of
    omitting, and pass a **thunk** to defer an expensive value until the condition is
    true:
    
    ```ts
    token: this.when(fresh, () => mintToken(user), null),  // null when not fresh
    ```
    
    To gate *several* keys at once, `mergeWhen` returns an object to spread — `{}`
    when the condition is false, so nothing is added:
    
    ```ts
    return {
      id: user.id,
      ...this.mergeWhen(user.admin, { role: user.role, permissions: user.permissions }),
    };
    ```
    
    Transformers pass the current viewer (or any context) through the constructor —
    they're plain instances:
    
    ```ts
    export class UserTransformer extends Transformer {
      constructor(private viewerId: string | null) {
        super();
      }
      // …use this.viewerId in transform()
    }
    
    json(new UserTransformer(auth().id()).collection(users));
    ```
    
    ## Nesting & relations
    
    Embed one transformer inside another by calling it inline — the seam composes:
    
    ```ts
    transform(post: Post): Attributes {
      return {
        id: post.id,
        title: post.title,
        author: new UserTransformer(this.viewerId).item(post.author),
      };
    }
    ```
    
    But for a [model](./models.md) relation, reach for `whenLoaded` — it includes the
    relation **only if it was eager-loaded**, so a transformer never fires a query
    behind your back:
    
    ```ts
    transform(user: User): Attributes {
      return {
        id: user.id,
        name: user.name,
        posts: this.whenLoaded(user, "posts", new PostTransformer()),
      };
    }
    ```
    
    `whenLoaded` reads the relation off the model (via the model's `getRelation`, set
    by [`Model.load`](./models.md)), and, if present, runs it through the transformer
    you pass — a `collection` for an array relation, an `item` for a single one. If
    the relation wasn't loaded, the key is omitted. Pass a plain function instead of a
    transformer to map it yourself:
    
    ```ts
    roles: this.whenLoaded(user, "roles", (roles) => roles.map((r) => r.name)),
    ```
    
    So the caller controls depth by choosing what to load:
    
    ```ts
    const users = await User.all();
    await User.load(users, "posts");                 // eager-load first
    json(new UserTransformer().collection(users));   // …then posts appear
    ```
    
    Without the `load`, the same transformer simply omits `posts` — no N+1, no
    surprise. See [Models](./models.md#eager-loading) for eager loading.
    
    ## Response documents
    
    `document` builds the envelope most JSON APIs return — a wrapped payload plus
    top-level metadata:
    
    ```ts
    const page = await User.all();
    return json(
      new UserTransformer().document(page, {
        meta: { total: page.length, page: 1 },
      }),
    );
    // { "data": [ … ], "total": 42, "page": 1 }
    ```
    
    Change the wrapper per class by setting `wrapKey`, or per call with the `key`
    option; set `key: null` to merge a single object's fields to the top level (meta
    included):
    
    ```ts
    class UserTransformer extends Transformer {
      wrapKey = "user"; // → { user: { … } }
    }
    
    new UserTransformer().document(user, { key: null, meta: { fetchedAt } });
    // { id, name, …, fetchedAt }
    ```
    
    `item` and `collection` return the **bare** shape (no wrapper) so they compose
    cleanly when nested; `document` is the one that wraps. Reach for `document` at the
    edge of a response and `item`/`collection` everywhere inside.
    
    ## In a controller
    
    The whole point is a controller that reads clean:
    
    ```ts
    export class UserController {
      async show(c: Ctx) {
        const user = await User.findOrFail(c.req.param("id"));
        return c.json(new UserTransformer(auth().id()).item(user));
      }
    
      async index(c: Ctx) {
        const users = await User.all();
        await User.load(users, "posts");
        return c.json(new UserTransformer(auth().id()).document(users));
      }
    }
    ```
    
    ## Related
    
    Transformers sit downstream of [Models](./models.md) — they shape what a model
    exposes without the model knowing about the API. They pair with the
    [request/response](./request-response.md) helpers (`json`) at the edge, and with
    [authentication](./authentication.md) when a field depends on the viewer.
    
    ---
    
    ## API reference
    
    ### `Transformer`
    
    The abstract base. Subclass it, set the generic to the value you map *from*, and
    implement `transform`. Instances are plain — pass request context (a viewer id, a
    locale) through the constructor.
    
    ```ts
    class UserTransformer extends Transformer {
      transform(user: User): Attributes {
        return { id: user.id, name: user.name };
      }
    }
    ```
    
    #### `transform(item)`
    
    `abstract transform(item: T): Attributes`
    
    Maps one value to its API shape — the only method a subclass must implement.
    Returns a plain object; use the helpers below to add fields conditionally.
    
    ```ts
    transform(user: User): Attributes {
      return { id: user.id, name: user.name };
    }
    ```
    
    **Notes:** called once per value by `item`/`collection`. Its result is *pruned*
    (any `when`/`whenLoaded`-omitted keys are stripped, recursively) before you see
    it, so an omitted key is truly absent — not `undefined`.
    
    #### `item(value)`
    
    `item(value: T | null | undefined): Attributes | null`
    
    Transforms a single value. A nullish value passes straight through as `null`.
    
    ```ts
    new UserTransformer().item(user);   // { id, name }
    new UserTransformer().item(null);   // null
    ```
    
    **Notes:** returns the **bare** shape (no `wrapKey` wrapper) — wrap with
    `document` when returning a response. `null` in, `null` out, so a `findOrNull`
    result needs no guard.
    
    #### `collection(values)`
    
    `collection(values: T[]): Attributes[]`
    
    Transforms an array, each value through `transform`.
    
    ```ts
    new UserTransformer().collection(await User.all());
    ```
    
    **Notes:** returns a bare array (no wrapper). Empty in, empty out. Combine with
    `Model.load` beforehand so any `whenLoaded` relations are present.
    
    #### `document(value, options?)`
    
    `document(value: T | T[] | null | undefined, options?: DocumentOptions): Attributes`
    
    Builds a response document: the transformed payload wrapped under a key, with
    optional top-level `meta`. An array becomes a list; anything else a single object.
    
    ```ts
    new UserTransformer().document(users, { meta: { total: users.length } });
    // { data: [ … ], total }
    ```
    
    **Notes:** the wrapper key is `options.key` if given, else the instance `wrapKey`
    (default `"data"`). With `key: null` a single object's fields merge to the top
    level alongside `meta`; an array with no key still gets a `data` home (meta can't
    share a level with a bare array).
    
    #### `wrapKey`
    
    `wrapKey: string | null`
    
    The key `document` wraps under by default. Override per subclass; `null` disables
    wrapping.
    
    ```ts
    class UserTransformer extends Transformer {
      wrapKey = "user";
    }
    ```
    
    **Notes:** defaults to `"data"`. Only consulted by `document` — `item` and
    `collection` never wrap.
    
    #### `when(condition, value, fallback?)`
    
    `protected when(condition: unknown, value: V | (() => V), fallback?: V): V`
    
    Include `value` when `condition` is truthy; otherwise **omit the key** — or use
    `fallback` if you pass one. `value` may be a thunk, evaluated only when the
    condition holds.
    
    ```ts
    email: this.when(isSelf, user.email),          // key vanishes for others
    token: this.when(fresh, () => mint(), null),   // null fallback, lazy value
    ```
    
    **Notes:** a helper for use inside `transform`. With no `fallback`, a false
    condition removes the key entirely (via a sentinel that pruning strips) rather
    than emitting `null`. The thunk form defers work you don't want to pay for when
    the field is hidden.
    
    #### `mergeWhen(condition, values)`
    
    `protected mergeWhen(condition: unknown, values: Attributes | (() => Attributes)): Attributes`
    
    The merge counterpart to `when` — returns `values` (spread several keys in) when
    `condition` holds, or `{}` when it doesn't.
    
    ```ts
    return { id: u.id, ...this.mergeWhen(u.admin, { role: u.role, flags: u.flags }) };
    ```
    
    **Notes:** meant to be spread (`...`). `values` may be a thunk, deferred until the
    condition is true. Use it when a *group* of fields appears together.
    
    #### `whenLoaded(model, name, map?)`
    
    `protected whenLoaded(model: unknown, name: string, map?: Transformer | ((value) => unknown)): V`
    
    Include a relation only if it was already loaded — **never fires a query**. Reads
    the relation off the model and, if present, runs it through `map` (a transformer
    or a function). Omits the key when it isn't loaded.
    
    ```ts
    posts: this.whenLoaded(user, "posts", new PostTransformer()),
    roles: this.whenLoaded(user, "roles", (rs) => rs.map((r) => r.name)),
    ```
    
    **Notes:** resolves the relation via the model's `getRelation` (set by
    `Model.load`) or a plain loaded property — a relation *method* is never mistaken
    for a value. With a `Transformer`, an array relation goes through `collection` and
    a single one through `item`. With no `map`, the raw loaded value is used.
    
    ### `Attributes`
    
    ```ts
    type Attributes = Record;
    ```
    
    The shape a transformer produces — a plain, JSON-ready object. `transform` returns
    one; so do `item` and `document`.
    
    ### `DocumentOptions`
    
    ```ts
    interface DocumentOptions {
      key?: string | null;   // wrap under this key; null disables. Defaults to wrapKey.
      meta?: Attributes;     // top-level fields merged beside the payload.
    }
    ```
    
    Controls `document`'s envelope. `key` overrides the instance `wrapKey` for one
    call; `meta` supplies pagination, counts, or links at the top level.
    
    ```ts
    new UserTransformer().document(users, { key: "records", meta: { total: 42 } });
    // { records: [ … ], total: 42 }
    ```
    
    
    
    ---
    
    
    
    # UI
    
    Keel ships a small design kit for server-rendered [views](./views.md): CSS
    tokens, named component styles, and Hono JSX components. Starters import it from
    `@shaferllc/keel/ui` — no second package.
    
    ```tsx
    import { Button, Field, Panel } from "@shaferllc/keel/ui";
    ```
    
    ## Stylesheet
    
    Import the kit once in your app CSS, then Tailwind for app-authored utilities:
    
    ```css
    /* resources/css/app.css */
    @import "@shaferllc/keel/ui/css";
    @import "tailwindcss";
    ```
    
    Build with the Tailwind CLI (as the starters do) into `public/assets/app.css`,
    and link that from your layout. The kit uses stable `.keel-*` classes so you do
    **not** need to `@source` `node_modules` for Tailwind scanning.
    
    ## Tokens
    
    The maritime default (Syne + IBM Plex, sea / ink / brass) lives in the kit CSS.
    Override any token after the import:
    
    ```css
    @import "@shaferllc/keel/ui/css";
    @import "tailwindcss";
    
    :root {
      --color-sea: #1a6b5c;
      --color-ink: #0a1218;
    }
    ```
    
    Tailwind v4 also sees the same values via `@theme`, so utilities like
    `text-ink` and `bg-sea` work in your own markup.
    
    ## Components
    
    | Component | Role |
    |-----------|------|
    | `Button` | `primary` / `ghost` / `sea`. Pass `href` to render an ``. |
    | `Field` | Styled text input; extra attrs pass through. |
    | `Panel` | Surface. `variant="auth"` / `"auth-wide"` for auth cards. |
    | `Notice` / `Alert` | Soft callout / danger box. |
    | `Brand` | Display-face wordmark. |
    | `Shell` / `ShellNav` / `ShellLinks` | App chrome column + header nav. |
    | `SectionLabel` / `Muted` / `RowForm` | Eyebrow, secondary text, inline form row. |
    | `Hero` / `HeroGlow` / `HeroInner` | Full-viewport welcome stage. |
    | `Grain` / `Rise` | Body grain overlay; staggered entrance. |
    
    ```tsx
    import { Button, Field, Panel, Alert } from "@shaferllc/keel/ui";
    
    export default function Login({ error }: { error: string | null }) {
      return (
        
          {error && {error}}
          
    ); } ``` Compose with your own layout — the kit does not own `` or the CSS link. Starters keep `resources/views/layout.tsx` for that. ## Escape hatches Prefer components. When you need a raw class on your own element: ```ts import { classes, cx } from "@shaferllc/keel/ui";
    Get started ``` `classes` mirrors every kit selector (`btnPrimary`, `field`, `shell`, …). ## What stays yours - Document shell (`layout.tsx`) and asset URL - Route-specific copy and forms - Auth page composition (`AuthShell` in the starters) - Extra Tailwind utilities for spacing, type scale, and one-off layout --- # URL Builder Generate URLs from **named routes** so paths live in one place. Name a route, then build its URL by name — with params and query strings — and never hardcode a path again. The URL builder lives on the `Router`. In an app you resolve it from the container (`app.make(Router)`); the examples below assume a `router` in scope. ## Building URLs ```ts router.get("/users/:id", [UserController, "show"]).name("users.show"); router.url("users.show", { id: 42 }); // "/users/42" router.url("users.show", { id: 42 }, { qs: { tab: "posts", page: 2 } }); // "/users/42?tab=posts&page=2" ``` Params are matched by name against the `:param` segments in the route path, and each value is `encodeURIComponent`-escaped, so slashes and spaces are safe: ```ts router.get("/files/:name", [FileController]).name("files.show"); router.url("files.show", { name: "a/b c.txt" }); // "/files/a%2Fb%20c.txt" ``` Query values are coerced to strings (numbers become their decimal form), so you can pass `{ page: 2 }` and get `page=2`. An empty `qs` (`{}`) adds no `?`. ### Optional params A trailing `:param?` segment is dropped when you don't pass it: ```ts router.get("/posts/:id?", [PostController, "show"]).name("posts.show"); router.url("posts.show", { id: 7 }); // "/posts/7" router.url("posts.show", {}); // "/posts" (optional segment stripped) ``` Any required `:param` you forget to supply is stripped too — so a missing param silently produces a shorter path rather than throwing. Pass every required param. ### Errors `url()` throws `No route named [name].` if no registered route carries that name. Names come from `.name()` / `.as()`, so name a route before you build its URL. ```ts router.url("nope"); // throws: No route named [nope]. ``` ## Signed URLs A signed URL carries a tamper-proof signature — perfect for one-off links (email confirmations, unsubscribe, downloads) where you want to trust the parameters without a database lookup. Signing uses `config('app.key')`, so set an `APP_KEY`: ```ts // generate (async — uses Web Crypto, works on Node and the edge) const url = await router.signedUrl("download", { id: 7 }); const expiring = await router.signedUrl("download", { id: 7 }, { expiresIn: 3600 }); ``` `signedUrl` builds the URL exactly like `url()`, appends any `qs` you pass, adds an `expires` timestamp when `expiresIn` is set, then HMAC-SHA256 signs the whole path-plus-query with the app key and appends a `signature` parameter. The result looks like: ``` /download/7?expires=1710000000&signature=8f3c… ``` Verify the incoming request in your handler or a middleware: ```ts show() { if (!(await router.hasValidSignature())) { return response.abort("Invalid or expired link", 403); } // …trusted params } ``` `hasValidSignature()` reads the current request, strips the `signature` parameter, re-signs the remaining path and query with the app key, and compares. It returns `false` if the signature is missing, the URL was tampered with, or an `expires` timestamp has passed. Both `signedUrl()` and `hasValidSignature()` throw `Signed URLs require config('app.key').` when no app key is configured — set `APP_KEY` before you use either. ## Parameter constraints & matchers Route params can be constrained with a regex so a URL only matches when the segment fits. A constraint is a `Matcher` — a `RegExp`, a raw source string, or a `{ match: RegExp }` object — passed to `.where()`: ```ts router.get("/users/:id", [UserController]).where("id", /\d+/); router.get("/p/:slug", [PostController]).where("slug", { match: /[a-z0-9-]+/ }); ``` The `matchers` export bundles the common patterns so you don't rewrite them: ```ts import { matchers } from "@shaferllc/keel/core"; router.get("/users/:id", [UserController]).where("id", matchers.number()); router.get("/t/:id", [TeamController]).where("id", matchers.uuid()); router.get("/p/:slug", [PostController]).where("slug", matchers.slug()); router.get("/c/:code", [CodeController]).where("code", matchers.alpha()); ``` The same helpers hang off the router instance as `router.matchers`, so you can reach them without a separate import. `matchers` is not part of URL *generation* — it shapes which URLs a route will *match* — but the two work together: build a URL with `url()` and it will satisfy the constraint if your params are the right shape. ## Notes - Signatures cover the path **and** query string, so changing any parameter invalidates the link. - The signing key must be stable and secret. Set `APP_KEY` to a long random value (and keep it out of source control). --- ## API reference ### Router (URL methods) You get the `Router` from the container (`app.make(Router)`); in tests it's constructed directly as `new Router(container)`. These three methods make up the URL-building surface. #### `url(name, params?, options?)` `url(name: string, params?: Record, options?: UrlOptions): string` Builds the path for a named route, substituting `:params` and appending an optional query string. ```ts router.url("users.show", { id: 42 }, { qs: { tab: "posts" } }); // "/users/42?tab=posts" ``` **Notes:** `params` defaults to `{}`, `options` to `{}`. Values are `encodeURIComponent`-escaped; query values are stringified. Optional (`:id?`) and any unsupplied required params are stripped from the path. Throws `No route named [name].` if the name is unknown. Each `:param` is replaced once, so a param that appears twice in a single path only substitutes its first occurrence — avoid repeating a param name in one route. #### `signedUrl(name, params?, options?)` `signedUrl(name: string, params?: Record, options?: SignedUrlOptions): Promise` Like `url()`, but HMAC-SHA256 signs the path-plus-query with `config('app.key')` and appends a `signature` parameter, yielding a tamper-proof link. ```ts const link = await router.signedUrl("download", { id: 7 }, { expiresIn: 3600 }); ``` **Notes:** async (Web Crypto — Node and edge). `expiresIn` is seconds from now; it adds an `expires` unix-second timestamp that is covered by the signature. Throws `Signed URLs require config('app.key').` if no app key is set. Reserve the `signature` and `expires` query keys — passing them via `options.qs` collides with the ones this method adds. #### `hasValidSignature()` `hasValidSignature(): Promise` Verifies the signature on the current request: re-signs the path and query (minus `signature`) and checks it matches, honoring any `expires` timestamp. ```ts if (!(await router.hasValidSignature())) { return response.abort("Invalid or expired link", 403); } ``` **Notes:** reads the ambient request, so call it inside a handler/middleware. Returns `false` when the `signature` param is absent, the recomputed HMAC differs, or `expires` is in the past. Throws `Signed URLs require config('app.key').` if no app key is set. ### `matchers` An object of built-in parameter-constraint patterns. Each is a zero-arg function returning a fresh `RegExp`, suitable as the `Matcher` argument to `.where()`. Also exposed on the router as `router.matchers`. #### `matchers.number()` `number(): RegExp` Matches one or more digits — `/\d+/`. ```ts router.get("/users/:id", [UserController]).where("id", matchers.number()); ``` #### `matchers.uuid()` `uuid(): RegExp` Matches a canonical 8-4-4-4-12 hex UUID (case-insensitive). ```ts router.get("/t/:id", [TeamController]).where("id", matchers.uuid()); ``` #### `matchers.slug()` `slug(): RegExp` Matches a lowercase slug — `[a-z0-9]+` groups joined by single hyphens (`/[a-z0-9]+(?:-[a-z0-9]+)*/`). ```ts router.get("/p/:slug", [PostController]).where("slug", matchers.slug()); ``` #### `matchers.alpha()` `alpha(): RegExp` Matches one or more ASCII letters — `/[a-zA-Z]+/`. ```ts router.get("/c/:code", [CodeController]).where("code", matchers.alpha()); ``` ### Interfaces & types #### `UrlOptions` ```ts interface UrlOptions { qs?: Record; } ``` The options bag for `url()`. `qs` becomes the query string; each value is stringified. Use it to tack a query onto a generated URL. ```ts const opts: UrlOptions = { qs: { page: 2, tab: "posts" } }; router.url("users.show", { id: 1 }, opts); ``` #### `SignedUrlOptions` ```ts interface SignedUrlOptions extends UrlOptions { /** Expiry in seconds from now. */ expiresIn?: number; } ``` Extends `UrlOptions` with `expiresIn` for `signedUrl()`. With `expiresIn` set, the signed link stops validating after that many seconds. ```ts const opts: SignedUrlOptions = { qs: { plan: "pro" }, expiresIn: 3600 }; await router.signedUrl("download", { id: 7 }, opts); ``` #### `Matcher` ```ts type Matcher = RegExp | string | { match: RegExp }; ``` A route-parameter constraint accepted by `.where()`: a `RegExp`, a raw regex *source* string, or a `{ match: RegExp }` wrapper. The `matchers` helpers return the `RegExp` form. ```ts const a: Matcher = /\d+/; const b: Matcher = "[0-9]+"; const c: Matcher = { match: /[a-z-]+/ }; router.get("/x/:id", [XController]).where("id", a); ``` --- # Validation `validate()` parses request input against a schema and returns typed data. If the input is invalid it throws a `ValidationException`, which the HTTP kernel renders as a `422` with per-field errors — no manual checking. ## Bring a schema library Keel's `validate()` is schema-agnostic: it works with any schema that has a Zod-style `safeParse`. [Zod](https://zod.dev) is the recommended choice — the framework never bundles it, so install it in your app: ```bash npm install zod ``` Nothing about `validate()` is Zod-specific: it only ever calls `schema.safeParse(input)` and reads back `.success`, `.data`, and `.error.issues`. Anything that mirrors that shape (see the [`Schema`](#schemat) type) works — including a hand-rolled validator or a mock in a test. ## Validating a request body Call `validate(schema)` with no data and it parses the JSON body. The result is fully typed from the schema: ```ts import { json, validate } from "@shaferllc/keel/core"; import { z } from "zod"; const NewUser = z.object({ email: z.string().email(), age: z.number().min(18), }); export class UserController { async store() { const data = await validate(NewUser); // { email: string; age: number } return json({ created: data.email }, 201); } } ``` Invalid input never reaches your logic — it becomes a 422: ```jsonc // POST /users { "email": "nope", "age": 15 } { "error": "The given data was invalid.", "status": 422, "errors": { "email": ["Invalid email address"], "age": ["Too small: expected number to be >=18"] } } ``` ## Validating other input Pass data explicitly to validate anything — query strings, params, config. `validate()` is `async` in **both** forms, so `await` it even when you hand it data directly: ```ts import { validate, request } from "@shaferllc/keel/core"; const Search = z.object({ q: z.string().min(1), page: z.coerce.number().default(1) }); async function search() { const { q, page } = await validate(Search, request.query()); // … } ``` The rule is simple: if the second argument is anything other than `undefined`, `validate()` parses that value; if it's omitted, it awaits the JSON body. Because the check is `data !== undefined`, passing `null` counts as "explicit data" — the schema sees `null`, not the body. ## Declarative validation (before the handler) `validate()` above is *imperative* — you call it inside the handler. For the common case, `validateRequest()` is a middleware that validates the request **before** the handler runs, rejecting a bad request with a `422` so your handler only ever sees valid input: ```ts import { validateRequest, validated } from "@shaferllc/keel/core"; const NewUser = z.object({ email: z.string().email(), name: z.string().min(1) }); router .post("/users", [Users, "store"]) .middleware([validateRequest({ body: NewUser })]); // in Users@store — guaranteed valid, fully typed: const user = validated>("body"); ``` Validate `body`, `query`, and `params` together — errors from every part are aggregated into one `422`, keyed `body.field` / `query.field` / `params.field`: ```ts router.get("/posts/:id", [Posts, "show"]).middleware([ validateRequest({ params: z.object({ id: z.coerce.number() }), query: z.object({ page: z.coerce.number().min(1).default(1) }), }), ]); // validated<{ id: number }>("params"); validated<{ page: number }>("query"); ``` `validated(part)` returns the parsed, typed value for that part (defaults to `"body"`). Coercion (`z.coerce.number()`) is the schema's job — it applies before your handler sees the value. Built on the same `validate()` engine. ## Body parsing is JSON-only The no-argument form reads the body with `body()`, which calls `request.json()`. That means `validate(schema)` expects a JSON request body; a form-encoded or empty body will reject at JSON parse time before the schema even runs. If you need to validate merged query + form input, pass it in explicitly: ```ts import { validate, request } from "@shaferllc/keel/core"; // request.all() merges the query string with the parsed body (JSON or form) const data = await validate(NewUser, await request.all()); ``` ## The error shape On failure `validate()` walks `error.issues` and folds them into a `Record` — one array of messages per field: - Each issue's `path` is joined with `.` into a key: a nested path `["address", "zip"]` becomes `"address.zip"`. - Symbol path segments use their `.description`; everything else is stringified. - A **root-level** issue (empty path) is keyed `"_"`. - Multiple issues on the same path accumulate in that field's array. ```jsonc // nested + root-level errors { "errors": { "address.zip": ["Invalid postal code"], "_": ["Passwords do not match"] } } ``` That map is exactly what `ValidationException.errors` carries. ## Handling errors yourself `ValidationException` carries the field errors, so a custom error handler (see [Errors](./errors.md)) can format them however you like: ```ts import { ValidationException } from "@shaferllc/keel/core"; if (err instanceof ValidationException) { return response.json({ fields: err.errors }, 422); } ``` --- ## API reference ### `validate(schema, data?)` `validate(schema: Schema, data?: unknown): Promise` Parses `data` (or the JSON request body, when `data` is omitted) against `schema` and resolves to the typed value, throwing `ValidationException` on failure. ```ts import { validate } from "@shaferllc/keel/core"; import { z } from "zod"; const NewUser = z.object({ email: z.string().email(), age: z.number().min(18) }); const fromBody = await validate(NewUser); // parses request.json() const fromData = await validate(NewUser, { email, age }); // parses the given value ``` **Notes:** always returns a `Promise`, in both forms — `await` it even when you pass data. The body form calls `body()` (`request.json()`), so it expects a JSON body. The "use my data" branch triggers on `data !== undefined`, so `null` is treated as explicit input (the schema sees `null`). On failure it throws `ValidationException` whose `errors` is a `Record` keyed by dotted field path (root-level issues key `"_"`); it never returns a partial result. `T` is inferred from the schema, so the resolved value is fully typed. ### `validateRequest(schemas)` `validateRequest(schemas: RequestSchemas): MiddlewareHandler` Middleware that validates `body` / `query` / `params` before the handler, throwing a `422` `ValidationException` (errors from all parts aggregated, keyed `part.field`). On success the parsed values are stashed for `validated()`. ```ts router.post("/users", [Users, "store"]).middleware([validateRequest({ body: NewUser })]); ``` **Notes:** validates every declared part and aggregates their errors, rather than failing on the first. `body` reads the JSON body; `query`/`params` read the URL. Coercion is the schema's responsibility. ### `validated(part?)` `validated(part?: "body" | "query" | "params"): T` The parsed, typed value for a request part (default `"body"`), set by `validateRequest`. ```ts const user = validated>("body"); ``` **Notes:** throws if that part wasn't validated (no `validateRequest` for it), or if called outside a request. ### Interfaces & types #### `Schema` ```ts interface Schema { safeParse(data: unknown): | { success: true; data: T } | { success: false; error: { issues: ReadonlyArray<{ path: PropertyKey[]; message: string }> }; }; } ``` The minimal contract `validate()` needs from a schema — a single `safeParse` that returns a discriminated `success` union. Zod's `z.object({...})` satisfies it out of the box, which is why you normally never write this type by hand. Implement it yourself only to plug in a different validator or a test stub: ```ts import { validate, type Schema } from "@shaferllc/keel/core"; const Positive: Schema = { safeParse: (data) => typeof data === "number" && data > 0 ? { success: true, data } : { success: false, error: { issues: [{ path: [], message: "must be > 0" }] } }, }; const n = await validate(Positive, 42); // number ``` **Notes:** `validate()` only reads `success`, `data`, and `error.issues` (each issue's `path` and `message`). It ignores every other field a real Zod result carries, so any object matching this shape is enough. An issue with an empty `path` (as above) lands under the `"_"` key in the thrown errors. #### `ValidationException` `new ValidationException(errors: Record, message?: string)` The 422 exception `validate()` throws on failure; re-exported from the framework's [HTTP exceptions](./errors.md). You rarely construct it — you catch it. ```ts import { ValidationException } from "@shaferllc/keel/core"; try { await validate(NewUser); } catch (err) { if (err instanceof ValidationException) { err.status; // 422 err.errors; // Record, per-field messages } } ``` **Notes:** extends `HttpException` with `status` fixed at `422` and a default `message` of `"The given data was invalid."`. The per-field `errors` map is the one `validate()` built from the schema's issues. See [Errors](./errors.md) for how the kernel renders it and how to override that. --- # Views Keel renders HTML with **Hono JSX** — type-safe components that run identically on Node and on Cloudflare Workers (no filesystem templating, so it ports anywhere). Views live by convention in `resources/views/`. For a ready-made look — tokens, buttons, fields, panels — see [UI](./ui.md) (`@shaferllc/keel/ui`). Starters already import it. ## A view is a component ```tsx // resources/views/welcome.tsx // @jsxRuntime automatic // @jsxImportSource hono/jsx import type { FC } from "hono/jsx"; import { Layout } from "./layout.js"; export const WelcomePage: FC<{ appName: string }> = ({ appName }) => (

    ⚓ {appName}

    Your view is rendering.

    ); ``` > **The two pragma comments at the top are required** on every `.tsx` file. They > tell the compiler (tsx / esbuild / wrangler) to use Hono's JSX runtime instead > of React. Without them you'll get `ReferenceError: React is not defined`. ## Layouts are just components Composition is the layout system — a `Layout` component wraps its `children`: ```tsx // resources/views/layout.tsx // @jsxRuntime automatic // @jsxImportSource hono/jsx import type { FC, PropsWithChildren } from "hono/jsx"; export const Layout: FC> = ({ title, children, }) => ( {title} {children} ); ``` ## Rendering a view The quickest way is the global `view()` helper — pass the component and its props in one call. Props are type-checked against the component, and the result is a complete HTML document (doctype included) you can return straight from a route handler: ```ts import { config, view } from "@shaferllc/keel/core"; import type { Ctx } from "@shaferllc/keel/core"; import { WelcomePage } from "../../resources/views/welcome.js"; export class HomeController { welcome(c: Ctx) { return view(WelcomePage, { appName: config("app.name", "Keel") }); } } ``` For a component with no props, just pass the component: `view(HomePage)`. Note the view file is imported with a `.js` extension (TypeScript convention) even though the source is `.tsx`. ### The long form `view()` is sugar over the `View` service. You can resolve it yourself: ```ts import { View } from "@shaferllc/keel/core"; // inside a controller with the container as `this.app`: return this.app.make(View).render(WelcomePage({ appName: "Keel" })); ``` ## The View service `View` is bound as a singleton in the container. | Method | Purpose | |--------|---------| | `render(content)` | Render a component / string / promise to a full HTML document (async) | `render()` accepts a JSX node, a raw string, a promise of either, or `null` (which renders just the doctype). Configure it by binding your own instance: ```ts this.app.singleton(View, () => new View({ doctype: false })); ``` ## Passing data Props are the data channel — plain typed function arguments: ```ts this.app.make(View).render(UserProfile({ user, posts })); ``` No magic globals, no separate "view data" bag: if a component needs something, it's a prop. ## Async views `render()` awaits its content, so a component may be `async` (or use Hono's ``) — do data-fetching inside the component and return the resolved tree. Both the sync and async cases go through the same call: ```tsx // resources/views/dashboard.tsx // @jsxRuntime automatic // @jsxImportSource hono/jsx import type { FC } from "hono/jsx"; export const Dashboard: FC<{ userId: number }> = async ({ userId }) => { const stats = await loadStats(userId); return
    {JSON.stringify(stats)}
    ; }; ``` The helper renders it the same way: `return view(Dashboard, { userId })`. The returned `Promise` doesn't resolve until the component's own promises do. ## The doctype By default `render()` prepends `\n` — the output is a complete document ready to serve. Two things to know: - Passing `null`/`undefined` renders **just** the doctype (an empty document shell), not an empty string. - For fragments — an HTMX swap, an email partial, anything that isn't a standalone page — bind a `View` with the doctype off: ```ts this.app.singleton(View, () => new View({ doctype: false })); ``` Now `render()` returns exactly the component's HTML, no prefix. ## Why JSX (and not a file-based template engine)? File-based template engines need to read templates from disk at runtime, which doesn't work on edge runtimes like Cloudflare Workers. JSX components compile to plain functions, so the exact same view code runs on your Node dev server and on a Worker in production. That portability is what lets Keel's own website be a Keel app deployed to Cloudflare. ## Related Views are what a [controller](./controllers.md) returns; wire them to URLs in [routing](./routing.md). For sending HTML by email rather than over HTTP, the same components feed [mail](./mail.md). --- ## API reference ### `view(component, props?)` `view

    (component: (props: P, ...rest: any[]) => Renderable, props: P): Promise` `view(component: (...rest: any[]) => Renderable): Promise` Renders a component through the container's `View` service in one call, returning a complete HTML document. The props overload type-checks `props` against the component's own prop type. ```ts import { view } from "@shaferllc/keel/core"; return view(WelcomePage, { appName: "Keel" }); // component with props return view(HomePage); // component with no props ``` **Notes:** resolves the singleton `View` from the active application, so it throws `No Keel application has been bootstrapped…` if called before an `Application` exists. It calls the component as `component(props)` and renders the result — meaning it invokes the function directly rather than through JSX, so pass the component itself, not ``. Returns a `Promise`; return it straight from a route handler. ### `View` The rendering service. Bound as a singleton in the container, so you normally reach it via the `view()` helper or `app.make(View)`. Construct one yourself only to change its config (e.g. disabling the doctype). #### `new View(config?)` `new View(config?: ViewConfig): View` Creates a view renderer. With no argument the doctype is on. ```ts import { View } from "@shaferllc/keel/core"; const fragments = new View({ doctype: false }); ``` **Notes:** rebind the singleton to install a custom instance app-wide: `app.singleton(View, () => new View({ doctype: false }))`. #### `render(content)` `render(content: Renderable): Promise` Renders a component, string, or promise to an HTML string, awaiting any async tree first. ```ts await new View().render(WelcomePage({ appName: "Keel" })); await new View().render("

    plain html

    "); await new View({ doctype: false }).render(Fragment({})); ``` **Notes:** `await`s `content`, then `String()`s the result — so an async component or a `Promise` resolves before rendering, and a JSX node collapses to its HTML. `null`/`undefined` renders just the doctype (or the empty string when `doctype: false`). Pass the *called* component (`WelcomePage(props)`), not JSX (``), when invoking `render` directly. ### Interfaces & types #### `Renderable` ```ts type Renderable = | string | Promise | { toString(): string | Promise } | null | undefined; ``` Anything `render()` accepts. Covers a raw HTML string, a promise of one, any object with a `toString()` (which is what a Hono JSX node is), or nullish (renders empty). It matches the return type of a Hono function component, so components drop straight in. ```ts const a: Renderable = "

    hi

    "; const b: Renderable = Promise.resolve("

    hi

    "); const c: Renderable = null; ``` #### `ViewConfig` ```ts interface ViewConfig { doctype?: boolean; // default true } ``` The options bag for `new View(...)`. Set `doctype: false` to stop prepending `` — use it for fragments and partials. ```ts const config: ViewConfig = { doctype: false }; ``` --- # Vite Wire a modern frontend build — bundling, hashed filenames, hot module reload — to Keel's server-rendered HTML, the way modern full-stack frameworks do. There are two halves: - **`keelVite()`** — a plugin for `vite.config.ts` (from `@shaferllc/keel/vite`). It configures the build and, while the dev server runs, writes a `public/hot` marker file. - **`Vite`** — a server service (from `@shaferllc/keel/core`) that renders the ` ``` For production, build once — `vite build` writes hashed files and `public/assets/.vite/manifest.json` — and the same call renders the manifest's output, with the CSS extracted to a `` and imported chunks preloaded: ```html ``` Serve those built files with the [static middleware](./static-files.md) pointed at `public/` (Keel's default) — a request for `/assets/app-abc123.js` maps to `public/assets/app-abc123.js`: ```ts this.use(serveStatic({ root: "./public" })); ``` ## Multiple entrypoints Each entrypoint produces its own bundle. List them in the config and tag whichever a page needs — shared vendor chunks are preloaded once, deduplicated: ```ts keelVite({ entrypoints: ["resources/js/app.ts", "resources/js/admin.ts"] }); ``` ```tsx {viteTags(["resources/js/app.ts", "resources/js/admin.ts"])} ``` ## React (and other frameworks) Add the React plugin to `vite.config.ts` and keep `viteReactRefresh()` before `viteTags()` in your layout — it injects the Fast Refresh preamble in development and renders nothing in production: ```ts import react from "@vitejs/plugin-react"; export default defineConfig({ plugins: [keelVite({ entrypoints: ["resources/js/app.tsx"] }), react()], }); ``` This is exactly what the [Inertia](./inertia.md) adapter's root view uses to load its client bundle. ## Serving from a CDN Set `assetsUrl` to your CDN base in **both** the plugin and the service, then upload `public/assets` after building: ```ts keelVite({ entrypoints: ["resources/js/app.ts"], assetsUrl: "https://cdn.example.com" }); new Vite({ entrypoints: ["resources/js/app.ts"], assetsUrl: "https://cdn.example.com" }); ``` Production tags then point at `https://cdn.example.com/app-abc123.js`. ## Custom tag attributes Pass `scriptAttributes` / `styleAttributes` to add attributes to the generated tags — as a static object, or a function that decides per asset: ```ts new Vite({ entrypoints: ["resources/js/app.ts"], scriptAttributes: { defer: true, crossorigin: "anonymous" }, styleAttributes: ({ src }) => src.includes("admin") ? { "data-turbo-track": "reload" } : undefined, }); ``` A `true` value renders a bare attribute (`defer`); `false`/`undefined` drops it. ## On the edge There's no filesystem on Workers, so skip `loadFromDisk` and hand the bundled manifest straight in — bundle `manifest.json` as a JSON import and pass it to `useManifest`: ```ts import manifest from "../public/assets/.vite/manifest.json"; singleton(Vite, () => new Vite({ entrypoints: ["resources/js/app.ts"] }).useManifest(manifest)); ``` Tag generation from there is pure and edge-safe. ## Related Vite pairs with [views](./views.md) (the JSX layout that renders the tags), [static files](./static-files.md) (serving the build in production), and [Inertia](./inertia.md) (whose root view loads the client bundle through it). --- ## API reference ### `keelVite(options)` — `@shaferllc/keel/vite` `keelVite(options: KeelViteOptions): Plugin[]` The build-time plugin for `vite.config.ts`. Configures the manifest, output directory, entrypoints, and `base`, and manages the `public/hot` dev marker. ```ts export default defineConfig({ plugins: [keelVite({ entrypoints: ["resources/js/app.ts"] })], }); ``` **Notes:** returns an array (spread into `plugins`). Sets `build.manifest`, `build.outDir` (= `buildDirectory`), a flat `build.assetsDir`, and `rollupOptions.input`; `base` is `assetsUrl` for a build and `/` for the dev server. It leaves any of these alone if you set them yourself. Throws if `entrypoints` is empty. #### `KeelViteOptions` ```ts interface KeelViteOptions { entrypoints: string | string[]; // required — one bundle per entry buildDirectory?: string; // default "public/assets" (match the service) hotFile?: string; // default "public/hot" assetsUrl?: string; // default "/assets" (or a CDN base) reload?: string[]; // globs that trigger a full page reload } ``` `reload` globs support `*`, `**`, and `?`. A change to a matching file sends a `full-reload` to the browser — useful for server-rendered views Vite doesn't otherwise watch. ### `Vite` — `@shaferllc/keel/core` The server service. Bind it as a singleton, `loadFromDisk()` at boot, and render its tags from your views (usually through the `viteTags` helper). ```ts const vite = await new Vite({ entrypoints: ["resources/js/app.ts"] }).loadFromDisk(); ``` #### `new Vite(options?)` `new Vite(options?: ViteOptions)` Constructs the service. All options are optional; sensible defaults match the plugin. ```ts new Vite({ entrypoints: ["resources/js/app.ts"], assetsUrl: "/assets" }); ``` #### `loadFromDisk()` `loadFromDisk(): Promise` Reads the hot file (dev) or the build manifest (prod) from disk. Node only — imports `node:fs` dynamically. Call once at boot. ```ts async boot() { await this.app.make(Vite).loadFromDisk(); } ``` **Notes:** if neither a hot file nor a manifest exists yet, it resolves anyway; the clear error is raised later, when tags are actually generated. In dev it re-checks the hot file on each render, so starting the dev server after the app still works. #### `useManifest(manifest)` `useManifest(manifest: Manifest): this` Injects a manifest directly instead of reading disk — the edge path. ```ts new Vite({ … }).useManifest(manifest); ``` #### `useHotUrl(url)` `useHotUrl(url: string | null): this` Forces the dev-server URL (or `null` for production), bypassing the hot file. ```ts new Vite({ … }).useHotUrl("http://localhost:5173"); ``` #### `generateEntryPointsTags(entrypoints?)` `generateEntryPointsTags(entrypoints?: string | string[]): HtmlEscapedString` The `