@shaferllc/keel 0.68.0 → 0.77.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/AGENTS.md +2 -0
  2. package/README.md +15 -5
  3. package/dist/api/api.config.stub +9 -0
  4. package/dist/api/config.d.ts +13 -0
  5. package/dist/api/config.js +14 -0
  6. package/dist/api/index.d.ts +16 -0
  7. package/dist/api/index.js +13 -0
  8. package/dist/api/provider.d.ts +10 -0
  9. package/dist/api/provider.js +17 -0
  10. package/dist/api/query.d.ts +35 -0
  11. package/dist/api/query.js +42 -0
  12. package/dist/api/resource.d.ts +93 -0
  13. package/dist/api/resource.js +185 -0
  14. package/dist/core/application.js +6 -0
  15. package/dist/core/cache.d.ts +1 -2
  16. package/dist/core/cache.js +9 -2
  17. package/dist/core/cli/index.d.ts +28 -0
  18. package/dist/core/cli/index.js +427 -0
  19. package/dist/core/cli/stubs.d.ts +14 -0
  20. package/dist/core/cli/stubs.js +105 -0
  21. package/dist/core/console-prompt.d.ts +79 -0
  22. package/dist/core/console-prompt.js +239 -0
  23. package/dist/core/console-ui.d.ts +96 -0
  24. package/dist/core/console-ui.js +187 -0
  25. package/dist/core/console.d.ts +188 -0
  26. package/dist/core/console.js +395 -0
  27. package/dist/core/database.d.ts +70 -1
  28. package/dist/core/database.js +174 -15
  29. package/dist/core/env.d.ts +96 -0
  30. package/dist/core/env.js +140 -0
  31. package/dist/core/health.d.ts +2 -2
  32. package/dist/core/health.js +2 -2
  33. package/dist/core/http/kernel.d.ts +2 -0
  34. package/dist/core/http/kernel.js +48 -0
  35. package/dist/core/http/router.d.ts +5 -5
  36. package/dist/core/http/router.js +5 -5
  37. package/dist/core/i18n.d.ts +162 -0
  38. package/dist/core/i18n.js +472 -0
  39. package/dist/core/index.d.ts +25 -4
  40. package/dist/core/index.js +13 -3
  41. package/dist/core/instrumentation.d.ts +113 -0
  42. package/dist/core/instrumentation.js +52 -0
  43. package/dist/core/logger.d.ts +7 -0
  44. package/dist/core/logger.js +28 -1
  45. package/dist/core/notification.js +10 -1
  46. package/dist/core/package.d.ts +117 -0
  47. package/dist/core/package.js +169 -0
  48. package/dist/core/pages.d.ts +108 -0
  49. package/dist/core/pages.js +199 -0
  50. package/dist/core/queue.js +26 -5
  51. package/dist/core/repl.d.ts +33 -0
  52. package/dist/core/repl.js +88 -0
  53. package/dist/core/scheduler.js +6 -0
  54. package/dist/core/social.d.ts +4 -4
  55. package/dist/core/social.js +4 -4
  56. package/dist/core/storage.js +15 -3
  57. package/dist/core/telemetry.d.ts +208 -0
  58. package/dist/core/telemetry.js +383 -0
  59. package/dist/core/template.d.ts +2 -3
  60. package/dist/core/template.js +2 -3
  61. package/dist/core/testing.d.ts +171 -1
  62. package/dist/core/testing.js +505 -2
  63. package/dist/db/d1.js +13 -0
  64. package/dist/db/pg.d.ts +13 -0
  65. package/dist/db/pg.js +46 -4
  66. package/dist/openapi/config.d.ts +28 -0
  67. package/dist/openapi/config.js +25 -0
  68. package/dist/openapi/doc.d.ts +40 -0
  69. package/dist/openapi/doc.js +20 -0
  70. package/dist/openapi/export.d.ts +8 -0
  71. package/dist/openapi/export.js +19 -0
  72. package/dist/openapi/gate.d.ts +15 -0
  73. package/dist/openapi/gate.js +27 -0
  74. package/dist/openapi/index.d.ts +19 -0
  75. package/dist/openapi/index.js +15 -0
  76. package/dist/openapi/openapi.config.stub +29 -0
  77. package/dist/openapi/provider.d.ts +18 -0
  78. package/dist/openapi/provider.js +35 -0
  79. package/dist/openapi/routes.d.ts +9 -0
  80. package/dist/openapi/routes.js +23 -0
  81. package/dist/openapi/spec.d.ts +23 -0
  82. package/dist/openapi/spec.js +132 -0
  83. package/dist/openapi/ui.d.ts +8 -0
  84. package/dist/openapi/ui.js +31 -0
  85. package/dist/openapi/zod.d.ts +12 -0
  86. package/dist/openapi/zod.js +46 -0
  87. package/dist/watch/config.d.ts +33 -0
  88. package/dist/watch/config.js +38 -0
  89. package/dist/watch/entry.d.ts +53 -0
  90. package/dist/watch/entry.js +105 -0
  91. package/dist/watch/gate.d.ts +20 -0
  92. package/dist/watch/gate.js +32 -0
  93. package/dist/watch/index.d.ts +21 -0
  94. package/dist/watch/index.js +17 -0
  95. package/dist/watch/migration.d.ts +7 -0
  96. package/dist/watch/migration.js +20 -0
  97. package/dist/watch/provider.d.ts +22 -0
  98. package/dist/watch/provider.js +58 -0
  99. package/dist/watch/prune.d.ts +11 -0
  100. package/dist/watch/prune.js +22 -0
  101. package/dist/watch/recorder.d.ts +24 -0
  102. package/dist/watch/recorder.js +39 -0
  103. package/dist/watch/routes.d.ts +13 -0
  104. package/dist/watch/routes.js +55 -0
  105. package/dist/watch/store.d.ts +54 -0
  106. package/dist/watch/store.js +158 -0
  107. package/dist/watch/ui/dist/watch.css +1 -0
  108. package/dist/watch/ui/dist/watch.js +555 -0
  109. package/dist/watch/ui-shell.d.ts +12 -0
  110. package/dist/watch/ui-shell.js +24 -0
  111. package/dist/watch/watch.config.stub +47 -0
  112. package/dist/watch/watchers.d.ts +12 -0
  113. package/dist/watch/watchers.js +156 -0
  114. package/docs/ai-manifest.json +737 -3
  115. package/docs/api-resources.md +119 -0
  116. package/docs/configuration.md +74 -0
  117. package/docs/console.md +217 -13
  118. package/docs/database.md +101 -0
  119. package/docs/examples/api-resources.ts +86 -0
  120. package/docs/examples/configuration.ts +40 -0
  121. package/docs/examples/console.ts +134 -0
  122. package/docs/examples/database.ts +90 -0
  123. package/docs/examples/i18n.ts +117 -0
  124. package/docs/examples/logger.ts +74 -0
  125. package/docs/examples/mail.ts +103 -0
  126. package/docs/examples/pages.ts +82 -0
  127. package/docs/examples/telemetry.ts +127 -0
  128. package/docs/examples/testing.ts +150 -0
  129. package/docs/health.md +4 -4
  130. package/docs/i18n.md +302 -0
  131. package/docs/logger.md +156 -10
  132. package/docs/mail.md +73 -0
  133. package/docs/openapi.md +111 -0
  134. package/docs/packages.md +118 -0
  135. package/docs/pages.md +217 -0
  136. package/docs/storage.md +5 -3
  137. package/docs/telemetry.md +263 -0
  138. package/docs/templates.md +3 -4
  139. package/docs/testing.md +252 -0
  140. package/docs/watch.md +118 -0
  141. package/llms-full.txt +2389 -241
  142. package/llms.txt +13 -1
  143. package/package.json +32 -6
