@sincpro/client-framework 0.6.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. package/AGENTS.md +95 -25
  2. package/MANIFESTO.md +103 -40
  3. package/README.md +608 -333
  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/{memory.d.ts → adapters/memory.d.ts} +1 -1
  13. package/dist/client/{memory.js → adapters/memory.js} +2 -2
  14. package/dist/{adapters → client/adapters}/parquet.d.ts +1 -1
  15. package/dist/client/adapters/rest-operations.d.ts +19 -0
  16. package/dist/client/adapters/rest-operations.js +66 -0
  17. package/dist/client/{rest.d.ts → adapters/rest.d.ts} +1 -1
  18. package/dist/client/{rest.js → adapters/rest.js} +2 -2
  19. package/dist/client/{entity.d.ts → domain/entity.d.ts} +6 -10
  20. package/dist/client/domain/operation.d.ts +73 -0
  21. package/dist/client/domain/operation.js +19 -0
  22. package/dist/client/index.d.ts +20 -0
  23. package/dist/client/index.js +20 -0
  24. package/dist/client/services/guarded.d.ts +17 -0
  25. package/dist/client/services/guarded.js +19 -0
  26. package/dist/client/services/propagation.d.ts +45 -0
  27. package/dist/{services/execution.js → client/services/propagation.js} +49 -36
  28. package/dist/client/{retry.js → services/retry.js} +1 -1
  29. package/dist/codegen/{metamodel.d.ts → domain/metamodel.d.ts} +17 -2
  30. package/dist/codegen/{names.d.ts → domain/names.d.ts} +2 -2
  31. package/dist/codegen/{names.js → domain/names.js} +3 -3
  32. package/dist/codegen/{cli.js → entrypoint/cli.js} +15 -14
  33. package/dist/codegen/index.d.ts +7 -7
  34. package/dist/codegen/index.js +5 -10
  35. package/dist/codegen/services/client.d.ts +15 -0
  36. package/dist/codegen/services/client.js +111 -0
  37. package/dist/codegen/services/domain.d.ts +20 -0
  38. package/dist/codegen/{domain.js → services/domain.js} +82 -25
  39. package/dist/codegen/{entities.d.ts → services/entities.d.ts} +4 -3
  40. package/dist/codegen/{entities.js → services/entities.js} +18 -4
  41. package/dist/codegen/{rest.d.ts → services/rest.d.ts} +1 -1
  42. package/dist/codegen/{scaffold.d.ts → services/scaffold.d.ts} +8 -5
  43. package/dist/codegen/{scaffold.js → services/scaffold.js} +39 -43
  44. package/dist/common/failures.d.ts +83 -0
  45. package/dist/common/failures.js +212 -0
  46. package/dist/common/index.d.ts +11 -0
  47. package/dist/common/index.js +11 -0
  48. package/dist/context/adapters/memory.d.ts +6 -0
  49. package/dist/context/adapters/memory.js +34 -0
  50. package/dist/context/adapters/plain-codec.d.ts +3 -0
  51. package/dist/context/adapters/plain-codec.js +16 -0
  52. package/dist/context/{codec.d.ts → domain/codec.d.ts} +0 -1
  53. package/dist/context/{codec.js → domain/codec.js} +0 -14
  54. package/dist/context/domain/execution.d.ts +45 -0
  55. package/dist/context/domain/execution.js +11 -0
  56. package/dist/context/{store.d.ts → domain/store.d.ts} +0 -5
  57. package/dist/context/{store.js → domain/store.js} +1 -34
  58. package/dist/context/{context.d.ts → entrypoint/context.d.ts} +1 -1
  59. package/dist/context/index.d.ts +17 -0
  60. package/dist/context/index.js +15 -0
  61. package/dist/context/infrastructure/process.d.ts +13 -0
  62. package/dist/context/infrastructure/process.js +35 -0
  63. package/dist/core/domain/bus.d.ts +27 -0
  64. package/dist/core/domain/bus.js +26 -0
  65. package/dist/core/domain/dto.d.ts +62 -0
  66. package/dist/core/domain/dto.js +43 -0
  67. package/dist/core/domain/errors.d.ts +18 -0
  68. package/dist/core/domain/errors.js +1 -0
  69. package/dist/core/domain/pipeline.d.ts +89 -0
  70. package/dist/core/domain/pipeline.js +1 -0
  71. package/dist/core/domain/services.d.ts +47 -0
  72. package/dist/core/domain/services.js +31 -0
  73. package/dist/core/entrypoint/bounded-context.d.ts +139 -0
  74. package/dist/core/entrypoint/bounded-context.js +426 -0
  75. package/dist/core/entrypoint/screens.d.ts +6 -0
  76. package/dist/core/entrypoint/screens.js +136 -0
  77. package/dist/core/index.d.ts +19 -0
  78. package/dist/core/index.js +17 -0
  79. package/dist/core/infrastructure/listeners.d.ts +28 -0
  80. package/dist/core/infrastructure/listeners.js +34 -0
  81. package/dist/core/infrastructure/process.d.ts +56 -0
  82. package/dist/core/infrastructure/process.js +35 -0
  83. package/dist/core/services/binding.d.ts +6 -0
  84. package/dist/core/services/binding.js +16 -0
  85. package/dist/core/services/error-handlers.d.ts +11 -0
  86. package/dist/core/services/error-handlers.js +21 -0
  87. package/dist/core/services/execution.d.ts +66 -0
  88. package/dist/core/services/execution.js +216 -0
  89. package/dist/core/services/interceptors.d.ts +15 -0
  90. package/dist/core/services/interceptors.js +25 -0
  91. package/dist/core/services/listening.d.ts +17 -0
  92. package/dist/core/services/listening.js +25 -0
  93. package/dist/event_driven/adapters/memory-event-store.d.ts +10 -0
  94. package/dist/event_driven/adapters/memory-event-store.js +23 -0
  95. package/dist/event_driven/adapters/stored-queue.d.ts +67 -0
  96. package/dist/event_driven/adapters/stored-queue.js +192 -0
  97. package/dist/event_driven/adapters/sync-queue.d.ts +11 -0
  98. package/dist/event_driven/adapters/sync-queue.js +19 -0
  99. package/dist/event_driven/domain/envelope.d.ts +28 -0
  100. package/dist/event_driven/domain/envelope.js +19 -0
  101. package/dist/event_driven/domain/queue.d.ts +40 -0
  102. package/dist/event_driven/domain/queue.js +1 -0
  103. package/dist/event_driven/domain/store.d.ts +56 -0
  104. package/dist/event_driven/domain/store.js +1 -0
  105. package/dist/event_driven/entrypoint/publisher.d.ts +26 -0
  106. package/dist/event_driven/entrypoint/publisher.js +35 -0
  107. package/dist/event_driven/entrypoint/subscriber.d.ts +34 -0
  108. package/dist/event_driven/entrypoint/subscriber.js +51 -0
  109. package/dist/event_driven/index.d.ts +19 -0
  110. package/dist/event_driven/index.js +17 -0
  111. package/dist/index.d.ts +21 -38
  112. package/dist/index.js +21 -38
  113. package/dist/model/index.d.ts +10 -0
  114. package/dist/model/index.js +10 -0
  115. package/dist/model/{metas.d.ts → infrastructure/metas.d.ts} +1 -1
  116. package/dist/model/{schema.d.ts → services/schema.d.ts} +1 -1
  117. package/dist/model/{schema.js → services/schema.js} +1 -1
  118. package/dist/observability/adapters/console.d.ts +12 -0
  119. package/dist/observability/adapters/console.js +35 -0
  120. package/dist/observability/adapters/memory.d.ts +41 -0
  121. package/dist/observability/adapters/memory.js +49 -0
  122. package/dist/{adapters → observability/adapters}/opentelemetry.d.ts +1 -1
  123. package/dist/{adapters → observability/adapters}/sentry.d.ts +1 -1
  124. package/dist/observability/domain/errors.d.ts +23 -0
  125. package/dist/observability/domain/errors.js +8 -0
  126. package/dist/observability/domain/logging.d.ts +44 -0
  127. package/dist/observability/domain/logging.js +1 -0
  128. package/dist/observability/domain/tracing.d.ts +49 -0
  129. package/dist/observability/domain/tracing.js +20 -0
  130. package/dist/observability/index.d.ts +16 -0
  131. package/dist/observability/index.js +4 -0
  132. package/dist/observability/infrastructure/registry.d.ts +21 -0
  133. package/dist/observability/infrastructure/registry.js +18 -0
  134. package/dist/observability/services/logging.d.ts +27 -0
  135. package/dist/observability/services/logging.js +106 -0
  136. package/dist/observability/services/outcome.d.ts +12 -0
  137. package/dist/observability/services/outcome.js +17 -0
  138. package/dist/{services/report.js → observability/services/reporting.js} +4 -3
  139. package/dist/testing/index.d.ts +3 -33
  140. package/dist/testing/index.js +5 -37
  141. package/package.json +16 -8
  142. package/dist/codegen/client.d.ts +0 -14
  143. package/dist/codegen/client.js +0 -201
  144. package/dist/codegen/domain.d.ts +0 -15
  145. package/dist/observability/ports.d.ts +0 -65
  146. package/dist/observability/ports.js +0 -21
  147. package/dist/services/bounded-context.d.ts +0 -79
  148. package/dist/services/bounded-context.js +0 -261
  149. package/dist/services/execution.d.ts +0 -63
  150. package/dist/services/feature.d.ts +0 -85
  151. package/dist/services/feature.js +0 -86
  152. package/dist/services/process.d.ts +0 -60
  153. package/dist/services/process.js +0 -57
  154. package/dist/services/screens.d.ts +0 -2
  155. package/dist/services/screens.js +0 -76
  156. /package/dist/{adapters → client/adapters}/parquet.js +0 -0
  157. /package/dist/client/{contract.d.ts → domain/contract.d.ts} +0 -0
  158. /package/dist/client/{contract.js → domain/contract.js} +0 -0
  159. /package/dist/client/{entity.js → domain/entity.js} +0 -0
  160. /package/dist/client/{transport.d.ts → domain/transport.d.ts} +0 -0
  161. /package/dist/client/{transport.js → domain/transport.js} +0 -0
  162. /package/dist/client/{discovery.d.ts → services/discovery.d.ts} +0 -0
  163. /package/dist/client/{discovery.js → services/discovery.js} +0 -0
  164. /package/dist/client/{retry.d.ts → services/retry.d.ts} +0 -0
  165. /package/dist/codegen/{metamodel.js → domain/metamodel.js} +0 -0
  166. /package/dist/codegen/{cli.d.ts → entrypoint/cli.d.ts} +0 -0
  167. /package/dist/codegen/{check.d.ts → services/check.d.ts} +0 -0
  168. /package/dist/codegen/{check.js → services/check.js} +0 -0
  169. /package/dist/codegen/{rest.js → services/rest.js} +0 -0
  170. /package/dist/context/{context.js → entrypoint/context.js} +0 -0
  171. /package/dist/model/{meta.d.ts → domain/meta.d.ts} +0 -0
  172. /package/dist/model/{meta.js → domain/meta.js} +0 -0
  173. /package/dist/model/{metas.js → infrastructure/metas.js} +0 -0
  174. /package/dist/{adapters → observability/adapters}/opentelemetry.js +0 -0
  175. /package/dist/{adapters → observability/adapters}/sentry.js +0 -0
  176. /package/dist/{services/report.d.ts → observability/services/reporting.d.ts} +0 -0
