@sincpro/client-framework 1.0.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 (78) hide show
  1. package/AGENTS.md +53 -12
  2. package/MANIFESTO.md +56 -13
  3. package/README.md +329 -33
  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 +6 -3
  26. package/dist/codegen/services/scaffold.js +14 -13
  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/core/domain/bus.d.ts +16 -7
  33. package/dist/core/domain/bus.js +14 -6
  34. package/dist/core/domain/dto.d.ts +32 -2
  35. package/dist/core/domain/dto.js +23 -0
  36. package/dist/core/domain/pipeline.d.ts +89 -0
  37. package/dist/core/domain/pipeline.js +1 -0
  38. package/dist/core/domain/services.d.ts +14 -4
  39. package/dist/core/domain/services.js +4 -1
  40. package/dist/core/entrypoint/bounded-context.d.ts +79 -10
  41. package/dist/core/entrypoint/bounded-context.js +207 -33
  42. package/dist/core/index.d.ts +4 -2
  43. package/dist/core/index.js +3 -2
  44. package/dist/core/infrastructure/listeners.d.ts +28 -0
  45. package/dist/core/infrastructure/listeners.js +34 -0
  46. package/dist/core/services/binding.d.ts +2 -2
  47. package/dist/core/services/binding.js +6 -3
  48. package/dist/core/services/execution.d.ts +37 -6
  49. package/dist/core/services/execution.js +86 -7
  50. package/dist/core/services/interceptors.d.ts +15 -0
  51. package/dist/core/services/interceptors.js +25 -0
  52. package/dist/core/services/listening.d.ts +17 -0
  53. package/dist/core/services/listening.js +25 -0
  54. package/dist/event_driven/adapters/memory-event-store.d.ts +10 -0
  55. package/dist/event_driven/adapters/memory-event-store.js +23 -0
  56. package/dist/event_driven/adapters/stored-queue.d.ts +67 -0
  57. package/dist/event_driven/adapters/stored-queue.js +192 -0
  58. package/dist/event_driven/adapters/sync-queue.d.ts +11 -0
  59. package/dist/event_driven/adapters/sync-queue.js +19 -0
  60. package/dist/event_driven/domain/envelope.d.ts +28 -0
  61. package/dist/event_driven/domain/envelope.js +19 -0
  62. package/dist/event_driven/domain/queue.d.ts +40 -0
  63. package/dist/event_driven/domain/queue.js +1 -0
  64. package/dist/event_driven/domain/store.d.ts +56 -0
  65. package/dist/event_driven/domain/store.js +1 -0
  66. package/dist/event_driven/entrypoint/publisher.d.ts +26 -0
  67. package/dist/event_driven/entrypoint/publisher.js +35 -0
  68. package/dist/event_driven/entrypoint/subscriber.d.ts +34 -0
  69. package/dist/event_driven/entrypoint/subscriber.js +51 -0
  70. package/dist/event_driven/index.d.ts +19 -0
  71. package/dist/event_driven/index.js +17 -0
  72. package/dist/index.d.ts +7 -2
  73. package/dist/index.js +7 -2
  74. package/dist/observability/domain/logging.d.ts +2 -0
  75. package/dist/observability/services/logging.js +1 -0
  76. package/dist/observability/services/outcome.d.ts +6 -6
  77. package/dist/observability/services/outcome.js +10 -13
  78. package/package.json +1 -1
