@shaferllc/keel 0.68.0 → 0.74.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -0
- package/README.md +14 -5
- package/dist/api/api.config.stub +9 -0
- package/dist/api/config.d.ts +13 -0
- package/dist/api/config.js +14 -0
- package/dist/api/index.d.ts +16 -0
- package/dist/api/index.js +13 -0
- package/dist/api/provider.d.ts +10 -0
- package/dist/api/provider.js +17 -0
- package/dist/api/query.d.ts +35 -0
- package/dist/api/query.js +42 -0
- package/dist/api/resource.d.ts +91 -0
- package/dist/api/resource.js +188 -0
- package/dist/core/application.js +6 -0
- package/dist/core/cache.d.ts +1 -2
- package/dist/core/cache.js +9 -2
- package/dist/core/cli/stubs.d.ts +14 -0
- package/dist/core/cli/stubs.js +105 -0
- package/dist/core/console-prompt.d.ts +79 -0
- package/dist/core/console-prompt.js +239 -0
- package/dist/core/console-ui.d.ts +96 -0
- package/dist/core/console-ui.js +187 -0
- package/dist/core/console.d.ts +188 -0
- package/dist/core/console.js +395 -0
- package/dist/core/database.d.ts +70 -1
- package/dist/core/database.js +174 -15
- package/dist/core/env.d.ts +96 -0
- package/dist/core/env.js +140 -0
- package/dist/core/health.d.ts +2 -2
- package/dist/core/health.js +2 -2
- package/dist/core/http/kernel.d.ts +2 -0
- package/dist/core/http/kernel.js +48 -0
- package/dist/core/http/router.d.ts +5 -5
- package/dist/core/http/router.js +5 -5
- package/dist/core/i18n.d.ts +162 -0
- package/dist/core/i18n.js +472 -0
- package/dist/core/index.d.ts +25 -4
- package/dist/core/index.js +13 -3
- package/dist/core/instrumentation.d.ts +113 -0
- package/dist/core/instrumentation.js +52 -0
- package/dist/core/logger.d.ts +7 -0
- package/dist/core/logger.js +28 -1
- package/dist/core/notification.js +10 -1
- package/dist/core/package.d.ts +120 -0
- package/dist/core/package.js +169 -0
- package/dist/core/pages.d.ts +108 -0
- package/dist/core/pages.js +199 -0
- package/dist/core/queue.js +26 -5
- package/dist/core/repl.d.ts +33 -0
- package/dist/core/repl.js +88 -0
- package/dist/core/scheduler.js +6 -0
- package/dist/core/social.d.ts +4 -4
- package/dist/core/social.js +4 -4
- package/dist/core/storage.js +15 -3
- package/dist/core/telemetry.d.ts +208 -0
- package/dist/core/telemetry.js +383 -0
- package/dist/core/template.d.ts +2 -3
- package/dist/core/template.js +2 -3
- package/dist/core/testing.d.ts +170 -1
- package/dist/core/testing.js +504 -2
- package/dist/db/d1.js +13 -0
- package/dist/db/pg.d.ts +13 -0
- package/dist/db/pg.js +46 -4
- package/dist/openapi/config.d.ts +28 -0
- package/dist/openapi/config.js +25 -0
- package/dist/openapi/doc.d.ts +40 -0
- package/dist/openapi/doc.js +20 -0
- package/dist/openapi/export.d.ts +8 -0
- package/dist/openapi/export.js +19 -0
- package/dist/openapi/gate.d.ts +15 -0
- package/dist/openapi/gate.js +27 -0
- package/dist/openapi/index.d.ts +19 -0
- package/dist/openapi/index.js +15 -0
- package/dist/openapi/openapi.config.stub +29 -0
- package/dist/openapi/provider.d.ts +18 -0
- package/dist/openapi/provider.js +35 -0
- package/dist/openapi/routes.d.ts +9 -0
- package/dist/openapi/routes.js +23 -0
- package/dist/openapi/spec.d.ts +23 -0
- package/dist/openapi/spec.js +132 -0
- package/dist/openapi/ui.d.ts +8 -0
- package/dist/openapi/ui.js +31 -0
- package/dist/openapi/zod.d.ts +12 -0
- package/dist/openapi/zod.js +46 -0
- package/dist/watch/config.d.ts +33 -0
- package/dist/watch/config.js +38 -0
- package/dist/watch/entry.d.ts +53 -0
- package/dist/watch/entry.js +105 -0
- package/dist/watch/gate.d.ts +20 -0
- package/dist/watch/gate.js +32 -0
- package/dist/watch/index.d.ts +21 -0
- package/dist/watch/index.js +17 -0
- package/dist/watch/migration.d.ts +7 -0
- package/dist/watch/migration.js +20 -0
- package/dist/watch/provider.d.ts +22 -0
- package/dist/watch/provider.js +58 -0
- package/dist/watch/prune.d.ts +11 -0
- package/dist/watch/prune.js +20 -0
- package/dist/watch/recorder.d.ts +24 -0
- package/dist/watch/recorder.js +39 -0
- package/dist/watch/routes.d.ts +13 -0
- package/dist/watch/routes.js +55 -0
- package/dist/watch/store.d.ts +54 -0
- package/dist/watch/store.js +158 -0
- package/dist/watch/ui/dist/watch.css +1 -0
- package/dist/watch/ui/dist/watch.js +555 -0
- package/dist/watch/ui-shell.d.ts +12 -0
- package/dist/watch/ui-shell.js +24 -0
- package/dist/watch/watch.config.stub +47 -0
- package/dist/watch/watchers.d.ts +12 -0
- package/dist/watch/watchers.js +156 -0
- package/docs/ai-manifest.json +737 -3
- package/docs/api-resources.md +118 -0
- package/docs/configuration.md +74 -0
- package/docs/console.md +193 -13
- package/docs/database.md +101 -0
- package/docs/examples/configuration.ts +40 -0
- package/docs/examples/console.ts +134 -0
- package/docs/examples/database.ts +90 -0
- package/docs/examples/i18n.ts +117 -0
- package/docs/examples/logger.ts +74 -0
- package/docs/examples/mail.ts +103 -0
- package/docs/examples/pages.ts +82 -0
- package/docs/examples/telemetry.ts +127 -0
- package/docs/examples/testing.ts +149 -0
- package/docs/health.md +4 -4
- package/docs/i18n.md +302 -0
- package/docs/logger.md +156 -10
- package/docs/mail.md +73 -0
- package/docs/openapi.md +111 -0
- package/docs/packages.md +118 -0
- package/docs/pages.md +217 -0
- package/docs/storage.md +5 -3
- package/docs/telemetry.md +263 -0
- package/docs/templates.md +3 -4
- package/docs/testing.md +251 -0
- package/docs/watch.md +118 -0
- package/llms-full.txt +2370 -248
- package/llms.txt +12 -1
- package/package.json +18 -2
package/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
|
|
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 (`
|
|
41
|
-
`error`
|
|
42
|
-
default threshold is `"info"`, so `debug`
|
|
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`,
|
|
48
|
-
to `console.log`. In JSON mode every level is written to
|
|
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.
|
package/docs/openapi.md
ADDED
|
@@ -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.
|
package/docs/packages.md
ADDED
|
@@ -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
|
+
```
|