@shaferllc/keel 0.66.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.
Files changed (239) hide show
  1. package/AGENTS.md +169 -0
  2. package/README.md +44 -6
  3. package/bin/keel-mcp.mjs +9 -0
  4. package/dist/api/api.config.stub +9 -0
  5. package/dist/api/config.d.ts +13 -0
  6. package/dist/api/config.js +14 -0
  7. package/dist/api/index.d.ts +16 -0
  8. package/dist/api/index.js +13 -0
  9. package/dist/api/provider.d.ts +10 -0
  10. package/dist/api/provider.js +17 -0
  11. package/dist/api/query.d.ts +35 -0
  12. package/dist/api/query.js +42 -0
  13. package/dist/api/resource.d.ts +91 -0
  14. package/dist/api/resource.js +188 -0
  15. package/dist/core/application.d.ts +5 -5
  16. package/dist/core/application.js +8 -2
  17. package/dist/core/cache.d.ts +81 -5
  18. package/dist/core/cache.js +188 -23
  19. package/dist/core/cli/stubs.d.ts +26 -0
  20. package/dist/core/cli/stubs.js +225 -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/events.d.ts +129 -5
  32. package/dist/core/events.js +165 -7
  33. package/dist/core/health.d.ts +141 -0
  34. package/dist/core/health.js +226 -0
  35. package/dist/core/helpers.d.ts +9 -3
  36. package/dist/core/helpers.js +11 -3
  37. package/dist/core/http/kernel.d.ts +2 -0
  38. package/dist/core/http/kernel.js +48 -0
  39. package/dist/core/http/router.d.ts +5 -5
  40. package/dist/core/http/router.js +5 -5
  41. package/dist/core/i18n.d.ts +162 -0
  42. package/dist/core/i18n.js +472 -0
  43. package/dist/core/index.d.ts +39 -13
  44. package/dist/core/index.js +19 -6
  45. package/dist/core/instrumentation.d.ts +113 -0
  46. package/dist/core/instrumentation.js +52 -0
  47. package/dist/core/lock.d.ts +139 -0
  48. package/dist/core/lock.js +215 -0
  49. package/dist/core/logger.d.ts +89 -4
  50. package/dist/core/logger.js +167 -22
  51. package/dist/core/mail.d.ts +128 -7
  52. package/dist/core/mail.js +264 -16
  53. package/dist/core/notification.js +10 -1
  54. package/dist/core/package.d.ts +120 -0
  55. package/dist/core/package.js +169 -0
  56. package/dist/core/pages.d.ts +108 -0
  57. package/dist/core/pages.js +199 -0
  58. package/dist/core/queue.d.ts +134 -9
  59. package/dist/core/queue.js +325 -14
  60. package/dist/core/repl.d.ts +33 -0
  61. package/dist/core/repl.js +88 -0
  62. package/dist/core/scheduler.js +6 -0
  63. package/dist/core/social.d.ts +4 -4
  64. package/dist/core/social.js +4 -4
  65. package/dist/core/storage.d.ts +159 -6
  66. package/dist/core/storage.js +299 -7
  67. package/dist/core/telemetry.d.ts +208 -0
  68. package/dist/core/telemetry.js +383 -0
  69. package/dist/core/template.d.ts +2 -3
  70. package/dist/core/template.js +2 -3
  71. package/dist/core/testing.d.ts +170 -1
  72. package/dist/core/testing.js +504 -2
  73. package/dist/db/d1.js +13 -0
  74. package/dist/db/pg.d.ts +13 -0
  75. package/dist/db/pg.js +46 -4
  76. package/dist/mcp/server.d.ts +19 -0
  77. package/dist/mcp/server.js +355 -0
  78. package/dist/openapi/config.d.ts +28 -0
  79. package/dist/openapi/config.js +25 -0
  80. package/dist/openapi/doc.d.ts +40 -0
  81. package/dist/openapi/doc.js +20 -0
  82. package/dist/openapi/export.d.ts +8 -0
  83. package/dist/openapi/export.js +19 -0
  84. package/dist/openapi/gate.d.ts +15 -0
  85. package/dist/openapi/gate.js +27 -0
  86. package/dist/openapi/index.d.ts +19 -0
  87. package/dist/openapi/index.js +15 -0
  88. package/dist/openapi/openapi.config.stub +29 -0
  89. package/dist/openapi/provider.d.ts +18 -0
  90. package/dist/openapi/provider.js +35 -0
  91. package/dist/openapi/routes.d.ts +9 -0
  92. package/dist/openapi/routes.js +23 -0
  93. package/dist/openapi/spec.d.ts +23 -0
  94. package/dist/openapi/spec.js +132 -0
  95. package/dist/openapi/ui.d.ts +8 -0
  96. package/dist/openapi/ui.js +31 -0
  97. package/dist/openapi/zod.d.ts +12 -0
  98. package/dist/openapi/zod.js +46 -0
  99. package/dist/watch/config.d.ts +33 -0
  100. package/dist/watch/config.js +38 -0
  101. package/dist/watch/entry.d.ts +53 -0
  102. package/dist/watch/entry.js +105 -0
  103. package/dist/watch/gate.d.ts +20 -0
  104. package/dist/watch/gate.js +32 -0
  105. package/dist/watch/index.d.ts +21 -0
  106. package/dist/watch/index.js +17 -0
  107. package/dist/watch/migration.d.ts +7 -0
  108. package/dist/watch/migration.js +20 -0
  109. package/dist/watch/provider.d.ts +22 -0
  110. package/dist/watch/provider.js +58 -0
  111. package/dist/watch/prune.d.ts +11 -0
  112. package/dist/watch/prune.js +20 -0
  113. package/dist/watch/recorder.d.ts +24 -0
  114. package/dist/watch/recorder.js +39 -0
  115. package/dist/watch/routes.d.ts +13 -0
  116. package/dist/watch/routes.js +55 -0
  117. package/dist/watch/store.d.ts +54 -0
  118. package/dist/watch/store.js +158 -0
  119. package/dist/watch/ui/dist/watch.css +1 -0
  120. package/dist/watch/ui/dist/watch.js +555 -0
  121. package/dist/watch/ui-shell.d.ts +12 -0
  122. package/dist/watch/ui-shell.js +24 -0
  123. package/dist/watch/watch.config.stub +47 -0
  124. package/dist/watch/watchers.d.ts +12 -0
  125. package/dist/watch/watchers.js +156 -0
  126. package/docs/ai-manifest.json +3206 -0
  127. package/docs/ai.md +128 -0
  128. package/docs/api-resources.md +118 -0
  129. package/docs/architecture.md +331 -0
  130. package/docs/authentication.md +453 -0
  131. package/docs/authorization.md +167 -0
  132. package/docs/broadcasting.md +137 -0
  133. package/docs/broker.md +500 -0
  134. package/docs/cache.md +558 -0
  135. package/docs/configuration.md +385 -0
  136. package/docs/console.md +536 -0
  137. package/docs/container.md +467 -0
  138. package/docs/controllers.md +265 -0
  139. package/docs/cors.md +51 -0
  140. package/docs/database.md +631 -0
  141. package/docs/debugging.md +129 -0
  142. package/docs/decorators.md +127 -0
  143. package/docs/errors.md +395 -0
  144. package/docs/events.md +496 -0
  145. package/docs/examples/architecture-app.ts +27 -0
  146. package/docs/examples/authentication.ts +61 -0
  147. package/docs/examples/authorization.ts +79 -0
  148. package/docs/examples/broadcasting.ts +60 -0
  149. package/docs/examples/broker-cache-validate.ts +34 -0
  150. package/docs/examples/broker-fault-tolerance.ts +29 -0
  151. package/docs/examples/broker-middleware.ts +27 -0
  152. package/docs/examples/broker.ts +203 -0
  153. package/docs/examples/cache.ts +222 -0
  154. package/docs/examples/configuration.ts +121 -0
  155. package/docs/examples/console.ts +134 -0
  156. package/docs/examples/container.ts +134 -0
  157. package/docs/examples/controllers.ts +86 -0
  158. package/docs/examples/database.ts +208 -0
  159. package/docs/examples/debugging.ts +41 -0
  160. package/docs/examples/decorators.ts +40 -0
  161. package/docs/examples/errors.ts +121 -0
  162. package/docs/examples/events.ts +204 -0
  163. package/docs/examples/factories.ts +84 -0
  164. package/docs/examples/hashing.ts +71 -0
  165. package/docs/examples/health.ts +94 -0
  166. package/docs/examples/helpers.ts +171 -0
  167. package/docs/examples/hooks.ts +54 -0
  168. package/docs/examples/i18n.ts +117 -0
  169. package/docs/examples/inertia.ts +81 -0
  170. package/docs/examples/locks.ts +120 -0
  171. package/docs/examples/logger.ts +166 -0
  172. package/docs/examples/mail.ts +263 -0
  173. package/docs/examples/middleware.ts +119 -0
  174. package/docs/examples/migrations.ts +126 -0
  175. package/docs/examples/models.ts +239 -0
  176. package/docs/examples/notification.ts +124 -0
  177. package/docs/examples/pages.ts +82 -0
  178. package/docs/examples/providers.ts +123 -0
  179. package/docs/examples/queues.ts +254 -0
  180. package/docs/examples/rate-limiting.ts +42 -0
  181. package/docs/examples/redis.ts +99 -0
  182. package/docs/examples/request-response.ts +197 -0
  183. package/docs/examples/routing.ts +186 -0
  184. package/docs/examples/scheduling.ts +62 -0
  185. package/docs/examples/sessions.ts +102 -0
  186. package/docs/examples/static-files.ts +63 -0
  187. package/docs/examples/storage.ts +132 -0
  188. package/docs/examples/telemetry.ts +127 -0
  189. package/docs/examples/templates.ts +58 -0
  190. package/docs/examples/testing.ts +215 -0
  191. package/docs/examples/transformer.ts +141 -0
  192. package/docs/examples/transformers.ts +49 -0
  193. package/docs/examples/url-builder.ts +86 -0
  194. package/docs/examples/validation.ts +102 -0
  195. package/docs/examples/views.tsx +62 -0
  196. package/docs/examples/vite.ts +106 -0
  197. package/docs/factories.md +166 -0
  198. package/docs/getting-started.md +290 -0
  199. package/docs/hashing.md +259 -0
  200. package/docs/health.md +225 -0
  201. package/docs/helpers.md +347 -0
  202. package/docs/hono.md +186 -0
  203. package/docs/hooks.md +118 -0
  204. package/docs/i18n.md +302 -0
  205. package/docs/inertia.md +241 -0
  206. package/docs/locks.md +323 -0
  207. package/docs/logger.md +436 -0
  208. package/docs/mail.md +751 -0
  209. package/docs/middleware.md +425 -0
  210. package/docs/migrations.md +476 -0
  211. package/docs/models.md +810 -0
  212. package/docs/notifications.md +474 -0
  213. package/docs/openapi.md +111 -0
  214. package/docs/packages.md +118 -0
  215. package/docs/pages.md +217 -0
  216. package/docs/providers.md +363 -0
  217. package/docs/queues.md +679 -0
  218. package/docs/rate-limiting.md +155 -0
  219. package/docs/redis.md +178 -0
  220. package/docs/request-response.md +953 -0
  221. package/docs/routing.md +804 -0
  222. package/docs/scheduling.md +110 -0
  223. package/docs/security.md +85 -0
  224. package/docs/sessions.md +354 -0
  225. package/docs/social-auth.md +174 -0
  226. package/docs/static-files.md +211 -0
  227. package/docs/storage.md +452 -0
  228. package/docs/telemetry.md +263 -0
  229. package/docs/templates.md +314 -0
  230. package/docs/testing.md +376 -0
  231. package/docs/transformers.md +381 -0
  232. package/docs/url-builder.md +295 -0
  233. package/docs/validation.md +288 -0
  234. package/docs/views.md +267 -0
  235. package/docs/vite.md +434 -0
  236. package/docs/watch.md +118 -0
  237. package/llms-full.txt +19816 -0
  238. package/llms.txt +127 -0
  239. package/package.json +42 -7
