hexok 2.2.0 → 2.2.2
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/package.json
CHANGED
package/skills/hexok/SKILL.md
CHANGED
|
@@ -86,7 +86,7 @@ An adapter is the concrete port: memory while sketching, a database in the API,
|
|
|
86
86
|
|
|
87
87
|
Read [references/mapper.md](references/mapper.md) when the stored document is a different shape from the domain props. Persisting `toProps()` as JSON needs no mapper.
|
|
88
88
|
|
|
89
|
-
The publisher adapter accepts `EventInstance<typeof Catalog>`. It sends `Catalog.message(event)` when the transport stores bytes. The subscription adapter runs `Catalog.parse` and then `handle`. Ack, retry, prefetch, and consumer groups stay on that adapter.
|
|
89
|
+
The publisher adapter accepts `EventInstance<typeof Catalog>`. It sends `Catalog.message(event)` when the transport stores bytes. The subscription adapter runs `Catalog.parse` and then `handle`. Ack, retry, prefetch, and consumer groups stay on that adapter. The application sets `maxDeliveries` on `subscribe`. The adapter enforces it. The lease stays on the adapter.
|
|
90
90
|
|
|
91
91
|
### 5. Gateway
|
|
92
92
|
|
|
@@ -96,7 +96,7 @@ Use the HTTP library the application already depends on. Express, Hono, and Koa
|
|
|
96
96
|
|
|
97
97
|
The route builds the call context, calls `execute`, and maps failures to the reply. A missing entity is a 404. `CodedError` code `VALIDATION` is a 400 and `data.issues` names the fields. A catalog conflict, such as an already published post, is a 409. The catalog does not contain status codes.
|
|
98
98
|
|
|
99
|
-
Construct adapters,
|
|
99
|
+
Construct adapters, await `start` on each, and construct use cases in the entry point (`new PublishPost({ posts, events })`). Serve traffic only after every `start` resolves. On shutdown, await `stop` in the reverse order. An adapter that holds a connection, such as the Redis adapters, follows the lifecycle in [references/adapter.md](references/adapter.md). A use case instance is long-lived. Context is an argument to `execute`, not a field set per request.
|
|
100
100
|
|
|
101
101
|
### 6. Where handlers run
|
|
102
102
|
|
|
@@ -104,7 +104,7 @@ Skip this step when there is no handler.
|
|
|
104
104
|
|
|
105
105
|
Decide whether each handler shares the API process. Share it when the work is small, or when no adapter can publish in one process and subscribe in another.
|
|
106
106
|
|
|
107
|
-
Add a background-worker entry point when the handler does heavy work and a distributed publish/subscribe adapter exists. The API entry publishes. The worker entry
|
|
107
|
+
Add a background-worker entry point when the handler does heavy work and a distributed publish/subscribe adapter exists. The API entry publishes. The worker entry awaits `start` on the subscription adapter, awaits `subscribe` for each handler, and runs them. A `subscribe` that is not awaited can miss the first messages. The two processes use the same catalog. The worker parses the wire message, then calls `handle`.
|
|
108
108
|
|
|
109
109
|
Do not add a broker only to create a second process. Do not move the handler's work back into `execute`.
|
|
110
110
|
|
|
@@ -48,10 +48,59 @@ Store `toProps()` (or a mapped document) and rebuild with `restore` on the way o
|
|
|
48
48
|
|
|
49
49
|
The defaults of `start` and `stop` resolve immediately, so an in-memory adapter can leave them alone. A database adapter overrides `start` to connect and `stop` to disconnect. The application calls them. The use case calls `get` and `save`.
|
|
50
50
|
|
|
51
|
-
A publisher adapter accepts the catalog instance and, on a real transport, sends `BlogEvents.message(event)`. A subscription adapter owns the consumer loop. It parses with `BlogEvents.parse`, then calls each subscribed handler. It does not hide handler errors. Ack, retry, prefetch, and consumer groups live here, and only when the broker has them.
|
|
51
|
+
A publisher adapter accepts the catalog instance and, on a real transport, sends `BlogEvents.message(event)`. A subscription adapter owns the consumer loop. It parses with `BlogEvents.parse`, then calls each subscribed handler. It does not hide handler errors. Ack, retry, prefetch, and consumer groups live here, and only when the broker has them. The application sets `maxDeliveries` on `subscribe`. The adapter enforces it. The lease stays on the adapter.
|
|
52
52
|
|
|
53
53
|
One adapter implements one port. A second port is a second class.
|
|
54
54
|
|
|
55
|
+
## Adapters that own a connection
|
|
56
|
+
|
|
57
|
+
`@hexok/queue-redis`, `@hexok/broadcast-redis`, and `@hexok/schedule-coordinator-redis` each hold their own Redis connections. They share one lifecycle. A new adapter that holds an ioredis connection builds on `@hexok/ioredis-lifecycle`.
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
await queue.start();
|
|
61
|
+
await broadcast.start();
|
|
62
|
+
await broadcast.subscribe(PostChanged, onPostChanged);
|
|
63
|
+
const server = app.listen(port);
|
|
64
|
+
|
|
65
|
+
async function stop(): Promise<void> {
|
|
66
|
+
await new Promise((resolve) => server.close(resolve));
|
|
67
|
+
await broadcast.stop();
|
|
68
|
+
await queue.stop();
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
- **Start.** Await `start()` before the adapter is used. It resolves once Redis is ready. Start every adapter before the process serves traffic or subscribes handlers. A call made before `start()` resolves rejects or throws with code `NotStarted` or `Starting`. `register()` on the schedule coordinator is synchronous, so it throws.
|
|
73
|
+
- **State.** `state` is `ready` from `start()` until `stop()`, even while Redis is down. It says whether the adapter accepts calls, not whether Redis answers. Use it to gate traffic during startup and shutdown, not as a Redis health check.
|
|
74
|
+
- **Subscribe.** Await `subscribe()` before you rely on delivery. It resolves once Redis confirms the subscription. Redis pub/sub keeps no history, so a broadcast `subscribe()` that is not awaited misses messages published right after it. Its failure is also unhandled.
|
|
75
|
+
- **Stop.** Stop in the reverse order of start, and await each `stop()`. Close the gateway first so no new work arrives. `stop()` waits for in-flight commands, and for the queue's handlers and the schedule's ticks, and resolves within `stopTimeoutMs` even when Redis is unreachable. A call after `stop()` begins rejects or throws with code `Stopped`.
|
|
76
|
+
- **Unknown outcome.** A command still pending when `stop()` stops waiting, after `stopTimeoutMs`, rejects with code `StoppedBeforeReply`. Redis may or may not have run it. With at-least-once delivery and idempotent handlers, a duplicate is safe, so the next process can send it again. With at-most-once work, such as a one-time code, treat it as possibly done. Check whether it happened before you send it again.
|
|
77
|
+
|
|
78
|
+
`NotStarted`, `Starting`, and `Stopped` mean the call came at the wrong time. Fix the call order. Do not retry them. `StoppedBeforeReply` means Redis may or may not have run the command. The adapter is stopping, so do not retry on it. `StartTimeout` and `EndedBeforeReady` reject `start()` and close every connection. Call `start()` again, or exit and let the orchestrator restart the process.
|
|
79
|
+
|
|
80
|
+
Match on `error.code`, not on the message. Message text can change in any release. Each package exports `LifecycleError`, but `LifecycleError.is` uses `instanceof`. It returns false when the application installs two copies of the package. Matching on `code` also works across packages.
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
try {
|
|
84
|
+
await queue.publish('post.published', payload);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
const code = error instanceof Error && 'code' in error ? error.code : undefined;
|
|
87
|
+
if (code === 'StoppedBeforeReply') {
|
|
88
|
+
// Redis may have stored it. Send it again only when a duplicate is safe.
|
|
89
|
+
}
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`startTimeoutMs` makes `start()` reject with code `StartTimeout` when Redis is not ready in time. Unset, `start()` waits for Redis, which suits compose-style boot. Set it under an orchestrator such as Kubernetes, so the process exits and is restarted.
|
|
95
|
+
|
|
96
|
+
On Kubernetes, a pod has `terminationGracePeriodSeconds` (30 by default) between `SIGTERM` and `SIGKILL`. An app stops its adapters one after another, so the server close plus every adapter's `stopTimeoutMs` must fit inside it.
|
|
97
|
+
|
|
98
|
+
| Adapter | Default `stopTimeoutMs` |
|
|
99
|
+
| --- | --- |
|
|
100
|
+
| `@hexok/queue-redis` | 10000 |
|
|
101
|
+
| `@hexok/broadcast-redis` | 1000 |
|
|
102
|
+
| `@hexok/schedule-coordinator-redis` | 5000 |
|
|
103
|
+
|
|
55
104
|
## API
|
|
56
105
|
|
|
57
106
|
`Adapter(PortClass)` returns an abstract class that extends that port. Implement the port's methods. The compiler reports a missing one on the class.
|
|
@@ -52,7 +52,7 @@ A port parameter typed as the catalog class does not check event key or payload,
|
|
|
52
52
|
|
|
53
53
|
`parse` throws `hexok: catalog "blog" expected { key, payload }` when `value` lacks a string `key` or a `payload`. An unknown key throws `hexok: event "<key>" is not in catalog "blog"`. A payload the event schema rejects throws `CodedError` `VALIDATION` from that event's `parse`.
|
|
54
54
|
|
|
55
|
-
`examples/app-template` keeps
|
|
55
|
+
`examples/app-template` keeps one catalog in the app, `DomainEvents`. `DomainEventPublisher` calls `DomainEvents.message` and `@hexok/queue` carries that message. The worker calls `DomainEvents.parse` before `handle`. `@hexok/queue` does not own the catalog.
|
|
56
56
|
|
|
57
57
|
`EventInstance<typeof BlogEvents>` is the union of event instances in the catalog. Each instance carries its token. `EventMessage<typeof BlogEvents>` is the matching `{ key, payload }` union. The second type argument keeps one entry. `key` on a message is the map key. The event token is `BlogEvents.get(message.key).token`, and `event.token` on the instance.
|
|
58
58
|
|
|
@@ -38,7 +38,7 @@ class EmailSubscribers extends EventHandler('email.subscribers', PostPublished)
|
|
|
38
38
|
|
|
39
39
|
Annotate `event` with `PostPublished`. An unannotated `handle(event)` is implicit `any`. Keep `handle` a method.
|
|
40
40
|
|
|
41
|
-
A process that shares the API's memory can pass the instance straight to `handle`. A worker in another process can parse with `handler.event.parse` when the message is that event's payload, or read a catalog message, run `BlogEvents.parse`, and then call `handle`. Subscription, ack, and retry belong to that adapter. The instance `token` and `event` are the same values as the statics.
|
|
41
|
+
A process that shares the API's memory can pass the instance straight to `handle`. A worker in another process can parse with `handler.event.parse` when the message is that event's payload, or read a catalog message, run `BlogEvents.parse`, and then call `handle`. Subscription, ack, and retry belong to that adapter. The application sets `maxDeliveries` on `subscribe`. The adapter enforces it. The lease stays on the adapter. The instance `token` and `event` are the same values as the statics.
|
|
42
42
|
|
|
43
43
|
One handler is bound to one event class. A second event is a second handler.
|
|
44
44
|
|