@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.
- package/AGENTS.md +95 -25
- package/MANIFESTO.md +103 -40
- package/README.md +608 -333
- package/dist/circuit_breaker/domain/circuit.d.ts +29 -0
- package/dist/circuit_breaker/domain/circuit.js +37 -0
- package/dist/circuit_breaker/entrypoint/circuit-breaker.d.ts +56 -0
- package/dist/circuit_breaker/entrypoint/circuit-breaker.js +94 -0
- package/dist/circuit_breaker/index.d.ts +9 -0
- package/dist/circuit_breaker/index.js +9 -0
- package/dist/client/adapters/json-rpc.d.ts +40 -0
- package/dist/client/adapters/json-rpc.js +97 -0
- package/dist/client/{memory.d.ts → adapters/memory.d.ts} +1 -1
- package/dist/client/{memory.js → adapters/memory.js} +2 -2
- package/dist/{adapters → client/adapters}/parquet.d.ts +1 -1
- package/dist/client/adapters/rest-operations.d.ts +19 -0
- package/dist/client/adapters/rest-operations.js +66 -0
- package/dist/client/{rest.d.ts → adapters/rest.d.ts} +1 -1
- package/dist/client/{rest.js → adapters/rest.js} +2 -2
- package/dist/client/{entity.d.ts → domain/entity.d.ts} +6 -10
- package/dist/client/domain/operation.d.ts +73 -0
- package/dist/client/domain/operation.js +19 -0
- package/dist/client/index.d.ts +20 -0
- package/dist/client/index.js +20 -0
- package/dist/client/services/guarded.d.ts +17 -0
- package/dist/client/services/guarded.js +19 -0
- package/dist/client/services/propagation.d.ts +45 -0
- package/dist/{services/execution.js → client/services/propagation.js} +49 -36
- package/dist/client/{retry.js → services/retry.js} +1 -1
- package/dist/codegen/{metamodel.d.ts → domain/metamodel.d.ts} +17 -2
- package/dist/codegen/{names.d.ts → domain/names.d.ts} +2 -2
- package/dist/codegen/{names.js → domain/names.js} +3 -3
- package/dist/codegen/{cli.js → entrypoint/cli.js} +15 -14
- package/dist/codegen/index.d.ts +7 -7
- package/dist/codegen/index.js +5 -10
- package/dist/codegen/services/client.d.ts +15 -0
- package/dist/codegen/services/client.js +111 -0
- package/dist/codegen/services/domain.d.ts +20 -0
- package/dist/codegen/{domain.js → services/domain.js} +82 -25
- package/dist/codegen/{entities.d.ts → services/entities.d.ts} +4 -3
- package/dist/codegen/{entities.js → services/entities.js} +18 -4
- package/dist/codegen/{rest.d.ts → services/rest.d.ts} +1 -1
- package/dist/codegen/{scaffold.d.ts → services/scaffold.d.ts} +8 -5
- package/dist/codegen/{scaffold.js → services/scaffold.js} +39 -43
- package/dist/common/failures.d.ts +83 -0
- package/dist/common/failures.js +212 -0
- package/dist/common/index.d.ts +11 -0
- package/dist/common/index.js +11 -0
- package/dist/context/adapters/memory.d.ts +6 -0
- package/dist/context/adapters/memory.js +34 -0
- package/dist/context/adapters/plain-codec.d.ts +3 -0
- package/dist/context/adapters/plain-codec.js +16 -0
- package/dist/context/{codec.d.ts → domain/codec.d.ts} +0 -1
- package/dist/context/{codec.js → domain/codec.js} +0 -14
- package/dist/context/domain/execution.d.ts +45 -0
- package/dist/context/domain/execution.js +11 -0
- package/dist/context/{store.d.ts → domain/store.d.ts} +0 -5
- package/dist/context/{store.js → domain/store.js} +1 -34
- package/dist/context/{context.d.ts → entrypoint/context.d.ts} +1 -1
- package/dist/context/index.d.ts +17 -0
- package/dist/context/index.js +15 -0
- package/dist/context/infrastructure/process.d.ts +13 -0
- package/dist/context/infrastructure/process.js +35 -0
- package/dist/core/domain/bus.d.ts +27 -0
- package/dist/core/domain/bus.js +26 -0
- package/dist/core/domain/dto.d.ts +62 -0
- package/dist/core/domain/dto.js +43 -0
- package/dist/core/domain/errors.d.ts +18 -0
- package/dist/core/domain/errors.js +1 -0
- package/dist/core/domain/pipeline.d.ts +89 -0
- package/dist/core/domain/pipeline.js +1 -0
- package/dist/core/domain/services.d.ts +47 -0
- package/dist/core/domain/services.js +31 -0
- package/dist/core/entrypoint/bounded-context.d.ts +139 -0
- package/dist/core/entrypoint/bounded-context.js +426 -0
- package/dist/core/entrypoint/screens.d.ts +6 -0
- package/dist/core/entrypoint/screens.js +136 -0
- package/dist/core/index.d.ts +19 -0
- package/dist/core/index.js +17 -0
- package/dist/core/infrastructure/listeners.d.ts +28 -0
- package/dist/core/infrastructure/listeners.js +34 -0
- package/dist/core/infrastructure/process.d.ts +56 -0
- package/dist/core/infrastructure/process.js +35 -0
- package/dist/core/services/binding.d.ts +6 -0
- package/dist/core/services/binding.js +16 -0
- package/dist/core/services/error-handlers.d.ts +11 -0
- package/dist/core/services/error-handlers.js +21 -0
- package/dist/core/services/execution.d.ts +66 -0
- package/dist/core/services/execution.js +216 -0
- package/dist/core/services/interceptors.d.ts +15 -0
- package/dist/core/services/interceptors.js +25 -0
- package/dist/core/services/listening.d.ts +17 -0
- package/dist/core/services/listening.js +25 -0
- package/dist/event_driven/adapters/memory-event-store.d.ts +10 -0
- package/dist/event_driven/adapters/memory-event-store.js +23 -0
- package/dist/event_driven/adapters/stored-queue.d.ts +67 -0
- package/dist/event_driven/adapters/stored-queue.js +192 -0
- package/dist/event_driven/adapters/sync-queue.d.ts +11 -0
- package/dist/event_driven/adapters/sync-queue.js +19 -0
- package/dist/event_driven/domain/envelope.d.ts +28 -0
- package/dist/event_driven/domain/envelope.js +19 -0
- package/dist/event_driven/domain/queue.d.ts +40 -0
- package/dist/event_driven/domain/queue.js +1 -0
- package/dist/event_driven/domain/store.d.ts +56 -0
- package/dist/event_driven/domain/store.js +1 -0
- package/dist/event_driven/entrypoint/publisher.d.ts +26 -0
- package/dist/event_driven/entrypoint/publisher.js +35 -0
- package/dist/event_driven/entrypoint/subscriber.d.ts +34 -0
- package/dist/event_driven/entrypoint/subscriber.js +51 -0
- package/dist/event_driven/index.d.ts +19 -0
- package/dist/event_driven/index.js +17 -0
- package/dist/index.d.ts +21 -38
- package/dist/index.js +21 -38
- package/dist/model/index.d.ts +10 -0
- package/dist/model/index.js +10 -0
- package/dist/model/{metas.d.ts → infrastructure/metas.d.ts} +1 -1
- package/dist/model/{schema.d.ts → services/schema.d.ts} +1 -1
- package/dist/model/{schema.js → services/schema.js} +1 -1
- package/dist/observability/adapters/console.d.ts +12 -0
- package/dist/observability/adapters/console.js +35 -0
- package/dist/observability/adapters/memory.d.ts +41 -0
- package/dist/observability/adapters/memory.js +49 -0
- package/dist/{adapters → observability/adapters}/opentelemetry.d.ts +1 -1
- package/dist/{adapters → observability/adapters}/sentry.d.ts +1 -1
- package/dist/observability/domain/errors.d.ts +23 -0
- package/dist/observability/domain/errors.js +8 -0
- package/dist/observability/domain/logging.d.ts +44 -0
- package/dist/observability/domain/logging.js +1 -0
- package/dist/observability/domain/tracing.d.ts +49 -0
- package/dist/observability/domain/tracing.js +20 -0
- package/dist/observability/index.d.ts +16 -0
- package/dist/observability/index.js +4 -0
- package/dist/observability/infrastructure/registry.d.ts +21 -0
- package/dist/observability/infrastructure/registry.js +18 -0
- package/dist/observability/services/logging.d.ts +27 -0
- package/dist/observability/services/logging.js +106 -0
- package/dist/observability/services/outcome.d.ts +12 -0
- package/dist/observability/services/outcome.js +17 -0
- package/dist/{services/report.js → observability/services/reporting.js} +4 -3
- package/dist/testing/index.d.ts +3 -33
- package/dist/testing/index.js +5 -37
- package/package.json +16 -8
- package/dist/codegen/client.d.ts +0 -14
- package/dist/codegen/client.js +0 -201
- package/dist/codegen/domain.d.ts +0 -15
- package/dist/observability/ports.d.ts +0 -65
- package/dist/observability/ports.js +0 -21
- package/dist/services/bounded-context.d.ts +0 -79
- package/dist/services/bounded-context.js +0 -261
- package/dist/services/execution.d.ts +0 -63
- package/dist/services/feature.d.ts +0 -85
- package/dist/services/feature.js +0 -86
- package/dist/services/process.d.ts +0 -60
- package/dist/services/process.js +0 -57
- package/dist/services/screens.d.ts +0 -2
- package/dist/services/screens.js +0 -76
- /package/dist/{adapters → client/adapters}/parquet.js +0 -0
- /package/dist/client/{contract.d.ts → domain/contract.d.ts} +0 -0
- /package/dist/client/{contract.js → domain/contract.js} +0 -0
- /package/dist/client/{entity.js → domain/entity.js} +0 -0
- /package/dist/client/{transport.d.ts → domain/transport.d.ts} +0 -0
- /package/dist/client/{transport.js → domain/transport.js} +0 -0
- /package/dist/client/{discovery.d.ts → services/discovery.d.ts} +0 -0
- /package/dist/client/{discovery.js → services/discovery.js} +0 -0
- /package/dist/client/{retry.d.ts → services/retry.d.ts} +0 -0
- /package/dist/codegen/{metamodel.js → domain/metamodel.js} +0 -0
- /package/dist/codegen/{cli.d.ts → entrypoint/cli.d.ts} +0 -0
- /package/dist/codegen/{check.d.ts → services/check.d.ts} +0 -0
- /package/dist/codegen/{check.js → services/check.js} +0 -0
- /package/dist/codegen/{rest.js → services/rest.js} +0 -0
- /package/dist/context/{context.js → entrypoint/context.js} +0 -0
- /package/dist/model/{meta.d.ts → domain/meta.d.ts} +0 -0
- /package/dist/model/{meta.js → domain/meta.js} +0 -0
- /package/dist/model/{metas.js → infrastructure/metas.js} +0 -0
- /package/dist/{adapters → observability/adapters}/opentelemetry.js +0 -0
- /package/dist/{adapters → observability/adapters}/sentry.js +0 -0
- /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
|
-
|
|
4
|
-
|
|
5
|
-
Features
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
class CommandTotal extends Command<ResponseTotal> {
|
|
39
|
+
constructor(readonly quantity: number) {
|
|
40
|
+
super();
|
|
63
41
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
class CommandQuote extends Command<ResponseTotal> {
|
|
45
|
+
constructor(readonly quantity: number) {
|
|
46
|
+
super();
|
|
69
47
|
}
|
|
70
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
export class ApplicationService extends BaseApplicationService<
|
|
138
|
-
ProjectDependencies,
|
|
139
|
-
ProjectFeatures
|
|
140
|
-
> {}
|
|
108
|
+
### Replacing a service
|
|
141
109
|
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
async execute(
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
//
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
//
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
183
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
191
|
-
its
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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
|
-
|
|
241
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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 {
|
|
281
|
-
import {
|
|
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
|
-
|
|
284
|
-
|
|
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
|
-
|
|
288
|
-
the same
|
|
289
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
393
|
+
### Logging
|
|
296
394
|
|
|
297
|
-
The
|
|
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 {
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
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
|
|
337
|
-
|
|
338
|
-
|
|
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 {
|
|
342
|
-
|
|
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
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
371
|
-
`
|
|
372
|
-
|
|
373
|
-
|
|
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
|
|
377
|
-
|
|
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
|
-
`
|
|
381
|
-
|
|
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
|
-
|
|
385
|
-
three ports, in its `main`:
|
|
560
|
+
### Types, not classes
|
|
386
561
|
|
|
387
|
-
|
|
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
|
-
|
|
395
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
|