@sincpro/client-framework 0.0.0-stage → 0.2.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 +85 -0
- package/LICENSE +21 -0
- package/MANIFESTO.md +127 -0
- package/README.md +100 -2
- package/dist/adapters/opentelemetry.d.ts +21 -0
- package/dist/adapters/opentelemetry.js +68 -0
- package/dist/adapters/sentry.d.ts +28 -0
- package/dist/adapters/sentry.js +36 -0
- package/dist/client/contract.d.ts +34 -0
- package/dist/client/contract.js +41 -0
- package/dist/client/discovery.d.ts +47 -0
- package/dist/client/discovery.js +68 -0
- package/dist/client/entity.d.ts +242 -0
- package/dist/client/entity.js +187 -0
- package/dist/client/ids.d.ts +17 -0
- package/dist/client/ids.js +52 -0
- package/dist/client/memory.d.ts +46 -0
- package/dist/client/memory.js +108 -0
- package/dist/client/rest.d.ts +49 -0
- package/dist/client/rest.js +143 -0
- package/dist/client/retry.d.ts +24 -0
- package/dist/client/retry.js +25 -0
- package/dist/client/transport.d.ts +89 -0
- package/dist/client/transport.js +26 -0
- package/dist/codegen/cli.d.ts +2 -0
- package/dist/codegen/cli.js +82 -0
- package/dist/codegen/client.d.ts +14 -0
- package/dist/codegen/client.js +180 -0
- package/dist/codegen/domain.d.ts +14 -0
- package/dist/codegen/domain.js +195 -0
- package/dist/codegen/entities.d.ts +14 -0
- package/dist/codegen/entities.js +158 -0
- package/dist/codegen/index.d.ts +10 -0
- package/dist/codegen/index.js +8 -0
- package/dist/codegen/metamodel.d.ts +89 -0
- package/dist/codegen/metamodel.js +1 -0
- package/dist/codegen/names.d.ts +39 -0
- package/dist/codegen/names.js +113 -0
- package/dist/codegen/rest.d.ts +31 -0
- package/dist/codegen/rest.js +27 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +31 -0
- package/dist/model/meta.d.ts +121 -0
- package/dist/model/meta.js +130 -0
- package/dist/model/metas.d.ts +17 -0
- package/dist/model/metas.js +7 -0
- package/dist/model/schema.d.ts +39 -0
- package/dist/model/schema.js +128 -0
- package/dist/observability/ports.d.ts +64 -0
- package/dist/observability/ports.js +21 -0
- package/dist/services/execution.d.ts +63 -0
- package/dist/services/execution.js +88 -0
- package/dist/services/feature.d.ts +60 -0
- package/dist/services/feature.js +220 -0
- package/dist/services/runtime.d.ts +48 -0
- package/dist/services/runtime.js +30 -0
- package/dist/testing/index.d.ts +35 -0
- package/dist/testing/index.js +37 -0
- package/package.json +92 -4
package/AGENTS.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# AGENTS.md — working on @sincpro/client-framework
|
|
2
|
+
|
|
3
|
+
Rules for any agent (or person) changing this package. Read [`MANIFESTO.md`](MANIFESTO.md) first:
|
|
4
|
+
it says what this package is responsible for and what it is not. This file is how to keep it.
|
|
5
|
+
|
|
6
|
+
## Before you change anything
|
|
7
|
+
|
|
8
|
+
- **Read the manifesto.** A piece that serves nothing there does not go in; a piece it forbids
|
|
9
|
+
does not either. If you think it should, change the manifesto first and say why.
|
|
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
|
|
12
|
+
word or a behaviour, read how the backend does it, not memory.
|
|
13
|
+
- **Look at who uses it.** @sincpro/web-framework (`~/dev/sincpro_web_framework`) and
|
|
14
|
+
@sincpro/mobile (`~/dev/mobile/sincpro_mobile`). A piece only one of them needs, and that
|
|
15
|
+
needs a platform, belongs there.
|
|
16
|
+
|
|
17
|
+
## Where things go
|
|
18
|
+
|
|
19
|
+
| Folder | What |
|
|
20
|
+
| -------------------- | ------------------------------------------------------------------------------------------- |
|
|
21
|
+
| `src/client/` | The seam with a server: `Transport`, REST, memory, `entity()`, the client contract, ids |
|
|
22
|
+
| `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 |
|
|
24
|
+
| `src/observability/` | The ports a platform plugs: `Tracer`, `ErrorReporter` |
|
|
25
|
+
| `src/codegen/` | The generator and its CLI (`sincpro-client generate`) — the only place Node is allowed |
|
|
26
|
+
| `src/adapters/` | Optional entries over a vendor (`./opentelemetry`, `./sentry`): the only place one is named |
|
|
27
|
+
| `src/testing/` | `memoryTracer`, `memoryReporter` — the ports in memory, for tests |
|
|
28
|
+
| `tests/` | vitest in Node; `tests/support/` holds the fake domains and forge's real contracts |
|
|
29
|
+
|
|
30
|
+
## Hard rules (the build enforces them — `make doctor`)
|
|
31
|
+
|
|
32
|
+
- **Platform-neutral.** No React, no `react-native`, no web-ui, no composer, no @sincpro/mobile;
|
|
33
|
+
no `document`, `window`, `localStorage`, `navigator`. What a platform must give, it gives
|
|
34
|
+
through a port.
|
|
35
|
+
- **Node only in the generator.** `node:` imports, `process` and `Buffer` live in `src/codegen/`
|
|
36
|
+
and nowhere else; nothing in the runtime imports `codegen/`.
|
|
37
|
+
- **Self-imports, no extensions.** Inside `src/`, import `@sincpro/client-framework/<path>` —
|
|
38
|
+
never a relative path, never `.ts`.
|
|
39
|
+
|
|
40
|
+
## Conventions
|
|
41
|
+
|
|
42
|
+
- Code, comments and docs in English: this package is open source.
|
|
43
|
+
- `Context:` blocks at the top of a file are the design record: why it is the way it is. Keep
|
|
44
|
+
them true.
|
|
45
|
+
- A change ships with a test in `tests/` that fails without it, run in Node.
|
|
46
|
+
- Public means exported from `src/index.ts`. No side effects at import (`sideEffects: false`).
|
|
47
|
+
- A port (an `interface`) only where there are two implementations; a class or a type otherwise.
|
|
48
|
+
|
|
49
|
+
## Known traps
|
|
50
|
+
|
|
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.
|
|
61
|
+
- **What the generator writes is compared byte for byte** (`tests/support/forge-contract/`, out
|
|
62
|
+
of prettier and eslint). After changing the generator on purpose, run `make codegen-fixtures`
|
|
63
|
+
and read the diff.
|
|
64
|
+
- **A module name the generator writes is built in parts** (`codegen/names.ts`, `FRAMEWORK`): the
|
|
65
|
+
build's `tsc-alias` rewrites this package's specifiers inside strings too.
|
|
66
|
+
- **An adapter wraps what the application built, never builds it.** `openTelemetryTracer` takes
|
|
67
|
+
the app's tracer, `sentryReporter` the app's client: no `register()`, no `Sentry.init()`, no
|
|
68
|
+
endpoint or DSN here, nothing read from the environment. A vendor's SDK is imported only in
|
|
69
|
+
`src/adapters/`, and nothing else imports an adapter (`make doctor`).
|
|
70
|
+
- **A span nothing records claims no trace.** OpenTelemetry's API with no provider answers an
|
|
71
|
+
all-zeros span: the adapter gives no ids, so no `traceparent` of zeros reaches the server.
|
|
72
|
+
- **Reported means unexpected.** A `TransportError` the server answered with a 4xx is `refused`,
|
|
73
|
+
an `AbortError` is `aborted`; neither is reported (`outcomeOf`). Change that with
|
|
74
|
+
`RuntimeOptions.outcomeOf`, never by catching in a Feature.
|
|
75
|
+
|
|
76
|
+
## Before you hand it back
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
make verify-format # eslint --fix + prettier + typecheck + doctor
|
|
80
|
+
make test
|
|
81
|
+
make build
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Never bump `version` in `package.json` — the release pipeline does. Never commit; a person
|
|
85
|
+
does.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SINCPRO S.R.L
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/MANIFESTO.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# Manifesto
|
|
2
|
+
|
|
3
|
+
> **@sincpro/client-framework is the client side of
|
|
4
|
+
> [sincpro_framework](https://github.com/Sincpro-SRL/sincpro_framework), for every platform:
|
|
5
|
+
> where a client's logic lives, how every use case is traced and reported, how an application is
|
|
6
|
+
> made of domains, and how it talks to a service through a client generated from that service's
|
|
7
|
+
> contract. No React, no DOM, no platform.**
|
|
8
|
+
|
|
9
|
+
This is the founding statement of the repository: what it is responsible for, what it is not,
|
|
10
|
+
how it is composed and where it is going. Every change is measured against it. If a change needs
|
|
11
|
+
something this page does not say, change this page first, and say why.
|
|
12
|
+
|
|
13
|
+
## Why it exists
|
|
14
|
+
|
|
15
|
+
Sincpro builds business software with one backend framework and several clients: the web
|
|
16
|
+
([@sincpro/web-framework](https://github.com/Sincpro-SRL/sincpro_web_framework)), mobile
|
|
17
|
+
([@sincpro/mobile](https://github.com/Sincpro-SRL/sincpro_mobile)), server-side renders, scripts,
|
|
18
|
+
tests. Each one was growing the same core on its own — mobile wrote `Feature` and
|
|
19
|
+
`ApplicationService` with interceptors; the web wrote the transport, the entities and the
|
|
20
|
+
generator from the contract — and a use case written for one could not run on the other.
|
|
21
|
+
|
|
22
|
+
The backend already answers how a use case is written, traced and reported. This package gives
|
|
23
|
+
every client the same answer, once: **a Feature written here runs in a browser, on a phone, in a
|
|
24
|
+
server-side render, in a script and in a test, unchanged.**
|
|
25
|
+
|
|
26
|
+
## What it is responsible for
|
|
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.
|
|
31
|
+
2. **Interception.** Every public method of a use case passes through interceptors, and that is
|
|
32
|
+
what makes the defaults free: the trace, the error report, the context. The author of a
|
|
33
|
+
Feature writes none of it.
|
|
34
|
+
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.
|
|
44
|
+
6. **The client.** The `Transport` port and its implementations (REST, memory), `entity()` with
|
|
45
|
+
typed commands and answers, the service's client contract, model definitions read once from
|
|
46
|
+
the server, client-side ids (UUID v7) and the server's `Problem`.
|
|
47
|
+
7. **The generator.** From a service's client contract to each domain's types, client and
|
|
48
|
+
entities — generated, never edited, and as platform-neutral as everything else here.
|
|
49
|
+
8. **Testing.** An in-memory transport, tracer and reporter, so a use case is proven in Node with
|
|
50
|
+
nothing else.
|
|
51
|
+
|
|
52
|
+
## What it is not
|
|
53
|
+
|
|
54
|
+
- **Not a UI.** No React, no components, no hooks: drawing belongs to @sincpro/web-framework on
|
|
55
|
+
the web and to @sincpro/mobile on a phone.
|
|
56
|
+
- **Not a shell, a router or a window manager.** Where things open is the platform's.
|
|
57
|
+
- **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.
|
|
59
|
+
- **Not offline storage.** SQLite on a phone or IndexedDB in a point of sale are platform
|
|
60
|
+
adapters.
|
|
61
|
+
- **Not a telemetry or error-tracking SDK.** It defines the ports and calls them. For the
|
|
62
|
+
common vendors it offers optional adapters (`./opentelemetry`, `./sentry`) that wrap what the
|
|
63
|
+
application built — its provider, its client, with its endpoint and its DSN. It initialises
|
|
64
|
+
nothing and registers nothing global; an application that does not import them installs
|
|
65
|
+
nothing and ships nothing, and without a tracer or a reporter everything runs the same.
|
|
66
|
+
- **Not where business rules live.** The server keeps them; a client reads what it publishes.
|
|
67
|
+
- **Not an event bus**, and not a dispatcher: a use case is executed by calling it, and the
|
|
68
|
+
interceptors are what make that call traced and reported.
|
|
69
|
+
|
|
70
|
+
## What we believe
|
|
71
|
+
|
|
72
|
+
1. **Platform-neutral, or it does not go in.** It runs on Node, in a browser and on React Native.
|
|
73
|
+
What it needs from a platform it asks through a port, never by reaching for it. The build
|
|
74
|
+
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.
|
|
80
|
+
4. **Context travels with the call.** Never in a global stack: clients run many things at once.
|
|
81
|
+
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.
|
|
83
|
+
6. **Generated, never edited.** Applications extend by composition; regenerating never loses
|
|
84
|
+
work.
|
|
85
|
+
7. **The server is the source of truth.** A client reads the model, the preview and the refusal,
|
|
86
|
+
and never re-implements a rule.
|
|
87
|
+
8. **Small, and paid for only when used.** No side effects at import, tree-shakable, and no
|
|
88
|
+
dependency it can avoid.
|
|
89
|
+
9. **Grow by evidence.** Nothing before its second use; every change ships with a test that fails
|
|
90
|
+
without it.
|
|
91
|
+
|
|
92
|
+
## How it is composed
|
|
93
|
+
|
|
94
|
+
```text
|
|
95
|
+
@sincpro/criteria the contract with the server: Criteria, Meta, Page, Problem
|
|
96
|
+
▲
|
|
97
|
+
@sincpro/client-framework this package — platform-neutral
|
|
98
|
+
│ Feature · ApplicationService · interceptors · context per call
|
|
99
|
+
│ Domain · application · Tracer and ErrorReporter ports
|
|
100
|
+
│ Transport · entity() · client contract · model definitions · ids
|
|
101
|
+
│ the generator · in-memory doubles for tests
|
|
102
|
+
▲
|
|
103
|
+
@sincpro/web-framework the web: hooks, components on @sincpro/web-ui, the shell on
|
|
104
|
+
@sincpro/web-composer, the browser's tracing and error reporting
|
|
105
|
+
@sincpro/mobile mobile: the React Native shell, SQLite, its queue, its exporter
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Dependencies point down only. This package knows nothing above it.
|
|
109
|
+
|
|
110
|
+
## Where it stands
|
|
111
|
+
|
|
112
|
+
**The first cut is here.** The client and the generator came out of @sincpro/web-framework,
|
|
113
|
+
where they already ran without the DOM. `Feature` and `ApplicationService` came from
|
|
114
|
+
@sincpro/mobile, with each call carrying its own execution instead of a global stack. And the
|
|
115
|
+
ports for tracing and error reporting are in, with the trace and the context written on every
|
|
116
|
+
request a use case sends.
|
|
117
|
+
|
|
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).
|
|
120
|
+
|
|
121
|
+
## How we decide
|
|
122
|
+
|
|
123
|
+
This page is the yardstick. A piece that serves nothing here does not go in. A piece this page
|
|
124
|
+
forbids does not go in either. To change what this page says, change the page first — in the
|
|
125
|
+
open, with the reason.
|
|
126
|
+
|
|
127
|
+
Open source, under the [MIT license](LICENSE).
|
package/README.md
CHANGED
|
@@ -1,3 +1,101 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @sincpro/client-framework
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**The client side of [sincpro_framework](https://github.com/Sincpro-SRL/sincpro_framework), for
|
|
4
|
+
every platform.** Write a use case once — a `Feature`, or an `ApplicationService` that composes
|
|
5
|
+
Features — and run it in a browser, on a phone, in a server-side render, in a script or in a test.
|
|
6
|
+
Every use case is traced and every unexpected error reported, continuing the backend's trace,
|
|
7
|
+
without its author writing either. A service's client contract becomes typed code for each of
|
|
8
|
+
its domains.
|
|
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.
|
|
14
|
+
|
|
15
|
+
## A use case, traced and reported by default
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import {
|
|
19
|
+
ApplicationService,
|
|
20
|
+
bindServices,
|
|
21
|
+
createRuntime,
|
|
22
|
+
Feature,
|
|
23
|
+
} from "@sincpro/client-framework";
|
|
24
|
+
|
|
25
|
+
class SendIssue extends Feature<{ project: ProjectClient }> {
|
|
26
|
+
execute(input: dto.CommandSendIssue) {
|
|
27
|
+
return this.deps.project.sendIssue(input); // carries this run's traceparent
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
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
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
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) }),
|
|
42
|
+
});
|
|
43
|
+
await project.closeIssue.execute(input); // project/closeIssue → project/preview, project/send
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`tracer` and `reporter` are injected by the application — this package initialises nothing.
|
|
47
|
+
Any object that fulfils the port works; for the common vendors there are optional adapters that
|
|
48
|
+
wrap what the application built:
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { openTelemetryTracer } from "@sincpro/client-framework/opentelemetry"; // peer: @opentelemetry/api
|
|
52
|
+
import { sentryReporter } from "@sincpro/client-framework/sentry"; // no dependency
|
|
53
|
+
|
|
54
|
+
const tracer = openTelemetryTracer(provider.getTracer("my-app")); // the app's provider and exporter
|
|
55
|
+
const reporter = sentryReporter(client); // the app's Sentry-compatible client (GlitchTip too)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Nothing is installed or shipped unless it is imported, and without a tracer or a reporter every use
|
|
59
|
+
case runs the same — nothing traces, nothing reports. In a test, `memoryTracer()` and
|
|
60
|
+
`memoryReporter()` (`@sincpro/client-framework/testing`).
|
|
61
|
+
|
|
62
|
+
## Where it sits
|
|
63
|
+
|
|
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
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
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.
|
|
74
|
+
|
|
75
|
+
## Development
|
|
76
|
+
|
|
77
|
+
Node 26 (`nvm use`).
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
make init # pre-commit + dependencies
|
|
81
|
+
make test # vitest, in Node
|
|
82
|
+
make verify-format # format + lint + doctor; fails if the tree is left dirty (what CI runs)
|
|
83
|
+
make build # tsc into dist/
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`make doctor` enforces what the type checker cannot: the source imports itself, never a relative
|
|
87
|
+
path; no React, no UI, no browser global; Node only inside the generator. Read
|
|
88
|
+
[`AGENTS.md`](AGENTS.md) before changing anything, and [`CONTRIBUTING.md`](CONTRIBUTING.md)
|
|
89
|
+
before opening a pull request.
|
|
90
|
+
|
|
91
|
+
## CI/CD
|
|
92
|
+
|
|
93
|
+
Every workflow in `.github/workflows/` delegates to
|
|
94
|
+
[`Sincpro-SRL/.github`](https://github.com/Sincpro-SRL/.github) and only calls `make` targets:
|
|
95
|
+
checks on every pull request and push to `main`, a release draft on `main`, publication to npm
|
|
96
|
+
when a release is published, and notifications. The version is written by the release pipeline,
|
|
97
|
+
never by hand.
|
|
98
|
+
|
|
99
|
+
## License
|
|
100
|
+
|
|
101
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type Tracer as OtelTracer } from "@opentelemetry/api";
|
|
2
|
+
import type { Tracer } from "../observability/ports.js";
|
|
3
|
+
/**
|
|
4
|
+
* The `Tracer` port over OpenTelemetry's API: every use case a span of whatever provider the
|
|
5
|
+
* platform registered.
|
|
6
|
+
*
|
|
7
|
+
* // a browser (@sincpro/web-framework/telemetry does this), Node, React Native…
|
|
8
|
+
* const tracer = openTelemetryTracer(provider.getTracer("forge-web"));
|
|
9
|
+
* const runtime = createRuntime({ tracer });
|
|
10
|
+
*
|
|
11
|
+
* Context: only `@opentelemetry/api`, which runs the same on every platform — the provider, its
|
|
12
|
+
* exporter and where it sends are the platform's (the browser's OTLP over fetch, a phone's
|
|
13
|
+
* queue, a server's SDK). An optional entry with an optional peer: an application that does not
|
|
14
|
+
* import it installs nothing and ships nothing.
|
|
15
|
+
*
|
|
16
|
+
* The parent is given, never read from OpenTelemetry's context: a run carries its own execution
|
|
17
|
+
* (`Execution`), so two use cases at once never take each other's span, and no context manager
|
|
18
|
+
* (Zone.js) is needed. A span nothing records (a sampler said no, no provider registered)
|
|
19
|
+
* answers no ids — so no `traceparent` claims a trace that does not exist.
|
|
20
|
+
*/
|
|
21
|
+
export declare function openTelemetryTracer(tracer: OtelTracer): Tracer;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { isSpanContextValid, ROOT_CONTEXT, SpanKind, SpanStatusCode, trace, TraceFlags, } from "@opentelemetry/api";
|
|
2
|
+
/**
|
|
3
|
+
* The `Tracer` port over OpenTelemetry's API: every use case a span of whatever provider the
|
|
4
|
+
* platform registered.
|
|
5
|
+
*
|
|
6
|
+
* // a browser (@sincpro/web-framework/telemetry does this), Node, React Native…
|
|
7
|
+
* const tracer = openTelemetryTracer(provider.getTracer("forge-web"));
|
|
8
|
+
* const runtime = createRuntime({ tracer });
|
|
9
|
+
*
|
|
10
|
+
* Context: only `@opentelemetry/api`, which runs the same on every platform — the provider, its
|
|
11
|
+
* exporter and where it sends are the platform's (the browser's OTLP over fetch, a phone's
|
|
12
|
+
* queue, a server's SDK). An optional entry with an optional peer: an application that does not
|
|
13
|
+
* import it installs nothing and ships nothing.
|
|
14
|
+
*
|
|
15
|
+
* The parent is given, never read from OpenTelemetry's context: a run carries its own execution
|
|
16
|
+
* (`Execution`), so two use cases at once never take each other's span, and no context manager
|
|
17
|
+
* (Zone.js) is needed. A span nothing records (a sampler said no, no provider registered)
|
|
18
|
+
* answers no ids — so no `traceparent` claims a trace that does not exist.
|
|
19
|
+
*/
|
|
20
|
+
export function openTelemetryTracer(tracer) {
|
|
21
|
+
return {
|
|
22
|
+
start(name, options) {
|
|
23
|
+
const parent = options.parent;
|
|
24
|
+
const parentContext = parent !== undefined && parent.traceId !== "" && parent.spanId !== ""
|
|
25
|
+
? trace.setSpanContext(ROOT_CONTEXT, {
|
|
26
|
+
traceId: parent.traceId,
|
|
27
|
+
spanId: parent.spanId,
|
|
28
|
+
traceFlags: parent.sampled ? TraceFlags.SAMPLED : TraceFlags.NONE,
|
|
29
|
+
isRemote: false,
|
|
30
|
+
})
|
|
31
|
+
: ROOT_CONTEXT;
|
|
32
|
+
const span = tracer.startSpan(name, {
|
|
33
|
+
kind: SpanKind.INTERNAL,
|
|
34
|
+
...(options.attributes ? { attributes: options.attributes } : {}),
|
|
35
|
+
}, parentContext);
|
|
36
|
+
const own = span.spanContext();
|
|
37
|
+
const valid = isSpanContextValid(own);
|
|
38
|
+
const state = own.traceState?.serialize();
|
|
39
|
+
return {
|
|
40
|
+
traceId: valid ? own.traceId : "",
|
|
41
|
+
spanId: valid ? own.spanId : "",
|
|
42
|
+
sampled: valid && (own.traceFlags & TraceFlags.SAMPLED) === TraceFlags.SAMPLED,
|
|
43
|
+
...(state ? { state } : {}),
|
|
44
|
+
set: (attributes) => void span.setAttributes(attributes),
|
|
45
|
+
end: (outcome, error) => close(span, outcome, error),
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* How the use case ended, said the way OpenTelemetry reads it: `failed` is an error with its
|
|
52
|
+
* exception; `refused` and `aborted` are expected answers — said in `sincpro.outcome`, never as
|
|
53
|
+
* an error, the way the backend leaves a refusal out of its error rate.
|
|
54
|
+
*/
|
|
55
|
+
function close(span, outcome, error) {
|
|
56
|
+
span.setAttribute("sincpro.outcome", outcome);
|
|
57
|
+
if (outcome === "failed") {
|
|
58
|
+
if (error instanceof Error)
|
|
59
|
+
span.recordException(error);
|
|
60
|
+
span.setStatus({
|
|
61
|
+
code: SpanStatusCode.ERROR,
|
|
62
|
+
...(error instanceof Error ? { message: error.message } : {}),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
else if (outcome === "ok")
|
|
66
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
67
|
+
span.end();
|
|
68
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ErrorReporter } from "../observability/ports.js";
|
|
2
|
+
/**
|
|
3
|
+
* What this adapter needs of a Sentry-compatible client: its `captureException`. A browser's
|
|
4
|
+
* `BrowserClient`, Node's, React Native's — and GlitchTip, which speaks Sentry's protocol.
|
|
5
|
+
*/
|
|
6
|
+
export type ExceptionClient = {
|
|
7
|
+
captureException(error: unknown, hint?: {
|
|
8
|
+
captureContext?: {
|
|
9
|
+
tags?: Record<string, string>;
|
|
10
|
+
contexts?: Record<string, Record<string, unknown>>;
|
|
11
|
+
};
|
|
12
|
+
}): unknown;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* The `ErrorReporter` port over a Sentry-compatible client: an unexpected error reported with the
|
|
16
|
+
* use case it happened in, its trace — the id the backend logged too — and its context.
|
|
17
|
+
*
|
|
18
|
+
* // @sincpro/web-framework/errors builds the browser's client with GlitchTip's DSN
|
|
19
|
+
* const reporter = sentryReporter(client);
|
|
20
|
+
* const runtime = createRuntime({ reporter });
|
|
21
|
+
*
|
|
22
|
+
* Context: it imports nothing — it takes the client the platform made (with its DSN, release
|
|
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.
|
|
25
|
+
* The context goes as a context, not as tags: a tag is indexed and searched, and an execution's
|
|
26
|
+
* values are not the place for that.
|
|
27
|
+
*/
|
|
28
|
+
export declare function sentryReporter(client: ExceptionClient): ErrorReporter;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `ErrorReporter` port over a Sentry-compatible client: an unexpected error reported with the
|
|
3
|
+
* use case it happened in, its trace — the id the backend logged too — and its context.
|
|
4
|
+
*
|
|
5
|
+
* // @sincpro/web-framework/errors builds the browser's client with GlitchTip's DSN
|
|
6
|
+
* const reporter = sentryReporter(client);
|
|
7
|
+
* const runtime = createRuntime({ reporter });
|
|
8
|
+
*
|
|
9
|
+
* Context: it imports nothing — it takes the client the platform made (with its DSN, release
|
|
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.
|
|
12
|
+
* The context goes as a context, not as tags: a tag is indexed and searched, and an execution's
|
|
13
|
+
* values are not the place for that.
|
|
14
|
+
*/
|
|
15
|
+
export function sentryReporter(client) {
|
|
16
|
+
return {
|
|
17
|
+
report(error, about) {
|
|
18
|
+
client.captureException(error, {
|
|
19
|
+
captureContext: {
|
|
20
|
+
tags: {
|
|
21
|
+
"sincpro.use_case": about.useCase,
|
|
22
|
+
...(about.traceId === "" ? {} : { trace_id: about.traceId }),
|
|
23
|
+
},
|
|
24
|
+
contexts: {
|
|
25
|
+
sincpro: {
|
|
26
|
+
use_case: about.useCase,
|
|
27
|
+
execution_id: about.executionId,
|
|
28
|
+
...(about.traceId === "" ? {} : { trace_id: about.traceId }),
|
|
29
|
+
context: about.context,
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type Contract, type Meta } from "@sincpro/criteria";
|
|
2
|
+
/**
|
|
3
|
+
* A bounded context's client contract, as the application keeps it: the snapshot
|
|
4
|
+
* `sincpro-web generate` writes (`contract.json`), or the document a service serves
|
|
5
|
+
* (`GET /client-contract/<context>.json`). Only its models are read here.
|
|
6
|
+
*
|
|
7
|
+
* Context: the generated entities are keyed `<domain>:<kind>` (`project:issue`) — the key the
|
|
8
|
+
* workspace refers to them by and the meta store keeps their definitions under —, so the rule
|
|
9
|
+
* that turns a model's name into its kind lives once, here, and both the generator and the
|
|
10
|
+
* preload read it.
|
|
11
|
+
*/
|
|
12
|
+
export type ClientContract = {
|
|
13
|
+
models: Readonly<Record<string, Contract>>;
|
|
14
|
+
$defs?: Readonly<Record<string, Contract>>;
|
|
15
|
+
};
|
|
16
|
+
/** `ProjectEvent` → `project_event`: the kind in an entity's key. */
|
|
17
|
+
export declare function kindOf(model: string): string;
|
|
18
|
+
/** The key of a model's entity: `entityKeyOf("project", "ProjectEvent")` → `"project:project_event"`. */
|
|
19
|
+
export declare function entityKeyOf(domain: string, model: string): string;
|
|
20
|
+
/**
|
|
21
|
+
* The version of every entity of `contract`, by its model's name — what `restTransport`
|
|
22
|
+
* compares each answer with (`snapshot`) to say when the generated code is behind the server.
|
|
23
|
+
*
|
|
24
|
+
* restTransport({ base: "/v1", snapshot: versionsOf(contract), onVersionDrift: warn })
|
|
25
|
+
*/
|
|
26
|
+
export declare function versionsOf(contract: ClientContract): Record<string, string>;
|
|
27
|
+
/**
|
|
28
|
+
* The definition of every model of `contract`, by its entity's key — what `FrameworkProvider
|
|
29
|
+
* metas` preloads, so the first screen draws its labels with no request at all. The transport
|
|
30
|
+
* still reads the live definition and asks again when an answer says another version.
|
|
31
|
+
*
|
|
32
|
+
* <FrameworkProvider transport={transport} metas={metasOf(contract, "project")}>
|
|
33
|
+
*/
|
|
34
|
+
export declare function metasOf(contract: ClientContract, domain: string): Record<string, Meta>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { metaFromContract } from "@sincpro/criteria";
|
|
2
|
+
/** `ProjectEvent` → `project_event`: the kind in an entity's key. */
|
|
3
|
+
export function kindOf(model) {
|
|
4
|
+
return model
|
|
5
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
6
|
+
.replace(/([A-Z])([A-Z][a-z])/g, "$1_$2")
|
|
7
|
+
.toLowerCase();
|
|
8
|
+
}
|
|
9
|
+
/** The key of a model's entity: `entityKeyOf("project", "ProjectEvent")` → `"project:project_event"`. */
|
|
10
|
+
export function entityKeyOf(domain, model) {
|
|
11
|
+
return `${domain}:${kindOf(model)}`;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The version of every entity of `contract`, by its model's name — what `restTransport`
|
|
15
|
+
* compares each answer with (`snapshot`) to say when the generated code is behind the server.
|
|
16
|
+
*
|
|
17
|
+
* restTransport({ base: "/v1", snapshot: versionsOf(contract), onVersionDrift: warn })
|
|
18
|
+
*/
|
|
19
|
+
export function versionsOf(contract) {
|
|
20
|
+
return Object.fromEntries(Object.entries(contract.models).flatMap(([name, model]) => {
|
|
21
|
+
const version = model["x-sincpro"]?.version;
|
|
22
|
+
return model["x-sincpro"]?.identity != null && typeof version === "string"
|
|
23
|
+
? [[name, version]]
|
|
24
|
+
: [];
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The definition of every model of `contract`, by its entity's key — what `FrameworkProvider
|
|
29
|
+
* metas` preloads, so the first screen draws its labels with no request at all. The transport
|
|
30
|
+
* still reads the live definition and asks again when an answer says another version.
|
|
31
|
+
*
|
|
32
|
+
* <FrameworkProvider transport={transport} metas={metasOf(contract, "project")}>
|
|
33
|
+
*/
|
|
34
|
+
export function metasOf(contract, domain) {
|
|
35
|
+
// A related model, or a value a record holds, is pointed at by its name (`#/models/Run`).
|
|
36
|
+
const related = { ...(contract.$defs ?? {}), ...contract.models };
|
|
37
|
+
return Object.fromEntries(Object.entries(contract.models)
|
|
38
|
+
// Only an entity has a key of its own; a value it holds is described inside it.
|
|
39
|
+
.filter(([, model]) => model["x-sincpro"]?.identity != null)
|
|
40
|
+
.map(([name, model]) => [entityKeyOf(domain, name), metaFromContract(model, related)]));
|
|
41
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type Meta } from "@sincpro/criteria";
|
|
2
|
+
/**
|
|
3
|
+
* The discovery document of a bounded context, as far as this reads it: its paths, each
|
|
4
|
+
* operation with the entities it belongs to, and the published contract of every model.
|
|
5
|
+
*
|
|
6
|
+
* Context: sincpro_framework describes a model once — JSON Schema with `x-sincpro`, under the
|
|
7
|
+
* document's `components.schemas` — and every read answers only the version of it
|
|
8
|
+
* (`model_version`). So the definition a list, a form and a select are built from is read here,
|
|
9
|
+
* once, and asked for again only when an answer says another version.
|
|
10
|
+
*/
|
|
11
|
+
export type DiscoveryDocument = {
|
|
12
|
+
paths?: Readonly<Record<string, Readonly<Record<string, DiscoveryOperation>>>>;
|
|
13
|
+
components?: {
|
|
14
|
+
schemas?: Readonly<Record<string, DocumentSchema>>;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
export type DiscoveryOperation = {
|
|
18
|
+
"x-sincpro-entities"?: readonly string[];
|
|
19
|
+
responses?: Readonly<Record<string, {
|
|
20
|
+
content?: Readonly<Record<string, {
|
|
21
|
+
schema?: DocumentSchema;
|
|
22
|
+
}>>;
|
|
23
|
+
}>>;
|
|
24
|
+
};
|
|
25
|
+
type DocumentSchema = {
|
|
26
|
+
$ref?: string;
|
|
27
|
+
items?: DocumentSchema;
|
|
28
|
+
anyOf?: readonly DocumentSchema[];
|
|
29
|
+
properties?: Readonly<Record<string, DocumentSchema>>;
|
|
30
|
+
"x-sincpro"?: {
|
|
31
|
+
entity?: string;
|
|
32
|
+
version?: string;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* The model a request to `path` answers, by the name of its contract in the document: the
|
|
37
|
+
* entity the operation says it belongs to, else the one its answer holds.
|
|
38
|
+
*
|
|
39
|
+
* modelAt(published, "/v1/projects/p1", "get") // "Project"
|
|
40
|
+
*/
|
|
41
|
+
export declare function modelAt(published: DiscoveryDocument, path: string, method: string): string | undefined;
|
|
42
|
+
/** The definition of the model named `name`, read from its contract, and its version. */
|
|
43
|
+
export declare function definitionOf(published: DiscoveryDocument, name: string): {
|
|
44
|
+
meta: Meta;
|
|
45
|
+
version: string | null;
|
|
46
|
+
} | undefined;
|
|
47
|
+
export {};
|