@sincpro/client-framework 0.2.0 → 0.4.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 +51 -23
- package/MANIFESTO.md +44 -26
- package/README.md +304 -29
- package/dist/adapters/opentelemetry.d.ts +1 -1
- package/dist/adapters/opentelemetry.js +1 -1
- package/dist/adapters/sentry.d.ts +2 -2
- package/dist/adapters/sentry.js +2 -2
- package/dist/client/memory.js +14 -6
- package/dist/common/decimal.d.ts +49 -0
- package/dist/common/decimal.js +146 -0
- package/dist/{client → common}/ids.d.ts +7 -0
- package/dist/{client → common}/ids.js +12 -0
- package/dist/common/money.d.ts +74 -0
- package/dist/common/money.js +168 -0
- package/dist/common/serialization.d.ts +49 -0
- package/dist/common/serialization.js +155 -0
- package/dist/common/time.d.ts +40 -0
- package/dist/common/time.js +93 -0
- package/dist/context/codec.d.ts +24 -0
- package/dist/context/codec.js +28 -0
- package/dist/context/context.d.ts +45 -0
- package/dist/context/context.js +70 -0
- package/dist/context/store.d.ts +51 -0
- package/dist/context/store.js +59 -0
- package/dist/index.d.ts +17 -5
- package/dist/index.js +17 -5
- package/dist/observability/ports.d.ts +2 -2
- package/dist/services/bounded-context.d.ts +79 -0
- package/dist/services/bounded-context.js +272 -0
- package/dist/services/execution.d.ts +8 -8
- package/dist/services/execution.js +8 -6
- package/dist/services/feature.d.ts +70 -48
- package/dist/services/feature.js +57 -191
- package/dist/services/process.d.ts +60 -0
- package/dist/services/process.js +57 -0
- package/dist/testing/index.d.ts +3 -3
- package/package.json +2 -2
- package/dist/services/runtime.d.ts +0 -48
- package/dist/services/runtime.js +0 -30
package/AGENTS.md
CHANGED
|
@@ -8,24 +8,30 @@ it says what this package is responsible for and what it is not. This file is ho
|
|
|
8
8
|
- **Read the manifesto.** A piece that serves nothing there does not go in; a piece it forbids
|
|
9
9
|
does not either. If you think it should, change the manifesto first and say why.
|
|
10
10
|
- **Look at the backend.** This package mirrors sincpro_framework (`~/dev/sincpro_framework`):
|
|
11
|
-
`Feature`, `ApplicationService`, the context
|
|
11
|
+
the bounded context and its dependencies, `Feature`, `ApplicationService`, the context and its
|
|
12
|
+
store, the process, the span per use case. When in doubt about a
|
|
12
13
|
word or a behaviour, read how the backend does it, not memory.
|
|
14
|
+
- **Use the shared words.** [`docs/UBIQUITOUS_LANGUAGE.md`](docs/UBIQUITOUS_LANGUAGE.md) is the
|
|
15
|
+
vocabulary of every Sincpro framework. A new word, or a word that changes, goes there in the
|
|
16
|
+
same pull request; a word in its «do not use» table never enters code or docs.
|
|
13
17
|
- **Look at who uses it.** @sincpro/web-framework (`~/dev/sincpro_web_framework`) and
|
|
14
18
|
@sincpro/mobile (`~/dev/mobile/sincpro_mobile`). A piece only one of them needs, and that
|
|
15
19
|
needs a platform, belongs there.
|
|
16
20
|
|
|
17
21
|
## Where things go
|
|
18
22
|
|
|
19
|
-
| Folder | What
|
|
20
|
-
| -------------------- |
|
|
21
|
-
| `src/
|
|
22
|
-
| `src/
|
|
23
|
-
| `src/
|
|
24
|
-
| `src/
|
|
25
|
-
| `src/
|
|
26
|
-
| `src/
|
|
27
|
-
| `src/
|
|
28
|
-
| `
|
|
23
|
+
| Folder | What |
|
|
24
|
+
| -------------------- | --------------------------------------------------------------------------------------------- |
|
|
25
|
+
| `src/common/` | The values of the wire: ids, a day and an instant, `Decimal`, `Money`, `valuesOf` / `rebuilt` |
|
|
26
|
+
| `src/client/` | The seam with a server: `Transport`, REST, memory, `entity()`, the client contract |
|
|
27
|
+
| `src/model/` | Model definitions: `defineMeta`, from a JSON Schema, the store of known ones |
|
|
28
|
+
| `src/services/` | `UseBoundedContext`, `Feature`, `ApplicationService`, the process, the execution |
|
|
29
|
+
| `src/context/` | The `ContextStore` port, `memoryContexts`, and `Context` — one level of it |
|
|
30
|
+
| `src/observability/` | The ports a platform plugs: `Tracer`, `ErrorReporter` |
|
|
31
|
+
| `src/codegen/` | The generator and its CLI (`sincpro-client generate`) — the only place Node is allowed |
|
|
32
|
+
| `src/adapters/` | Optional entries over a vendor (`./opentelemetry`, `./sentry`): the only place one is named |
|
|
33
|
+
| `src/testing/` | `memoryTracer`, `memoryReporter` — the ports in memory, for tests |
|
|
34
|
+
| `tests/` | vitest in Node; `tests/support/` holds the fake domains and forge's real contracts |
|
|
29
35
|
|
|
30
36
|
## Hard rules (the build enforces them — `make doctor`)
|
|
31
37
|
|
|
@@ -33,7 +39,7 @@ it says what this package is responsible for and what it is not. This file is ho
|
|
|
33
39
|
no `document`, `window`, `localStorage`, `navigator`. What a platform must give, it gives
|
|
34
40
|
through a port.
|
|
35
41
|
- **Node only in the generator.** `node:` imports, `process` and `Buffer` live in `src/codegen/`
|
|
36
|
-
and nowhere else; nothing
|
|
42
|
+
and nowhere else; nothing an application runs imports `codegen/`.
|
|
37
43
|
- **Self-imports, no extensions.** Inside `src/`, import `@sincpro/client-framework/<path>` —
|
|
38
44
|
never a relative path, never `.ts`.
|
|
39
45
|
|
|
@@ -48,16 +54,28 @@ it says what this package is responsible for and what it is not. This file is ho
|
|
|
48
54
|
|
|
49
55
|
## Known traps
|
|
50
56
|
|
|
51
|
-
- **A run sees the instance through a view of its own.** Inside a method,
|
|
52
|
-
`this.context` and `this.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
- **A run sees the instance through a view of its own.** Inside a method, each dependency
|
|
58
|
+
(`this.client`), `this.context` and `this.feature` are that run's — bound to its execution. So a
|
|
59
|
+
service declares no `#private` fields (they refuse the view); use TypeScript's `private`.
|
|
60
|
+
- **A service is a singleton of its bounded context.** What a method writes on `this` lands on the
|
|
61
|
+
instance, shared by the next call, the call beside it and every `with`/`scoped` variant — as in
|
|
62
|
+
sincpro_framework. What belongs to one request goes in its arguments or its context.
|
|
63
|
+
- **A span is named by the registered name, not the class** (`register({ features: { Issues } })`
|
|
64
|
+
→ `project/Issues.close`, identity `project.Issues.close`): a production build renames classes,
|
|
65
|
+
never the keys of an object.
|
|
66
|
+
- **The dependencies are typed, not declared.** `BaseFeature<ProjectDependencies>` types
|
|
67
|
+
`this.client` from an interface, which does not exist at run time; the bounded context answers
|
|
68
|
+
whatever was added with `addDependency`. A dependency nobody added reads `undefined`.
|
|
69
|
+
- **The trace reaches the wire through the dependencies.** A dependency is bound to the run when it
|
|
70
|
+
is a `Transport` or carries `BIND` (every generated client does, through `bindable`); anything
|
|
71
|
+
else is handed as it is. A client built elsewhere and closed over sends no trace.
|
|
72
|
+
- **The process is read on every call, never at import.** `project` is defined when its module
|
|
73
|
+
loads, before `main` runs `configureProcess`; nothing may capture the tracer, the reporter or the
|
|
74
|
+
store at definition time. `configureProcess` replaces everything at once — a test calls it in
|
|
75
|
+
`beforeEach` and starts with an empty context.
|
|
76
|
+
- **A store's `load` may answer a new object each time** (one that parses JSON does): `Context`
|
|
77
|
+
keeps one object per `version`, so a screen can hand `values` to `useSyncExternalStore`. A store
|
|
78
|
+
that changes without moving the version is a broken store.
|
|
61
79
|
- **What the generator writes is compared byte for byte** (`tests/support/forge-contract/`, out
|
|
62
80
|
of prettier and eslint). After changing the generator on purpose, run `make codegen-fixtures`
|
|
63
81
|
and read the diff.
|
|
@@ -71,7 +89,17 @@ it says what this package is responsible for and what it is not. This file is ho
|
|
|
71
89
|
all-zeros span: the adapter gives no ids, so no `traceparent` of zeros reaches the server.
|
|
72
90
|
- **Reported means unexpected.** A `TransportError` the server answered with a 4xx is `refused`,
|
|
73
91
|
an `AbortError` is `aborted`; neither is reported (`outcomeOf`). Change that with
|
|
74
|
-
`
|
|
92
|
+
`ProcessOptions.outcomeOf`, never by catching in a Feature.
|
|
93
|
+
|
|
94
|
+
- **Only what is transversal comes here.** A value's wire form (a decimal as text, a day never
|
|
95
|
+
an instant, a money with its currency) is the backend's contract, the same on every platform,
|
|
96
|
+
so it lives in `src/common/`. How a value is shown to a person, typed in an input, or kept in
|
|
97
|
+
SQLite or IndexedDB is the platform's — @sincpro/web-framework's or @sincpro/mobile's.
|
|
98
|
+
- **A decimal never becomes a `number`.** It travels as text and is computed with `Decimal` or
|
|
99
|
+
`Money` (exact, on `BigInt`); `Number(...)` or `parseFloat` on an amount is the float the
|
|
100
|
+
backend sends text to avoid. And a day is never a `Date`: a `Date` is an instant.
|
|
101
|
+
- **The in-memory server copies by the wire's rules** (`valuesOf` → `readValues`): what it receives
|
|
102
|
+
and answers is what a real server would — a `Money` as `{ amount, currency }`, a `Date` as text.
|
|
75
103
|
|
|
76
104
|
## Before you hand it back
|
|
77
105
|
|
package/MANIFESTO.md
CHANGED
|
@@ -25,28 +25,37 @@ server-side render, in a script and in a test, unchanged.**
|
|
|
25
25
|
|
|
26
26
|
## What it is responsible for
|
|
27
27
|
|
|
28
|
-
1. **
|
|
29
|
-
|
|
30
|
-
ApplicationService. All of a client's logic lives in them
|
|
28
|
+
1. **Services.** A `Feature` is a facade over one part of the domain, each public method a use
|
|
29
|
+
case; an `ApplicationService` composes the Features of its own bounded context — never another
|
|
30
|
+
ApplicationService. All of a client's logic lives in them, with their dependencies typed and
|
|
31
|
+
injected by their bounded context (`this.client`, `this.session`).
|
|
31
32
|
2. **Interception.** Every public method of a use case passes through interceptors, and that is
|
|
32
33
|
what makes the defaults free: the trace, the error report, the context. The author of a
|
|
33
34
|
Feature writes none of it.
|
|
34
35
|
3. **Observability, as ports.** `Tracer` and `ErrorReporter` are the contract: one span per use
|
|
35
|
-
case, named `<
|
|
36
|
-
what the server already explained (a validation, a conflict) not reported.
|
|
37
|
-
the implementation — OpenTelemetry, GlitchTip, a queue on the phone, memory
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
36
|
+
case, named `<bounded context>/<Service>.<method>` like the backend's; the unexpected reported
|
|
37
|
+
with its trace id; what the server already explained (a validation, a conflict) not reported.
|
|
38
|
+
The platform plugs the implementation — OpenTelemetry, GlitchTip, a queue on the phone, memory
|
|
39
|
+
in a test — once, for the process (`configureProcess`).
|
|
40
|
+
4. **Context.** The levels a call sees — the process, its bounded context, a scope — read once
|
|
41
|
+
where a gesture starts and inherited by everything it runs, and what travels to the server with
|
|
42
|
+
it (`traceparent`, `sincpro-context`). Where the values live is a port (`ContextStore`) each
|
|
43
|
+
platform implements with its own technology; the execution is carried by each call, never kept
|
|
44
|
+
in a global stack: two calls at once never mix their parents.
|
|
45
|
+
5. **Bounded contexts.** Set up like the backend's — its dependencies (`addDependency`), its
|
|
46
|
+
Features and ApplicationServices, registered once — and used through one light object:
|
|
47
|
+
`project.Issues.close(input)`. An application is the set of bounded contexts it loads; they
|
|
48
|
+
never import each other's insides.
|
|
44
49
|
6. **The client.** The `Transport` port and its implementations (REST, memory), `entity()` with
|
|
45
50
|
typed commands and answers, the service's client contract, model definitions read once from
|
|
46
|
-
the server
|
|
47
|
-
7. **The
|
|
51
|
+
the server and the server's `Problem`.
|
|
52
|
+
7. **The values of the wire.** How every value travels, as the backend writes it — a decimal as
|
|
53
|
+
text, a money with its currency, a day that is never an instant, bytes tagged, ids as UUID v7 —
|
|
54
|
+
and the exact arithmetic a client needs on them (`Decimal`, `Money`). Only the wire form and
|
|
55
|
+
what follows from it: showing a value to a person is each platform's.
|
|
56
|
+
8. **The generator.** From a service's client contract to each domain's types, client and
|
|
48
57
|
entities — generated, never edited, and as platform-neutral as everything else here.
|
|
49
|
-
|
|
58
|
+
9. **Testing.** An in-memory transport, tracer and reporter, so a use case is proven in Node with
|
|
50
59
|
nothing else.
|
|
51
60
|
|
|
52
61
|
## What it is not
|
|
@@ -55,7 +64,8 @@ server-side render, in a script and in a test, unchanged.**
|
|
|
55
64
|
the web and to @sincpro/mobile on a phone.
|
|
56
65
|
- **Not a shell, a router or a window manager.** Where things open is the platform's.
|
|
57
66
|
- **Not a cache or a state manager.** Holding server state for screens is the platform's job,
|
|
58
|
-
with the libraries that do it best.
|
|
67
|
+
with the libraries that do it best. The context is not one either: a handful of values every
|
|
68
|
+
use case runs with — the tenant, the company — kept wherever the platform says.
|
|
59
69
|
- **Not offline storage.** SQLite on a phone or IndexedDB in a point of sale are platform
|
|
60
70
|
adapters.
|
|
61
71
|
- **Not a telemetry or error-tracking SDK.** It defines the ports and calls them. For the
|
|
@@ -72,14 +82,16 @@ server-side render, in a script and in a test, unchanged.**
|
|
|
72
82
|
1. **Platform-neutral, or it does not go in.** It runs on Node, in a browser and on React Native.
|
|
73
83
|
What it needs from a platform it asks through a port, never by reaching for it. The build
|
|
74
84
|
enforces it.
|
|
75
|
-
2. **The backend's words.** Feature, ApplicationService,
|
|
76
|
-
same as sincpro_framework, so whoever knows the backend already
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
85
|
+
2. **The backend's words.** Bounded context, Feature, ApplicationService, dependencies, context,
|
|
86
|
+
process, the span name — the same as sincpro_framework, so whoever knows the backend already
|
|
87
|
+
knows the client.
|
|
88
|
+
3. **Nothing escapes the interceptors.** Reusing a use case is calling it through its bounded
|
|
89
|
+
context. A helper that wraps one skips the trace and the report — that is how an incident ends
|
|
90
|
+
up with nothing to look at.
|
|
80
91
|
4. **Context travels with the call.** Never in a global stack: clients run many things at once.
|
|
81
92
|
5. **A port where there are two implementations, a class where there is one.** `Transport` has
|
|
82
|
-
REST and memory; `Tracer` has a platform's and a test's
|
|
93
|
+
REST and memory; `Tracer` has a platform's and a test's; `ContextStore` has the browser's, the
|
|
94
|
+
phone's and memory.
|
|
83
95
|
6. **Generated, never edited.** Applications extend by composition; regenerating never loses
|
|
84
96
|
work.
|
|
85
97
|
7. **The server is the source of truth.** A client reads the model, the preview and the refusal,
|
|
@@ -95,8 +107,8 @@ server-side render, in a script and in a test, unchanged.**
|
|
|
95
107
|
@sincpro/criteria the contract with the server: Criteria, Meta, Page, Problem
|
|
96
108
|
▲
|
|
97
109
|
@sincpro/client-framework this package — platform-neutral
|
|
98
|
-
│ Feature · ApplicationService · interceptors
|
|
99
|
-
│
|
|
110
|
+
│ UseBoundedContext · Feature · ApplicationService · interceptors
|
|
111
|
+
│ the process · the context · Tracer, ErrorReporter and ContextStore ports
|
|
100
112
|
│ Transport · entity() · client contract · model definitions · ids
|
|
101
113
|
│ the generator · in-memory doubles for tests
|
|
102
114
|
▲
|
|
@@ -115,8 +127,14 @@ where they already ran without the DOM. `Feature` and `ApplicationService` came
|
|
|
115
127
|
ports for tracing and error reporting are in, with the trace and the context written on every
|
|
116
128
|
request a use case sends.
|
|
117
129
|
|
|
118
|
-
**
|
|
119
|
-
|
|
130
|
+
**Then the bounded context**, set up as the backend sets one up: a dependency type that types every
|
|
131
|
+
service's `this.client`, `addDependency`, the services registered by name and used as
|
|
132
|
+
`project.Issues.close(input)` and `this.feature.Issues`; the process configured once in `main`
|
|
133
|
+
(`configureProcess`); and the context as a port, so the browser and the phone keep it each with
|
|
134
|
+
their own technology behind the same door.
|
|
135
|
+
|
|
136
|
+
**Next:** @sincpro/mobile on this package, and the screens' side in @sincpro/web-framework — the
|
|
137
|
+
cache of reads, invalidated by the writes a use case sends.
|
|
120
138
|
|
|
121
139
|
## How we decide
|
|
122
140
|
|
package/README.md
CHANGED
|
@@ -5,44 +5,252 @@ every platform.** Write a use case once — a `Feature`, or an `ApplicationServi
|
|
|
5
5
|
Features — and run it in a browser, on a phone, in a server-side render, in a script or in a test.
|
|
6
6
|
Every use case is traced and every unexpected error reported, continuing the backend's trace,
|
|
7
7
|
without its author writing either. A service's client contract becomes typed code for each of
|
|
8
|
-
its
|
|
8
|
+
its bounded contexts.
|
|
9
9
|
|
|
10
|
-
> **Status: 0.x
|
|
11
|
-
>
|
|
12
|
-
> and
|
|
13
|
-
> for, what it is not and where it is going
|
|
10
|
+
> **Status: 0.x.** The API may still change before 1.0. In: the client, the generator, the
|
|
11
|
+
> bounded context with its `Feature` and `ApplicationService`, the process (tracing, error
|
|
12
|
+
> reporting, the context) and its ports. Read the [manifesto](MANIFESTO.md) for what this package
|
|
13
|
+
> is responsible for, what it is not and where it is going, and the
|
|
14
|
+
> [ubiquitous language](docs/UBIQUITOUS_LANGUAGE.md) every Sincpro framework shares.
|
|
14
15
|
|
|
15
|
-
##
|
|
16
|
+
## What it gives, at a glance
|
|
17
|
+
|
|
18
|
+
```mermaid
|
|
19
|
+
classDiagram
|
|
20
|
+
direction LR
|
|
21
|
+
class UseBoundedContext {
|
|
22
|
+
+name
|
|
23
|
+
+context : Context
|
|
24
|
+
+addDependency(name, value)
|
|
25
|
+
+register(features, applicationServices) BoundedContext
|
|
26
|
+
}
|
|
27
|
+
class BoundedContext {
|
|
28
|
+
+Issues : the methods of Issues
|
|
29
|
+
+CloseAndNotify : the methods of CloseAndNotify
|
|
30
|
+
+context : Context
|
|
31
|
+
+with(dependencies) BoundedContext
|
|
32
|
+
+scoped(context) BoundedContext
|
|
33
|
+
}
|
|
34
|
+
class Feature {
|
|
35
|
+
#client, session : each dependency
|
|
36
|
+
#context : what this run executes with
|
|
37
|
+
}
|
|
38
|
+
class ApplicationService {
|
|
39
|
+
#feature : Issues, Notices
|
|
40
|
+
}
|
|
41
|
+
class Context {
|
|
42
|
+
+values
|
|
43
|
+
+get(name)
|
|
44
|
+
+set(values)
|
|
45
|
+
+clear(names)
|
|
46
|
+
+watch(listener)
|
|
47
|
+
}
|
|
48
|
+
class ContextStore {
|
|
49
|
+
<<port>>
|
|
50
|
+
+load(key)
|
|
51
|
+
+save(key, values)
|
|
52
|
+
+delete(key)
|
|
53
|
+
+version(key)
|
|
54
|
+
+watch(key, listener)
|
|
55
|
+
}
|
|
56
|
+
class Tracer {
|
|
57
|
+
<<port>>
|
|
58
|
+
+start(name, parent, attributes) Span
|
|
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
|
+
```
|
|
16
111
|
|
|
17
112
|
```ts
|
|
113
|
+
// domains/project/infrastructure/dependencies.ts — what the services can count on
|
|
114
|
+
export interface ProjectDependencies {
|
|
115
|
+
client: ProjectClient; // generated from the service's contract
|
|
116
|
+
session: Session;
|
|
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
|
|
18
128
|
import {
|
|
19
|
-
ApplicationService,
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
Feature,
|
|
129
|
+
ApplicationService as BaseApplicationService,
|
|
130
|
+
Feature as BaseFeature,
|
|
131
|
+
UseBoundedContext,
|
|
23
132
|
} from "@sincpro/client-framework";
|
|
24
133
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
134
|
+
import type { ProjectFeatures } from "../services";
|
|
135
|
+
|
|
136
|
+
export class Feature extends BaseFeature<ProjectDependencies> {}
|
|
137
|
+
export class ApplicationService extends BaseApplicationService<
|
|
138
|
+
ProjectDependencies,
|
|
139
|
+
ProjectFeatures
|
|
140
|
+
> {}
|
|
141
|
+
|
|
142
|
+
export function configProjectBoundedContext(name: string) {
|
|
143
|
+
return registerDependencies(new UseBoundedContext<ProjectDependencies>(name));
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
// domains/project/services/issues.ts — a Feature is a facade: one part of the domain
|
|
149
|
+
export class Issues extends Feature {
|
|
150
|
+
close(input: dto.CommandCloseIssue) {
|
|
151
|
+
this.session.require(); // each dependency is a typed property
|
|
152
|
+
return this.client.closeIssue(input); // carries this run's traceparent
|
|
28
153
|
}
|
|
29
154
|
}
|
|
30
155
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
return this.
|
|
156
|
+
// domains/project/services/close_and_notify.ts — composes the Features of its bounded context
|
|
157
|
+
export class CloseAndNotify extends ApplicationService {
|
|
158
|
+
async execute(input: dto.CommandCloseIssue) {
|
|
159
|
+
await this.feature.Issues.close(input); // a child span, the same trace
|
|
160
|
+
return this.feature.Notices.tell(input);
|
|
36
161
|
}
|
|
37
162
|
}
|
|
38
163
|
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
164
|
+
// domains/project/services/index.ts
|
|
165
|
+
export const features = { Issues, Notices };
|
|
166
|
+
export const applicationServices = { CloseAndNotify };
|
|
167
|
+
export type ProjectFeatures = typeof features;
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
// domains/project/index.ts
|
|
172
|
+
export const project = configProjectBoundedContext("project").register({
|
|
173
|
+
features,
|
|
174
|
+
applicationServices,
|
|
42
175
|
});
|
|
43
|
-
|
|
176
|
+
|
|
177
|
+
// anywhere: a screen, a script, a test
|
|
178
|
+
await project.Issues.close(input); // span project/Issues.close
|
|
179
|
+
await project.CloseAndNotify.execute(input); // project/CloseAndNotify → its Features under it
|
|
44
180
|
```
|
|
45
181
|
|
|
182
|
+
Every public method is a use case: a span named as the backend names its own
|
|
183
|
+
(`project/Issues.close`, identity `project.Issues.close`), and a report when it fails in a way
|
|
184
|
+
nobody expected. One gesture is one trace, from the click to the database:
|
|
185
|
+
|
|
186
|
+
```mermaid
|
|
187
|
+
sequenceDiagram
|
|
188
|
+
participant caller as Caller<br/>(a screen, a test)
|
|
189
|
+
participant app as project.CloseAndNotify<br/>(ApplicationService)
|
|
190
|
+
participant feature as this.feature.Issues<br/>(Feature)
|
|
191
|
+
participant client as this.client<br/>(generated, bound to the run)
|
|
192
|
+
participant process as Process<br/>(Tracer · ErrorReporter)
|
|
193
|
+
participant server as sincpro_framework
|
|
194
|
+
|
|
195
|
+
caller->>app: execute(input)
|
|
196
|
+
app->>process: span project/CloseAndNotify — the context read once
|
|
197
|
+
app->>feature: close(input)
|
|
198
|
+
feature->>process: child span project/Issues.close
|
|
199
|
+
feature->>client: closeIssue(input)
|
|
200
|
+
client->>server: POST /issues/{issue_id}/close · traceparent · sincpro-context
|
|
201
|
+
server-->>client: ResponseCloseIssue — or a Problem
|
|
202
|
+
client-->>caller: the answer, through each run
|
|
203
|
+
Note over process: each run ends ok · refused (shown, never reported) · aborted · failed (reported once, with its trace id)
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
`project` holds references only — each service is made once, the first time it is
|
|
207
|
+
asked for — and it is never frozen: `project.with({ client: fake })` and
|
|
208
|
+
`project.scoped({ company_id: 2 })` derive a variant that shares everything else.
|
|
209
|
+
|
|
210
|
+
## The process: tracing, errors and the context
|
|
211
|
+
|
|
212
|
+
What every bounded context runs with is set once, where the application starts — the way the
|
|
213
|
+
backend's process is. All of it is optional:
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
// entrypoints/main.ts
|
|
217
|
+
configureProcess({
|
|
218
|
+
tracer, // the platform's Tracer
|
|
219
|
+
reporter, // the platform's ErrorReporter
|
|
220
|
+
context: memoryContexts(), // where the context lives (this is the default)
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
processContext.set({ tenant_id: "acme" }); // every use case runs with it and sends it
|
|
224
|
+
project.context.set({ warehouse_id: 4 }); // only what «project» runs
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
A service reads it as `this.context` — the process's values, under them its bounded context's,
|
|
228
|
+
under them a scope's, read once where the gesture started — and every request it sends carries
|
|
229
|
+
it in `sincpro-context`. A screen hears it change with `processContext.watch(…)`.
|
|
230
|
+
|
|
231
|
+
```mermaid
|
|
232
|
+
flowchart LR
|
|
233
|
+
p["processContext<br/>tenant_id · company_id"]
|
|
234
|
+
b["project.context<br/>warehouse_id"]
|
|
235
|
+
s["project.scoped({ company_id: 2 })<br/>one window"]
|
|
236
|
+
e["this.context<br/>read once where the gesture starts"]
|
|
237
|
+
c["this.context of each Feature it runs<br/>the same values"]
|
|
238
|
+
h["sincpro-context<br/>on every request"]
|
|
239
|
+
store[("ContextStore<br/>the platform's")]
|
|
240
|
+
p --> e
|
|
241
|
+
b --> e
|
|
242
|
+
s --> e
|
|
243
|
+
e --> c
|
|
244
|
+
e --> h
|
|
245
|
+
c --> h
|
|
246
|
+
p -.-> store
|
|
247
|
+
b -.-> store
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
The context lives in a `ContextStore`, the port each platform implements with its own technology:
|
|
251
|
+
memory by default, `sessionStorage` or the screens' own store in a browser, MMKV on a phone,
|
|
252
|
+
`AsyncLocalStorage` in a server that renders. The services never know which.
|
|
253
|
+
|
|
46
254
|
`tracer` and `reporter` are injected by the application — this package initialises nothing.
|
|
47
255
|
Any object that fulfils the port works; for the common vendors there are optional adapters that
|
|
48
256
|
wrap what the application built:
|
|
@@ -59,18 +267,85 @@ Nothing is installed or shipped unless it is imported, and without a tracer or a
|
|
|
59
267
|
case runs the same — nothing traces, nothing reports. In a test, `memoryTracer()` and
|
|
60
268
|
`memoryReporter()` (`@sincpro/client-framework/testing`).
|
|
61
269
|
|
|
270
|
+
## Values on the wire
|
|
271
|
+
|
|
272
|
+
The backend writes every message with one set of rules, and every client reads and writes them
|
|
273
|
+
the same — so they live here, not in each platform:
|
|
274
|
+
|
|
275
|
+
| Value | Travels as | Here |
|
|
276
|
+
| ----------- | ------------------------------------------ | ------------------------------------------------ |
|
|
277
|
+
| a decimal | text, every digit: `"10.50"` | `Decimal` — exact, on `BigInt`; rounds half up |
|
|
278
|
+
| a money | `{ "amount": "10.50", "currency": "BOB" }` | `Money` — never adds two currencies |
|
|
279
|
+
| a day | `"2026-10-02"`, never converted | `isDay`, `dayOf(instant, zone)` for «today» |
|
|
280
|
+
| an instant | ISO text: `"2026-10-02T14:30:00Z"` | `wallTimeOf` / `instantOf` on the company's zone |
|
|
281
|
+
| an id | a UUID v7 as 32 hex | `newEntityId`, `isEntityId`, `momentOf` |
|
|
282
|
+
| bytes | `{ "$bytes": "<base64>" }` | `valuesOf` / `readValues` |
|
|
283
|
+
| the context | only what travels: text, numbers, lists | `plainCodec`, `travelling` |
|
|
284
|
+
|
|
285
|
+
```ts
|
|
286
|
+
import { Money, pack, unpack, valuesOf } from "@sincpro/client-framework";
|
|
287
|
+
|
|
288
|
+
const line = new Money("19.90", "BOB").times("3"); // 59.70 BOB, exact
|
|
289
|
+
const total = Money.total([line, delivery], "BOB").rounded(); // to the currency's decimals
|
|
290
|
+
valuesOf({ total, at: new Date() }); // { total: { amount, currency }, at: "…Z" }
|
|
291
|
+
const answer = unpack(text, Money); // a typed read when asked for — never imposed
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
Not restrictive, like the backend: reading keeps every key it finds, a typed read is asked for
|
|
295
|
+
(`rebuilt(values, Money)`), any currency code is read, and an application adds its own decimals
|
|
296
|
+
(`Money.decimals["USDT"] = 6`). What JSON cannot write (`NaN`, a function, a cycle) is refused at
|
|
297
|
+
once, naming where. Showing a value to a person — `Intl.NumberFormat`, a date picker — is each
|
|
298
|
+
platform's, never this package's.
|
|
299
|
+
|
|
62
300
|
## Where it sits
|
|
63
301
|
|
|
64
|
-
```
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
@sincpro/
|
|
68
|
-
|
|
69
|
-
@sincpro/web-framework
|
|
302
|
+
```mermaid
|
|
303
|
+
flowchart TB
|
|
304
|
+
backend["sincpro_framework<br/>the service: its bounded contexts, Commands, the client contract"]
|
|
305
|
+
criteria["@sincpro/criteria<br/>Criteria · Meta · Page · Problem"]
|
|
306
|
+
cf["@sincpro/client-framework<br/>bounded contexts · services · process · context · ports · the client · the generator"]
|
|
307
|
+
web["@sincpro/web-framework<br/>hooks · screens · the browser's tracer and reporter"]
|
|
308
|
+
mobile["@sincpro/mobile<br/>React Native · SQLite · its queue"]
|
|
309
|
+
rest["a script · a server-side render · a test"]
|
|
310
|
+
web --> cf
|
|
311
|
+
mobile --> cf
|
|
312
|
+
rest --> cf
|
|
313
|
+
cf --> criteria
|
|
314
|
+
backend -. "client contract → sincpro-client generate" .-> cf
|
|
315
|
+
cf -- "HTTP · traceparent · sincpro-context" --> backend
|
|
70
316
|
```
|
|
71
317
|
|
|
72
318
|
No React, no DOM, no platform: what a platform must provide — how a span is exported, where an
|
|
73
|
-
error is reported — it plugs through a port.
|
|
319
|
+
error is reported, where the context lives — it plugs through a port.
|
|
320
|
+
|
|
321
|
+
## On each platform
|
|
322
|
+
|
|
323
|
+
This package asks the platform for little: `fetch`, `AbortSignal`, `URL` / `URLSearchParams`,
|
|
324
|
+
`crypto.getRandomValues` (client-side ids, retry keys), `Proxy` / `WeakMap`. Browsers and Node 26
|
|
325
|
+
have them all. **React Native** (Hermes) needs two polyfills, installed once where the app
|
|
326
|
+
starts, before anything imports this package:
|
|
327
|
+
|
|
328
|
+
```ts
|
|
329
|
+
import "react-native-get-random-values"; // or expo-crypto's getRandomValues polyfill
|
|
330
|
+
import "react-native-url-polyfill/auto"; // RN's own URL resolves relative paths poorly
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
`memoryTransport` copies with `structuredClone` where the engine has it and a JSON round trip
|
|
334
|
+
where it does not. The OpenTelemetry adapter takes `@opentelemetry/api` from 1.9, the version
|
|
335
|
+
`@sincpro/mobile` runs.
|
|
336
|
+
|
|
337
|
+
The services are the same everywhere; each platform plugs its own technology behind the same
|
|
338
|
+
three ports, in its `main`:
|
|
339
|
+
|
|
340
|
+
| Port | Browser (@sincpro/web-framework) | Phone (@sincpro/mobile) | Test |
|
|
341
|
+
| --------------- | ----------------------------------------------- | ------------------------------------------ | ----------------- |
|
|
342
|
+
| `Tracer` | OpenTelemetry + OTLP/HTTP (`./telemetry`) | OpenTelemetry with its queue and exporter | `memoryTracer` |
|
|
343
|
+
| `ErrorReporter` | GlitchTip / Sentry browser client (`./errors`) | Sentry React Native client | `memoryReporter` |
|
|
344
|
+
| `ContextStore` | memory, `sessionStorage`, or the screens' store | MMKV, or memory hydrated from AsyncStorage | `memoryContexts` |
|
|
345
|
+
| `Transport` | `restTransport` over `fetch` | `restTransport`, or SQLite offline | `memoryTransport` |
|
|
346
|
+
|
|
347
|
+
A store's reads are synchronous — a use case reads its context where a gesture starts and never
|
|
348
|
+
waits for it — so an asynchronous storage is held in memory and written through.
|
|
74
349
|
|
|
75
350
|
## Development
|
|
76
351
|
|
|
@@ -6,7 +6,7 @@ import type { Tracer } from "../observability/ports.js";
|
|
|
6
6
|
*
|
|
7
7
|
* // a browser (@sincpro/web-framework/telemetry does this), Node, React Native…
|
|
8
8
|
* const tracer = openTelemetryTracer(provider.getTracer("forge-web"));
|
|
9
|
-
*
|
|
9
|
+
* configureProcess({ tracer });
|
|
10
10
|
*
|
|
11
11
|
* Context: only `@opentelemetry/api`, which runs the same on every platform — the provider, its
|
|
12
12
|
* exporter and where it sends are the platform's (the browser's OTLP over fetch, a phone's
|
|
@@ -5,7 +5,7 @@ import { isSpanContextValid, ROOT_CONTEXT, SpanKind, SpanStatusCode, trace, Trac
|
|
|
5
5
|
*
|
|
6
6
|
* // a browser (@sincpro/web-framework/telemetry does this), Node, React Native…
|
|
7
7
|
* const tracer = openTelemetryTracer(provider.getTracer("forge-web"));
|
|
8
|
-
*
|
|
8
|
+
* configureProcess({ tracer });
|
|
9
9
|
*
|
|
10
10
|
* Context: only `@opentelemetry/api`, which runs the same on every platform — the provider, its
|
|
11
11
|
* exporter and where it sends are the platform's (the browser's OTLP over fetch, a phone's
|
|
@@ -17,11 +17,11 @@ export type ExceptionClient = {
|
|
|
17
17
|
*
|
|
18
18
|
* // @sincpro/web-framework/errors builds the browser's client with GlitchTip's DSN
|
|
19
19
|
* const reporter = sentryReporter(client);
|
|
20
|
-
*
|
|
20
|
+
* configureProcess({ reporter });
|
|
21
21
|
*
|
|
22
22
|
* Context: it imports nothing — it takes the client the platform made (with its DSN, release
|
|
23
23
|
* and data rules), so it costs no dependency and works on every platform. What is reported is
|
|
24
|
-
* already decided by the
|
|
24
|
+
* already decided by the process (`outcomeOf`: only `failed`); this only says where it happened.
|
|
25
25
|
* The context goes as a context, not as tags: a tag is indexed and searched, and an execution's
|
|
26
26
|
* values are not the place for that.
|
|
27
27
|
*/
|