package/AGENTS.md CHANGED
@@ -25,17 +25,18 @@ Each module is laid out as sincpro_framework's are: `domain/` (ports, types and
25
25
  (the process's state and technical support) and `entrypoint/` (the doors a caller uses). A
26
26
  module has only the layers it needs, and says what it offers in its own `index.ts`.
27
27
 
28
- | Folder | What |
29
- | -------------------- | ---------------------------------------------------------------------------------------------- |
30
- | `src/common/` | The values of the wire: ids, a day and an instant, `Decimal`, `Money`, `valuesOf` / `rebuilt` |
31
- | `src/context/` | `ContextStore`, codec, `Execution` (domain); memory and plain codec; `Context`; the process's |
32
- | `src/observability/` | Tracer/ErrorReporter/Logger ports; console, memory, OpenTelemetry, Sentry; loggers, reporting |
33
- | `src/client/` | `Transport`, `entity()`, the contract (domain); REST, memory, Parquet; discovery, propagation |
34
- | `src/model/` | Model definitions: `defineMeta`, from a JSON Schema, the store of known ones |
35
- | `src/core/` | DTOs, bus, Feature/ApplicationService, error handlers; pipeline; `BoundedContext`; the process |
36
- | `src/codegen/` | The generator and its CLI (`sincpro-client generate`) — the only place Node is allowed |
37
- | `src/testing/` | The public entry of the in-memory observability adapters |
38
- | `tests/` | vitest in Node, one folder per module; `tests/support/` holds fakes and forge's contracts |
28
+ | Folder | What |
29
+ | -------------------- | ----------------------------------------------------------------------------------------------- |
30
+ | `src/common/` | The values of the wire: ids, a day and an instant, `Decimal`, `Money`, `valuesOf` / `rebuilt` |
31
+ | `src/context/` | `ContextStore`, codec, `Execution` (domain); memory and plain codec; `Context`; the process's |
32
+ | `src/observability/` | Tracer/ErrorReporter/Logger ports; console, memory, OpenTelemetry, Sentry; loggers, reporting |
33
+ | `src/client/` | `Transport`, `entity()`, the contract (domain); REST, memory, Parquet; discovery, propagation |
34
+ | `src/model/` | Model definitions: `defineMeta`, from a JSON Schema, the store of known ones |
35
+ | `src/core/` | DTOs and events, bus, services, handlers, extension points; pipeline; `BoundedContext`; process |
36
+ | `src/event_driven/` | `Envelope`, `EventQueue`/`Subscribes` ports; `syncQueue`; `Publisher`, `Subscriber` |
37
+ | `src/codegen/` | The generator and its CLI (`sincpro-client generate`) — the only place Node is allowed |
38
+ | `src/testing/` | The public entry of the in-memory observability adapters |
39
+ | `tests/` | vitest in Node, one folder per module; `tests/support/` holds fakes and forge's contracts |
39
40
 
40
41
  ## Hard rules (the build enforces them — `make doctor`)
41
42
 
@@ -70,7 +71,41 @@ module has only the layers it needs, and says what it offers in its own `index.t
70
71
  instance, shared by the next call, the call beside it and every `scoped` variant — as in
71
72
  sincpro_framework. What belongs to one request goes in its arguments or its context.
72
73
  - **A span uses stable service metadata, not constructor.name.** `serviceName = "Cancel"` gives
73
- `project/Cancel`, identity `project.Cancel`. Commands route by exact constructor identity.
74
+ `project/Cancel`, identity `project.Cancel`. Commands and events route by exact constructor
75
+ identity. An event declares `static eventName` (its wire name) for the same reason; `name` is
76
+ reserved for it.
77
+ - **A replacement names what it replaces.** `feature(dto, { replaces: Service })` is refused
78
+ unless `Service` is registered now for `dto`; its spans carry `sincpro.replaces`. A service
79
+ meant to be subclassed declares `static readonly serviceName: string = "…"`, or the literal
80
+ type stops a subclass from renaming itself.
81
+ - **How events are carried is here; where they are kept is the platform's.** One subscriber
82
+ per bounded context; a `Subscriber` executes an event in each. `storedQueue` holds the rules —
83
+ store first, deliver in order, retry what may succeed, set aside what never will, never run a
84
+ context twice — over an `EventStore` the platform plugs (SQLite, IndexedDB, memory). A store
85
+ only keeps records; it never decides.
86
+ - **A circuit belongs where the dependency is.** `CircuitBreaker` (in the process's memory, the
87
+ backend's semantics: `COUNTED_KINDS`, half-open with one probe) guards a transport
88
+ (`guardedTransport`) or an adapter; its `CircuitOpen` declares `unavailable` and a wait, so
89
+ nothing else needs to know a circuit exists.
90
+ - **Delivery is at least once.** A stored event may run twice in a context after a crash; a
91
+ subscriber keys its effect by `this.execution.causationId` (the envelope's id). `maxAttempts`
92
+ sets aside what keeps failing. One process drains a store. `execution` is reserved.
93
+ - **A failure says its kind, whatever the wire.** `failureOf` reads a declared `failureKind`, the
94
+ `Problem` (the same over REST, JSON-RPC and gRPC), then a REST status. A transport of another
95
+ wire, or another backend's adapter, declares the kind with `kindOfGrpcCode` /
96
+ `kindOfJsonRpcCode` / `kindOfHttpStatus` — never a table of its own. Retrying, setting aside,
97
+ reporting and the outcome all follow from it.
98
+ - **A run can be cancelled.** `execute(…, { signal })`: it ends `aborted` at once, its Features
99
+ inherit the signal, `this.signal` reads it, and a bound transport sends it with every request.
100
+ `signal` is reserved, like `context` and `logger`.
101
+ - **No variable named `process`.** `make doctor` reads `process.` as Node outside `codegen/`.
102
+ - **A run belongs to a flow.** `correlationId` is shared by every run and event of one flow;
103
+ `causationId` names what directly caused it. A dependency carrying `BIND` (the `Publisher`)
104
+ is bound to the run, so what it publishes joins that flow with nothing passed by hand.
105
+ - **The pipeline is closed for modification, open for extension.** Change the flow in
106
+ `executeService` only for every run; what one application needs goes through an interceptor
107
+ (control, inside the run, keeps the class), a listener (observation, never changes the
108
+ answer, its failure ignored) or `extend`.
74
109
  - **Dependency declarations do not register anything.** Service bases declare typed attributes
75
110
  from a shared dependency interface. Only `addDependency` supplies runtime values; a missing
76
111
  dependency fails explicitly.
@@ -87,6 +122,12 @@ module has only the layers it needs, and says what it offers in its own `index.t
87
122
  - **A store's `load` may answer a new object each time** (one that parses JSON does): `Context`
88
123
  keeps one object per `version`, so a screen can hand `values` to `useSyncExternalStore`. A store
89
124
  that changes without moving the version is a broken store.
125
+ - **The generated client never names a wire.** A method is `transport.call(operations.X, input,
126
+ options)`: no route, no verb, no RPC method. The contract's bindings (`rest`, `rpc`, `grpc`,
127
+ `mcp`) are data in `operations`; an `OperationTransport` adapter reads the one of its wire
128
+ (`restOperations`, `jsonRpcOperations`, a platform's gRPC) and throws `OperationNotPublished`
129
+ for the rest. A run binds an `OperationTransport` as it binds a `Transport`: headers and signal.
130
+ The REST `Transport` stays for `restOperations` and for screens (`entity()`, pages, drafts).
90
131
  - **What is generated is types, never classes.** A generated DTO is the remote contract: a
91
132
  class extending `Command` could be registered on the local bus, which is what keeps a remote
92
133
  operation and a local use case apart. Runtime knowledge goes in data (`operations`, `events`);
package/MANIFESTO.md CHANGED
@@ -28,8 +28,11 @@ server-side render, in a script and in a test, unchanged.**
28
28
  1. **Services.** A `Feature` executes a registered Command through `execute(dto)`;
29
29
  an `ApplicationService` composes its context's Features through `featureBus`, never another
30
30
  ApplicationService. Dependencies are typed attributes supplied by the bounded context.
31
- 2. **Execution.** Every command enters one internal execution pipeline. Context, tracing,
32
- outcomes and error reporting are supplied there, not handwritten by each service.
31
+ 2. **Execution.** Every Command and event enters one execution pipeline, defined here: context,
32
+ chain, span, log lines, outcome, report, error handlers — not handwritten by each service. An
33
+ application changes it from outside through its extension points, as the backend's
34
+ `bus_pipeline`: interceptors control a run (look, veto, adjust, answer); listeners hear it
35
+ start, answer or fail, and never change it; an extension wires a component in one place.
33
36
  3. **Observability, as ports.** `Tracer`, `ErrorReporter` and `Logger` are the contracts: one span per use
34
37
  case, named `<bounded context>/<serviceName>`; the unexpected reported
35
38
  with its trace id; what the server already explained (a validation, a conflict), or what a
@@ -47,7 +50,9 @@ server-side render, in a script and in a test, unchanged.**
47
50
  Features and ApplicationServices, registered for class DTOs — and called as
48
51
  `project.execute(new Command(...))`. The bounded context is the bus. An application is the set
49
52
  of bounded contexts it loads; they never import each other's insides.
50
- 6. **The client.** The `Transport` port and its implementations (REST, memory), `entity()` with
53
+ 6. **The client.** The `OperationTransport` port — an operation called by who it is, over any
54
+ wire — with its REST and JSON-RPC adapters (a platform adds gRPC or its own); the `Transport`
55
+ port and its implementations (REST, memory), `entity()` with
51
56
  typed commands and answers, the service's client contract, model definitions read once from
52
57
  the server and the server's `Problem`.
53
58
  7. **The values of the wire.** How every value travels, as the backend writes it — a decimal as
@@ -62,9 +67,18 @@ server-side render, in a script and in a test, unchanged.**
62
67
  10. **The command core (`./core`).** The single model matching the backend's application
63
68
  abstractions: class DTOs and Responses, a bounded context that is a bus, Features and
64
69
  ApplicationServices registered for DTO classes, attribute-injected dependencies and a
65
- feature bus for composition, and the backend's error handlers — global, feature and
66
- application service — with the errors a bounded context expects. The internal engine uses
70
+ feature bus for composition, domain events executed by their subscribers, replacements,
71
+ and the backend's error handlers — global, feature and application service — with the
72
+ errors a bounded context expects. The internal engine uses
67
73
  the injected process ports. Remote clients and mapping remain distinct.
74
+ 11. **Event-driven, as ports (`event_driven`).** How every platform publishes domain events and
75
+ subscribes to them the same way: the envelope that travels beside an event (its flow and
76
+ its cause), the `EventQueue` and `Subscribes` ports, a `Publisher` a Feature holds, a
77
+ `Subscriber` over the bounded contexts that hear, the queue that delivers in the call, and
78
+ the stored queue's rules over an `EventStore` the platform plugs. The guidelines, the rules
79
+ and the contracts are here; where events are kept is the platform's.
80
+ 12. **Resilience.** What a failure is (`failureOf`, the backend's kinds on every wire) and the
81
+ backend's circuit breaker, guarding any transport or adapter.
68
82
 
69
83
  ## What it is not
70
84
 
@@ -84,8 +98,10 @@ server-side render, in a script and in a test, unchanged.**
84
98
  - **Not where application-specific business rules live.** The package supplies execution
85
99
  abstractions; a registered use case may delegate to a server or use a local domain. A remote
86
100
  adapter does not authorize reimplementing the server's rules.
87
- - **Not an event delivery system.** The command dispatcher in `./core` executes use cases.
88
- An event contract does not supply a publisher, queue, outbox, broker or synchronization engine.
101
+ - **Not a durable event delivery system.** It defines how events are published, carried and
102
+ executed — the ports, the envelope, the in-call queue — so web and mobile do it alike; storing
103
+ them, retrying, a dead-letter queue, waiting for a connection, other tabs, a broker are the
104
+ platform's adapters behind `EventQueue`.
89
105
 
90
106
  ## What we believe
91
107
 
@@ -130,6 +146,14 @@ server-side render, in a script and in a test, unchanged.**
130
146
 
131
147
  Dependencies point down only. This package knows nothing above it.
132
148
 
149
+ **What goes where.** client-framework + web-framework is to a web application what
150
+ sincpro_framework is to a backend; client-framework + @sincpro/mobile, the same for a phone. A
151
+ piece web and mobile can both use, and that needs no platform, comes here — as a port, its rules
152
+ and a memory adapter. A piece only one platform needs, or that needs a platform's technology, is
153
+ that platform's — an adapter of a port. An application's business is the application's. Nothing
154
+ is replicated: what differs by nature (a phone's SQLite domain, a browser's entity screens) stays
155
+ different, and the platforms meet here only through the ports.
156
+
133
157
  ## Where it stands
134
158
 
135
159
  **The application layer is the backend's.** A bounded context executes class Commands through
@@ -145,16 +169,35 @@ bounded context has its own, and a run hands `this.logger`, bound to its executi
145
169
  infrastructure and the adapters have a predefined one.
146
170
 
147
171
  **The client and the generator read the backend's contract.** Types per model, operation and —
148
- when the contract is exported with them — domain event; a client per wire REST reaches; the
149
- entities screens use. What is generated is types, never classes: the remote contract describes
150
- the wire and never reaches the local bus.
172
+ when the contract is exported with them — domain event; a client whose methods call each
173
+ operation by who it is, over the `OperationTransport` the application injects — REST, JSON-RPC,
174
+ a platform's gRPC —; the entities screens use. What is generated is types, never classes, and
175
+ never names a wire: the contract's bindings are data, read by the adapter of each wire.
151
176
 
152
177
  **Each module is laid out as the backend's.** `domain/`, `adapters/`, `services/`,
153
178
  `infrastructure/`, `entrypoint/`, only those a module needs; `domain/` depends only on `domain/`.
154
179
 
155
- **Next:** web and mobile adopt it in their own releases; generated readers that validate and
156
- convert what arrives, when a use needs them; durable delivery, designed apart from
157
- instrumentation.
180
+ **The pipeline is the core's, and open.** The flow of a run is defined once; interceptors,
181
+ listeners and extensions change or hear it from outside, without touching a service.
182
+
183
+ **Events travel the same way on every platform.** A Feature publishes through a `Publisher`;
184
+ a `Subscriber` executes the event in every bounded context that hears it, in the same flow
185
+ (`correlationId`, caused by the run that published it). In the call (`syncQueue`), or stored
186
+ first and delivered by the backend's store-and-forward rules (`storedQueue`) over the
187
+ `EventStore` each platform plugs — a SQLite table, IndexedDB, memory.
188
+
189
+ **A failure means the same thing everywhere.** `failureOf` reads any error — whatever the wire,
190
+ whatever backend — in the backend's kinds and next steps; retrying, setting aside, reporting and
191
+ a screen's answer follow from it. A run can be cancelled, and its requests with it. The
192
+ backend's circuit breaker guards any transport.
193
+
194
+ **Consumers have a guide.** The `sincpro-client-framework` skill says, for an application and for
195
+ a platform's framework, what goes where and how each thing is done.
196
+
197
+ **Next:** web and mobile adopt it in their own releases — mobile's SQLite `EventStore` and gRPC or
198
+ JSON-RPC wire, web's in-memory or IndexedDB store and its hooks —, declaring this package as a
199
+ peer dependency; events from the server live, with the backend's server-sent events; generated
200
+ readers that validate and convert what arrives, when a use needs them.
158
201
 
159
202
  ## How we decide
160
203
 
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)
@@ -103,6 +105,164 @@ without a decorator transform, call them: `sales.feature(CommandTotal)(Total)`.
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
+ printing.feature(CommandPrint, { replaces: Print })(PrintOnBixolon);
118
+ printing.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 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
+
133
+ ```ts
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();
138
+ }
139
+ }
140
+
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;
171
+ }
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
191
+
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
201
+ ```
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
+
237
+ ```ts
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);
242
+ });
243
+ shop.interceptor()((executable, next, run) => next(executable)); // every class
244
+
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);
252
+ ```
253
+
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.
265
+
106
266
  ## Errors
107
267
 
108
268
  As the backend's, a bounded context has three chains of error handlers, run
@@ -126,18 +286,81 @@ the answer, not whether the failure is seen; an answered one adds an `error.hand
126
286
  feature handler answers wherever the Feature ran, inside an ApplicationService too; the global
127
287
  chain wraps only `execute` and `scoped(...).execute`.
128
288
 
129
- How an error ended a use case:
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
303
+ ```
304
+
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
+ }
323
+ ```
324
+
325
+ How an error ended a use case follows from it (`outcomeOf`):
130
326
 
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 |
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 |
137
333
 
138
334
  `configureProcess({ outcomeOf })` replaces the process-wide rule. `reportError(error, where)`
139
335
  reports what fails outside a use case — a view, a worker —, with the same rule.
140
336
 
337
+ ### Cancelling a run
338
+
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
343
+ ```
344
+
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
354
+ ```
355
+
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
+
141
364
  ## Observability
142
365
 
143
366
  The platform plugs the implementations once, at startup; nothing breaks without them:
@@ -229,17 +452,67 @@ its own. `values` is the same object until it changes, so a screen can hand it t
229
452
 
230
453
  ## Talking to a server
231
454
 
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 |
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.
458
+
459
+ ```ts
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.
492
+
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
+ }
511
+ ```
240
512
 
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.
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.
243
516
 
244
517
  ## Generated code
245
518
 
@@ -252,14 +525,18 @@ sincpro-client check --root src
252
525
 
253
526
  `generate` writes, from the contract the backend exports — never edited, written again whole:
254
527
 
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 |
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 |
260
533
 
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.
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.
263
540
 
264
541
  **Domain events, on demand.** A backend leaves its events out of the contract unless it is
265
542
  exported with `--events`. Then each event is a type as it is received — every field present,
@@ -341,7 +618,8 @@ Each module is laid out as sincpro_framework's are, and says what it offers in i
341
618
 
342
619
  ```text
