hexok 2.2.0 → 2.2.1

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hexok",
3
- "version": "2.2.0",
3
+ "version": "2.2.1",
4
4
  "description": "TypeScript primitives for hexagonal software: Schema, Entity, UseCase, Port, Adapter, Mapper, Event, EventCatalog, EventHandler, and Errors.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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, call `start`, and construct use cases in the entry point (`new PublishPost({ posts, events })`). A use case instance is long-lived. Context is an argument to `execute`, not a field set per request.
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 starts the subscription adapter, registers the handlers, and runs them. The two processes use the same catalog. The worker parses the wire message, then calls `handle`.
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
 
@@ -52,6 +52,55 @@ A publisher adapter accepts the catalog instance and, on a real transport, sends
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 two catalogs in the app. `DomainEventPublisher` calls `DomainEvents.message` and `@hexok/queue` carries that message. The worker calls `DomainEvents.parse` before `handle`. `ClientEventPublisher` calls `ClientEvents.message` and `@hexok/broadcast` carries that message. The websocket process calls `ClientEvents.parse` and sends the event to subscribed sockets. Neither package owns a catalog.
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