hexok 2.1.0 → 2.1.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/dist/use-case.d.ts +3 -3
- package/dist/use-case.js +3 -3
- package/package.json +1 -1
- package/skills/hexok/SKILL.md +4 -3
- package/skills/hexok/references/adapter.md +2 -2
- package/skills/hexok/references/entity.md +22 -0
- package/skills/hexok/references/event-handler.md +7 -5
- package/skills/hexok/references/mapper.md +3 -3
- package/skills/hexok/references/port.md +8 -6
- package/skills/hexok/references/use-case.md +17 -13
package/dist/use-case.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* ```ts
|
|
8
8
|
* class CreateUser extends UseCase('user.create') {
|
|
9
|
-
* constructor(private readonly users: UserRepository) { super() }
|
|
9
|
+
* constructor(private readonly deps: { users: UserRepository }) { super() }
|
|
10
10
|
* async execute(input: { email: string }): Promise<{ id: string }> {
|
|
11
11
|
* return { id: input.email }
|
|
12
12
|
* }
|
|
@@ -65,12 +65,12 @@ type Statics<S> = Omit<S, ReservedKey>;
|
|
|
65
65
|
* input: z.object({ query: z.string() }),
|
|
66
66
|
* permission: 'users.read',
|
|
67
67
|
* }) {
|
|
68
|
-
* constructor(private readonly users: UserRepository) { super() }
|
|
68
|
+
* constructor(private readonly deps: { users: UserRepository }) { super() }
|
|
69
69
|
* async execute(
|
|
70
70
|
* ctx: UseCase.Ctx<typeof ApiUseCase>,
|
|
71
71
|
* input: { query: string },
|
|
72
72
|
* ): Promise<User[]> {
|
|
73
|
-
* return this.users.search(input.query)
|
|
73
|
+
* return this.deps.users.search(input.query)
|
|
74
74
|
* }
|
|
75
75
|
* }
|
|
76
76
|
* ```
|
package/dist/use-case.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* ```ts
|
|
8
8
|
* class CreateUser extends UseCase('user.create') {
|
|
9
|
-
* constructor(private readonly users: UserRepository) { super() }
|
|
9
|
+
* constructor(private readonly deps: { users: UserRepository }) { super() }
|
|
10
10
|
* async execute(input: { email: string }): Promise<{ id: string }> {
|
|
11
11
|
* return { id: input.email }
|
|
12
12
|
* }
|
|
@@ -63,12 +63,12 @@ const RESERVED_STATICS = new Set([
|
|
|
63
63
|
* input: z.object({ query: z.string() }),
|
|
64
64
|
* permission: 'users.read',
|
|
65
65
|
* }) {
|
|
66
|
-
* constructor(private readonly users: UserRepository) { super() }
|
|
66
|
+
* constructor(private readonly deps: { users: UserRepository }) { super() }
|
|
67
67
|
* async execute(
|
|
68
68
|
* ctx: UseCase.Ctx<typeof ApiUseCase>,
|
|
69
69
|
* input: { query: string },
|
|
70
70
|
* ): Promise<User[]> {
|
|
71
|
-
* return this.users.search(input.query)
|
|
71
|
+
* return this.deps.users.search(input.query)
|
|
72
72
|
* }
|
|
73
73
|
* }
|
|
74
74
|
* ```
|
package/package.json
CHANGED
package/skills/hexok/SKILL.md
CHANGED
|
@@ -60,7 +60,7 @@ A blog post is an id, a title, a body, and whether it is published. The schema i
|
|
|
60
60
|
|
|
61
61
|
Define each use case as one action, and the ports that action calls, when they are not already there.
|
|
62
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).
|
|
63
|
+
Saving a post is a port. Publishing an event is a different port. The use case receives both on a `deps` object in its constructor and calls them from `execute` through `this.deps`. Add a second argument, `config`, only when the composition root sets flags on that long-lived instance. It does not import a driver. Read [references/use-case.md](references/use-case.md) and [references/port.md](references/port.md).
|
|
64
64
|
|
|
65
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
66
|
|
|
@@ -74,7 +74,7 @@ When the story has no consequence beyond its own write, this step is `none`. Do
|
|
|
74
74
|
|
|
75
75
|
### 3.c. Event handlers
|
|
76
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).
|
|
77
|
+
When step 3.b named a side effect, implement the handler if it does not exist. The handler takes its own ports on a `deps` object 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
78
|
|
|
79
79
|
The handler does not subscribe itself. Subscription belongs to the adapter in step 4.
|
|
80
80
|
|
|
@@ -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. A use case instance is long-lived. Context is an argument to `execute`, not a field set per request.
|
|
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.
|
|
100
100
|
|
|
101
101
|
### 6. Where handlers run
|
|
102
102
|
|
|
@@ -110,6 +110,7 @@ Do not add a broker only to create a second process. Do not move the handler's w
|
|
|
110
110
|
|
|
111
111
|
## Conventions
|
|
112
112
|
|
|
113
|
+
- A use case, an event handler, and an adapter that takes a driver receive collaborators as one `readonly` object named `deps`, including a class with a single collaborator. Keys are the collaborator names. Write the core port first in the type. Call sites may pass keys in any order. The body uses `this.deps`. Optional instance settings are a second argument, `config?`, also `readonly`. Omit `config` when there are none. New ports go on `deps`. New flags go on `config`. Do not add a third positional argument. A per-request choice stays on `execute`'s input. A value declared for a whole `UseCase.context` family stays on that factory's static spec. Entity payloads, event payloads, error-catalog factories, and port methods stay data arguments.
|
|
113
114
|
- 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
115
|
- `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
116
|
- 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.
|
|
@@ -21,7 +21,7 @@ The use case asks a `PostRepository` to save. Something has to actually keep the
|
|
|
21
21
|
|
|
22
22
|
## How to use it
|
|
23
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`.
|
|
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` on `deps`. An adapter that takes a driver or another collaborator uses that same `deps` object.
|
|
25
25
|
|
|
26
26
|
```ts
|
|
27
27
|
class InMemoryPosts extends Adapter(PostRepository) {
|
|
@@ -41,7 +41,7 @@ class InMemoryPosts extends Adapter(PostRepository) {
|
|
|
41
41
|
|
|
42
42
|
const posts = new InMemoryPosts();
|
|
43
43
|
await posts.start();
|
|
44
|
-
const publishPost = new PublishPost(posts, events);
|
|
44
|
+
const publishPost = new PublishPost({ posts, events });
|
|
45
45
|
```
|
|
46
46
|
|
|
47
47
|
Store `toProps()` (or a mapped document) and rebuild with `restore` on the way out. Call `commit()` after the write succeeds.
|
|
@@ -46,6 +46,28 @@ Read `props` for the current data. Write only through `set` or a method that cal
|
|
|
46
46
|
|
|
47
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
48
|
|
|
49
|
+
## Closing `set`
|
|
50
|
+
|
|
51
|
+
`set` stays public. The instance is an `EntityInstance`, so a private or protected `set` is a type error.
|
|
52
|
+
|
|
53
|
+
When every change belongs on a method, override `set` with a `never` producer and throw. Methods call `super.set`.
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
class User extends Entity('User', UserSchema) {
|
|
57
|
+
override set(_producer: never): never {
|
|
58
|
+
throw new Error('User.set is closed');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
changeEmail(email: string): this {
|
|
62
|
+
return super.set((draft) => {
|
|
63
|
+
draft.email = email;
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`user.set(...)` fails to typecheck. A value typed as `EntityInstance` can still name `set`. That call throws.
|
|
70
|
+
|
|
49
71
|
## API
|
|
50
72
|
|
|
51
73
|
`Entity(token, schema)` returns the class you extend. `token` is a string literal.
|
|
@@ -19,16 +19,18 @@ The use case that publishes a post is finished once the post is saved and the ev
|
|
|
19
19
|
```ts
|
|
20
20
|
class EmailSubscribers extends EventHandler('email.subscribers', PostPublished) {
|
|
21
21
|
constructor(
|
|
22
|
-
private readonly
|
|
23
|
-
|
|
22
|
+
private readonly deps: {
|
|
23
|
+
subscribers: SubscriberDirectory;
|
|
24
|
+
mail: MailSender;
|
|
25
|
+
},
|
|
24
26
|
) {
|
|
25
27
|
super();
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
async handle(event: PostPublished): Promise<void> {
|
|
29
|
-
const addresses = await this.subscribers.list();
|
|
31
|
+
const addresses = await this.deps.subscribers.list();
|
|
30
32
|
for (const address of addresses) {
|
|
31
|
-
await this.mail.send(address, event.payload.title);
|
|
33
|
+
await this.deps.mail.send(address, event.payload.title);
|
|
32
34
|
}
|
|
33
35
|
}
|
|
34
36
|
}
|
|
@@ -51,6 +53,6 @@ A static field such as `groupId` is yours to read when you subscribe the handler
|
|
|
51
53
|
| `EmailSubscribers.token` | The name you passed in. |
|
|
52
54
|
| `EmailSubscribers.event` | The event class this handler receives. |
|
|
53
55
|
| `handle(event)` | Required. Annotate `event` with that class. Return `void` or `Promise<void>`. |
|
|
54
|
-
| `constructor` |
|
|
56
|
+
| `constructor` | Ports arrive on `deps`. Call `super()`. The composition root passes `new EmailSubscribers({ subscribers, mail })`. |
|
|
55
57
|
|
|
56
58
|
The compiler reports a missing `handle`. The factory records the token and the event class. It takes no consumer-group argument.
|
|
@@ -35,12 +35,12 @@ The domain port stays in domain types. Each adapter has its own stored shape. On
|
|
|
35
35
|
|
|
36
36
|
```ts
|
|
37
37
|
class MongoUsers extends Adapter(UserRepository) {
|
|
38
|
-
constructor(private readonly users: MongoUser) {
|
|
38
|
+
constructor(private readonly deps: { users: MongoUser }) {
|
|
39
39
|
super();
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
override async save(user: User): Promise<void> {
|
|
43
|
-
const doc = this.users.toModel(user);
|
|
43
|
+
const doc = this.deps.users.toModel(user);
|
|
44
44
|
await this.collection.updateOne({ _id: doc._id }, { $set: doc }, { upsert: true });
|
|
45
45
|
user.commit();
|
|
46
46
|
}
|
|
@@ -48,7 +48,7 @@ class MongoUsers extends Adapter(UserRepository) {
|
|
|
48
48
|
override async get(id: string): Promise<User | null> {
|
|
49
49
|
const doc = await this.collection.findOne({ _id: id });
|
|
50
50
|
if (doc === null) return null;
|
|
51
|
-
return this.users.fromModel(doc);
|
|
51
|
+
return this.deps.users.fromModel(doc);
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
```
|
|
@@ -24,23 +24,25 @@ abstract class BlogEventPublisher extends Port('BlogEventPublisher') {
|
|
|
24
24
|
|
|
25
25
|
class PublishPost extends UseCase('post.publish') {
|
|
26
26
|
constructor(
|
|
27
|
-
private readonly
|
|
28
|
-
|
|
27
|
+
private readonly deps: {
|
|
28
|
+
posts: PostRepository;
|
|
29
|
+
events: BlogEventPublisher;
|
|
30
|
+
},
|
|
29
31
|
) {
|
|
30
32
|
super();
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
async execute(input: { id: string }): Promise<{ id: string }> {
|
|
34
|
-
const post = await this.posts.get(input.id);
|
|
36
|
+
const post = await this.deps.posts.get(input.id);
|
|
35
37
|
post.publish(new Date());
|
|
36
|
-
await this.posts.save(post);
|
|
37
|
-
await this.events.publish(new PostPublished(post.toProps()));
|
|
38
|
+
await this.deps.posts.save(post);
|
|
39
|
+
await this.deps.events.publish(new PostPublished(post.toProps()));
|
|
38
40
|
return { id: post.props.id };
|
|
39
41
|
}
|
|
40
42
|
}
|
|
41
43
|
```
|
|
42
44
|
|
|
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
|
|
45
|
+
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. The composition root passes adapters on `deps`: `new PublishPost({ posts, events })`.
|
|
44
46
|
|
|
45
47
|
Search for an existing port before adding one. A second seam is a second `Port(...)` call, even when the method names match.
|
|
46
48
|
|
|
@@ -19,23 +19,25 @@ Publishing a post is a story with a beginning and an end. Load the post, apply t
|
|
|
19
19
|
```ts
|
|
20
20
|
class PublishPost extends UseCase('post.publish') {
|
|
21
21
|
constructor(
|
|
22
|
-
private readonly
|
|
23
|
-
|
|
22
|
+
private readonly deps: {
|
|
23
|
+
posts: PostRepository;
|
|
24
|
+
events: BlogEventPublisher;
|
|
25
|
+
},
|
|
24
26
|
) {
|
|
25
27
|
super();
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
async execute(input: { id: string }): Promise<{ id: string }> {
|
|
29
|
-
const post = await this.posts.get(input.id);
|
|
31
|
+
const post = await this.deps.posts.get(input.id);
|
|
30
32
|
post.publish(new Date());
|
|
31
|
-
await this.posts.save(post);
|
|
32
|
-
await this.events.publish(new PostPublished(post.toProps()));
|
|
33
|
+
await this.deps.posts.save(post);
|
|
34
|
+
await this.deps.events.publish(new PostPublished(post.toProps()));
|
|
33
35
|
return { id: post.props.id };
|
|
34
36
|
}
|
|
35
37
|
}
|
|
36
38
|
```
|
|
37
39
|
|
|
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`).
|
|
40
|
+
The application constructs `PublishPost` with concrete adapters (`new PublishPost({ posts, events })`) and calls `execute` from a gateway. One action is one class and one token. Pass every port on `deps`, including a use case with one port. Put the core port first in the type. Optional flags chosen for that long-lived instance are a second argument, `config`. A value the caller sends on one request stays on `execute`'s input. A value declared for the whole context family stays on the factory's static spec. A caller who can save a draft and later publish it gets `SavePost` (`post.save`) and `PublishPost` (`post.publish`).
|
|
39
41
|
|
|
40
42
|
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
43
|
|
|
@@ -49,7 +51,7 @@ Declare `execute` as a method. A class field, or an assignment after `super()`,
|
|
|
49
51
|
| --- | --- |
|
|
50
52
|
| `PublishPost.token` | The name you passed in. |
|
|
51
53
|
| `execute(input)` | Required. Annotate the input and the return type on the subclass. |
|
|
52
|
-
| `constructor` |
|
|
54
|
+
| `constructor` | Ports arrive on `deps`. Optional instance settings arrive on `config`. Call `super()`. |
|
|
53
55
|
|
|
54
56
|
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
57
|
|
|
@@ -68,8 +70,10 @@ const ApiUseCase = UseCase.context<ApiContext>().guard((call) => {
|
|
|
68
70
|
|
|
69
71
|
class PublishPost extends ApiUseCase('post.publish') {
|
|
70
72
|
constructor(
|
|
71
|
-
private readonly
|
|
72
|
-
|
|
73
|
+
private readonly deps: {
|
|
74
|
+
posts: PostRepository;
|
|
75
|
+
events: BlogEventPublisher;
|
|
76
|
+
},
|
|
73
77
|
) {
|
|
74
78
|
super();
|
|
75
79
|
}
|
|
@@ -78,15 +82,15 @@ class PublishPost extends ApiUseCase('post.publish') {
|
|
|
78
82
|
ctx: UseCase.Ctx<typeof ApiUseCase>,
|
|
79
83
|
input: { id: string },
|
|
80
84
|
): Promise<{ id: string }> {
|
|
81
|
-
const post = await this.posts.get(input.id);
|
|
85
|
+
const post = await this.deps.posts.get(input.id);
|
|
82
86
|
post.publish(new Date());
|
|
83
|
-
await this.posts.save(post);
|
|
84
|
-
await this.events.publish(new PostPublished(post.toProps()));
|
|
87
|
+
await this.deps.posts.save(post);
|
|
88
|
+
await this.deps.events.publish(new PostPublished(post.toProps()));
|
|
85
89
|
return { id: post.props.id };
|
|
86
90
|
}
|
|
87
91
|
}
|
|
88
92
|
|
|
89
|
-
await new PublishPost(posts, events).execute({ traceId: 'req-1' }, { id: 'p1' });
|
|
93
|
+
await new PublishPost({ posts, events }).execute({ traceId: 'req-1' }, { id: 'p1' });
|
|
90
94
|
```
|
|
91
95
|
|
|
92
96
|
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`.
|