@sincpro/client-framework 0.2.0 → 0.3.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 CHANGED
@@ -8,8 +8,12 @@ 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, the span per use case. When in doubt about a
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.
@@ -20,7 +24,8 @@ it says what this package is responsible for and what it is not. This file is ho
20
24
  | -------------------- | ------------------------------------------------------------------------------------------- |
21
25
  | `src/client/` | The seam with a server: `Transport`, REST, memory, `entity()`, the client contract, ids |
22
26
  | `src/model/` | Model definitions: `defineMeta`, from a JSON Schema, the store of known ones |
23
- | `src/services/` | `Feature`, `ApplicationService`, the runtime, the execution and what binds to it |
27
+ | `src/services/` | `UseBoundedContext`, `Feature`, `ApplicationService`, the process, the execution |
28
+ | `src/context/` | The `ContextStore` port, `memoryContexts`, and `Context` — one level of it |
24
29
  | `src/observability/` | The ports a platform plugs: `Tracer`, `ErrorReporter` |
25
30
  | `src/codegen/` | The generator and its CLI (`sincpro-client generate`) — the only place Node is allowed |
26
31
  | `src/adapters/` | Optional entries over a vendor (`./opentelemetry`, `./sentry`): the only place one is named |
@@ -33,7 +38,7 @@ it says what this package is responsible for and what it is not. This file is ho
33
38
  no `document`, `window`, `localStorage`, `navigator`. What a platform must give, it gives
34
39
  through a port.
35
40
  - **Node only in the generator.** `node:` imports, `process` and `Buffer` live in `src/codegen/`
36
- and nowhere else; nothing in the runtime imports `codegen/`.
41
+ and nowhere else; nothing an application runs imports `codegen/`.
37
42
  - **Self-imports, no extensions.** Inside `src/`, import `@sincpro/client-framework/<path>` —
38
43
  never a relative path, never `.ts`.
39
44
 
@@ -48,16 +53,28 @@ it says what this package is responsible for and what it is not. This file is ho
48
53
 
49
54
  ## Known traps
50
55
 