package/docs/logger.md CHANGED
@@ -29,23 +29,94 @@ field; steer clear of those names in your payloads.
29
29
 
30
30
  ## Levels
31
31
 
32
- `debug` < `info` < `warn` < `error`. Only events at or above the configured
33
- level are emitted. Set the threshold via config:
32
+ `trace` < `debug` < `info` < `warn` < `error` < `fatal`. Only events at or above
33
+ the configured level are emitted. Set the threshold via config:
34
34
 
35
35
  ```ts
36
36
  // config/logger.ts
37
37
  export default { level: env("LOG_LEVEL", "info") };
38
38
  ```
39
39
 
40
- Under the hood the levels are ordinal (`debug` 10, `info` 20, `warn` 30,
41
- `error` 40); a line is dropped when its level sits below the threshold. The
42
- default threshold is `"info"`, so `debug` lines stay silent until you lower it.
40
+ Under the hood the levels are ordinal (`trace` 10, `debug` 20, `info` 30, `warn`
41
+ 40, `error` 50, `fatal` 60); a line is dropped when its level sits below the
42
+ threshold. The default threshold is `"info"`, so `debug` and `trace` stay silent
43
+ until you lower it.
44
+
45
+ `log(level, message, context?)` takes the level at runtime, when it isn't known
46
+ statically.
43
47
 
