@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.
Files changed (84) hide show
  1. package/AGENTS.md +71 -13
  2. package/MANIFESTO.md +59 -14
  3. package/README.md +456 -55
  4. package/dist/circuit_breaker/domain/circuit.d.ts +29 -0
  5. package/dist/circuit_breaker/domain/circuit.js +37 -0
  6. package/dist/circuit_breaker/entrypoint/circuit-breaker.d.ts +56 -0
  7. package/dist/circuit_breaker/entrypoint/circuit-breaker.js +94 -0
  8. package/dist/circuit_breaker/index.d.ts +9 -0
  9. package/dist/circuit_breaker/index.js +9 -0
  10. package/dist/client/adapters/json-rpc.d.ts +40 -0
  11. package/dist/client/adapters/json-rpc.js +97 -0
  12. package/dist/client/adapters/rest-operations.d.ts +19 -0
  13. package/dist/client/adapters/rest-operations.js +66 -0
  14. package/dist/client/domain/operation.d.ts +73 -0
  15. package/dist/client/domain/operation.js +19 -0
  16. package/dist/client/index.d.ts +5 -1
  17. package/dist/client/index.js +5 -1
  18. package/dist/client/services/guarded.d.ts +17 -0
  19. package/dist/client/services/guarded.js +19 -0
  20. package/dist/client/services/propagation.d.ts +20 -7
  21. package/dist/client/services/propagation.js +44 -15
  22. package/dist/codegen/entrypoint/cli.js +7 -7
  23. package/dist/codegen/services/client.d.ts +7 -8
  24. package/dist/codegen/services/client.js +50 -148
  25. package/dist/codegen/services/scaffold.d.ts +7 -4
  26. package/dist/codegen/services/scaffold.js +23 -27
  27. package/dist/common/failures.d.ts +83 -0
  28. package/dist/common/failures.js +212 -0
  29. package/dist/common/index.d.ts +1 -0
  30. package/dist/common/index.js +1 -0
  31. package/dist/context/domain/execution.d.ts +12 -0
  32. package/dist/context/entrypoint/context.d.ts +5 -4
  33. package/dist/core/domain/bus.d.ts +16 -7
  34. package/dist/core/domain/bus.js +14 -6
  35. package/dist/core/domain/dto.d.ts +33 -3
  36. package/dist/core/domain/dto.js +24 -1
  37. package/dist/core/domain/errors.d.ts +2 -2
  38. package/dist/core/domain/pipeline.d.ts +89 -0
  39. package/dist/core/domain/pipeline.js +1 -0
  40. package/dist/core/domain/services.d.ts +16 -5
  41. package/dist/core/domain/services.js +5 -2
  42. package/dist/core/entrypoint/bounded-context.d.ts +96 -16
  43. package/dist/core/entrypoint/bounded-context.js +244 -39
  44. package/dist/core/index.d.ts +4 -2
  45. package/dist/core/index.js +3 -2
  46. package/dist/core/infrastructure/listeners.d.ts +28 -0
  47. package/dist/core/infrastructure/listeners.js +34 -0
  48. package/dist/core/services/binding.d.ts +2 -2
  49. package/dist/core/services/binding.js +6 -3
  50. package/dist/core/services/execution.d.ts +59 -12
  51. package/dist/core/services/execution.js +114 -19
  52. package/dist/core/services/interceptors.d.ts +15 -0
  53. package/dist/core/services/interceptors.js +25 -0
  54. package/dist/core/services/listening.d.ts +17 -0
  55. package/dist/core/services/listening.js +25 -0
  56. package/dist/event_driven/adapters/memory-event-store.d.ts +10 -0
  57. package/dist/event_driven/adapters/memory-event-store.js +23 -0
  58. package/dist/event_driven/adapters/stored-queue.d.ts +68 -0
  59. package/dist/event_driven/adapters/stored-queue.js +193 -0
  60. package/dist/event_driven/adapters/sync-queue.d.ts +11 -0
  61. package/dist/event_driven/adapters/sync-queue.js +19 -0
  62. package/dist/event_driven/domain/envelope.d.ts +28 -0
  63. package/dist/event_driven/domain/envelope.js +19 -0
  64. package/dist/event_driven/domain/queue.d.ts +40 -0
  65. package/dist/event_driven/domain/queue.js +1 -0
  66. package/dist/event_driven/domain/store.d.ts +56 -0
  67. package/dist/event_driven/domain/store.js +1 -0
  68. package/dist/event_driven/entrypoint/publisher.d.ts +26 -0
  69. package/dist/event_driven/entrypoint/publisher.js +35 -0
  70. package/dist/event_driven/entrypoint/subscriber.d.ts +42 -0
  71. package/dist/event_driven/entrypoint/subscriber.js +62 -0
  72. package/dist/event_driven/entrypoint/subscriptions.d.ts +110 -0
  73. package/dist/event_driven/entrypoint/subscriptions.js +184 -0
  74. package/dist/event_driven/index.d.ts +22 -0
  75. package/dist/event_driven/index.js +20 -0
  76. package/dist/index.d.ts +7 -2
  77. package/dist/index.js +7 -2
  78. package/dist/observability/adapters/memory.d.ts +1 -1
  79. package/dist/observability/domain/logging.d.ts +10 -4
  80. package/dist/observability/services/logging.d.ts +7 -4
  81. package/dist/observability/services/logging.js +3 -2
  82. package/dist/observability/services/outcome.d.ts +6 -6
  83. package/dist/observability/services/outcome.js +10 -13
  84. 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 sales = new BoundedContext<{ unitPrice: number }>("sales");
