@sincpro/client-framework 0.5.0 → 1.0.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 +54 -25
- package/MANIFESTO.md +59 -39
- package/README.md +329 -328
- package/dist/client/{memory.d.ts → adapters/memory.d.ts} +1 -1
- package/dist/client/{memory.js → adapters/memory.js} +7 -5
- package/dist/client/adapters/parquet.d.ts +50 -0
- package/dist/client/adapters/parquet.js +151 -0
- package/dist/client/{rest.d.ts → adapters/rest.d.ts} +7 -1
- package/dist/client/{rest.js → adapters/rest.js} +48 -3
- package/dist/client/{entity.d.ts → domain/entity.d.ts} +22 -11
- package/dist/client/{entity.js → domain/entity.js} +16 -1
- package/dist/client/{transport.d.ts → domain/transport.d.ts} +37 -1
- package/dist/client/{transport.js → domain/transport.js} +6 -0
- package/dist/client/index.d.ts +16 -0
- package/dist/client/index.js +16 -0
- package/dist/client/services/propagation.d.ts +32 -0
- package/dist/{services/execution.js → client/services/propagation.js} +8 -21
- package/dist/client/{retry.js → services/retry.js} +1 -1
- package/dist/codegen/{metamodel.d.ts → domain/metamodel.d.ts} +21 -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} +9 -8
- package/dist/codegen/index.d.ts +7 -7
- package/dist/codegen/index.js +5 -10
- package/dist/codegen/{client.d.ts → services/client.d.ts} +3 -1
- package/dist/codegen/{client.js → services/client.js} +34 -5
- package/dist/codegen/services/domain.d.ts +20 -0
- package/dist/codegen/{domain.js → services/domain.js} +121 -23
- package/dist/codegen/{entities.d.ts → services/entities.d.ts} +4 -2
- package/dist/codegen/{entities.js → services/entities.js} +34 -4
- package/dist/codegen/{rest.d.ts → services/rest.d.ts} +1 -1
- package/dist/codegen/{scaffold.d.ts → services/scaffold.d.ts} +2 -2
- package/dist/codegen/{scaffold.js → services/scaffold.js} +31 -36
- package/dist/common/collection.d.ts +110 -0
- package/dist/common/collection.js +169 -0
- package/dist/common/index.d.ts +10 -0
- package/dist/common/index.js +10 -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 +33 -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 +18 -0
- package/dist/core/domain/bus.js +18 -0
- package/dist/core/domain/dto.d.ts +32 -0
- package/dist/core/domain/dto.js +20 -0
- package/dist/core/domain/errors.d.ts +18 -0
- package/dist/core/domain/errors.js +1 -0
- package/dist/core/domain/services.d.ts +37 -0
- package/dist/core/domain/services.js +28 -0
- package/dist/core/entrypoint/bounded-context.d.ts +70 -0
- package/dist/core/entrypoint/bounded-context.js +252 -0
- package/dist/core/entrypoint/screens.d.ts +6 -0
- package/dist/core/entrypoint/screens.js +136 -0
- package/dist/core/index.d.ts +17 -0
- package/dist/core/index.js +16 -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 +13 -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 +35 -0
- package/dist/core/services/execution.js +137 -0
- package/dist/index.d.ts +16 -36
- package/dist/index.js +16 -36
- package/dist/model/{meta.d.ts → domain/meta.d.ts} +1 -0
- 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 +42 -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 +105 -0
- package/dist/observability/services/outcome.d.ts +12 -0
- package/dist/observability/services/outcome.js +20 -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 +25 -8
- 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 -67
- /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/{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.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,403 +1,404 @@
|
|
|
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
|
-
direction LR
|
|
21
|
-
class UseBoundedContext {
|
|
22
|
-
+name
|
|
23
|
-
+context : Context
|
|
24
|
-
+addDependency(name, value)
|
|
25
|
-
+register(features, applicationServices) BoundedContext
|
|
26
|
-
}
|
|
27
|
-
class BoundedContext {
|
|
28
|
-
+Issues : the methods of Issues
|
|
29
|
-
+CloseAndNotify : the methods of CloseAndNotify
|
|
30
|
-
+context : Context
|
|
31
|
-
+with(dependencies) BoundedContext
|
|
32
|
-
+scoped(context) BoundedContext
|
|
33
|
-
}
|
|
34
|
-
class Feature {
|
|
35
|
-
#client, session : each dependency
|
|
36
|
-
#context : what this run executes with
|
|
37
|
-
}
|
|
38
|
-
class ApplicationService {
|
|
39
|
-
#feature : Issues, Notices
|
|
40
|
-
}
|
|
41
|
-
class Context {
|
|
42
|
-
+values
|
|
43
|
-
+get(name)
|
|
44
|
-
+set(values)
|
|
45
|
-
+clear(names)
|
|
46
|
-
+watch(listener)
|
|
47
|
-
}
|
|
48
|
-
class ContextStore {
|
|
49
|
-
<<port>>
|
|
50
|
-
+load(key)
|
|
51
|
-
+save(key, values)
|
|
52
|
-
+delete(key)
|
|
53
|
-
+version(key)
|
|
54
|
-
+watch(key, listener)
|
|
55
|
-
}
|
|
56
|
-
class Tracer {
|
|
57
|
-
<<port>>
|
|
58
|
-
+start(name, parent, attributes) Span
|
|
59
|
-
}
|
|
60
|
-
class ErrorReporter {
|
|
61
|
-
<<port>>
|
|
62
|
-
+report(error, about)
|
|
63
|
-
}
|
|
64
|
-
class Transport {
|
|
65
|
-
<<port>>
|
|
66
|
-
+list(route, criteria)
|
|
67
|
-
+one(route)
|
|
68
|
-
+send(method, route, body)
|
|
69
|
-
}
|
|
70
|
-
UseBoundedContext --> BoundedContext : register
|
|
71
|
-
BoundedContext o-- Feature : each by its name
|
|
72
|
-
Feature <|-- ApplicationService
|
|
73
|
-
BoundedContext --> Context : its level
|
|
74
|
-
Context --> ContextStore : kept in
|
|
75
|
-
BoundedContext ..> Tracer : a span per use case
|
|
76
|
-
BoundedContext ..> ErrorReporter : what nobody expected
|
|
77
|
-
Feature ..> Transport : through its generated client
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
`configureProcess({ tracer, reporter, context })` plugs the ports once; `processContext` is the
|
|
81
|
-
process's level of the context. Beside them: the generated client (`sincpro-client generate`),
|
|
82
|
-
`entity()`, model definitions (`defineMeta`, `metaFromSchema`) and client-side ids.
|
|
83
|
-
|
|
84
|
-
## A bounded context, set up like the backend's
|
|
85
|
-
|
|
86
|
-
Each bounded context of an application has the layout sincpro_framework gives a service: what its
|
|
87
|
-
services can count on, its bases, its services, and the one object the rest of the application
|
|
88
|
-
uses it through.
|
|
89
|
-
|
|
90
|
-
```mermaid
|
|
91
|
-
flowchart LR
|
|
92
|
-
subgraph py["sincpro_framework · Python"]
|
|
93
|
-
direction TB
|
|
94
|
-
pd["infrastructure/dependencies.py<br/>DependencyContextType · add_dependency"]
|
|
95
|
-
pf["infrastructure/framework.py<br/>Feature · ApplicationService · UseFramework"]
|
|
96
|
-
ps["services/<br/>@bus.feature · @bus.app_service"]
|
|
97
|
-
pi["__init__.py<br/>the bounded context"]
|
|
98
|
-
pd --> pf --> ps --> pi
|
|
99
|
-
end
|
|
100
|
-
subgraph ts["@sincpro/client-framework · TypeScript"]
|
|
101
|
-
direction TB
|
|
102
|
-
td["infrastructure/dependencies.ts<br/>ProjectDependencies · addDependency"]
|
|
103
|
-
tf["infrastructure/bounded_context.ts<br/>Feature · ApplicationService · UseBoundedContext"]
|
|
104
|
-
tsv["services/<br/>features · applicationServices"]
|
|
105
|
-
ti["index.ts<br/>export const project"]
|
|
106
|
-
td --> tf --> tsv --> ti
|
|
107
|
-
end
|
|
108
|
-
ti --> users["a screen · another bounded context · a test<br/>project.Issues.close(input)"]
|
|
109
|
-
pi -. "the client contract" .-> td
|
|
110
|
-
```
|
|
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
|
+
- [Errors](#errors)
|
|
11
|
+
- [Observability](#observability)
|
|
12
|
+
- [Context](#context)
|
|
13
|
+
- [Talking to a server](#talking-to-a-server)
|
|
14
|
+
- [Generated code](#generated-code)
|
|
15
|
+
- [Values on the wire](#values-on-the-wire)
|
|
16
|
+
- [Package layout](#package-layout)
|
|
17
|
+
- [Development](#development)
|
|
18
|
+
|
|
19
|
+
## A bounded context
|
|
111
20
|
|
|
112
21
|
```ts
|
|
113
|
-
// domains/project/infrastructure/dependencies.ts — what the services can count on
|
|
114
|
-
export interface ProjectDependencies {
|
|
115
|
-
client: ProjectClient; // generated from the service's contract
|
|
116
|
-
session: Session;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
export function registerDependencies(bc: UseBoundedContext<ProjectDependencies>) {
|
|
120
|
-
bc.addDependency("client", clientOf(transport));
|
|
121
|
-
bc.addDependency("session", session);
|
|
122
|
-
return bc;
|
|
123
|
-
}
|
|
124
|
-
```
|
|
125
|
-
|
|
126
|
-
```ts
|
|
127
|
-
// domains/project/infrastructure/bounded_context.ts
|
|
128
22
|
import {
|
|
129
|
-
ApplicationService
|
|
130
|
-
|
|
131
|
-
|
|
23
|
+
ApplicationService,
|
|
24
|
+
BoundedContext,
|
|
25
|
+
Command,
|
|
26
|
+
Feature,
|
|
27
|
+
Response,
|
|
132
28
|
} from "@sincpro/client-framework";
|
|
133
29
|
|
|
134
|
-
|
|
30
|
+
class ResponseTotal extends Response {
|
|
31
|
+
constructor(readonly total: number) {
|
|
32
|
+
super();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
135
35
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
36
|
+
class CommandTotal extends Command<ResponseTotal> {
|
|
37
|
+
constructor(readonly quantity: number) {
|
|
38
|
+
super();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
141
41
|
|
|
142
|
-
|
|
143
|
-
|
|
42
|
+
class CommandQuote extends Command<ResponseTotal> {
|
|
43
|
+
constructor(readonly quantity: number) {
|
|
44
|
+
super();
|
|
45
|
+
}
|
|
144
46
|
}
|
|
145
|
-
```
|
|
146
47
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
48
|
+
class Total extends Feature<CommandTotal, ResponseTotal> {
|
|
49
|
+
static readonly serviceName = "Total";
|
|
50
|
+
declare readonly unitPrice: number; // a dependency, typed
|
|
51
|
+
|
|
52
|
+
execute(command: CommandTotal): ResponseTotal {
|
|
53
|
+
this.logger.info("Totalling", { quantity: command.quantity });
|
|
54
|
+
return new ResponseTotal(command.quantity * this.unitPrice);
|
|
153
55
|
}
|
|
154
56
|
}
|
|
155
57
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
return this.
|
|
58
|
+
class Quote extends ApplicationService<CommandQuote, ResponseTotal> {
|
|
59
|
+
static readonly serviceName = "Quote";
|
|
60
|
+
|
|
61
|
+
execute(command: CommandQuote): Promise<ResponseTotal> {
|
|
62
|
+
return this.featureBus.execute(new CommandTotal(command.quantity));
|
|
161
63
|
}
|
|
162
64
|
}
|
|
163
65
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
66
|
+
const sales = new BoundedContext<{ unitPrice: number }>("sales");
|
|
67
|
+
sales.addDependency("unitPrice", 10);
|
|
68
|
+
sales.feature(CommandTotal)(Total);
|
|
69
|
+
sales.appService(CommandQuote)(Quote);
|
|
70
|
+
|
|
71
|
+
const answer = await sales.execute(new CommandQuote(3)); // ResponseTotal — total 30
|
|
168
72
|
```
|
|
169
73
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
74
|
+
A Command names, by its type, the Response it answers: `execute` infers it, with no second
|
|
75
|
+
argument (the backend's `bus(dto, ResponseType)` is accepted too, and checked in TypeScript
|
|
76
|
+
only). A Response may be a class, a plain object, a page, a primitive or `void`.
|
|
77
|
+
|
|
78
|
+
- **One Command, one service.** A Command class is registered once, across both layers, and
|
|
79
|
+
dispatched by its exact constructor. `serviceName` names the service in spans and logs —
|
|
80
|
+
stable, where a minifier renames classes.
|
|
81
|
+
- **Dependencies are attributes.** `addDependency` supplies them; a service declares their types
|
|
82
|
+
(`declare readonly client: …`), usually once on a base class per bounded context, as
|
|
83
|
+
`sincpro-client scaffold` writes it. Declaring registers nothing; reading a missing one fails.
|
|
84
|
+
A replacement names what it replaces: `addDependency(name, next, { replaces: previous })`.
|
|
85
|
+
- **A service is a singleton of its generation.** What belongs to one request goes in the
|
|
86
|
+
Command or the context, never on `this`. Use TypeScript `private`, not `#private`: a run sees
|
|
87
|
+
the instance through a view of its own.
|
|
88
|
+
- **Generations.** The first call (or `buildRootBus()`) builds one; what is registered later
|
|
89
|
+
answers from the next call, and a running call keeps its own. `fresh()` copies the
|
|
90
|
+
registrations; `scoped({ company_id: 2 })` runs with more context and the same instances.
|
|
91
|
+
- **Composition.** An ApplicationService runs its context's Features through `this.featureBus`,
|
|
92
|
+
as children of its own execution; never another ApplicationService.
|
|
93
|
+
|
|
94
|
+
### Decorators, when the project has them
|
|
176
95
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
96
|
+
```ts
|
|
97
|
+
@sales.feature(CommandTotal)
|
|
98
|
+
class Total extends Feature<CommandTotal, ResponseTotal> { … }
|
|
180
99
|
```
|
|
181
100
|
|
|
182
|
-
The
|
|
183
|
-
a
|
|
101
|
+
The same functions work as standard decorators and as legacy ones (`experimentalDecorators`);
|
|
102
|
+
without a decorator transform, call them: `sales.feature(CommandTotal)(Total)`. No DI library
|
|
103
|
+
and no `reflect-metadata`. The tests compile both decorator forms with TypeScript; a bundler's
|
|
104
|
+
transform (Vite, Metro/Babel) is each application's to configure and test.
|
|
184
105
|
|
|
185
|
-
|
|
186
|
-
sincpro-client generate project.contract.json --out src/domains/project/generated --domain project
|
|
187
|
-
sincpro-client scaffold project.contract.json --out src/domains/project # dependencies, bases, services, index
|
|
188
|
-
```
|
|
106
|
+
## Errors
|
|
189
107
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
entrypoints skip a bounded context's `index.ts` — put it in `make doctor`:
|
|
108
|
+
As the backend's, a bounded context has three chains of error handlers, run
|
|
109
|
+
first-registered-first. A handler returns the answer, or throws to hand the error on; the last
|
|
110
|
+
one's throw reaches the caller:
|
|
194
111
|
|
|
195
|
-
```
|
|
196
|
-
|
|
197
|
-
|
|
112
|
+
```ts
|
|
113
|
+
sales.addFeatureErrorHandler((error) => {
|
|
114
|
+
if (error instanceof PriceNotFound) return new ResponseTotal(0); // the answer
|
|
115
|
+
throw error; // the next handler, or the caller
|
|
116
|
+
});
|
|
117
|
+
sales.addAppServiceErrorHandler(toBusinessError);
|
|
118
|
+
sales.addGlobalErrorHandler(toProblem); // what reaches sales.execute or a scope
|
|
198
119
|
|
|
199
|
-
|
|
200
|
-
(`project/Issues.close`, identity `project.Issues.close`), and a report when it fails in a way
|
|
201
|
-
nobody expected. One gesture is one trace, from the click to the database:
|
|
202
|
-
|
|
203
|
-
```mermaid
|
|
204
|
-
sequenceDiagram
|
|
205
|
-
participant caller as Caller<br/>(a screen, a test)
|
|
206
|
-
participant app as project.CloseAndNotify<br/>(ApplicationService)
|
|
207
|
-
participant feature as this.feature.Issues<br/>(Feature)
|
|
208
|
-
participant client as this.client<br/>(generated, bound to the run)
|
|
209
|
-
participant process as Process<br/>(Tracer · ErrorReporter)
|
|
210
|
-
participant server as sincpro_framework
|
|
211
|
-
|
|
212
|
-
caller->>app: execute(input)
|
|
213
|
-
app->>process: span project/CloseAndNotify — the context read once
|
|
214
|
-
app->>feature: close(input)
|
|
215
|
-
feature->>process: child span project/Issues.close
|
|
216
|
-
feature->>client: closeIssue(input)
|
|
217
|
-
client->>server: POST /issues/{issue_id}/close · traceparent · sincpro-context
|
|
218
|
-
server-->>client: ResponseCloseIssue — or a Problem
|
|
219
|
-
client-->>caller: the answer, through each run
|
|
220
|
-
Note over process: each run ends ok · refused (shown, never reported) · aborted · failed (reported once, with its trace id)
|
|
120
|
+
sales.expectErrors(QuoteExpired); // the business answering: `refused`, never reported
|
|
221
121
|
```
|
|
222
122
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
123
|
+
A failure is observed where it happened, before any handler runs: its span closes with its
|
|
124
|
+
outcome, its log line is written and, when unexpected, it is reported once. A handler decides
|
|
125
|
+
the answer, not whether the failure is seen; an answered one adds an `error.handled` line. A
|
|
126
|
+
feature handler answers wherever the Feature ran, inside an ApplicationService too; the global
|
|
127
|
+
chain wraps only `execute` and `scoped(...).execute`.
|
|
128
|
+
|
|
129
|
+
How an error ended a use case:
|
|
130
|
+
|
|
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 |
|
|
226
137
|
|
|
227
|
-
|
|
138
|
+
`configureProcess({ outcomeOf })` replaces the process-wide rule. `reportError(error, where)`
|
|
139
|
+
reports what fails outside a use case — a view, a worker —, with the same rule.
|
|
228
140
|
|
|
229
|
-
|
|
230
|
-
|
|
141
|
+
## Observability
|
|
142
|
+
|
|
143
|
+
The platform plugs the implementations once, at startup; nothing breaks without them:
|
|
231
144
|
|
|
232
145
|
```ts
|
|
233
|
-
|
|
146
|
+
import { configureProcess, memoryContexts } from "@sincpro/client-framework";
|
|
147
|
+
import { openTelemetryTracer } from "@sincpro/client-framework/opentelemetry";
|
|
148
|
+
import { sentryReporter } from "@sincpro/client-framework/sentry";
|
|
149
|
+
|
|
234
150
|
configureProcess({
|
|
235
|
-
tracer, // the
|
|
236
|
-
reporter, // the
|
|
237
|
-
|
|
151
|
+
tracer: openTelemetryTracer(provider.getTracer("forge-web")), // the app's provider
|
|
152
|
+
reporter: sentryReporter(client), // the app's Sentry or GlitchTip client
|
|
153
|
+
logger: { mode: production ? "production" : "development" },
|
|
154
|
+
context: memoryContexts(),
|
|
238
155
|
});
|
|
239
|
-
|
|
240
|
-
processContext.set({ tenant_id: "acme" }); // every use case runs with it and sends it
|
|
241
|
-
project.context.set({ warehouse_id: 4 }); // only what «project» runs
|
|
242
156
|
```
|
|
243
157
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
under them a scope's, read once where the gesture started — and every request it sends carries
|
|
250
|
-
it in `sincpro-context`. A screen hears it change with `processContext.watch(…)`.
|
|
251
|
-
|
|
252
|
-
```mermaid
|
|
253
|
-
flowchart LR
|
|
254
|
-
p["processContext<br/>tenant_id · company_id"]
|
|
255
|
-
b["project.context<br/>warehouse_id"]
|
|
256
|
-
s["project.scoped({ company_id: 2 })<br/>one window"]
|
|
257
|
-
e["this.context<br/>read once where the gesture starts"]
|
|
258
|
-
c["this.context of each Feature it runs<br/>the same values"]
|
|
259
|
-
h["sincpro-context<br/>on every request"]
|
|
260
|
-
store[("ContextStore<br/>the platform's")]
|
|
261
|
-
p --> e
|
|
262
|
-
b --> e
|
|
263
|
-
s --> e
|
|
264
|
-
e --> c
|
|
265
|
-
e --> h
|
|
266
|
-
c --> h
|
|
267
|
-
p -.-> store
|
|
268
|
-
b -.-> store
|
|
269
|
-
```
|
|
158
|
+
Every execution opens a span `<bounded context>/<serviceName>`; an ApplicationService's Features
|
|
159
|
+
are its children, on the same trace. A dependency that is a `Transport` or a generated client is
|
|
160
|
+
bound to the run, so each request carries `traceparent` (and `tracestate`) and
|
|
161
|
+
`sincpro-context` — the backend continues the trace. A tracer, reporter or logger that fails is
|
|
162
|
+
ignored: telling about a use case never changes its answer.
|
|
270
163
|
|
|
271
|
-
The
|
|
272
|
-
|
|
273
|
-
|
|
164
|
+
The vendor entries wrap what the application built — they initialize nothing, read no
|
|
165
|
+
environment and register nothing global. There is no context manager: the trace reaches the
|
|
166
|
+
wire through the transport. On the web, leave the backend's origin out of OpenTelemetry's
|
|
167
|
+
automatic `fetch` instrumentation (`ignoreUrls`), or it writes a `traceparent` of a trace of its
|
|
168
|
+
own over this one.
|
|
274
169
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
170
|
+
### Logging
|
|
171
|
+
|
|
172
|
+
The application layer imports no logger. Two are predefined, for the code around the use cases:
|
|
278
173
|
|
|
279
174
|
```ts
|
|
280
|
-
import {
|
|
281
|
-
import { sentryReporter } from "@sincpro/client-framework/sentry"; // no dependency
|
|
175
|
+
import { logger, loggerAdapter } from "@sincpro/client-framework";
|
|
282
176
|
|
|
283
|
-
|
|
284
|
-
|
|
177
|
+
logger.info("Database opened"); // infrastructure — the global logger
|
|
178
|
+
loggerAdapter.warn("Printer is slow", { ms: 900 }); // an adapter
|
|
179
|
+
sales.logger.info("Catalog preloaded"); // a bounded context, outside a use case
|
|
180
|
+
this.logger.info("Totalling"); // inside a Feature or ApplicationService
|
|
285
181
|
```
|
|
286
182
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
183
|
+
| Logger | Layer | Each line carries |
|
|
184
|
+
| ------------------------- | --------------------- | ---------------------------------------------- |
|
|
185
|
+
| `logger` | `infrastructure` | — |
|
|
186
|
+
| `loggerAdapter` | `adapter` | the run, when injected as a dependency |
|
|
187
|
+
| `sales.logger` | `bounded_context` | its bounded context |
|
|
188
|
+
| `this.logger`, Feature | `feature` | bounded context, use case, execution and trace |
|
|
189
|
+
| `this.logger`, AppService | `application_service` | the same |
|
|
290
190
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
`
|
|
191
|
+
`this.logger` keeps its run across awaits and concurrent calls, without a global stack. An
|
|
192
|
+
adapter that imports `loggerAdapter` at module level writes without the run; inject it
|
|
193
|
+
(`sales.addDependency("log", loggerAdapter)`) or pass `this.logger` when a line must carry it.
|
|
294
194
|
|
|
295
|
-
|
|
195
|
+
One configuration, two doors — `configureProcess({ logger })` replaces every process port,
|
|
196
|
+
`configureLogger(...)` only logging (an in-app debug switch):
|
|
296
197
|
|
|
297
|
-
|
|
298
|
-
|
|
198
|
+
```ts
|
|
199
|
+
configureLogger({ mode: "development", level: "info", layers: { adapter: false } }); // the console
|
|
200
|
+
configureLogger({ mode: "production" }); // the console, silent
|
|
201
|
+
configureLogger({ engine: combineLoggers(consoleLogger({ mode }), lokiLogger) }); // other sinks
|
|
202
|
+
configureLogger({ engine: false }); // no logs
|
|
203
|
+
```
|
|
299
204
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
| bytes | `{ "$bytes": "<base64>" }` | `valuesOf` / `readValues` |
|
|
308
|
-
| the context | only what travels: text, numbers, lists | `plainCodec`, `travelling` |
|
|
205
|
+
The console is the default engine. In production it is silent; in development it prints
|
|
206
|
+
everything, and `level` and `layers` hide only INFO/DEBUG. A supplied `engine` owns its own
|
|
207
|
+
policy, and each sink of `combineLoggers` keeps its own — the console's silence never silences
|
|
208
|
+
another. Every execution writes `execution.started` (DEBUG) and `execution.finished` with its
|
|
209
|
+
outcome and duration (INFO, WARN when refused, ERROR when failed). No line carries a DTO, a
|
|
210
|
+
result, an error body or the session's context unless the author writes it. Delivery, buffering
|
|
211
|
+
and credentials (Loki, a phone's queue) are the platform's.
|
|
309
212
|
|
|
310
|
-
|
|
311
|
-
import { Money, pack, unpack, valuesOf } from "@sincpro/client-framework";
|
|
213
|
+
## Context
|
|
312
214
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
215
|
+
The values every use case runs with — the tenant, the company —, in levels: the process's, the
|
|
216
|
+
bounded context's, a scope's. A root execution reads them once; its Features inherit that
|
|
217
|
+
snapshot, and what travels goes to the server in `sincpro-context`.
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
processContext.set({ tenant_id: "acme" });
|
|
221
|
+
sales.context.set({ warehouse_id: 4 });
|
|
222
|
+
await sales.scoped({ company_id: 2 }).execute(new CommandQuote(1));
|
|
317
223
|
```
|
|
318
224
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
225
|
+
Where the values live is a port (`ContextStore`): synchronous reads, a version per change and an
|
|
226
|
+
optional `watch`. `memoryContexts()` is the default; a browser, a phone or a server render plugs
|
|
227
|
+
its own. `values` is the same object until it changes, so a screen can hand it to React's
|
|
228
|
+
`useSyncExternalStore`.
|
|
229
|
+
|
|
230
|
+
## Talking to a server
|
|
231
|
+
|
|
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 |
|
|
240
|
+
|
|
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.
|
|
243
|
+
|
|
244
|
+
## Generated code
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
python -m sincpro_framework.entrypoints export-client-contract app.http:gateways contracts [--events]
|
|
248
|
+
sincpro-client generate contracts/project.contract.json --out src/domains/project/generated --domain project
|
|
249
|
+
sincpro-client scaffold contracts/project.contract.json --out src/domains/project
|
|
250
|
+
sincpro-client check --root src
|
|
341
251
|
```
|
|
342
252
|
|
|
343
|
-
|
|
344
|
-
error is reported, where the context lives — it plugs through a port.
|
|
253
|
+
`generate` writes, from the contract the backend exports — never edited, written again whole:
|
|
345
254
|
|
|
346
|
-
|
|
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 |
|
|
347
260
|
|
|
348
|
-
|
|
349
|
-
`
|
|
350
|
-
|
|
351
|
-
|
|
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.
|
|
263
|
+
|
|
264
|
+
**Domain events, on demand.** A backend leaves its events out of the contract unless it is
|
|
265
|
+
exported with `--events`. Then each event is a type as it is received — every field present,
|
|
266
|
+
`name` its wire name as a literal — and `DomainEvent`, their union, narrows by `name`:
|
|
352
267
|
|
|
353
268
|
```ts
|
|
354
|
-
import
|
|
355
|
-
|
|
269
|
+
import type * as remote from "./generated/domain";
|
|
270
|
+
|
|
271
|
+
function subjectOf(event: remote.DomainEvent): string {
|
|
272
|
+
switch (event.name) {
|
|
273
|
+
case "project.issue.v1.closed":
|
|
274
|
+
return event.issue_id; // remote.IssueClosed
|
|
275
|
+
// …
|
|
276
|
+
}
|
|
277
|
+
}
|
|
356
278
|
```
|
|
357
279
|
|
|
358
|
-
`
|
|
359
|
-
|
|
360
|
-
|
|
280
|
+
`events` in `client.ts` says, per event, its wire name and whether the bounded context records it
|
|
281
|
+
(its aggregates' own) or handles it (a Feature subscribes).
|
|
282
|
+
|
|
283
|
+
### Types, not classes
|
|
284
|
+
|
|
285
|
+
What is generated is the remote service's contract and an adapter over it, so it is written as
|
|
286
|
+
types; the application's own Commands are classes, written by hand.
|
|
361
287
|
|
|
362
|
-
The
|
|
363
|
-
|
|
288
|
+
- **The wire is JSON, and a type says it exactly.** A decimal is text, a day is text, a page is
|
|
289
|
+
`{ items, count, cursor }`. A class around that JSON would claim more than it holds unless
|
|
290
|
+
every field had a reader; a type claims nothing it does not hold.
|
|
291
|
+
- **The local bus routes by class, and the remote contract must not reach it.** A generated
|
|
292
|
+
class extending `Command` could be registered and executed locally — the confusion between a
|
|
293
|
+
remote operation and a local use case this package keeps apart. A remote call happens inside
|
|
294
|
+
a local Feature, through the injected client.
|
|
295
|
+
- **Events read best as a union.** A stream of facts — a queue, a history page — is narrowed by
|
|
296
|
+
`name`, with no registry of constructors to rebuild each one.
|
|
297
|
+
- **Nothing runs.** `domain.ts` adds no byte to a bundle and no side effect; a contract of three
|
|
298
|
+
hundred models costs nothing at runtime.
|
|
364
299
|
|
|
365
|
-
|
|
366
|
-
| --------------- | ----------------------------------------------- | ------------------------------------------ | ----------------- |
|
|
367
|
-
| `Tracer` | OpenTelemetry + OTLP/HTTP (`./telemetry`) | OpenTelemetry with its queue and exporter | `memoryTracer` |
|
|
368
|
-
| `ErrorReporter` | GlitchTip / Sentry browser client (`./errors`) | Sentry React Native client | `memoryReporter` |
|
|
369
|
-
| `ContextStore` | memory, `sessionStorage`, or the screens' store | MMKV, or memory hydrated from AsyncStorage | `memoryContexts` |
|
|
370
|
-
| `Transport` | `restTransport` over `fetch` | `restTransport`, or SQLite offline | `memoryTransport` |
|
|
300
|
+
A local class takes the contract's shape and stays checked against it:
|
|
371
301
|
|
|
372
|
-
|
|
373
|
-
|
|
302
|
+
```ts
|
|
303
|
+
import type * as remote from "../generated/domain";
|
|
304
|
+
|
|
305
|
+
class CommandCancelIssue
|
|
306
|
+
extends Command<ResponseCancelIssue>
|
|
307
|
+
implements remote.CommandCancelIssue
|
|
308
|
+
{
|
|
309
|
+
constructor(readonly issue_id: string) {
|
|
310
|
+
super();
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
The generated name and the local one may be the same — the backend's —, so the generated ones
|
|
316
|
+
are imported under their own (`remote`). Validating and converting what arrives (a decimal into
|
|
317
|
+
`Decimal`) is a reader's job, not a class's: generated readers are the next step, when a use
|
|
318
|
+
needs them.
|
|
319
|
+
|
|
320
|
+
## Values on the wire
|
|
321
|
+
|
|
322
|
+
| Value | Travels as | API |
|
|
323
|
+
| ---------- | -------------------------------------- | ---------------------------------------------- |
|
|
324
|
+
| Decimal | exact text, `"10.50"` | `Decimal` |
|
|
325
|
+
| Money | `{ amount: "10.50", currency: "BOB" }` | `Money` |
|
|
326
|
+
| Day | `"2026-10-02"`, never an instant | `isDay`, `dayOf` |
|
|
327
|
+
| Instant | ISO text | `wallTimeOf`, `instantOf` |
|
|
328
|
+
| ID | UUID v7 as 32 hex | `newEntityId`, `isEntityId`, `momentOf` |
|
|
329
|
+
| Bytes | `{ "$bytes": "<base64>" }` | `valuesOf`, `readValues` |
|
|
330
|
+
| Collection | items, count and cursor | `collectionOf`, `isPartial`, `relatedCriteria` |
|
|
331
|
+
| Export | Parquet, Arrow or JSON Lines | `downloadRoute`, `exportRoute` |
|
|
332
|
+
|
|
333
|
+
`Decimal` and `Money` compute exactly, on `BigInt`; a decimal never becomes a `number` and a day
|
|
334
|
+
never a `Date`. `valuesOf`/`pack` write; `readValues` reads tagged values back; `rebuilt`/`unpack`
|
|
335
|
+
use a reader given explicitly. Cycles, functions and non-finite numbers are refused. How a value
|
|
336
|
+
is shown, typed or stored is the platform's.
|
|
337
|
+
|
|
338
|
+
## Package layout
|
|
339
|
+
|
|
340
|
+
Each module is laid out as sincpro_framework's are, and says what it offers in its `index.ts`:
|
|
341
|
+
|
|
342
|
+
```text
|
|
343
|
+
src/
|
|
344
|
+
common/ the values of the wire
|
|
345
|
+
context/
|
|
346
|
+
domain/ ContextStore and ContextCodec ports, what travels, Execution
|
|
347
|
+
adapters/ memoryContexts, plainCodec
|
|
348
|
+
entrypoint/ Context: one door over a level
|
|
349
|
+
infrastructure/ the process's store and processContext
|
|
350
|
+
observability/
|
|
351
|
+
domain/ Tracer, ErrorReporter, Logger ports
|
|
352
|
+
adapters/ console (default), memory, opentelemetry and sentry (optional entries)
|
|
353
|
+
infrastructure/ the ports this process runs with
|
|
354
|
+
services/ logger, loggerAdapter, reportError, outcomeOf
|
|
355
|
+
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
|
|
359
|
+
model/ defineMeta, metaFromSchema, metaStore
|
|
360
|
+
core/
|
|
361
|
+
domain/ Command, Response, Feature, ApplicationService, FeatureBus, ErrorHandler
|
|
362
|
+
services/ the execution pipeline, dependency binding, the handler chain
|
|
363
|
+
entrypoint/ BoundedContext, screensTransport
|
|
364
|
+
infrastructure/ configureProcess, configureLogger
|
|
365
|
+
codegen/ the generator and its CLI — the only place Node is allowed
|
|
366
|
+
testing/ memoryTracer, memoryReporter, memoryLogger
|
|
367
|
+
```
|
|
374
368
|
|
|
375
|
-
|
|
376
|
-
|
|
369
|
+
| Entry | What |
|
|
370
|
+
| ------------------------------------------------------- | ------------------------------------------- |
|
|
371
|
+
| `@sincpro/client-framework` | everything an application runs |
|
|
372
|
+
| `@sincpro/client-framework/core` | the application layer alone |
|
|
373
|
+
| `@sincpro/client-framework/testing` | the observability ports in memory |
|
|
374
|
+
| `@sincpro/client-framework/codegen` | the generator, as functions |
|
|
375
|
+
| `./console`, `./opentelemetry`, `./sentry`, `./parquet` | optional entries; a vendor's SDK only there |
|
|
377
376
|
|
|
378
377
|
## Development
|
|
379
378
|
|
|
380
|
-
Node 26 (`nvm use`)
|
|
379
|
+
Node 26 (`nvm use`):
|
|
381
380
|
|
|
382
381
|
```bash
|
|
383
|
-
make init
|
|
384
|
-
make
|
|
385
|
-
make
|
|
386
|
-
make
|
|
382
|
+
make init
|
|
383
|
+
make typecheck
|
|
384
|
+
make test
|
|
385
|
+
make verify-format # eslint --fix + prettier + typecheck + doctor; fails on a dirty tree
|
|
386
|
+
make build
|
|
387
|
+
make codegen-fixtures # after changing the generator on purpose; read the diff
|
|
387
388
|
```
|
|
388
389
|
|
|
389
|
-
`make doctor` enforces what the
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
390
|
+
`make doctor` enforces what the compiler does not: self-imports, no platform (React, DOM, Node
|
|
391
|
+
outside `codegen/`), a vendor's SDK only in its optional entry, and `domain/` importing only
|
|
392
|
+
`domain/`. Tests run in Node, one folder per module; `tests/support/forge-contract/` holds real
|
|
393
|
+
contracts and what the generator writes from them, compared byte for byte.
|
|
393
394
|
|
|
394
|
-
|
|
395
|
+
The runtime uses `fetch`, `AbortSignal`, `URL`, `crypto.getRandomValues`, `Proxy`, `WeakMap` and
|
|
396
|
+
`BigInt`; React Native must provide URL and random values before ids or transports are used.
|
|
395
397
|
|
|
396
|
-
|
|
397
|
-
[`
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
never by hand.
|
|
398
|
+
Read [`MANIFESTO.md`](MANIFESTO.md) (what this package is and is not), [`AGENTS.md`](AGENTS.md)
|
|
399
|
+
(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.
|
|
401
402
|
|
|
402
403
|
## License
|
|
403
404
|
|