hexok 2.0.0 → 2.1.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/README.md +9 -0
- package/package.json +3 -2
- package/skills/hexok/SKILL.md +127 -0
- package/skills/hexok/references/adapter.md +66 -0
- package/skills/hexok/references/entity.md +74 -0
- package/skills/hexok/references/error.md +140 -0
- package/skills/hexok/references/event-catalog.md +57 -0
- package/skills/hexok/references/event-handler.md +56 -0
- package/skills/hexok/references/event.md +53 -0
- package/skills/hexok/references/mapper.md +77 -0
- package/skills/hexok/references/port.md +58 -0
- package/skills/hexok/references/schema.md +47 -0
- package/skills/hexok/references/use-case.md +164 -0
package/README.md
CHANGED
|
@@ -6,4 +6,13 @@ Import them from `hexok`. There is no HTTP layer and no composition root in this
|
|
|
6
6
|
|
|
7
7
|
`UseCase.context<Ctx, Spec>()` is a family factory for a per-call context, optional guards, optional hooks, and required statics. The optional argument is a settings object with no keys yet. Pass nothing. `{}` is allowed. `guard` and `hooks` are methods, not fields of that object. A missing `<Ctx>` type argument is a type error. `Spec` defaults to no required statics. `.guard(fn)` and `.hooks(def)` each return the same factory. Order does not matter. Either may be omitted. Another `.guard()` runs after guards already registered. A second `.hooks()` throws `hexok: UseCase hooks are already set` when the factory is created, if the first registration had at least one callback. An empty `.hooks({})` does not register hooks and does not wrap `execute`. The call is `{ ctx, spec, token, input }`. `input` is the argument passed to `execute`. The method receives that same value. Hexok does not validate it. `UseCase.GuardParameters<typeof Factory>` is that call. `UseCase.Ctx<typeof Factory>` is that `ctx`. Pass the factory. `.hooks` infers the value returned from `preExecute` as the `state` argument of `postExecute`, `onCatch`, and `onFinally`. If `preExecute` is omitted, that state is `void`. `postExecute` also receives `result`. `onFinally` receives `status: 'success'` with `result`, or `status: 'failure'` with `error`. The pipeline is guard (outside try), `preExecute`, the method, `postExecute`, `onCatch` then rethrow, `onFinally`. A guard throw does not enter the hooks. A throw inside `onCatch` replaces the error after `onFinally` sees the original one. `UseCase(token)` still has `execute(input)`.
|
|
8
8
|
|
|
9
|
+
## Agent skill
|
|
10
|
+
|
|
11
|
+
This package's repository includes an agent skill named `hexok`. It walks a feature from the user story through entities, use cases, events, adapters, the gateway, and where handlers run. Install it from GitHub. `npx skills update` refreshes that install from GitHub.
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx skills add crobinson42/hexok
|
|
15
|
+
npx skills update
|
|
16
|
+
```
|
|
17
|
+
|
|
9
18
|
The root [README](../../README.md) shows the shape. Tokens and schemas are arguments of each primitive, so they cannot be forgotten. `execute`, `handle`, and port methods are abstract, so a missing method is a compiler error on the class. `start`, `stop`, `parse`, and `set` are concrete methods you can override.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hexok",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.0",
|
|
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",
|
|
@@ -35,7 +35,8 @@
|
|
|
35
35
|
"./package.json": "./package.json"
|
|
36
36
|
},
|
|
37
37
|
"files": [
|
|
38
|
-
"dist"
|
|
38
|
+
"dist",
|
|
39
|
+
"skills"
|
|
39
40
|
],
|
|
40
41
|
"scripts": {
|
|
41
42
|
"build": "tsc -b",
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: hexok
|
|
3
|
+
description: >
|
|
4
|
+
Build application features with the hexok primitives. Use when the task adds
|
|
5
|
+
or changes a user story, entity, schema, use case, port, adapter, event,
|
|
6
|
+
event handler, gateway, or background worker in a project that depends on
|
|
7
|
+
hexok. Follow its thought, research, and plan steps, and read the primitive
|
|
8
|
+
reference it names before writing that primitive.
|
|
9
|
+
license: MIT
|
|
10
|
+
metadata:
|
|
11
|
+
short-description: Build features with hexok
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# Hexok
|
|
15
|
+
|
|
16
|
+
Import primitives from `hexok`. The application owns HTTP, RPC, websockets, gRPC, the object graph, brokers, ack, retry, and consumer groups.
|
|
17
|
+
|
|
18
|
+
The running shape is a person who creates a blog post and publishes it. Subscribers are emailed afterward. Use the application's real story. The blog is only the shape.
|
|
19
|
+
|
|
20
|
+
## Concepts
|
|
21
|
+
|
|
22
|
+
Read the reference before writing that concept. Each file is the supporting documentation for one primitive.
|
|
23
|
+
|
|
24
|
+
| Concept | Reference | Place |
|
|
25
|
+
| --- | --- | --- |
|
|
26
|
+
| Schema | [references/schema.md](references/schema.md) | Domain. The shape of stored data and event payloads. |
|
|
27
|
+
| Entity | [references/entity.md](references/entity.md) | Domain. One stored thing and its rules. |
|
|
28
|
+
| Error | [references/error.md](references/error.md) | Domain. Named refusals, the error map, and `CodedError` `VALIDATION`. |
|
|
29
|
+
| Event | [references/event.md](references/event.md) | Domain. A fact the use case records. |
|
|
30
|
+
| EventCatalog | [references/event-catalog.md](references/event-catalog.md) | Domain. The set of events, and the conversion to a wire message. |
|
|
31
|
+
| UseCase | [references/use-case.md](references/use-case.md) | Application. One caller action. `UseCase.context` is in that file. |
|
|
32
|
+
| Port | [references/port.md](references/port.md) | Application. The contract a use case or handler calls. |
|
|
33
|
+
| EventHandler | [references/event-handler.md](references/event-handler.md) | Application. Work that follows one event. |
|
|
34
|
+
| Adapter | [references/adapter.md](references/adapter.md) | Interface adapter. The current implementation of one port. |
|
|
35
|
+
| Mapper | [references/mapper.md](references/mapper.md) | Interface adapter. Read it when the stored document is not the domain props. |
|
|
36
|
+
|
|
37
|
+
Dependencies point inward. Domain code does not import a database client, a queue, an HTTP library, or a mail SDK. Use cases and handlers depend on ports. Adapters implement ports. A gateway calls use cases.
|
|
38
|
+
|
|
39
|
+
## How to build a feature
|
|
40
|
+
|
|
41
|
+
Walk the steps below three times, in this order, before you edit files.
|
|
42
|
+
|
|
43
|
+
1. **Thought.** State one user story: who acts, and what they can do. Example: as a user I can create a blog post and publish it. Name the actions a caller can invoke on their own. Creating a draft and publishing it are two use cases when both are reachable. One use case is enough when the caller can only publish.
|
|
44
|
+
2. **Research.** Search the codebase for that story. Note the entity, schema, error catalog, use case, ports, events, handlers, adapters, gateway route, and worker entry that already exist.
|
|
45
|
+
3. **Plan.** Write a short plan in the step order below. Mark each item `exists` or `add`. A step with nothing to add is `none`. Then implement in that same order.
|
|
46
|
+
|
|
47
|
+
When the task is only a plan, stop after the plan. When the task is to build the feature, implement the plan after it is written. Read the matching reference at the start of each step you implement.
|
|
48
|
+
|
|
49
|
+
### 1. User story
|
|
50
|
+
|
|
51
|
+
Write the story in one sentence. That sentence is the feature. Later files, tokens, and routes should be recognizable as parts of it.
|
|
52
|
+
|
|
53
|
+
### 2. Entities and schemas
|
|
54
|
+
|
|
55
|
+
Identify what the story persists. Define the schema, then the entity, when they are not already there.
|
|
56
|
+
|
|
57
|
+
A blog post is an id, a title, a body, and whether it is published. The schema is that shape. The entity holds the rules, such as refusing a blank title or a second publish. Read [references/schema.md](references/schema.md) and [references/entity.md](references/entity.md). Add catalog members used by those rules while you are here. Read [references/error.md](references/error.md).
|
|
58
|
+
|
|
59
|
+
### 3. Use cases and ports
|
|
60
|
+
|
|
61
|
+
Define each use case as one action, and the ports that action calls, when they are not already there.
|
|
62
|
+
|
|
63
|
+
Saving a post is a port. Publishing an event is a different port. The use case receives both in its constructor and calls them from `execute`. It does not import a driver. Read [references/use-case.md](references/use-case.md) and [references/port.md](references/port.md).
|
|
64
|
+
|
|
65
|
+
Use `UseCase(token)` when `execute` only needs the input. Use `UseCase.context<Ctx>()` when every call in the family carries the same extra facts, such as a trace id, the actor, or the caller IP. Share one family across the gateway's use cases.
|
|
66
|
+
|
|
67
|
+
### 3.b. Side effects
|
|
68
|
+
|
|
69
|
+
Identify work that is a consequence of the story and is outside the use case's job. Emailing subscribers after a post is published is that kind of work. Indexing and heavy computation are too.
|
|
70
|
+
|
|
71
|
+
When that work exists and the event does not, define the event and register it on a catalog. Give the use case an event-publisher port and publish the event instance at the end of `execute`, after the entity rule has run and the save has succeeded. Read [references/event.md](references/event.md) and [references/event-catalog.md](references/event-catalog.md).
|
|
72
|
+
|
|
73
|
+
When the story has no consequence beyond its own write, this step is `none`. Do not add an event so the step looks complete.
|
|
74
|
+
|
|
75
|
+
### 3.c. Event handlers
|
|
76
|
+
|
|
77
|
+
When step 3.b named a side effect, implement the handler if it does not exist. The handler takes its own ports in the constructor. `handle` receives the event. Emailing subscribers loads addresses through a subscriber port and sends mail through a mail port. Read [references/event-handler.md](references/event-handler.md).
|
|
78
|
+
|
|
79
|
+
The handler does not subscribe itself. Subscription belongs to the adapter in step 4.
|
|
80
|
+
|
|
81
|
+
### 4. Adapters
|
|
82
|
+
|
|
83
|
+
List every port a use case or handler calls, including repositories, the event publisher, the event subscription, and outbound ports such as email. Implement each missing port as an adapter.
|
|
84
|
+
|
|
85
|
+
An adapter is the concrete port: memory while sketching, a database in the API, a fake in a test, a broker when a worker runs elsewhere. Swapping it changes the composition root. `execute` stays put. Read [references/adapter.md](references/adapter.md).
|
|
86
|
+
|
|
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
|
+
|
|
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.
|
|
90
|
+
|
|
91
|
+
### 5. Gateway
|
|
92
|
+
|
|
93
|
+
Identify how a caller outside the process reaches the use case. The common surface is HTTP in an RPC shape: one path per use case, such as `POST /posts` and `POST /posts/:id/publish`. REST, websockets, and gRPC are the same kind of edge when the story needs them.
|
|
94
|
+
|
|
95
|
+
Use the HTTP library the application already depends on. Express, Hono, and Koa are all suitable. Add one only when the application has none.
|
|
96
|
+
|
|
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
|
+
|
|
99
|
+
Construct adapters, call `start`, and construct use cases in the entry point. A use case instance is long-lived. Context is an argument to `execute`, not a field set per request.
|
|
100
|
+
|
|
101
|
+
### 6. Where handlers run
|
|
102
|
+
|
|
103
|
+
Skip this step when there is no handler.
|
|
104
|
+
|
|
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
|
+
|
|
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`.
|
|
108
|
+
|
|
109
|
+
Do not add a broker only to create a second process. Do not move the handler's work back into `execute`.
|
|
110
|
+
|
|
111
|
+
## Conventions
|
|
112
|
+
|
|
113
|
+
- Tokens are factory arguments: `Entity('Post', PostSchema)`, `UseCase('post.publish')`, `Port('PostRepository')`, `Event('post.published', PostSchema)`, `EventCatalog('blog', { postPublished })`, `EventHandler('email.subscribers', PostPublished)`, `Errors('blog', { ... })`. A subclass does not redeclare the token.
|
|
114
|
+
- `execute`, `handle`, port methods, and a mapper's `fromSource` and `toSource` are methods on the class. The compiler reports a missing one. `start`, `stop`, `parse`, and `set` already exist and may be overridden.
|
|
115
|
+
- A use case throws a catalog member when the story refuses. Schema checks throw `CodedError` with code `VALIDATION`. Catch both at the gateway or the worker.
|
|
116
|
+
- Call catalog factories without `new`: `BlogError.BlankTitle()`.
|
|
117
|
+
- Check use-case input in a guard or in the first lines of `execute`. A schema stored on a `UseCase.context` static bag is application data. Hexok does not run it.
|
|
118
|
+
- Annotate `execute` on the subclass. Callers see that annotation. On a context family, the parameters are `(ctx, input)` and `ctx` is `UseCase.Ctx<typeof Factory>`.
|
|
119
|
+
- Publish `new PostPublished(payload)`. Compare events with `instanceof` or `event.token`. The catalog map key (`postPublished`) and the event token (`post.published`) are different strings.
|
|
120
|
+
- After a successful driver write, the adapter calls `commit()` on the entity. The mapper does not commit.
|
|
121
|
+
- Tests construct the use case with adapters of the same ports. A test of the story does not need a live broker.
|
|
122
|
+
- Put one concept in one file. A practical layout is `schemas/`, `entities/`, `errors/`, `events/`, `ports/`, `use-cases/`, `handlers/`, `adapters/`, an HTTP entry, and a worker entry when step 6 splits a process.
|
|
123
|
+
- Zod, Valibot, and ArkType are passed straight to the primitives. A TypeBox 1 schematic goes through `@hexok/typebox` (`typebox(schema)` or `typeboxDecode(schema)`) before it reaches `Schema`, `Entity`, or `Event`.
|
|
124
|
+
|
|
125
|
+
## When a primitive changes
|
|
126
|
+
|
|
127
|
+
A change to primitive behavior updates the matching file under `references/` in the same change. `npx skills update` refreshes installed copies from the GitHub repository.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Adapter
|
|
2
|
+
|
|
3
|
+
An adapter is a port with its methods filled in. Pass the port class to `Adapter` and implement every abstract method. `start` and `stop` are already there for connecting and disconnecting.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
class InMemoryPosts extends Adapter(PostRepository) {
|
|
7
|
+
override async get(id: string): Promise<Post> {
|
|
8
|
+
/* … */
|
|
9
|
+
}
|
|
10
|
+
override async save(post: Post): Promise<void> {
|
|
11
|
+
/* … */
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Why it exists
|
|
17
|
+
|
|
18
|
+
The use case asks a `PostRepository` to save. Something has to actually keep the post: a `Map` while you sketch the feature, a database when the API runs, a fake in a test that records the calls. Those are adapters of one port. Swapping them changes the constructor arguments at the composition root, and `execute` stays put.
|
|
19
|
+
|
|
20
|
+
`start` and `stop` give a connection a home that is still outside the use case. The process opens the database before it serves traffic, and closes it when it shuts down.
|
|
21
|
+
|
|
22
|
+
## How to use it
|
|
23
|
+
|
|
24
|
+
An in-memory repository is enough to walk the publish story. The API process can later substitute a database class that extends the same port. The composition root constructs the adapter, starts it, and hands it to `PublishPost`.
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
class InMemoryPosts extends Adapter(PostRepository) {
|
|
28
|
+
private readonly rows = new Map<string, ReturnType<Post['toProps']>>();
|
|
29
|
+
|
|
30
|
+
override async get(id: string): Promise<Post> {
|
|
31
|
+
const props = this.rows.get(id);
|
|
32
|
+
if (props === undefined) throw BlogError.PostMissing({ id });
|
|
33
|
+
return Post.restore(props);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
override async save(post: Post): Promise<void> {
|
|
37
|
+
this.rows.set(post.props.id, post.toProps());
|
|
38
|
+
post.commit();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const posts = new InMemoryPosts();
|
|
43
|
+
await posts.start();
|
|
44
|
+
const publishPost = new PublishPost(posts, events);
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Store `toProps()` (or a mapped document) and rebuild with `restore` on the way out. Call `commit()` after the write succeeds.
|
|
48
|
+
|
|
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
|
+
|
|
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.
|
|
52
|
+
|
|
53
|
+
One adapter implements one port. A second port is a second class.
|
|
54
|
+
|
|
55
|
+
## API
|
|
56
|
+
|
|
57
|
+
`Adapter(PortClass)` returns an abstract class that extends that port. Implement the port's methods. The compiler reports a missing one on the class.
|
|
58
|
+
|
|
59
|
+
| Member | Role |
|
|
60
|
+
| --- | --- |
|
|
61
|
+
| `token` | The port's token. An adapter keeps it and cannot replace it. |
|
|
62
|
+
| Port methods | Implement each abstract method from the port. |
|
|
63
|
+
| `start()` | Called by the application before the adapter is used. Override it to connect. The default resolves immediately. |
|
|
64
|
+
| `stop()` | Called by the application when it is finished with the adapter. Override it to disconnect. The default resolves immediately. |
|
|
65
|
+
|
|
66
|
+
`InMemoryPosts.token` is `PostRepository.token`.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Entity
|
|
2
|
+
|
|
3
|
+
An entity is one thing the software stores and changes, such as a blog post. Pair a name with a schema, then add methods for the rules. Callers build an instance with `create`, `restore`, or `parse`, change it through those methods, and read the props back out when it is time to save.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
class Post extends Entity('Post', PostSchema) {}
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
`schema` is a Standard Schema or a Schema class.
|
|
10
|
+
|
|
11
|
+
## Why it exists
|
|
12
|
+
|
|
13
|
+
A post has data and rules. The data is an id, a title, a body, and whether it is published. A rule says that publishing records `publishedAt`, and that a blank title is refused. The entity keeps both on one object.
|
|
14
|
+
|
|
15
|
+
The schema runs when the post is created from typed props, when unknown input is parsed, and again when a method writes. A failed write restores the previous props. The instance remembers whether it is new and which fields changed, so a repository can save it without a second copy of those rules.
|
|
16
|
+
|
|
17
|
+
## How to use it
|
|
18
|
+
|
|
19
|
+
Publishing is a method on the entity. The use case loads the post, calls the method, and saves. The method refuses a blank title with a catalog error, then updates the draft.
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
class Post extends Entity('Post', PostSchema) {
|
|
23
|
+
publish(now: Date): this {
|
|
24
|
+
if (this.props.title.trim() === '') throw BlogError.BlankTitle();
|
|
25
|
+
if (this.props.published) throw BlogError.AlreadyPublished();
|
|
26
|
+
return this.set((draft) => {
|
|
27
|
+
draft.published = true;
|
|
28
|
+
draft.publishedAt = now;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const post = Post.create({
|
|
34
|
+
id: 'p1',
|
|
35
|
+
title: 'Hello',
|
|
36
|
+
body: 'First post',
|
|
37
|
+
published: false,
|
|
38
|
+
});
|
|
39
|
+
post.publish(new Date());
|
|
40
|
+
await posts.save(post);
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`create` checks the props and marks the post new. A repository loading a stored row uses `restore`, which keeps those props and treats the post as already saved. A request body is untyped, so the edge uses `parse`: the schema runs, and the post is treated as existing.
|
|
44
|
+
|
|
45
|
+
Read `props` for the current data. Write only through `set` or a method that calls it. `toProps()` is the frozen snapshot you persist or return from a use case.
|
|
46
|
+
|
|
47
|
+
The adapter calls `commit()` after the driver write succeeds. That is what clears `isNew` and the dirty set. The use case does not commit, and a mapper does not commit.
|
|
48
|
+
|
|
49
|
+
## API
|
|
50
|
+
|
|
51
|
+
`Entity(token, schema)` returns the class you extend. `token` is a string literal.
|
|
52
|
+
|
|
53
|
+
| Member | Role |
|
|
54
|
+
| --- | --- |
|
|
55
|
+
| `Post.token` | The name you passed in. |
|
|
56
|
+
| `Post.schema` | The Standard Schema used by `create`, `parse`, `set`, and `validate`. |
|
|
57
|
+
| `Post.create(props)` | Check `props` and return a new instance. `isNew` stays true until `commit`. |
|
|
58
|
+
| `Post.restore(props)` | Build an instance from stored props. The schema waits until `validate` or a writing `set`. `isNew` is false. |
|
|
59
|
+
| `Post.parse(value)` | Check an unknown value, then build an instance the way `restore` does. `isNew` is false. |
|
|
60
|
+
| `props` | The current data, typed read-only. Change it with `set`. |
|
|
61
|
+
| `set(draft => …)` | Copy-on-write edit. A write runs the schema. A failed check restores the previous props and throws. |
|
|
62
|
+
| `validate()` | Run the schema when this instance has not already passed. A passed instance returns itself. |
|
|
63
|
+
| `commit()` | Treat the current props as the saved original. `isNew` becomes false. |
|
|
64
|
+
| `isNew` | True after `create`, until `commit`. |
|
|
65
|
+
| `isValidated` | True after `create`, `parse`, a writing `set`, or `validate`. False after `restore` until one of those runs. |
|
|
66
|
+
| `original` | Props from before the first change on a restored instance. `undefined` while the instance is clean, and while a created instance has not been committed. |
|
|
67
|
+
| `isDirty()` | True when the post is new, or a shallow field differs from the saved original. |
|
|
68
|
+
| `getChangedKeys()` | Shallow keys that differ. `{ deep: true }` returns dotted paths. |
|
|
69
|
+
| `toProps()` | A deep-frozen snapshot for saving or returning. Reused until the next `set`. |
|
|
70
|
+
| `toJSON()` | The same snapshot as `toProps()`. |
|
|
71
|
+
|
|
72
|
+
A schema failure throws `CodedError` with code `VALIDATION` and the message `hexok: Post validation failed`. A domain rule throws a member of the error catalog.
|
|
73
|
+
|
|
74
|
+
`DeepReadonly<T>` is the type of `props`, `original`, and `toProps()`. `Date` values stay `Date`. `EntityInstance` is the props-only instance type.
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# Error
|
|
2
|
+
|
|
3
|
+
An error catalog is the named set of refusals the application throws. Define it with `Errors`, giving the catalog a name and one entry per code. Call sites throw a member. The edge catches the catalog and decides what a caller sees.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
class BlogError extends Errors('blog', {
|
|
7
|
+
BlankTitle: { message: 'Title is blank' },
|
|
8
|
+
AlreadyPublished: { message: 'Post is already published' },
|
|
9
|
+
}) {}
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
The shape of each entry is an error map. A failed schema check is a different failure: `CodedError` with code `VALIDATION`.
|
|
13
|
+
|
|
14
|
+
## Why it exists
|
|
15
|
+
|
|
16
|
+
Publishing can refuse for reasons the caller can act on. The title is blank. The post is already published. The trace id is missing. A catalog gives each refusal a stable code, a message, and, when the caller needs a value back, a payload such as the post id.
|
|
17
|
+
|
|
18
|
+
The catalog stays a list of domain facts. Mapping `BlankTitle` to an HTTP status, an RPC code, or a queue retry lives in the gateway or the worker.
|
|
19
|
+
|
|
20
|
+
## How to use it
|
|
21
|
+
|
|
22
|
+
The entity throws. The use case lets the error travel. The gateway matches and chooses the reply.
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
class Post extends Entity('Post', PostSchema) {
|
|
26
|
+
publish(now: Date): this {
|
|
27
|
+
if (this.props.title.trim() === '') throw BlogError.BlankTitle();
|
|
28
|
+
if (this.props.published) throw BlogError.AlreadyPublished();
|
|
29
|
+
return this.set((draft) => {
|
|
30
|
+
draft.published = true;
|
|
31
|
+
draft.publishedAt = now;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
await publishPost.execute({ id });
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (!BlogError.is(error)) throw error;
|
|
40
|
+
return BlogError.match(error, {
|
|
41
|
+
BlankTitle: () => reply(400, error.message),
|
|
42
|
+
AlreadyPublished: () => reply(409, error.message),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Call the member. `BlogError.BlankTitle()` uses the catalog message. `BlogError.BlankTitle('Title cannot be empty')` replaces it for this throw. `new BlogError.BlankTitle()` throws `TypeError`, because the member is a factory.
|
|
48
|
+
|
|
49
|
+
`UseCase`, `Entity`, `Port`, `Adapter`, `Schema`, `Mapper`, `Event`, `EventCatalog`, and `EventHandler` do not take an error map. There is no `error()` method on those classes.
|
|
50
|
+
|
|
51
|
+
## Error map
|
|
52
|
+
|
|
53
|
+
The object passed to `Errors` is the error map. Each key is a code. Each value is an `ErrorDef`: an optional `message`, and an optional `data` Standard Schema. `ErrorMap` and `ErrorDef` are exported because declaration emit names them.
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
class BlogError extends Errors('blog', {
|
|
57
|
+
BlankTitle: { message: 'Title is blank' },
|
|
58
|
+
TraceIdMissing: {},
|
|
59
|
+
PostMissing: {
|
|
60
|
+
message: 'Post was not found',
|
|
61
|
+
data: z.object({ id: z.string() }),
|
|
62
|
+
},
|
|
63
|
+
}) {}
|
|
64
|
+
|
|
65
|
+
throw BlogError.BlankTitle();
|
|
66
|
+
throw BlogError.TraceIdMissing();
|
|
67
|
+
throw BlogError.PostMissing({ id: 'p1' });
|
|
68
|
+
throw BlogError.PostMissing('No post p1', { id: 'p1' });
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`TraceIdMissing` omits `message`, so the factory's fallback text is the code, `'TraceIdMissing'`. `PostMissing` requires `{ id: string }` because that is the schema's output. The `data` schema types the argument. The factory stores the value it was given and does not validate it. Run the schema yourself before the throw when a bad payload should fail as a schema check.
|
|
72
|
+
|
|
73
|
+
| Entry | Call |
|
|
74
|
+
| --- | --- |
|
|
75
|
+
| No `data` | `Code()` or `Code(message)`. |
|
|
76
|
+
| `data` whose output is an object or number | `Code(data)` or `Code(message, data)`. |
|
|
77
|
+
| `data` whose output is a string | One argument is the payload. The message is first only when the payload follows it: `Code(message, data)`. |
|
|
78
|
+
|
|
79
|
+
Keys `is`, `match`, `prototype`, and `token` are reserved. Defining one of them throws `hexok: error code "<code>" is reserved`.
|
|
80
|
+
|
|
81
|
+
## Catalog API
|
|
82
|
+
|
|
83
|
+
`Errors(token, defs)` returns the class you extend. `token` is the catalog name.
|
|
84
|
+
|
|
85
|
+
| Member | Role |
|
|
86
|
+
| --- | --- |
|
|
87
|
+
| `BlogError.token` | The catalog name. |
|
|
88
|
+
| `BlogError.BlankTitle(...)` | Builds that member. You throw the return value. |
|
|
89
|
+
| `BlogError.is(error)` | True when `error` is a member of this catalog. Narrows to the union of members. |
|
|
90
|
+
| `BlogError.match(error, cases)` | Runs the handler for `error.code`. Every code in the catalog is required. A code that is missing at runtime throws `hexok: unmatched blog.<code>`. |
|
|
91
|
+
| `error.catalog` | The catalog token. |
|
|
92
|
+
| `error.code` | The member code, such as `'BlankTitle'`. |
|
|
93
|
+
| `error.message` | The catalog message, or the override passed at the call. |
|
|
94
|
+
| `error.data` | The payload, or `undefined` when the definition has no `data` schema. |
|
|
95
|
+
| `error.name` | `` `${token}.${code}` ``, such as `'blog.BlankTitle'`. |
|
|
96
|
+
|
|
97
|
+
The message on the returned error keeps the literal you passed, including a template literal. `is` and `match` type `message` as `string`, because one code can be thrown with the catalog text or an override.
|
|
98
|
+
|
|
99
|
+
`instanceof BlogError` is the same catalog check as `is`. `is` is what narrows the union so `match` and `code` see each member.
|
|
100
|
+
|
|
101
|
+
## Coded error
|
|
102
|
+
|
|
103
|
+
A coded error is a failure identified by a `code` string, with a message and an optional payload. Schema checks throw one whose code is `VALIDATION`. Application refusals use the catalog.
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
new CodedError({
|
|
107
|
+
code: 'VALIDATION',
|
|
108
|
+
message: 'hexok: Post validation failed',
|
|
109
|
+
data: { issues },
|
|
110
|
+
});
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Checking a shape and refusing a story are different events. `Post.parse` can reject a body that has no title. `post.publish()` can reject a post that is already live. The gateway shows field errors from `data.issues` and matches catalog members on their own codes.
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
try {
|
|
117
|
+
const post = Post.parse(body);
|
|
118
|
+
await posts.save(post);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (error instanceof CodedError && error.code === 'VALIDATION') {
|
|
121
|
+
return reply(400, error.data);
|
|
122
|
+
}
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The same error is thrown by `Schema.parse`, entity `create`, `parse`, a writing `set`, or `validate`, `Event.parse`, and `EventCatalog.parse` when the payload schema rejects the message. `Entity.restore` does not validate. The next writing `set`, or an explicit `validate()`, can throw.
|
|
128
|
+
|
|
129
|
+
| Member | Role |
|
|
130
|
+
| --- | --- |
|
|
131
|
+
| `new CodedError({ code, message?, data? })` | `message` defaults to `code`. `data` is set only when the argument object has a `data` key. |
|
|
132
|
+
| `error.code` | The code. Schema checks use `'VALIDATION'`. |
|
|
133
|
+
| `error.message` | The message. Schema checks use `` `hexok: ${token} validation failed` ``. |
|
|
134
|
+
| `error.data` | Optional payload. For a schema check it is `{ issues }` when the schema reported issues. |
|
|
135
|
+
| `error.name` | `'CodedError'`. |
|
|
136
|
+
| `validationError(message, issues?)` | Builds a `CodedError<'VALIDATION'>`. Passes `data: { issues }` when `issues` is present. |
|
|
137
|
+
|
|
138
|
+
An issue is a Standard Schema issue: a `message` and an optional `path`. An async schema throws a plain `Error` with the message `hexok: async schemas belong at the application edge`.
|
|
139
|
+
|
|
140
|
+
`ok`, `fail`, and `Result` are exported for application code that already returns a result. Entity methods, `execute`, and `handle` throw. They do not return `Result`.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# EventCatalog
|
|
2
|
+
|
|
3
|
+
An event catalog is the set of events one publisher and its subscribers share. Name the catalog and pass a map of event classes. The map key is the name in a serialized message. The event's own token stays the name on the class.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
class BlogEvents extends EventCatalog('blog', {
|
|
7
|
+
postPublished: PostPublished,
|
|
8
|
+
}) {}
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Why it exists
|
|
12
|
+
|
|
13
|
+
A use case has a `PostPublished` object. A queue, a log, or another process needs something it can store: a key and a payload. The catalog is the agreement on which events exist, and the two conversions between those forms. `message` turns an instance into `{ key, payload }`. `parse` turns that message back into the event class, checking the payload with the event's schema.
|
|
14
|
+
|
|
15
|
+
The map key and the event token are different strings on purpose. `postPublished` is the name in the catalog and on the wire message. `post.published` is the token on the class, the same string passed to `Event`.
|
|
16
|
+
|
|
17
|
+
## How to use it
|
|
18
|
+
|
|
19
|
+
The publisher port accepts any instance from the catalog. The adapter that talks to a transport calls `message` on the way out and `parse` on the way in. A handler then receives the event object.
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
abstract class BlogEventPublisher extends Port('BlogEventPublisher') {
|
|
23
|
+
abstract publish(event: EventInstance<typeof BlogEvents>): Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const message = BlogEvents.message(new PostPublished(post.toProps()));
|
|
27
|
+
// { key: 'postPublished', payload: { id, title, body, published } }
|
|
28
|
+
|
|
29
|
+
const event = BlogEvents.parse(message);
|
|
30
|
+
await emailSubscribers.handle(event);
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`publish` takes the instance union. A generic `publish<K>(key, payload)` does not keep the payload paired with the key inside the method. The use case publishes the instance. The adapter converts it.
|
|
34
|
+
|
|
35
|
+
`BlogEvents.get('postPublished').token` is `'post.published'`.
|
|
36
|
+
|
|
37
|
+
Narrow one entry when a function handles a single key: `EventInstance<typeof BlogEvents, 'postPublished'>` and `EventMessage<typeof BlogEvents, 'postPublished'>`.
|
|
38
|
+
|
|
39
|
+
A port parameter typed as the catalog class does not check event key or payload, because a catalog instance has no fields. Use `typeof BlogEvents` when the type you need is the static side.
|
|
40
|
+
|
|
41
|
+
## API
|
|
42
|
+
|
|
43
|
+
`EventCatalog(token, events)` returns the class you extend. `token` is a string literal. `events` is a map of event classes. Duplicate event tokens throw `hexok: duplicate event "<token>" in catalog "<name>"` when the class is defined. The map is frozen on the catalog.
|
|
44
|
+
|
|
45
|
+
| Member | Role |
|
|
46
|
+
| --- | --- |
|
|
47
|
+
| `BlogEvents.token` | The catalog name. |
|
|
48
|
+
| `BlogEvents.events` | The map you passed in. |
|
|
49
|
+
| `BlogEvents.get(name)` | The class registered under that map key. An unknown key throws `hexok: event "<name>" is not in catalog "blog"`. |
|
|
50
|
+
| `BlogEvents.message(event)` | `{ key, payload }` for an instance of a class in this catalog. `key` is the map key. An event from elsewhere throws `hexok: event "<token>" is not in catalog "blog"`. |
|
|
51
|
+
| `BlogEvents.parse(value)` | Checks a `{ key, payload }` message and returns the event instance. |
|
|
52
|
+
|
|
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
|
+
|
|
55
|
+
`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.
|
|
56
|
+
|
|
57
|
+
Narrow a handler or a switch with `event.token` or `instanceof`. Indexing a handler map by `event.token` does not narrow the payload. A `never` default on the whole event reports an unhandled catalog entry.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# EventHandler
|
|
2
|
+
|
|
3
|
+
An event handler is the work that follows one event. Name the handler and pass the event class. `handle` receives an instance of that event. Ports, such as a mail sender, arrive through the constructor.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
class EmailSubscribers extends EventHandler('email.subscribers', PostPublished) {}
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The application subscribes the handler. An in-process dispatcher or a queue worker calls `handle` once it has the event. The handler does not subscribe itself.
|
|
10
|
+
|
|
11
|
+
## Why it exists
|
|
12
|
+
|
|
13
|
+
The use case that publishes a post is finished once the post is saved and the event is published. Emailing every subscriber can be slow, and it can run in another process. The handler is that second story. It stays tied to one event class, so `event.payload` is the published post and the compiler notices a handler pointed at a different event.
|
|
14
|
+
|
|
15
|
+
## How to use it
|
|
16
|
+
|
|
17
|
+
`EmailSubscribers` loads the subscriber list and sends one mail per address. The published post is `event.payload`.
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
class EmailSubscribers extends EventHandler('email.subscribers', PostPublished) {
|
|
21
|
+
constructor(
|
|
22
|
+
private readonly subscribers: SubscriberDirectory,
|
|
23
|
+
private readonly mail: MailSender,
|
|
24
|
+
) {
|
|
25
|
+
super();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async handle(event: PostPublished): Promise<void> {
|
|
29
|
+
const addresses = await this.subscribers.list();
|
|
30
|
+
for (const address of addresses) {
|
|
31
|
+
await this.mail.send(address, event.payload.title);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Annotate `event` with `PostPublished`. An unannotated `handle(event)` is implicit `any`. Keep `handle` a method.
|
|
38
|
+
|
|
39
|
+
A process that shares the API's memory can pass the instance straight to `handle`. A worker in another process reads a catalog message, runs `BlogEvents.parse`, and then calls `handle` with the result. Subscription, ack, and retry belong to that adapter.
|
|
40
|
+
|
|
41
|
+
One handler is bound to one event class. A second event is a second handler.
|
|
42
|
+
|
|
43
|
+
A static field such as `groupId` is yours to read when you subscribe the handler. Hexok does not read it. Same group string is one competing consumer set only for a real broker. Different groups each receive the event. An in-memory adapter may ignore the group.
|
|
44
|
+
|
|
45
|
+
## API
|
|
46
|
+
|
|
47
|
+
`EventHandler(token, event)` returns the class you extend. `token` is a string literal, the handler's name. `event` is an event class.
|
|
48
|
+
|
|
49
|
+
| Member | Role |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| `EmailSubscribers.token` | The name you passed in. |
|
|
52
|
+
| `EmailSubscribers.event` | The event class this handler receives. |
|
|
53
|
+
| `handle(event)` | Required. Annotate `event` with that class. Return `void` or `Promise<void>`. |
|
|
54
|
+
| `constructor` | Receive ports here and call `super()`. |
|
|
55
|
+
|
|
56
|
+
The compiler reports a missing `handle`. The factory records the token and the event class. It takes no consumer-group argument.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Event
|
|
2
|
+
|
|
3
|
+
An event is a fact the application records, such as "this post was published." Name it and give it the schema of its payload. `new` stores a payload you already typed. `parse` checks an unknown value and then constructs the event.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
class PostPublished extends Event('post.published', PostSchema) {}
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Register the class on an event catalog when a publisher and a subscriber need to agree on the set. An event handler performs the work that follows from one event.
|
|
10
|
+
|
|
11
|
+
## Why it exists
|
|
12
|
+
|
|
13
|
+
Publishing a post saves the post. Emailing subscribers is a consequence, and it is a different story: it can fail, retry, and run in another process without changing whether the post is published. The event is the fact the use case records. The handler is where the email happens. The payload schema keeps that fact the same shape on both sides.
|
|
14
|
+
|
|
15
|
+
## How to use it
|
|
16
|
+
|
|
17
|
+
The use case constructs the event from the post it just saved and hands it to a publisher port. The handler later reads `event.payload`.
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
class PostPublished extends Event('post.published', PostSchema) {}
|
|
21
|
+
|
|
22
|
+
await events.publish(
|
|
23
|
+
new PostPublished({
|
|
24
|
+
id: post.props.id,
|
|
25
|
+
title: post.props.title,
|
|
26
|
+
body: post.props.body,
|
|
27
|
+
published: true,
|
|
28
|
+
}),
|
|
29
|
+
);
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`new PostPublished(...)` trusts that object. When the payload arrives as unknown JSON, the boundary uses `PostPublished.parse(value)`, or the catalog's `parse` for a `{ key, payload }` message.
|
|
33
|
+
|
|
34
|
+
The string `'post.published'` is the event's token. A catalog map key, such as `postPublished`, is a separate name.
|
|
35
|
+
|
|
36
|
+
## API
|
|
37
|
+
|
|
38
|
+
`Event(token, schema)` returns the class. `token` is a string literal. `schema` is a Standard Schema or a Schema class. A Schema class is stored as its definition.
|
|
39
|
+
|
|
40
|
+
| Member | Role |
|
|
41
|
+
| --- | --- |
|
|
42
|
+
| `PostPublished.token` | The name you passed in. |
|
|
43
|
+
| `PostPublished.schema` | The payload schema. |
|
|
44
|
+
| `new PostPublished(payload)` | Stores `payload`. The constructor expects the schema's output type. |
|
|
45
|
+
| `PostPublished.parse(value)` | Checks `value` and returns an instance of the class you called it on. |
|
|
46
|
+
| `event.token` | The same literal as the static token. |
|
|
47
|
+
| `event.payload` | The payload. |
|
|
48
|
+
|
|
49
|
+
A rejected `parse` throws `CodedError` with code `VALIDATION` and the message `hexok: post.published validation failed`.
|
|
50
|
+
|
|
51
|
+
Override `parse` when the boundary needs to preprocess the value. `parse` uses the class it was called on, so a catalog `parse` still returns the registered subclass and `instanceof` holds.
|
|
52
|
+
|
|
53
|
+
Callers tell two event classes apart with `instanceof` or by comparing `event.token`. Two classes that share a schema stay assignable to each other until the instance token separates them. Prefer the catalog's `EventInstance` union on a publisher port so the port accepts every event in the set.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Mapper
|
|
2
|
+
|
|
3
|
+
A mapper translates the value a port speaks into the document an adapter stores, and back. Use one when those shapes differ: `_id` instead of `id`, a GeoJSON point instead of a `[lat, lng]` pair, a driver row instead of entity props. Persisting `toProps()` as JSON needs no mapper.
|
|
4
|
+
|
|
5
|
+
The mapper is stateless. Domain code does not import the stored schema. The adapter owns the mapper and calls it. The adapter commits the entity after the driver write succeeds. The mapper does not commit.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
class MongoUser extends Mapper('mongo.User', User, mongoUserSchema) {
|
|
9
|
+
protected fromSource(user: User) {
|
|
10
|
+
const props = user.toProps();
|
|
11
|
+
const [lat, lng] = props.location;
|
|
12
|
+
return {
|
|
13
|
+
_id: props.id,
|
|
14
|
+
name: props.name,
|
|
15
|
+
location: { type: 'Point' as const, coordinates: [lng, lat] },
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
protected toSource(doc: { _id: string; name: string; location: { coordinates: [number, number] } }) {
|
|
20
|
+
const [lng, lat] = doc.location.coordinates;
|
|
21
|
+
return { id: doc._id, name: doc.name, location: [lat, lng] };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Extend `Mapper(...)`, not `MapperClass`. Export a named class. An anonymous class expression cannot emit the protected methods.
|
|
27
|
+
|
|
28
|
+
## Why it exists
|
|
29
|
+
|
|
30
|
+
The domain port stays in domain types. Each adapter has its own stored shape. One mapper keeps that translation next to the adapter, so `execute` still calls `posts.save(post)` and never sees `_id` or coordinate order.
|
|
31
|
+
|
|
32
|
+
## How to use it
|
|
33
|
+
|
|
34
|
+
`fromSource` feeds `toModel`. `toSource` feeds `fromModel`. The adapter calls the public pair.
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
class MongoUsers extends Adapter(UserRepository) {
|
|
38
|
+
constructor(private readonly users: MongoUser) {
|
|
39
|
+
super();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
override async save(user: User): Promise<void> {
|
|
43
|
+
const doc = this.users.toModel(user);
|
|
44
|
+
await this.collection.updateOne({ _id: doc._id }, { $set: doc }, { upsert: true });
|
|
45
|
+
user.commit();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
override async get(id: string): Promise<User | null> {
|
|
49
|
+
const doc = await this.collection.findOne({ _id: id });
|
|
50
|
+
if (doc === null) return null;
|
|
51
|
+
return this.users.fromModel(doc);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The source argument is an Entity class, a Schema class, or a raw Standard Schema. The same primitive covers a schema or a plain object, not only an entity.
|
|
57
|
+
|
|
58
|
+
Checks are off unless the factory or that call passes `{ validate: true }`. A call overrides the factory.
|
|
59
|
+
|
|
60
|
+
- Off: `toModel` returns the `fromSource` draft unchecked. `fromModel` uses `Entity.restore` or returns a schema draft as given, so `isValidated` stays false.
|
|
61
|
+
- On: both directions run the stored schema. An entity load uses `parse`. A stored-schema failure throws `CodedError` `VALIDATION` with the mapper token. An entity parse failure uses the entity token.
|
|
62
|
+
|
|
63
|
+
## API
|
|
64
|
+
|
|
65
|
+
`Mapper(token, source, storedSchema, options?)` returns the class you extend. `token` is a string literal. `options` is `{ validate?: boolean }`.
|
|
66
|
+
|
|
67
|
+
| Member | Role |
|
|
68
|
+
| --- | --- |
|
|
69
|
+
| `fromSource(source)` | Protected. Required. Entity source receives the instance. A schema source receives its output. Return the stored draft. |
|
|
70
|
+
| `toSource(model)` | Protected. Required. Receives the stored output. Return entity props, or the source schema's output. |
|
|
71
|
+
| `toModel(source, options?)` | Public. Adapters call this. Do not override. |
|
|
72
|
+
| `fromModel(value, options?)` | Public. Adapters call this. Do not override. |
|
|
73
|
+
| `token` | The name passed to `Mapper`. |
|
|
74
|
+
| `schema` | The stored Standard Schema. A Schema class is stored as its definition. |
|
|
75
|
+
| `source` | The entity, Schema class, or Standard Schema this mapper reads and builds. |
|
|
76
|
+
|
|
77
|
+
`MapperClass` is exported so an exported subclass's `.d.ts` can name the base and keep `fromSource` and `toSource` protected. A protected constructor on the subclass makes `new Subclass()` a type error. Leave the constructor public.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Port
|
|
2
|
+
|
|
3
|
+
A port is the contract a use case or an event handler calls. Name it with `Port` and declare the methods the caller needs. An adapter is the class that implements those methods.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
abstract class PostRepository extends Port('PostRepository') {
|
|
7
|
+
abstract get(id: string): Promise<Post>;
|
|
8
|
+
abstract save(post: Post): Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Why it exists
|
|
13
|
+
|
|
14
|
+
Publishing a post has to save the post and announce the fact. Those are two different jobs, and each can be done more than one way: memory in a test, a database in the API, a broker when a worker lives in another process. The use case names the jobs. The port is the name. The adapter is the current way the job is done, and it can be replaced without editing `execute`.
|
|
15
|
+
|
|
16
|
+
## How to use it
|
|
17
|
+
|
|
18
|
+
`PublishPost` asks a `PostRepository` for the post and asks a `BlogEventPublisher` to take the published-post event. It never imports a database client or a queue.
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
abstract class BlogEventPublisher extends Port('BlogEventPublisher') {
|
|
22
|
+
abstract publish(event: EventInstance<typeof BlogEvents>): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
class PublishPost extends UseCase('post.publish') {
|
|
26
|
+
constructor(
|
|
27
|
+
private readonly posts: PostRepository,
|
|
28
|
+
private readonly events: BlogEventPublisher,
|
|
29
|
+
) {
|
|
30
|
+
super();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async execute(input: { id: string }): Promise<{ id: string }> {
|
|
34
|
+
const post = await this.posts.get(input.id);
|
|
35
|
+
post.publish(new Date());
|
|
36
|
+
await this.posts.save(post);
|
|
37
|
+
await this.events.publish(new PostPublished(post.toProps()));
|
|
38
|
+
return { id: post.props.id };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The methods are whatever this seam needs. A repository offers `get` and `save`. A publisher offers `publish`. A mail port offers `send`. A subscription port offers `subscribe`. Keep publishing and subscribing as two ports. `start` and `stop` stay on the adapter. The use case depends on the port class, and the composition root passes an adapter.
|
|
44
|
+
|
|
45
|
+
Search for an existing port before adding one. A second seam is a second `Port(...)` call, even when the method names match.
|
|
46
|
+
|
|
47
|
+
## API
|
|
48
|
+
|
|
49
|
+
`Port(token)` returns an abstract class. `token` is a string literal.
|
|
50
|
+
|
|
51
|
+
| Member | Role |
|
|
52
|
+
| --- | --- |
|
|
53
|
+
| `PostRepository.token` | The name you passed in. |
|
|
54
|
+
| Methods you declare | The contract. Mark them `abstract`. A subclass that leaves one unimplemented is a compile error. |
|
|
55
|
+
|
|
56
|
+
Each `Port(...)` call is its own class, with its own nominal identity. A class with the same method names is a different type. Extend one port. One class cannot extend two ports.
|
|
57
|
+
|
|
58
|
+
The adapter reuses this token. `InMemoryPosts.token` is `'PostRepository'` when `InMemoryPosts` extends `Adapter(PostRepository)`. An adapter cannot redeclare a different literal.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Schema
|
|
2
|
+
|
|
3
|
+
A schema names a shape and checks values against it. Pass the name and a [Standard Schema](https://standardschema.dev) to `Schema`. `parse` returns the schema output, or throws when the value does not fit.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
class PostSchema extends Schema('Post', postSchema) {}
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Zod, Valibot, ArkType, and any other Standard Schema library are passed straight through. `@hexok/typebox` adapts a TypeBox 1 schematic first: `typebox(schema)` infers `Static`, and `typeboxDecode(schema)` infers `StaticDecode` and decodes. Decode is a second function. Entity and event construction use the non-decoding wrapper.
|
|
10
|
+
|
|
11
|
+
## Why it exists
|
|
12
|
+
|
|
13
|
+
A post is an id, a title, a body, and a published flag. That shape shows up on the entity, on the event that says the post was published, and at the edge where a request body arrives. One named schema is the definition those sites share. The name is what appears in the failure message, so a rejected post is distinguishable from a rejected subscriber.
|
|
14
|
+
|
|
15
|
+
## How to use it
|
|
16
|
+
|
|
17
|
+
Define the post once. The entity and the published-post event both take `PostSchema`. At a trust boundary, `parse` turns unknown JSON into the typed output.
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
const post = PostSchema.parse({
|
|
21
|
+
id: 'p1',
|
|
22
|
+
title: 'Hello',
|
|
23
|
+
body: 'First post',
|
|
24
|
+
published: false,
|
|
25
|
+
});
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`post.title` is a string because the schema says so. A missing title throws a coded error whose code is `VALIDATION` and whose message is `hexok: Post validation failed`.
|
|
29
|
+
|
|
30
|
+
An entity or an event can take the Standard Schema directly, without this class. Use `Schema` when you want the name, `parse`, and a place to customize the check.
|
|
31
|
+
|
|
32
|
+
## API
|
|
33
|
+
|
|
34
|
+
`Schema(token, definition)` returns the class you extend. `token` is a string literal. `definition` is a Standard Schema.
|
|
35
|
+
|
|
36
|
+
| Member | Role |
|
|
37
|
+
| --- | --- |
|
|
38
|
+
| `PostSchema.token` | The name you passed in. |
|
|
39
|
+
| `PostSchema.definition` | The Standard Schema this class wraps. |
|
|
40
|
+
| `PostSchema.parse(value)` | Check `value` and return the schema output. |
|
|
41
|
+
| `PostSchema.check(value)` | The same check. Overrides of `parse` call this when they want the default result. |
|
|
42
|
+
|
|
43
|
+
A rejected value throws `CodedError` with code `VALIDATION`. `data.issues` is the Standard Schema issue list when the schema reported one. Each issue has a `message` and an optional `path`.
|
|
44
|
+
|
|
45
|
+
Override `parse` to preprocess or postprocess. Override `check` when the check itself should change.
|
|
46
|
+
|
|
47
|
+
`InferSchema<S>` is the output type of a Schema class or a raw Standard Schema. `SchemaSource` is that input. Both are exported from `hexok`. An async schema throws `Error` with the message `hexok: async schemas belong at the application edge`.
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# UseCase
|
|
2
|
+
|
|
3
|
+
A use case is one thing a caller can do: publish a post, save a draft, subscribe an email address. Name it with `UseCase` and write the story in `execute`. The ports it needs arrive through the constructor.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
class PublishPost extends UseCase('post.publish') {}
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
When every call in a family carries the same extra context, such as a trace id or the caller IP, use `UseCase.context` below. Plain `UseCase(token)` has `execute(input)` and never wraps that method.
|
|
10
|
+
|
|
11
|
+
## Why it exists
|
|
12
|
+
|
|
13
|
+
Publishing a post is a story with a beginning and an end. Load the post, apply the publish rule, save it, and announce that it was published. The use case is that story in one place. Saving and announcing stay behind ports, so the story reads the same when the database or the event transport changes. A refusal throws an error-catalog member. The application catches it at the edge.
|
|
14
|
+
|
|
15
|
+
## How to use it
|
|
16
|
+
|
|
17
|
+
`PublishPost` receives the repository and the event publisher. `execute` is the story. Callers see the input and return types you annotate.
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
class PublishPost extends UseCase('post.publish') {
|
|
21
|
+
constructor(
|
|
22
|
+
private readonly posts: PostRepository,
|
|
23
|
+
private readonly events: BlogEventPublisher,
|
|
24
|
+
) {
|
|
25
|
+
super();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async execute(input: { id: string }): Promise<{ id: string }> {
|
|
29
|
+
const post = await this.posts.get(input.id);
|
|
30
|
+
post.publish(new Date());
|
|
31
|
+
await this.posts.save(post);
|
|
32
|
+
await this.events.publish(new PostPublished(post.toProps()));
|
|
33
|
+
return { id: post.props.id };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The application constructs `PublishPost` with concrete adapters and calls `execute` from a gateway. One action is one class and one token. A caller who can save a draft and later publish it gets `SavePost` (`post.save`) and `PublishPost` (`post.publish`).
|
|
39
|
+
|
|
40
|
+
Declare `execute` as a method. A class field, or an assignment after `super()`, is the wrong shape. The class is incomplete until `execute` exists, and the compiler reports the missing method.
|
|
41
|
+
|
|
42
|
+
`execute` receives the input you declare and returns what you declare. Checking that input is the application's job: a guard on a context family, or the first lines of `execute`. Hexok does not validate `execute` and does not read a static key named `input`.
|
|
43
|
+
|
|
44
|
+
## API
|
|
45
|
+
|
|
46
|
+
`UseCase(token)` returns the class you extend. `token` is a string literal, such as `'post.publish'`.
|
|
47
|
+
|
|
48
|
+
| Member | Role |
|
|
49
|
+
| --- | --- |
|
|
50
|
+
| `PublishPost.token` | The name you passed in. |
|
|
51
|
+
| `execute(input)` | Required. Annotate the input and the return type on the subclass. |
|
|
52
|
+
| `constructor` | Receive ports here and call `super()`. |
|
|
53
|
+
|
|
54
|
+
A subclass of plain `UseCase(token)` takes one argument. Adding a context argument there is a type error. Reach for the family factory instead.
|
|
55
|
+
|
|
56
|
+
## UseCase.context
|
|
57
|
+
|
|
58
|
+
`UseCase.context` builds a family of use cases that share one kind of call. Every member receives a context argument. The family can refuse a call in a guard or observe it with hooks. Each member still has its own token, its own ports, and its own `execute`.
|
|
59
|
+
|
|
60
|
+
Use it when the call carries something the story should see, such as a trace id or the actor who published the post. Putting that bag on `execute` as a first argument keeps it out of the post payload. The use case instance stays long-lived. The gateway builds a new context per request.
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
type ApiContext = { traceId: string };
|
|
64
|
+
|
|
65
|
+
const ApiUseCase = UseCase.context<ApiContext>().guard((call) => {
|
|
66
|
+
if (call.ctx.traceId.length < 1) throw BlogError.TraceIdMissing();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
class PublishPost extends ApiUseCase('post.publish') {
|
|
70
|
+
constructor(
|
|
71
|
+
private readonly posts: PostRepository,
|
|
72
|
+
private readonly events: BlogEventPublisher,
|
|
73
|
+
) {
|
|
74
|
+
super();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async execute(
|
|
78
|
+
ctx: UseCase.Ctx<typeof ApiUseCase>,
|
|
79
|
+
input: { id: string },
|
|
80
|
+
): Promise<{ id: string }> {
|
|
81
|
+
const post = await this.posts.get(input.id);
|
|
82
|
+
post.publish(new Date());
|
|
83
|
+
await this.posts.save(post);
|
|
84
|
+
await this.events.publish(new PostPublished(post.toProps()));
|
|
85
|
+
return { id: post.props.id };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
await new PublishPost(posts, events).execute({ traceId: 'req-1' }, { id: 'p1' });
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
A member that needs a static contract, such as a permission string or an input schema the application will read, declares that contract as the second type argument. The schema is data the guard can read. Validating the input stays in the guard or in `execute`.
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
const ActorUseCase = UseCase.context<
|
|
96
|
+
{ traceId: string; actorId: string },
|
|
97
|
+
{ permission: string }
|
|
98
|
+
>().guard((call) => {
|
|
99
|
+
if (call.spec.permission.length < 1) throw BlogError.Forbidden();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
class PublishPost extends ActorUseCase('post.publish', {
|
|
103
|
+
permission: 'posts.publish',
|
|
104
|
+
}) {
|
|
105
|
+
async execute(
|
|
106
|
+
ctx: UseCase.Ctx<typeof ActorUseCase>,
|
|
107
|
+
input: { id: string },
|
|
108
|
+
): Promise<{ id: string }> {
|
|
109
|
+
return { id: input.id };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Factory
|
|
115
|
+
|
|
116
|
+
`UseCase.context<Ctx, Spec>(config?)` returns a factory. `Ctx` is required. Omitting it makes the return type the string `'Pass a type argument: UseCase.context<Ctx>()'`. `Spec` defaults to an empty contract, so a member is `Factory(token)` only.
|
|
117
|
+
|
|
118
|
+
`config` accepts no keys yet. Call `UseCase.context<Ctx>()` or `UseCase.context<Ctx>({})`. `guard` and `hooks` are methods on the factory, not fields of that object.
|
|
119
|
+
|
|
120
|
+
`.guard(fn)` and `.hooks(def)` each return the factory again, so the calls can sit in either order and either one can be left off. A further `.guard()` runs after guards already registered. A second `.hooks()` that includes at least one callback throws `hexok: UseCase hooks are already set` when the factory is created. An empty `.hooks({})` adds no callbacks and does not wrap `execute`.
|
|
121
|
+
|
|
122
|
+
When `Spec` has keys, the member is `Factory(token, statics)` and the bag has to satisfy `Spec`. Those entries are copied onto the class as statics. Literals stay narrow. The names `token`, `prototype`, `name`, and `length` stay on the class. Passing one of them is a type error.
|
|
123
|
+
|
|
124
|
+
`execute` is still required. Annotate `ctx` and `input` on the subclass. A member with no input omits the second parameter. Declare `execute` as a method. Assigning it as a class field, or replacing it after `super()`, skips the guard and the hooks.
|
|
125
|
+
|
|
126
|
+
When the family has no guard and no hooks, `execute` stays unwrapped so a missing method is still a class-site error.
|
|
127
|
+
|
|
128
|
+
`UseCase.Ctx<typeof ApiUseCase>` is the context type. Pass the factory from `UseCase.context`. A subclass, and a plain `UseCase(token)` class, resolve to `never`. Annotate member `execute` parameters with it. A guard passed into the factory names `ctx` and `spec` from the application's own types. A function passed into the factory must not mention `UseCase.Ctx` or `UseCase.GuardParameters` of that still-inferred factory.
|
|
129
|
+
|
|
130
|
+
### The call
|
|
131
|
+
|
|
132
|
+
A guard and `preExecute` receive `{ ctx, spec, token, input }`.
|
|
133
|
+
|
|
134
|
+
| Field | Meaning |
|
|
135
|
+
| --- | --- |
|
|
136
|
+
| `ctx` | The context the caller passed. |
|
|
137
|
+
| `spec` | The static contract, typed as `Spec`. A guard sees `permission: string` even when one member passed the literal `'posts.publish'`. |
|
|
138
|
+
| `token` | This member's token. |
|
|
139
|
+
| `input` | The second argument the caller passed to `execute`. Typed `unknown`. The method receives that same value. |
|
|
140
|
+
|
|
141
|
+
`UseCase.GuardParameters<typeof ApiUseCase>` is that call. `UseCase.Ctx<typeof ApiUseCase>` is `ctx`.
|
|
142
|
+
|
|
143
|
+
Family hook `input` stays `unknown` on purpose. One factory hook is checked against every member. `SavePost` and `SubscribeToPosts` do not share an input type. Member annotations are what callers of that class see. Read `ctx` and `spec` in a family hook. Input-field logic belongs on `execute` or in a guard that already knows the member.
|
|
144
|
+
|
|
145
|
+
### Guards
|
|
146
|
+
|
|
147
|
+
A guard is `(call) => void | Promise<void>`. Guards run before `execute`, and outside the hook `try`. A throw from a guard skips `preExecute`, `postExecute`, `onCatch`, and `onFinally`. A zero-argument guard typechecks. A one-argument guard receives the call, not the bare context.
|
|
148
|
+
|
|
149
|
+
### Hooks
|
|
150
|
+
|
|
151
|
+
`.hooks(definition)` registers one set of callbacks for the family. `State` is inferred from `preExecute`'s return. It is `void` when `preExecute` is omitted. Later hooks receive `Awaited<State>`. `onCatch` and `onFinally` receive `Awaited<State> | undefined` because `preExecute` may have thrown or been omitted.
|
|
152
|
+
|
|
153
|
+
| Hook | When it runs | Extra fields |
|
|
154
|
+
| --- | --- | --- |
|
|
155
|
+
| `preExecute(call)` | After the guards, before the method. | Its return value is `state` for the later hooks. |
|
|
156
|
+
| `postExecute(call, state)` | After the method returns. | `call.result` is the method's return value. |
|
|
157
|
+
| `onCatch(call, state)` | When `preExecute`, the method, or `postExecute` throws. | `call.error` is that error. The return value is ignored. The original error is thrown again. |
|
|
158
|
+
| `onFinally(call, state)` | After a successful return or a caught failure. | `call.status` is `'success'` with `result`, or `'failure'` with `error`. |
|
|
159
|
+
|
|
160
|
+
The pipeline is: guards, then `preExecute`, then the method with the caller's argument, then `postExecute`, then `onCatch` which rethrows, then `onFinally`. `postExecute` runs only after `preExecute` returns. `onFinally` always runs for attempts that entered the hooks. State is local to that invocation. Overlapping `execute` calls do not share it.
|
|
161
|
+
|
|
162
|
+
The method receives the caller's input, and the caller receives the method's return value. Hooks do not replace either one. Record success in `postExecute` and the failure in `onCatch`. Finish a span in `onFinally`, which runs for both outcomes. A throw inside `onCatch` replaces the error after `onFinally` has seen the original one.
|
|
163
|
+
|
|
164
|
+
There is no `around` hook. Leave `AsyncLocalStorage` to the application.
|