44
48
  Pretty output turns on automatically when `app.debug` is true. In pretty mode
45
49
  each event is a single human-readable line —
46
50
  `[2026-07-10T…] INFO user registered {"userId":42}` — and the writer routes by
47
- level: `warn` goes to `console.warn`, `error` to `console.error`, everything else
48
- to `console.log`. In JSON mode every level is written to `console.log`.
51
+ level: `warn` goes to `console.warn`, `error` and `fatal` to `console.error`,
52
+ everything else to `console.log`. In JSON mode every level is written to
53
+ `console.log`.
54
+
55
+ `enabled: false` silences a logger entirely, at every level.
56
+
57
+ ### Don't pay for lines you won't emit
58
+
59
+ The threshold drops the *line*, but the **context object is built either way** —
60
+ so an expensive snapshot costs you even when nobody sees it. Gate it:
61
+
62
+ ```ts
63
+ if (logger().isLevelEnabled("debug")) {
64
+ logger().debug("state", { snapshot: expensiveSnapshot() });
65
+ }
66
+
67
+ // ...or the callback form
68
+ logger().ifLevelEnabled("debug", (log) => log.debug("state", { snapshot: expensiveSnapshot() }));
69
+ ```
70
+
71
+ ## Where the lines go
72
+
73
+ A **sink** is where log records land. The default writes to the console (JSON, or
74
+ pretty), but it's just a function, so logs can go anywhere — a file, an HTTP
75
+ collector, a buffer:
76
+
77
+ ```ts
78
+ import { Logger, type Sink } from "@shaferllc/keel/core";
79
+
80
+ const httpSink: Sink = (record) => {
81
+ void fetch("https://logs.example.com", { method: "POST", body: JSON.stringify(record) });
82
+ };
83
+
84
+ new Logger({ sink: httpSink });
85
+ ```
86
+
87
+ A sink receives the structured `LogRecord` — `{ level, time, msg, fields }` — not a
88
+ formatted string, so it can do what it likes with the shape. `fields` is already
89
+ redacted.
90
+
91
+ `MemorySink` collects records in memory, which is what you want in a test:
92
+
93
+ ```ts
94
+ import { Logger, MemorySink } from "@shaferllc/keel/core";
95
+
96
+ const sink = new MemorySink();
97
+ const log = new Logger({ level: "trace", sink: sink.sink });
98
+
99
+ log.info("hello", { userId: 1 });
100
+
101
+ sink.messages(); // ["hello"]
102
+ sink.at("info"); // the records at one level
103
+ sink.records[0].fields; // { userId: 1 }
104
+ sink.clear();
105
+ ```
106
+
107
+ ## Named loggers
108
+
109
+ Give a subsystem its own level or destination:
110
+
111
+ ```ts
112
+ import { setLogger, namedLogger, Logger } from "@shaferllc/keel/core";
113
+
114
+ setLogger(new Logger({ level: "trace", sink: auditSink }), "audit");
115
+
116
+ namedLogger("audit").trace("permission granted", { userId });
117
+ ```
118
+
119
+ The application's own logger stays where it is — reach that with `logger()`.
49
120
 
50
121
  ## Child loggers
51
122
 
@@ -113,7 +184,8 @@ middleware), `requestLog()` falls back to the base `logger()`.
113
184
  ## Redaction
114
185
 
115
186
  Keep secrets out of your logs with `redact` — top-level keys or dot paths. Matched
116
- values are replaced with `"[redacted]"`; the original object is never mutated:
187
+ values are replaced with `"[redacted]"`; **the original object is never mutated**,
188
+ so redacting doesn't corrupt the data you're still using:
117
189
 
118
190
  ```ts
119
191
  const log = new Logger({
@@ -124,8 +196,30 @@ log.info("login", { user: "ada", password: "s3cret", req: { headers: { authoriza
124
196
  // {"level":"info",…,"user":"ada","password":"[redacted]","req":{"headers":{"authorization":"[redacted]"}}}
125
197
  ```
126
198
 