package/README.md CHANGED
@@ -1,425 +1,700 @@
1
1
  # @sincpro/client-framework
2
2
 
3
- **The client side of [sincpro_framework](https://github.com/Sincpro-SRL/sincpro_framework), for
4
- every platform.** Write a use case once — a `Feature`, or an `ApplicationService` that composes
5
- Features — and run it in a browser, on a phone, in a server-side render, in a script or in a test.
6
- Every use case is traced and every unexpected error reported, continuing the backend's trace,
7
- without its author writing either. A service's client contract becomes typed code for each of
8
- its bounded contexts.
9
-
10
- > **Status: 0.x.** The API may still change before 1.0. In: the client, the generator, the
11
- > bounded context with its `Feature` and `ApplicationService`, the process (tracing, error
12
- > reporting, the context) and its ports. Read the [manifesto](MANIFESTO.md) for what this package
13
- > is responsible for, what it is not and where it is going, and the
14
- > [ubiquitous language](docs/UBIQUITOUS_LANGUAGE.md) every Sincpro framework shares.
15
-
16
- ## What it gives, at a glance
17
-
18
- ```mermaid
19
- classDiagram
20
- direction LR
21
- class UseBoundedContext {
22
- +name
23
- +context : Context
24
- +addDependency(name, value)
25
- +register(features, applicationServices) BoundedContext
26
- }
27
- class BoundedContext {
28
- +Issues : the methods of Issues
29
- +CloseAndNotify : the methods of CloseAndNotify
30
- +context : Context
31
- +with(dependencies) BoundedContext
32
- +scoped(context) BoundedContext
33
- }
34
- class Feature {
35
- #client, session : each dependency
36
- #context : what this run executes with
37
- }
38
- class ApplicationService {
39
- #feature : Issues, Notices
40
- }
41
- class Context {
42
- +values
43
- +get(name)
44
- +set(values)
45
- +clear(names)
46
- +watch(listener)
47
- }
48
- class ContextStore {
49
- <<port>>
50
- +load(key)
51
- +save(key, values)
52
- +delete(key)
53
- +version(key)
54
- +watch(key, listener)
55
- }
56
- class Tracer {
57
- <<port>>
58
- +start(name, parent, attributes) Span
3
+ The client side of [sincpro_framework](https://github.com/Sincpro-SRL/sincpro_framework), for
4
+ the web, React Native, scripts and tests: the same application layer as the backend —
5
+ Commands, Features, ApplicationServices and a bounded context that is their bus —, observed
6
+ the same way, and a typed client generated from a service's contract. No React, no DOM, no
7
+ platform storage: what a platform must give, it gives through a port.
8
+
9
+ - [A bounded context](#a-bounded-context)
10
+ - [Domain events](#domain-events)
11
+ - [Extending the pipeline](#extending-the-pipeline)
12
+ - [Errors](#errors)
13
+ - [Observability](#observability)
14
+ - [Context](#context)
15
+ - [Talking to a server](#talking-to-a-server)
16
+ - [Generated code](#generated-code)
17
+ - [Values on the wire](#values-on-the-wire)
18
+ - [Package layout](#package-layout)
19
+ - [Development](#development)
20
+
21
+ ## A bounded context
22
+
23
+ ```ts
24
+ import {
25
+ ApplicationService,
26
+ BoundedContext,
27
+ Command,
28
+ Feature,
29
+ Response,
30
+ } from "@sincpro/client-framework";
31
+
32
+ class ResponseTotal extends Response {
33
+ constructor(readonly total: number) {
34
+ super();
59
35
  }
60
- class ErrorReporter {
61
- <<port>>
62
- +report(error, about)
36
+ }
37
+
38
+ class CommandTotal extends Command<ResponseTotal> {
39
+ constructor(readonly quantity: number) {
40
+ super();
63
41
  }
64
- class Transport {
65
- <<port>>
66
- +list(route, criteria)
67
- +one(route)
68
- +send(method, route, body)
42
+ }
43
+
44
+ class CommandQuote extends Command<ResponseTotal> {
45
+ constructor(readonly quantity: number) {
46
+ super();
69
47
  }
70
- UseBoundedContext --> BoundedContext : register
71
- BoundedContext o-- Feature : each by its name
72
- Feature <|-- ApplicationService
73
- BoundedContext --> Context : its level
74
- Context --> ContextStore : kept in
75
- BoundedContext ..> Tracer : a span per use case
76
- BoundedContext ..> ErrorReporter : what nobody expected
77
- Feature ..> Transport : through its generated client
78
- ```
48
+ }
79
49
 
80
- `configureProcess({ tracer, reporter, context })` plugs the ports once; `processContext` is the
81
- process's level of the context. Beside them: the generated client (`sincpro-client generate`),
82
- `entity()`, model definitions (`defineMeta`, `metaFromSchema`) and client-side ids.
83
-
84
- ## A bounded context, set up like the backend's
85
-
86
- Each bounded context of an application has the layout sincpro_framework gives a service: what its
87
- services can count on, its bases, its services, and the one object the rest of the application
88
- uses it through.
89
-
90
- ```mermaid
91
- flowchart LR
92
- subgraph py["sincpro_framework · Python"]
93
- direction TB
94
- pd["infrastructure/dependencies.py<br/>DependencyContextType · add_dependency"]
95
- pf["infrastructure/framework.py<br/>Feature · ApplicationService · UseFramework"]
96
- ps["services/<br/>@bus.feature · @bus.app_service"]
97
- pi["__init__.py<br/>the bounded context"]
98
- pd --> pf --> ps --> pi
99
- end
100
- subgraph ts["@sincpro/client-framework · TypeScript"]
101
- direction TB
102
- td["infrastructure/dependencies.ts<br/>ProjectDependencies · addDependency"]
103
- tf["infrastructure/bounded_context.ts<br/>Feature · ApplicationService · UseBoundedContext"]
104
- tsv["services/<br/>features · applicationServices"]
105
- ti["index.ts<br/>export const project"]
106
- td --> tf --> tsv --> ti
107
- end
108
- ti --> users["a screen · another bounded context · a test<br/>project.Issues.close(input)"]
109
- pi -. "the client contract" .-> td
110
- ```
50
+ class Total extends Feature<CommandTotal, ResponseTotal> {
51
+ static readonly serviceName = "Total";
52
+ declare readonly unitPrice: number; // a dependency, typed
111
53
 
112
- ```ts
113
- // domains/project/infrastructure/dependencies.ts — what the services can count on
114
- export interface ProjectDependencies {
115
- client: ProjectClient; // generated from the service's contract
116
- session: Session;
54
+ execute(command: CommandTotal): ResponseTotal {
55
+ this.logger.info("Totalling", { quantity: command.quantity });
56
+ return new ResponseTotal(command.quantity * this.unitPrice);
57
+ }
117
58
  }
118
59
 
119
- export function registerDependencies(bc: UseBoundedContext<ProjectDependencies>) {
120
- bc.addDependency("client", clientOf(transport));
121
- bc.addDependency("session", session);
122
- return bc;
60
+ class Quote extends ApplicationService<CommandQuote, ResponseTotal> {
61
+ static readonly serviceName = "Quote";
62
+
63
+ execute(command: CommandQuote): Promise<ResponseTotal> {
64
+ return this.featureBus.execute(new CommandTotal(command.quantity));
65
+ }
123
66
  }
67
+
68
+ const sales = new BoundedContext<{ unitPrice: number }>("sales");
69
+ sales.addDependency("unitPrice", 10);
70
+ sales.feature(CommandTotal)(Total);
71
+ sales.appService(CommandQuote)(Quote);
72
+
73
+ const answer = await sales.execute(new CommandQuote(3)); // ResponseTotal — total 30
124
74
  ```
125
75
 
76
+ A Command names, by its type, the Response it answers: `execute` infers it, with no second
77
+ argument (the backend's `bus(dto, ResponseType)` is accepted too, and checked in TypeScript
78
+ only). A Response may be a class, a plain object, a page, a primitive or `void`.
79
+
80
+ - **One Command, one service.** A Command class is registered once, across both layers, and
81
+ dispatched by its exact constructor. `serviceName` names the service in spans and logs —
82
+ stable, where a minifier renames classes.
83
+ - **Dependencies are attributes.** `addDependency` supplies them; a service declares their types
84
+ (`declare readonly client: …`), usually once on a base class per bounded context, as
85
+ `sincpro-client scaffold` writes it. Declaring registers nothing; reading a missing one fails.
86
+ A replacement names what it replaces: `addDependency(name, next, { replaces: previous })`.
87
+ - **A service is a singleton of its generation.** What belongs to one request goes in the
88
+ Command or the context, never on `this`. Use TypeScript `private`, not `#private`: a run sees
89
+ the instance through a view of its own.
90
+ - **Generations.** The first call (or `buildRootBus()`) builds one; what is registered later
91
+ answers from the next call, and a running call keeps its own. `fresh()` copies the
92
+ registrations; `scoped({ company_id: 2 })` runs with more context and the same instances.
93
+ - **Composition.** An ApplicationService runs its context's Features through `this.featureBus`,
94
+ as children of its own execution; never another ApplicationService.
95
+
96
+ ### Decorators, when the project has them
97
+
126
98
  ```ts
127
- // domains/project/infrastructure/bounded_context.ts
128
- import {
129
- ApplicationService as BaseApplicationService,
130
- Feature as BaseFeature,
131
- UseBoundedContext,
132
- } from "@sincpro/client-framework";
99
+ @sales.feature(CommandTotal)
100
+ class Total extends Feature<CommandTotal, ResponseTotal> { … }
101
+ ```
133
102
 
134
- import type { ProjectFeatures } from "../services";
103
+ The same functions work as standard decorators and as legacy ones (`experimentalDecorators`);
104
+ without a decorator transform, call them: `sales.feature(CommandTotal)(Total)`. No DI library
105
+ and no `reflect-metadata`. The tests compile both decorator forms with TypeScript; a bundler's
106
+ transform (Vite, Metro/Babel) is each application's to configure and test.
135
107
 
136
- export class Feature extends BaseFeature<ProjectDependencies> {}
137
- export class ApplicationService extends BaseApplicationService<
138
- ProjectDependencies,
139
- ProjectFeatures
140
- > {}
108
+ ### Replacing a service
141
109
 
142
- export function configProjectBoundedContext(name: string) {
143
- return registerDependencies(new UseBoundedContext<ProjectDependencies>(name));
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) { … }
144
116
  }
117
+ printing.feature(CommandPrint, { replaces: Print })(PrintOnBixolon);
118
+ printing.handlerOf(CommandPrint); // PrintOnBixolon
145
119
  ```
146
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 executed as a Command is: by the Feature or
130
+ ApplicationService registered for its class, its subscriber in this bounded context, through
131
+ the same pipeline (span, log lines, error handlers, report):
132
+
147
133
  ```ts
148
- // domains/project/services/issues.ts — a Feature is a facade: one part of the domain
149
- export class Issues extends Feature {
150
- close(input: dto.CommandCloseIssue) {
151
- this.session.require(); // each dependency is a typed property
152
- return this.client.closeIssue(input); // carries this run's traceparent
134
+ class OrderPaid extends DomainEvent {
135
+ static readonly eventName = "shop.order.v1.paid"; // its wire name, as the backend's `name`
136
+ constructor(readonly order_id: string) {
137
+ super();
153
138
  }
154
139
  }
155
140
 
156
- // domains/project/services/close_and_notify.ts — composes the Features of its bounded context
157
- export class CloseAndNotify extends ApplicationService {
158
- async execute(input: dto.CommandCloseIssue) {
159
- await this.feature.Issues.close(input); // a child span, the same trace
160
- return this.feature.Notices.tell(input);
141
+ class ThankCustomer extends Feature<OrderPaid, void> {
142
+ static readonly serviceName = "ThankCustomer";
143
+ async execute(event: OrderPaid) { … }
144
+ }
145
+
146
+ shop.feature(OrderPaid)(ThankCustomer);
147
+ await shop.execute(new OrderPaid("o-1")); // span shop/ThankCustomer, sincpro.event = its name
148
+ ```
149
+
150
+ - **`eventName` is declared**, as `serviceName` is: a minifier renames classes. `event.name`
151
+ reads it; `name` is reserved, and a subclass cannot make it a field.
152
+ - **One subscriber per bounded context.** Another context that hears the event registers its
153
+ own; a `Subscriber` executes it in each.
154
+ - An ApplicationService can subscribe too, and compose Features — of Commands or of events —
155
+ through `featureBus`. `execute(event)` answers whatever the subscriber returns (`unknown`).
156
+
157
+ ### Publishing and subscribing
158
+
159
+ ```ts
160
+ import { Publisher, Subscriber, syncQueue } from "@sincpro/client-framework";
161
+
162
+ const subscriber = new Subscriber(mail, stock); // every bounded context that hears
163
+ const queue = syncQueue(subscriber); // in the call, in memory — or a platform's
164
+ shop.addDependency("publisher", new Publisher(queue));
165
+
166
+ class Pay extends Feature<CommandPay, string> {
167
+ declare readonly publisher: Publisher;
168
+ async execute(command: CommandPay) {
169
+ await this.publisher.publish(new OrderPaid(command.order_id)); // its Envelope
170
+ return command.order_id;
161
171
  }
162
172
  }
173
+ ```
174
+
175
+ The `Publisher` is bound to the run that holds it, so the event joins that run's flow: its
176
+ envelope carries the run's `correlationId` and names the run as its cause. The `Subscriber`
177
+ executes it in `mail` and in `stock` — `context.execute(event, envelope)` —, so their runs
178
+ continue the same flow; one that fails does not stop the others.
179
+
180
+ What is defined here, and what each platform plugs behind the ports:
181
+
182
+ | Piece | Here | The platform's |
183
+ | ------------------------- | -------------------------------------------------------------- | -------------------------------------- |
184
+ | `DomainEvent`, subscriber | the class, its `eventName`, its execution | — |
185
+ | `Envelope` | `name`, `id`, `correlationId`, `causationId`, `publishedAt` | storing it beside the event |
186
+ | `EventQueue` port | `syncQueue` (in the call); `storedQueue` (store, then deliver) | a broker's queue, `BroadcastChannel` |
187
+ | `EventStore` port | `memoryEventStore()` | mobile: a SQLite table; web: IndexedDB |
188
+ | `Subscribes` port | `Subscriber(...contexts)`, `eventTypeOf(name)` | — |
189
+
190
+ ### Stored events: one way to carry them, any store
163
191
 
164
- // domains/project/services/index.ts
165
- export const features = { Issues, Notices };
166
- export const applicationServices = { CloseAndNotify };
167
- export type ProjectFeatures = typeof features;
192
+ ```ts
193
+ const queue = storedQueue({
194
+ store: sqliteEventStore(db), // the platform's EventStore; memoryEventStore() in a tab or a test
195
+ subscriber: new Subscriber(sales, stock, printing),
196
+ ready: (stored) => connectivity.online || !needsNetwork(stored.envelope.name),
197
+ onSettled: (stored) => activity.show(stored.envelope.name, stored.state),
198
+ });
199
+ shop.addDependency("publisher", new Publisher(queue));
200
+ connectivity.onBack(() => queue.drain()); // and on coming back to the foreground
168
201
  ```
169
202
 
203
+ `publish` returns once the event is stored. Then the queue delivers what waits, one at a time,
204
+ in the order stored — the backend's store-and-forward, over whatever store the platform plugs:
205
+
206
+ - It is rebuilt as its class — `static fromJSON` when it has one — and executed in every
207
+ bounded context that hears it and does not have it yet: a retry never runs one twice.
208
+ - A failure a retry may fix (`failureOf`: unavailable, no answer, a conflict…) is tried again
209
+ after the backoff or the wait it asks for, and what follows waits, so the order holds.
210
+ - A failure every attempt would repeat (`PERMANENT_KINDS`: a refusal, invalid, not found) sets
211
+ it aside — the dead-letter queue — and never holds back what follows.
212
+ - One that is not `ready` (no connection, no session) waits while the rest go on.
213
+ - After `maxAttempts` (10) a failure a retry might still fix is set aside too: a bug of the
214
+ process (`internal`) never holds the line forever.
215
+
216
+ The port only keeps records — `add`, `next(skip)`, `save` — so a SQLite table, IndexedDB or
217
+ memory carry events the same way, with the same rules.
218
+
219
+ Three things a platform keeps in mind:
220
+
221
+ - **At least once.** A process that stops after a context executed an event and before the store
222
+ saved it runs it there again. A subscriber that must not act twice keys what it does by the
223
+ envelope's id, `this.execution.causationId` in its run.
224
+ - **One drainer per store.** Two tabs over one IndexedDB claim it first (the Web Locks API).
225
+ - **Values come back as JSON.** An event that carries a `Money`, a `Decimal` or a date declares
226
+ `static fromJSON` to read them back as such.
227
+
228
+ The events a remote service publishes arrive as generated types (`DomainEvent` in
229
+ `generated/domain.ts`, a union narrowed by `name`); a local event is a class, like a local
230
+ Command.
231
+
232
+ ## Extending the pipeline
233
+
234
+ The flow of a run is the core's — context, chain, span, log lines, outcome, report, handlers —,
235
+ and an application changes or hears it from outside, as the backend's `bus_pipeline`:
236
+
170
237
  ```ts
171
- // domains/project/index.ts
172
- export const project = configProjectBoundedContext("project").register({
173
- features,
174
- applicationServices,
238
+ // control: around the service — look, veto (throw), adjust (same class), or answer
239
+ shop.interceptor(CommandPay)(async (command, next, run) => {
240
+ if (!credit.allows(command.customer_id)) throw new NoCredit();
241
+ return next(command);
175
242
  });
243
+ shop.interceptor()((executable, next, run) => next(executable)); // every class
176
244
 
177
- // anywhere: a screen, a script, a test
178
- await project.Issues.close(input); // span project/Issues.close
179
- await project.CloseAndNotify.execute(input); // project/CloseAndNotify → its Features under it
245
+ // observation: never changes the answer
246
+ shop.onStart((started) => activity.running(started.execution.useCase));
247
+ shop.onCompletion((done) => activity.succeeded(done.execution.useCase), CommandPay);
248
+ shop.onFailure((failed) => activity.failed(failed.execution?.useCase, failed.outcome));
249
+
250
+ // composition: a module or plugin wires its pieces in one place
251
+ shop.extend(auditing);
180
252
  ```
181
253
 
182
- The generator writes this skeleton for you, once, from the service's contract — and never touches
183
- a file that is already there, because from then on it is yours:
254
+ - **Interceptors** run inside the run, outermost first in the order registered: a veto is the
255
+ use case's failure — observed, answered by the error handlers. What an interceptor passes on
256
+ keeps its class (the bus routes by class); `run` gives it the use case, the execution and its
257
+ logger. `withoutInterceptor(fn)` stops one; registered later, it wraps from the next call.
258
+ - **Listeners** hear each run start and each service that answered, filtered by class when
259
+ named; a failure is heard once, when it escapes the call after every handler, from where it
260
+ first failed. One that throws is ignored. Each `on…` answers how to stop.
261
+ `processListeners.onStart / onCompletion / onFailure` hear every bounded context at once — a
262
+ progress bar, a toast —, after each one's own.
263
+ - **`extend(fn)`** is `fn(context)`, said as composition; `fresh()` copies everything an
264
+ extension registered.
184
265
 
185
- ```bash
186
- sincpro-client generate project.contract.json --out src/domains/project/generated --domain project
187
- sincpro-client scaffold project.contract.json --out src/domains/project # dependencies, bases, services, index
266
+ ## Errors
267
+
268
+ As the backend's, a bounded context has three chains of error handlers, run
269
+ first-registered-first. A handler returns the answer, or throws to hand the error on; the last
270
+ one's throw reaches the caller:
271
+
272
+ ```ts
273
+ sales.addFeatureErrorHandler((error) => {
274
+ if (error instanceof PriceNotFound) return new ResponseTotal(0); // the answer
275
+ throw error; // the next handler, or the caller
276
+ });
277
+ sales.addAppServiceErrorHandler(toBusinessError);
278
+ sales.addGlobalErrorHandler(toProblem); // what reaches sales.execute or a scope
279
+
280
+ sales.expectErrors(QuoteExpired); // the business answering: `refused`, never reported
188
281
  ```
189
282
 
190
- And the layers stay layers: `sincpro-client check` resolves every import of the application with
191
- its own TypeScript and `tsconfig` (aliases included) and fails when a bounded context reaches into
192
- another's insides, a service imports React or web-ui, `common` imports a bounded context, or the
193
- entrypoints skip a bounded context's `index.ts` — put it in `make doctor`:
283
+ A failure is observed where it happened, before any handler runs: its span closes with its
284
+ outcome, its log line is written and, when unexpected, it is reported once. A handler decides
285
+ the answer, not whether the failure is seen; an answered one adds an `error.handled` line. A
286
+ feature handler answers wherever the Feature ran, inside an ApplicationService too; the global
287
+ chain wraps only `execute` and `scoped(...).execute`.
194
288
 
195
- ```bash
196
- sincpro-client check --root src
289
+ ### What kind of failure it is
290
+
291
+ `failureOf(error)` says what any error is, in the backend's words (`FailureKind`, `NextStep`
292
+ from @sincpro/criteria), whatever raised it:
293
+
294
+ ```ts
295
+ const failure = failureOf(error);
296
+ failure.kind; // "conflict", "unavailable", "domain", "unknown_outcome"…
297
+ failure.nextStep; // "refresh_and_retry", "retry_later", "authenticate", "do_not_retry"…
298
+ failure.retryable; // may sending it again succeed
299
+ failure.retryAfter; // seconds to wait first
300
+ failure.permanent; // every attempt fails the same way: set it aside
301
+ failure.fault; // the system's problem: reported
302
+ failure.aborted; // whoever asked stopped waiting
197
303
  ```
198
304
 
199
- Every public method is a use case: a span named as the backend names its own
200
- (`project/Issues.close`, identity `project.Issues.close`), and a report when it fails in a way
201
- nobody expected. One gesture is one trace, from the click to the database:
202
-
203
- ```mermaid
204
- sequenceDiagram
205
- participant caller as Caller<br/>(a screen, a test)
206
- participant app as project.CloseAndNotify<br/>(ApplicationService)
207
- participant feature as this.feature.Issues<br/>(Feature)
208
- participant client as this.client<br/>(generated, bound to the run)
209
- participant process as Process<br/>(Tracer · ErrorReporter)
210
- participant server as sincpro_framework
211
-
212
- caller->>app: execute(input)
213
- app->>process: span project/CloseAndNotify — the context read once
214
- app->>feature: close(input)
215
- feature->>process: child span project/Issues.close
216
- feature->>client: closeIssue(input)
217
- client->>server: POST /issues/{issue_id}/close · traceparent · sincpro-context
218
- server-->>client: ResponseCloseIssue — or a Problem
219
- client-->>caller: the answer, through each run
220
- Note over process: each run ends ok · refused (shown, never reported) · aborted · failed (reported once, with its trace id)
305
+ It reads, in order: an `AbortError`; what the error declares (`failureKind`, `reason`,
306
+ `retryAfter` — on itself or as a static of its class, as the backend's errors do); the `Problem`
307
+ the server sent, the same over REST, JSON-RPC and gRPC; a REST status. Not every wire is REST: a
308
+ JSON-RPC or gRPC transport — or another backend's adapter, such as Odoo's — makes errors that
309
+ declare their kind, with the backend's own tables:
310
+
311
+ ```ts
312
+ class RpcError extends Error {
313
+ constructor(
314
+ readonly code: number,
315
+ message: string,
316
+ ) {
317
+ super(message);
318
+ }
319
+ get failureKind() {
320
+ return kindOfJsonRpcCode(this.code); // kindOfGrpcCode, kindOfHttpStatus
321
+ }
322
+ }
221
323
  ```
222
324
 
223
- `project` holds references only — each service is made once, the first time it is
224
- asked for — and it is never frozen: `project.with({ client: fake })` and
225
- `project.scoped({ company_id: 2 })` derive a variant that shares everything else.
325
+ How an error ended a use case follows from it (`outcomeOf`):
226
326
 
227
- ## The process: tracing, errors and the context
327
+ | Outcome | When | Reported |
328
+ | --------- | -------------------------------------------------------------------------- | -------- |
329
+ | `ok` | it answered | — |
330
+ | `refused` | the caller's answer: a 4xx, a declared kind (`domain`…), or `expectErrors` | no |
331
+ | `aborted` | whoever asked stopped waiting: an `AbortError`, a cancelled run | no |
332
+ | `failed` | a fault: a bug, a 5xx, a server nobody reached | yes |
228
333
 
229
- What every bounded context runs with is set once, where the application starts — the way the
230
- backend's process is. All of it is optional:
334
+ `configureProcess({ outcomeOf })` replaces the process-wide rule. `reportError(error, where)`
335
+ reports what fails outside a use case — a view, a worker —, with the same rule.
231
336
 
232
- ```ts
233
- // entrypoints/main.ts
234
- configureProcess({
235
- tracer, // the platform's Tracer
236
- reporter, // the platform's ErrorReporter
237
- context: memoryContexts(), // where the context lives (this is the default)
238
- });
337
+ ### Cancelling a run
239
338
 
240
- processContext.set({ tenant_id: "acme" }); // every use case runs with it and sends it
241
- project.context.set({ warehouse_id: 4 }); // only what «project» runs
339
+ ```ts
340
+ const screen = new AbortController();
341
+ const answer = sales.execute(new CommandQuote(3), { signal: screen.signal });
342
+ screen.abort(); // the run ends `aborted` at once; nothing is reported
242
343
  ```
243
344
 
244
- What a screen reads and writes is a use case too when it goes through `screensTransport(transport)`:
245
- each request is `screens/EntityReads.list`, `…/EntityWrites.send` — traced, with the context on
246
- it, and reported when it failed.
247
-
248
- A service reads it as `this.context` — the process's values, under them its bounded context's,
249
- under them a scope's, read once where the gesture started — and every request it sends carries
250
- it in `sincpro-context`. A screen hears it change with `processContext.watch(…)`.
251
-
252
- ```mermaid
253
- flowchart LR
254
- p["processContext<br/>tenant_id · company_id"]
255
- b["project.context<br/>warehouse_id"]
256
- s["project.scoped({ company_id: 2 })<br/>one window"]
257
- e["this.context<br/>read once where the gesture starts"]
258
- c["this.context of each Feature it runs<br/>the same values"]
259
- h["sincpro-context<br/>on every request"]
260
- store[("ContextStore<br/>the platform's")]
261
- p --> e
262
- b --> e
263
- s --> e
264
- e --> c
265
- e --> h
266
- c --> h
267
- p -.-> store
268
- b -.-> store
345
+ Its Features inherit the signal, a service reads it as `this.signal`, and every request its bound
346
+ transport or generated client sends is cancelled with it — with nothing passed by hand.
347
+
348
+ ### A circuit breaker
349
+
350
+ ```ts
351
+ const odoo = new CircuitBreaker("odoo", { failures: 5, openFor: 30_000 });
352
+ const transport = guardedTransport(rpcTransport, odoo); // every call through the circuit
353
+ await odoo.call(() => printer.print(receipt)); // or one call; odoo.wrap(fn) for a function
269
354
  ```
270
355
 
271
- The context lives in a `ContextStore`, the port each platform implements with its own technology:
272
- memory by default, `sessionStorage` or the screens' own store in a browser, MMKV on a phone,
273
- `AsyncLocalStorage` in a server that renders. The services never know which.
356
+ The backend's `CircuitBreaker`, in this process's memory. After `failures` counted failures in a
357
+ row — the dependency did not answer (`unavailable`, `unknown_outcome`, `exhausted`, `internal`) —
358
+ it opens: calls fail at once with `CircuitOpen`, `unavailable` with the time left as its
359
+ `retryAfter`, so `failureOf`, a stored queue (which waits that long) and a screen already know
360
+ what to do. After `openFor`, one call tries: an answer closes it, a counted failure opens it
361
+ again. A refusal is an answer and never counts. Put it where the dependency is — the transport, an
362
+ adapter —, not in each Feature.
363
+
364
+ ## Observability
274
365
 
275
- `tracer` and `reporter` are injected by the application — this package initialises nothing.
276
- Any object that fulfils the port works; for the common vendors there are optional adapters that
277
- wrap what the application built:
366
+ The platform plugs the implementations once, at startup; nothing breaks without them:
278
367
 
279
368
  ```ts
280
- import { openTelemetryTracer } from "@sincpro/client-framework/opentelemetry"; // peer: @opentelemetry/api
281
- import { sentryReporter } from "@sincpro/client-framework/sentry"; // no dependency
369
+ import { configureProcess, memoryContexts } from "@sincpro/client-framework";
370
+ import { openTelemetryTracer } from "@sincpro/client-framework/opentelemetry";
371
+ import { sentryReporter } from "@sincpro/client-framework/sentry";
282
372
 
283
- const tracer = openTelemetryTracer(provider.getTracer("my-app")); // the app's provider and exporter
284
- const reporter = sentryReporter(client); // the app's Sentry-compatible client (GlitchTip too)
373
+ configureProcess({
374
+ tracer: openTelemetryTracer(provider.getTracer("forge-web")), // the app's provider
375
+ reporter: sentryReporter(client), // the app's Sentry or GlitchTip client
376
+ logger: { mode: production ? "production" : "development" },
377
+ context: memoryContexts(),
378
+ });
285
379
  ```
286
380
 
287
- What fails outside a use case — a screen that broke while drawing, a subscriber, a worker — goes to
288
- the same reporter with `reportError(error, where)`: once (an error a use case already reported is
289
- not sent again), with the process's context.
381
+ Every execution opens a span `<bounded context>/<serviceName>`; an ApplicationService's Features
382
+ are its children, on the same trace. A dependency that is a `Transport` or a generated client is
383
+ bound to the run, so each request carries `traceparent` (and `tracestate`) and
384
+ `sincpro-context` — the backend continues the trace. A tracer, reporter or logger that fails is
385
+ ignored: telling about a use case never changes its answer.
290
386
 
291
- Nothing is installed or shipped unless it is imported, and without a tracer or a reporter every use
292
- case runs the same — nothing traces, nothing reports. In a test, `memoryTracer()` and
293
- `memoryReporter()` (`@sincpro/client-framework/testing`).
387
+ The vendor entries wrap what the application built — they initialize nothing, read no
388
+ environment and register nothing global. There is no context manager: the trace reaches the
389
+ wire through the transport. On the web, leave the backend's origin out of OpenTelemetry's
390
+ automatic `fetch` instrumentation (`ignoreUrls`), or it writes a `traceparent` of a trace of its
391
+ own over this one.
294
392
 
295
- ## Values on the wire
393
+ ### Logging
296
394
 
297
- The backend writes every message with one set of rules, and every client reads and writes them
298
- the same — so they live here, not in each platform:
299
-
300
- | Value | Travels as | Here |
301
- | ------------ | ------------------------------------------ | -------------------------------------------------- |
302
- | a decimal | text, every digit: `"10.50"` | `Decimal` — exact, on `BigInt`; rounds half up |
303
- | a money | `{ "amount": "10.50", "currency": "BOB" }` | `Money` — never adds two currencies |
304
- | a day | `"2026-10-02"`, never converted | `isDay`, `dayOf(instant, zone)` for «today» |
305
- | an instant | ISO text: `"2026-10-02T14:30:00Z"` | `wallTimeOf` / `instantOf` on the company's zone |
306
- | an id | a UUID v7 as 32 hex | `newEntityId`, `isEntityId`, `momentOf` |
307
- | bytes | `{ "$bytes": "<base64>" }` | `valuesOf` / `readValues` |
308
- | the context | only what travels: text, numbers, lists | `plainCodec`, `travelling` |
309
- | a to-many | `{ "items": […], "count": {…}, "cursor" }` | `EntityCollection<T>`, `collectionOf`, `isPartial` |
310
- | an export | Parquet, Arrow or JSON Lines, streamed | `downloadRoute`, `exportRoute`, `Download` |
311
- | a large page | Parquet, when the search says so | `restTransport({ decoders: [parquetDecoder] })` |
395
+ The application layer imports no logger. Two are predefined, for the code around the use cases:
312
396
 
313
397
  ```ts
314
- import { Money, pack, unpack, valuesOf } from "@sincpro/client-framework";
398
+ import { logger, loggerAdapter } from "@sincpro/client-framework";
399
+
400
+ logger.info("Database opened"); // infrastructure — the global logger
401
+ loggerAdapter.warn("Printer is slow", { ms: 900 }); // an adapter
402
+ sales.logger.info("Catalog preloaded"); // a bounded context, outside a use case
403
+ this.logger.info("Totalling"); // inside a Feature or ApplicationService
404
+ ```
405
+
406
+ | Logger | Layer | Each line carries |
407
+ | ------------------------- | --------------------- | ---------------------------------------------- |
408
+ | `logger` | `infrastructure` | — |
409
+ | `loggerAdapter` | `adapter` | the run, when injected as a dependency |
410
+ | `sales.logger` | `bounded_context` | its bounded context |
411
+ | `this.logger`, Feature | `feature` | bounded context, use case, execution and trace |
412
+ | `this.logger`, AppService | `application_service` | the same |
413
+
414
+ `this.logger` keeps its run across awaits and concurrent calls, without a global stack. An
415
+ adapter that imports `loggerAdapter` at module level writes without the run; inject it
416
+ (`sales.addDependency("log", loggerAdapter)`) or pass `this.logger` when a line must carry it.
417
+
418
+ One configuration, two doors — `configureProcess({ logger })` replaces every process port,
419
+ `configureLogger(...)` only logging (an in-app debug switch):
420
+
421
+ ```ts
422
+ configureLogger({ mode: "development", level: "info", layers: { adapter: false } }); // the console
423
+ configureLogger({ mode: "production" }); // the console, silent
424
+ configureLogger({ engine: combineLoggers(consoleLogger({ mode }), lokiLogger) }); // other sinks
425
+ configureLogger({ engine: false }); // no logs
426
+ ```
315
427
 
316
- const line = new Money("19.90", "BOB").times("3"); // 59.70 BOB, exact
317
- const total = Money.total([line, delivery], "BOB").rounded(); // to the currency's decimals
318
- valuesOf({ total, at: new Date() }); // { total: { amount, currency }, at: "…Z" }
319
- const answer = unpack(text, Money); // a typed read when asked for — never imposed
428
+ The console is the default engine. In production it is silent; in development it prints
429
+ everything, and `level` and `layers` hide only INFO/DEBUG. A supplied `engine` owns its own
430
+ policy, and each sink of `combineLoggers` keeps its own — the console's silence never silences
431
+ another. Every execution writes `execution.started` (DEBUG) and `execution.finished` with its
432
+ outcome and duration (INFO, WARN when refused, ERROR when failed). No line carries a DTO, a
433
+ result, an error body or the session's context unless the author writes it. Delivery, buffering
434
+ and credentials (Loki, a phone's queue) are the platform's.
435
+
436
+ ## Context
437
+
438
+ The values every use case runs with — the tenant, the company —, in levels: the process's, the
439
+ bounded context's, a scope's. A root execution reads them once; its Features inherit that
440
+ snapshot, and what travels goes to the server in `sincpro-context`.
441
+
442
+ ```ts
443
+ processContext.set({ tenant_id: "acme" });
444
+ sales.context.set({ warehouse_id: 4 });
445
+ await sales.scoped({ company_id: 2 }).execute(new CommandQuote(1));
320
446
  ```
321
447
 
322
- Not restrictive, like the backend: reading keeps every key it finds, a typed read is asked for
323
- (`rebuilt(values, Money)`), any currency code is read, and an application adds its own decimals
324
- (`Money.decimals["USDT"] = 6`). What JSON cannot write (`NaN`, a function, a cycle) is refused at
325
- once, naming where. Showing a value to a person — `Intl.NumberFormat`, a date picker — is each
326
- platform's, never this package's.
448
+ Where the values live is a port (`ContextStore`): synchronous reads, a version per change and an
449
+ optional `watch`. `memoryContexts()` is the default; a browser, a phone or a server render plugs
450
+ its own. `values` is the same object until it changes, so a screen can hand it to React's
451
+ `useSyncExternalStore`.
327
452
 
328
- A relation that holds several records — the invoices of a customer — comes as a page of them,
329
- never an array: forty records, «at least 1000», and the cursor to go on. Read it with
330
- `collectionOf` (an array from a hand-written domain reads the same) — a `HeldCollection`, edited
331
- the way a string is: `append`, `extend`, `remove`, `replace` answer a new one. Say «1000+» when
332
- `count.exact` is false, and ask for the rest as a list of the related entity
333
- (`relatedCriteria(meta.fields.invoices, customer)`). A part of the record — the lines of an
334
- invoice — is written back only whole: the backend refuses a page of it.
453
+ ## Talking to a server
335
454
 
336
- A search asked for millions of records answers Parquet instead of JSON to a client that reads it.
337
- Give the transport the decoder — an optional entry over `hyparquet` — and nothing above it
338
- changes: the same page, each value as the JSON wire writes it.
455
+ A remote service is reached through a port. The generated client never names a wire: it says
456
+ which operation, with what input, and the adapter the application injects decides how it
457
+ travels.
339
458
 
340
459
  ```ts
341
- import { restTransport } from "@sincpro/client-framework";
342
- import { parquetDecoder } from "@sincpro/client-framework/parquet";
460
+ import { clientOf } from "./generated/client";
461
+
462
+ // pick the wire; the client, the Features and the tests stay the same
463
+ const forge = clientOf(restOperations(restTransport({ base: "/v1", headers: session })));
464
+ // or clientOf(jsonRpcOperations({ endpoint: "/rpc", headers: session }))
465
+ // or clientOf(grpcOperations(channel)) — a platform's adapter, the same port
466
+
467
+ await forge.sendIssue({ issue_id: "acme-3", message: "hola" }); // ResponseSendIssue
468
+ ```
469
+
470
+ | Piece | What it does |
471
+ | --------------------------------- | ------------------------------------------------------------------------------------- |
472
+ | `OperationTransport` port | `call(operation, input, options)`: an operation by who it is, over any wire |
473
+ | `restOperations(transport)` | over REST: the path from the input's fields, a Query's query, a Command's body |
474
+ | `jsonRpcOperations({ endpoint })` | over JSON-RPC 2.0: one `POST`, the operation's method, its input as named params |
475
+ | `restTransport({ base })` | REST over `fetch`: `Idempotency-Key`, the server's `Problem`, model versions, Parquet |
476
+ | `memoryTransport(tables)` | the same REST contract in memory, copying by the wire's rules — for tests and demos |
477
+ | `entity(key, routes)` | a model's routes and Commands, typed — what a generated `entities.ts` declares |
478
+ | `retryKeys()` | one `Idempotency-Key` per gesture, kept while a retry may repeat it |
479
+ | `guardedTransport(t, breaker)` | every call of a transport through a circuit breaker |
480
+ | `screensTransport(transport)` | screen reads and writes run as use cases of `screens`: traced and reported |
481
+ | `parquetDecoder` (`./parquet`) | large pages read as Parquet through the same transport |
482
+
483
+ Inside a Feature, inject the generated client as it is: the run binds it, so every call carries
484
+ its trace (`traceparent`), its context (`sincpro-context`) and its signal — as headers over HTTP,
485
+ as metadata where a platform's adapter puts them. `screensTransport` and `entity()` stay on the
486
+ REST `Transport`: they are how a screen reads pages of an entity, with its model and its drafts.
487
+
488
+ ### Writing an adapter for another wire
489
+
490
+ An adapter is one function: read the operation's binding for its wire, send the input, answer the
491
+ answer, and fail with errors `failureOf` reads.
343
492
 
344
- const transport = restTransport({ base: "/v1", decoders: [parquetDecoder] });
493
+ ```ts
494
+ function grpcOperations(channel: Channel): OperationTransport {
495
+ return {
496
+ async call(operation, input, options) {
497
+ const method = operation.grpc?.method;
498
+ if (!method) throw new OperationNotPublished(operation.identity, "gRPC");
499
+ try {
500
+ return await channel.unary(method, input, {
501
+ metadata: options?.headers, // traceparent, sincpro-context
502
+ signal: options?.signal,
503
+ });
504
+ } catch (error) {
505
+ const code = (error as { code: number }).code;
506
+ throw Object.assign(error as Error, { failureKind: kindOfGrpcCode(code) });
507
+ }
508
+ },
509
+ };
510
+ }
345
511
  ```
346
512
 
347
- ## Where it sits
348
-
349
- ```mermaid
350
- flowchart TB
351
- backend["sincpro_framework<br/>the service: its bounded contexts, Commands, the client contract"]
352
- criteria["@sincpro/criteria<br/>Criteria · Meta · Page · Problem"]
353
- cf["@sincpro/client-framework<br/>bounded contexts · services · process · context · ports · the client · the generator"]
354
- web["@sincpro/web-framework<br/>hooks · screens · the browser's tracer and reporter"]
355
- mobile["@sincpro/mobile<br/>React Native · SQLite · its queue"]
356
- rest["a script · a server-side render · a test"]
357
- web --> cf
358
- mobile --> cf
359
- rest --> cf
360
- cf --> criteria
361
- backend -. "client contract → sincpro-client generate" .-> cf
362
- cf -- "HTTP · traceparent · sincpro-context" --> backend
513
+ Every operation in `operations` carries its identity and each wire's binding (`rest`, `rpc`,
514
+ `grpc`, `mcp`); an operation the adapter's wire does not publish is `OperationNotPublished`
515
+ (`not_found`), never guessed.
516
+
517
+ ## Generated code
518
+
519
+ ```bash
520
+ python -m sincpro_framework.entrypoints export-client-contract app.http:gateways contracts [--events]
521
+ sincpro-client generate contracts/project.contract.json --out src/domains/project/generated --domain project
522
+ sincpro-client scaffold contracts/project.contract.json --out src/domains/project
523
+ sincpro-client check --root src
363
524
  ```
364
525
 
365
- No React, no DOM, no platform: what a platform must provide — how a span is exported, where an
366
- error is reported, where the context lives — it plugs through a port.
526
+ `generate` writes, from the contract the backend exports — never edited, written again whole:
367
527
 
368
- ## On each platform
528
+ | File | What |
529
+ | ------------- | --------------------------------------------------------------------------------------------- |
530
+ | `domain.ts` | a type per model, per `<Name>Input`, per operation's input and answer, per domain event |
531
+ | `client.ts` | `clientOf(operations)`: a method per operation, over any wire; `operations` and `events` data |
532
+ | `entities.ts` | each entity's list, record, edit, export, Commands and history, for screens |
369
533
 
370
- This package asks the platform for little: `fetch`, `AbortSignal`, `URL` / `URLSearchParams`,
371
- `crypto.getRandomValues` (client-side ids, retry keys), `Proxy` / `WeakMap`. Browsers and Node 26
372
- have them all. **React Native** (Hermes) needs two polyfills, installed once where the app
373
- starts, before anything imports this package:
534
+ Each method takes the operation's input and answers its answer, as `domain.ts` types them — a
535
+ search answers its `Response…` with its records and cursor, as the backend's callers get it, on
536
+ every wire. `scaffold` writes, once, the application's own files: its dependencies (the client
537
+ over the `operations` the application exports — `--operations <module>` —), its service bases
538
+ and `registerServices`. `check` enforces the application's layers and bounded-context
539
+ boundaries.
540
+
541
+ **Domain events, on demand.** A backend leaves its events out of the contract unless it is
542
+ exported with `--events`. Then each event is a type as it is received — every field present,
543
+ `name` its wire name as a literal — and `DomainEvent`, their union, narrows by `name`:
374
544
 
375
545
  ```ts
376
- import "react-native-get-random-values"; // or expo-crypto's getRandomValues polyfill
377
- import "react-native-url-polyfill/auto"; // RN's own URL resolves relative paths poorly
546
+ import type * as remote from "./generated/domain";
547
+
548
+ function subjectOf(event: remote.DomainEvent): string {
549
+ switch (event.name) {
550
+ case "project.issue.v1.closed":
551
+ return event.issue_id; // remote.IssueClosed
552
+ // …
553
+ }
554
+ }
378
555
  ```
379
556
 
380
- `memoryTransport` copies with `structuredClone` where the engine has it and a JSON round trip
381
- where it does not. The OpenTelemetry adapter takes `@opentelemetry/api` from 1.9, the version
382
- `@sincpro/mobile` runs.
557
+ `events` in `client.ts` says, per event, its wire name and whether the bounded context records it
558
+ (its aggregates' own) or handles it (a Feature subscribes).
383
559
 
384
- The services are the same everywhere; each platform plugs its own technology behind the same
385
- three ports, in its `main`:
560
+ ### Types, not classes
386
561
 
387
- | Port | Browser (@sincpro/web-framework) | Phone (@sincpro/mobile) | Test |
388
- | --------------- | ----------------------------------------------- | ------------------------------------------ | ----------------- |
389
- | `Tracer` | OpenTelemetry + OTLP/HTTP (`./telemetry`) | OpenTelemetry with its queue and exporter | `memoryTracer` |
390
- | `ErrorReporter` | GlitchTip / Sentry browser client (`./errors`) | Sentry React Native client | `memoryReporter` |
391
- | `ContextStore` | memory, `sessionStorage`, or the screens' store | MMKV, or memory hydrated from AsyncStorage | `memoryContexts` |
392
- | `Transport` | `restTransport` over `fetch` | `restTransport`, or SQLite offline | `memoryTransport` |
562
+ What is generated is the remote service's contract and an adapter over it, so it is written as
563
+ types; the application's own Commands are classes, written by hand.
393
564
 
394
- How @sincpro/mobile adopts all of this — what maps to what, the MMKV store, the order to move
395
- in — is [`docs/MOBILE.md`](docs/MOBILE.md).
565
+ - **The wire is JSON, and a type says it exactly.** A decimal is text, a day is text, a page is
566
+ `{ items, count, cursor }`. A class around that JSON would claim more than it holds unless
567
+ every field had a reader; a type claims nothing it does not hold.
568
+ - **The local bus routes by class, and the remote contract must not reach it.** A generated
569
+ class extending `Command` could be registered and executed locally — the confusion between a
570
+ remote operation and a local use case this package keeps apart. A remote call happens inside
571
+ a local Feature, through the injected client.
572
+ - **Events read best as a union.** A stream of facts — a queue, a history page — is narrowed by
573
+ `name`, with no registry of constructors to rebuild each one.
574
+ - **Nothing runs.** `domain.ts` adds no byte to a bundle and no side effect; a contract of three
575
+ hundred models costs nothing at runtime.
396
576
 
397
- A store's reads are synchronous — a use case reads its context where a gesture starts and never
398
- waits for it — so an asynchronous storage is held in memory and written through.
577
+ A local class takes the contract's shape and stays checked against it:
399
578
 
400
- ## Development
579
+ ```ts
580
+ import type * as remote from "../generated/domain";
581
+
582
+ class CommandCancelIssue
583
+ extends Command<ResponseCancelIssue>
584
+ implements remote.CommandCancelIssue
585
+ {
586
+ constructor(readonly issue_id: string) {
587
+ super();
588
+ }
589
+ }
590
+ ```
401
591
 
402
- Node 26 (`nvm use`).
592
+ The generated name and the local one may be the same — the backend's —, so the generated ones
593
+ are imported under their own (`remote`). Validating and converting what arrives (a decimal into
594
+ `Decimal`) is a reader's job, not a class's: generated readers are the next step, when a use
595
+ needs them.
403
596
 
404
- ```bash
405
- make init # pre-commit + dependencies
406
- make test # vitest, in Node
407
- make verify-format # format + lint + doctor; fails if the tree is left dirty (what CI runs)
408
- make build # tsc into dist/
597
+ ## Values on the wire
598
+
599
+ | Value | Travels as | API |
600
+ | ---------- | -------------------------------------- | ---------------------------------------------- |
601
+ | Decimal | exact text, `"10.50"` | `Decimal` |
602
+ | Money | `{ amount: "10.50", currency: "BOB" }` | `Money` |
603
+ | Day | `"2026-10-02"`, never an instant | `isDay`, `dayOf` |
604
+ | Instant | ISO text | `wallTimeOf`, `instantOf` |
605
+ | ID | UUID v7 as 32 hex | `newEntityId`, `isEntityId`, `momentOf` |
606
+ | Bytes | `{ "$bytes": "<base64>" }` | `valuesOf`, `readValues` |
607
+ | Collection | items, count and cursor | `collectionOf`, `isPartial`, `relatedCriteria` |
608
+ | Export | Parquet, Arrow or JSON Lines | `downloadRoute`, `exportRoute` |
609
+
610
+ `Decimal` and `Money` compute exactly, on `BigInt`; a decimal never becomes a `number` and a day
611
+ never a `Date`. `valuesOf`/`pack` write; `readValues` reads tagged values back; `rebuilt`/`unpack`
612
+ use a reader given explicitly. Cycles, functions and non-finite numbers are refused. How a value
613
+ is shown, typed or stored is the platform's.
614
+
615
+ ## Package layout
616
+
617
+ Each module is laid out as sincpro_framework's are, and says what it offers in its `index.ts`:
618
+
619
+ ```text
620
+ src/
621
+ common/ the values of the wire, and what kind of failure an error is
622
+ circuit_breaker/ CircuitBreaker, CircuitOpen
623
+ context/
624
+ domain/ ContextStore and ContextCodec ports, what travels, Execution
625
+ adapters/ memoryContexts, plainCodec
626
+ entrypoint/ Context: one door over a level
627
+ infrastructure/ the process's store and processContext
628
+ observability/
629
+ domain/ Tracer, ErrorReporter, Logger ports
630
+ adapters/ console (default), memory, opentelemetry and sentry (optional entries)
631
+ infrastructure/ the ports this process runs with
632
+ services/ logger, loggerAdapter, reportError, outcomeOf
633
+ client/
634
+ domain/ OperationTransport, RemoteOperation, Transport, TransportError, entity(),
635
+ the client contract
636
+ adapters/ restOperations, jsonRpcOperations, rest, memory, parquet (optional entry)
637
+ services/ discovery, retry keys, propagation of trace, context and signal,
638
+ guardedTransport
639
+ model/ defineMeta, metaFromSchema, metaStore
640
+ core/
641
+ domain/ Command, DomainEvent, Response, Feature, ApplicationService, FeatureBus,
642
+ ErrorHandler, the pipeline's extension points
643
+ services/ the execution pipeline, interceptors, dependency binding, the handler chain
644
+ entrypoint/ BoundedContext, screensTransport
645
+ infrastructure/ configureProcess, configureLogger, processListeners
646
+ event_driven/
647
+ domain/ Envelope, the EventQueue, Subscribes and EventStore ports
648
+ adapters/ syncQueue, storedQueue, memoryEventStore
649
+ entrypoint/ Publisher, Subscriber
650
+ codegen/ the generator and its CLI — the only place Node is allowed
651
+ testing/ memoryTracer, memoryReporter, memoryLogger
409
652
  ```
410
653
 
411
- `make doctor` enforces what the type checker cannot: the source imports itself, never a relative
412
- path; no React, no UI, no browser global; Node only inside the generator. Read
413
- [`AGENTS.md`](AGENTS.md) before changing anything, and [`CONTRIBUTING.md`](CONTRIBUTING.md)
414
- before opening a pull request.
654
+ | Entry | What |
655
+ | ------------------------------------------------------- | ------------------------------------------- |
656
+ | `@sincpro/client-framework` | everything an application runs |
657
+ | `@sincpro/client-framework/core` | the application layer alone |
658
+ | `@sincpro/client-framework/testing` | the observability ports in memory |
659
+ | `@sincpro/client-framework/codegen` | the generator, as functions |
660
+ | `./console`, `./opentelemetry`, `./sentry`, `./parquet` | optional entries; a vendor's SDK only there |
661
+
662
+ ## Development
415
663
 
416
- ## CI/CD
664
+ Node 26 (`nvm use`):
665
+
666
+ ```bash
667
+ make init
668
+ make typecheck
669
+ make test
670
+ make verify-format # eslint --fix + prettier + typecheck + doctor; fails on a dirty tree
671
+ make build
672
+ make codegen-fixtures # after changing the generator on purpose; read the diff
673
+ ```
417
674
 
418
- Every workflow in `.github/workflows/` delegates to
419
- [`Sincpro-SRL/.github`](https://github.com/Sincpro-SRL/.github) and only calls `make` targets:
420
- checks on every pull request and push to `main`, a release draft on `main`, publication to npm
421
- when a release is published, and notifications. The version is written by the release pipeline,
422
- never by hand.
675
+ `make doctor` enforces what the compiler does not: self-imports, no platform (React, DOM, Node
676
+ outside `codegen/`), a vendor's SDK only in its optional entry, and `domain/` importing only
677
+ `domain/`. Tests run in Node, one folder per module; `tests/support/forge-contract/` holds real
678
+ contracts and what the generator writes from them, compared byte for byte.
679
+
680
+ The runtime uses `fetch`, `AbortSignal`, `URL`, `crypto.getRandomValues`, `Proxy`, `WeakMap` and
681
+ `BigInt`; React Native must provide URL and random values before ids or transports are used.
682
+
683
+ Read [`MANIFESTO.md`](MANIFESTO.md) (what this package is and is not), [`AGENTS.md`](AGENTS.md)
684
+ (how to change it), [`CONTRIBUTING.md`](CONTRIBUTING.md),
685
+ [`docs/UBIQUITOUS_LANGUAGE.md`](docs/UBIQUITOUS_LANGUAGE.md),
686
+ [`docs/WEB.md`](docs/WEB.md) and [`docs/MOBILE.md`](docs/MOBILE.md). Versions are written by the release pipeline.
687
+
688
+ ## For consumers: the skill
689
+
690
+ [`.claude/skills/sincpro-client-framework/SKILL.md`](.claude/skills/sincpro-client-framework/SKILL.md)
691
+ is the guide for whoever builds on this package — an application, @sincpro/web-framework,
692
+ @sincpro/mobile — and for the agents that help them: the rule for what goes here, in a platform's
693
+ framework or in an application; the vocabulary; how an application is laid out; how to start the
694
+ process, reach the services over any wire, write a use case, decide what a screen does with a
695
+ failure, publish and subscribe to events, extend the pipeline, write a platform's adapter for each
696
+ port, and test; and the mistakes to avoid. It follows the format of sincpro_framework's skills, so
697
+ the shared knowledge base can carry it beside them.
423
698
 
424
699
  ## License
425
700