@sincpro/client-framework 1.1.0 → 1.2.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/AGENTS.md +27 -10
- package/MANIFESTO.md +8 -6
- package/README.md +163 -58
- package/dist/client/services/guarded.d.ts +1 -1
- package/dist/codegen/services/scaffold.d.ts +1 -1
- package/dist/codegen/services/scaffold.js +12 -17
- package/dist/context/entrypoint/context.d.ts +5 -4
- package/dist/core/domain/dto.d.ts +3 -3
- package/dist/core/domain/dto.js +3 -3
- package/dist/core/domain/errors.d.ts +2 -2
- package/dist/core/domain/pipeline.d.ts +3 -3
- package/dist/core/domain/services.d.ts +5 -4
- package/dist/core/domain/services.js +1 -1
- package/dist/core/entrypoint/bounded-context.d.ts +24 -13
- package/dist/core/entrypoint/bounded-context.js +46 -15
- package/dist/core/infrastructure/listeners.d.ts +1 -1
- package/dist/core/infrastructure/listeners.js +1 -1
- package/dist/core/services/execution.d.ts +33 -17
- package/dist/core/services/execution.js +54 -38
- package/dist/event_driven/adapters/stored-queue.d.ts +5 -4
- package/dist/event_driven/adapters/stored-queue.js +5 -4
- package/dist/event_driven/entrypoint/publisher.d.ts +1 -1
- package/dist/event_driven/entrypoint/publisher.js +1 -1
- package/dist/event_driven/entrypoint/subscriber.d.ts +14 -6
- package/dist/event_driven/entrypoint/subscriber.js +16 -5
- package/dist/event_driven/entrypoint/subscriptions.d.ts +110 -0
- package/dist/event_driven/entrypoint/subscriptions.js +184 -0
- package/dist/event_driven/index.d.ts +4 -1
- package/dist/event_driven/index.js +4 -1
- package/dist/observability/adapters/memory.d.ts +1 -1
- package/dist/observability/domain/logging.d.ts +8 -4
- package/dist/observability/services/logging.d.ts +7 -4
- package/dist/observability/services/logging.js +2 -2
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -33,7 +33,7 @@ module has only the layers it needs, and says what it offers in its own `index.t
|
|
|
33
33
|
| `src/client/` | `Transport`, `entity()`, the contract (domain); REST, memory, Parquet; discovery, propagation |
|
|
34
34
|
| `src/model/` | Model definitions: `defineMeta`, from a JSON Schema, the store of known ones |
|
|
35
35
|
| `src/core/` | DTOs and events, bus, services, handlers, extension points; pipeline; `BoundedContext`; process |
|
|
36
|
-
| `src/event_driven/` | `Envelope`, `EventQueue`/`Subscribes` ports;
|
|
36
|
+
| `src/event_driven/` | `Envelope`, `EventQueue`/`Subscribes` ports; queues; `Publisher`, `Subscriber`, `Subscriptions` |
|
|
37
37
|
| `src/codegen/` | The generator and its CLI (`sincpro-client generate`) — the only place Node is allowed |
|
|
38
38
|
| `src/testing/` | The public entry of the in-memory observability adapters |
|
|
39
39
|
| `tests/` | vitest in Node, one folder per module; `tests/support/` holds fakes and forge's contracts |
|
|
@@ -78,18 +78,35 @@ module has only the layers it needs, and says what it offers in its own `index.t
|
|
|
78
78
|
unless `Service` is registered now for `dto`; its spans carry `sincpro.replaces`. A service
|
|
79
79
|
meant to be subclassed declares `static readonly serviceName: string = "…"`, or the literal
|
|
80
80
|
type stops a subclass from renaming itself.
|
|
81
|
-
- **How events are carried is here; where they are kept is the platform's.** One
|
|
82
|
-
per bounded context
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
only keeps records; it
|
|
81
|
+
- **How events are carried is here; where they are kept is the platform's.** One use case of an
|
|
82
|
+
event per bounded context, one `EventSubscriber` per event per `Subscriptions`; a `Subscriber`
|
|
83
|
+
executes an event in each. `storedQueue` holds the rules — store first, deliver in order,
|
|
84
|
+
retry what may succeed, set aside what never will, never run one who heard it twice — over an
|
|
85
|
+
`EventStore` the platform plugs (SQLite, IndexedDB, memory). A store only keeps records; it
|
|
86
|
+
never decides.
|
|
87
|
+
- **A subscriber is an entrypoint, not a use case.** An `EventSubscriber` reads the event and
|
|
88
|
+
executes Commands on a bounded context it was given, as a cron does on a tick; what to decide,
|
|
89
|
+
and a workflow of several use cases, is a Feature or an ApplicationService it executes. Its run
|
|
90
|
+
is layer `entrypoint`: no interceptor wraps it, no listener hears it, no error handler answers
|
|
91
|
+
it. An event may instead be the input of a use case (`feature(Event)(Service)`) when the use
|
|
92
|
+
case exists because of it. A `Subscriptions` is named apart from every bounded context: the
|
|
93
|
+
queue keeps who heard an event by name, and `Subscriber` refuses two of one name.
|
|
94
|
+
- **A bounded context injected into a run is bound to it** (`BIND`): what it executes is a child
|
|
95
|
+
of the caller — span, flow, cause, signal, one report — with its own dependencies, context and
|
|
96
|
+
handlers. A failure keeps the outcome where it first failed, so a caller does not report what
|
|
97
|
+
the callee expected.
|
|
98
|
+
- **A bounded context is named for what it is.** `salesBoundedContext`, never `sales` (an
|
|
99
|
+
entity?) and never `ctx` or `bc`: `context` is already the run's. The scaffold writes it so.
|
|
86
100
|
- **A circuit belongs where the dependency is.** `CircuitBreaker` (in the process's memory, the
|
|
87
101
|
backend's semantics: `COUNTED_KINDS`, half-open with one probe) guards a transport
|
|
88
102
|
(`guardedTransport`) or an adapter; its `CircuitOpen` declares `unavailable` and a wait, so
|
|
89
103
|
nothing else needs to know a circuit exists.
|
|
90
|
-
- **Delivery is at least once.** A stored event may run twice
|
|
91
|
-
|
|
92
|
-
|
|
104
|
+
- **Delivery is at least once.** A stored event may run twice after a crash. A use case of the
|
|
105
|
+
event keys its effect by `this.execution.causationId` (the envelope's id); an `EventSubscriber`
|
|
106
|
+
finds the same id in its own run and hands it in the Command it executes — the use case's
|
|
107
|
+
`causationId` is the subscriber's run —, or the effect is keyed by the entity the event names.
|
|
108
|
+
`maxAttempts` sets aside what keeps failing. One process drains a store. `execution` is
|
|
109
|
+
reserved.
|
|
93
110
|
- **A failure says its kind, whatever the wire.** `failureOf` reads a declared `failureKind`, the
|
|
94
111
|
`Problem` (the same over REST, JSON-RPC and gRPC), then a REST status. A transport of another
|
|
95
112
|
wire, or another backend's adapter, declares the kind with `kindOfGrpcCode` /
|
|
@@ -153,7 +170,7 @@ options)`: no route, no verb, no RPC method. The contract's bindings (`rest`, `r
|
|
|
153
170
|
- **An error handler answers; it does not observe.** The failure is recorded where it happened —
|
|
154
171
|
span, log line, report — before the layer's handlers run, as in sincpro_framework. A handler
|
|
155
172
|
returns the answer or throws to the next; the global chain wraps only `execute` and `scoped`.
|
|
156
|
-
- **The application layer imports no logger.** A bounded context has `
|
|
173
|
+
- **The application layer imports no logger.** A bounded context has `projectBoundedContext.logger`; a run
|
|
157
174
|
hands `this.logger`, bound to its execution. Only `logger` (infrastructure, the global one) and
|
|
158
175
|
`loggerAdapter` are predefined; injected as a dependency they bind to the run (`BIND`), imported
|
|
159
176
|
at module level they do not. No logger is predefined for an application layer. Console is the
|
package/MANIFESTO.md
CHANGED
|
@@ -48,7 +48,7 @@ server-side render, in a script and in a test, unchanged.**
|
|
|
48
48
|
in a global stack: two calls at once never mix their parents.
|
|
49
49
|
5. **Bounded contexts.** Set up like the backend's — its dependencies (`addDependency`), its
|
|
50
50
|
Features and ApplicationServices, registered for class DTOs — and called as
|
|
51
|
-
`
|
|
51
|
+
`projectBoundedContext.execute(new Command(...))`. The bounded context is the bus. An application is the set
|
|
52
52
|
of bounded contexts it loads; they never import each other's insides.
|
|
53
53
|
6. **The client.** The `OperationTransport` port — an operation called by who it is, over any
|
|
54
54
|
wire — with its REST and JSON-RPC adapters (a platform adds gRPC or its own); the `Transport`
|
|
@@ -67,16 +67,18 @@ server-side render, in a script and in a test, unchanged.**
|
|
|
67
67
|
10. **The command core (`./core`).** The single model matching the backend's application
|
|
68
68
|
abstractions: class DTOs and Responses, a bounded context that is a bus, Features and
|
|
69
69
|
ApplicationServices registered for DTO classes, attribute-injected dependencies and a
|
|
70
|
-
feature bus for composition, domain events
|
|
70
|
+
feature bus for composition, domain events as the input of a use case, replacements,
|
|
71
71
|
and the backend's error handlers — global, feature and application service — with the
|
|
72
72
|
errors a bounded context expects. The internal engine uses
|
|
73
73
|
the injected process ports. Remote clients and mapping remain distinct.
|
|
74
74
|
11. **Event-driven, as ports (`event_driven`).** How every platform publishes domain events and
|
|
75
75
|
subscribes to them the same way: the envelope that travels beside an event (its flow and
|
|
76
|
-
its cause), the `EventQueue` and `Subscribes` ports, a `Publisher` a Feature holds,
|
|
77
|
-
`
|
|
78
|
-
|
|
79
|
-
|
|
76
|
+
its cause), the `EventQueue` and `Subscribes` ports, a `Publisher` a Feature holds, the
|
|
77
|
+
`EventSubscriber` — the entrypoint that hears an event and calls use cases, as a cron does on
|
|
78
|
+
a tick — and its `Subscriptions`, a `Subscriber` over everyone who hears, the queue that
|
|
79
|
+
delivers in the call, and the stored queue's rules over an `EventStore` the platform plugs.
|
|
80
|
+
The guidelines, the rules and the contracts are here; where events are kept is the
|
|
81
|
+
platform's.
|
|
80
82
|
12. **Resilience.** What a failure is (`failureOf`, the backend's kinds on every wire) and the
|
|
81
83
|
backend's circuit breaker, guarding any transport or adapter.
|
|
82
84
|
|
package/README.md
CHANGED
|
@@ -65,12 +65,12 @@ class Quote extends ApplicationService<CommandQuote, ResponseTotal> {
|
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
68
|
+
const salesBoundedContext = new BoundedContext<{ unitPrice: number }>("sales");
|
|
69
|
+
salesBoundedContext.addDependency("unitPrice", 10);
|
|
70
|
+
salesBoundedContext.feature(CommandTotal)(Total);
|
|
71
|
+
salesBoundedContext.appService(CommandQuote)(Quote);
|
|
72
72
|
|
|
73
|
-
const answer = await
|
|
73
|
+
const answer = await salesBoundedContext.execute(new CommandQuote(3)); // ResponseTotal — total 30
|
|
74
74
|
```
|
|
75
75
|
|
|
76
76
|
A Command names, by its type, the Response it answers: `execute` infers it, with no second
|
|
@@ -96,12 +96,12 @@ only). A Response may be a class, a plain object, a page, a primitive or `void`.
|
|
|
96
96
|
### Decorators, when the project has them
|
|
97
97
|
|
|
98
98
|
```ts
|
|
99
|
-
@
|
|
99
|
+
@salesBoundedContext.feature(CommandTotal)
|
|
100
100
|
class Total extends Feature<CommandTotal, ResponseTotal> { … }
|
|
101
101
|
```
|
|
102
102
|
|
|
103
103
|
The same functions work as standard decorators and as legacy ones (`experimentalDecorators`);
|
|
104
|
-
without a decorator transform, call them: `
|
|
104
|
+
without a decorator transform, call them: `salesBoundedContext.feature(CommandTotal)(Total)`. No DI library
|
|
105
105
|
and no `reflect-metadata`. The tests compile both decorator forms with TypeScript; a bundler's
|
|
106
106
|
transform (Vite, Metro/Babel) is each application's to configure and test.
|
|
107
107
|
|
|
@@ -114,8 +114,8 @@ class PrintOnBixolon extends Print {
|
|
|
114
114
|
static override readonly serviceName = "PrintOnBixolon";
|
|
115
115
|
override execute(command: CommandPrint) { … }
|
|
116
116
|
}
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
printingBoundedContext.feature(CommandPrint, { replaces: Print })(PrintOnBixolon);
|
|
118
|
+
printingBoundedContext.handlerOf(CommandPrint); // PrintOnBixolon
|
|
119
119
|
```
|
|
120
120
|
|
|
121
121
|
`replaces` names the service registered now for that class, or the registration is refused. The
|
|
@@ -126,9 +126,22 @@ dependency is replaced the same way: `addDependency(name, next, { replaces: prev
|
|
|
126
126
|
|
|
127
127
|
## Domain events
|
|
128
128
|
|
|
129
|
-
A fact that already happened — `OrderPaid` — is
|
|
130
|
-
|
|
131
|
-
|
|
129
|
+
A fact that already happened — `OrderPaid` — is heard in one of two ways:
|
|
130
|
+
|
|
131
|
+
| What hears it | When |
|
|
132
|
+
| --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
|
133
|
+
| **A use case of the event**: a Feature or ApplicationService whose input _is_ the event | the use case exists because of the event, and nothing else calls it |
|
|
134
|
+
| **An `EventSubscriber`**: an entrypoint that reads the event and executes Commands on the bus | the event starts use cases that have their own Command — a screen or a cron calls them too |
|
|
135
|
+
|
|
136
|
+
A subscriber is not a use case — it is a door, as a cron or a screen is: it decides nothing. Work
|
|
137
|
+
of several steps is an ApplicationService (the workflow) that the subscriber executes, never
|
|
138
|
+
logic in the subscriber.
|
|
139
|
+
|
|
140
|
+
### A use case of the event
|
|
141
|
+
|
|
142
|
+
The event is executed as a Command is: by the Feature or ApplicationService registered for its
|
|
143
|
+
class in this bounded context, through the same pipeline (span, log lines, error handlers,
|
|
144
|
+
report):
|
|
132
145
|
|
|
133
146
|
```ts
|
|
134
147
|
class OrderPaid extends DomainEvent {
|
|
@@ -143,25 +156,67 @@ class ThankCustomer extends Feature<OrderPaid, void> {
|
|
|
143
156
|
async execute(event: OrderPaid) { … }
|
|
144
157
|
}
|
|
145
158
|
|
|
146
|
-
|
|
147
|
-
await
|
|
159
|
+
shopBoundedContext.feature(OrderPaid)(ThankCustomer);
|
|
160
|
+
await shopBoundedContext.execute(new OrderPaid("o-1")); // span shop/ThankCustomer, sincpro.event = its name
|
|
148
161
|
```
|
|
149
162
|
|
|
150
163
|
- **`eventName` is declared**, as `serviceName` is: a minifier renames classes. `event.name`
|
|
151
164
|
reads it; `name` is reserved, and a subclass cannot make it a field.
|
|
152
|
-
- **One
|
|
153
|
-
own;
|
|
154
|
-
- An ApplicationService can
|
|
155
|
-
through `featureBus`. `execute(event)` answers whatever
|
|
165
|
+
- **One use case per event in a bounded context.** Another context that hears the event
|
|
166
|
+
registers its own; the queue's `Subscriber` executes it in each.
|
|
167
|
+
- An ApplicationService can take the event too, and compose Features — of Commands or of events
|
|
168
|
+
— through `featureBus`. `execute(event)` answers whatever its use case returns (`unknown`).
|
|
169
|
+
|
|
170
|
+
### An `EventSubscriber`: the entrypoint that hears an event
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
// entrypoints/queue/logged-successfully.subscriber.ts — reads the event, executes the Command
|
|
174
|
+
export class LoggedSuccessfully extends EventSubscriber<OdooLoggedIn> {
|
|
175
|
+
static readonly subscriberName = "LoggedSuccessfully";
|
|
176
|
+
declare readonly ticketsBoundedContext: BoundedContext<TicketsDependencies>;
|
|
177
|
+
|
|
178
|
+
async handle(event: OdooLoggedIn) {
|
|
179
|
+
await this.ticketsBoundedContext.execute(new CommandStartDay(event.uid)); // an ApplicationService
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// entrypoints/queue/index.ts — who hears what, and what they call
|
|
184
|
+
export const ticketsSubscriptions = new Subscriptions<TicketsEntrypoints>(
|
|
185
|
+
"tickets-subscriptions",
|
|
186
|
+
);
|
|
187
|
+
ticketsSubscriptions.addDependency("ticketsBoundedContext", ticketsBoundedContext);
|
|
188
|
+
ticketsSubscriptions.subscribe(OdooLoggedIn)(LoggedSuccessfully);
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
- **Each delivery is a run of its own**, `tickets-subscriptions/LoggedSuccessfully` in the layer
|
|
192
|
+
`entrypoint`: its span, its log lines, `this.logger`, `this.execution`. It continues the
|
|
193
|
+
envelope's flow, and the use cases it executes are its children.
|
|
194
|
+
- **A bounded context it was given is bound to it** (`BIND`), as a transport is: what it executes
|
|
195
|
+
runs under the subscriber's span, in the same flow, caused by it, cancelled with it — and still
|
|
196
|
+
with its own dependencies, context, interceptors and handlers. A Feature that calls another
|
|
197
|
+
bounded context injected into it gets the same.
|
|
198
|
+
- **One subscriber per event in a `Subscriptions`**; a second is refused, a replacement names the
|
|
199
|
+
one it replaces (`subscribe(Event, { replaces: Old })`). Several reactions to one event are one
|
|
200
|
+
ApplicationService, or subscribers of different registries.
|
|
201
|
+
- **It is not a use case**: interceptors and listeners see the use cases it calls, not it. A
|
|
202
|
+
failure is reported once along the flow, where it happened, with the outcome its bounded
|
|
203
|
+
context gave it (`expectErrors` holds).
|
|
204
|
+
- **Its name is its identity** in spans, logs and the stored queue's record of who already
|
|
205
|
+
heard an event, so it differs from every bounded context's (`<context>-subscriptions`).
|
|
156
206
|
|
|
157
207
|
### Publishing and subscribing
|
|
158
208
|
|
|
159
209
|
```ts
|
|
160
210
|
import { Publisher, Subscriber, syncQueue } from "@sincpro/client-framework";
|
|
161
211
|
|
|
162
|
-
|
|
212
|
+
// the queue's door: every bounded context with a use case of the event, every Subscriptions
|
|
213
|
+
const subscriber = new Subscriber(
|
|
214
|
+
mailBoundedContext,
|
|
215
|
+
stockBoundedContext,
|
|
216
|
+
ticketsSubscriptions,
|
|
217
|
+
);
|
|
163
218
|
const queue = syncQueue(subscriber); // in the call, in memory — or a platform's
|
|
164
|
-
|
|
219
|
+
shopBoundedContext.addDependency("publisher", new Publisher(queue));
|
|
165
220
|
|
|
166
221
|
class Pay extends Feature<CommandPay, string> {
|
|
167
222
|
declare readonly publisher: Publisher;
|
|
@@ -174,37 +229,43 @@ class Pay extends Feature<CommandPay, string> {
|
|
|
174
229
|
|
|
175
230
|
The `Publisher` is bound to the run that holds it, so the event joins that run's flow: its
|
|
176
231
|
envelope carries the run's `correlationId` and names the run as its cause. The `Subscriber`
|
|
177
|
-
executes it in `mail` and in `
|
|
178
|
-
continue the same flow; one that fails does not stop the others.
|
|
232
|
+
executes it in `mail`, in `stock` and in `tickets-subscriptions` — `execute(event, envelope)`
|
|
233
|
+
—, so their runs continue the same flow; one that fails does not stop the others. Two of them
|
|
234
|
+
never share a name.
|
|
179
235
|
|
|
180
236
|
What is defined here, and what each platform plugs behind the ports:
|
|
181
237
|
|
|
182
|
-
| Piece
|
|
183
|
-
|
|
|
184
|
-
| `DomainEvent
|
|
185
|
-
| `
|
|
186
|
-
| `
|
|
187
|
-
| `
|
|
188
|
-
| `
|
|
238
|
+
| Piece | Here | The platform's |
|
|
239
|
+
| ----------------- | -------------------------------------------------------------- | -------------------------------------- |
|
|
240
|
+
| `DomainEvent` | the class, its `eventName`, its execution | — |
|
|
241
|
+
| `EventSubscriber` | the entrypoint, its run, `Subscriptions` | — |
|
|
242
|
+
| `Envelope` | `name`, `id`, `correlationId`, `causationId`, `publishedAt` | storing it beside the event |
|
|
243
|
+
| `EventQueue` port | `syncQueue` (in the call); `storedQueue` (store, then deliver) | a broker's queue, `BroadcastChannel` |
|
|
244
|
+
| `EventStore` port | `memoryEventStore()` | mobile: a SQLite table; web: IndexedDB |
|
|
245
|
+
| `Subscribes` port | `Subscriber(...hearing)`, `eventTypeOf(name)` | — |
|
|
189
246
|
|
|
190
247
|
### Stored events: one way to carry them, any store
|
|
191
248
|
|
|
192
249
|
```ts
|
|
193
250
|
const queue = storedQueue({
|
|
194
251
|
store: sqliteEventStore(db), // the platform's EventStore; memoryEventStore() in a tab or a test
|
|
195
|
-
subscriber: new Subscriber(
|
|
252
|
+
subscriber: new Subscriber(
|
|
253
|
+
salesBoundedContext,
|
|
254
|
+
stockBoundedContext,
|
|
255
|
+
printingBoundedContext,
|
|
256
|
+
),
|
|
196
257
|
ready: (stored) => connectivity.online || !needsNetwork(stored.envelope.name),
|
|
197
258
|
onSettled: (stored) => activity.show(stored.envelope.name, stored.state),
|
|
198
259
|
});
|
|
199
|
-
|
|
260
|
+
shopBoundedContext.addDependency("publisher", new Publisher(queue));
|
|
200
261
|
connectivity.onBack(() => queue.drain()); // and on coming back to the foreground
|
|
201
262
|
```
|
|
202
263
|
|
|
203
264
|
`publish` returns once the event is stored. Then the queue delivers what waits, one at a time,
|
|
204
265
|
in the order stored — the backend's store-and-forward, over whatever store the platform plugs:
|
|
205
266
|
|
|
206
|
-
- It is rebuilt as its class — `static fromJSON` when it has one — and executed
|
|
207
|
-
|
|
267
|
+
- It is rebuilt as its class — `static fromJSON` when it has one — and executed by everyone who
|
|
268
|
+
hears it and does not have it yet: a retry never runs one twice.
|
|
208
269
|
- A failure a retry may fix (`failureOf`: unavailable, no answer, a conflict…) is tried again
|
|
209
270
|
after the backoff or the wait it asks for, and what follows waits, so the order holds.
|
|
210
271
|
- A failure every attempt would repeat (`PERMANENT_KINDS`: a refusal, invalid, not found) sets
|
|
@@ -219,8 +280,9 @@ memory carry events the same way, with the same rules.
|
|
|
219
280
|
Three things a platform keeps in mind:
|
|
220
281
|
|
|
221
282
|
- **At least once.** A process that stops after a context executed an event and before the store
|
|
222
|
-
saved it runs it there again.
|
|
223
|
-
|
|
283
|
+
saved it runs it there again. What must not act twice keys what it does by the envelope's id —
|
|
284
|
+
`this.execution.causationId` in a use case of the event, or in an `EventSubscriber`, which hands
|
|
285
|
+
it to the Command it executes — or by what the event names (the entity's own id).
|
|
224
286
|
- **One drainer per store.** Two tabs over one IndexedDB claim it first (the Web Locks API).
|
|
225
287
|
- **Values come back as JSON.** An event that carries a `Money`, a `Decimal` or a date declares
|
|
226
288
|
`static fromJSON` to read them back as such.
|
|
@@ -236,19 +298,24 @@ and an application changes or hears it from outside, as the backend's `bus_pipel
|
|
|
236
298
|
|
|
237
299
|
```ts
|
|
238
300
|
// control: around the service — look, veto (throw), adjust (same class), or answer
|
|
239
|
-
|
|
301
|
+
shopBoundedContext.interceptor(CommandPay)(async (command, next, run) => {
|
|
240
302
|
if (!credit.allows(command.customer_id)) throw new NoCredit();
|
|
241
303
|
return next(command);
|
|
242
304
|
});
|
|
243
|
-
|
|
305
|
+
shopBoundedContext.interceptor()((executable, next, run) => next(executable)); // every class
|
|
244
306
|
|
|
245
307
|
// observation: never changes the answer
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
308
|
+
shopBoundedContext.onStart((started) => activity.running(started.execution.useCase));
|
|
309
|
+
shopBoundedContext.onCompletion(
|
|
310
|
+
(done) => activity.succeeded(done.execution.useCase),
|
|
311
|
+
CommandPay,
|
|
312
|
+
);
|
|
313
|
+
shopBoundedContext.onFailure((failed) =>
|
|
314
|
+
activity.failed(failed.execution?.useCase, failed.outcome),
|
|
315
|
+
);
|
|
249
316
|
|
|
250
317
|
// composition: a module or plugin wires its pieces in one place
|
|
251
|
-
|
|
318
|
+
shopBoundedContext.extend(auditing);
|
|
252
319
|
```
|
|
253
320
|
|
|
254
321
|
- **Interceptors** run inside the run, outermost first in the order registered: a veto is the
|
|
@@ -270,14 +337,14 @@ first-registered-first. A handler returns the answer, or throws to hand the erro
|
|
|
270
337
|
one's throw reaches the caller:
|
|
271
338
|
|
|
272
339
|
```ts
|
|
273
|
-
|
|
340
|
+
salesBoundedContext.addFeatureErrorHandler((error) => {
|
|
274
341
|
if (error instanceof PriceNotFound) return new ResponseTotal(0); // the answer
|
|
275
342
|
throw error; // the next handler, or the caller
|
|
276
343
|
});
|
|
277
|
-
|
|
278
|
-
|
|
344
|
+
salesBoundedContext.addAppServiceErrorHandler(toBusinessError);
|
|
345
|
+
salesBoundedContext.addGlobalErrorHandler(toProblem); // what reaches salesBoundedContext.execute or a scope
|
|
279
346
|
|
|
280
|
-
|
|
347
|
+
salesBoundedContext.expectErrors(QuoteExpired); // the business answering: `refused`, never reported
|
|
281
348
|
```
|
|
282
349
|
|
|
283
350
|
A failure is observed where it happened, before any handler runs: its span closes with its
|
|
@@ -338,7 +405,7 @@ reports what fails outside a use case — a view, a worker —, with the same ru
|
|
|
338
405
|
|
|
339
406
|
```ts
|
|
340
407
|
const screen = new AbortController();
|
|
341
|
-
const answer =
|
|
408
|
+
const answer = salesBoundedContext.execute(new CommandQuote(3), { signal: screen.signal });
|
|
342
409
|
screen.abort(); // the run ends `aborted` at once; nothing is reported
|
|
343
410
|
```
|
|
344
411
|
|
|
@@ -399,21 +466,21 @@ import { logger, loggerAdapter } from "@sincpro/client-framework";
|
|
|
399
466
|
|
|
400
467
|
logger.info("Database opened"); // infrastructure — the global logger
|
|
401
468
|
loggerAdapter.warn("Printer is slow", { ms: 900 }); // an adapter
|
|
402
|
-
|
|
469
|
+
salesBoundedContext.logger.info("Catalog preloaded"); // a bounded context, outside a use case
|
|
403
470
|
this.logger.info("Totalling"); // inside a Feature or ApplicationService
|
|
404
471
|
```
|
|
405
472
|
|
|
406
|
-
| Logger
|
|
407
|
-
|
|
|
408
|
-
| `logger`
|
|
409
|
-
| `loggerAdapter`
|
|
410
|
-
| `
|
|
411
|
-
| `this.logger`, Feature
|
|
412
|
-
| `this.logger`, AppService
|
|
473
|
+
| Logger | Layer | Each line carries |
|
|
474
|
+
| ---------------------------- | --------------------- | ---------------------------------------------- |
|
|
475
|
+
| `logger` | `infrastructure` | — |
|
|
476
|
+
| `loggerAdapter` | `adapter` | the run, when injected as a dependency |
|
|
477
|
+
| `salesBoundedContext.logger` | `bounded_context` | its bounded context |
|
|
478
|
+
| `this.logger`, Feature | `feature` | bounded context, use case, execution and trace |
|
|
479
|
+
| `this.logger`, AppService | `application_service` | the same |
|
|
413
480
|
|
|
414
481
|
`this.logger` keeps its run across awaits and concurrent calls, without a global stack. An
|
|
415
482
|
adapter that imports `loggerAdapter` at module level writes without the run; inject it
|
|
416
|
-
(`
|
|
483
|
+
(`salesBoundedContext.addDependency("log", loggerAdapter)`) or pass `this.logger` when a line must carry it.
|
|
417
484
|
|
|
418
485
|
One configuration, two doors — `configureProcess({ logger })` replaces every process port,
|
|
419
486
|
`configureLogger(...)` only logging (an in-app debug switch):
|
|
@@ -441,8 +508,8 @@ snapshot, and what travels goes to the server in `sincpro-context`.
|
|
|
441
508
|
|
|
442
509
|
```ts
|
|
443
510
|
processContext.set({ tenant_id: "acme" });
|
|
444
|
-
|
|
445
|
-
await
|
|
511
|
+
salesBoundedContext.context.set({ warehouse_id: 4 });
|
|
512
|
+
await salesBoundedContext.scoped({ company_id: 2 }).execute(new CommandQuote(1));
|
|
446
513
|
```
|
|
447
514
|
|
|
448
515
|
Where the values live is a port (`ContextStore`): synchronous reads, a version per change and an
|
|
@@ -612,6 +679,44 @@ never a `Date`. `valuesOf`/`pack` write; `readValues` reads tagged values back;
|
|
|
612
679
|
use a reader given explicitly. Cycles, functions and non-finite numbers are refused. How a value
|
|
613
680
|
is shown, typed or stored is the platform's.
|
|
614
681
|
|
|
682
|
+
## An application's layout
|
|
683
|
+
|
|
684
|
+
An application is laid out as a sincpro_framework service is — the entrypoints call the bus, the
|
|
685
|
+
bus runs the use cases, the use cases use the adapters:
|
|
686
|
+
|
|
687
|
+
```text
|
|
688
|
+
src/
|
|
689
|
+
entrypoints/main.ts(x) configureProcess, the stored queue and its Subscriber
|
|
690
|
+
domains/
|
|
691
|
+
common/infrastructure/operations.ts export const operations — the wire every client uses
|
|
692
|
+
<bounded context>/
|
|
693
|
+
generated/ sincpro-client generate — never edited
|
|
694
|
+
domain/ local Commands, Responses, DomainEvents, value rules
|
|
695
|
+
adapters/ repositories, printers, other systems
|
|
696
|
+
services/ the use cases, one folder per aggregate or entity
|
|
697
|
+
<aggregate>/ sale_order/: its Features and ApplicationServices
|
|
698
|
+
confirm-quotation.feature.ts
|
|
699
|
+
<entity of the aggregate>/ payments/: a related entity with use cases of its own
|
|
700
|
+
register-payment.feature.ts
|
|
701
|
+
reconcile-payments.app-service.ts
|
|
702
|
+
index.ts registerServices: each Command with its service
|
|
703
|
+
infrastructure/ dependencies.ts, bounded_context.ts (service bases)
|
|
704
|
+
entrypoints/ the doors: they translate, they decide nothing
|
|
705
|
+
queue/ EventSubscribers: event → Command → bus; index.ts: Subscriptions
|
|
706
|
+
cron/ the platform's scheduler: tick → Command → bus
|
|
707
|
+
index.ts export const <context>BoundedContext — ready to execute
|
|
708
|
+
```
|
|
709
|
+
|
|
710
|
+
- **Services by aggregate.** A simple entity is one folder with its Features (a CRUD). When an
|
|
711
|
+
aggregate holds related entities with use cases of their own — the payments of a sale order —,
|
|
712
|
+
they get a folder inside it, and its Features and ApplicationServices live there, grouped by
|
|
713
|
+
what they are about.
|
|
714
|
+
- **Entrypoints translate.** A subscriber, a cron and a screen build a Command and execute it;
|
|
715
|
+
a workflow of several use cases is an ApplicationService they execute. `sincpro-client check`
|
|
716
|
+
refuses a service that imports an entrypoint.
|
|
717
|
+
- **A bounded context is named for what it is**: `salesBoundedContext`, never `sales` (an
|
|
718
|
+
entity?) or `ctx` — `context` is already the run's (`this.context`).
|
|
719
|
+
|
|
615
720
|
## Package layout
|
|
616
721
|
|
|
617
722
|
Each module is laid out as sincpro_framework's are, and says what it offers in its `index.ts`:
|
|
@@ -646,7 +751,7 @@ src/
|
|
|
646
751
|
event_driven/
|
|
647
752
|
domain/ Envelope, the EventQueue, Subscribes and EventStore ports
|
|
648
753
|
adapters/ syncQueue, storedQueue, memoryEventStore
|
|
649
|
-
entrypoint/ Publisher, Subscriber
|
|
754
|
+
entrypoint/ Publisher, Subscriber (the queue's door), Subscriptions, EventSubscriber
|
|
650
755
|
codegen/ the generator and its CLI — the only place Node is allowed
|
|
651
756
|
testing/ memoryTracer, memoryReporter, memoryLogger
|
|
652
757
|
```
|
|
@@ -6,7 +6,7 @@ import type { Transport } from "../domain/transport.js";
|
|
|
6
6
|
* `Transport`:
|
|
7
7
|
*
|
|
8
8
|
* const operations = guardedTransport(jsonRpcOperations({ endpoint }), new CircuitBreaker("forge"));
|
|
9
|
-
*
|
|
9
|
+
* projectBoundedContext.addDependency("client", clientOf(operations));
|
|
10
10
|
*
|
|
11
11
|
* Context: the circuit belongs where the dependency is — the transport —, not in each Feature. A
|
|
12
12
|
* run binds it as it binds any transport, so the trace and the context still travel; an open
|
|
@@ -8,7 +8,7 @@ import type { Metamodel } from "../domain/metamodel.js";
|
|
|
8
8
|
* infrastructure/dependencies.ts what the services can count on: the generated client
|
|
9
9
|
* infrastructure/bounded_context.ts its Feature and ApplicationService, and how it is made
|
|
10
10
|
* services/index.ts registers command classes and their services
|
|
11
|
-
* index.ts `export const
|
|
11
|
+
* index.ts `export const projectBoundedContext` — ready to use
|
|
12
12
|
*
|
|
13
13
|
* Context: the layout sincpro_framework gives a service (`dependencies.py`, `framework.py`,
|
|
14
14
|
* `services/`, `__init__.py`), so a bounded context starts the way every other does and nobody
|
|
@@ -1,10 +1,4 @@
|
|
|
1
1
|
import { aliasOf, FRAMEWORK } from "../domain/names.js";
|
|
2
|
-
/** Names the skeleton's `index.ts` cannot give its constant. */
|
|
3
|
-
const TAKEN = new Set([
|
|
4
|
-
"operations",
|
|
5
|
-
"registerServices",
|
|
6
|
-
..."break case catch class const continue debugger default delete do else enum export extends false finally for function if import in instanceof new null return super switch this throw true try typeof var void while with yield let static implements interface package private protected public await".split(" "),
|
|
7
|
-
]);
|
|
8
2
|
/** `sales-orders` → `SalesOrders`. */
|
|
9
3
|
function pascalOf(alias) {
|
|
10
4
|
const joined = alias
|
|
@@ -20,10 +14,9 @@ const HEADER = (what) => `// ${what} — written once by \`sincpro-client scaffo
|
|
|
20
14
|
export function scaffoldFilesOf(metamodel, options = {}) {
|
|
21
15
|
const alias = aliasOf(metamodel);
|
|
22
16
|
const Name = pascalOf(alias);
|
|
23
|
-
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
const name = TAKEN.has(bare) ? `${bare}BoundedContext` : bare;
|
|
17
|
+
// Named for what it is — `projectBoundedContext`, never `project` (an entity?) or `ctx` —:
|
|
18
|
+
// nobody misreads it, and no word of the language or name `index.ts` imports can take it.
|
|
19
|
+
const name = `${Name.charAt(0).toLowerCase()}${Name.slice(1)}BoundedContext`;
|
|
27
20
|
const Dependencies = `${Name}Dependencies`;
|
|
28
21
|
const client = metamodel.bindings !== undefined;
|
|
29
22
|
const operations = options.operations ?? at("..", "common", "infrastructure", "operations");
|
|
@@ -44,11 +37,11 @@ export function scaffoldFilesOf(metamodel, options = {}) {
|
|
|
44
37
|
"}",
|
|
45
38
|
"",
|
|
46
39
|
"export function registerDependencies(",
|
|
47
|
-
`
|
|
40
|
+
` boundedContext: BoundedContext<${Dependencies}>,`,
|
|
48
41
|
` ${client ? "operations" : "_operations"}: OperationTransport,`,
|
|
49
42
|
") {",
|
|
50
|
-
...(client ? [`
|
|
51
|
-
" return
|
|
43
|
+
...(client ? [` boundedContext.addDependency("client", clientOf(operations));`] : []),
|
|
44
|
+
" return boundedContext;",
|
|
52
45
|
"}",
|
|
53
46
|
"",
|
|
54
47
|
].join("\n");
|
|
@@ -66,7 +59,7 @@ export function scaffoldFilesOf(metamodel, options = {}) {
|
|
|
66
59
|
"",
|
|
67
60
|
`import { type ${Dependencies}, registerDependencies } from "${at(".", "dependencies")}";`,
|
|
68
61
|
"",
|
|
69
|
-
`/** A Feature of «${alias}» — of a Command, or a
|
|
62
|
+
`/** A Feature of «${alias}» — of a Command, or a use case of an event: its dependencies typed on \`this\`, and \`this.context\`. */`,
|
|
70
63
|
"export abstract class Feature<C extends Command<R> | DomainEvent, R> extends BaseFeature<C, R> {",
|
|
71
64
|
...(client ? [` declare readonly client: ${Dependencies}["client"];`] : []),
|
|
72
65
|
"}",
|
|
@@ -91,10 +84,12 @@ export function scaffoldFilesOf(metamodel, options = {}) {
|
|
|
91
84
|
" * Generated DTO types are remote contracts, not runtime command constructors: import",
|
|
92
85
|
` * them under their own name — \`import type * as remote from "${at("..", "generated", "domain")}"\` —`,
|
|
93
86
|
" * and let a local Command implement one (`implements remote.CommandCancelIssue`).",
|
|
94
|
-
" * Register with
|
|
95
|
-
" *
|
|
87
|
+
" * Register with boundedContext.feature(CommandClass)(FeatureClass) or",
|
|
88
|
+
" * boundedContext.appService(CommandClass)(ApplicationServiceClass); the services live in",
|
|
89
|
+
" * one folder per aggregate (`services/<aggregate>/`), and an entity of that aggregate with",
|
|
90
|
+
" * use cases of its own in a folder inside it (`services/<aggregate>/<entity>/`).",
|
|
96
91
|
" */",
|
|
97
|
-
`export function registerServices(
|
|
92
|
+
`export function registerServices(_boundedContext: BoundedContext<${Dependencies}>): void {}`,
|
|
98
93
|
"",
|
|
99
94
|
].join("\n");
|
|
100
95
|
const index = [
|
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
import type { ContextStore, ContextValues } from "../domain/store.js";
|
|
2
2
|
/**
|
|
3
3
|
* One level of the context: the process's (`processContext`) or a bounded context's
|
|
4
|
-
* (`
|
|
4
|
+
* (`projectBoundedContext.context`), read and written the same way on every platform.
|
|
5
5
|
*
|
|
6
6
|
* processContext.set({ tenant_id: "acme" }); the company changed — every screen hears it
|
|
7
7
|
* processContext.get("tenant_id") → "acme"
|
|
8
|
-
*
|
|
8
|
+
* projectBoundedContext.context.set({ warehouse_id: 4 }); only what it runs
|
|
9
9
|
* const stop = processContext.watch(() => redraw());
|
|
10
10
|
*
|
|
11
11
|
* Context: sincpro_framework reads its context from a tree — the process, the bounded context,
|
|
12
12
|
* the scope, the execution — and the nearest wins. A client has the same levels: a use case runs
|
|
13
13
|
* with the process's values, under them its bounded context's, under them a scope's
|
|
14
|
-
* (`
|
|
15
|
-
* it runs. That snapshot is `this.context` and what travels to the server in
|
|
14
|
+
* (`projectBoundedContext.scoped({...})`), read once where the gesture starts and inherited by
|
|
15
|
+
* every Feature it runs. That snapshot is `this.context` and what travels to the server in
|
|
16
|
+
* `sincpro-context`.
|
|
16
17
|
*
|
|
17
18
|
* Where the values live is the platform's (`ContextStore`): this is only the door. `values` is
|
|
18
19
|
* the same object until something changes it, so a screen can hand it straight to React's
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* class CommandCancelIssue extends Command<ResponseCancelIssue> {
|
|
6
6
|
* constructor(readonly issue_id: string) { super(); }
|
|
7
7
|
* }
|
|
8
|
-
* const answer = await
|
|
8
|
+
* const answer = await projectBoundedContext.execute(new CommandCancelIssue("x")); // ResponseCancelIssue
|
|
9
9
|
*
|
|
10
10
|
* Context: the backend asks `bus(dto, ResponseType)` for the type; here the Command carries it, so
|
|
11
11
|
* `execute` infers it with no second argument. The association is type-only: absent from a
|
|
@@ -31,8 +31,8 @@ export declare abstract class Command<R> extends DataTransferObject {
|
|
|
31
31
|
* static readonly eventName = "shop.order.v1.paid";
|
|
32
32
|
* constructor(readonly order_id: string) { super(); }
|
|
33
33
|
* }
|
|
34
|
-
*
|
|
35
|
-
* await
|
|
34
|
+
* projectBoundedContext.feature(OrderPaid)(NotifyCustomer);
|
|
35
|
+
* await projectBoundedContext.execute(new OrderPaid("o-1"));
|
|
36
36
|
*
|
|
37
37
|
* Context: the backend names an event on the wire by `name`, `<context>.<aggregate>.vN.<fact>`,
|
|
38
38
|
* and defaults it to the class's name; a client is minified, so the name is declared, as
|
package/dist/core/domain/dto.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* class CommandCancelIssue extends Command<ResponseCancelIssue> {
|
|
6
6
|
* constructor(readonly issue_id: string) { super(); }
|
|
7
7
|
* }
|
|
8
|
-
* const answer = await
|
|
8
|
+
* const answer = await projectBoundedContext.execute(new CommandCancelIssue("x")); // ResponseCancelIssue
|
|
9
9
|
*
|
|
10
10
|
* Context: the backend asks `bus(dto, ResponseType)` for the type; here the Command carries it, so
|
|
11
11
|
* `execute` infers it with no second argument. The association is type-only: absent from a
|
|
@@ -27,8 +27,8 @@ export class Command extends DataTransferObject {
|
|
|
27
27
|
* static readonly eventName = "shop.order.v1.paid";
|
|
28
28
|
* constructor(readonly order_id: string) { super(); }
|
|
29
29
|
* }
|
|
30
|
-
*
|
|
31
|
-
* await
|
|
30
|
+
* projectBoundedContext.feature(OrderPaid)(NotifyCustomer);
|
|
31
|
+
* await projectBoundedContext.execute(new OrderPaid("o-1"));
|
|
32
32
|
*
|
|
33
33
|
* Context: the backend names an event on the wire by `name`, `<context>.<aggregate>.vN.<fact>`,
|
|
34
34
|
* and defaults it to the class's name; a client is minified, so the name is declared, as
|