199
+ A `*` segment matches every key at that level — which is how you catch a secret
200
+ that appears under a key you don't know in advance:
201
+
202
+ ```ts
203
+ new Logger({ redact: ["*.password", "creds.*.token"] });
204
+
205
+ log.info("audit", {
206
+ alice: { password: "a", name: "Alice" },
207
+ bob: { password: "b", name: "Bob" },
208
+ });
209
+ // both passwords redacted; both names kept
210
+ ```
211
+
212
+ Pass an object instead of an array to change the placeholder, or drop the key
213
+ outright:
214
+
215
+ ```ts
216
+ new Logger({ redact: { paths: ["password"], censor: "***" } });
217
+ new Logger({ redact: { paths: ["password"], remove: true } }); // the key disappears
218
+ ```
219
+
127
220
  Redaction is inherited by child loggers, so a redacting base logger keeps
128
- per-request loggers safe too.
221
+ per-request loggers safe too, and it runs **before** the sink — a custom sink can
222
+ never see the unredacted values.
129
223
 
130
224
  ---
131
225
 
@@ -279,7 +373,7 @@ const log = new Logger({ level: "debug", pretty: true, bindings: { app: "api" }
279
373
 
280
374
  #### `LogLevel`
281
375
 
282
- `type LogLevel = "debug" | "info" | "warn" | "error"`
376
+ `type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal"`
283
377
 
284
378
  The four severity levels, in ascending order. Used for the `level` option and
285
379
  selected implicitly by each method.
@@ -288,3 +382,55 @@ selected implicitly by each method.
288
382
  const threshold: LogLevel = "warn";
289
383
  new Logger({ level: threshold });
290
384
  ```
385
+
386
+ ### `isLevelEnabled(level)` / `ifLevelEnabled(level, fn)`
387
+
388
+ `isLevelEnabled(level: LogLevel): boolean` — whether a level would be emitted.
389
+ Check it before building an expensive context object.
390
+
391
+ `ifLevelEnabled(level: LogLevel, fn: (log: Logger) => void): void` — the callback
392
+ form.
393
+
394
+ ### `log(level, message, context?)`
395
+
396
+ `log(level: LogLevel, message: string, context?: Record<string, unknown>): void` —
397
+ log at a level chosen at runtime.
398
+
399
+ ### `consoleSink(pretty?)`
400
+
401
+ `consoleSink(pretty = false): Sink` — the default sink. JSON to stdout, or a pretty
402
+ single line.
403
+
404
+ ### `MemorySink`
405
+
406
+ Collects records in memory — for tests.
407
+
408
+ | Member | Signature |
409
+ |--------|-----------|
410
+ | `sink` | `Sink` — hand this to `LoggerOptions.sink` |
411
+ | `records` | `LogRecord[]` |
412
+ | `at` | `(level) => LogRecord[]` |
413
+ | `messages` | `() => string[]` |
414
+ | `clear` | `() => void` |
415
+
416
+ ### `setLogger(logger, name)` / `namedLogger(name)`
417
+
418
+ Register a logger under a name, and resolve it. `namedLogger` throws for an unknown
419
+ name.
420
+
421
+ ### Interfaces & types (added)
422
+
423
+ #### `Sink`
424
+
425
+ `type Sink = (record: LogRecord) => void` — where log lines go.
426
+
427
+ #### `LogRecord`
428
+
429
+ `{ level: LogLevel; time: string; msg: string; fields: Record<string, unknown> }` —
430
+ `fields` is already redacted.
431
+
432
+ #### `RedactOptions`
433
+
434
+ `{ paths: string[]; censor?: string; remove?: boolean }` — a `*` path segment
435
+ matches every key at that level. `LoggerOptions.redact` also accepts a bare
436
+ `string[]`.
package/docs/mail.md CHANGED
@@ -676,3 +676,76 @@ const opts: FetchTransportOptions = {
676
676
  body: (m) => ({ from: m.from, to: m.to, subject: m.subject, html: m.html }),
677
677
  };
678
678
  ```
679
+
680
+ ### `mailer(name?)`
681
+
682
+ `mailer(name?: string): Mailer` — the default mailer, or a named one. Throws for an
683
+ unknown name.
684
+
685
+ ### `send(email, name?)` / `sendLater(email, name?)`
686
+
687
+ `send(email: BaseMail, name?: string): Promise<Message>` — build a class-based mail
688
+ and send it. `sendLater` queues it instead.
689
+
690
+ ### `BaseMail`
691
+
692
+ Abstract. Implement `build(message: PendingMail): void | Promise<void>` to compose
693
+ the message.
694
+
695
+ ### `PendingMail.sendLater()`
696
+
697
+ `sendLater(): Promise<void>` — validate now, then put the message on the queue.
698
+
699
+ ### `PendingMail.attach()` / `.embed()`
700
+
701
+ `attach(filename, content: string | Uint8Array, contentType?): this` — content type
702
+ inferred from the extension when omitted.
703
+
704
+ `embed(cid, content, filename?, contentType?): this` — an inline attachment,
705
+ referenced from the HTML as `cid:<cid>`.
706
+
707
+ ### `PendingMail.toMessage()`
708
+
709
+ `toMessage(): Message` — the message as composed, before the mailer applies its
710
+ defaults.
711
+
712
+ ### Testing
713
+
714
+ #### `fakeMail(name?)` / `restoreMail(name?)`
715
+
716
+ `fakeMail(name?): FakeMailer` swaps a mailer for one that records instead of
717
+ delivering. `restoreMail(name?)` puts the real one back — with no name, every faked
718
+ mailer.
719
+
720
+ `FakeMailer`:
721
+
722
+ | Method | Signature |
723
+ |--------|-----------|
724
+ | `assertSent` | `(where?) => void` |
725
+ | `assertNotSent` | `(where?) => void` |
726
+ | `assertSentCount` | `(count) => void` |
727
+ | `assertQueued` | `(where?) => void` |
728
+ | `assertNotQueued` | `(where?) => void` |
729
+ | `assertQueuedCount` | `(count) => void` |
730
+ | `assertNothingSent` | `() => void` — nothing sent *and* nothing queued |
731
+ | `sent()` / `queued()` | `() => Message[]` |
732
+
733
+ ### Interfaces & types
734
+
735
+ #### `Attachment`
736
+
737
+ `{ filename, content: string | Uint8Array, contentType?, cid? }` — a `cid` makes it
738
+ an inline attachment.
739
+
740
+ #### `MailerOptions`
741
+
742
+ `{ from?, replyTo? }` — defaults applied to messages that don't set their own.
743
+
744
+ #### `RecordedMail`
745
+
746
+ `{ message: Message, queued: boolean }` — what a `FakeMailer` records.
747
+
748
+ #### `SendMailJob`
749
+
750
+ The `Job` that carries a queued message. Exported so a custom queue driver can
751
+ recognize it.
@@ -0,0 +1,111 @@
1
+ # OpenAPI
2
+
3
+ Keel OpenAPI generates an [OpenAPI 3](https://spec.openapis.org/oas/v3.0.3) spec
4
+ from your routes and serves [Swagger UI](https://swagger.io/tools/swagger-ui/) to
5
+ explore it. It's a Keel [package](./packages.md): one `register()` mounts the docs
6
+ at `/docs` and the spec at `/docs/openapi.json`.
7
+
8
+ Nothing is scraped or guessed. The generator reads Keel's own route table —
9
+ methods, paths, names, and param constraints are always correct — and enriches
10
+ each operation with whatever the route attaches via `.config(apiDoc(...))`.
11
+
12
+ ## Install
13
+
14
+ ```ts
15
+ // bootstrap/providers.ts
16
+ import { OpenApiServiceProvider } from "@shaferllc/keel/openapi";
17
+
18
+ export const providers = [AppServiceProvider, OpenApiServiceProvider];
19
+ ```
20
+
21
+ Open `http://localhost:3000/docs`. That's enough for a spec of every route (paths,
22
+ methods, path params). To add summaries, request/response schemas, and tags,
23
+ document the routes.
24
+
25
+ ## Documenting a route
26
+
27
+ `apiDoc()` returns route config the generator understands. Its `request` field is
28
+ the same `{ body, query, params }` shape you hand `validateRequest`, so one set of
29
+ Zod schemas both validates and documents:
30
+
31
+ ```ts
32
+ import { apiDoc } from "@shaferllc/keel/openapi";
33
+ import { validateRequest } from "@shaferllc/keel/core";
34
+ import { z } from "zod";
35
+
36
+ const NewUser = z.object({ email: z.string().email(), age: z.number().min(18) });
37
+
38
+ router
39
+ .post("/users", [Users, "store"])
40
+ .config(apiDoc({
41
+ summary: "Create a user",
42
+ tags: ["users"],
43
+ request: { body: NewUser },
44
+ responses: { 201: { description: "The created user", schema: UserShape } },
45
+ }))
46
+ .middleware([validateRequest({ body: NewUser })]);
47
+ ```
48
+
49
+ What the generator does with it:
50
+
51
+ - **Path params** — `/users/:id` becomes `/users/{id}`; a `.where("id", /\d+/)`
52
+ constraint becomes a `pattern`.
53
+ - **Query params** — a `request.query` schema's fields expand into query
54
+ parameters (each `required` per the schema).
55
+ - **Request body** — a `request.body` schema becomes a JSON request body
56
+ (Zod → JSON Schema via Zod 4's `z.toJSONSchema`).
57
+ - **Responses** — your documented responses, plus an automatic `422` when the
58
+ route validates input. Undocumented routes get a default `200`.
59
+ - **Tags** — `tags`, or the first path segment.
60
+ - **operationId** — the route's `.name()`, else `method_path`.
61
+
62
+ Fields on `apiDoc`: `summary`, `description`, `tags`, `operationId`,
63
+ `deprecated`, `request`, `responses`, and `hidden` (leave the route out entirely).
64
+ Response and request schemas accept a Zod schema **or** a plain JSON Schema
65
+ object.
66
+
67
+ ## Configuration
68
+
69
+ `config/openapi.ts` (publish with `keel vendor:publish --tag openapi-config`):
70
+
71
+ ```ts
72
+ export default {
73
+ enabled: true,
74
+ path: "docs", // /docs and /docs/openapi.json
75
+ title: "", // defaults to config("app.name")
76
+ version: "1.0.0",
77
+ servers: [], // e.g. ["https://api.example.com"]
78
+ public: false, // serve in production too
79
+ cdn: "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.17.14",
80
+ ignorePaths: ["/watch"], // route prefixes to leave out
81
+ };
82
+ ```
83
+
84
+ ## Access
85
+
86
+ Like [Watch](./watch.md), the docs are gated shut in production by default (open
87
+ only when `app.debug` is on or the app isn't in production). Set `public: true` to
88
+ serve them everywhere, or plug in your own check:
89
+
90
+ ```ts
91
+ import { OpenApi } from "@shaferllc/keel/openapi";
92
+ OpenApi.auth((c) => auth().check());
93
+ ```
94
+
95
+ The gate guards the spec endpoint too.
96
+
97
+ ## Exporting the spec
98
+
99
+ Write the spec to a file — for CI, client generation, or committing it:
100
+
101
+ ```bash
102
+ keel openapi:export --out openapi.json
103
+ ```
104
+
105
+ ## On the UI dependency
106
+
107
+ The spec (`/docs/openapi.json`) is generated with **zero dependencies** and runs
108
+ anywhere Keel does, including the edge. The Swagger **UI** loads its assets from
109
+ the configured `cdn` — the one external dependency, confined to the browser. Pin
110
+ the version (the default is pinned) or point `cdn` at a copy you host if you need
111
+ a fully self-contained deployment.
@@ -0,0 +1,118 @@
1
+ # Packages
2
+
3
+ A **package** is a redistributable slice of a Keel app — routes, a UI, config,
4
+ migrations, console commands — that installs with a single `app.register(...)`.
5
+ Keel's `ServiceProvider` is already the unit of composition; `PackageProvider`
6
+ adds the conventions a *shippable* package needs so it can carry its own schema
7
+ and assets instead of asking the app to wire them by hand.
8
+
9
+ [Keel Watch](./watch.md) — the debug dashboard — is a first-party package and the
10
+ reference implementation of everything below.
11
+
12
+ ## The shape of a package
13
+
14
+ ```ts
15
+ import { PackageProvider, type Router } from "@shaferllc/keel/core";
16
+ import { fileURLToPath } from "node:url";
17
+ import { dirname, join } from "node:path";
18
+
19
+ const here = dirname(fileURLToPath(import.meta.url));
20
+
21
+ export class BillingServiceProvider extends PackageProvider {
22
+ readonly name = "billing"; // used for publish grouping and diagnostics
23
+
24
+ register(): void {
25
+ this.mergeConfig("billing", { enabled: true, path: "billing" });
26
+ this.migrations([createInvoicesTable]);
27
+ this.publishes({ [join(here, "config.stub")]: "config/billing.ts" }, "billing-config");
28
+ this.commands([syncInvoicesCommand]);
29
+ }
30
+
31
+ boot(): void {
32
+ this.assets("billing/assets", join(here, "ui/dist"), { maxAge: 3600 });
33
+ this.routes((r: Router) => registerBillingRoutes(r), { prefix: "billing", as: "billing" });
34
+ }
35
+ }
36
+ ```
37
+
38
+ Scaffold that skeleton with `keel make:package billing`.
39
+
40
+ ## The helpers
41
+
42
+ Each is a thin wrapper over an existing Keel primitive — the value is the
43
+ convention, not new machinery.
44
+
45
+ | Helper | What it does |
46
+ |--------|--------------|
47
+ | `mergeConfig(key, defaults)` | Set config defaults under `key`. The app's `config/<key>.ts` deep-merges **over** them, so the app always wins. |
48
+ | `routes(register, { prefix, middleware, as })` | Register a route group (the callback gets the `Router`), already prefixed/guarded/name-prefixed. |
49
+ | `assets(urlPrefix, dir, { maxAge, immutable })` | Serve a directory of built files (a bundled UI) under a URL prefix. Node-only. |
50
+ | `migrations(list)` | Contribute migrations, run by `keel migrate` alongside the app's own. |
51
+ | `commands(list)` | Add `keel` console commands (e.g. `billing:sync`). |
52
+ | `publishes(map, tag?)` | Declare files a consuming app can copy in with `keel vendor:publish`. |
53
+
54
+ ## Lifecycle: mind the kernel
55
+
56
+ `register()` and `boot()` run **before** the app's HTTP kernel is bound (see
57
+ `bootstrap/app.ts`). So a package must not reach for `HttpKernel`. That's why
58
+ `routes()` and `assets()` go through the `Router` (bound in the Application
59
+ constructor) — routes are compiled onto the kernel later, at build time. Use
60
+ `register()` for config/bindings and `boot()` for wiring; both are safe for the
61
+ helpers above.
62
+
63
+ ## Migrations
64
+
65
+ Package migrations join the app's under one command:
66
+
67
+ ```bash
68
+ keel migrate # run pending (app + package) migrations
69
+ keel migrate:status # show which have run
70
+ keel migrate:rollback # roll back the last batch
71
+ ```
72
+
73
+ App migrations are discovered from `database/migrations/*.ts` (each file
74
+ default-exports a `Migration` or `Migration[]`); package migrations come from
75
+ `this.migrations(...)`. Both run against the default connection.
76
+
77
+ ## Publishing files
78
+
79
+ `publishes()` declares source→destination copies; `keel vendor:publish` performs
80
+ them (skipping files that already exist unless `--force`):
81
+
82
+ ```bash
83
+ keel vendor:publish # everything
84
+ keel vendor:publish --tag billing-config # just one tagged group
85
+ ```
86
+
87
+ This is how a package ships an overridable config stub, or copies a starter view
88
+ into the consuming app.
89
+
90
+ ## Observing the framework
91
+
92
+ A package often wants to *see* what the app is doing — every query, request, or
93
+ job — without patching anything. The framework emits a typed **instrumentation
94
+ event stream** for exactly this; subscribe with `listen()`:
95
+
96
+ ```ts
97
+ import { listen, type QueryEvent } from "@shaferllc/keel/core";
98
+
99
+ listen<QueryEvent>("db.query", (e) => metrics.timing("db", e.durationMs));
100
+ ```
101
+
102
+ | Event | Fired when |
103
+ |-------|-----------|
104
+ | `db.query` | a query runs (sql, bindings, durationMs, connection, kind) |
105
+ | `request.handled` | a request finishes (method, path, status, durationMs, headers) |
106
+ | `exception` | an error reaches the HTTP kernel |
107
+ | `job.processing` / `job.processed` / `job.failed` | a queued job's lifecycle |
108
+ | `cache.hit` / `cache.miss` | a cache lookup |
109
+ | `notification.sent` | a notification is delivered |
110
+ | `schedule.task.run` | a scheduled task runs |
111
+ | `mail.sending` / `mail.sent` | mail lifecycle |
112
+
113
+ Every request opens a scope with a **request id** that flows through async work,
114
+ so anything emitted inside a request can attribute itself to it via
115
+ `currentRequestId()` — that's what lets [Watch](./watch.md) tie a request to the
116
+ queries and logs it produced. Emitting is fire-and-forget: a broken listener can
117
+ never break the work it observes.
118
+ ```