@shaferllc/keel 0.66.0 → 0.68.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 +167 -0
- package/README.md +30 -1
- package/bin/keel-mcp.mjs +9 -0
- package/dist/core/application.d.ts +5 -5
- package/dist/core/application.js +2 -2
- package/dist/core/cache.d.ts +82 -5
- package/dist/core/cache.js +181 -23
- package/dist/core/cli/stubs.d.ts +12 -0
- package/dist/core/cli/stubs.js +120 -0
- package/dist/core/events.d.ts +129 -5
- package/dist/core/events.js +165 -7
- package/dist/core/health.d.ts +141 -0
- package/dist/core/health.js +226 -0
- package/dist/core/helpers.d.ts +9 -3
- package/dist/core/helpers.js +11 -3
- package/dist/core/index.d.ts +15 -10
- package/dist/core/index.js +7 -4
- package/dist/core/lock.d.ts +139 -0
- package/dist/core/lock.js +215 -0
- package/dist/core/logger.d.ts +82 -4
- package/dist/core/logger.js +141 -23
- package/dist/core/mail.d.ts +128 -7
- package/dist/core/mail.js +264 -16
- package/dist/core/queue.d.ts +134 -9
- package/dist/core/queue.js +304 -14
- package/dist/core/storage.d.ts +159 -6
- package/dist/core/storage.js +287 -7
- package/dist/mcp/server.d.ts +19 -0
- package/dist/mcp/server.js +355 -0
- package/docs/ai-manifest.json +2472 -0
- package/docs/ai.md +128 -0
- package/docs/architecture.md +331 -0
- package/docs/authentication.md +453 -0
- package/docs/authorization.md +167 -0
- package/docs/broadcasting.md +137 -0
- package/docs/broker.md +500 -0
- package/docs/cache.md +558 -0
- package/docs/configuration.md +311 -0
- package/docs/console.md +356 -0
- package/docs/container.md +467 -0
- package/docs/controllers.md +265 -0
- package/docs/cors.md +51 -0
- package/docs/database.md +530 -0
- package/docs/debugging.md +129 -0
- package/docs/decorators.md +127 -0
- package/docs/errors.md +395 -0
- package/docs/events.md +496 -0
- package/docs/examples/architecture-app.ts +27 -0
- package/docs/examples/authentication.ts +61 -0
- package/docs/examples/authorization.ts +79 -0
- package/docs/examples/broadcasting.ts +60 -0
- package/docs/examples/broker-cache-validate.ts +34 -0
- package/docs/examples/broker-fault-tolerance.ts +29 -0
- package/docs/examples/broker-middleware.ts +27 -0
- package/docs/examples/broker.ts +203 -0
- package/docs/examples/cache.ts +222 -0
- package/docs/examples/configuration.ts +81 -0
- package/docs/examples/container.ts +134 -0
- package/docs/examples/controllers.ts +86 -0
- package/docs/examples/database.ts +118 -0
- package/docs/examples/debugging.ts +41 -0
- package/docs/examples/decorators.ts +40 -0
- package/docs/examples/errors.ts +121 -0
- package/docs/examples/events.ts +204 -0
- package/docs/examples/factories.ts +84 -0
- package/docs/examples/hashing.ts +71 -0
- package/docs/examples/health.ts +94 -0
- package/docs/examples/helpers.ts +171 -0
- package/docs/examples/hooks.ts +54 -0
- package/docs/examples/inertia.ts +81 -0
- package/docs/examples/locks.ts +120 -0
- package/docs/examples/logger.ts +92 -0
- package/docs/examples/mail.ts +160 -0
- package/docs/examples/middleware.ts +119 -0
- package/docs/examples/migrations.ts +126 -0
- package/docs/examples/models.ts +239 -0
- package/docs/examples/notification.ts +124 -0
- package/docs/examples/providers.ts +123 -0
- package/docs/examples/queues.ts +254 -0
- package/docs/examples/rate-limiting.ts +42 -0
- package/docs/examples/redis.ts +99 -0
- package/docs/examples/request-response.ts +197 -0
- package/docs/examples/routing.ts +186 -0
- package/docs/examples/scheduling.ts +62 -0
- package/docs/examples/sessions.ts +102 -0
- package/docs/examples/static-files.ts +63 -0
- package/docs/examples/storage.ts +132 -0
- package/docs/examples/templates.ts +58 -0
- package/docs/examples/testing.ts +66 -0
- package/docs/examples/transformer.ts +141 -0
- package/docs/examples/transformers.ts +49 -0
- package/docs/examples/url-builder.ts +86 -0
- package/docs/examples/validation.ts +102 -0
- package/docs/examples/views.tsx +62 -0
- package/docs/examples/vite.ts +106 -0
- package/docs/factories.md +166 -0
- package/docs/getting-started.md +290 -0
- package/docs/hashing.md +259 -0
- package/docs/health.md +225 -0
- package/docs/helpers.md +347 -0
- package/docs/hono.md +186 -0
- package/docs/hooks.md +118 -0
- package/docs/inertia.md +241 -0
- package/docs/locks.md +323 -0
- package/docs/logger.md +290 -0
- package/docs/mail.md +678 -0
- package/docs/middleware.md +425 -0
- package/docs/migrations.md +476 -0
- package/docs/models.md +810 -0
- package/docs/notifications.md +474 -0
- package/docs/providers.md +363 -0
- package/docs/queues.md +679 -0
- package/docs/rate-limiting.md +155 -0
- package/docs/redis.md +178 -0
- package/docs/request-response.md +953 -0
- package/docs/routing.md +804 -0
- package/docs/scheduling.md +110 -0
- package/docs/security.md +85 -0
- package/docs/sessions.md +354 -0
- package/docs/social-auth.md +174 -0
- package/docs/static-files.md +211 -0
- package/docs/storage.md +450 -0
- package/docs/templates.md +315 -0
- package/docs/testing.md +125 -0
- package/docs/transformers.md +381 -0
- package/docs/url-builder.md +295 -0
- package/docs/validation.md +288 -0
- package/docs/views.md +267 -0
- package/docs/vite.md +434 -0
- package/llms-full.txt +17694 -0
- package/llms.txt +116 -0
- package/package.json +26 -7
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/** Code-generation templates for `keel make:*`. */
|
|
2
|
+
export function controllerStub(name) {
|
|
3
|
+
return `import type { Ctx } from "@shaferllc/keel/core";
|
|
4
|
+
|
|
5
|
+
export class ${name} {
|
|
6
|
+
index(c: Ctx) {
|
|
7
|
+
return c.json({ controller: "${name}", action: "index" });
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
`;
|
|
11
|
+
}
|
|
12
|
+
export function resourceControllerStub(name) {
|
|
13
|
+
const actions = ["index", "create", "store", "show", "edit", "update", "destroy"];
|
|
14
|
+
const body = actions
|
|
15
|
+
.map((a) => ` ${a}(c: Ctx) {\n return c.json({ action: "${a}" });\n }`)
|
|
16
|
+
.join("\n\n");
|
|
17
|
+
return `import type { Ctx } from "@shaferllc/keel/core";
|
|
18
|
+
|
|
19
|
+
export class ${name} {
|
|
20
|
+
${body}
|
|
21
|
+
}
|
|
22
|
+
`;
|
|
23
|
+
}
|
|
24
|
+
export function providerStub(name) {
|
|
25
|
+
return `import { ServiceProvider } from "@shaferllc/keel/core";
|
|
26
|
+
|
|
27
|
+
export class ${name} extends ServiceProvider {
|
|
28
|
+
register(): void {
|
|
29
|
+
// Bind services into the container here.
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
boot(): void {
|
|
33
|
+
// Resolve and wire things up here.
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
`;
|
|
37
|
+
}
|
|
38
|
+
export function middlewareStub(name) {
|
|
39
|
+
const fn = name.charAt(0).toLowerCase() + name.slice(1);
|
|
40
|
+
return `import type { MiddlewareHandler } from "hono";
|
|
41
|
+
|
|
42
|
+
export const ${fn}: MiddlewareHandler = async (c, next) => {
|
|
43
|
+
// ...before
|
|
44
|
+
await next();
|
|
45
|
+
// ...after
|
|
46
|
+
};
|
|
47
|
+
`;
|
|
48
|
+
}
|
|
49
|
+
/** `name` is the model class (e.g. "User") the factory builds. */
|
|
50
|
+
export function factoryStub(model) {
|
|
51
|
+
return `import { factory } from "@shaferllc/keel/core";
|
|
52
|
+
import { ${model} } from "../../app/Models/${model}.js";
|
|
53
|
+
|
|
54
|
+
export const ${model.toLowerCase()}Factory = factory(${model}, (f) => ({
|
|
55
|
+
// Describe one ${model}'s attributes; \`f\` is a Faker.
|
|
56
|
+
name: f.name(),
|
|
57
|
+
email: f.email(),
|
|
58
|
+
}));
|
|
59
|
+
`;
|
|
60
|
+
}
|
|
61
|
+
export function seederStub(name) {
|
|
62
|
+
return `import { Seeder } from "@shaferllc/keel/core";
|
|
63
|
+
|
|
64
|
+
export class ${name} extends Seeder {
|
|
65
|
+
async run(): Promise<void> {
|
|
66
|
+
// Populate the database, e.g.:
|
|
67
|
+
// await userFactory.count(10).create();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
`;
|
|
71
|
+
}
|
|
72
|
+
export function jobStub(name) {
|
|
73
|
+
return `import { Job } from "@shaferllc/keel/core";
|
|
74
|
+
|
|
75
|
+
export class ${name} extends Job {
|
|
76
|
+
constructor(/* pass the data this job needs */) {
|
|
77
|
+
super();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async handle(): Promise<void> {
|
|
81
|
+
// Do the background work here.
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
`;
|
|
85
|
+
}
|
|
86
|
+
export function notificationStub(name) {
|
|
87
|
+
return `import { Notification, type Notifiable, type MailContent } from "@shaferllc/keel/core";
|
|
88
|
+
|
|
89
|
+
export class ${name} extends Notification {
|
|
90
|
+
via(_notifiable: Notifiable): string[] {
|
|
91
|
+
return ["mail"];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
toMail(_notifiable: Notifiable): MailContent {
|
|
95
|
+
return {
|
|
96
|
+
subject: "${name}",
|
|
97
|
+
text: "Notification body.",
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
`;
|
|
102
|
+
}
|
|
103
|
+
/** `name` is the class (e.g. "UserTransformer"); `model` is the value it maps. */
|
|
104
|
+
export function transformerStub(name, model) {
|
|
105
|
+
return `import { Transformer, type Attributes } from "@shaferllc/keel/core";
|
|
106
|
+
// import { ${model} } from "../Models/${model}.js";
|
|
107
|
+
|
|
108
|
+
export class ${name} extends Transformer</* ${model} */ any> {
|
|
109
|
+
transform(item: /* ${model} */ any): Attributes {
|
|
110
|
+
// Map the value to the exact shape your API exposes.
|
|
111
|
+
return {
|
|
112
|
+
id: item.id,
|
|
113
|
+
// name: item.name,
|
|
114
|
+
// email: this.when(canSeeEmail, item.email),
|
|
115
|
+
// posts: this.whenLoaded(item, "posts", new PostTransformer()),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
`;
|
|
120
|
+
}
|
package/dist/core/events.d.ts
CHANGED
|
@@ -5,17 +5,141 @@
|
|
|
5
5
|
*
|
|
6
6
|
* listen("user.registered", (user) => sendWelcome(user));
|
|
7
7
|
* await emit("user.registered", user);
|
|
8
|
+
*
|
|
9
|
+
* Declare an event in `EventsList` and the emitter checks both sides of it —
|
|
10
|
+
* the payload you fire and the payload your listener receives:
|
|
11
|
+
*
|
|
12
|
+
* declare module "@shaferllc/keel/core" {
|
|
13
|
+
* interface EventsList {
|
|
14
|
+
* "order.paid": { id: number; total: number };
|
|
15
|
+
* }
|
|
16
|
+
* }
|
|
17
|
+
*
|
|
18
|
+
* One listener's failure never stops another's: `emit` runs them all, then
|
|
19
|
+
* reports what broke — to `onError()` if you registered one, otherwise by
|
|
20
|
+
* rejecting.
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* The registry of known events, keyed by name. Empty by default — augment it
|
|
24
|
+
* from your app to type an event's payload:
|
|
25
|
+
*
|
|
26
|
+
* declare module "@shaferllc/keel/core" {
|
|
27
|
+
* interface EventsList {
|
|
28
|
+
* "user.registered": User;
|
|
29
|
+
* }
|
|
30
|
+
* }
|
|
31
|
+
*/
|
|
32
|
+
export interface EventsList {
|
|
33
|
+
}
|
|
34
|
+
/** A declared event name, or any other string. */
|
|
35
|
+
export type EventName = keyof EventsList | (string & {});
|
|
36
|
+
/**
|
|
37
|
+
* The declared payload for an event, or `unknown` for an undeclared one.
|
|
38
|
+
*
|
|
39
|
+
* The `[E]` tuple wrapper stops the conditional from distributing: `EventName`
|
|
40
|
+
* is itself a union, and a naked `E` would resolve to a *union* of every
|
|
41
|
+
* declared payload rather than the one event's.
|
|
42
|
+
*/
|
|
43
|
+
export type PayloadOf<E> = [E] extends [keyof EventsList] ? EventsList[E & keyof EventsList] : unknown;
|
|
44
|
+
/**
|
|
45
|
+
* `emit`'s payload argument. Declared events must be fired with their payload;
|
|
46
|
+
* undeclared (and void) ones may be fired without.
|
|
8
47
|
*/
|
|
48
|
+
export type EmitArgs<P> = [P] extends [void | undefined] ? [payload?: P] : unknown extends P ? [payload?: P] : [payload: P];
|
|
49
|
+
/**
|
|
50
|
+
* The payload type for an event.
|
|
51
|
+
*
|
|
52
|
+
* A **declared** event takes its payload from `EventsList`, and `NoInfer` is
|
|
53
|
+
* load-bearing there: without it TypeScript would infer the payload type from
|
|
54
|
+
* the value you pass, so `emit("order.paid", anythingAtAll)` would type-check by
|
|
55
|
+
* inferring it to whatever you handed over — and the registry would enforce
|
|
56
|
+
* nothing.
|
|
57
|
+
*
|
|
58
|
+
* An **undeclared** event behaves as it always has: the payload type is inferred
|
|
59
|
+
* from the listener, or given explicitly as `listen<Order>("order.paid", …)`,
|
|
60
|
+
* and falls back to `unknown`.
|
|
61
|
+
*/
|
|
62
|
+
export type Resolve<T, E> = [E] extends [keyof EventsList] ? NoInfer<EventsList[E & keyof EventsList]> : T;
|
|
9
63
|
export type Listener<T = unknown> = (payload: T) => void | Promise<void>;
|
|
64
|
+
/** A listener for *every* event, as registered by `onAny`. */
|
|
65
|
+
export type AnyListener = (event: string, payload: unknown) => void | Promise<void>;
|
|
66
|
+
/** Called when a listener throws, instead of `emit` rejecting. */
|
|
67
|
+
export type ErrorHandler = (event: string, error: unknown, payload: unknown) => void | Promise<void>;
|
|
68
|
+
/** One recorded emission, as captured by a fake. */
|
|
69
|
+
export interface RecordedEvent {
|
|
70
|
+
event: string;
|
|
71
|
+
payload: unknown;
|
|
72
|
+
}
|
|
10
73
|
export declare class Events {
|
|
11
74
|
private listeners;
|
|
75
|
+
private anyListeners;
|
|
76
|
+
private errorHandler?;
|
|
77
|
+
/** Non-null while faking: the events to intercept (`true` = all of them). */
|
|
78
|
+
private faked?;
|
|
79
|
+
private buffer?;
|
|
12
80
|
/** Subscribe to an event. Returns an unsubscribe function. */
|
|
13
|
-
on<T = unknown>(event:
|
|
81
|
+
on<T = unknown, E extends EventName = EventName>(event: E, listener: Listener<Resolve<T, E>>): () => void;
|
|
14
82
|
/** Subscribe for a single emission. */
|
|
15
|
-
once<T = unknown>(event:
|
|
16
|
-
off<T = unknown>(event:
|
|
17
|
-
/**
|
|
18
|
-
|
|
83
|
+
once<T = unknown, E extends EventName = EventName>(event: E, listener: Listener<Resolve<T, E>>): () => void;
|
|
84
|
+
off<T = unknown, E extends EventName = EventName>(event: E, listener: Listener<Resolve<T, E>>): void;
|
|
85
|
+
/**
|
|
86
|
+
* Subscribe to *every* event — for logging and other cross-cutting concerns.
|
|
87
|
+
* These run before the event's own listeners. Returns an unsubscribe function.
|
|
88
|
+
*/
|
|
89
|
+
onAny(listener: AnyListener): () => void;
|
|
90
|
+
/**
|
|
91
|
+
* Handle listener failures instead of letting `emit` reject. Without one, an
|
|
92
|
+
* `emit` whose listeners threw rejects (with an `AggregateError` if more than
|
|
93
|
+
* one did) — errors are never silently swallowed.
|
|
94
|
+
*/
|
|
95
|
+
onError(handler: ErrorHandler): void;
|
|
96
|
+
/**
|
|
97
|
+
* Emit an event, awaiting every listener in registration order.
|
|
98
|
+
*
|
|
99
|
+
* A listener that throws does not stop the others — they all run, and the
|
|
100
|
+
* failures are reported afterwards. That's the point of an emitter: one
|
|
101
|
+
* subscriber's bug shouldn't silently cancel an unrelated subscriber's work.
|
|
102
|
+
*/
|
|
103
|
+
emit<T = unknown, E extends EventName = EventName>(event: E, ...args: EmitArgs<Resolve<T, E>>): Promise<void>;
|
|
19
104
|
listenerCount(event: string): number;
|
|
20
105
|
clear(event?: string): void;
|
|
106
|
+
/** Drop every `onAny` listener and the error handler too. */
|
|
107
|
+
clearAll(): void;
|
|
108
|
+
/**
|
|
109
|
+
* Record emissions instead of running listeners, so a test can assert an event
|
|
110
|
+
* fired without triggering its side effects. Undo with `restore()`.
|
|
111
|
+
*
|
|
112
|
+
* const events = events().fake();
|
|
113
|
+
* await register(user);
|
|
114
|
+
* events.assertEmitted("user.registered");
|
|
115
|
+
*
|
|
116
|
+
* Pass one or more event names to fake only those — everything else dispatches
|
|
117
|
+
* for real.
|
|
118
|
+
*/
|
|
119
|
+
fake(only?: EventName | EventName[]): EventBuffer;
|
|
120
|
+
/** Stop faking; listeners run for real again. */
|
|
121
|
+
restore(): void;
|
|
122
|
+
}
|
|
123
|
+
/** What a fake records, plus assertions over it. */
|
|
124
|
+
export declare class EventBuffer {
|
|
125
|
+
private events;
|
|
126
|
+
/** @internal — called by `Events.emit` while faking. */
|
|
127
|
+
record(event: string, payload: unknown): void;
|
|
128
|
+
/** Every emission recorded so far, in order. */
|
|
129
|
+
all(): RecordedEvent[];
|
|
130
|
+
/** The payloads recorded for one event. */
|
|
131
|
+
payloadsFor<E extends EventName>(event: E): PayloadOf<E>[];
|
|
132
|
+
/**
|
|
133
|
+
* Assert the event fired — optionally only counting emissions whose payload
|
|
134
|
+
* satisfies `predicate`.
|
|
135
|
+
*
|
|
136
|
+
* buffer.assertEmitted("order.paid", (o) => o.total === 4200);
|
|
137
|
+
*/
|
|
138
|
+
assertEmitted<E extends EventName>(event: E, predicate?: (payload: PayloadOf<E>) => boolean): void;
|
|
139
|
+
assertNotEmitted<E extends EventName>(event: E): void;
|
|
140
|
+
assertEmittedCount<E extends EventName>(event: E, expected: number): void;
|
|
141
|
+
/** Assert nothing at all was emitted. */
|
|
142
|
+
assertNoneEmitted(): void;
|
|
143
|
+
private listenerCountFor;
|
|
144
|
+
private summary;
|
|
21
145
|
}
|
package/dist/core/events.js
CHANGED
|
@@ -5,9 +5,27 @@
|
|
|
5
5
|
*
|
|
6
6
|
* listen("user.registered", (user) => sendWelcome(user));
|
|
7
7
|
* await emit("user.registered", user);
|
|
8
|
+
*
|
|
9
|
+
* Declare an event in `EventsList` and the emitter checks both sides of it —
|
|
10
|
+
* the payload you fire and the payload your listener receives:
|
|
11
|
+
*
|
|
12
|
+
* declare module "@shaferllc/keel/core" {
|
|
13
|
+
* interface EventsList {
|
|
14
|
+
* "order.paid": { id: number; total: number };
|
|
15
|
+
* }
|
|
16
|
+
* }
|
|
17
|
+
*
|
|
18
|
+
* One listener's failure never stops another's: `emit` runs them all, then
|
|
19
|
+
* reports what broke — to `onError()` if you registered one, otherwise by
|
|
20
|
+
* rejecting.
|
|
8
21
|
*/
|
|
9
22
|
export class Events {
|
|
10
23
|
listeners = new Map();
|
|
24
|
+
anyListeners = new Set();
|
|
25
|
+
errorHandler;
|
|
26
|
+
/** Non-null while faking: the events to intercept (`true` = all of them). */
|
|
27
|
+
faked;
|
|
28
|
+
buffer;
|
|
11
29
|
/** Subscribe to an event. Returns an unsubscribe function. */
|
|
12
30
|
on(event, listener) {
|
|
13
31
|
const set = this.listeners.get(event) ?? new Set();
|
|
@@ -18,7 +36,9 @@ export class Events {
|
|
|
18
36
|
/** Subscribe for a single emission. */
|
|
19
37
|
once(event, listener) {
|
|
20
38
|
const wrapper = async (payload) => {
|
|
21
|
-
|
|
39
|
+
// Unsubscribe before awaiting, so a listener that re-emits the same event
|
|
40
|
+
// doesn't re-trigger this one.
|
|
41
|
+
this.listeners.get(event)?.delete(wrapper);
|
|
22
42
|
await listener(payload);
|
|
23
43
|
};
|
|
24
44
|
return this.on(event, wrapper);
|
|
@@ -26,13 +46,66 @@ export class Events {
|
|
|
26
46
|
off(event, listener) {
|
|
27
47
|
this.listeners.get(event)?.delete(listener);
|
|
28
48
|
}
|
|
29
|
-
/**
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Subscribe to *every* event — for logging and other cross-cutting concerns.
|
|
51
|
+
* These run before the event's own listeners. Returns an unsubscribe function.
|
|
52
|
+
*/
|
|
53
|
+
onAny(listener) {
|
|
54
|
+
this.anyListeners.add(listener);
|
|
55
|
+
return () => {
|
|
56
|
+
this.anyListeners.delete(listener);
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Handle listener failures instead of letting `emit` reject. Without one, an
|
|
61
|
+
* `emit` whose listeners threw rejects (with an `AggregateError` if more than
|
|
62
|
+
* one did) — errors are never silently swallowed.
|
|
63
|
+
*/
|
|
64
|
+
onError(handler) {
|
|
65
|
+
this.errorHandler = handler;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Emit an event, awaiting every listener in registration order.
|
|
69
|
+
*
|
|
70
|
+
* A listener that throws does not stop the others — they all run, and the
|
|
71
|
+
* failures are reported afterwards. That's the point of an emitter: one
|
|
72
|
+
* subscriber's bug shouldn't silently cancel an unrelated subscriber's work.
|
|
73
|
+
*/
|
|
74
|
+
async emit(event, ...args) {
|
|
75
|
+
const payload = args[0];
|
|
76
|
+
if (this.faked && (this.faked === true || this.faked.has(event))) {
|
|
77
|
+
this.buffer?.record(event, payload);
|
|
33
78
|
return;
|
|
34
|
-
|
|
35
|
-
|
|
79
|
+
}
|
|
80
|
+
const errors = [];
|
|
81
|
+
// Snapshot both sets: a listener that (un)subscribes mid-emit affects only
|
|
82
|
+
// the next emission, not this one.
|
|
83
|
+
for (const listener of [...this.anyListeners]) {
|
|
84
|
+
try {
|
|
85
|
+
await listener(event, payload);
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
errors.push(error);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
for (const listener of [...(this.listeners.get(event) ?? [])]) {
|
|
92
|
+
try {
|
|
93
|
+
await listener(payload);
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
errors.push(error);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (!errors.length)
|
|
100
|
+
return;
|
|
101
|
+
if (this.errorHandler) {
|
|
102
|
+
for (const error of errors)
|
|
103
|
+
await this.errorHandler(event, error, payload);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (errors.length === 1)
|
|
107
|
+
throw errors[0];
|
|
108
|
+
throw new AggregateError(errors, `${errors.length} listeners for "${event}" failed.`);
|
|
36
109
|
}
|
|
37
110
|
listenerCount(event) {
|
|
38
111
|
return this.listeners.get(event)?.size ?? 0;
|
|
@@ -43,4 +116,89 @@ export class Events {
|
|
|
43
116
|
else
|
|
44
117
|
this.listeners.clear();
|
|
45
118
|
}
|
|
119
|
+
/** Drop every `onAny` listener and the error handler too. */
|
|
120
|
+
clearAll() {
|
|
121
|
+
this.listeners.clear();
|
|
122
|
+
this.anyListeners.clear();
|
|
123
|
+
this.errorHandler = undefined;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Record emissions instead of running listeners, so a test can assert an event
|
|
127
|
+
* fired without triggering its side effects. Undo with `restore()`.
|
|
128
|
+
*
|
|
129
|
+
* const events = events().fake();
|
|
130
|
+
* await register(user);
|
|
131
|
+
* events.assertEmitted("user.registered");
|
|
132
|
+
*
|
|
133
|
+
* Pass one or more event names to fake only those — everything else dispatches
|
|
134
|
+
* for real.
|
|
135
|
+
*/
|
|
136
|
+
fake(only) {
|
|
137
|
+
this.faked = only === undefined ? true : new Set(Array.isArray(only) ? only : [only]);
|
|
138
|
+
this.buffer = new EventBuffer();
|
|
139
|
+
return this.buffer;
|
|
140
|
+
}
|
|
141
|
+
/** Stop faking; listeners run for real again. */
|
|
142
|
+
restore() {
|
|
143
|
+
this.faked = undefined;
|
|
144
|
+
this.buffer = undefined;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/* ------------------------------ event buffer ------------------------------ */
|
|
148
|
+
/** What a fake records, plus assertions over it. */
|
|
149
|
+
export class EventBuffer {
|
|
150
|
+
events = [];
|
|
151
|
+
/** @internal — called by `Events.emit` while faking. */
|
|
152
|
+
record(event, payload) {
|
|
153
|
+
this.events.push({ event, payload });
|
|
154
|
+
}
|
|
155
|
+
/** Every emission recorded so far, in order. */
|
|
156
|
+
all() {
|
|
157
|
+
return [...this.events];
|
|
158
|
+
}
|
|
159
|
+
/** The payloads recorded for one event. */
|
|
160
|
+
payloadsFor(event) {
|
|
161
|
+
return this.events.filter((e) => e.event === event).map((e) => e.payload);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Assert the event fired — optionally only counting emissions whose payload
|
|
165
|
+
* satisfies `predicate`.
|
|
166
|
+
*
|
|
167
|
+
* buffer.assertEmitted("order.paid", (o) => o.total === 4200);
|
|
168
|
+
*/
|
|
169
|
+
assertEmitted(event, predicate) {
|
|
170
|
+
const matches = this.payloadsFor(event).filter((p) => predicate?.(p) ?? true);
|
|
171
|
+
if (matches.length)
|
|
172
|
+
return;
|
|
173
|
+
const fired = this.listenerCountFor(event);
|
|
174
|
+
throw new Error(predicate && fired
|
|
175
|
+
? `Expected "${event}" to be emitted with a matching payload. It fired ${fired} time(s), but none matched.`
|
|
176
|
+
: `Expected "${event}" to be emitted, but it was not. ${this.summary()}`);
|
|
177
|
+
}
|
|
178
|
+
assertNotEmitted(event) {
|
|
179
|
+
const fired = this.listenerCountFor(event);
|
|
180
|
+
if (fired)
|
|
181
|
+
throw new Error(`Expected "${event}" not to be emitted, but it fired ${fired} time(s).`);
|
|
182
|
+
}
|
|
183
|
+
assertEmittedCount(event, expected) {
|
|
184
|
+
const fired = this.listenerCountFor(event);
|
|
185
|
+
if (fired !== expected) {
|
|
186
|
+
throw new Error(`Expected "${event}" to be emitted ${expected} time(s), but it fired ${fired}.`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/** Assert nothing at all was emitted. */
|
|
190
|
+
assertNoneEmitted() {
|
|
191
|
+
if (this.events.length) {
|
|
192
|
+
throw new Error(`Expected no events, but ${this.events.length} fired. ${this.summary()}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
listenerCountFor(event) {
|
|
196
|
+
return this.events.filter((e) => e.event === event).length;
|
|
197
|
+
}
|
|
198
|
+
summary() {
|
|
199
|
+
if (!this.events.length)
|
|
200
|
+
return "No events were emitted.";
|
|
201
|
+
const names = [...new Set(this.events.map((e) => e.event))].join(", ");
|
|
202
|
+
return `Emitted: ${names}.`;
|
|
203
|
+
}
|
|
46
204
|
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health checks — the two endpoints an orchestrator (Kubernetes, Fly, Railway,
|
|
3
|
+
* a load balancer) asks about before it sends you traffic:
|
|
4
|
+
*
|
|
5
|
+
* /health/live is the process up? — answers instantly, checks nothing
|
|
6
|
+
* /health/ready can it serve requests? — runs every registered check
|
|
7
|
+
*
|
|
8
|
+
* A liveness probe that touched the database would restart a healthy app during a
|
|
9
|
+
* database blip, so it deliberately checks nothing. Readiness is where the checks
|
|
10
|
+
* live: it reports 200 while everything it depends on is reachable, and 503 when
|
|
11
|
+
* something isn't — which pulls the instance out of the pool without killing it.
|
|
12
|
+
*
|
|
13
|
+
* health().register([new DatabaseCheck(), new RedisCheck()]);
|
|
14
|
+
* this.use(healthCheck()); // serves both endpoints
|
|
15
|
+
*
|
|
16
|
+
* Keel ships the checks that mean something wherever it runs — a database, Redis,
|
|
17
|
+
* the cache. Deliberately absent are AdonisJS's disk-space and heap/RSS checks:
|
|
18
|
+
* they measure a Node process, and on Workers there isn't one.
|
|
19
|
+
*/
|
|
20
|
+
import type { MiddlewareHandler } from "hono";
|
|
21
|
+
export type HealthStatus = "ok" | "warning" | "error";
|
|
22
|
+
/** The outcome of a single check. Build one with `Result.ok/warning/failed`. */
|
|
23
|
+
export declare class Result {
|
|
24
|
+
readonly status: HealthStatus;
|
|
25
|
+
readonly message: string;
|
|
26
|
+
readonly error?: unknown | undefined;
|
|
27
|
+
meta?: Record<string, unknown> | undefined;
|
|
28
|
+
private constructor();
|
|
29
|
+
/** Healthy. */
|
|
30
|
+
static ok(message: string): Result;
|
|
31
|
+
/**
|
|
32
|
+
* Working, but degraded — worth paging someone, not worth pulling the instance
|
|
33
|
+
* out of the pool. A warning keeps readiness at 200.
|
|
34
|
+
*/
|
|
35
|
+
static warning(message: string): Result;
|
|
36
|
+
/** Broken. Any failed check takes readiness to 503. */
|
|
37
|
+
static failed(message: string, error?: unknown): Result;
|
|
38
|
+
/** Attach arbitrary detail — connection counts, latencies, versions. */
|
|
39
|
+
withMeta(meta: Record<string, unknown>): this;
|
|
40
|
+
}
|
|
41
|
+
/** One thing worth knowing about before serving traffic. */
|
|
42
|
+
export declare abstract class BaseCheck {
|
|
43
|
+
abstract readonly name: string;
|
|
44
|
+
private ttl;
|
|
45
|
+
private cached?;
|
|
46
|
+
abstract run(): Promise<Result>;
|
|
47
|
+
/**
|
|
48
|
+
* Reuse this check's last result for `seconds`, so a probe every few seconds
|
|
49
|
+
* doesn't hammer the thing it's checking. The report marks a reused result
|
|
50
|
+
* with `isCached: true`.
|
|
51
|
+
*/
|
|
52
|
+
cacheFor(seconds: number): this;
|
|
53
|
+
/**
|
|
54
|
+
* Run the check, honouring `cacheFor`, and never throw: a check that blows up
|
|
55
|
+
* is itself a failure, and one broken check must not take down the report.
|
|
56
|
+
*
|
|
57
|
+
* @internal — called by `HealthChecks.run()`.
|
|
58
|
+
*/
|
|
59
|
+
execute(): Promise<{
|
|
60
|
+
result: Result;
|
|
61
|
+
isCached: boolean;
|
|
62
|
+
}>;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* A check from a plain function — the escape hatch, for anything Keel doesn't
|
|
66
|
+
* ship a check for.
|
|
67
|
+
*
|
|
68
|
+
* health().register([
|
|
69
|
+
* check("stripe", async () => {
|
|
70
|
+
* const res = await fetch("https://api.stripe.com/healthcheck");
|
|
71
|
+
* return res.ok ? Result.ok("Stripe is reachable") : Result.failed("Stripe is down");
|
|
72
|
+
* }),
|
|
73
|
+
* ]);
|
|
74
|
+
*/
|
|
75
|
+
export declare function check(name: string, run: () => Promise<Result> | Result): BaseCheck;
|
|
76
|
+
/** Is the database reachable? Runs `SELECT 1` on a connection. */
|
|
77
|
+
export declare class DatabaseCheck extends BaseCheck {
|
|
78
|
+
private connectionName?;
|
|
79
|
+
readonly name: string;
|
|
80
|
+
constructor(connectionName?: string | undefined);
|
|
81
|
+
run(): Promise<Result>;
|
|
82
|
+
}
|
|
83
|
+
/** Is Redis reachable? Reads a key — a failed read means a broken connection. */
|
|
84
|
+
export declare class RedisCheck extends BaseCheck {
|
|
85
|
+
readonly name = "redis";
|
|
86
|
+
run(): Promise<Result>;
|
|
87
|
+
}
|
|
88
|
+
/** Does the cache round-trip? Writes a key, reads it back, deletes it. */
|
|
89
|
+
export declare class CacheCheck extends BaseCheck {
|
|
90
|
+
readonly name = "cache";
|
|
91
|
+
run(): Promise<Result>;
|
|
92
|
+
}
|
|
93
|
+
export interface CheckReport {
|
|
94
|
+
name: string;
|
|
95
|
+
status: HealthStatus;
|
|
96
|
+
message: string;
|
|
97
|
+
/** Whether this result was reused from a `cacheFor()` window. */
|
|
98
|
+
isCached: boolean;
|
|
99
|
+
finishedAt: string;
|
|
100
|
+
meta?: Record<string, unknown>;
|
|
101
|
+
}
|
|
102
|
+
export interface HealthReport {
|
|
103
|
+
/** True unless a check failed. A warning is still healthy. */
|
|
104
|
+
isHealthy: boolean;
|
|
105
|
+
/** The worst status any check reported. */
|
|
106
|
+
status: HealthStatus;
|
|
107
|
+
finishedAt: string;
|
|
108
|
+
checks: CheckReport[];
|
|
109
|
+
}
|
|
110
|
+
export declare class HealthChecks {
|
|
111
|
+
private checks;
|
|
112
|
+
/** Add checks. Call it once, in a provider's `boot()`. */
|
|
113
|
+
register(checks: BaseCheck[]): this;
|
|
114
|
+
/** Every registered check. */
|
|
115
|
+
all(): BaseCheck[];
|
|
116
|
+
clear(): this;
|
|
117
|
+
/** Run every check — concurrently, since they're independent I/O. */
|
|
118
|
+
run(): Promise<HealthReport>;
|
|
119
|
+
}
|
|
120
|
+
/** The application's health-check registry. */
|
|
121
|
+
export declare function health(): HealthChecks;
|
|
122
|
+
export interface HealthCheckOptions {
|
|
123
|
+
/** URL prefix for the two endpoints. Default: `"/health"`. */
|
|
124
|
+
basePath?: string;
|
|
125
|
+
/**
|
|
126
|
+
* Require `Authorization: Bearer <secret>` on the readiness endpoint. The
|
|
127
|
+
* report names your infrastructure, so don't publish it — set this whenever
|
|
128
|
+
* the endpoint is reachable from outside your network.
|
|
129
|
+
*/
|
|
130
|
+
secret?: string;
|
|
131
|
+
/** The registry to run. Defaults to the global one from `health()`. */
|
|
132
|
+
checks?: HealthChecks;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Serve `/health/live` and `/health/ready`. Anything else falls through to your
|
|
136
|
+
* routes.
|
|
137
|
+
*
|
|
138
|
+
* this.use(healthCheck());
|
|
139
|
+
* this.use(healthCheck({ secret: env.HEALTH_SECRET }));
|
|
140
|
+
*/
|
|
141
|
+
export declare function healthCheck(options?: HealthCheckOptions): MiddlewareHandler;
|