@shaferllc/keel 0.81.1 → 0.82.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2095 @@
1
+ # Changelog
2
+
3
+ All notable changes to Keel are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project aims to
5
+ adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [0.81.1] — 2026-07-12
8
+
9
+ ### Documentation
10
+
11
+ - **Full API-reference entries for the new ORM surface.** `0.81.0` shipped the
12
+ guides; this fills in the per-method reference the docs maintain for everything
13
+ else — the query-builder additions (`join`/`leftJoin`, `groupBy`/`having`/
14
+ `distinct`, `whereColumn`/`whereRaw`/`orderByRaw`, `when`, `increment`/
15
+ `decrement`, `upsert`/`insertOrIgnore`, `chunk`), the migration builders
16
+ (`index`/`foreign`/`alterTable`, `AlterTableBuilder`, `ForeignKeyBuilder`), and
17
+ the model additions (`with`/`withCount`/`whereHas`/`has`/`doesntHave`,
18
+ `ModelQuery`, lifecycle events + `observe`, global scopes, soft deletes,
19
+ `hidden`/`visible`/`appends`, `morphMany`/`morphOne`/`morphTo`/
20
+ `registerMorphType`). Corrects two now-stale notes ("no soft-delete built in",
21
+ "nested eager loading isn't here yet"). `llms.txt` / `llms-full.txt` /
22
+ `ai-manifest.json` regenerated to match.
23
+
24
+ ## [0.81.0] — 2026-07-12
25
+
26
+ ### Added
27
+
28
+ - **ORM: Eloquent-parity features.** The active-record `Model` grows most of the
29
+ Eloquent surface people reach for, all backward-compatible and still on the
30
+ driver-agnostic query builder (no JOINs, edge-safe):
31
+
32
+ - **Lifecycle events & observers** — `creating`/`created`, `updating`/
33
+ `updated`, `saving`/`saved`, `deleting`/`deleted`, `restoring`/`restored`,
34
+ `retrieved`. The `*ing` events are **cancelable** (a hook returning `false`
35
+ vetoes the write); `Model.observe({...})` attaches an observer object.
36
+
37
+ ```ts
38
+ User.creating((u) => { u.uuid = crypto.randomUUID(); });
39
+ User.deleting((u) => (u.isRoot ? false : undefined)); // veto
40
+ ```
41
+
42
+ - **Global scopes** (`addGlobalScope`) applied to every query the model builds
43
+ and **inherited by subclasses** — the base for tenancy and published-only
44
+ reads. `withoutGlobalScope(...)` opts out, explicitly and greppably. Local
45
+ scopes are just static methods returning `query()`.
46
+
47
+ - **Soft deletes** — `static softDeletes = true` + `deleted_at`; `delete()`
48
+ sets the timestamp, a scope hides trashed rows, and `withTrashed`/
49
+ `onlyTrashed`/`restore`/`forceDelete`/`trashed` round it out.
50
+
51
+ - **`with` / `withCount` / `whereHas` / `has` / `doesntHave`** — a model-aware
52
+ `ModelQuery` with **nested** eager loading (`"posts.comments"`) and
53
+ relationship-existence filters, via a two-query strategy (no JOIN).
54
+
55
+ ```ts
56
+ await User.query().with("posts.comments").withCount("posts")
57
+ .whereHas("posts", (q) => q.where("published", true)).get();
58
+ ```
59
+
60
+ - **Serialization control** — `static hidden` / `visible` / `appends` on
61
+ `toJSON()` (appends resolve getters or zero-arg methods).
62
+
63
+ - **Polymorphic relations** — `morphOne` / `morphMany` / `morphTo` with a
64
+ morph-type registry (`registerMorphType`), eager loading across mixed types,
65
+ and `whereHas`/`withCount` support.
66
+
67
+ - **Query builder** grows `join`/`leftJoin`, `groupBy`/`having`, `distinct`,
68
+ `whereColumn`, `whereRaw`, `orderByRaw`, `when()`, `increment`/`decrement`,
69
+ dialect-aware `upsert`/`insertOrIgnore`, and `chunk()` for paged iteration over
70
+ large tables.
71
+
72
+ - **Migrations** grow `index()`/`uniqueIndex()` and `foreign().references().on()`
73
+ in `createTable`, plus **`SchemaBuilder.alterTable`** (add/drop/rename column,
74
+ add/drop index) — so altering a table no longer needs hand-written `raw()` SQL.
75
+
76
+ - **Accounts** — a new `@shaferllc/keel/accounts` package: password reset, email
77
+ verification, and two-factor auth (TOTP + single-use recovery codes), driven by
78
+ `attempt()` and an `AccountsServiceProvider`, with its own migration and
79
+ publishable config.
80
+
81
+ - **Teams** — a new `@shaferllc/keel/teams` package: multi-tenancy with a
82
+ `TenantModel` (a `TENANT_SCOPE` global scope), request-scoped team context, and
83
+ invitations.
84
+
85
+ ### Changed
86
+
87
+ - `Model.create()` now routes through `save()`, so mass-assignment, timestamps,
88
+ and the `saving`/`creating` events all apply in one place.
89
+
90
+ ## [0.80.0] — 2026-07-12
91
+
92
+ ### Added
93
+
94
+ - **Billing** — a new `@shaferllc/keel/billing` package: a Cashier-style
95
+ subscription layer with **one gateway-neutral API over Stripe and Paddle**
96
+ (switching gateways is a config change). `class User extends Billable(Model)`
97
+ gives a gateway customer, subscriptions (create/swap/quantity/trials/cancel/
98
+ resume + status checks), single charges + refunds, invoices, hosted checkout,
99
+ and verified per-gateway webhooks that sync local state and emit typed events.
100
+
101
+ ```ts
102
+ class User extends Billable(Model) { static table = "users"; }
103
+ await user.newSubscription("default", "price_pro").trialDays(14).create(pmId);
104
+ if (await user.subscribed()) { /* … */ }
105
+ ```
106
+
107
+ Reaches the active gateway from model methods through a module-level singleton
108
+ (`setBilling`/`billing`), matching Keel's `setConnection`/`setLogger` pattern.
109
+ Ships `Subscription`/`SubscriptionItem` models, a gateway-neutral migration, a
110
+ publishable config stub, and an in-memory `FakeGateway` so billing flows test
111
+ without a network. Webhook signatures are verified with a vendored hex
112
+ HMAC-SHA256 (edge-safe Web Crypto). Paddle's merchant-of-record differences
113
+ (checkout-created subscriptions, no raw card handling) surface as clear
114
+ `BillingError`s rather than silent gaps.
115
+
116
+ ## [0.79.0] — 2026-07-12
117
+
118
+ ### Added
119
+
120
+ - **Route model binding.** A `:post` in the path arrives as a **`Post`**, not a
121
+ string.
122
+
123
+ ```ts
124
+ bindModel("post", Post);
125
+
126
+ router.get("/posts/:post", (c) => {
127
+ const post = boundModel(Post); // already fetched. Not a string, not null.
128
+ return c.json(post);
129
+ });
130
+ ```
131
+
132
+ The row is looked up **before the handler runs**, and a miss is a 404 there and
133
+ then — so the handler never sees a `null` and never has to remember to check for
134
+ one. **"Forgot the 404" stops being a bug you can write.**
135
+
136
+ - **`key`** binds by another column (`/posts/hello-world` → `{ key: "slug" }`).
137
+ - **`scope` is security, not a filter.** A row outside it is a **404** — not a 403
138
+ (which would confirm it exists), and not merely absent from a list — so it can't
139
+ be reached by *guessing its id*. The scope gets the request, so it can depend on
140
+ who's asking. Tested against a real database, asserting the handler never runs
141
+ for an out-of-scope row.
142
+ - **Binding runs before route middleware**, so a policy can read the model instead
143
+ of re-fetching it.
144
+ - **`bindRoute(param, fn)`** resolves anything that isn't a model — a tenant, a
145
+ feature flag; returning `undefined` is a 404.
146
+ - An unbound param is untouched (still a string), and only routes with parameters
147
+ pay for any of this. Two params bound to the same model must be disambiguated —
148
+ guessing would be worse than asking.
149
+
150
+ ## [0.78.2] — 2026-07-11
151
+
152
+ ### Added
153
+
154
+ - **Releases publish themselves.** Pushing a `v*` tag now builds, tests, and
155
+ publishes to npm from CI, via **trusted publishing** (OIDC) — no token to leak or
156
+ rotate, and npm attaches a **provenance attestation**: proof the tarball was built
157
+ from that commit by that workflow, rather than uploaded from a laptop.
158
+
159
+ Publishing by hand is why npm drifted so far from git — git reached v0.77.0 while
160
+ npm's `latest` still said 0.74.0, and the published versions were sporadic (0.12,
161
+ 0.35, 0.58, 0.66, 0.68, 0.74). The tag is the release now.
162
+
163
+ It refuses to publish if the tag disagrees with `package.json`, or if the
164
+ typecheck, tests, or build fail — a tag can point at any commit, including one CI
165
+ never saw.
166
+
167
+ (v0.78.1 was tagged before this workflow existed, so it never reached npm; its
168
+ `./package.json` export fix ships here.)
169
+
170
+ ## [0.78.1] — 2026-07-11
171
+
172
+ ### Fixed
173
+
174
+ - **`./package.json` is exported.** With an `exports` map, Node blocks
175
+ `require("@shaferllc/keel/package.json")` unless it's listed — and plenty of
176
+ tooling reads it (bundlers, version checks, framework plugins). Caught by
177
+ installing the published package and poking at it, which is the only way to see
178
+ this class of problem.
179
+
180
+ ## [0.78.0] — 2026-07-11
181
+
182
+ ### Removed
183
+
184
+ - **The demo app is gone from the framework repo.** `app/`, `bootstrap/`, `config/`,
185
+ `routes/`, `resources/`, `database/`, `bin/keel.ts` and `vite.config.ts` were an
186
+ example application living inside the library. None of it shipped (the `files`
187
+ field is `dist`, `docs`, and the MCP bin), and after 0.76.0 inverted the CLI's
188
+ dependency, **nothing in the framework referenced it any more**.
189
+
190
+ This repo is now the framework, and only the framework. Application code —
191
+ controllers, providers, routes, config — belongs in *your* repo; the
192
+ [starter app](https://github.com/shaferllc/keel-app) has the layout.
193
+
194
+ The `keel` / `serve` / `dev` / `dev:client` / `build:client` npm scripts went with
195
+ it: they existed to run the demo. `npm test`, `npm run typecheck`, `npm run build`
196
+ and `npm run verify:release` are what you run here.
197
+
198
+ ## [0.77.0] — 2026-07-11
199
+
200
+ ### Changed
201
+
202
+ - **The console runs on Keel's own console.** All 20 built-in commands (`serve`,
203
+ `routes`, `repl`, `mcp`, every `make:*`, `migrate:*`, `vendor:publish`) are now
204
+ `defineCommand()`s on the `ConsoleKernel`. **`commander` is gone** — not moved to
205
+ a runtime dependency, removed.
206
+
207
+ This was forced by shipping the console in 0.76.0: `@shaferllc/keel/cli` imported
208
+ `commander`, which was a *devDependency*, so a consumer installing the package got
209
+ `ERR_MODULE_NOT_FOUND` on import. Promoting commander to a runtime dep would have
210
+ fixed the symptom while shipping a second command system alongside the one we'd
211
+ just built. So the built-ins moved instead.
212
+
213
+ What you get for it: **generated help** (`keel help make:controller` prints usage,
214
+ args, and options), commands **grouped by namespace**, `routes` and `migrate:status`
215
+ as real tables, and typed flags everywhere — `keel routes --nope` is now an error
216
+ rather than a shrug.
217
+
218
+ - **`PackageCommand` is now `defineCommand()`'s shape** — it was typed against a
219
+ commander `Command`. A package's command gets typed args and flags, generated help,
220
+ the terminal UI, and the prompt-trapping test story for free.
221
+
222
+ **Breaking** for a package contributing commands: replace `configure`/`action` with
223
+ `flags`/`args`/`run`. Both in-tree packages (`openapi:export`, `watch:prune`) are
224
+ ported.
225
+
226
+ That port fixed a live bug: `watch:prune --hours lots` did `Number("lots")` → `NaN`,
227
+ so the retention cutoff became `NaN` and the prune silently misbehaved. A typed
228
+ `flag.number()` rejects it as a usage error.
229
+
230
+ - **`@hono/node-server` is an optional peer dependency**, dynamically imported by
231
+ `serve`. It's only needed to *serve* on Node — a Workers app has no reason to
232
+ install it — and a missing one now says so instead of failing at import.
233
+
234
+ ## [0.76.0] — 2026-07-11
235
+
236
+ ### Added
237
+
238
+ - **The console ships in the package.** `@shaferllc/keel/cli` — so an app gets
239
+ `serve`, `routes`, `repl`, `migrate:*`, and every `make:*` generator from the
240
+ dependency, rather than having to vendor them.
241
+
242
+ ### Changed
243
+
244
+ - **`run(argv, { createApplication })` — the console is handed an application
245
+ factory instead of importing one.** `src/core/cli/index.ts` imported
246
+ `bootstrap/app.ts`: the *framework* depended on an *application*, which is the
247
+ dependency pointing the wrong way. It also had consequences —
248
+ - the file reached outside `rootDir: src`, so `tsconfig.build.json` had to
249
+ **exclude it from the build**;
250
+ - which meant the console was **not in the published package at all** (only the
251
+ `keel-mcp` bin was);
252
+ - and it's why `runCommand()` in the testing toolkit takes a callback rather than
253
+ an argv array — importing the CLI from `testing.ts` broke the build.
254
+
255
+ Your `bin/keel.ts` now passes its own factory. The CLI compiles, ships, and is
256
+ importable.
257
+
258
+ **Breaking** for anyone calling `run()` directly: it takes a second argument.
259
+ A one-line change in `bin/keel.ts`.
260
+
261
+ ## [Unreleased]
262
+
263
+ ### Added
264
+
265
+ - **CI.** Every push and pull request now runs the checks that were, until now, only
266
+ ever run by hand in the right order by someone who remembered to:
267
+ - `typecheck` — **src and tests**. The suite went unchecked for a long time, so a
268
+ test asserting a type at compile time was asserting nothing.
269
+ - `test`.
270
+ - **`build`** — the check that matters most. A git install runs the build through
271
+ `prepare`, so a tree that doesn't build cannot be installed *at all* — and
272
+ neither the tests nor the typecheck can see it, because they run against the
273
+ working directory. CI's checkout **is** the committed tree, which is exactly
274
+ what shipped three uninstallable tags (v0.74.0–v0.74.2).
275
+ - `typecheck:docs` — the docs examples compile against `dist/`, i.e. the real
276
+ published surface. They have caught bugs the tests could not: inference that
277
+ worked in-repo but broke for a consumer.
278
+ - **The generated AI surface is in sync** — regenerating `llms.txt`,
279
+ `llms-full.txt` and `docs/ai-manifest.json` must be a no-op, or someone changed
280
+ a doc or an export and shipped a stale surface.
281
+ - **No control characters in source** — we have shipped both a NUL byte (a cache
282
+ key) and raw ANSI escapes (the console colors), each of which turns a text file
283
+ "binary" to grep, diff, and code review.
284
+
285
+ ## [0.75.1] — 2026-07-11
286
+
287
+ ### Changed
288
+
289
+ - **Removed the Remult references** from the API-resources guide and source
290
+ comments. Keel isn't that, and the docs shouldn't read as a comparison to another
291
+ framework. The surrounding sentences are rewritten so they still say what the
292
+ feature does rather than leaving a hole. No behavior change.
293
+
294
+ ## [0.75.0] — 2026-07-11
295
+
296
+ ### Added
297
+
298
+ - **API resources — a full CRUD REST API from a model.** `@shaferllc/keel/api`.
299
+
300
+ ```ts
301
+ apiResource(router, Post, {
302
+ filter: ["status", "authorId"],
303
+ sort: ["createdAt", "title"],
304
+ body: PostSchema,
305
+ access: { read: true, write: (c) => isEditor(c) },
306
+ scope: (q, c) => q.where("authorId", currentUserId(c)),
307
+ });
308
+ ```
309
+
310
+ It registers **real routes** on the router — so `url()` finds them, `keel routes`
311
+ lists them, and `@shaferllc/keel/openapi` documents them for free — rather than
312
+ hiding a generic handler behind a wildcard.
313
+
314
+ - **Access is deny-by-default.** An action with no rule returns 403. For a
315
+ generated API that's the only safe default: you opt routes *open*, never shut,
316
+ so forgetting a rule fails closed rather than publishing your table.
317
+ - **Filtering and sorting are allow-listed.** A column not on the list is silently
318
+ ignored, never passed to SQL — which is what stops `?sort=password` or
319
+ `?secret_column=x` from doing anything. `perPage` is clamped to a ceiling, so
320
+ there's no "give me everything".
321
+ - **`scope` is row-level security, not decoration.** A row outside the scope 404s
322
+ for *read, update and delete* — not merely absent from the list — so it can't be
323
+ fetched, changed, or removed by guessing its id. There are tests for each of
324
+ those three, asserting against the database that the row really wasn't touched.
325
+ - Writes run through the model's mass-assignment guard *and* your Zod schema
326
+ (`body`, or `createBody`/`updateBody`), with `beforeWrite` to set fields the
327
+ client never sends (an owner id, timestamps).
328
+ - `transform` shapes the output (a function or a Keel `Transformer`); `only` /
329
+ `except` trim the action set; `ApiServiceProvider` publishes a `config/api.ts`
330
+ for the pagination defaults.
331
+
332
+ The api package **does not import openapi** — it writes its operation docs under a
333
+ known route-config key, so the two install independently. A test asserts that key
334
+ still matches the one openapi reads, because a silent drift there would stop
335
+ documenting every generated route and no comment would catch it.
336
+
337
+ ## [0.74.4] — 2026-07-11
338
+
339
+ ### Fixed
340
+
341
+ - **`flag.string({ parse })` and friends now infer their parameter.** The `const`
342
+ generic on the option builders swallowed the contextual type in the emitted
343
+ declarations, so a consumer writing `parse: (raw) => raw.toUpperCase()` would have
344
+ had to annotate `raw` by hand even though it compiled fine inside this repo. Same
345
+ bug, same fix, as `envVar`'s `validate`.
346
+
347
+ ### Changed
348
+
349
+ - **The test suite is type-checked.** `tests/` was not in any tsconfig's `include`,
350
+ and tests run through `tsx`, which strips types without checking them — so the
351
+ suite had **44 type errors nobody could see**, and a test asserting a type at
352
+ compile time was asserting nothing at all. All 44 are fixed, and
353
+ `npm run typecheck` now covers `src` *and* `tests`.
354
+
355
+ - **`npm run verify:release` builds from what is committed**, not from the working
356
+ tree. `npm test` and `npm run typecheck` both run against your working directory,
357
+ so neither can see a file you forgot to commit or committed half-written — while a
358
+ git install runs the build through `prepare`, so a broken tree there means the
359
+ package cannot be installed at all. That is exactly how v0.74.0–v0.74.2 shipped
360
+ unusable. This exports `HEAD` and does the install a consumer would.
361
+
362
+ ## [0.74.3] — 2026-07-11
363
+
364
+ ### Fixed
365
+
366
+ - **v0.74.0–v0.74.2 did not build from a clean checkout**, which made them unusable
367
+ as a dependency (a git install runs `npm run build` through `prepare`). An
368
+ in-progress `src/api` feature was committed by accident, half-written, and it
369
+ didn't compile; a leftover `cp src/api/…` in the build script then failed once the
370
+ feature was untracked.
371
+
372
+ It is now untracked again — the package exports and the build config no longer
373
+ include it — so the released tree is back to what it was in 0.73.0 plus the
374
+ environment validation that 0.74.0 was actually about. A clean-clone build is
375
+ verified before tagging now, rather than after.
376
+
377
+ ## [0.74.0] — 2026-07-11
378
+
379
+ ### Added
380
+
381
+ - **Environment validation — fail at boot, not at 3am.** `env("DATABASE_URL")`
382
+ hands back whatever is (or isn't) in `process.env`, so a missing variable boots a
383
+ perfectly healthy-looking app that dies on the first request that needs it, in
384
+ production, at night. `defineEnv()` checks the whole environment up front and
385
+ **refuses to start** otherwise.
386
+
387
+ ```ts
388
+ export const env = defineEnv({
389
+ APP_KEY: envVar.string({ required: true, description: "32+ random characters" }),
390
+ PORT: envVar.number({ default: 3000 }),
391
+ NODE_ENV: envVar.enum(["development", "test", "production"], { default: "development" }),
392
+ DATABASE_URL: envVar.url({ required: true }),
393
+ SENTRY_DSN: envVar.string(),
394
+ });
395
+
396
+ env.PORT; // number — not "3000"
397
+ env.NODE_ENV; // "development" | "test" | "production" — not string
398
+ env.SENTRY_DSN; // string | undefined
399
+ ```
400
+
401
+ - The value types are **inferred from the rules**: a `number` rule gives a
402
+ `number`, an `enum` gives the literal union rather than `string`, and anything
403
+ optional without a default is `| undefined` — so you can't forget to handle it.
404
+ - **Every problem is reported at once**, not the first one. Fixing a deploy one
405
+ missing variable per restart is its own small hell.
406
+ - Rules: `envVar.string/number/boolean/enum/url`, each with `required`, `default`,
407
+ `description` (shown in the failure, so they know what to set), and `validate`.
408
+ `url` catches a truncated connection string; `boolean` accepts the spellings
409
+ people actually use (`1`, `yes`, `on`).
410
+ - **An empty string counts as absent** — `PORT=` in a `.env` is a typo, not a
411
+ deliberate empty port.
412
+ - The returned object is frozen, so nothing reassigns your config at runtime.
413
+
414
+ ## [0.73.0] — 2026-07-11
415
+
416
+ ### Added
417
+
418
+ - **Database transactions.** `transaction(fn)` commits when `fn` returns and
419
+ **rolls back if it throws** — so two related writes either both land or neither
420
+ does, and a failure between them can't leave the card charged and the order
421
+ missing. The error is rethrown after the rollback; nothing is swallowed.
422
+ - **Queries inside are ambient.** `db()`, models, and relations all pick up the
423
+ open transaction without being handed it, because it lives in
424
+ `AsyncLocalStorage` rather than a module global — so two requests running
425
+ transactions at once can't steal each other's connection. `transaction()` also
426
+ passes an explicit handle (`tx.table()`, `tx.write()`, `tx.rollback()`) for
427
+ when you'd rather be obvious, and `inTransaction()` reports whether one is open.
428
+ - **Nesting uses savepoints.** A `transaction()` inside another doesn't open a
429
+ second transaction — databases don't have those — it takes a savepoint, so an
430
+ inner failure rolls back only the inner work and the outer transaction carries
431
+ on. Without that, a nested helper's failure would silently abandon its caller's
432
+ writes too.
433
+ - **The pooling trap, closed.** A transaction needs every statement on *one*
434
+ connection, but a pool hands each statement to whichever is free — so `BEGIN`
435
+ issued through a pool wraps nothing, the `COMMIT` commits nothing, and a
436
+ failure half-writes. It looks like it works. `Connection` therefore gains an
437
+ optional `begin()`: the Postgres adapter now checks a connection out of the
438
+ `Pool` (detected via `connect()`), runs the whole transaction on it, and
439
+ releases it afterwards **even if the `COMMIT` throws**. Single-connection
440
+ drivers (a bare `pg.Client`, SQLite, libSQL) need nothing and fall back to
441
+ `BEGIN`/`COMMIT`/`ROLLBACK`.
442
+ - **D1 refuses honestly.** Cloudflare D1 can't hold a transaction open across
443
+ awaits, so `transaction()` on it throws a clear error pointing at
444
+ `database.batch([...])`, rather than letting a `BEGIN` fail cryptically inside
445
+ the driver. A transaction that quietly isn't one is far worse than one that
446
+ refuses to start.
447
+
448
+ ## [0.72.0] — 2026-07-11
449
+
450
+ ### Added
451
+
452
+ - **A real console.** Commands with **typed arguments and flags**, prompts, a
453
+ terminal UI, and a REPL. `keel make:command greet` scaffolds one; anything in
454
+ `app/Commands` is discovered automatically. See the
455
+ [console guide](https://keeljs.com/docs/console).
456
+ - **The types are inferred from the spec, not cast.** `arg.string()` gives you a
457
+ `string`; `arg.string({ required: false })` gives you `string | undefined`; add
458
+ a default and it's a `string` again. The parsing is generated from the same
459
+ declaration, so the two can't drift apart.
460
+ - `arg.string/number/spread` and `flag.boolean/string/number/array`, each with
461
+ `description`, `default`, `required`, `parse`, and (for flags) a single-letter
462
+ `alias`. The parser handles `--flag value`, `--flag=value`, `--no-flag`,
463
+ `-f value`, bundled shorthands (`-lt 5`), and `--` passthrough.
464
+ - **An unknown flag is an error, not a shrug** — a typo'd `--forse` should tell
465
+ you rather than silently doing nothing. `allowUnknownFlags` opts out.
466
+ - Usage errors print what's wrong **and the command's help**; a thrown error exits
467
+ 1 with its message, not a stack trace. A console is a bad place to show someone
468
+ a stack because they mistyped a flag.
469
+ - **Terminal UI**: `info` / `success` / `warning` / `error`, `action()` for
470
+ aligned CREATE/SKIP lines, tables, stickers, numbered instructions, colors, and
471
+ a task runner that **stops at the first failure** (the tasks after it almost
472
+ certainly depended on it, and a cascade of red tells you nothing new). No
473
+ dependency — ANSI codes are a dozen escape sequences, not a package.
474
+ - **Prompts**: `ask`, `secure`, `confirm`, `toggle`, `choice`, `multiple`,
475
+ `autocomplete`, with `default` / `hint` / `validate` / `result`. A failed
476
+ `validate` re-asks rather than dying.
477
+ - **Prompts are testable**, which is the whole point: `createPrompt({ trap: true })`
478
+ lets a test script the answers, and `createUi({ raw: true })` buffers the output
479
+ colorlessly so you can assert on exactly what a command said. An **untrapped
480
+ prompt throws instead of hanging** — otherwise a test would block forever on
481
+ stdin it will never receive, and the suite would just stop, with no failure to
482
+ read. `assertAllTrapsUsed()` catches a question you scripted but never asked.
483
+ - **`keel repl`** — an interactive shell with the application booted: the
484
+ container is up, the providers have run, and `db`, `make`, `cache`, `router`
485
+ and friends are in scope. `.ls` lists them. Poking at a model in a REPL is the
486
+ fastest debugging loop there is, and it shouldn't cost you a throwaway script.
487
+
488
+ The built-in commands (`serve`, `routes`, `make:*`, `migrate:*`) and
489
+ package-contributed commands still run through the original console wrapper; your
490
+ commands run on the new system and take precedence over a built-in of the same
491
+ name. Migrating the built-ins across is mechanical and changes none of the API
492
+ above.
493
+
494
+ ## [0.71.0] — 2026-07-11
495
+
496
+ ### Added
497
+
498
+ - **Pages — page-based routing, where a file *is* a route.** `resources/pages/users/[id].tsx`
499
+ serves `/users/:id`; no route file to keep in sync, no controller, no wiring. New
500
+ [pages guide](https://keeljs.com/docs/pages), and `keel make:page users/[id]`.
501
+ - Conventions: `index.tsx` names its directory, `[id]` is a parameter,
502
+ `[...slug]` is a catch-all, and a leading `_` keeps a file private — so a
503
+ layout or a partial can live beside your pages without becoming a URL.
504
+ - **`loader`** runs before the page renders and its return value arrives as
505
+ `data`; **`middleware`** guards a page (and runs *before* the loader, so a
506
+ refused page never loads its data); **`name`** and **`path`** override the
507
+ derived route name and URL.
508
+ - **Specificity is decided for you.** This is the part file-based routing
509
+ usually gets wrong: register `/users/:id` before `/users/new` and the literal
510
+ page is unreachable forever, because `:id` matches `"new"`. Pages are sorted
511
+ before they're registered — literals beat parameters, parameters beat
512
+ catch-alls — so the file layout stops being a trap.
513
+ - **It drives the router rather than replacing it.** Every page becomes an
514
+ ordinary named route, so `url()` finds it, route middleware applies, and
515
+ `keel routes` lists it. Mix pages and hand-written routes freely, and reach for
516
+ a controller the moment a page outgrows a file.
517
+ - Edge-safe: `pages()` scans the filesystem on Node, while `definePages()` takes
518
+ a build-time manifest — `import.meta.glob("./pages/**/*.tsx", { eager: true })`
519
+ — so the same pages run on Workers.
520
+
521
+ - **Packages — a redistributable slice of an app.** Routes, a UI, config,
522
+ migrations, and console commands that install with a single `app.register(...)`.
523
+ `ServiceProvider` was already the unit of composition; `PackageProvider` adds the
524
+ conventions a *shippable* package needs, so it can carry its own schema and
525
+ assets instead of asking the host app to wire them by hand. `MigrationRegistry`,
526
+ `CommandRegistry`, `PublishRegistry`. New [packages guide](https://keeljs.com/docs/packages).
527
+
528
+ - **Watch — a debug dashboard.** Records the requests, queries, exceptions, logs,
529
+ jobs, mail, notifications, cache lookups, events, and scheduled tasks flowing
530
+ through the app, and shows them at `/watch`, with each request linked to the
531
+ queries and logs it produced. Built on a new instrumentation seam (`instrument()`,
532
+ `runRequest()`, `currentRequestId()`) and on `tapLogs()`, which observes every log
533
+ record without changing where logs normally go. Ships as a Keel package, and is
534
+ that system's reference implementation. New [watch guide](https://keeljs.com/docs/watch).
535
+
536
+ ## [0.70.0] — 2026-07-11
537
+
538
+ ### Added
539
+
540
+ - **Telemetry — distributed tracing with no SDK.** Spans, W3C trace context, and an
541
+ OTLP exporter, in a module you can read. The OpenTelemetry Node SDK is a large
542
+ tree of packages that assumes a Node process; what a trace *is*, though, is
543
+ small — an id, a parent, a start and an end, some attributes, and a documented
544
+ JSON shape to POST them in. This speaks **OTLP/HTTP over `fetch`**, so it runs on
545
+ Workers as happily as on Node, and any collector takes it (Jaeger, Tempo,
546
+ Honeycomb, Grafana, Datadog). New [telemetry guide](https://keeljs.com/docs/telemetry).
547
+ - `trace(name, fn)` opens a span, ends it when `fn` settles, and records a throw
548
+ before rethrowing. Spans **nest automatically** across `await` boundaries — and
549
+ across *concurrent* traces, because the current span lives in
550
+ `AsyncLocalStorage`, not a global, so two in-flight requests can't get tangled.
551
+ - `tracing()` middleware: a server span per request, joined to the caller's trace
552
+ via their `traceparent`, with the trace id written back on the response so a
553
+ user reporting a slow page can be looked up. A 5xx fails the span; a 404
554
+ doesn't — that's a valid answer, not a fault.
555
+ - `injectTraceContext()` for outgoing calls, plus `parseTraceparent()` /
556
+ `traceparent()`. A malformed header starts a fresh trace rather than failing
557
+ the request.
558
+ - `traceIds()` to hang `trace_id` / `span_id` on a log line — the jump from a log
559
+ to the trace it came from.
560
+ - `sampleRatio`, decided **once at the root and inherited by every child**,
561
+ because half a trace is worse than none.
562
+ - `otlpExporter()`, `consoleExporter()`, and `MemoryExporter` for tests. Spans
563
+ batch; `flushTelemetry()` drains them before an isolate goes away.
564
+
565
+ - **A real testing toolkit.** The test client injects requests without a server;
566
+ this fills in everything around it. See the
567
+ [testing guide](https://keeljs.com/docs/testing).
568
+ - **Request building:** `withToken()`, `withBasicAuth()`, `withHeader(s)`,
569
+ `withCookie(s)`, `acceptJson()` — each returning a **copy**, so a configured
570
+ client can't leak into another test — plus `form()` and `multipart()`.
571
+ - **Response assertions:** `assertJsonContains()` (a subset match — pins the
572
+ fields a test is about, so adding an unrelated field doesn't break twenty
573
+ tests), `assertSee()` / `assertDontSee()`, `assertValidationErrors(...fields)`,
574
+ `assertCookie()` / `assertCookieMissing()`, `assertHeaderMissing()`, status
575
+ shorthands (`assertCreated`, `assertNotFound`, `assertUnprocessable`, …), and
576
+ `dump()`.
577
+ - **Database assertions:** `assertDatabaseHas()` / `assertDatabaseMissing()` /
578
+ `assertDatabaseCount()` / `assertDatabaseEmpty()`, and `truncate()` — which
579
+ deletes rows rather than rolling back a transaction, so it works on every
580
+ driver rather than only the ones with savepoints.
581
+ - **Time control:** `freezeTime()` / `timeTravel()` / `restoreTime()`, so
582
+ "expires in an hour" doesn't take an hour to test. Mocks `Date` and `Date.now()`
583
+ — not timers, and not `new Date("2020-01-01")`; only "what time is it *now*".
584
+ - **Spies:** `spy()` and `spyOn()`, which **call through by default** — observing
585
+ rather than stubbing — until you tell them otherwise. `restoreSpies()` undoes
586
+ them.
587
+ - **State reset:** `resetState()` restores every fake, unfreezes the clock, drops
588
+ event listeners, empties the cache, and hands back a fresh lock store.
589
+ - **Console tests:** `runCommand(fn)` captures stdout, stderr, and the exit code,
590
+ with `assertSucceeded()` / `assertFailed()` / `assertOutputContains()` and
591
+ friends. You pass the command *in*, because the console entry point belongs to
592
+ your app, not the core.
593
+ - **Browser tests** are documented rather than wrapped: Playwright already does
594
+ this well, and a thinner API in front of it would only get in the way.
595
+
596
+ ## [0.69.1] — 2026-07-11
597
+
598
+ ### Fixed
599
+
600
+ - **`serveStorage({ signed: true })` now fails loudly on a `basePath` mismatch.**
601
+ `signedUrl()` signs the path the *disk* reports, so a disk handing out
602
+ `/storage/…` while the middleware is mounted at `/private` could never produce a
603
+ matching signature — and every request 403'd, which reads as "your link expired"
604
+ and sends you hunting in the wrong place. It now throws, naming both paths and
605
+ how to line them up.
606
+
607
+ ## [0.69.0] — 2026-07-11
608
+
609
+ ### Added
610
+
611
+ - **AI-native tooling — write Keel apps with an agent.** A machine-readable
612
+ surface generated from the same source as the human docs, so it never drifts:
613
+ - **An MCP server** (`keel mcp` / the shipped `keel-mcp` bin) exposing Keel's
614
+ docs, full public API (400+ exports), generators, and conventions to any
615
+ [Model Context Protocol](https://modelcontextprotocol.io) client. Tools:
616
+ `keel_overview`, `keel_search_docs`, `keel_read_doc`, `keel_search_api`,
617
+ `keel_list_generators`, `keel_scaffold`. Resources: `keel://overview`,
618
+ `keel://llms-full`, and `keel://docs/<slug>` per guide.
619
+ - **`AGENTS.md` + `CLAUDE.md`** — an agent playbook (import rule, folder map,
620
+ container/provider model, how-to-add-X table, guardrails), shipped in the
621
+ package.
622
+ - **`llms.txt` + `llms-full.txt`** — a [spec-compliant](https://llmstxt.org)
623
+ doc index and a one-file concatenation of every guide, shipped in the package.
624
+ - **`docs/ai.md`** — the "Building Keel apps with AI" guide.
625
+ - **`npm run build:ai`** regenerates `llms.txt`, `llms-full.txt`, and
626
+ `docs/ai-manifest.json` (the index the MCP server reads); wired into `build`.
627
+ - **Distributed locks** (`lock()`, `MemoryLockStore`, `LockStore`) — "only one
628
+ of you may do this at a time" across processes and nodes, with a pluggable
629
+ store seam (the core imports no driver). See the locks guide.
630
+
631
+ Every acquisition mints an owner token, and release/extend only succeed for the
632
+ owner. That isn't bookkeeping — without it, a lock whose TTL expires mid-work
633
+ gets picked up by process B, and A's late `release()` would delete **B's** lock
634
+ and let a third process in. `run()` takes the lock, runs, and always gives it
635
+ back; `extend()` throws rather than silently no-op'ing once the lock is lost.
636
+
637
+ - **Internationalization** — ICU message formatting plus the `Intl` formatters
638
+ that go with it, with **no dependency**: Node and Workers both ship full ICU, so plurals,
639
+ currencies, dates, and relative times are the platform's job, and Keel only adds
640
+ the message parser on top.
641
+ - `t(key, data)` / `i18n(locale)`, with an ICU subset covering interpolation,
642
+ `plural` (including exact `=0` branches and `#`), `selectordinal`, `select`,
643
+ `number` (incl. `::currency/USD`), `date`, and `time` — nested arbitrarily
644
+ deep. Plural categories come from the **locale**, not from English.
645
+ - `Intl`-backed `formatNumber` / `formatCurrency` / `formatDate` / `formatTime` /
646
+ `formatRelativeTime` / `formatList` / `formatPlural` / `formatDisplayName` —
647
+ worth using even in a single-locale app.
648
+ - `detectLocale()` middleware (custom resolver → query → cookie →
649
+ `Accept-Language` → default; only **supported** locales are honored, so
650
+ `?lang=xx` can't push the app into a locale you have no translations for) and
651
+ `negotiateLocale()` on its own.
652
+ - Nested or flat translation keys, and a fallback chain that walks `es-MX` →
653
+ configured fallback → `es` → default, so a regional locale can be a handful of
654
+ overrides.
655
+ - A missing key renders as the key itself (the page still works and the gap is
656
+ visible) and fires `i18n.missing`.
657
+
658
+ - **Mail: queueing, attachments, class-based mails, and a fake.**
659
+ - **`sendLater()`** — put the message on the queue instead of holding the request
660
+ open for an SMTP round trip. Validated at the call site, not on the worker, so
661
+ a malformed message throws where the stack trace means something.
662
+ - **Attachments** — `attach()` (content type inferred from the extension) and
663
+ `embed()` for inline `cid:` images.
664
+ - **`BaseMail`** — a reusable, testable email class; `send()` / `sendLater()`.
665
+ - **Named mailers** — `setMailer(t, o, "marketing")` / `mail("marketing")`.
666
+ - **`fakeMail()` / `restoreMail()`** with `assertSent` / `assertNotSent` /
667
+ `assertSentCount` / `assertQueued` / `assertNotQueued` / `assertQueuedCount` /
668
+ `assertNothingSent`. Sent and queued are tracked separately, and the fake still
669
+ validates, so it can't paper over a message the real mailer would reject.
670
+ - `mail.sending` / `mail.sent` / `mail.queued` events, and a default `replyTo`.
671
+
672
+ - **Queues: retries, backoff, priority, and a dead-letter list.**
673
+ - **Retries with backoff** — `static maxRetries` and `static backoff` per job
674
+ class (`exponentialBackoff` / `linearBackoff` / `fixedBackoff` / `noBackoff`),
675
+ overridable per dispatch. `maxRetries` defaults to 0 — the safe default for
676
+ work that isn't idempotent.
677
+ - **`failed(error)` hook** and a **dead-letter list** (`driver.failed`): an
678
+ exhausted job is logged, handed to its hook, and kept, rather than vanishing.
679
+ - **Priority** (lower runs first) and per-class `queue` / `priority` defaults.
680
+ - **`JobContext`** (`jobId`, `attempt`, `queue`) readable from `handle()`.
681
+ - **`fakeQueue()` / `restoreQueue()`** with `assertPushed` / `assertNotPushed` /
682
+ `assertPushedCount` / `assertNothingPushed` / `pushedJobs`.
683
+
684
+ - **Logger: `trace` and `fatal`, sinks, and better redaction.**
685
+ - Levels are now `trace` < `debug` < `info` < `warn` < `error` < `fatal`, plus
686
+ `log(level, …)`, `isLevelEnabled()` / `ifLevelEnabled()` (so an expensive
687
+ context object isn't built for a line nobody will emit), and `enabled: false`.
688
+ - **Sinks** — output goes through a `Sink` function receiving the structured
689
+ `LogRecord`, so logs can go to a file or an HTTP collector instead of the
690
+ console. `MemorySink` collects them for tests.
691
+ - **Redaction** gains `*` wildcard path segments (`"*.password"`), a custom
692
+ `censor`, and `remove` to drop the key outright. It still never mutates the
693
+ caller's object, and it runs *before* the sink, so a custom sink can never see
694
+ the unredacted values.
695
+ - **Named loggers** — `setLogger(logger, "audit")` / `namedLogger("audit")`.
696
+
697
+ - **`hasApplication()`** — whether an application has been bootstrapped. The queue
698
+ and the mailer use it so they still work in a worker or a unit test that never
699
+ created one.
700
+
701
+ ### Changed
702
+
703
+ - **A failed queue job no longer takes down the worker.** `work()` used to
704
+ propagate the error and stop draining. It now retries the job, and once the
705
+ retries are exhausted it logs the failure loudly, runs `failed()`, records it in
706
+ the driver's dead-letter list, and **carries on with the rest of the queue** —
707
+ one bad job can't stop the others. `SyncDriver` is the exception: it ran the job
708
+ *inline*, so the caller still gets the error thrown at them.
709
+
710
+ - **`dispatch()` now materializes the queue defaults**, so a queued job's `options`
711
+ always carry `queue` and `priority`.
712
+
713
+ ## [0.68.0] — 2026-07-11
714
+
715
+ ### Added
716
+
717
+ - **Storage: signed URLs, direct uploads, and metadata.** Kept keel's "core imports
718
+ no SDK" rule — the `Disk` seam grew *optional* capabilities, so existing disks
719
+ keep working untouched:
720
+ - **Content types.** `put()` now infers the MIME type from the path's extension
721
+ (`.png` → `image/png`), so files stop landing in your bucket as
722
+ `application/octet-stream` — the difference between a browser *rendering* a
723
+ file and *downloading* it. New `WriteOptions` (`contentType`, `cacheControl`,
724
+ `visibility`, custom `metadata`) override it.
725
+ - **`signedUrl(path, { expiresIn })`** — a temporary URL for a private file. A
726
+ disk with backend presigning (S3/R2/GCS) returns the bucket's own; any other
727
+ disk gets one signed with `config('app.key')`. The signature covers the path
728
+ and query but **not the host**, so a signed URL survives a CDN hostname.
729
+ - **`signedUploadUrl(path, { contentType })`** — the browser `PUT`s straight to
730
+ the bucket, so a 50 MB upload never streams through a Worker. Requires a disk
731
+ that can presign; there's no generic fallback, and calling it on one that
732
+ can't throws rather than handing back a URL that won't work.
733
+ - **`serveStorage()`** — middleware that serves a disk's files over HTTP with
734
+ `ETag`/304 and stored `Cache-Control`, and (in `signed: true` mode) rejects
735
+ unsigned or expired requests with a 403. This is what makes app-signed URLs
736
+ real for disks without backend presigning.
737
+ - **`metadata()` / `size()` / `copy()` / `move()`** — using the backend's
738
+ server-side operation when the disk offers one, falling back to
739
+ read-then-write otherwise.
740
+ - **`fakeDisk()` / `restoreDisk()`** with `assertExists` / `assertMissing` /
741
+ `assertContents` / `assertCount`, so tests never touch a real bucket. Matches
742
+ the `hash.fake()` precedent from 0.66.0.
743
+ - Also `signStorageUrl` / `verifyStorageUrl` / `contentTypeFor` for signing any
744
+ URL yourself, and an S3/R2 presigning disk recipe in the
745
+ [storage guide](https://keeljs.com/docs/storage).
746
+
747
+ - **Events: a typed registry, error isolation, and fakes.**
748
+ - **The `EventsList` registry.** Declare an event's payload once via module
749
+ augmentation and *both* sides are checked — the value you `emit` and the one
750
+ your listener receives can no longer drift apart. Opt-in and incremental: an
751
+ undeclared event behaves exactly as before.
752
+ - **`onError(handler)`** — route listener failures to one handler (with the
753
+ event name and payload) instead of letting them reject `emit`.
754
+ - **`onAny(listener)`** — observe every event, for logging and metrics.
755
+ - **`fake()` / `restore()`** returning an `EventBuffer` with `assertEmitted`
756
+ (optionally payload-matching), `assertNotEmitted`, `assertEmittedCount`,
757
+ `assertNoneEmitted`, `all()`, and `payloadsFor()` — assert an event fired
758
+ without triggering its side effects.
759
+ - **`clearAll()`** — drop listeners, any-listeners, and the error handler.
760
+
761
+ - **Health checks.** New [health guide](https://keeljs.com/docs/health) and
762
+ `healthCheck()` middleware serving the two endpoints an orchestrator actually
763
+ asks about: `/health/live` (answers instantly, checks **nothing** — a liveness
764
+ probe that touched the database would get a healthy app restarted during a
765
+ database blip) and `/health/ready` (runs every registered check; **200** while
766
+ healthy, **503** when one fails, which evicts the instance without killing it).
767
+ `health().register([...])`, `Result.ok/warning/failed` (a warning is still
768
+ healthy), `withMeta()`, `cacheFor(seconds)` so a frequent probe doesn't hammer
769
+ what it's probing, `check(name, fn)` and `BaseCheck` for your own, and built-in
770
+ `DatabaseCheck` / `RedisCheck` / `CacheCheck`. A check that throws becomes a
771
+ failed result rather than taking down the report.
772
+
773
+ Deliberately **absent**: disk-space, heap, and RSS checks. They measure a Node
774
+ process, and on Workers there isn't one.
775
+
776
+ ### Changed
777
+
778
+ - **A throwing event listener no longer skips the listeners after it.** `emit()`
779
+ now runs *every* listener and reports failures afterwards — rejecting with the
780
+ error, or with an `AggregateError` if several failed, or handing them to
781
+ `onError()` if one is registered. Previously the first failure aborted the loop,
782
+ so an analytics listener blowing up could silently cancel the welcome email.
783
+ Failures are still never swallowed.
784
+
785
+ [0.68.0]: https://github.com/shaferllc/keel/releases/tag/v0.68.0
786
+
787
+ ## [0.67.0] — 2026-07-11
788
+
789
+ ### Added
790
+
791
+ - **Cache resilience & invalidation.** Stayed inside keel's single-store,
792
+ edge-native model:
793
+ - **Stampede protection.** `remember()` / `rememberForever()` now collapse
794
+ concurrent misses for the same key into a **single** factory run, sharing the
795
+ result — a hot key expiring no longer dog-piles the upstream. Per-isolate (no
796
+ cross-node lock), which is where the dog-pile actually melts a server.
797
+ - **Grace / stale-on-error.** `remember(key, ttl, factory, { grace })` retains
798
+ an expired value `grace` seconds longer and serves it if the refreshing
799
+ factory **throws** — a flaky upstream degrades to slightly-stale data instead
800
+ of an error. A plain `get()` still reports the expired key as a miss, so stale
801
+ values never leak through the read path.
802
+ - **Tags & `deleteByTag`.** Tag entries via a `{ tags }` option on
803
+ `put`/`add`/`remember`/`rememberForever`, then invalidate a whole group with
804
+ `deleteByTag(["posts"])`. Uses version-stamp invalidation (a per-tag counter
805
+ entries record and `deleteByTag` bumps), so it's O(number of tags) with no key
806
+ index and works on any `CacheStore`. Tag-dropped entries are a hard miss (not
807
+ grace-eligible).
808
+ - **Namespaces.** `cache().namespace("users")` scopes keys under a `users:`
809
+ prefix (so namespaces can reuse logical keys) and its `flush()` clears **only**
810
+ that namespace via the same version-stamp mechanism. Namespaces nest and carry
811
+ the full API.
812
+ - **`add(key, value, ttl?)`** — write only if absent, returns whether it wrote.
813
+ - **`missing(key)`** — the inverse of `has`.
814
+ - **`forgetMany(keys)`** — delete several keys at once.
815
+ - New `RememberOptions` and `PutOptions` types.
816
+
817
+ Values are now stored in an internal envelope (value + logical expiry + tag
818
+ stamps) so the cache can distinguish fresh from grace-retained or
819
+ tag-invalidated; this is transparent through the `Cache` API and JSON-safe for
820
+ the Redis store. Existing `get`/`put`/`has`/`pull`/`remember` behavior is
821
+ unchanged, and the pluggable `CacheStore` contract is untouched. Intentionally
822
+ **not** matched from bentocache: multi-tier L1/L2 + bus (multi-node sync),
823
+ soft/hard timeouts, and the DynamoDB/database/file drivers — larger features
824
+ that cut against keel's single-store simplicity.
825
+
826
+ [0.67.0]: https://github.com/shaferllc/keel/releases/tag/v0.67.0
827
+
828
+ ## [0.66.0] — 2026-07-11
829
+
830
+ ### Added
831
+
832
+ - **`hash.fake()` / `hash.restore()`.** Swap real PBKDF2 for a trivial, insecure
833
+ scheme in tests so a suite that creates many users doesn't pay the (deliberate)
834
+ hashing cost — `make` returns `fake$<password>` and `verify` just compares.
835
+ Never for use outside tests.
836
+
837
+ [0.66.0]: https://github.com/shaferllc/keel/releases/tag/v0.66.0
838
+
839
+ ## [0.65.0] — 2026-07-11
840
+
841
+ ### Added
842
+
843
+ - **Security middleware suite.** Hashing and encryption already existed; this adds
844
+ the rest — all edge-native:
845
+ - **`cors()`** — Cross-Origin Resource Sharing with automatic preflight
846
+ handling. `origin` as boolean / `"*"` / allowlist / predicate, plus
847
+ `methods`, `headers`, `exposeHeaders`, `credentials` (auto-downgrades `"*"`
848
+ to the concrete origin), and `maxAge`. New [CORS](https://keeljs.com/docs/cors)
849
+ guide.
850
+ - **`securityHeaders()`** — the SSR "shield": Content-Security-Policy (string or
851
+ a camelCase directives object), HSTS, `X-Frame-Options`, `X-Content-Type-Options:
852
+ nosniff`, and `Referrer-Policy`, each individually toggleable.
853
+ - **`csrf()`** — session-backed CSRF protection; rejects unsafe requests without
854
+ a valid token (`419`), with `csrfField()` / `csrfToken()` helpers, an
855
+ `XSRF-TOKEN` cookie for SPAs, and route exemptions. New
856
+ [Securing SSR apps](https://keeljs.com/docs/security) guide.
857
+
858
+ - **Container & provider lifecycle.** Container services already existed as the
859
+ global helpers in [`helpers.ts`](https://keeljs.com/docs/container); this fills
860
+ in the rest:
861
+ - **Provider `ready()` and `shutdown()` hooks.** Providers grew two optional
862
+ lifecycle methods beyond `register()`/`boot()`: `ready()` runs once the whole
863
+ app is up (after every provider's `boot()` and the app's `onReady` hooks), and
864
+ `shutdown()` runs on `app.terminate()` in reverse registration order (LIFO).
865
+ Both are optional, so plain duck-typed providers keep working.
866
+ - **`Container.swap(token, factory)` / `restore(token?)`.** Temporarily replace a
867
+ binding with a fake for tests — the original binding and any resolved instance
868
+ are remembered; `restore()` with no token undoes every swap. Also as the
869
+ `swap` / `restore` global helpers.
870
+ - **`Container.alias(alias, target)`.** Point a token at another so
871
+ `make("router")` resolves through to `make(Router)`, honoring the target's own
872
+ sharing. Also as the `alias` global helper.
873
+ - **Graceful shutdown is now wired.** `keel serve` traps SIGINT/SIGTERM, stops
874
+ accepting connections, and runs `app.terminate()` (and thus every provider's
875
+ `shutdown()`) before exiting — the `onShutdown`/`terminate()` machinery
876
+ existed but nothing triggered it.
877
+
878
+ All additive and backward compatible. `@inject`-style reflective constructor
879
+ injection and contextual bindings were intentionally left out — Keel's DI is by
880
+ convention (a provider/controller constructor receives the container), which
881
+ keeps the core free of `reflect-metadata` and edge-native.
882
+
883
+ ### Changed
884
+
885
+ - **`encryption.encrypt(value, { expiresIn, purpose })`** — encrypted values can
886
+ now self-expire and be bound to a purpose (e.g. `"password-reset"`), verified on
887
+ `decrypt(token, { purpose })`; a wrong/absent purpose or an expired token returns
888
+ `null`. Backward compatible — tokens made without options decrypt as before.
889
+ - **`rateLimiter`** now also emits the `X-RateLimit-Reset` header.
890
+
891
+ [0.65.0]: https://github.com/shaferllc/keel/releases/tag/v0.65.0
892
+
893
+ ## [0.64.0] — 2026-07-11
894
+
895
+ ### Added
896
+
897
+ - **OAuth 1.0a social sign-in.** Social auth grew a second flow for the older,
898
+ three-legged providers (Twitter/X, and any OAuth 1.0a API). Every request is
899
+ HMAC-SHA1-signed with Web Crypto, so it's edge-native like the OAuth2 side:
900
+ - `social.twitter(config)` preset, and `social.driver1(spec, config)` /
901
+ `oauth1Driver` for any OAuth 1.0a provider.
902
+ - `OAuth1Driver` — `requestToken()` → `redirect()` → `accessToken()` /
903
+ `user()`, plus a signed `get()` for profile calls. Returns the same
904
+ normalized `SocialUser` (its `token` is an `OAuth1Token`).
905
+ - `oauth1Signature()` — the low-level RFC 5849 HMAC-SHA1 signer, exposed for
906
+ signing arbitrary provider API requests (verified against the canonical
907
+ Twitter test vector).
908
+ - New types `OAuth1Config`, `OAuth1Token`, `OAuth1ProviderSpec`; `SocialUser`
909
+ is now generic over its token type. Documented in the
910
+ [Social authentication](https://keeljs.com/docs/social-auth) guide.
911
+
912
+ Additive and backward compatible — the OAuth2 presets are unchanged.
913
+
914
+ [0.64.0]: https://github.com/shaferllc/keel/releases/tag/v0.64.0
915
+
916
+ ## [0.63.0] — 2026-07-11
917
+
918
+ ### Added
919
+
920
+ - **A full authentication system.** Session and JWT already existed; this adds the
921
+ rest, all edge-native (Web Crypto + `fetch`, no native deps):
922
+ - **Opaque access tokens** (`tokens.ts`) — revocable, ability-scoped, DB-backed
923
+ bearer tokens, the stateful counterpart to `jwt`. `createToken(userId, {
924
+ abilities, expiresIn, name })` mints a `keel_<selector>.<verifier>` token
925
+ (plaintext shown once); `verifyToken`, `revokeToken`, `revokeTokens` (log out
926
+ everywhere), `listTokens`, `tokenAllows`/`tokenDenies`, `setTokensTable`. The
927
+ split selector/verifier design stores only a SHA-256 hash and needs no
928
+ `RETURNING`, so it's portable across every driver and a leaked DB can't mint
929
+ tokens. Expired tokens self-prune on use.
930
+ - **`tokenAuth(options?)`** guard — verifies an opaque `Bearer` token, sets the
931
+ authenticated user, enforces required `abilities`, and exposes the token via
932
+ `token()` / `tokenCan()`.
933
+ - **`basicAuth(verify, options?)`** guard — HTTP Basic auth with a
934
+ `WWW-Authenticate` challenge; the verifier returns a user id, `true`, or a
935
+ falsy value.
936
+ - **Social sign-in** (`social.ts`) — OAuth 2.0 "sign in with…", `fetch`-based
937
+ with GitHub/Google/Discord presets and `social.driver()` for any other
938
+ provider. Returns a normalized `SocialUser`; `redirect()`, `exchangeCode()`,
939
+ `userFromToken()`, `user()`, `oauthState()` for CSRF, `OAuthError`. Keel owns
940
+ the OAuth dance only — you find-or-create your user and log them in. New
941
+ [Social authentication](https://keeljs.com/docs/social-auth) guide.
942
+ - **Timing-safe credentials** — `hash.dummy`, a valid hash that never matches,
943
+ so verifying a missing user costs the same as a wrong password (closes the
944
+ email-enumeration timing leak).
945
+ - **`gateAfter(callback)`** — the after-hook counterpart to `gateBefore`,
946
+ completing authorization parity (audit or veto a decision after it's made).
947
+
948
+ All additive and backward compatible. The `Auth` session guard, `jwt` +
949
+ `bearerAuth`, and gates/policies are unchanged.
950
+
951
+ [0.63.0]: https://github.com/shaferllc/keel/releases/tag/v0.63.0
952
+
953
+ ## [0.62.0] — 2026-07-11
954
+
955
+ ### Added
956
+
957
+ - **Proxy-aware URL accessors on `request`.** `request.protocol`,
958
+ `request.secure`, `request.host`, `request.hostname`, `request.origin`,
959
+ `request.fullUrl`, and `request.querystring` introspect the request URL and
960
+ connection. They honor `X-Forwarded-Proto` / `X-Forwarded-Host` over the raw
961
+ URL, so an app behind a TLS-terminating proxy or load balancer sees the
962
+ client's real scheme and host — use `origin` to build absolute links and
963
+ `secure` to gate insecure requests. (Koa-inspired.)
964
+ - **`response.back(fallback?)` and `redirect("back")`.** Redirect to the
965
+ request's `Referer`, falling back to `fallback` (default `"/"`) when it's
966
+ absent — the "return where you came from" shortcut for post-form flows.
967
+ - **`response.attachment(filename?)`.** Marks the response as a downloadable
968
+ attachment via `Content-Disposition`, emitting both a quoted ASCII `filename`
969
+ and an RFC 5987 `filename*` so non-ASCII names survive. Chainable, so pair it
970
+ with `type()`.
971
+ - **Encoding & charset negotiation.** `request.encoding(encodings)` /
972
+ `request.encodings()` and `request.charset(charsets)` / `request.charsets()`
973
+ complete the content-negotiation set alongside the existing `accepts` and
974
+ `language` helpers, using the same q-weight and `*` rules.
975
+
976
+ ## [0.61.0] — 2026-07-11
977
+
978
+ ### Added
979
+
980
+ - **Batteries-included database adapters.** Ready-made `Connection` implementations
981
+ for the common drivers, so you no longer hand-write the `select`/`write` bridge.
982
+ Each ships as an optional subpath import and takes your driver instance:
983
+ - `@shaferllc/keel/db/d1` — `d1Connection(env.DB)` for Cloudflare D1 (sqlite).
984
+ - `@shaferllc/keel/db/pg` — `pgConnection(client)` for any node-postgres-compatible
985
+ client: `pg` on Node or `@neondatabase/serverless` on the edge (postgres).
986
+ - `@shaferllc/keel/db/libsql` — `libsqlConnection(client)` for `@libsql/client` /
987
+ Turso, on Node and the edge (sqlite).
988
+
989
+ Each adapter **duck-types its driver** (a minimal structural interface) and
990
+ imports no driver — so Keel's core stays dependency-free and nothing is bundled
991
+ until you import an adapter, and you install only the driver you use (a peer, not
992
+ a Keel dependency). D1 and libSQL return the last insert id natively; Postgres
993
+ needs a `RETURNING id` clause for `insertGetId()`.
994
+
995
+ [0.61.0]: https://github.com/shaferllc/keel/releases/tag/v0.61.0
996
+
997
+ ## [0.60.0] — 2026-07-11
998
+
999
+ ### Added
1000
+
1001
+ - **Multiple database connections.** The database layer grew from a single global
1002
+ connection to a named registry, so an app can talk to several databases at once
1003
+ — a Postgres primary and a SQLite/D1 cache, a separate reporting warehouse, a
1004
+ per-tenant shard — each with its own dialect. Inspired by the common API behind
1005
+ the [Feathers database adapters](https://feathersjs.com/api/databases/adapters.html)
1006
+ (register many, route per resource), but kept in Keel's driver-agnostic,
1007
+ edge-safe `Connection` model (still no bundled driver):
1008
+ - `addConnection(name, conn, dialect?)` registers a named connection alongside
1009
+ the default; `setConnection` still registers the default (unchanged).
1010
+ - `db(table, connectionName?)` routes a single query to a named connection.
1011
+ - `connection(name?)` returns a `ConnectionHandle` — `table()` plus a raw,
1012
+ dialect-adjusted `select`/`write` bridge.
1013
+ - `Model.connection` (a `static`) puts a whole model — reads, writes, and
1014
+ relations — on a chosen connection.
1015
+ - `setDefaultConnection(name)` switches the default; `connectionNames()` lists
1016
+ the registered ones; `clearConnections()` resets (test helper).
1017
+ - New type `ConnectionHandle`.
1018
+
1019
+ Fully backward compatible: `setConnection` + `db(table)` behave exactly as
1020
+ before (the unnamed default lives under `"default"`), and connection resolution
1021
+ stays lazy — building a query never throws, only running one does.
1022
+
1023
+ [0.60.0]: https://github.com/shaferllc/keel/releases/tag/v0.60.0
1024
+
1025
+ ## [0.59.0] — 2026-07-11
1026
+
1027
+ ### Added
1028
+
1029
+ - **Stateless token authentication (JWT + bearer guard).** Took the token half of
1030
+ the [Feathers authentication API](https://feathersjs.com/api/authentication/)
1031
+ ([service](https://feathersjs.com/api/authentication/service.html),
1032
+ [JWT](https://feathersjs.com/api/authentication/jwt.html),
1033
+ [hook](https://feathersjs.com/api/authentication/hook.html)) — the piece Keel
1034
+ was missing next to its session/cookie `Auth` — and built it edge-native:
1035
+ - **`jwt`** — HS256 sign/verify on the Web Crypto API (no `jsonwebtoken`, no
1036
+ native bindings), signed with `config('app.key')`. `jwt.sign(payload, opts?)`
1037
+ stamps `iat` and (with `expiresIn`) `exp`, and supports `subject`/`issuer`/
1038
+ `audience`/`secret`; `jwt.verify()` returns the payload or `null` for a
1039
+ malformed, tampered, expired, not-yet-valid, or wrong-issuer/audience token.
1040
+ Only HS256 is accepted — `alg: none` and asymmetric algs are refused, closing
1041
+ the JWT algorithm-confusion hole. New types `JwtPayload`, `JwtSignOptions`,
1042
+ `JwtVerifyOptions`.
1043
+ - **`bearerAuth(options?)`** — a guard middleware that reads `Authorization:
1044
+ Bearer <token>`, verifies it, and makes the token's `sub` the authenticated
1045
+ id, so `auth().user()` resolves through the registered provider exactly as
1046
+ with sessions. Needs no session store (ideal on Workers). `{ optional: true }`
1047
+ lets unauthenticated requests through.
1048
+ - **`auth().id()`** now honors a `bearerAuth()` token (it wins over the session)
1049
+ and reads the request context directly, so token-only APIs work without
1050
+ `sessionMiddleware()`.
1051
+
1052
+ Username/password login is unchanged — `hash` + `auth().login()` already cover
1053
+ the local flow. OAuth remains out of scope. All additive and backward compatible.
1054
+
1055
+ [0.59.0]: https://github.com/shaferllc/keel/releases/tag/v0.59.0
1056
+
1057
+ ## [0.58.0] — 2026-07-11
1058
+
1059
+ ### Added
1060
+
1061
+ - **Errors: the full HTTP exception family.** Rounded out the built-in exceptions
1062
+ against the [Feathers errors API](https://feathersjs.com/api/errors.html) — the
1063
+ set now covers every common status, each with a fixed `status` and a stable
1064
+ machine `code`: `BadRequestException` (400), `PaymentRequiredException` (402),
1065
+ `MethodNotAllowedException` (405), `NotAcceptableException` (406),
1066
+ `RequestTimeoutException` (408), `ConflictException` (409),
1067
+ `LengthRequiredException` (411), `TooManyRequestsException` (429),
1068
+ `ServerErrorException` (500), `NotImplementedException` (501),
1069
+ `BadGatewayException` (502), and `ServiceUnavailableException` (503) — joining
1070
+ the existing `NotFoundException`, `UnauthorizedException`, `ForbiddenException`,
1071
+ and `ValidationException`. `STATUS_TEXT` gained labels for the new statuses.
1072
+ - **Structured error data.** `HttpException` gained an optional `data` bag
1073
+ (`new ConflictException(message, data)`) that surfaces in the JSON error body
1074
+ under `data`, plus a `toJSON()` returning the exact rendered body shape
1075
+ (`{ error, status, code?, data? }`, and `errors` for `ValidationException`) so
1076
+ an exception can be serialized outside the HTTP kernel.
1077
+
1078
+ All additive and backward compatible.
1079
+
1080
+ [0.58.0]: https://github.com/shaferllc/keel/releases/tag/v0.58.0
1081
+
1082
+ ## [0.57.0] — 2026-07-11
1083
+
1084
+ ### Added
1085
+
1086
+ - **Application object: Feathers-style ergonomics.** Adopted the useful parts of
1087
+ the [Feathers Application API](https://feathersjs.com/api/application.html)
1088
+ onto `Application`, all additive:
1089
+ - `app.configure(fn)` — run a `(app) => unknown` configurator and chain. The
1090
+ one-shot inline alternative to a `ServiceProvider` (no register/boot split).
1091
+ - `app.set(key, value)` / `app.get(key, fallback?)` — app-wide settings store,
1092
+ backed by `Config` so `app.set` and `config().get` share one store.
1093
+ - `app.on` / `app.once` / `app.off` / `app.emit` — app-level events delegating
1094
+ to the `Events` singleton (same emitter as the global `listen()` helper).
1095
+ - New exported type `Configurator`.
1096
+
1097
+ All backward compatible. `app.listen`/`teardown` map to Keel's existing
1098
+ Hono adapter + `boot()`/`terminate()`; the registry (`use`/`service`) is
1099
+ Keel's [container](https://keeljs.com/docs/container) + service broker.
1100
+
1101
+ [0.57.0]: https://github.com/shaferllc/keel/releases/tag/v0.57.0
1102
+
1103
+ ## [0.56.0] — 2026-07-11
1104
+
1105
+ ### Added
1106
+
1107
+ - **Service broker: params validation & result caching.** Six more Moleculer pages
1108
+ checked ([validating](https://moleculer.services/docs/0.15/validating),
1109
+ [caching](https://moleculer.services/docs/0.15/caching),
1110
+ [metrics](https://moleculer.services/docs/0.15/metrics),
1111
+ [tracing](https://moleculer.services/docs/0.15/tracing),
1112
+ [errors](https://moleculer.services/docs/0.15/errors),
1113
+ [runner](https://moleculer.services/docs/0.15/runner)):
1114
+ - **Validating** — an action's `params` schema is validated (and coerced) before
1115
+ the handler; a bad call rejects with `ValidationException`. Bring your own
1116
+ Zod-style schema.
1117
+ - **Caching** — mark an action `cache: true | { ttl, keys }` and give the broker
1118
+ a `cacher` (any Keel `Cache` — memory or Redis); results memoize by action +
1119
+ params (`keys` limits the key). No cacher → no-op.
1120
+ - **Metrics / tracing** — the middleware `localAction` seam is the hook; the
1121
+ trace context (`requestID`/`parentID`/`level`/`caller`) is already on every
1122
+ ctx. **Errors** — typed broker errors exist plus `createError`. **Runner** —
1123
+ `createService()` + `broker.start()` from boot. All documented rather than added.
1124
+
1125
+ All additive and backward compatible.
1126
+
1127
+ [0.56.0]: https://github.com/shaferllc/keel/releases/tag/v0.56.0
1128
+
1129
+ ## [0.55.0] — 2026-07-11
1130
+
1131
+ ### Added
1132
+
1133
+ - **Service broker: fault tolerance & registry introspection.** Two more Moleculer
1134
+ pages checked ([fault-tolerance](https://moleculer.services/docs/0.15/fault-tolerance),
1135
+ [registry](https://moleculer.services/docs/0.15/registry)):
1136
+ - **Retry** — `call(action, params, { retries: 3 })` re-runs the whole call on
1137
+ failure (total attempts = retries + 1); `BrokerOptions.retries` sets a default.
1138
+ - **Fallback** — `{ fallback: value }` or `{ fallback: (err, ctx) => value }`
1139
+ returns instead of throwing once every attempt (and `error` hooks) fails.
1140
+ Order: retry → error hooks → fallback → throw. (Timeout was already present.)
1141
+ - **Registry introspection** — `broker.hasAction(name)`, `listActions()`,
1142
+ `listServices()`, `getService(name)`.
1143
+ - **Networking / balancing** — clustering is the `Transporter` seam (NATS/Redis/
1144
+ TCP); single-node has one endpoint per action, so cross-node balancing doesn't
1145
+ apply — event **group** balancing already works via `emit(…, { groups })`.
1146
+ Documented rather than added.
1147
+
1148
+ All additive and backward compatible.
1149
+
1150
+ [0.55.0]: https://github.com/shaferllc/keel/releases/tag/v0.55.0
1151
+
1152
+ ## [0.54.0] — 2026-07-11
1153
+
1154
+ ### Added
1155
+
1156
+ - **Service broker: Moleculer-parity events & context.** A second parity pass over
1157
+ the broker, drawn from Moleculer's
1158
+ [events](https://moleculer.services/docs/0.15/events) and
1159
+ [context](https://moleculer.services/docs/0.15/context) pages:
1160
+ - **`broadcastLocal`** — broadcast to every listener on this node (mirrors
1161
+ `broadcast` until a real transporter would relay across nodes).
1162
+ - **Event groups & patterns** — the Events docs now spell out group-based
1163
+ balancing (`emit(..., { groups })`), and subscription keys gain the `?`
1164
+ single-char wildcard alongside `*` / `**`.
1165
+ - **Internal events** — the broker now emits `$broker.started`,
1166
+ `$broker.stopped`, and `$services.changed` (`{ service }` payload) that any
1167
+ service can subscribe to.
1168
+ - **Event context** — event handlers receive `ctx.eventName`, `ctx.eventType`
1169
+ (`"emit"` / `"broadcast"`), and `ctx.eventGroups`.
1170
+ - **Request-tree context** — every context now carries `ctx.parentID`,
1171
+ `ctx.level` (depth from 1), `ctx.caller` (invoking service), and `ctx.action`;
1172
+ `ctx.toJSON()` returns a log-safe snapshot with no functions or live refs.
1173
+ - **Broker middlewares** (Moleculer's
1174
+ [middlewares](https://moleculer.services/docs/0.15/middlewares)) — pass
1175
+ `middlewares: [...]` to wrap every action call and tap broker lifecycle. A
1176
+ middleware's `localAction(next, action)` wraps the handler (they compose,
1177
+ first = outermost); `started(broker)` / `stopped(broker)` run during
1178
+ `broker.start()` / `stop()`. (Service **lifecycle** hooks and per-service
1179
+ **`this.logger`** were already in place — the other two pages checked.)
1180
+
1181
+ All additive and backward compatible.
1182
+
1183
+ [0.54.0]: https://github.com/shaferllc/keel/releases/tag/v0.54.0
1184
+
1185
+ ## [0.53.0] — 2026-07-11
1186
+
1187
+ ### Added
1188
+
1189
+ - **Route config / metadata.** Attach arbitrary data to a route or group with
1190
+ `.config({ … })` and read it in the handler or route middleware via
1191
+ `request.route.config` — per-route flags like an auth scope, rate tier, or
1192
+ layout choice (Fastify's route `config`). Group config merges into every route,
1193
+ with a route's own keys winning. The matched-route context is now set *before*
1194
+ a route's middleware, so route/group middleware can branch on `request.route`.
1195
+ See [docs/routing.md](./docs/routing.md#route-config).
1196
+
1197
+ [0.53.0]: https://github.com/shaferllc/keel/releases/tag/v0.53.0
1198
+
1199
+ ## [0.52.0] — 2026-07-11
1200
+
1201
+ ### Added
1202
+
1203
+ - **Service broker: Moleculer-parity actions & services.** The
1204
+ [broker](./docs/broker.md) grows the pieces a service-oriented app leans on,
1205
+ drawn from Moleculer's [services](https://moleculer.services/docs/0.15/services)
1206
+ and [actions](https://moleculer.services/docs/0.15/actions) pages:
1207
+ - **Full action definitions** — an action may now be `{ handler, visibility,
1208
+ timeout, hooks }` instead of only a bare handler.
1209
+ - **Action hooks** — `before` / `after` / `error` at the service level (keyed by
1210
+ action name, with `*`, `"a|b"`, and glob matching) or inline per action, run
1211
+ in Moleculer's order (before: wildcard → named → action; after/error reversed).
1212
+ - **Visibility** — `published` / `public` / `protected` / `private`; `private`
1213
+ actions are hidden from `call` but reachable internally via `this.actions.x`.
1214
+ - **`mcall`** — batch calls as an array or keyed map, with `settled` for
1215
+ per-call `{ status, value | reason }`.
1216
+ - **Mixins** — reusable schemas merged by type (settings/metadata deep-merge,
1217
+ actions/events/methods/hooks by key, lifecycle hooks chained), with a
1218
+ `merged()` hook; the service's own schema wins on conflict.
1219
+ - **Dependencies** — `dependencies` gates a service's `started` hook on other
1220
+ services being registered; `broker.waitForServices()` / `this.waitForServices()`
1221
+ wait explicitly.
1222
+ - **Richer context** — `ctx.locals` (per-call scratch), `ctx.headers` (transient,
1223
+ not propagated), and `ctx.requestID` (correlation id threaded through the
1224
+ request tree); `metadata` on the service instance; event listeners may declare
1225
+ a `group` that `emit`'s `groups` option targets.
1226
+
1227
+ All additive and backward compatible — a bare-handler action, function-shorthand
1228
+ event, and hook-less service behave exactly as before.
1229
+
1230
+ [0.52.0]: https://github.com/shaferllc/keel/releases/tag/v0.52.0
1231
+
1232
+ ## [0.51.0] — 2026-07-11
1233
+
1234
+ ### Added
1235
+
1236
+ - **Response header helpers.** The `response` accessor gains `headers({...})` (set
1237
+ several at once), `getHeader(name)`, and `hasHeader(name)` — so middleware can
1238
+ inspect and conditionally set what a handler already put on the response (e.g.
1239
+ a default `cache-control`). Brings `response` to parity with Fastify's Reply.
1240
+ See [docs/request-response.md](./docs/request-response.md).
1241
+ - **Design principles** documented in
1242
+ [docs/architecture.md](./docs/architecture.md#design-principles) — edge-safe /
1243
+ driver-agnostic and explicit-over-implicit spelled out alongside the existing
1244
+ container-first and thin-over-clever tenets.
1245
+
1246
+ [0.51.0]: https://github.com/shaferllc/keel/releases/tag/v0.51.0
1247
+
1248
+ ## [0.50.0] — 2026-07-11
1249
+
1250
+ ### Added
1251
+
1252
+ - **Parameterized providers.** Service providers — Keel's plugin system — now take
1253
+ options at registration: `app.register(RateLimitProvider, { max: 100 })`, typed
1254
+ via `ServiceProvider<{ max: number }>` and read as `this.options`. The same
1255
+ provider class can register more than once with different options, so a provider
1256
+ is now genuinely reusable (Fastify's `register(plugin, options)`). Backward
1257
+ compatible — options default to `{}`. (Keel providers stay un-encapsulated by
1258
+ design; per-request scoping is [middleware](./docs/middleware.md).) See
1259
+ [docs/providers.md](./docs/providers.md#providers-are-keels-plugin-system).
1260
+
1261
+ [0.50.0]: https://github.com/shaferllc/keel/releases/tag/v0.50.0
1262
+
1263
+ ## [0.49.0] — 2026-07-11
1264
+
1265
+ ### Added
1266
+
1267
+ - **Broadcasting.** Push events to clients in real time over named channels on a
1268
+ pluggable `Broadcaster` — the core owns no socket, so point it at Pusher/Ably
1269
+ (`fetch`), a Cloudflare Durable Object, or the built-in `MemoryBroadcaster`
1270
+ (in-process fan-out, for tests). `broadcast(channels, event, payload)`
1271
+ publishes; `channelAuth("orders.{id}", (user, params) => …)` gates private and
1272
+ presence channels (return `false`/`true`/member-data), resolved by
1273
+ `authorizeChannel` at your socket endpoint — composing with `auth()` and
1274
+ authorization. `MemoryBroadcaster.subscribe()` fans out in-process (Durable
1275
+ Object / SSE). See [docs/broadcasting.md](./docs/broadcasting.md).
1276
+
1277
+ [0.49.0]: https://github.com/shaferllc/keel/releases/tag/v0.49.0
1278
+
1279
+ ## [0.48.0] — 2026-07-11
1280
+
1281
+ ### Added
1282
+
1283
+ - **Task scheduling.** Declare recurring work with a fluent cadence — `schedule(new
1284
+ PruneSessions()).daily()`, `schedule(() => sync()).everyFiveMinutes()`,
1285
+ `schedule(job).cron("0 9 * * 1")` — then run the scheduler once a minute from a
1286
+ single trigger. Cadences: `everyMinute` … `everyThirtyMinutes`, `hourly` /
1287
+ `hourlyAt`, `daily` / `dailyAt("13:30")`, `weekly` / `monthly`, or any 5-field
1288
+ `cron()`. `scheduler().runDue(now)` runs everything due (to the minute); `due()`
1289
+ lists without running. Built-in cron matcher (`*`, lists, ranges, steps, and
1290
+ standard dom/dow semantics) — wire it to Cloudflare Cron Triggers'
1291
+ `scheduled()` handler or a Node interval. A task is a `Job` or a function. See
1292
+ [docs/scheduling.md](./docs/scheduling.md).
1293
+
1294
+ [0.48.0]: https://github.com/shaferllc/keel/releases/tag/v0.48.0
1295
+
1296
+ ## [0.47.0] — 2026-07-11
1297
+
1298
+ ### Added
1299
+
1300
+ - **File storage.** A driver-agnostic storage layer on a pluggable `Disk` — the
1301
+ core imports no filesystem or SDK, so it runs on Node and the edge.
1302
+ `setDisk(disk, name?)` then `storage(name?)`: `put` (string / bytes /
1303
+ ArrayBuffer) / `get` / `getText` / `exists` / `delete` / `list(prefix?)` /
1304
+ `url`. `MemoryDisk` is a full in-memory driver and the default, so `storage()`
1305
+ works in tests with no setup; point disks at the local filesystem (Node), S3
1306
+ (`fetch`), or a Cloudflare R2 binding (adapters in the docs). Register several
1307
+ disks by name and select with `storage("s3")`. See
1308
+ [docs/storage.md](./docs/storage.md).
1309
+
1310
+ [0.47.0]: https://github.com/shaferllc/keel/releases/tag/v0.47.0
1311
+
1312
+ ## [0.46.0] — 2026-07-11
1313
+
1314
+ ### Added
1315
+
1316
+ - **Authorization — gates & policies.** Where `auth()` is *who you are*, this is
1317
+ *what you're allowed to do*. `define(ability, (user, ...args) => …)` registers a
1318
+ gate; `policy(Model, PolicyClass)` groups abilities as methods on a plain class,
1319
+ and `can("update", post)` routes to `PostPolicy.update(user, post)` by the
1320
+ argument's class. `can` / `cannot` return booleans; `authorize` throws a `403`;
1321
+ `canFor` / `authorizeFor` check a specific user; `gateBefore` short-circuits
1322
+ every check (admin bypass). The current user resolves from `auth().user()` by
1323
+ default (overridable with `setUserResolver`); unknown abilities deny. See
1324
+ [docs/authorization.md](./docs/authorization.md).
1325
+
1326
+ [0.46.0]: https://github.com/shaferllc/keel/releases/tag/v0.46.0
1327
+
1328
+ ## [0.45.0] — 2026-07-11
1329
+
1330
+ ### Added
1331
+
1332
+ - **Test client.** `testClient(app)` injects requests into your app — no server,
1333
+ no port — and returns a `TestResponse` with verb helpers (`get` / `post` (JSON
1334
+ body) / `put` / `patch` / `delete`) and fluent, chainable assertions
1335
+ (`assertStatus` / `assertOk` / `assertJson` / `assertText` / `assertHeader` /
1336
+ `assertRedirect`). The response body is pre-buffered, so reads are synchronous
1337
+ and repeatable. Accepts an `Application`, an `HttpKernel` (to register global
1338
+ middleware first), or any `request()`-able. Edge-safe — the same fetch-style
1339
+ injection Keel's own suite uses, minus the boilerplate. See
1340
+ [docs/testing.md](./docs/testing.md).
1341
+
1342
+ [0.45.0]: https://github.com/shaferllc/keel/releases/tag/v0.45.0
1343
+
1344
+ ## [0.44.0] — 2026-07-11
1345
+
1346
+ ### Added
1347
+
1348
+ - **Declarative request validation.** `validateRequest({ body, query, params })`
1349
+ is middleware that validates the request *before* the handler runs — rejecting
1350
+ a bad request with a `422` `ValidationException` (errors from every part
1351
+ aggregated, keyed `body.field` / `query.field` / `params.field`) so the handler
1352
+ only ever sees valid input. `validated(part)` returns the parsed, typed value.
1353
+ The declarative counterpart to Fastify's route schemas, built on the same
1354
+ schema-agnostic `validate()` engine (bring your own Zod-style schema). See
1355
+ [docs/validation.md](./docs/validation.md#declarative-validation-before-the-handler).
1356
+
1357
+ [0.44.0]: https://github.com/shaferllc/keel/releases/tag/v0.44.0
1358
+
1359
+ ## [0.43.0] — 2026-07-10
1360
+
1361
+ ### Added
1362
+
1363
+ - **Per-request logging.** `requestLogger()` middleware binds a child logger with
1364
+ a generated `reqId` to each request, so every log line within a request
1365
+ correlates (Fastify's `request.log`). It logs request start/completion
1366
+ (method, path, status, ms) by default; options for `genReqId`, reusing an
1367
+ incoming `idHeader` (distributed tracing), and disabling the auto lines.
1368
+ `requestLog()` reaches the current request's logger anywhere (falls back to the
1369
+ base logger outside a request).
1370
+ - **Log redaction.** `new Logger({ redact: ["password", "req.headers.authorization"] })`
1371
+ replaces matched values (top-level keys or dot paths) with `"[redacted]"`
1372
+ without mutating the logged object; inherited by child loggers. See
1373
+ [docs/logger.md](./docs/logger.md#per-request-logging).
1374
+
1375
+ [0.43.0]: https://github.com/shaferllc/keel/releases/tag/v0.43.0
1376
+
1377
+ ## [0.42.0] — 2026-07-10
1378
+
1379
+ ### Added
1380
+
1381
+ - **Application lifecycle hooks & graceful shutdown.** `onReady(hook)` runs after
1382
+ boot (or immediately if already booted); `onShutdown(hook)` registers cleanup
1383
+ and `terminate()` runs every shutdown hook newest-first (LIFO) — close DB/Redis
1384
+ connections, flush queues on `SIGTERM`. `terminate()` is idempotent and a
1385
+ throwing hook can't strand the rest (first error re-thrown after all run).
1386
+ `Router.onRoute(hook)` observes route registration (fired live and replayed for
1387
+ existing routes). Available as `Application` methods and global helpers.
1388
+ Request-lifecycle hooks remain [middleware](./docs/middleware.md). See
1389
+ [docs/hooks.md](./docs/hooks.md).
1390
+
1391
+ [0.42.0]: https://github.com/shaferllc/keel/releases/tag/v0.42.0
1392
+
1393
+ ## [0.41.1] — 2026-07-10
1394
+
1395
+ ### Added
1396
+
1397
+ - **Coded errors — `createError`.** Mint a reusable, coded `HttpException`
1398
+ subclass in one line: `createError("E_FUNDS", "Balance too low: need %s", 402)`.
1399
+ `%s` placeholders fill from the constructor arguments, the result renders
1400
+ through the default path (with `code` in the JSON body) and passes
1401
+ `instanceof HttpException`. The built-in exceptions now carry stable codes too
1402
+ (`E_NOT_FOUND`, `E_UNAUTHORIZED`, `E_FORBIDDEN`, `E_VALIDATION`), so `code`
1403
+ surfaces without any work. Inspired by Fastify's `@fastify/error`. Also
1404
+ documented: serving over **HTTP/2** needs no framework code — it's a transport
1405
+ concern handled by the edge platform, a reverse proxy, or a `@hono/node-server`
1406
+ `node:http2` option. See [docs/errors.md](./docs/errors.md#coded-errors-with-createerror)
1407
+ and [docs/hono.md](./docs/hono.md#serving-over-http2).
1408
+
1409
+ [0.41.1]: https://github.com/shaferllc/keel/releases/tag/v0.41.1
1410
+
1411
+ ## [0.41.0] — 2026-07-10
1412
+
1413
+ ### Added
1414
+
1415
+ - **Service Broker.** A Moleculer-style backbone for service-oriented code.
1416
+ Register services (a name plus `actions` and `events`) with a `Broker`, then
1417
+ reach them by string name: `broker().call("users.get", { id })` runs an action;
1418
+ `broker().emit("user.created", user)` fans an event out to every listener
1419
+ (balanced), or `broadcast` to all. Actions receive a `Context` and call other
1420
+ actions via `ctx.call`, threading `meta` (auth, trace ids) down through nested
1421
+ calls. Services support `version` prefixes (`v2.users.*`), `settings`, bound
1422
+ `methods`, glob event subscriptions (`user.*` / `user.**`), lifecycle hooks
1423
+ (`created` / `started` / `stopped`), and per-call `timeout`. Clustering lives
1424
+ behind a pluggable `Transporter` seam — the default `LocalTransporter` is a
1425
+ single-node no-op, so the core imports no network client and stays edge-safe.
1426
+ `broker()` / `setBroker()` manage the default instance, mirroring
1427
+ `redis()` / `setRedis()`. See [docs/broker.md](./docs/broker.md).
1428
+
1429
+ [0.41.0]: https://github.com/shaferllc/keel/releases/tag/v0.41.0
1430
+
1431
+ ## [0.40.1] — 2026-07-10
1432
+
1433
+ ### Added
1434
+
1435
+ - **Raw request-body accessors.** `request.text()`, `request.arrayBuffer()`, and
1436
+ `request.blob()` read the body for content types `json()` / `all()` don't
1437
+ handle — XML, CSV, protobuf, msgpack, or any custom format — which you then
1438
+ parse yourself. Keel keeps body parsing explicit (no Fastify-style content-type
1439
+ parser registry): you call the accessor you want. See
1440
+ [docs/request-response.md](./docs/request-response.md#other-content-types).
1441
+
1442
+ [0.40.1]: https://github.com/shaferllc/keel/releases/tag/v0.40.1
1443
+
1444
+ ## [0.40.0] — 2026-07-10
1445
+
1446
+ ### Added
1447
+
1448
+ - **Request decorators.** Attach named, computed values to the current request
1449
+ — `request.user` / `tenant` / `locale` — resolved lazily and memoized for the
1450
+ life of the request. `decorateRequest(name, resolver)` registers a resolver
1451
+ (sync or async), `decorated(name)` reads it (computed once, then cached),
1452
+ `setRequestValue(name, value)` sets it imperatively (e.g. from middleware), and
1453
+ `hasRequestDecorator(name)` checks. Inspired by Fastify's `decorateRequest`,
1454
+ but without the null-placeholder/`onRequest`-hook dance — the per-request memo
1455
+ is keyed off the context via a WeakMap, so nothing leaks between requests.
1456
+ (Decorating the *app* is already the container's job.) See
1457
+ [docs/decorators.md](./docs/decorators.md).
1458
+
1459
+ [0.40.0]: https://github.com/shaferllc/keel/releases/tag/v0.40.0
1460
+
1461
+ ## [0.39.0] — 2026-07-10
1462
+
1463
+ ### Added
1464
+
1465
+ - **Redis.** A Redis integration on a pluggable `RedisConnection` driver — the
1466
+ core imports no client, so it runs on Node and the edge. `setRedis(driver)`
1467
+ then `redis()`: `get` / `set` (with `{ ex }` / `{ px }` TTL) / `del` / `exists`
1468
+ / `incr` / `decr` / `expire` / `ttl` / `keys` / `flushAll`, plus `getJson` /
1469
+ `setJson` and a `remember` read-through cache. `MemoryRedis` is a full
1470
+ in-memory driver (TTL-aware) and the default, so `redis()` works in tests with
1471
+ no setup; point it at Upstash (`fetch`), ioredis, or node-redis in production.
1472
+ `redisStore()` adapts it into a `CacheStore` so the cache can be Redis-backed.
1473
+ See [docs/redis.md](./docs/redis.md).
1474
+
1475
+ [0.39.0]: https://github.com/shaferllc/keel/releases/tag/v0.39.0
1476
+
1477
+ ## [0.38.0] — 2026-07-10
1478
+
1479
+ ### Added
1480
+
1481
+ - **ORM maturity — timestamps.** `static timestamps = true` auto-manages
1482
+ `created_at` / `updated_at` (both on insert, only `updated_at` on update);
1483
+ column names are overridable via `createdAtColumn` / `updatedAtColumn`.
1484
+ - **Pagination.** `Model.paginate(page, perPage)` and `db(table).paginate(...)`
1485
+ return a `Paginated<T>` — `{ data, total, perPage, currentPage, lastPage }`.
1486
+ - **Aggregates & single values.** Query builder `sum` / `avg` / `min` / `max`,
1487
+ plus `value(column)` (one column of the first row) and `pluck(column)` (a
1488
+ column across all rows).
1489
+ - **More query clauses.** `whereBetween`, `whereNotIn`, `whereLike`, and
1490
+ `latest()` / `oldest()` ordering by a timestamp column.
1491
+ - **Find-or-create & convenience writes.** `Model.firstOrCreate(match, values)`,
1492
+ `Model.updateOrCreate(match, values)`, instance `update(attrs)` (fill + save),
1493
+ and `refresh()` (re-read the row). See [docs/models.md](./docs/models.md).
1494
+ - **Full Vite support.** A first-class frontend build, the way modern full-stack
1495
+ frameworks do it. A `keelVite()` plugin (new `@shaferllc/keel/vite` entry) wires
1496
+ `vite.config.ts` — manifest, output, entrypoints, `base` — and writes a
1497
+ `public/hot` marker while the dev server runs; optional `reload` globs
1498
+ full-reload the browser on server-view changes. The `Vite` service renders the
1499
+ `<script>`/`<link>` tags for your entrypoints and resolves asset URLs, flipping
1500
+ automatically between the dev server (with HMR) and the hashed, preloaded
1501
+ production manifest. Helpers `viteTags` / `viteAsset` / `viteReactRefresh` slot
1502
+ straight into a JSX `<head>`; `scriptAttributes` / `styleAttributes`, a CDN
1503
+ `assetsUrl`, React Fast Refresh, and an edge-safe `useManifest` path are all
1504
+ covered. Tag generation is pure and runs on the edge. See
1505
+ [docs/vite.md](./docs/vite.md).
1506
+
1507
+ [0.38.0]: https://github.com/shaferllc/keel/releases/tag/v0.38.0
1508
+
1509
+ ## [0.37.1] — 2026-07-10
1510
+
1511
+ ### Fixed
1512
+
1513
+ - **`make:*` stubs import the resolvable specifier.** Generated files now import
1514
+ from `@shaferllc/keel/core` (the published entry point) instead of the internal
1515
+ `@keel/core` alias, so scaffolded code compiles in a real project.
1516
+ - **`Connection.select` is no longer generic** — it returns `Promise<Row[]>`, so a
1517
+ driver implementation no longer needs an `as Connection` cast. `db<T>()` still
1518
+ types results (the builder casts internally).
1519
+ - **`hash.verify` never throws.** A malformed hash (right prefix but a non-numeric
1520
+ iteration count or invalid base64) now returns `false` instead of throwing.
1521
+ - **Sessions handle non-Latin1 values.** Cookie serialization is UTF-8-safe, so
1522
+ storing emoji or non-Latin text no longer crashes the response (`btoa` throw).
1523
+ - **`router.url()` fills repeated params.** A `:param` appearing more than once in
1524
+ a path is now fully substituted, and won't match inside a longer param name.
1525
+
1526
+ [0.37.1]: https://github.com/shaferllc/keel/releases/tag/v0.37.1
1527
+
1528
+ ## [0.37.0] — 2026-07-10
1529
+
1530
+ ### Added
1531
+
1532
+ - **Transformers.** A presentation layer between your models and your JSON:
1533
+ subclass `Transformer<T>`, define one `transform()`, and get `item` /
1534
+ `collection` / `document`. `when(condition, value)` includes a field only when
1535
+ a condition holds — omitting the key entirely rather than leaking `null` — with
1536
+ a `mergeWhen` counterpart for groups of fields and thunks for deferred values.
1537
+ `whenLoaded(model, name, transformer)` embeds a relation only if it was
1538
+ eager-loaded, so a transformer never fires a surprise query. `document()` wraps
1539
+ the payload under a key (`data` by default) with top-level `meta`. Edge-safe;
1540
+ depends on nothing but the value you hand it. New generator
1541
+ `keel make:transformer`. See [docs/transformers.md](./docs/transformers.md).
1542
+ - **Templates.** A string templating engine:
1543
+ `{{ }}` / `{{{ }}}` interpolation, `{{-- comments --}}`, and `@`-tags —
1544
+ `@if` / `@elseif` / `@else`, `@each` (with `$loop`), `@include` / `@includeIf`,
1545
+ `@set`, layouts (`@layout` / `@section` / `@yield`), components with slots
1546
+ (`@component` / `@slot`), filters (`{{ name | upper }}`), globals, and `@dump`.
1547
+ `templates().register(name, src)` then `render(name, state)`. Unlike engines
1548
+ that compile to a function, Keel *interprets* templates against a safe
1549
+ expression evaluator instead of `eval` / `new Function`, so the same templates
1550
+ run on Node and on Workers.
1551
+ See [docs/templates.md](./docs/templates.md).
1552
+
1553
+ [0.37.0]: https://github.com/shaferllc/keel/releases/tag/v0.37.0
1554
+
1555
+ ## [0.36.0] — 2026-07-10
1556
+
1557
+ ### Added
1558
+
1559
+ - **Notifications.** Send a message to one or many recipients over pluggable
1560
+ channels: `notify(user, new InvoicePaid(4200))`. A `Notification` declares
1561
+ `via()` (channels) and per-channel content (`toMail`, `toArray`). Built-in
1562
+ channels: `MailChannel` (via the mailer, routed by `email` or
1563
+ `routeNotificationFor`), `DatabaseChannel` (inserts `toArray` into a table),
1564
+ and `ArrayChannel` (for tests). Set `shouldQueue = true` to deliver from a
1565
+ queued job. This is where the mail and queue layers compose — a custom channel
1566
+ is one `send` method. New generator `keel make:notification`. See
1567
+ [docs/notifications.md](./docs/notifications.md).
1568
+
1569
+ [0.36.0]: https://github.com/shaferllc/keel/releases/tag/v0.36.0
1570
+
1571
+ ## [0.35.0] — 2026-07-10
1572
+
1573
+ ### Added
1574
+
1575
+ - **Queues & jobs.** Move slow work off the request path: `dispatch(new
1576
+ SendWelcome(id))` places a `Job` (or a plain function) on a queue, and a
1577
+ pluggable `QueueDriver` decides when it runs. Built-in drivers: `SyncDriver`
1578
+ (runs immediately — the default), `MemoryDriver` (defers; `work()` drains it
1579
+ FIFO, inspect `.jobs`). `dispatch` takes `{ delay, queue }` options; a custom
1580
+ `push`-only driver is the seam for a real broker (e.g. Cloudflare Queues).
1581
+ New generator `keel make:job`. Core imports no broker, edge-safe. See
1582
+ [docs/queues.md](./docs/queues.md).
1583
+
1584
+ [0.35.0]: https://github.com/shaferllc/keel/releases/tag/v0.35.0
1585
+
1586
+ ## [0.34.0] — 2026-07-10
1587
+
1588
+ ### Added
1589
+
1590
+ - **Mail.** A fluent, edge-safe mailer: `mail().to().subject().html().send()`,
1591
+ with a pluggable `Transport` (like the database `Connection`). Register a
1592
+ default with `setMailer(transport, { from })`. Built-in transports:
1593
+ `ArrayTransport` (collects to `.sent`, the default and ideal for tests),
1594
+ `LogTransport` (logs instead of delivering), and `fetchTransport({ url,
1595
+ headers, body })` for provider HTTP APIs (Resend/Postmark/Mailgun) over
1596
+ `fetch` — the core imports no SDK. `send()` validates recipient/subject/body/
1597
+ from. See [docs/mail.md](./docs/mail.md).
1598
+
1599
+ [0.34.0]: https://github.com/shaferllc/keel/releases/tag/v0.34.0
1600
+
1601
+ ## [0.33.0] — 2026-07-10
1602
+
1603
+ ### Added
1604
+
1605
+ - **Model attribute casts.** `static casts = { active: "boolean", meta: "json",
1606
+ joined_at: "date" }` round-trips columns as real JS types — cast when read
1607
+ (from the database or `fill`) and back to storable primitives on write. This
1608
+ is what lets `boolean`/`json` columns bind cleanly on drivers that reject JS
1609
+ booleans and objects. Types: `int`, `float`, `boolean`, `string`, `json` /
1610
+ `array`, `date`.
1611
+ - **Mass-assignment guarding.** `static fillable` (allowlist) or `static
1612
+ guarded` (denylist) filter the attributes `create()` and `fill()` accept, so
1613
+ untrusted request data can't over-post protected columns. `forceFill()`
1614
+ bypasses it deliberately. With neither declared, behavior is unchanged
1615
+ (backward compatible). See [docs/models.md](./docs/models.md#attribute-casts).
1616
+
1617
+ [0.33.0]: https://github.com/shaferllc/keel/releases/tag/v0.33.0
1618
+
1619
+ ## [0.32.0] — 2026-07-10
1620
+
1621
+ ### Added
1622
+
1623
+ - **Factories & seeders.** `factory(Model, (f, i) => ({ ... }))` builds model
1624
+ attributes with a built-in, dependency-free `Faker` (names, emails, words,
1625
+ numbers, uuids — seedable for deterministic runs). Call `.make()` (unsaved) or
1626
+ `.create()` (persisted), `.count(n)` for batches, and override attributes
1627
+ inline. `Seeder` classes have a `run()` and can `call([OtherSeeder])` to
1628
+ compose; `seed(DatabaseSeeder)` runs one. New generators `keel make:factory`
1629
+ and `keel make:seeder`. Edge-safe (no external faker library). See
1630
+ [docs/factories.md](./docs/factories.md).
1631
+
1632
+ [0.32.0]: https://github.com/shaferllc/keel/releases/tag/v0.32.0
1633
+
1634
+ ## [0.31.0] — 2026-07-10
1635
+
1636
+ ### Added
1637
+
1638
+ - **Model relationships.** Define relationships as methods on your model:
1639
+ `hasMany` / `hasOne` / `belongsTo` / `belongsToMany`, with conventional
1640
+ foreign keys (`user_id`) you can override. Relations are awaitable
1641
+ (`await user.posts()`), expose `.query()` to drop to the builder, and
1642
+ `Model.load(models, "posts", "roles")` eager-loads with one `whereIn` per
1643
+ relation (fixes N+1). `belongsToMany` reads through a pivot table and offers
1644
+ `attach` / `detach` / `sync`. Loaded relations stay out of `save()` and
1645
+ serialize through `toJSON()`. Runs entirely on the query builder — no JOINs,
1646
+ edge-safe. See [docs/models.md](./docs/models.md#relationships).
1647
+
1648
+ [0.31.0]: https://github.com/shaferllc/keel/releases/tag/v0.31.0
1649
+
1650
+ ## [0.30.0] — 2026-07-10
1651
+
1652
+ ### Added
1653
+
1654
+ - **Migrations.** A fluent schema builder (`schema.createTable(name, t => { t.id();
1655
+ t.string("email").unique(); t.timestamps(); })`) and a `Migrator` that runs
1656
+ `{ name, up, down }` migrations against your connection, tracking applied ones
1657
+ in a `migrations` table (`up`/`down`/`ran`, batched). Dialect-aware SQL
1658
+ (sqlite/mysql/postgres). See [docs/migrations.md](./docs/migrations.md).
1659
+
1660
+ [0.30.0]: https://github.com/shaferllc/keel/releases/tag/v0.30.0
1661
+
1662
+ ## [0.29.0] — 2026-07-10
1663
+
1664
+ ### Added
1665
+
1666
+ - **Active-record `Model`.** Subclass `Model`, set a `table`, and get static
1667
+ `find` / `findOrFail` / `all` / `first` / `where` / `create` plus instance
1668
+ `save` (insert or update), `delete`, `fill`, and `toJSON`. Built on the query
1669
+ builder, so it runs on any registered connection (edge-safe). `Model.query()`
1670
+ drops to the raw builder for richer queries. See [docs/models.md](./docs/models.md).
1671
+
1672
+ [0.29.0]: https://github.com/shaferllc/keel/releases/tag/v0.29.0
1673
+
1674
+ ## [0.28.0] — 2026-07-10
1675
+
1676
+ ### Added
1677
+
1678
+ - **Database query builder.** A driver-agnostic, parameterized query builder:
1679
+ `db(table).where().orderBy().limit().get()/first()/count()/exists()`, plus
1680
+ `whereIn` / `whereNull` / `orWhere`, and `insert` / `insertGetId` / `update` /
1681
+ `delete`. Runs through a two-method `Connection` you register with
1682
+ `setConnection(conn, dialect)` — works with D1, Neon/Postgres, PlanetScale,
1683
+ Turso, better-sqlite3, `pg`. The core imports no driver (edge-safe). See
1684
+ [docs/database.md](./docs/database.md).
1685
+
1686
+ [0.28.0]: https://github.com/shaferllc/keel/releases/tag/v0.28.0
1687
+
1688
+ ## [0.27.0] — 2026-07-10
1689
+
1690
+ ### Added
1691
+
1692
+ - **Authentication.** Session-based auth: `auth().login(id)` / `logout()` /
1693
+ `check()` / `guest()` / `id()` / `user()`, a pluggable user provider via
1694
+ `setUserProvider()`, and an `authGuard({ redirectTo? })` middleware (401 or
1695
+ redirect). Built on the session + hash primitives. See
1696
+ [docs/authentication.md](./docs/authentication.md).
1697
+
1698
+ [0.27.0]: https://github.com/shaferllc/keel/releases/tag/v0.27.0
1699
+
1700
+ ## [0.26.0] — 2026-07-10
1701
+
1702
+ ### Added
1703
+
1704
+ - **Logger.** A leveled logger (`logger().debug/info/warn/error`) with structured
1705
+ JSON output (pretty in debug), a level threshold from `config('logger.level')`,
1706
+ and `logger().child({ … })` for bound fields. See [docs/logger.md](./docs/logger.md).
1707
+
1708
+ [0.26.0]: https://github.com/shaferllc/keel/releases/tag/v0.26.0
1709
+
1710
+ ## [0.25.0] — 2026-07-10
1711
+
1712
+ ### Added
1713
+
1714
+ - **Rate limiting.** `rateLimiter({ max, window, key, message })` — a fixed-window
1715
+ limiter middleware with per-key buckets (client IP by default), the standard
1716
+ `X-RateLimit-*` / `Retry-After` headers, and `429` on exceed. In-memory store
1717
+ (pluggable for distributed limiting). See [docs/rate-limiting.md](./docs/rate-limiting.md).
1718
+
1719
+ [0.25.0]: https://github.com/shaferllc/keel/releases/tag/v0.25.0
1720
+
1721
+ ## [0.24.0] — 2026-07-10
1722
+
1723
+ ### Added
1724
+
1725
+ - **Password hashing.** `hash.make(password)` (PBKDF2-SHA256, self-describing),
1726
+ `hash.verify(hashed, password)` (timing-safe), and `hash.needsRehash()`.
1727
+ - **Value encryption.** `encryption.encrypt(value)` / `encryption.decrypt(token)`
1728
+ (AES-GCM, keyed by `config('app.key')`; `decrypt` returns `null` on tamper).
1729
+ - Both use the Web Crypto API — edge-safe, no native bindings. See
1730
+ [docs/hashing.md](./docs/hashing.md).
1731
+
1732
+ [0.24.0]: https://github.com/shaferllc/keel/releases/tag/v0.24.0
1733
+
1734
+ ## [0.23.0] — 2026-07-10
1735
+
1736
+ ### Added
1737
+
1738
+ - **Debugging helpers.** `dump(...values)` prints to the console and returns its
1739
+ first argument (inline-friendly); `dd(...values)` dumps to the browser and
1740
+ halts the request via a self-rendering exception. Both edge-safe. See
1741
+ [docs/debugging.md](./docs/debugging.md).
1742
+
1743
+ [0.23.0]: https://github.com/shaferllc/keel/releases/tag/v0.23.0
1744
+
1745
+ ## [0.22.0] — 2026-07-10
1746
+
1747
+ ### Added
1748
+
1749
+ - **Self-handling & reportable exceptions.** An exception with a `handle(c)`
1750
+ method renders itself; one with a `report()` method has it called (and
1751
+ awaited) before rendering — for logging/metrics, without masking the error.
1752
+ - **Error codes.** `HttpException` now carries an optional `code` (e.g.
1753
+ `E_UNAUTHORIZED`), included in the JSON error body. See
1754
+ [docs/errors.md](./docs/errors.md).
1755
+
1756
+ [0.22.0]: https://github.com/shaferllc/keel/releases/tag/v0.22.0
1757
+
1758
+ ## [0.21.0] — 2026-07-10
1759
+
1760
+ ### Added
1761
+
1762
+ - **URL builder.** `router.url(name, params, { qs })` now takes a query string.
1763
+ - **Signed URLs.** `router.signedUrl(name, params, { qs, expiresIn })` produces a
1764
+ tamper-proof link (HMAC-SHA256 via Web Crypto, keyed by `config('app.key')`);
1765
+ `router.hasValidSignature()` verifies the current request. Edge-safe. See
1766
+ [docs/url-builder.md](./docs/url-builder.md).
1767
+
1768
+ [0.21.0]: https://github.com/shaferllc/keel/releases/tag/v0.21.0
1769
+
1770
+ ## [0.20.0] — 2026-07-10
1771
+
1772
+ ### Added
1773
+
1774
+ - **Named middleware registry.** `router.named({ auth, admin })` registers
1775
+ middleware by name; reference it with `.use("auth")` / `.middleware([...])` on
1776
+ routes, groups, and resources. Names resolve when the app builds (unknown
1777
+ names throw). Raw functions still work everywhere. See
1778
+ [docs/middleware.md](./docs/middleware.md).
1779
+
1780
+ [0.20.0]: https://github.com/shaferllc/keel/releases/tag/v0.20.0
1781
+
1782
+ ## [0.19.0] — 2026-07-10
1783
+
1784
+ ### Added
1785
+
1786
+ - **File uploads.** `request.file(name)`, `request.files(name)`, and
1787
+ `request.allFiles()` return web-standard `File` objects (edge-safe, no temp
1788
+ dir). The parsed `FormData` is cached per request, so file access and
1789
+ `request.all()` coexist.
1790
+ - **Content negotiation.** `request.accepts([...])`, `request.types()`,
1791
+ `request.language([...])`, `request.languages()`.
1792
+ - **Request meta.** `request.hasBody()`, `request.headers()`, `request.ips()`.
1793
+ - **Response helpers.** `response.type(mime)`, `response.append(name, value)`,
1794
+ `response.removeHeader(name)`, and the guards `response.abortIf(cond, …)` /
1795
+ `response.abortUnless(cond, …)`.
1796
+
1797
+ [0.19.0]: https://github.com/shaferllc/keel/releases/tag/v0.19.0
1798
+
1799
+ ## [0.18.0] — 2026-07-10
1800
+
1801
+ ### Added
1802
+
1803
+ - **Static file server.** `serveStatic(options)` serves files from a directory
1804
+ (default `public/`) before your routes, with `ETag` / `Last-Modified` / `304`
1805
+ handling, `Cache-Control` (`maxAge` / `immutable`), a dot-file policy
1806
+ (`ignore` / `deny` / `allow`), per-file `headers()`, and path-traversal
1807
+ protection. `node:fs` is imported dynamically so the core still loads on the
1808
+ edge. See [docs/static-files.md](./docs/static-files.md).
1809
+
1810
+ [0.18.0]: https://github.com/shaferllc/keel/releases/tag/v0.18.0
1811
+
1812
+ ## [0.17.0] — 2026-07-10
1813
+
1814
+ ### Added
1815
+
1816
+ - **Cache.** A memory-backed cache with TTLs and the `remember` pattern:
1817
+ `cache().get/put/has/forget/pull/flush`, `cache().remember(key, ttl, fn)`, and
1818
+ `rememberForever`. Pluggable via the `CacheStore` interface (swap in Redis/KV).
1819
+ See [docs/cache.md](./docs/cache.md).
1820
+
1821
+ [0.17.0]: https://github.com/shaferllc/keel/releases/tag/v0.17.0
1822
+
1823
+ ## [0.16.0] — 2026-07-10
1824
+
1825
+ ### Added
1826
+
1827
+ - **Events.** A tiny event emitter for decoupling — `emit(event, payload)` and
1828
+ `listen(event, fn)` global helpers, plus `events()` for `once` / `off` /
1829
+ `listenerCount` / `clear`. Listeners may be async and are awaited in order.
1830
+ See [docs/events.md](./docs/events.md).
1831
+
1832
+ [0.16.0]: https://github.com/shaferllc/keel/releases/tag/v0.16.0
1833
+
1834
+ ## [0.15.0] — 2026-07-10
1835
+
1836
+ ### Added
1837
+
1838
+ - **Sessions.** A cookie-backed session store (edge-safe, no external service):
1839
+ `session().get/put/has/forget/pull/increment/clear/all`, plus **flash**
1840
+ messages (`session().flash()` / `session().flashed()`) that survive one
1841
+ request. Enable with `sessionMiddleware()` in your HTTP kernel. See
1842
+ [docs/sessions.md](./docs/sessions.md).
1843
+
1844
+ [0.15.0]: https://github.com/shaferllc/keel/releases/tag/v0.15.0
1845
+
1846
+ ## [0.14.0] — 2026-07-10
1847
+
1848
+ ### Added
1849
+
1850
+ - **Request input API.** `request.all()`, `request.input(key, fallback?)`,
1851
+ `request.only([...])`, `request.except([...])` (merge query + parsed body),
1852
+ plus `request.ip()`.
1853
+ - **Cookies.** `request.cookie(name?)`, `response.cookie(name, value, options)`,
1854
+ and `response.clearCookie(name)`.
1855
+ - **Response helpers.** `response.send(data)` (objects → JSON, else text) and
1856
+ `response.abort(message, status)` (throws an `HttpException`).
1857
+ - See [docs/request-response.md](./docs/request-response.md).
1858
+
1859
+ [0.14.0]: https://github.com/shaferllc/keel/releases/tag/v0.14.0
1860
+
1861
+ ## [0.13.0] — 2026-07-10
1862
+
1863
+ ### Added
1864
+
1865
+ - **Single-action controllers.** `router.post("/publish", [PublishPost])` calls
1866
+ the controller's `handle` method.
1867
+ - **Lazy-loaded controllers.** `[() => import("../Controllers/X.js"), "index"]`
1868
+ — the controller is imported only when its route is first hit.
1869
+ - **Richer resources.** `RouteResource` gained `.as(name)`, `.params({ … })`,
1870
+ and `.use(actions, mw)`; `router.resource("posts.comments", C)` nests
1871
+ resources (`/posts/:post_id/comments/:id`).
1872
+ - **`make:controller --resource`** generates a controller with all seven RESTful
1873
+ actions.
1874
+ - See [docs/controllers.md](./docs/controllers.md).
1875
+
1876
+ [0.13.0]: https://github.com/shaferllc/keel/releases/tag/v0.13.0
1877
+
1878
+ ## [0.12.0] — 2026-07-10
1879
+
1880
+ ### Added
1881
+
1882
+ - **Inertia.js server adapter.** `inertia("Page", props)` and
1883
+ `router.on(path).renderInertia(...)` — full HTML on first load, JSON page
1884
+ object on XHR navigations, asset-version 409s, and partial reloads. Configure
1885
+ an `Inertia` instance (root view + version) in a provider. See
1886
+ [docs/inertia.md](./docs/inertia.md).
1887
+ - **Domain / subdomain routing.** `route.domain(pattern)` and
1888
+ `group(...).domain(":tenant.example.com")`, dispatched by `Host`; subdomain
1889
+ params via `request.subdomain(name)`.
1890
+ - **Route matchers & global constraints.** `router.matchers.number()/uuid()/slug()/alpha()`,
1891
+ a global `router.where(param, matcher)`, group `.where()`, and the `{ match }`
1892
+ matcher form.
1893
+ - **Brisk-route helpers.** `on().renderInertia()`, `on().redirectToPath()`, and
1894
+ `on().redirectToRoute(name, params, { qs })`.
1895
+ - **Current route.** `request.route` (`{ name, pattern, methods }`) and
1896
+ `request.routeIs(name)`.
1897
+ - **`.use()`** middleware alias on routes and groups.
1898
+
1899
+ ### Tests
1900
+
1901
+ - Suite grown to 45 tests; ~99% line coverage maintained.
1902
+
1903
+ [0.12.0]: https://github.com/shaferllc/keel/releases/tag/v0.12.0
1904
+
1905
+ ## [0.11.0] — 2026-07-10
1906
+
1907
+ ### Added
1908
+
1909
+ - **First-class routing.** The router gained a fluent API:
1910
+ - **Named routes** + `router.url(name, params)` for URL generation.
1911
+ - **Route groups** — `router.group(cb).prefix().middleware().as()`.
1912
+ - **Resource routes** — `router.resource(name, Controller)` with
1913
+ `.only()`/`.except()`/`.apiOnly()`.
1914
+ - **Per-route middleware** — `route.middleware([...])`.
1915
+ - **Param constraints** — `route.where("id", /\d+/)`.
1916
+ - **`router.on(path).redirect(to)` / `.render(Component)`** convenience routes.
1917
+ - **`router.any()`** and **`router.route(methods, path, handler)`**.
1918
+ - `keel routes` now lists verbs and route names.
1919
+
1920
+ ### Changed
1921
+
1922
+ - `RouteDefinition.method` → `methods: Method[]` (routes can match multiple
1923
+ verbs); route defs also carry `name`, `middleware`, and `wheres`.
1924
+
1925
+ [0.11.0]: https://github.com/shaferllc/keel/releases/tag/v0.11.0
1926
+
1927
+ ## [0.10.0] — 2026-07-10
1928
+
1929
+ ### Added
1930
+
1931
+ - **Request validation.** `validate(schema, data?)` parses input (the JSON body
1932
+ by default) and returns typed data, or throws a `ValidationException` that the
1933
+ kernel renders as a 422 with per-field `errors`. Schema-agnostic — works with
1934
+ any Zod-style `safeParse` schema, so the framework doesn't bundle a validation
1935
+ library. See [docs/validation.md](./docs/validation.md).
1936
+
1937
+ [0.10.0]: https://github.com/shaferllc/keel/releases/tag/v0.10.0
1938
+
1939
+ ## [0.9.0] — 2026-07-10
1940
+
1941
+ ### Added
1942
+
1943
+ - **Static response routes.** Pass a ready-made response as a handler, no
1944
+ closure: `router.get("/health", json({ status: "ok" }))`. The router clones
1945
+ the response per request. (Dynamic responses that read the request still use a
1946
+ closure, since `param()` etc. run per request.)
1947
+ - **`response` accessor.** Mirrors `request`: `response.json()`, `response.text()`,
1948
+ `response.html()`, `response.redirect()`, plus chainable `response.status(code)`
1949
+ and `response.header(name, value)`.
1950
+
1951
+ ### Changed
1952
+
1953
+ - `json()`, `text()`, `html()`, and `redirect()` now work outside a request too
1954
+ (returning a plain `Response`), which is what makes static-response routes
1955
+ possible. Inside a handler they still build on the context.
1956
+
1957
+ [0.9.0]: https://github.com/shaferllc/keel/releases/tag/v0.9.0
1958
+
1959
+ ## [0.8.0] — 2026-07-10
1960
+
1961
+ ### Added
1962
+
1963
+ - **`request` accessor.** A flat view of the current request/response —
1964
+ `request.method`, `request.path`, `request.url`, `request.status`, plus
1965
+ `request.header()`, `request.param()`, `request.query()`, `request.json()`,
1966
+ and `request.raw`. Write `` `${request.method} ${request.path} → ${request.status}` ``
1967
+ in a logger without touching `c`.
1968
+
1969
+ ### Changed
1970
+
1971
+ - `request` is now this accessor object rather than a function returning the raw
1972
+ Request; use `request.raw` for the underlying `Request`.
1973
+
1974
+ [0.8.0]: https://github.com/shaferllc/keel/releases/tag/v0.8.0
1975
+
1976
+ ## [0.7.0] — 2026-07-10
1977
+
1978
+ ### Added
1979
+
1980
+ - **Global container helpers.** `bind()`, `singleton()`, `instance()`,
1981
+ `make()`, and `bound()` operate on the active application, so you can register
1982
+ and resolve services from anywhere without `this.app` — e.g. `bind("clock",
1983
+ () => new Date())` and `make("clock")`. The `this.app.*` methods still work.
1984
+
1985
+ With this, Keel's whole surface is reachable as flat, easy-to-remember helpers:
1986
+ `config` · `view` · `json`/`text`/`html`/`redirect` · `param`/`query`/`body` ·
1987
+ `bind`/`singleton`/`instance`/`make` · `app`.
1988
+
1989
+ [0.7.0]: https://github.com/shaferllc/keel/releases/tag/v0.7.0
1990
+
1991
+ ## [0.6.0] — 2026-07-10
1992
+
1993
+ ### Added
1994
+
1995
+ - **Request & response helpers.** `json()`, `text()`, `html()`, `redirect()`,
1996
+ `param()`, `query()`, `header()`, `body()`, `request()`, and `ctx()` reach the
1997
+ current request without threading the context — write `json({ id: param("id") })`
1998
+ instead of `c.json({ id: c.req.param("id") })`. Backed by async-context storage
1999
+ the HTTP kernel enables per request. Taking `c` explicitly still works.
2000
+
2001
+ [0.6.0]: https://github.com/shaferllc/keel/releases/tag/v0.6.0
2002
+
2003
+ ## [0.5.0] — 2026-07-10
2004
+
2005
+ ### Added
2006
+
2007
+ - **Error & exception handling.** Throw `HttpException` (or `NotFoundException`,
2008
+ `UnauthorizedException`, `ForbiddenException`, `ValidationException`) anywhere
2009
+ and the HTTP kernel renders the right response — JSON or HTML by `Accept`, a
2010
+ readable stack-trace error page when `app.debug` is on, and hidden internals
2011
+ for unexpected 500s in production. Unmatched routes become a tidy 404.
2012
+ Customize via `kernel.onError(handler)` or by overriding `renderException`.
2013
+ See [docs/errors.md](./docs/errors.md).
2014
+
2015
+ [0.5.0]: https://github.com/shaferllc/keel/releases/tag/v0.5.0
2016
+
2017
+ ## [0.4.0] — 2026-07-10
2018
+
2019
+ ### Added
2020
+
2021
+ - **Global `view()` helper.** Render a view component in one call:
2022
+ `view(WelcomePage, { appName })` — props are type-checked against the
2023
+ component, and it returns a full HTML document. `view(HomePage)` works for
2024
+ components with no props. Sugar over the `View` service, matching `config()`.
2025
+
2026
+ [0.4.0]: https://github.com/shaferllc/keel/releases/tag/v0.4.0
2027
+
2028
+ ## [0.3.0] — 2026-07-10
2029
+
2030
+ ### Added
2031
+
2032
+ - **Global `config()` and `app()` helpers.** Read configuration from anywhere
2033
+ with `config("app.name")` / `config("app.port", 3000)` — no need to resolve
2034
+ the container by hand. `app()` returns the active application. Both resolve
2035
+ against the application registered automatically on construction.
2036
+ - **Published as `@shaferllc/keel`.** The framework is now a proper npm package
2037
+ with a real build (compiled JS + `.d.ts` in `dist/`). Apps install it with
2038
+ `npm install @shaferllc/keel` and import from `@shaferllc/keel/core`, so they
2039
+ receive core updates through `npm update`.
2040
+
2041
+ ### Changed
2042
+
2043
+ - Documentation and copy no longer describe Keel by comparison to other
2044
+ frameworks — it stands on its own.
2045
+
2046
+ [0.3.0]: https://github.com/shaferllc/keel/releases/tag/v0.3.0
2047
+
2048
+ ## [0.2.0] — 2026-07-10
2049
+
2050
+ Views, and a core that runs on the edge.
2051
+
2052
+ ### Added
2053
+
2054
+ - **View layer** — a `View` service that renders [Hono JSX](https://hono.dev)
2055
+ components to HTML. Views live in `resources/views/`; layouts are just
2056
+ components. Platform-neutral, so the same views run on Node and Cloudflare
2057
+ Workers. See [docs/views.md](./docs/views.md).
2058
+ - **`keel/core` package export** — the framework core is now installable by
2059
+ other apps (`import { Application } from "keel/core"`). Only `src/core` ships
2060
+ in the published package.
2061
+
2062
+ ### Changed
2063
+
2064
+ - **Workers-safe core** — `Application` no longer statically imports Node
2065
+ built-ins or dotenv; they're loaded dynamically only when filesystem config
2066
+ discovery runs. `boot(providers, { discoverConfig: false, config })` lets you
2067
+ configure inline on runtimes without a filesystem (e.g. Cloudflare Workers).
2068
+
2069
+ [0.2.0]: https://github.com/shaferllc/keel/releases/tag/v0.2.0
2070
+
2071
+ ## [0.1.0] — 2026-07-10
2072
+
2073
+ The first release: the **MVP core**. Enough of a framework to build and serve a
2074
+ real application.
2075
+
2076
+ ### Added
2077
+
2078
+ - **Service container** — `bind` / `singleton` / `instance` / `make`, with
2079
+ string, symbol, and class tokens and auto-construction of unbound classes.
2080
+ - **Application kernel** — loads `.env`, auto-loads `config/*.ts`, and runs the
2081
+ service-provider `register()` → `boot()` lifecycle.
2082
+ - **Configuration** — dot-notation `Config` repository plus a type-coercing
2083
+ `env()` helper.
2084
+ - **Service providers** — `ServiceProvider` base class and a `bootstrap`
2085
+ provider list.
2086
+ - **Routing** — a `Router` facade over Hono; handlers may be closures or
2087
+ `[Controller, method]` tuples resolved from the container.
2088
+ - **HTTP kernel** — global middleware stack that compiles routes onto Hono,
2089
+ served by `@hono/node-server`. Ships with a request-logging middleware.
2090
+ - **Console (`keel`)** — `serve`, `routes`, and `make:controller`,
2091
+ `make:provider`, `make:middleware` generators with an overwrite guard.
2092
+ - **Documentation** — getting started, container, providers, configuration,
2093
+ routing, middleware, console, and architecture guides.
2094
+
2095
+ [0.1.0]: https://github.com/shaferllc/keel/releases/tag/v0.1.0