67
- sales.addDependency("unitPrice", 10);
68
- sales.feature(CommandTotal)(Total);
69
- sales.appService(CommandQuote)(Quote);
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 sales.execute(new CommandQuote(3)); // ResponseTotal — total 30
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
- @sales.feature(CommandTotal)
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: `sales.feature(CommandTotal)(Total)`. No DI library
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
- sales.addFeatureErrorHandler((error) => {
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
- sales.addAppServiceErrorHandler(toBusinessError);
118
- sales.addGlobalErrorHandler(toProblem); // what reaches sales.execute or a scope
344
+ salesBoundedContext.addAppServiceErrorHandler(toBusinessError);
345
+ salesBoundedContext.addGlobalErrorHandler(toProblem); // what reaches salesBoundedContext.execute or a scope
119
346
 
120
- sales.expectErrors(QuoteExpired); // the business answering: `refused`, never reported
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
- How an error ended a use case:
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 | Reported |
132
- | --------- | ---------------------------------------------------------------------------- | -------- |
133
- | `ok` | it answered | — |
134
- | `refused` | the server answered a 4xx (`TransportError`), or the class is `expectErrors` | no |
135
- | `aborted` | whoever asked stopped waiting (`AbortError`) | no |
136
- | `failed` | anything else: a bug, a 5xx, a server nobody reached | yes |
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
- sales.logger.info("Catalog preloaded"); // a bounded context, outside a use case
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 | Layer | Each line carries |
184
- | ------------------------- | --------------------- | ---------------------------------------------- |
185
- | `logger` | `infrastructure` | — |
186
- | `loggerAdapter` | `adapter` | the run, when injected as a dependency |
187
- | `sales.logger` | `bounded_context` | its bounded context |
188
- | `this.logger`, Feature | `feature` | bounded context, use case, execution and trace |
189
- | `this.logger`, AppService | `application_service` | the same |
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
- (`sales.addDependency("log", loggerAdapter)`) or pass `this.logger` when a line must carry it.
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
- sales.context.set({ warehouse_id: 4 });
222
- await sales.scoped({ company_id: 2 }).execute(new CommandQuote(1));
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
- | Piece | What it does |
233
- | ------------------------------ | ------------------------------------------------------------------------------------- |
234
- | `restTransport({ base })` | REST over `fetch`: `Idempotency-Key`, the server's `Problem`, model versions, Parquet |
235
- | `memoryTransport(tables)` | the same contract in memory, copying by the wire's rules — for tests and demos |
236
- | `entity(key, routes)` | a model's routes and Commands, typed — what a generated `entities.ts` declares |
237
- | `retryKeys()` | one `Idempotency-Key` per gesture, kept while a retry may repeat it |
238
- | `screensTransport(transport)` | screen reads and writes run as use cases of `screens`: traced and reported |
239
- | `parquetDecoder` (`./parquet`) | large pages read as Parquet through the same transport |
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
- Inside a Feature, inject the transport or the generated client as they are — the run already
242
- observes them. `screensTransport` is for what a screen asks directly, outside any Feature.
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(transport)`: a method per operation REST reaches; `operations` and `events` data |
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
- `scaffold` writes, once, the application's own files: its dependencies, its service bases and
262
- `registerServices`. `check` enforces the application's layers and bounded-context boundaries.
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(), the client contract
357
- adapters/ rest, memory, parquet (optional entry)
358
- services/ discovery, retry keys, propagation of trace and context
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, ErrorHandler
362
- services/ the execution pipeline, dependency binding, the handler chain
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) and
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
+ }