@@ -0,0 +1,137 @@
1
+ # Broadcasting
2
+
3
+ Push events to clients in real time over named **channels**. Like the database
4
+ and mail layers, broadcasting rides a pluggable `Broadcaster`, so the core owns no
5
+ socket — point it at Pusher/Ably (`fetch`), a Cloudflare Durable Object, or the
6
+ built-in `MemoryBroadcaster` for tests and single-instance workers.
7
+
8
+ ## Broadcasting an event
9
+
10
+ ```ts
11
+ import { broadcast } from "@shaferllc/keel/core";
12
+
13
+ await broadcast("orders.42", "status", { state: "shipped" });
14
+ await broadcast(["team.7", "admins"], "deploy", { sha }); // several channels
15
+ ```
16
+
17
+ `broadcast(channels, event, payload)` hands the event to the registered
18
+ broadcaster. Register one at boot:
19
+
20
+ ```ts
21
+ import { setBroadcaster } from "@shaferllc/keel/core";
22
+ setBroadcaster(pusher(env.PUSHER_KEY, env.PUSHER_SECRET));
23
+ ```
24
+
25
+ ## Channel authorization
26
+
27
+ Public channels need nothing. **Private** and **presence** channels are gated:
28
+ register who may subscribe with `channelAuth`, then have your socket endpoint call
29
+ `authorizeChannel`. `{param}` segments are captured from the channel name:
30
+
31
+ ```ts
32
+ import { channelAuth, authorizeChannel } from "@shaferllc/keel/core";
33
+
34
+ // only the order's owner may subscribe:
35
+ channelAuth("orders.{orderId}", (user, params) => user.id === Number(params.orderId));
36
+
37
+ // presence: return member data to join
38
+ channelAuth("presence.room.{room}", (user, params) => ({ id: user.id, name: user.name }));
39
+ ```
40
+
41
+ At the subscription endpoint (the URL your client hits to authorize a channel):
42
+
43
+ ```ts
44
+ router.post("/broadcasting/auth", async () => {
45
+ const { channel } = await request.all();
46
+ const ok = await authorizeChannel(channel, await auth().user());
47
+ if (!ok) response.abort("Forbidden", 403);
48
+ return json(ok); // `true`, or member data for presence
49
+ });
50
+ ```
51
+
52
+ Return `false` to deny, `true` to allow, or an object of **member data** to allow
53
+ *and* join a presence channel. It composes with [`auth()`](./authentication.md)
54
+ and [authorization](./authorization.md).
55
+
56
+ ## Same-process fan-out
57
+
58
+ `MemoryBroadcaster` also lets you `subscribe` in-process — useful inside a
59
+ Cloudflare Durable Object (the WebSocket owner) or an SSE loop:
60
+
61
+ ```ts
62
+ import { MemoryBroadcaster } from "@shaferllc/keel/core";
63
+
64
+ const bus = new MemoryBroadcaster();
65
+ setBroadcaster(bus);
66
+
67
+ const off = bus.subscribe("orders.42", (event, payload) => socket.send(JSON.stringify({ event, payload })));
68
+ // … later
69
+ off();
70
+ ```
71
+
72
+ ## Writing a driver
73
+
74
+ A broadcaster is one method — `publish`. Here's the shape for a Pusher-style HTTP
75
+ provider over `fetch` (edge-safe):
76
+
77
+ ```ts
78
+ import type { Broadcaster } from "@shaferllc/keel/core";
79
+
80
+ const pusher = (url: string, auth: string): Broadcaster => ({
81
+ async publish(channels, event, payload) {
82
+ await fetch(url, {
83
+ method: "POST",
84
+ headers: { "content-type": "application/json", authorization: auth },
85
+ body: JSON.stringify({ channels, name: event, data: JSON.stringify(payload) }),
86
+ });
87
+ },
88
+ });
89
+ ```
90
+
91
+ For Cloudflare, the driver forwards to a Durable Object that owns the WebSockets;
92
+ the DO uses a `MemoryBroadcaster` internally to fan out to its connected sockets.
93
+
94
+ ## API reference
95
+
96
+ ### `broadcast(channels, event, payload?)`
97
+
98
+ `broadcast(channels: string | string[], event: string, payload?: unknown): Promise<void>`
99
+
100
+ Publish an event to one or more channels via the registered broadcaster.
101
+
102
+ ### `setBroadcaster(instance)` / `getBroadcaster()`
103
+
104
+ Register / read the default `Broadcaster`.
105
+
106
+ ### `MemoryBroadcaster`
107
+
108
+ `class MemoryBroadcaster implements Broadcaster` — in-process pub/sub; the default.
109
+ `subscribe(channel, cb)` returns an unsubscribe function.
110
+
111
+ ### `channelAuth(pattern, authorizer)`
112
+
113
+ `channelAuth(pattern: string, authorizer: (user, params) => boolean | object | Promise<…>): void`
114
+
115
+ Register an authorizer for a channel pattern. `{param}` segments are captured into
116
+ `params`.
117
+
118
+ ### `authorizeChannel(channel, user)`
119
+
120
+ `authorizeChannel(channel: string, user: unknown): Promise<boolean | Record<string, unknown>>`
121
+
122
+ Run the first matching rule (a channel with no rule is public → `true`). Returns
123
+ `false` (deny), `true` (allow), or member data (presence).
124
+
125
+ ### Interfaces & types
126
+
127
+ #### `Broadcaster`
128
+
129
+ `interface Broadcaster { publish(channels: string[], event: string, payload: unknown): Promise<void> }`
130
+
131
+ #### `ChannelAuthorizer`
132
+
133
+ `type ChannelAuthorizer = (user: unknown, params: Record<string, string>) => boolean | Record<string, unknown> | Promise<…>`
134
+
135
+ #### `Subscriber`
136
+
137
+ `type Subscriber = (event: string, payload: unknown, channel: string) => void`
package/docs/broker.md ADDED
@@ -0,0 +1,500 @@
1
+ # Service Broker
2
+
3
+ Structure an application as **services** that talk to each other by name instead
4
+ of by import. You register a service — a name plus a bag of `actions` and
5
+ `events` — with a **broker**, then reach it anywhere with
6
+ `broker.call("users.get", { id })` or fan an event out with
7
+ `broker.emit("user.created", user)`. It's a [Moleculer](https://moleculer.services/docs/0.15/broker)-style
8
+ backbone: actions receive a `Context` and can call *other* actions through it, so
9
+ a request threads its `meta` (auth, trace ids) all the way down.
10
+
11
+ Like the queue and Redis layers, clustering lives behind a pluggable seam. The
12
+ default `LocalTransporter` is a single-node no-op, so the core imports no network
13
+ client and runs on Node and the edge. Swap in a real `Transporter` to span
14
+ processes — the `call` / `emit` API never changes.
15
+
16
+ ## Defining a service
17
+
18
+ A service is a schema object. Handlers and lifecycle hooks run with `this` bound
19
+ to the live service, so they can reach `this.settings`, `this.metadata`,
20
+ `this.broker`, `this.logger`, and any `methods` you define.
21
+
22
+ ```ts
23
+ import { broker, type Context } from "@shaferllc/keel/core";
24
+
25
+ broker().createService({
26
+ name: "users",
27
+ settings: { defaultRole: "member" },
28
+ metadata: { region: "us-east" }, // descriptive; travels with discovery
29
+ actions: {
30
+ async get(this: any, ctx: Context<{ id: number }>) {
31
+ return { id: ctx.params.id, role: this.settings.defaultRole };
32
+ },
33
+ async create(ctx: Context<{ email: string }>) {
34
+ const user = { id: 1, email: ctx.params.email };
35
+ await ctx.emit("user.created", user); // inherits ctx.meta
36
+ return user;
37
+ },
38
+ },
39
+ events: {
40
+ "user.created": (ctx: Context) => {
41
+ // ctx.params is the event payload
42
+ },
43
+ },
44
+ });
45
+ ```
46
+
47
+ ## Calling actions
48
+
49
+ An action is addressed as `"<service>.<action>"`. Give a service a `version` and
50
+ its actions namespace under a `v`-prefix (`v2.users.get`).
51
+
52
+ ```ts
53
+ const user = await broker().call("users.create", { email: "ada@keel.dev" });
54
+
55
+ // pass metadata that flows down through nested ctx.call()s
56
+ await broker().call("users.get", { id: 1 }, { meta: { locale: "en" } });
57
+
58
+ // bound a call with a timeout (ms); rejects with RequestTimeoutError
59
+ await broker().call("reports.build", {}, { timeout: 5000 });
60
+ ```
61
+
62
+ Inside an action, use `ctx.call(...)` rather than `broker().call(...)` — it
63
+ carries the current `meta` (and `requestID`) into the child call automatically.
64
+
65
+ Call several actions at once with `mcall` — pass an array or a keyed map and get
66
+ the same shape back. With `settled: true` you get a per-call
67
+ `{ status, value | reason }` instead of failing on the first rejection.
68
+
69
+ ```ts
70
+ const [a, b] = await broker().mcall([
71
+ { action: "users.get", params: { id: 1 } },
72
+ { action: "users.get", params: { id: 2 } },
73
+ ]);
74
+
75
+ const { profile, posts } = await broker().mcall({
76
+ profile: { action: "users.get", params: { id: 1 } },
77
+ posts: { action: "posts.byUser", params: { id: 1 } },
78
+ });
79
+ ```
80
+
81
+ ### The call context
82
+
83
+ Every handler receives a `Context`. Beyond `params`, `meta`, and `call`, it
84
+ carries a few request-scoped slots:
85
+
86
+ - **`ctx.meta`** — flows *down* into nested `ctx.call()`s. Put auth, locale, and
87
+ trace data here.
88
+ - **`ctx.headers`** — per-call and **transient**: available to this handler and
89
+ its hooks, but *not* propagated to nested calls.
90
+ - **`ctx.locals`** — scratch space shared between a call's hooks and its handler
91
+ (e.g. a hook looks up the current user, the handler reads it back).
92
+ - **`ctx.requestID`** — one correlation id for the whole request tree; generated
93
+ once and threaded through every nested call. Pass your own to stitch a call
94
+ into an existing trace.
95
+
96
+ ```ts
97
+ await broker().call("reports.build", {}, {
98
+ headers: { "x-trace": "abc" },
99
+ requestID: "req-42",
100
+ });
101
+ ```
102
+
103
+ Each `ctx.call()` builds a child context linked to its parent, so a handler can
104
+ see where it sits in the request tree:
105
+
106
+ - **`ctx.id`** — unique per context; **`ctx.parentID`** — the caller's `id`
107
+ (`null` at the root).
108
+ - **`ctx.level`** — call depth, `1` at the root and `+1` per nested call.
109
+ - **`ctx.caller`** — the full name of the service that invoked this call (`null`
110
+ at the root).
111
+ - **`ctx.action`** — `{ name }` of the running action (absent in event handlers).
112
+ - **`ctx.toJSON()`** — a serializable snapshot (ids, level, caller, name, meta) —
113
+ no functions or live `broker`/`service` refs, so it is safe to log.
114
+
115
+ In an **event** handler the context instead carries **`ctx.eventName`**,
116
+ **`ctx.eventType`** (`"emit"` or `"broadcast"`), and **`ctx.eventGroups`**.
117
+
118
+ ## Full action definitions
119
+
120
+ An action is a bare handler by default. Swap in an object to attach per-action
121
+ options — `visibility`, a `timeout`, and `hooks`:
122
+
123
+ ```ts
124
+ broker().createService({
125
+ name: "billing",
126
+ actions: {
127
+ // shorthand — a plain handler
128
+ quote: (ctx: Context) => ({ cents: 999 }),
129
+
130
+ // full form
131
+ charge: {
132
+ visibility: "private", // hidden from broker.call — internal only
133
+ timeout: 3000, // per-action; the call option still overrides it
134
+ hooks: {
135
+ before: (ctx) => { /* validate */ },
136
+ after: (ctx, res) => res,
137
+ },
138
+ handler: (ctx: Context<{ cents: number }>) => ctx.params.cents,
139
+ },
140
+ },
141
+ });
142
+ ```
143
+
144
+ ### Visibility
145
+
146
+ `visibility` controls how far an action reaches:
147
+
148
+ | Value | Reachable via `broker.call` / `ctx.call` | Internally (`this.actions.x`) |
149
+ | ------------------------ | ---------------------------------------- | ----------------------------- |
150
+ | `published` *(default)* | yes | yes |
151
+ | `public` | yes | yes |
152
+ | `protected` | yes (same node) | yes |
153
+ | `private` | **no** — throws `ServiceNotFoundError` | yes |
154
+
155
+ A `private` action is invisible to `call`, but a service can still invoke its
156
+ own private actions through `this.actions.<name>(params)`, which runs the full
157
+ pipeline (hooks and timeout) while skipping the visibility gate.
158
+
159
+ ```ts
160
+ actions: {
161
+ charge: { visibility: "private", handler: (ctx) => /* ... */ },
162
+ checkout(this: any, ctx: Context) {
163
+ return this.actions.charge({ cents: ctx.params.cents }); // ok — internal
164
+ },
165
+ }
166
+ ```
167
+
168
+ ## Hooks
169
+
170
+ Hooks wrap action handlers to keep validation, sanitisation, and response
171
+ shaping out of the handler body. Declare them at the **service** level (keyed by
172
+ action name) or inline on a single **action**.
173
+
174
+ ```ts
175
+ broker().createService({
176
+ name: "users",
177
+ hooks: {
178
+ before: {
179
+ "*": (ctx) => { /* runs before every action */ },
180
+ "create|update": (ctx) => { /* pipe list */ },
181
+ remove: (ctx) => { /* exact name */ },
182
+ },
183
+ after: {
184
+ get: (ctx, res) => ({ ...res, fetchedAt: Date.now() }), // must return res
185
+ },
186
+ error: {
187
+ "*": (ctx, err) => { throw err; }, // return a fallback, or re-throw
188
+ },
189
+ },
190
+ actions: { /* ... */ },
191
+ });
192
+ ```
193
+
194
+ - **before** hooks receive `ctx` and may mutate `ctx.params`, `ctx.meta`, and
195
+ `ctx.locals`. Their return value is ignored — they can't skip the handler.
196
+ - **after** hooks receive `(ctx, res)` and **must return** the (possibly
197
+ transformed) response.
198
+ - **error** hooks receive `(ctx, err)`. Return a value to recover, or throw to
199
+ propagate. If several match, each re-throw feeds the next.
200
+
201
+ Keys may be `"*"` (all actions), an exact name, a `"a|b"` pipe list, or a `*`
202
+ glob (`"get*"`). Ordering matches Moleculer — **before** runs service-wildcard →
203
+ service-named → action, and **after**/**error** run in reverse (action →
204
+ service-named → service-wildcard):
205
+
206
+ ```
207
+ before: hooks.before["*"] → hooks.before[name] → action.hooks.before → handler
208
+ after: action.hooks.after → hooks.after[name] → hooks.after["*"]
209
+ ```
210
+
211
+ ## Mixins
212
+
213
+ `mixins` fold reusable schemas into a service. Every field is merged by type —
214
+ `settings`/`metadata` deep-merge, `actions`/`events`/`methods`/`hooks` merge by
215
+ key, and lifecycle hooks (`created`/`started`/`stopped`/`merged`) *chain* so all
216
+ of them run (mixins first, then the service). **The service's own schema always
217
+ wins on conflict.**
218
+
219
+ ```ts
220
+ const Timestamps = {
221
+ name: "timestamps",
222
+ settings: { softDelete: false },
223
+ methods: { touch(this: any) { /* ... */ } },
224
+ };
225
+
226
+ broker().createService({
227
+ mixins: [Timestamps],
228
+ name: "articles",
229
+ settings: { perPage: 10 }, // → { softDelete: false, perPage: 10 }
230
+ actions: { list: () => [] },
231
+ merged(schema) {
232
+ // fires once, after mixins merge, before the instance is built
233
+ },
234
+ });
235
+ ```
236
+
237
+ When several mixins collide, the **first** in the array wins.
238
+
239
+ ## Dependencies
240
+
241
+ List services a service needs with `dependencies`. During `broker.start()`, a
242
+ service's `started` hook waits until every dependency is registered. You can also
243
+ await readiness directly with `waitForServices` (from the broker or `this`):
244
+
245
+ ```ts
246
+ broker().createService({
247
+ name: "api",
248
+ dependencies: ["db", "cache"], // started() waits for both
249
+ async started() {
250
+ await this.waitForServices("mailer", 5000); // optional explicit wait (ms)
251
+ },
252
+ });
253
+ ```
254
+
255
+ ## Events
256
+
257
+ `emit` sends a **balanced** event — each listening *group* receives it once. In a
258
+ cluster only one instance per group is chosen; locally, with one instance per
259
+ service, that's every listener. `broadcast` always reaches every listener, and
260
+ `broadcastLocal` reaches every listener on this node (identical to `broadcast`
261
+ until a real transporter would otherwise relay across nodes).
262
+
263
+ ```ts
264
+ await broker().emit("user.created", user); // balanced
265
+ await broker().broadcast("cache.flush"); // everyone
266
+ await broker().broadcastLocal("cache.warm"); // everyone on this node
267
+ broker().hasEventListener("user.created"); // boolean
268
+ ```
269
+
270
+ ### Groups
271
+
272
+ Every listener belongs to a **group** — its service name by default, or whatever
273
+ `group` you set on the listener. `emit` delivers to one listener per group; pass
274
+ `groups` to target specific ones:
275
+
276
+ ```ts
277
+ broker().createService({
278
+ name: "mailer",
279
+ events: { "user.created": { group: "notify", handler: (ctx) => {} } },
280
+ });
281
+
282
+ await broker().emit("user.created", user, { groups: ["notify"] });
283
+ ```
284
+
285
+ ### Patterns
286
+
287
+ Subscription keys may glob: `*` matches one segment (`user.*`), `**` any depth
288
+ (`user.**`), and `?` a single non-dot character (`user.??eated`).
289
+
290
+ ### Internal events
291
+
292
+ The broker emits its own lifecycle events, which any service can subscribe to:
293
+
294
+ - **`$broker.started`** / **`$broker.stopped`** — after `start()` / before `stop()`.
295
+ - **`$services.changed`** — when a service is created or destroyed; the payload is
296
+ `{ service }`.
297
+
298
+ ```ts
299
+ broker().createService({
300
+ name: "registry",
301
+ events: {
302
+ "$services.changed": (ctx) => {
303
+ // ctx.params.service changed
304
+ },
305
+ },
306
+ });
307
+ ```
308
+
309
+ ## Lifecycle
310
+
311
+ Nothing runs actions across the network until you `start()`. Hooks fire in order:
312
+ `created` when the service is registered, `started` on `broker.start()`, and
313
+ `stopped` on `broker.stop()` (reverse order).
314
+
315
+ ```ts
316
+ const b = broker();
317
+ b.createService({
318
+ name: "clock",
319
+ async started() {
320
+ this.timer = setInterval(() => this.broker.broadcast("tick"), 1000);
321
+ },
322
+ async stopped() {
323
+ clearInterval(this.timer);
324
+ },
325
+ });
326
+
327
+ await b.start();
328
+ // ...
329
+ await b.stop();
330
+ ```
331
+
332
+ ## Clustering
333
+
334
+ The default broker is single-node. To span processes, implement `Transporter`
335
+ and pass it in — the broker calls `connect` on start and `disconnect` on stop,
336
+ and a real transporter registers remote services and relays calls/events:
337
+
338
+ ```ts
339
+ import { Broker, setBroker, type Transporter } from "@shaferllc/keel/core";
340
+
341
+ const nats: Transporter = {
342
+ async connect(broker) {
343
+ /* subscribe, register remote endpoints */
344
+ },
345
+ async disconnect() {
346
+ /* close */
347
+ },
348
+ };
349
+
350
+ setBroker(new Broker({ nodeID: "api-1", transporter: nats, requestTimeout: 10_000 }));
351
+ ```
352
+
353
+ `broker()` returns the default instance (a fresh single-node `Broker`);
354
+ `setBroker()` replaces it, exactly as `redis()` / `setRedis()` work.
355
+
356
+ ## Middlewares
357
+
358
+ Broker middlewares wrap every action call and tap broker lifecycle — the place
359
+ for cross-cutting concerns (logging, metrics, caching, auth) that apply to all
360
+ services. A middleware's `localAction` receives the next handler and returns a
361
+ replacement, so they compose (the first in the array is the outermost):
362
+
363
+ ```ts
364
+ import { Broker, type BrokerMiddleware } from "@shaferllc/keel/core";
365
+
366
+ const timing: BrokerMiddleware = {
367
+ name: "timing",
368
+ localAction(next, action) {
369
+ return async (ctx) => {
370
+ const start = performance.now();
371
+ try {
372
+ return await next(ctx);
373
+ } finally {
374
+ logger().debug("action", { action, ms: performance.now() - start });
375
+ }
376
+ };
377
+ },
378
+ started(broker) {
379
+ logger().info("broker up", { nodeID: broker.nodeID });
380
+ },
381
+ stopped() {
382
+ /* flush metrics, close connections */
383
+ },
384
+ };
385
+
386
+ const broker = new Broker({ middlewares: [timing] });
387
+ ```
388
+
389
+ `localAction(next, action)` wraps the handler (action = the full action name);
390
+ `started(broker)` / `stopped(broker)` run during `broker.start()` / `stop()`
391
+ (stopped in reverse order). A middleware that omits `localAction` leaves calls
392
+ untouched — handy for a lifecycle-only middleware.
393
+
394
+ ## Fault tolerance
395
+
396
+ A call can be made resilient with per-call options (or broker-wide defaults):
397
+
398
+ ```ts
399
+ // retry up to 3 times, then fall back to a cached value
400
+ await broker.call("orders.get", { id }, {
401
+ retries: 3,
402
+ fallback: { id, status: "unknown" },
403
+ });
404
+
405
+ // timeout + a computed fallback
406
+ await broker.call("pricing.quote", cart, {
407
+ timeout: 500,
408
+ fallback: (err: Error) => ({ error: err.message, price: null }),
409
+ });
410
+
411
+ const broker = new Broker({ requestTimeout: 1000, retries: 2 }); // defaults for every call
412
+ ```
413
+
414
+ - **`retries`** — total attempts are `retries + 1`; the whole call re-runs on
415
+ failure. Defaults to `BrokerOptions.retries`.
416
+ - **`fallback`** — a value, or `(err, ctx) => value`, returned once every attempt
417
+ (and any `error` hooks) has failed — instead of throwing.
418
+ - **`timeout`** — ms before a `RequestTimeoutError` (per call, per action, or the
419
+ broker default).
420
+
421
+ Order: retry → error hooks → fallback → throw.
422
+
423
+ ## Registry introspection
424
+
425
+ The broker's registry is queryable:
426
+
427
+ ```ts
428
+ broker.hasAction("users.find"); // boolean (private actions read as absent)
429
+ broker.listActions(); // ["orders.get", "users.find", …] (public, sorted)
430
+ broker.listServices(); // ["orders", "users", …]
431
+ broker.getService("users"); // the Service instance, or undefined
432
+ ```
433
+
434
+ ## Networking & balancing
435
+
436
+ The broker is **single-node** by default (`LocalTransporter`). Clustering across
437
+ nodes is the `Transporter` seam — implement `Transporter` for NATS, Redis, or TCP
438
+ and pass it as `transporter`. With a single node there's one endpoint per action,
439
+ so cross-node **load balancing** doesn't apply; event **group** balancing (one
440
+ listener per group) works today via `emit(event, payload, { groups })`.
441
+
442
+ ## Validating params
443
+
444
+ Give an action a `params` schema and it's validated (and coerced) before the
445
+ handler runs — a bad call rejects with a `ValidationException`, so the handler
446
+ only ever sees valid input:
447
+
448
+ ```ts
449
+ import { z } from "zod";
450
+
451
+ broker.createService({
452
+ name: "users",
453
+ actions: {
454
+ create: {
455
+ params: z.object({ email: z.string().email(), age: z.coerce.number().min(18) }),
456
+ handler: (ctx) => createUser(ctx.params), // params typed + validated
457
+ },
458
+ },
459
+ });
460
+ ```
461
+
462
+ Any [Zod-style schema](./validation.md) works — the broker bundles no validator.
463
+
464
+ ## Caching action results
465
+
466
+ Mark an action `cache` and give the broker a `cacher` (any Keel [`Cache`](./cache.md)
467
+ — memory, or Redis via `redisStore()`), and results are memoized by action + params:
468
+
469
+ ```ts
470
+ import { Cache } from "@shaferllc/keel/core";
471
+
472
+ const broker = new Broker({ cacher: new Cache() });
473
+
474
+ broker.createService({
475
+ name: "stats",
476
+ actions: {
477
+ daily: {
478
+ cache: { ttl: 300, keys: ["day"] }, // 5 min; key on the `day` param only
479
+ handler: (ctx) => computeDaily(ctx.params.day),
480
+ },
481
+ },
482
+ });
483
+ ```
484
+
485
+ `cache: true` caches forever keyed on all params; `{ ttl }` sets a TTL (seconds);
486
+ `{ keys }` limits the cache key to those params. With no `cacher`, `cache` is a
487
+ no-op.
488
+
489
+ ## Metrics, tracing, errors & runner
490
+
491
+ - **Metrics & tracing** — the [middleware](#middlewares) `localAction` seam is the
492
+ hook: wrap every call to time it, count it, or open a span. Every context
493
+ already carries the trace fields (`ctx.requestID`, `ctx.parentID`, `ctx.level`,
494
+ `ctx.caller`) a span exporter needs.
495
+ - **Errors** — the broker throws typed errors (`ServiceNotFoundError`,
496
+ `RequestTimeoutError`, and `ValidationException` from `params`); define your own
497
+ with [`createError`](./errors.md).
498
+ - **Runner** — no separate runner binary: register services with `createService()`
499
+ (loop over a folder of schemas) and call `broker.start()` from your app's boot
500
+ or a [service provider](./providers.md).