51
- - **A run sees the instance through a view of its own.** Inside a method, `this.deps`,
52
- `this.context` and `this.features` are that run's — bound to its execution. So a Feature keeps
53
- nothing of a request on `this`, and declares no `#private` fields (they refuse the view); use
54
- TypeScript's `private`.
55
- - **A span is named by the key, not the class** (`bindServices(runtime, "project", { sendIssue })`
56
- → `project/sendIssue`; an ApplicationService's Features by their keys too): a production build
57
- renames classes. Only an unbound Feature falls back to its class name.
58
- - **The trace reaches the wire through `deps`.** A dependency is bound to the run when it is a
59
- `Transport` or carries `BIND` (every generated client does, through `bindable`); anything else
60
- is handed as it is. A client built outside `deps` and closed over sends no trace.
56
+ - **A run sees the instance through a view of its own.** Inside a method, each dependency
57
+ (`this.client`), `this.context` and `this.feature` are that run's — bound to its execution. So a
58
+ service declares no `#private` fields (they refuse the view); use TypeScript's `private`.
59
+ - **A service is a singleton of its bounded context.** What a method writes on `this` lands on the
60
+ instance, shared by the next call, the call beside it and every `with`/`scoped` variant — as in
61
+ sincpro_framework. What belongs to one request goes in its arguments or its context.
62
+ - **A span is named by the registered name, not the class** (`register({ features: { Issues } })`
63
+ → `project/Issues.close`, identity `project.Issues.close`): a production build renames classes,
64
+ never the keys of an object.
65
+ - **The dependencies are typed, not declared.** `BaseFeature<ProjectDependencies>` types
66
+ `this.client` from an interface, which does not exist at run time; the bounded context answers
67
+ whatever was added with `addDependency`. A dependency nobody added reads `undefined`.
68
+ - **The trace reaches the wire through the dependencies.** A dependency is bound to the run when it
69
+ is a `Transport` or carries `BIND` (every generated client does, through `bindable`); anything
70
+ else is handed as it is. A client built elsewhere and closed over sends no trace.
71
+ - **The process is read on every call, never at import.** `project` is defined when its module
72
+ loads, before `main` runs `configureProcess`; nothing may capture the tracer, the reporter or the
73
+ store at definition time. `configureProcess` replaces everything at once — a test calls it in
74
+ `beforeEach` and starts with an empty context.
75
+ - **A store's `load` may answer a new object each time** (one that parses JSON does): `Context`
76
+ keeps one object per `version`, so a screen can hand `values` to `useSyncExternalStore`. A store
77
+ that changes without moving the version is a broken store.
61
78
  - **What the generator writes is compared byte for byte** (`tests/support/forge-contract/`, out
62
79
  of prettier and eslint). After changing the generator on purpose, run `make codegen-fixtures`
63
80
  and read the diff.
@@ -71,7 +88,7 @@ it says what this package is responsible for and what it is not. This file is ho
71
88
  all-zeros span: the adapter gives no ids, so no `traceparent` of zeros reaches the server.
72
89
  - **Reported means unexpected.** A `TransportError` the server answered with a 4xx is `refused`,
73
90
  an `AbortError` is `aborted`; neither is reported (`outcomeOf`). Change that with
74
- `RuntimeOptions.outcomeOf`, never by catching in a Feature.
91
+ `ProcessOptions.outcomeOf`, never by catching in a Feature.
75
92
 
76
93
  ## Before you hand it back
77
94
 
package/MANIFESTO.md CHANGED
@@ -25,22 +25,27 @@ server-side render, in a script and in a test, unchanged.**
25
25
 
26
26
  ## What it is responsible for
27
27
 
28
- 1. **Use cases.** `Feature` is one use case, with its dependencies injected; `ApplicationService`
29
- is a Feature that also executes the Features of its own domain — never another
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 `<domain>/<Feature>` like the backend's; the unexpected reported with its trace id;
36
- what the server already explained (a validation, a conflict) not reported. The platform plugs
37
- the implementation — OpenTelemetry, GlitchTip, a queue on the phone, memory in a test.
38
- 4. **Context.** The levels a call sees — the application, its domain, the call itself — and what
39
- travels to the server with it (`traceparent`, `sincpro-context`). Carried by each call, never
40
- kept in a global: two calls at once never mix their parents.
41
- 5. **Domains and the application.** A bounded context is declared once — its client, its use
42
- cases, what they depend on — and an application is the set of domains it loads. Domains never
43
- import each other; they talk by reference.
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
51
  the server, client-side ids (UUID v7) and the server's `Problem`.
@@ -55,7 +60,8 @@ server-side render, in a script and in a test, unchanged.**
55
60
  the web and to @sincpro/mobile on a phone.
56
61
  - **Not a shell, a router or a window manager.** Where things open is the platform's.
57
62
  - **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.
63
+ with the libraries that do it best. The context is not one either: a handful of values every
64
+ use case runs with — the tenant, the company — kept wherever the platform says.
59
65
  - **Not offline storage.** SQLite on a phone or IndexedDB in a point of sale are platform
60
66
  adapters.
61
67
  - **Not a telemetry or error-tracking SDK.** It defines the ports and calls them. For the
@@ -72,14 +78,16 @@ server-side render, in a script and in a test, unchanged.**
72
78
  1. **Platform-neutral, or it does not go in.** It runs on Node, in a browser and on React Native.
73
79
  What it needs from a platform it asks through a port, never by reaching for it. The build
74
80
  enforces it.
75
- 2. **The backend's words.** Feature, ApplicationService, domain, context, the span name — the
76
- same as sincpro_framework, so whoever knows the backend already knows the client.
77
- 3. **Nothing escapes the interceptors.** Reusing a use case is executing its Feature. A helper
78
- that wraps one skips the trace and the report — that is how an incident ends up with nothing
79
- to look at.
81
+ 2. **The backend's words.** Bounded context, Feature, ApplicationService, dependencies, context,
82
+ process, the span name — the same as sincpro_framework, so whoever knows the backend already
83
+ knows the client.
84
+ 3. **Nothing escapes the interceptors.** Reusing a use case is calling it through its bounded
85
+ context. A helper that wraps one skips the trace and the report — that is how an incident ends
86
+ up with nothing to look at.
80
87
  4. **Context travels with the call.** Never in a global stack: clients run many things at once.
81
88
  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.
89
+ REST and memory; `Tracer` has a platform's and a test's; `ContextStore` has the browser's, the
90
+ phone's and memory.
83
91
  6. **Generated, never edited.** Applications extend by composition; regenerating never loses
84
92
  work.
85
93
  7. **The server is the source of truth.** A client reads the model, the preview and the refusal,
@@ -95,8 +103,8 @@ server-side render, in a script and in a test, unchanged.**
95
103
  @sincpro/criteria the contract with the server: Criteria, Meta, Page, Problem
96
104
  ▲
97
105
  @sincpro/client-framework this package — platform-neutral
98
- │ Feature · ApplicationService · interceptors · context per call
99
- │ Domain · application · Tracer and ErrorReporter ports
106
+ │ UseBoundedContext · Feature · ApplicationService · interceptors
107
+ │ the process · the context · Tracer, ErrorReporter and ContextStore ports
100
108
  │ Transport · entity() · client contract · model definitions · ids
101
109
  │ the generator · in-memory doubles for tests
102
110
  ▲
@@ -115,8 +123,14 @@ where they already ran without the DOM. `Feature` and `ApplicationService` came
115
123
  ports for tracing and error reporting are in, with the trace and the context written on every
116
124
  request a use case sends.
117
125
 
118
- **Next:** the domains and the application. The plan is written in @sincpro/web-framework
119
- (`docs/prd/PRD_02_marco-de-trabajo.md`, in Spanish like the rest of Sincpro's design records).
126
+ **Then the bounded context**, set up as the backend sets one up: a dependency type that types every
127
+ service's `this.client`, `addDependency`, the services registered by name and used as
128
+ `project.Issues.close(input)` and `this.feature.Issues`; the process configured once in `main`
129
+ (`configureProcess`); and the context as a port, so the browser and the phone keep it each with
130
+ their own technology behind the same door.
131
+
132
+ **Next:** @sincpro/mobile on this package, and the screens' side in @sincpro/web-framework — the
133
+ cache of reads, invalidated by the writes a use case sends.
120
134
 
121
135
  ## How we decide
122
136
 
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 domains.
8
+ its bounded contexts.
9
9
 
10
- > **Status: 0.x, the first cut.** The API may still change before 1.0. In: the client, the
11
- > generator, `Feature` and `ApplicationService` with their interceptors, and the ports for tracing
12
- > and error reporting. Read the [manifesto](MANIFESTO.md) for what this package is responsible
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
- ## A use case, traced and reported by default
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
+ ```
111
+
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
+ ```
16
125
 
17
126
  ```ts
127
+ // domains/project/infrastructure/bounded_context.ts
18
128
  import {
19
- ApplicationService,
20
- bindServices,
21
- createRuntime,
22
- Feature,
129
+ ApplicationService as BaseApplicationService,
130
+ Feature as BaseFeature,
131
+ UseBoundedContext,
23
132
  } from "@sincpro/client-framework";
24
133
 
25
- class SendIssue extends Feature<{ project: ProjectClient }> {
26
- execute(input: dto.CommandSendIssue) {
27
- return this.deps.project.sendIssue(input); // carries this run's traceparent
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
- class CloseIssue extends ApplicationService<{ preview: PreviewClose; send: SendIssue }> {
32
- async execute(input: CloseIssueInput) {
33
- const preview = await this.features.preview.execute(input); // a child span
34
- if (preview.warnings.length > 0) return { blocked: preview };
35
- return this.features.send.execute({ ...input, message: "closed" }); // another child
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
- const runtime = createRuntime({ tracer, reporter, context: () => ({ tenant_id: "acme" }) });
40
- const project = bindServices(runtime, "project", {
41
- closeIssue: new CloseIssue({ preview: new PreviewClose(deps), send: new SendIssue(deps) }),
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,
175
+ });
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
180
+ ```
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)
42
221
  });
