@sincpro/client-framework 1.0.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 +71 -13
- package/MANIFESTO.md +59 -14
- package/README.md +456 -55
- package/dist/circuit_breaker/domain/circuit.d.ts +29 -0
- package/dist/circuit_breaker/domain/circuit.js +37 -0
- package/dist/circuit_breaker/entrypoint/circuit-breaker.d.ts +56 -0
- package/dist/circuit_breaker/entrypoint/circuit-breaker.js +94 -0
- package/dist/circuit_breaker/index.d.ts +9 -0
- package/dist/circuit_breaker/index.js +9 -0
- package/dist/client/adapters/json-rpc.d.ts +40 -0
- package/dist/client/adapters/json-rpc.js +97 -0
- package/dist/client/adapters/rest-operations.d.ts +19 -0
- package/dist/client/adapters/rest-operations.js +66 -0
- package/dist/client/domain/operation.d.ts +73 -0
- package/dist/client/domain/operation.js +19 -0
- package/dist/client/index.d.ts +5 -1
- package/dist/client/index.js +5 -1
- package/dist/client/services/guarded.d.ts +17 -0
- package/dist/client/services/guarded.js +19 -0
- package/dist/client/services/propagation.d.ts +20 -7
- package/dist/client/services/propagation.js +44 -15
- package/dist/codegen/entrypoint/cli.js +7 -7
- package/dist/codegen/services/client.d.ts +7 -8
- package/dist/codegen/services/client.js +50 -148
- package/dist/codegen/services/scaffold.d.ts +7 -4
- package/dist/codegen/services/scaffold.js +23 -27
- package/dist/common/failures.d.ts +83 -0
- package/dist/common/failures.js +212 -0
- package/dist/common/index.d.ts +1 -0
- package/dist/common/index.js +1 -0
- package/dist/context/domain/execution.d.ts +12 -0
- package/dist/context/entrypoint/context.d.ts +5 -4
- package/dist/core/domain/bus.d.ts +16 -7
- package/dist/core/domain/bus.js +14 -6
- package/dist/core/domain/dto.d.ts +33 -3
- package/dist/core/domain/dto.js +24 -1
- package/dist/core/domain/errors.d.ts +2 -2
- package/dist/core/domain/pipeline.d.ts +89 -0
- package/dist/core/domain/pipeline.js +1 -0
- package/dist/core/domain/services.d.ts +16 -5
- package/dist/core/domain/services.js +5 -2
- package/dist/core/entrypoint/bounded-context.d.ts +96 -16
- package/dist/core/entrypoint/bounded-context.js +244 -39
- package/dist/core/index.d.ts +4 -2
- package/dist/core/index.js +3 -2
- package/dist/core/infrastructure/listeners.d.ts +28 -0
- package/dist/core/infrastructure/listeners.js +34 -0
- package/dist/core/services/binding.d.ts +2 -2
- package/dist/core/services/binding.js +6 -3
- package/dist/core/services/execution.d.ts +59 -12
- package/dist/core/services/execution.js +114 -19
- package/dist/core/services/interceptors.d.ts +15 -0
- package/dist/core/services/interceptors.js +25 -0
- package/dist/core/services/listening.d.ts +17 -0
- package/dist/core/services/listening.js +25 -0
- package/dist/event_driven/adapters/memory-event-store.d.ts +10 -0
- package/dist/event_driven/adapters/memory-event-store.js +23 -0
- package/dist/event_driven/adapters/stored-queue.d.ts +68 -0
- package/dist/event_driven/adapters/stored-queue.js +193 -0
- package/dist/event_driven/adapters/sync-queue.d.ts +11 -0
- package/dist/event_driven/adapters/sync-queue.js +19 -0
- package/dist/event_driven/domain/envelope.d.ts +28 -0
- package/dist/event_driven/domain/envelope.js +19 -0
- package/dist/event_driven/domain/queue.d.ts +40 -0
- package/dist/event_driven/domain/queue.js +1 -0
- package/dist/event_driven/domain/store.d.ts +56 -0
- package/dist/event_driven/domain/store.js +1 -0
- package/dist/event_driven/entrypoint/publisher.d.ts +26 -0
- package/dist/event_driven/entrypoint/publisher.js +35 -0
- package/dist/event_driven/entrypoint/subscriber.d.ts +42 -0
- package/dist/event_driven/entrypoint/subscriber.js +62 -0
- 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 +22 -0
- package/dist/event_driven/index.js +20 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.js +7 -2
- package/dist/observability/adapters/memory.d.ts +1 -1
- package/dist/observability/domain/logging.d.ts +10 -4
- package/dist/observability/services/logging.d.ts +7 -4
- package/dist/observability/services/logging.js +3 -2
- package/dist/observability/services/outcome.d.ts +6 -6
- package/dist/observability/services/outcome.js +10 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,6 +7,8 @@ the same way, and a typed client generated from a service's contract. No React,
|
|
|
7
7
|
platform storage: what a platform must give, it gives through a port.
|
|
8
8
|
|
|
9
9
|
- [A bounded context](#a-bounded-context)
|
|
10
|
+
- [Domain events](#domain-events)
|
|
11
|
+
- [Extending the pipeline](#extending-the-pipeline)
|
|
10
12
|
- [Errors](#errors)
|
|
11
13
|
- [Observability](#observability)
|
|
12
14
|
- [Context](#context)
|
|
@@ -63,12 +65,12 @@ class Quote extends ApplicationService<CommandQuote, ResponseTotal> {
|
|
|
63
65
|
}
|
|
64
66
|
}
|
|
65
67
|
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
const salesBoundedContext = new BoundedContext<{ unitPrice: number }>("sales");
|
|
69
|
+
salesBoundedContext.addDependency("unitPrice", 10);
|
|
70
|
+
salesBoundedContext.feature(CommandTotal)(Total);
|
|
71
|
+
salesBoundedContext.appService(CommandQuote)(Quote);
|
|
70
72
|
|
|
71
|
-
const answer = await
|
|
73
|
+
const answer = await salesBoundedContext.execute(new CommandQuote(3)); // ResponseTotal — total 30
|
|
72
74
|
```
|
|
73
75
|
|
|
74
76
|
A Command names, by its type, the Response it answers: `execute` infers it, with no second
|
|
@@ -94,15 +96,240 @@ only). A Response may be a class, a plain object, a page, a primitive or `void`.
|
|
|
94
96
|
### Decorators, when the project has them
|
|
95
97
|
|
|
96
98
|
```ts
|
|
97
|
-
@
|
|
99
|
+
@salesBoundedContext.feature(CommandTotal)
|
|
98
100
|
class Total extends Feature<CommandTotal, ResponseTotal> { … }
|
|
99
101
|
```
|
|
100
102
|
|
|
101
103
|
The same functions work as standard decorators and as legacy ones (`experimentalDecorators`);
|
|
102
|
-
without a decorator transform, call them: `
|
|
104
|
+
without a decorator transform, call them: `salesBoundedContext.feature(CommandTotal)(Total)`. No DI library
|
|
103
105
|
and no `reflect-metadata`. The tests compile both decorator forms with TypeScript; a bundler's
|
|
104
106
|
transform (Vite, Metro/Babel) is each application's to configure and test.
|
|
105
107
|
|
|
108
|
+
### Replacing a service
|
|
109
|
+
|
|
110
|
+
An application takes the place of a service a module registered, as the backend's `replaces=`:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
class PrintOnBixolon extends Print {
|
|
114
|
+
static override readonly serviceName = "PrintOnBixolon";
|
|
115
|
+
override execute(command: CommandPrint) { … }
|
|
116
|
+
}
|
|
117
|
+
printingBoundedContext.feature(CommandPrint, { replaces: Print })(PrintOnBixolon);
|
|
118
|
+
printingBoundedContext.handlerOf(CommandPrint); // PrintOnBixolon
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`replaces` names the service registered now for that class, or the registration is refused. The
|
|
122
|
+
next call answers with the new one; its spans say which it replaced (`sincpro.replaces`). A
|
|
123
|
+
service meant to be replaced by a subclass widens its name — `static readonly serviceName:
|
|
124
|
+
string = "Print"` —, or TypeScript keeps the literal and the subclass cannot rename itself. A
|
|
125
|
+
dependency is replaced the same way: `addDependency(name, next, { replaces: previous })`.
|
|
126
|
+
|
|
127
|
+
## Domain events
|
|
128
|
+
|
|
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):
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
class OrderPaid extends DomainEvent {
|
|
148
|
+
static readonly eventName = "shop.order.v1.paid"; // its wire name, as the backend's `name`
|
|
149
|
+
constructor(readonly order_id: string) {
|
|
150
|
+
super();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
class ThankCustomer extends Feature<OrderPaid, void> {
|
|
155
|
+
static readonly serviceName = "ThankCustomer";
|
|
156
|
+
async execute(event: OrderPaid) { … }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
shopBoundedContext.feature(OrderPaid)(ThankCustomer);
|
|
160
|
+
await shopBoundedContext.execute(new OrderPaid("o-1")); // span shop/ThankCustomer, sincpro.event = its name
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
- **`eventName` is declared**, as `serviceName` is: a minifier renames classes. `event.name`
|
|
164
|
+
reads it; `name` is reserved, and a subclass cannot make it a field.
|
|
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`).
|
|
206
|
+
|
|
207
|
+
### Publishing and subscribing
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
import { Publisher, Subscriber, syncQueue } from "@sincpro/client-framework";
|
|
211
|
+
|
|
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
|
+
);
|
|
218
|
+
const queue = syncQueue(subscriber); // in the call, in memory — or a platform's
|
|
219
|
+
shopBoundedContext.addDependency("publisher", new Publisher(queue));
|
|
220
|
+
|
|
221
|
+
class Pay extends Feature<CommandPay, string> {
|
|
222
|
+
declare readonly publisher: Publisher;
|
|
223
|
+
async execute(command: CommandPay) {
|
|
224
|
+
await this.publisher.publish(new OrderPaid(command.order_id)); // its Envelope
|
|
225
|
+
return command.order_id;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
The `Publisher` is bound to the run that holds it, so the event joins that run's flow: its
|
|
231
|
+
envelope carries the run's `correlationId` and names the run as its cause. The `Subscriber`
|
|
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.
|
|
235
|
+
|
|
236
|
+
What is defined here, and what each platform plugs behind the ports:
|
|
237
|
+
|
|
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)` | — |
|
|
246
|
+
|
|
247
|
+
### Stored events: one way to carry them, any store
|
|
248
|
+
|
|
249
|
+
```ts
|
|
250
|
+
const queue = storedQueue({
|
|
251
|
+
store: sqliteEventStore(db), // the platform's EventStore; memoryEventStore() in a tab or a test
|
|
252
|
+
subscriber: new Subscriber(
|
|
253
|
+
salesBoundedContext,
|
|
254
|
+
stockBoundedContext,
|
|
255
|
+
printingBoundedContext,
|
|
256
|
+
),
|
|
257
|
+
ready: (stored) => connectivity.online || !needsNetwork(stored.envelope.name),
|
|
258
|
+
onSettled: (stored) => activity.show(stored.envelope.name, stored.state),
|
|
259
|
+
});
|
|
260
|
+
shopBoundedContext.addDependency("publisher", new Publisher(queue));
|
|
261
|
+
connectivity.onBack(() => queue.drain()); // and on coming back to the foreground
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
`publish` returns once the event is stored. Then the queue delivers what waits, one at a time,
|
|
265
|
+
in the order stored — the backend's store-and-forward, over whatever store the platform plugs:
|
|
266
|
+
|
|
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.
|
|
269
|
+
- A failure a retry may fix (`failureOf`: unavailable, no answer, a conflict…) is tried again
|
|
270
|
+
after the backoff or the wait it asks for, and what follows waits, so the order holds.
|
|
271
|
+
- A failure every attempt would repeat (`PERMANENT_KINDS`: a refusal, invalid, not found) sets
|
|
272
|
+
it aside — the dead-letter queue — and never holds back what follows.
|
|
273
|
+
- One that is not `ready` (no connection, no session) waits while the rest go on.
|
|
274
|
+
- After `maxAttempts` (10) a failure a retry might still fix is set aside too: a bug of the
|
|
275
|
+
process (`internal`) never holds the line forever.
|
|
276
|
+
|
|
277
|
+
The port only keeps records — `add`, `next(skip)`, `save` — so a SQLite table, IndexedDB or
|
|
278
|
+
memory carry events the same way, with the same rules.
|
|
279
|
+
|
|
280
|
+
Three things a platform keeps in mind:
|
|
281
|
+
|
|
282
|
+
- **At least once.** A process that stops after a context executed an event and before the store
|
|
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).
|
|
286
|
+
- **One drainer per store.** Two tabs over one IndexedDB claim it first (the Web Locks API).
|
|
287
|
+
- **Values come back as JSON.** An event that carries a `Money`, a `Decimal` or a date declares
|
|
288
|
+
`static fromJSON` to read them back as such.
|
|
289
|
+
|
|
290
|
+
The events a remote service publishes arrive as generated types (`DomainEvent` in
|
|
291
|
+
`generated/domain.ts`, a union narrowed by `name`); a local event is a class, like a local
|
|
292
|
+
Command.
|
|
293
|
+
|
|
294
|
+
## Extending the pipeline
|
|
295
|
+
|
|
296
|
+
The flow of a run is the core's — context, chain, span, log lines, outcome, report, handlers —,
|
|
297
|
+
and an application changes or hears it from outside, as the backend's `bus_pipeline`:
|
|
298
|
+
|
|
299
|
+
```ts
|
|
300
|
+
// control: around the service — look, veto (throw), adjust (same class), or answer
|
|
301
|
+
shopBoundedContext.interceptor(CommandPay)(async (command, next, run) => {
|
|
302
|
+
if (!credit.allows(command.customer_id)) throw new NoCredit();
|
|
303
|
+
return next(command);
|
|
304
|
+
});
|
|
305
|
+
shopBoundedContext.interceptor()((executable, next, run) => next(executable)); // every class
|
|
306
|
+
|
|
307
|
+
// observation: never changes the answer
|
|
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
|
+
);
|
|
316
|
+
|
|
317
|
+
// composition: a module or plugin wires its pieces in one place
|
|
318
|
+
shopBoundedContext.extend(auditing);
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
- **Interceptors** run inside the run, outermost first in the order registered: a veto is the
|
|
322
|
+
use case's failure — observed, answered by the error handlers. What an interceptor passes on
|
|
323
|
+
keeps its class (the bus routes by class); `run` gives it the use case, the execution and its
|
|
324
|
+
logger. `withoutInterceptor(fn)` stops one; registered later, it wraps from the next call.
|
|
325
|
+
- **Listeners** hear each run start and each service that answered, filtered by class when
|
|
326
|
+
named; a failure is heard once, when it escapes the call after every handler, from where it
|
|
327
|
+
first failed. One that throws is ignored. Each `on…` answers how to stop.
|
|
328
|
+
`processListeners.onStart / onCompletion / onFailure` hear every bounded context at once — a
|
|
329
|
+
progress bar, a toast —, after each one's own.
|
|
330
|
+
- **`extend(fn)`** is `fn(context)`, said as composition; `fresh()` copies everything an
|
|
331
|
+
extension registered.
|
|
332
|
+
|
|
106
333
|
## Errors
|
|
107
334
|
|
|
108
335
|
As the backend's, a bounded context has three chains of error handlers, run
|
|
@@ -110,14 +337,14 @@ first-registered-first. A handler returns the answer, or throws to hand the erro
|
|
|
110
337
|
one's throw reaches the caller:
|
|
111
338
|
|
|
112
339
|
```ts
|
|
113
|
-
|
|
340
|
+
salesBoundedContext.addFeatureErrorHandler((error) => {
|
|
114
341
|
if (error instanceof PriceNotFound) return new ResponseTotal(0); // the answer
|
|
115
342
|
throw error; // the next handler, or the caller
|
|
116
343
|
});
|
|
117
|
-
|
|
118
|
-
|
|
344
|
+
salesBoundedContext.addAppServiceErrorHandler(toBusinessError);
|
|
345
|
+
salesBoundedContext.addGlobalErrorHandler(toProblem); // what reaches salesBoundedContext.execute or a scope
|
|
119
346
|
|
|
120
|
-
|
|
347
|
+
salesBoundedContext.expectErrors(QuoteExpired); // the business answering: `refused`, never reported
|
|
121
348
|
```
|
|
122
349
|
|
|
123
350
|
A failure is observed where it happened, before any handler runs: its span closes with its
|
|
@@ -126,18 +353,81 @@ the answer, not whether the failure is seen; an answered one adds an `error.hand
|
|
|
126
353
|
feature handler answers wherever the Feature ran, inside an ApplicationService too; the global
|
|
127
354
|
chain wraps only `execute` and `scoped(...).execute`.
|
|
128
355
|
|
|
129
|
-
|
|
356
|
+
### What kind of failure it is
|
|
357
|
+
|
|
358
|
+
`failureOf(error)` says what any error is, in the backend's words (`FailureKind`, `NextStep`
|
|
359
|
+
from @sincpro/criteria), whatever raised it:
|
|
360
|
+
|
|
361
|
+
```ts
|
|
362
|
+
const failure = failureOf(error);
|
|
363
|
+
failure.kind; // "conflict", "unavailable", "domain", "unknown_outcome"…
|
|
364
|
+
failure.nextStep; // "refresh_and_retry", "retry_later", "authenticate", "do_not_retry"…
|
|
365
|
+
failure.retryable; // may sending it again succeed
|
|
366
|
+
failure.retryAfter; // seconds to wait first
|
|
367
|
+
failure.permanent; // every attempt fails the same way: set it aside
|
|
368
|
+
failure.fault; // the system's problem: reported
|
|
369
|
+
failure.aborted; // whoever asked stopped waiting
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
It reads, in order: an `AbortError`; what the error declares (`failureKind`, `reason`,
|
|
373
|
+
`retryAfter` — on itself or as a static of its class, as the backend's errors do); the `Problem`
|
|
374
|
+
the server sent, the same over REST, JSON-RPC and gRPC; a REST status. Not every wire is REST: a
|
|
375
|
+
JSON-RPC or gRPC transport — or another backend's adapter, such as Odoo's — makes errors that
|
|
376
|
+
declare their kind, with the backend's own tables:
|
|
377
|
+
|
|
378
|
+
```ts
|
|
379
|
+
class RpcError extends Error {
|
|
380
|
+
constructor(
|
|
381
|
+
readonly code: number,
|
|
382
|
+
message: string,
|
|
383
|
+
) {
|
|
384
|
+
super(message);
|
|
385
|
+
}
|
|
386
|
+
get failureKind() {
|
|
387
|
+
return kindOfJsonRpcCode(this.code); // kindOfGrpcCode, kindOfHttpStatus
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
How an error ended a use case follows from it (`outcomeOf`):
|
|
130
393
|
|
|
131
|
-
| Outcome | When
|
|
132
|
-
| --------- |
|
|
133
|
-
| `ok` | it answered
|
|
134
|
-
| `refused` | the
|
|
135
|
-
| `aborted` | whoever asked stopped waiting
|
|
136
|
-
| `failed` |
|
|
394
|
+
| Outcome | When | Reported |
|
|
395
|
+
| --------- | -------------------------------------------------------------------------- | -------- |
|
|
396
|
+
| `ok` | it answered | — |
|
|
397
|
+
| `refused` | the caller's answer: a 4xx, a declared kind (`domain`…), or `expectErrors` | no |
|
|
398
|
+
| `aborted` | whoever asked stopped waiting: an `AbortError`, a cancelled run | no |
|
|
399
|
+
| `failed` | a fault: a bug, a 5xx, a server nobody reached | yes |
|
|
137
400
|
|
|
138
401
|
`configureProcess({ outcomeOf })` replaces the process-wide rule. `reportError(error, where)`
|
|
139
402
|
reports what fails outside a use case — a view, a worker —, with the same rule.
|
|
140
403
|
|
|
404
|
+
### Cancelling a run
|
|
405
|
+
|
|
406
|
+
```ts
|
|
407
|
+
const screen = new AbortController();
|
|
408
|
+
const answer = salesBoundedContext.execute(new CommandQuote(3), { signal: screen.signal });
|
|
409
|
+
screen.abort(); // the run ends `aborted` at once; nothing is reported
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
Its Features inherit the signal, a service reads it as `this.signal`, and every request its bound
|
|
413
|
+
transport or generated client sends is cancelled with it — with nothing passed by hand.
|
|
414
|
+
|
|
415
|
+
### A circuit breaker
|
|
416
|
+
|
|
417
|
+
```ts
|
|
418
|
+
const odoo = new CircuitBreaker("odoo", { failures: 5, openFor: 30_000 });
|
|
419
|
+
const transport = guardedTransport(rpcTransport, odoo); // every call through the circuit
|
|
420
|
+
await odoo.call(() => printer.print(receipt)); // or one call; odoo.wrap(fn) for a function
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
The backend's `CircuitBreaker`, in this process's memory. After `failures` counted failures in a
|
|
424
|
+
row — the dependency did not answer (`unavailable`, `unknown_outcome`, `exhausted`, `internal`) —
|
|
425
|
+
it opens: calls fail at once with `CircuitOpen`, `unavailable` with the time left as its
|
|
426
|
+
`retryAfter`, so `failureOf`, a stored queue (which waits that long) and a screen already know
|
|
427
|
+
what to do. After `openFor`, one call tries: an answer closes it, a counted failure opens it
|
|
428
|
+
again. A refusal is an answer and never counts. Put it where the dependency is — the transport, an
|
|
429
|
+
adapter —, not in each Feature.
|
|
430
|
+
|
|
141
431
|
## Observability
|
|
142
432
|
|
|
143
433
|
The platform plugs the implementations once, at startup; nothing breaks without them:
|
|
@@ -176,21 +466,21 @@ import { logger, loggerAdapter } from "@sincpro/client-framework";
|
|
|
176
466
|
|
|
177
467
|
logger.info("Database opened"); // infrastructure — the global logger
|
|
178
468
|
loggerAdapter.warn("Printer is slow", { ms: 900 }); // an adapter
|
|
179
|
-
|
|
469
|
+
salesBoundedContext.logger.info("Catalog preloaded"); // a bounded context, outside a use case
|
|
180
470
|
this.logger.info("Totalling"); // inside a Feature or ApplicationService
|
|
181
471
|
```
|
|
182
472
|
|
|
183
|
-
| Logger
|
|
184
|
-
|
|
|
185
|
-
| `logger`
|
|
186
|
-
| `loggerAdapter`
|
|
187
|
-
| `
|
|
188
|
-
| `this.logger`, Feature
|
|
189
|
-
| `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 |
|
|
190
480
|
|
|
191
481
|
`this.logger` keeps its run across awaits and concurrent calls, without a global stack. An
|
|
192
482
|
adapter that imports `loggerAdapter` at module level writes without the run; inject it
|
|
193
|
-
(`
|
|
483
|
+
(`salesBoundedContext.addDependency("log", loggerAdapter)`) or pass `this.logger` when a line must carry it.
|
|
194
484
|
|
|
195
485
|
One configuration, two doors — `configureProcess({ logger })` replaces every process port,
|
|
196
486
|
`configureLogger(...)` only logging (an in-app debug switch):
|
|
@@ -218,8 +508,8 @@ snapshot, and what travels goes to the server in `sincpro-context`.
|
|
|
218
508
|
|
|
219
509
|
```ts
|
|
220
510
|
processContext.set({ tenant_id: "acme" });
|
|
221
|
-
|
|
222
|
-
await
|
|
511
|
+
salesBoundedContext.context.set({ warehouse_id: 4 });
|
|
512
|
+
await salesBoundedContext.scoped({ company_id: 2 }).execute(new CommandQuote(1));
|
|
223
513
|
```
|
|
224
514
|
|
|
225
515
|
Where the values live is a port (`ContextStore`): synchronous reads, a version per change and an
|
|
@@ -229,17 +519,67 @@ its own. `values` is the same object until it changes, so a screen can hand it t
|
|
|
229
519
|
|
|
230
520
|
## Talking to a server
|
|
231
521
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
522
|
+
A remote service is reached through a port. The generated client never names a wire: it says
|
|
523
|
+
which operation, with what input, and the adapter the application injects decides how it
|
|
524
|
+
travels.
|
|
525
|
+
|
|
526
|
+
```ts
|
|
527
|
+
import { clientOf } from "./generated/client";
|
|
528
|
+
|
|
529
|
+
// pick the wire; the client, the Features and the tests stay the same
|
|
530
|
+
const forge = clientOf(restOperations(restTransport({ base: "/v1", headers: session })));
|
|
531
|
+
// or clientOf(jsonRpcOperations({ endpoint: "/rpc", headers: session }))
|
|
532
|
+
// or clientOf(grpcOperations(channel)) — a platform's adapter, the same port
|
|
533
|
+
|
|
534
|
+
await forge.sendIssue({ issue_id: "acme-3", message: "hola" }); // ResponseSendIssue
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
| Piece | What it does |
|
|
538
|
+
| --------------------------------- | ------------------------------------------------------------------------------------- |
|
|
539
|
+
| `OperationTransport` port | `call(operation, input, options)`: an operation by who it is, over any wire |
|
|
540
|
+
| `restOperations(transport)` | over REST: the path from the input's fields, a Query's query, a Command's body |
|
|
541
|
+
| `jsonRpcOperations({ endpoint })` | over JSON-RPC 2.0: one `POST`, the operation's method, its input as named params |
|
|
542
|
+
| `restTransport({ base })` | REST over `fetch`: `Idempotency-Key`, the server's `Problem`, model versions, Parquet |
|
|
543
|
+
| `memoryTransport(tables)` | the same REST contract in memory, copying by the wire's rules — for tests and demos |
|
|
544
|
+
| `entity(key, routes)` | a model's routes and Commands, typed — what a generated `entities.ts` declares |
|
|
545
|
+
| `retryKeys()` | one `Idempotency-Key` per gesture, kept while a retry may repeat it |
|
|
546
|
+
| `guardedTransport(t, breaker)` | every call of a transport through a circuit breaker |
|
|
547
|
+
| `screensTransport(transport)` | screen reads and writes run as use cases of `screens`: traced and reported |
|
|
548
|
+
| `parquetDecoder` (`./parquet`) | large pages read as Parquet through the same transport |
|
|
549
|
+
|
|
550
|
+
Inside a Feature, inject the generated client as it is: the run binds it, so every call carries
|
|
551
|
+
its trace (`traceparent`), its context (`sincpro-context`) and its signal — as headers over HTTP,
|
|
552
|
+
as metadata where a platform's adapter puts them. `screensTransport` and `entity()` stay on the
|
|
553
|
+
REST `Transport`: they are how a screen reads pages of an entity, with its model and its drafts.
|
|
554
|
+
|
|
555
|
+
### Writing an adapter for another wire
|
|
556
|
+
|
|
557
|
+
An adapter is one function: read the operation's binding for its wire, send the input, answer the
|
|
558
|
+
answer, and fail with errors `failureOf` reads.
|
|
559
|
+
|
|
560
|
+
```ts
|
|
561
|
+
function grpcOperations(channel: Channel): OperationTransport {
|
|
562
|
+
return {
|
|
563
|
+
async call(operation, input, options) {
|
|
564
|
+
const method = operation.grpc?.method;
|
|
565
|
+
if (!method) throw new OperationNotPublished(operation.identity, "gRPC");
|
|
566
|
+
try {
|
|
567
|
+
return await channel.unary(method, input, {
|
|
568
|
+
metadata: options?.headers, // traceparent, sincpro-context
|
|
569
|
+
signal: options?.signal,
|
|
570
|
+
});
|
|
571
|
+
} catch (error) {
|
|
572
|
+
const code = (error as { code: number }).code;
|
|
573
|
+
throw Object.assign(error as Error, { failureKind: kindOfGrpcCode(code) });
|
|
574
|
+
}
|
|
575
|
+
},
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
```
|
|
240
579
|
|
|
241
|
-
|
|
242
|
-
|
|
580
|
+
Every operation in `operations` carries its identity and each wire's binding (`rest`, `rpc`,
|
|
581
|
+
`grpc`, `mcp`); an operation the adapter's wire does not publish is `OperationNotPublished`
|
|
582
|
+
(`not_found`), never guessed.
|
|
243
583
|
|
|
244
584
|
## Generated code
|
|
245
585
|
|
|
@@ -252,14 +592,18 @@ sincpro-client check --root src
|
|
|
252
592
|
|
|
253
593
|
`generate` writes, from the contract the backend exports — never edited, written again whole:
|
|
254
594
|
|
|
255
|
-
| File | What
|
|
256
|
-
| ------------- |
|
|
257
|
-
| `domain.ts` | a type per model, per `<Name>Input`, per operation's input and answer, per domain event
|
|
258
|
-
| `client.ts` | `clientOf(
|
|
259
|
-
| `entities.ts` | each entity's list, record, edit, export, Commands and history, for screens
|
|
595
|
+
| File | What |
|
|
596
|
+
| ------------- | --------------------------------------------------------------------------------------------- |
|
|
597
|
+
| `domain.ts` | a type per model, per `<Name>Input`, per operation's input and answer, per domain event |
|
|
598
|
+
| `client.ts` | `clientOf(operations)`: a method per operation, over any wire; `operations` and `events` data |
|
|
599
|
+
| `entities.ts` | each entity's list, record, edit, export, Commands and history, for screens |
|
|
260
600
|
|
|
261
|
-
|
|
262
|
-
|
|
601
|
+
Each method takes the operation's input and answers its answer, as `domain.ts` types them — a
|
|
602
|
+
search answers its `Response…` with its records and cursor, as the backend's callers get it, on
|
|
603
|
+
every wire. `scaffold` writes, once, the application's own files: its dependencies (the client
|
|
604
|
+
over the `operations` the application exports — `--operations <module>` —), its service bases
|
|
605
|
+
and `registerServices`. `check` enforces the application's layers and bounded-context
|
|
606
|
+
boundaries.
|
|
263
607
|
|
|
264
608
|
**Domain events, on demand.** A backend leaves its events out of the contract unless it is
|
|
265
609
|
exported with `--events`. Then each event is a type as it is received — every field present,
|
|
@@ -335,13 +679,52 @@ never a `Date`. `valuesOf`/`pack` write; `readValues` reads tagged values back;
|
|
|
335
679
|
use a reader given explicitly. Cycles, functions and non-finite numbers are refused. How a value
|
|
336
680
|
is shown, typed or stored is the platform's.
|
|
337
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
|
+
|
|
338
720
|
## Package layout
|
|
339
721
|
|
|
340
722
|
Each module is laid out as sincpro_framework's are, and says what it offers in its `index.ts`:
|
|
341
723
|
|
|
342
724
|
```text
|
|
343
725
|
src/
|
|
344
|
-
common/ the values of the wire
|
|
726
|
+
common/ the values of the wire, and what kind of failure an error is
|
|
727
|
+
circuit_breaker/ CircuitBreaker, CircuitOpen
|
|
345
728
|
context/
|
|
346
729
|
domain/ ContextStore and ContextCodec ports, what travels, Execution
|
|
347
730
|
adapters/ memoryContexts, plainCodec
|
|
@@ -353,15 +736,22 @@ src/
|
|
|
353
736
|
infrastructure/ the ports this process runs with
|
|
354
737
|
services/ logger, loggerAdapter, reportError, outcomeOf
|
|
355
738
|
client/
|
|
356
|
-
domain/ Transport, TransportError, entity(),
|
|
357
|
-
|
|
358
|
-
|
|
739
|
+
domain/ OperationTransport, RemoteOperation, Transport, TransportError, entity(),
|
|
740
|
+
the client contract
|
|
741
|
+
adapters/ restOperations, jsonRpcOperations, rest, memory, parquet (optional entry)
|
|
742
|
+
services/ discovery, retry keys, propagation of trace, context and signal,
|
|
743
|
+
guardedTransport
|
|
359
744
|
model/ defineMeta, metaFromSchema, metaStore
|
|
360
745
|
core/
|
|
361
|
-
domain/ Command, Response, Feature, ApplicationService, FeatureBus,
|
|
362
|
-
|
|
746
|
+
domain/ Command, DomainEvent, Response, Feature, ApplicationService, FeatureBus,
|
|
747
|
+
ErrorHandler, the pipeline's extension points
|
|
748
|
+
services/ the execution pipeline, interceptors, dependency binding, the handler chain
|
|
363
749
|
entrypoint/ BoundedContext, screensTransport
|
|
364
|
-
infrastructure/ configureProcess, configureLogger
|
|
750
|
+
infrastructure/ configureProcess, configureLogger, processListeners
|
|
751
|
+
event_driven/
|
|
752
|
+
domain/ Envelope, the EventQueue, Subscribes and EventStore ports
|
|
753
|
+
adapters/ syncQueue, storedQueue, memoryEventStore
|
|
754
|
+
entrypoint/ Publisher, Subscriber (the queue's door), Subscriptions, EventSubscriber
|
|
365
755
|
codegen/ the generator and its CLI — the only place Node is allowed
|
|
366
756
|
testing/ memoryTracer, memoryReporter, memoryLogger
|
|
367
757
|
```
|
|
@@ -397,8 +787,19 @@ The runtime uses `fetch`, `AbortSignal`, `URL`, `crypto.getRandomValues`, `Proxy
|
|
|
397
787
|
|
|
398
788
|
Read [`MANIFESTO.md`](MANIFESTO.md) (what this package is and is not), [`AGENTS.md`](AGENTS.md)
|
|
399
789
|
(how to change it), [`CONTRIBUTING.md`](CONTRIBUTING.md),
|
|
400
|
-
[`docs/UBIQUITOUS_LANGUAGE.md`](docs/UBIQUITOUS_LANGUAGE.md)
|
|
401
|
-
[`docs/MOBILE.md`](docs/MOBILE.md). Versions are written by the release pipeline.
|
|
790
|
+
[`docs/UBIQUITOUS_LANGUAGE.md`](docs/UBIQUITOUS_LANGUAGE.md),
|
|
791
|
+
[`docs/WEB.md`](docs/WEB.md) and [`docs/MOBILE.md`](docs/MOBILE.md). Versions are written by the release pipeline.
|
|
792
|
+
|
|
793
|
+
## For consumers: the skill
|
|
794
|
+
|
|
795
|
+
[`.claude/skills/sincpro-client-framework/SKILL.md`](.claude/skills/sincpro-client-framework/SKILL.md)
|
|
796
|
+
is the guide for whoever builds on this package — an application, @sincpro/web-framework,
|
|
797
|
+
@sincpro/mobile — and for the agents that help them: the rule for what goes here, in a platform's
|
|
798
|
+
framework or in an application; the vocabulary; how an application is laid out; how to start the
|
|
799
|
+
process, reach the services over any wire, write a use case, decide what a screen does with a
|
|
800
|
+
failure, publish and subscribe to events, extend the pipeline, write a platform's adapter for each
|
|
801
|
+
port, and test; and the mistakes to avoid. It follows the format of sincpro_framework's skills, so
|
|
802
|
+
the shared knowledge base can carry it beside them.
|
|
402
803
|
|
|
403
804
|
## License
|
|
404
805
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { FailureKind } from "@sincpro/criteria";
|
|
2
|
+
/**
|
|
3
|
+
* Where a circuit stands — for a health screen or a test; a call never needs it.
|
|
4
|
+
*
|
|
5
|
+
* - `closed`: calls go through.
|
|
6
|
+
* - `open`: calls fail at once (`CircuitOpen`) until its time passes.
|
|
7
|
+
* - `half_open`: its time passed; one call goes through to try, the others still fail at once.
|
|
8
|
+
*/
|
|
9
|
+
export type CircuitState = "closed" | "open" | "half_open";
|
|
10
|
+
/**
|
|
11
|
+
* The kinds a circuit counts by default — the backend's `COUNTED`: the dependency did not answer,
|
|
12
|
+
* may not have, asked to be left alone, or failed in a way nobody declared. Every other kind is
|
|
13
|
+
* an answer: a refusal is the dependency working.
|
|
14
|
+
*/
|
|
15
|
+
export declare const COUNTED_KINDS: ReadonlySet<FailureKind>;
|
|
16
|
+
/** Whether a failure says the dependency is not answering — not what it answered. */
|
|
17
|
+
export declare function countedByDefault(error: unknown): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* The dependency a circuit guards failed too often: the call was not made. It declares itself
|
|
20
|
+
* `unavailable` with its wait, so a retry, a stored queue and a screen already know what to do.
|
|
21
|
+
*/
|
|
22
|
+
export declare class CircuitOpen extends Error {
|
|
23
|
+
readonly circuit: string;
|
|
24
|
+
static readonly failureKind = "unavailable";
|
|
25
|
+
readonly reason = "CIRCUIT_OPEN";
|
|
26
|
+
/** Seconds to wait before trying again — one at least. */
|
|
27
|
+
readonly retryAfter: number;
|
|
28
|
+
constructor(circuit: string, retryAfterSeconds: number);
|
|
29
|
+
}
|