343
620
  src/
344
- common/ the values of the wire
621
+ common/ the values of the wire, and what kind of failure an error is
622
+ circuit_breaker/ CircuitBreaker, CircuitOpen
345
623
  context/
346
624
  domain/ ContextStore and ContextCodec ports, what travels, Execution
347
625
  adapters/ memoryContexts, plainCodec
@@ -353,15 +631,22 @@ src/
353
631
  infrastructure/ the ports this process runs with
354
632
  services/ logger, loggerAdapter, reportError, outcomeOf
355
633
  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
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
359
639
  model/ defineMeta, metaFromSchema, metaStore
360
640
  core/
361
- domain/ Command, Response, Feature, ApplicationService, FeatureBus, ErrorHandler
362
- services/ the execution pipeline, dependency binding, the handler chain
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
363
644
  entrypoint/ BoundedContext, screensTransport
364
- infrastructure/ configureProcess, configureLogger
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
365
650
  codegen/ the generator and its CLI — the only place Node is allowed
366
651
  testing/ memoryTracer, memoryReporter, memoryLogger
367
652
  ```
@@ -397,8 +682,19 @@ The runtime uses `fetch`, `AbortSignal`, `URL`, `crypto.getRandomValues`, `Proxy
397
682
 
398
683
  Read [`MANIFESTO.md`](MANIFESTO.md) (what this package is and is not), [`AGENTS.md`](AGENTS.md)
399
684
  (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.
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.
402
698
 
403
699
  ## License
404
700