43
- await project.closeIssue.execute(input); // project/closeIssue → project/preview, project/send
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
44
248
  ```
45
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:
@@ -61,16 +269,53 @@ case runs the same — nothing traces, nothing reports. In a test, `memoryTracer
61
269
 
62
270
  ## Where it sits
63
271
 
64
- ```text
65
- @sincpro/criteria the contract with the server
66
- ▲
67
- @sincpro/client-framework use cases · interceptors · context · ports · domains · the client
68
- ▲
69
- @sincpro/web-framework the web @sincpro/mobile a phone
272
+ ```mermaid
273
+ flowchart TB
274
+ backend["sincpro_framework<br/>the service: its bounded contexts, Commands, the client contract"]
275
+ criteria["@sincpro/criteria<br/>Criteria · Meta · Page · Problem"]
276
+ cf["@sincpro/client-framework<br/>bounded contexts · services · process · context · ports · the client · the generator"]
277
+ web["@sincpro/web-framework<br/>hooks · screens · the browser's tracer and reporter"]
278
+ mobile["@sincpro/mobile<br/>React Native · SQLite · its queue"]
279
+ rest["a script · a server-side render · a test"]
280
+ web --> cf
281
+ mobile --> cf
282
+ rest --> cf
283
+ cf --> criteria
284
+ backend -. "client contract → sincpro-client generate" .-> cf
285
+ cf -- "HTTP · traceparent · sincpro-context" --> backend
70
286
  ```
71
287
 
72
288
  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.
289
+ error is reported, where the context lives — it plugs through a port.
290
+
291
+ ## On each platform
292
+
293
+ This package asks the platform for little: `fetch`, `AbortSignal`, `URL` / `URLSearchParams`,
294
+ `crypto.getRandomValues` (client-side ids, retry keys), `Proxy` / `WeakMap`. Browsers and Node 26
295
+ have them all. **React Native** (Hermes) needs two polyfills, installed once where the app
296
+ starts, before anything imports this package:
297
+
298
+ ```ts
299
+ import "react-native-get-random-values"; // or expo-crypto's getRandomValues polyfill
300
+ import "react-native-url-polyfill/auto"; // RN's own URL resolves relative paths poorly
301
+ ```
302
+
303
+ `memoryTransport` copies with `structuredClone` where the engine has it and a JSON round trip
304
+ where it does not. The OpenTelemetry adapter takes `@opentelemetry/api` from 1.9, the version
305
+ `@sincpro/mobile` runs.
306
+
307
+ The services are the same everywhere; each platform plugs its own technology behind the same
308
+ three ports, in its `main`:
309
+
310
+ | Port | Browser (@sincpro/web-framework) | Phone (@sincpro/mobile) | Test |
311
+ | --------------- | ----------------------------------------------- | ------------------------------------------ | ----------------- |
312
+ | `Tracer` | OpenTelemetry + OTLP/HTTP (`./telemetry`) | OpenTelemetry with its queue and exporter | `memoryTracer` |
313
+ | `ErrorReporter` | GlitchTip / Sentry browser client (`./errors`) | Sentry React Native client | `memoryReporter` |
314
+ | `ContextStore` | memory, `sessionStorage`, or the screens' store | MMKV, or memory hydrated from AsyncStorage | `memoryContexts` |
315
+ | `Transport` | `restTransport` over `fetch` | `restTransport`, or SQLite offline | `memoryTransport` |
316
+
317
+ A store's reads are synchronous — a use case reads its context where a gesture starts and never
318
+ waits for it — so an asynchronous storage is held in memory and written through.
74
319
 
75
320
  ## Development
76
321
 
@@ -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
- * const runtime = createRuntime({ tracer });
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
- * const runtime = createRuntime({ tracer });
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
- * const runtime = createRuntime({ reporter });
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 runtime (`outcomeOf`: only `failed`); this only says where it happened.
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
  */
@@ -4,11 +4,11 @@
4
4
  *
5
5
  * // @sincpro/web-framework/errors builds the browser's client with GlitchTip's DSN
6
6
  * const reporter = sentryReporter(client);
7
- * const runtime = createRuntime({ reporter });
7
+ * configureProcess({ reporter });
8
8
  *
9
9
  * Context: it imports nothing — it takes the client the platform made (with its DSN, release
10
10
  * and data rules), so it costs no dependency and works on every platform. What is reported is
11
- * already decided by the runtime (`outcomeOf`: only `failed`); this only says where it happened.
11
+ * already decided by the process (`outcomeOf`: only `failed`); this only says where it happened.
12
12
  * The context goes as a context, not as tags: a tag is indexed and searched, and an execution's
13
13
  * values are not the place for that.
14
14
  */
@@ -1,5 +1,12 @@
1
1
  import { TransportError, } from "./transport.js";
2
2
  import { answer, answerGroups } from "@sincpro/criteria/engine";
3
+ /**
4
+ * A deep copy, as over a wire: `structuredClone` where the platform has it, a JSON round trip
5
+ * where it does not (some React Native engines) — the rows a fake server holds are plain JSON.
6
+ */
7
+ const copyOf = typeof globalThis.structuredClone === "function"
8
+ ? (value) => globalThis.structuredClone(value)
9
+ : (value) => (value === undefined ? value : JSON.parse(JSON.stringify(value)));
3
10
  function wait(ms, signal) {
4
11
  if (ms <= 0)
5
12
  return Promise.resolve();
@@ -46,7 +53,7 @@ export function memoryTransport(tables, options = {}) {
46
53
  ? answerGroups(table.rows, criteria, table.meta)
47
54
  : answer(table.rows, criteria, table.meta);
48
55
  // A copy, as over a wire: a screen holding a row must not see the server change it.
49
- return { ...page, rows: structuredClone(page.rows) };
56
+ return { ...page, rows: copyOf(page.rows) };
50
57
  },
51
58
  async one(route, signal) {
52
59
  await check(route, signal);
@@ -66,7 +73,7 @@ export function memoryTransport(tables, options = {}) {
66
73
  detail: `${table.meta.aggregate} ${id} does not exist.`,
67
74
  });
68
75
  return {
69
- record: structuredClone(record),
76
+ record: copyOf(record),
70
77
  meta: table.meta,
71
78
  dropped: [],
72
79
  draft: options.draft?.(route) ?? null,
@@ -78,7 +85,7 @@ export function memoryTransport(tables, options = {}) {
78
85
  // sincpro_framework's `@idempotency` does with a repeated `Idempotency-Key`.
79
86
  const key = sending?.idempotencyKey;
80
87
  if (key !== undefined && answered.has(key))
81
- return structuredClone(answered.get(key));
88
+ return copyOf(answered.get(key));
82
89
  if (options.handle === undefined)
83
90
  throw new TransportError(route, 404, {
84
91
  kind: "not_found",
@@ -90,9 +97,9 @@ export function memoryTransport(tables, options = {}) {
90
97
  detail: `Nothing answers ${method} ${route}.`,
91
98
  });
92
99
  // A deep copy: what the caller sent is not what the server keeps.
93
- const answer = await options.handle(method, route, structuredClone(body));
100
+ const answer = await options.handle(method, route, copyOf(body));
94
101
  if (key !== undefined)
95
- answered.set(key, structuredClone(answer));
102
+ answered.set(key, copyOf(answer));
96
103
  return answer;
97
104
  },
98
105
  ...(options.read === undefined
@@ -101,7 +108,7 @@ export function memoryTransport(tables, options = {}) {
101
108
  async read(route, signal) {
102
109
  await check(route, signal);
103
110
  // A copy, as every answer: over a wire nobody holds the server's object.
104
- return structuredClone(await options.read(route));
111
+ return copyOf(await options.read(route));
105
112
  },
106
113
  }),
107
114
  };