@sincpro/client-framework 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +53 -12
- package/MANIFESTO.md +56 -13
- package/README.md +329 -33
- package/dist/circuit_breaker/domain/circuit.d.ts +29 -0
- package/dist/circuit_breaker/domain/circuit.js +37 -0
- package/dist/circuit_breaker/entrypoint/circuit-breaker.d.ts +56 -0
- package/dist/circuit_breaker/entrypoint/circuit-breaker.js +94 -0
- package/dist/circuit_breaker/index.d.ts +9 -0
- package/dist/circuit_breaker/index.js +9 -0
- package/dist/client/adapters/json-rpc.d.ts +40 -0
- package/dist/client/adapters/json-rpc.js +97 -0
- package/dist/client/adapters/rest-operations.d.ts +19 -0
- package/dist/client/adapters/rest-operations.js +66 -0
- package/dist/client/domain/operation.d.ts +73 -0
- package/dist/client/domain/operation.js +19 -0
- package/dist/client/index.d.ts +5 -1
- package/dist/client/index.js +5 -1
- package/dist/client/services/guarded.d.ts +17 -0
- package/dist/client/services/guarded.js +19 -0
- package/dist/client/services/propagation.d.ts +20 -7
- package/dist/client/services/propagation.js +44 -15
- package/dist/codegen/entrypoint/cli.js +7 -7
- package/dist/codegen/services/client.d.ts +7 -8
- package/dist/codegen/services/client.js +50 -148
- package/dist/codegen/services/scaffold.d.ts +6 -3
- package/dist/codegen/services/scaffold.js +14 -13
- package/dist/common/failures.d.ts +83 -0
- package/dist/common/failures.js +212 -0
- package/dist/common/index.d.ts +1 -0
- package/dist/common/index.js +1 -0
- package/dist/context/domain/execution.d.ts +12 -0
- package/dist/core/domain/bus.d.ts +16 -7
- package/dist/core/domain/bus.js +14 -6
- package/dist/core/domain/dto.d.ts +32 -2
- package/dist/core/domain/dto.js +23 -0
- package/dist/core/domain/pipeline.d.ts +89 -0
- package/dist/core/domain/pipeline.js +1 -0
- package/dist/core/domain/services.d.ts +14 -4
- package/dist/core/domain/services.js +4 -1
- package/dist/core/entrypoint/bounded-context.d.ts +79 -10
- package/dist/core/entrypoint/bounded-context.js +207 -33
- package/dist/core/index.d.ts +4 -2
- package/dist/core/index.js +3 -2
- package/dist/core/infrastructure/listeners.d.ts +28 -0
- package/dist/core/infrastructure/listeners.js +34 -0
- package/dist/core/services/binding.d.ts +2 -2
- package/dist/core/services/binding.js +6 -3
- package/dist/core/services/execution.d.ts +37 -6
- package/dist/core/services/execution.js +86 -7
- package/dist/core/services/interceptors.d.ts +15 -0
- package/dist/core/services/interceptors.js +25 -0
- package/dist/core/services/listening.d.ts +17 -0
- package/dist/core/services/listening.js +25 -0
- package/dist/event_driven/adapters/memory-event-store.d.ts +10 -0
- package/dist/event_driven/adapters/memory-event-store.js +23 -0
- package/dist/event_driven/adapters/stored-queue.d.ts +67 -0
- package/dist/event_driven/adapters/stored-queue.js +192 -0
- package/dist/event_driven/adapters/sync-queue.d.ts +11 -0
- package/dist/event_driven/adapters/sync-queue.js +19 -0
- package/dist/event_driven/domain/envelope.d.ts +28 -0
- package/dist/event_driven/domain/envelope.js +19 -0
- package/dist/event_driven/domain/queue.d.ts +40 -0
- package/dist/event_driven/domain/queue.js +1 -0
- package/dist/event_driven/domain/store.d.ts +56 -0
- package/dist/event_driven/domain/store.js +1 -0
- package/dist/event_driven/entrypoint/publisher.d.ts +26 -0
- package/dist/event_driven/entrypoint/publisher.js +35 -0
- package/dist/event_driven/entrypoint/subscriber.d.ts +34 -0
- package/dist/event_driven/entrypoint/subscriber.js +51 -0
- package/dist/event_driven/index.d.ts +19 -0
- package/dist/event_driven/index.js +17 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.js +7 -2
- package/dist/observability/domain/logging.d.ts +2 -0
- package/dist/observability/services/logging.js +1 -0
- package/dist/observability/services/outcome.d.ts +6 -6
- package/dist/observability/services/outcome.js +10 -13
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -25,17 +25,18 @@ Each module is laid out as sincpro_framework's are: `domain/` (ports, types and
|
|
|
25
25
|
(the process's state and technical support) and `entrypoint/` (the doors a caller uses). A
|
|
26
26
|
module has only the layers it needs, and says what it offers in its own `index.ts`.
|
|
27
27
|
|
|
28
|
-
| Folder | What
|
|
29
|
-
| -------------------- |
|
|
30
|
-
| `src/common/` | The values of the wire: ids, a day and an instant, `Decimal`, `Money`, `valuesOf` / `rebuilt`
|
|
31
|
-
| `src/context/` | `ContextStore`, codec, `Execution` (domain); memory and plain codec; `Context`; the process's
|
|
32
|
-
| `src/observability/` | Tracer/ErrorReporter/Logger ports; console, memory, OpenTelemetry, Sentry; loggers, reporting
|
|
33
|
-
| `src/client/` | `Transport`, `entity()`, the contract (domain); REST, memory, Parquet; discovery, propagation
|
|
34
|
-
| `src/model/` | Model definitions: `defineMeta`, from a JSON Schema, the store of known ones
|
|
35
|
-
| `src/core/` | DTOs, bus,
|
|
36
|
-
| `src/
|
|
37
|
-
| `src/
|
|
38
|
-
| `
|
|
28
|
+
| Folder | What |
|
|
29
|
+
| -------------------- | ----------------------------------------------------------------------------------------------- |
|
|
30
|
+
| `src/common/` | The values of the wire: ids, a day and an instant, `Decimal`, `Money`, `valuesOf` / `rebuilt` |
|
|
31
|
+
| `src/context/` | `ContextStore`, codec, `Execution` (domain); memory and plain codec; `Context`; the process's |
|
|
32
|
+
| `src/observability/` | Tracer/ErrorReporter/Logger ports; console, memory, OpenTelemetry, Sentry; loggers, reporting |
|
|
33
|
+
| `src/client/` | `Transport`, `entity()`, the contract (domain); REST, memory, Parquet; discovery, propagation |
|
|
34
|
+
| `src/model/` | Model definitions: `defineMeta`, from a JSON Schema, the store of known ones |
|
|
35
|
+
| `src/core/` | DTOs and events, bus, services, handlers, extension points; pipeline; `BoundedContext`; process |
|
|
36
|
+
| `src/event_driven/` | `Envelope`, `EventQueue`/`Subscribes` ports; `syncQueue`; `Publisher`, `Subscriber` |
|
|
37
|
+
| `src/codegen/` | The generator and its CLI (`sincpro-client generate`) — the only place Node is allowed |
|
|
38
|
+
| `src/testing/` | The public entry of the in-memory observability adapters |
|
|
39
|
+
| `tests/` | vitest in Node, one folder per module; `tests/support/` holds fakes and forge's contracts |
|
|
39
40
|
|
|
40
41
|
## Hard rules (the build enforces them — `make doctor`)
|
|
41
42
|
|
|
@@ -70,7 +71,41 @@ module has only the layers it needs, and says what it offers in its own `index.t
|
|
|
70
71
|
instance, shared by the next call, the call beside it and every `scoped` variant — as in
|
|
71
72
|
sincpro_framework. What belongs to one request goes in its arguments or its context.
|
|
72
73
|
- **A span uses stable service metadata, not constructor.name.** `serviceName = "Cancel"` gives
|
|
73
|
-
`project/Cancel`, identity `project.Cancel`. Commands route by exact constructor
|
|
74
|
+
`project/Cancel`, identity `project.Cancel`. Commands and events route by exact constructor
|
|
75
|
+
identity. An event declares `static eventName` (its wire name) for the same reason; `name` is
|
|
76
|
+
reserved for it.
|
|
77
|
+
- **A replacement names what it replaces.** `feature(dto, { replaces: Service })` is refused
|
|
78
|
+
unless `Service` is registered now for `dto`; its spans carry `sincpro.replaces`. A service
|
|
79
|
+
meant to be subclassed declares `static readonly serviceName: string = "…"`, or the literal
|
|
80
|
+
type stops a subclass from renaming itself.
|
|
81
|
+
- **How events are carried is here; where they are kept is the platform's.** One subscriber
|
|
82
|
+
per bounded context; a `Subscriber` executes an event in each. `storedQueue` holds the rules —
|
|
83
|
+
store first, deliver in order, retry what may succeed, set aside what never will, never run a
|
|
84
|
+
context twice — over an `EventStore` the platform plugs (SQLite, IndexedDB, memory). A store
|
|
85
|
+
only keeps records; it never decides.
|
|
86
|
+
- **A circuit belongs where the dependency is.** `CircuitBreaker` (in the process's memory, the
|
|
87
|
+
backend's semantics: `COUNTED_KINDS`, half-open with one probe) guards a transport
|
|
88
|
+
(`guardedTransport`) or an adapter; its `CircuitOpen` declares `unavailable` and a wait, so
|
|
89
|
+
nothing else needs to know a circuit exists.
|
|
90
|
+
- **Delivery is at least once.** A stored event may run twice in a context after a crash; a
|
|
91
|
+
subscriber keys its effect by `this.execution.causationId` (the envelope's id). `maxAttempts`
|
|
92
|
+
sets aside what keeps failing. One process drains a store. `execution` is reserved.
|
|
93
|
+
- **A failure says its kind, whatever the wire.** `failureOf` reads a declared `failureKind`, the
|
|
94
|
+
`Problem` (the same over REST, JSON-RPC and gRPC), then a REST status. A transport of another
|
|
95
|
+
wire, or another backend's adapter, declares the kind with `kindOfGrpcCode` /
|
|
96
|
+
`kindOfJsonRpcCode` / `kindOfHttpStatus` — never a table of its own. Retrying, setting aside,
|
|
97
|
+
reporting and the outcome all follow from it.
|
|
98
|
+
- **A run can be cancelled.** `execute(…, { signal })`: it ends `aborted` at once, its Features
|
|
99
|
+
inherit the signal, `this.signal` reads it, and a bound transport sends it with every request.
|
|
100
|
+
`signal` is reserved, like `context` and `logger`.
|
|
101
|
+
- **No variable named `process`.** `make doctor` reads `process.` as Node outside `codegen/`.
|
|
102
|
+
- **A run belongs to a flow.** `correlationId` is shared by every run and event of one flow;
|
|
103
|
+
`causationId` names what directly caused it. A dependency carrying `BIND` (the `Publisher`)
|
|
104
|
+
is bound to the run, so what it publishes joins that flow with nothing passed by hand.
|
|
105
|
+
- **The pipeline is closed for modification, open for extension.** Change the flow in
|
|
106
|
+
`executeService` only for every run; what one application needs goes through an interceptor
|
|
107
|
+
(control, inside the run, keeps the class), a listener (observation, never changes the
|
|
108
|
+
answer, its failure ignored) or `extend`.
|
|
74
109
|
- **Dependency declarations do not register anything.** Service bases declare typed attributes
|
|
75
110
|
from a shared dependency interface. Only `addDependency` supplies runtime values; a missing
|
|
76
111
|
dependency fails explicitly.
|
|
@@ -87,6 +122,12 @@ module has only the layers it needs, and says what it offers in its own `index.t
|
|
|
87
122
|
- **A store's `load` may answer a new object each time** (one that parses JSON does): `Context`
|
|
88
123
|
keeps one object per `version`, so a screen can hand `values` to `useSyncExternalStore`. A store
|
|
89
124
|
that changes without moving the version is a broken store.
|
|
125
|
+
- **The generated client never names a wire.** A method is `transport.call(operations.X, input,
|
|
126
|
+
options)`: no route, no verb, no RPC method. The contract's bindings (`rest`, `rpc`, `grpc`,
|
|
127
|
+
`mcp`) are data in `operations`; an `OperationTransport` adapter reads the one of its wire
|
|
128
|
+
(`restOperations`, `jsonRpcOperations`, a platform's gRPC) and throws `OperationNotPublished`
|
|
129
|
+
for the rest. A run binds an `OperationTransport` as it binds a `Transport`: headers and signal.
|
|
130
|
+
The REST `Transport` stays for `restOperations` and for screens (`entity()`, pages, drafts).
|
|
90
131
|
- **What is generated is types, never classes.** A generated DTO is the remote contract: a
|
|
91
132
|
class extending `Command` could be registered on the local bus, which is what keeps a remote
|
|
92
133
|
operation and a local use case apart. Runtime knowledge goes in data (`operations`, `events`);
|
package/MANIFESTO.md
CHANGED
|
@@ -28,8 +28,11 @@ server-side render, in a script and in a test, unchanged.**
|
|
|
28
28
|
1. **Services.** A `Feature` executes a registered Command through `execute(dto)`;
|
|
29
29
|
an `ApplicationService` composes its context's Features through `featureBus`, never another
|
|
30
30
|
ApplicationService. Dependencies are typed attributes supplied by the bounded context.
|
|
31
|
-
2. **Execution.** Every
|
|
32
|
-
|
|
31
|
+
2. **Execution.** Every Command and event enters one execution pipeline, defined here: context,
|
|
32
|
+
chain, span, log lines, outcome, report, error handlers — not handwritten by each service. An
|
|
33
|
+
application changes it from outside through its extension points, as the backend's
|
|
34
|
+
`bus_pipeline`: interceptors control a run (look, veto, adjust, answer); listeners hear it
|
|
35
|
+
start, answer or fail, and never change it; an extension wires a component in one place.
|
|
33
36
|
3. **Observability, as ports.** `Tracer`, `ErrorReporter` and `Logger` are the contracts: one span per use
|
|
34
37
|
case, named `<bounded context>/<serviceName>`; the unexpected reported
|
|
35
38
|
with its trace id; what the server already explained (a validation, a conflict), or what a
|
|
@@ -47,7 +50,9 @@ server-side render, in a script and in a test, unchanged.**
|
|
|
47
50
|
Features and ApplicationServices, registered for class DTOs — and called as
|
|
48
51
|
`project.execute(new Command(...))`. The bounded context is the bus. An application is the set
|
|
49
52
|
of bounded contexts it loads; they never import each other's insides.
|
|
50
|
-
6. **The client.** The `
|
|
53
|
+
6. **The client.** The `OperationTransport` port — an operation called by who it is, over any
|
|
54
|
+
wire — with its REST and JSON-RPC adapters (a platform adds gRPC or its own); the `Transport`
|
|
55
|
+
port and its implementations (REST, memory), `entity()` with
|
|
51
56
|
typed commands and answers, the service's client contract, model definitions read once from
|
|
52
57
|
the server and the server's `Problem`.
|
|
53
58
|
7. **The values of the wire.** How every value travels, as the backend writes it — a decimal as
|
|
@@ -62,9 +67,18 @@ server-side render, in a script and in a test, unchanged.**
|
|
|
62
67
|
10. **The command core (`./core`).** The single model matching the backend's application
|
|
63
68
|
abstractions: class DTOs and Responses, a bounded context that is a bus, Features and
|
|
64
69
|
ApplicationServices registered for DTO classes, attribute-injected dependencies and a
|
|
65
|
-
feature bus for composition,
|
|
66
|
-
|
|
70
|
+
feature bus for composition, domain events executed by their subscribers, replacements,
|
|
71
|
+
and the backend's error handlers — global, feature and application service — with the
|
|
72
|
+
errors a bounded context expects. The internal engine uses
|
|
67
73
|
the injected process ports. Remote clients and mapping remain distinct.
|
|
74
|
+
11. **Event-driven, as ports (`event_driven`).** How every platform publishes domain events and
|
|
75
|
+
subscribes to them the same way: the envelope that travels beside an event (its flow and
|
|
76
|
+
its cause), the `EventQueue` and `Subscribes` ports, a `Publisher` a Feature holds, a
|
|
77
|
+
`Subscriber` over the bounded contexts that hear, the queue that delivers in the call, and
|
|
78
|
+
the stored queue's rules over an `EventStore` the platform plugs. The guidelines, the rules
|
|
79
|
+
and the contracts are here; where events are kept is the platform's.
|
|
80
|
+
12. **Resilience.** What a failure is (`failureOf`, the backend's kinds on every wire) and the
|
|
81
|
+
backend's circuit breaker, guarding any transport or adapter.
|
|
68
82
|
|
|
69
83
|
## What it is not
|
|
70
84
|
|
|
@@ -84,8 +98,10 @@ server-side render, in a script and in a test, unchanged.**
|
|
|
84
98
|
- **Not where application-specific business rules live.** The package supplies execution
|
|
85
99
|
abstractions; a registered use case may delegate to a server or use a local domain. A remote
|
|
86
100
|
adapter does not authorize reimplementing the server's rules.
|
|
87
|
-
- **Not
|
|
88
|
-
|
|
101
|
+
- **Not a durable event delivery system.** It defines how events are published, carried and
|
|
102
|
+
executed — the ports, the envelope, the in-call queue — so web and mobile do it alike; storing
|
|
103
|
+
them, retrying, a dead-letter queue, waiting for a connection, other tabs, a broker are the
|
|
104
|
+
platform's adapters behind `EventQueue`.
|
|
89
105
|
|
|
90
106
|
## What we believe
|
|
91
107
|
|
|
@@ -130,6 +146,14 @@ server-side render, in a script and in a test, unchanged.**
|
|
|
130
146
|
|
|
131
147
|
Dependencies point down only. This package knows nothing above it.
|
|
132
148
|
|
|
149
|
+
**What goes where.** client-framework + web-framework is to a web application what
|
|
150
|
+
sincpro_framework is to a backend; client-framework + @sincpro/mobile, the same for a phone. A
|
|
151
|
+
piece web and mobile can both use, and that needs no platform, comes here — as a port, its rules
|
|
152
|
+
and a memory adapter. A piece only one platform needs, or that needs a platform's technology, is
|
|
153
|
+
that platform's — an adapter of a port. An application's business is the application's. Nothing
|
|
154
|
+
is replicated: what differs by nature (a phone's SQLite domain, a browser's entity screens) stays
|
|
155
|
+
different, and the platforms meet here only through the ports.
|
|
156
|
+
|
|
133
157
|
## Where it stands
|
|
134
158
|
|
|
135
159
|
**The application layer is the backend's.** A bounded context executes class Commands through
|
|
@@ -145,16 +169,35 @@ bounded context has its own, and a run hands `this.logger`, bound to its executi
|
|
|
145
169
|
infrastructure and the adapters have a predefined one.
|
|
146
170
|
|
|
147
171
|
**The client and the generator read the backend's contract.** Types per model, operation and —
|
|
148
|
-
when the contract is exported with them — domain event; a client
|
|
149
|
-
|
|
150
|
-
the
|
|
172
|
+
when the contract is exported with them — domain event; a client whose methods call each
|
|
173
|
+
operation by who it is, over the `OperationTransport` the application injects — REST, JSON-RPC,
|
|
174
|
+
a platform's gRPC —; the entities screens use. What is generated is types, never classes, and
|
|
175
|
+
never names a wire: the contract's bindings are data, read by the adapter of each wire.
|
|
151
176
|
|
|
152
177
|
**Each module is laid out as the backend's.** `domain/`, `adapters/`, `services/`,
|
|
153
178
|
`infrastructure/`, `entrypoint/`, only those a module needs; `domain/` depends only on `domain/`.
|
|
154
179
|
|
|
155
|
-
**
|
|
156
|
-
|
|
157
|
-
|
|
180
|
+
**The pipeline is the core's, and open.** The flow of a run is defined once; interceptors,
|
|
181
|
+
listeners and extensions change or hear it from outside, without touching a service.
|
|
182
|
+
|
|
183
|
+
**Events travel the same way on every platform.** A Feature publishes through a `Publisher`;
|
|
184
|
+
a `Subscriber` executes the event in every bounded context that hears it, in the same flow
|
|
185
|
+
(`correlationId`, caused by the run that published it). In the call (`syncQueue`), or stored
|
|
186
|
+
first and delivered by the backend's store-and-forward rules (`storedQueue`) over the
|
|
187
|
+
`EventStore` each platform plugs — a SQLite table, IndexedDB, memory.
|
|
188
|
+
|
|
189
|
+
**A failure means the same thing everywhere.** `failureOf` reads any error — whatever the wire,
|
|
190
|
+
whatever backend — in the backend's kinds and next steps; retrying, setting aside, reporting and
|
|
191
|
+
a screen's answer follow from it. A run can be cancelled, and its requests with it. The
|
|
192
|
+
backend's circuit breaker guards any transport.
|
|
193
|
+
|
|
194
|
+
**Consumers have a guide.** The `sincpro-client-framework` skill says, for an application and for
|
|
195
|
+
a platform's framework, what goes where and how each thing is done.
|
|
196
|
+
|
|
197
|
+
**Next:** web and mobile adopt it in their own releases — mobile's SQLite `EventStore` and gRPC or
|
|
198
|
+
JSON-RPC wire, web's in-memory or IndexedDB store and its hooks —, declaring this package as a
|
|
199
|
+
peer dependency; events from the server live, with the backend's server-sent events; generated
|
|
200
|
+
readers that validate and convert what arrives, when a use needs them.
|
|
158
201
|
|
|
159
202
|
## How we decide
|
|
160
203
|
|
package/README.md
CHANGED
|
@@ -7,6 +7,8 @@ the same way, and a typed client generated from a service's contract. No React,
|
|
|
7
7
|
platform storage: what a platform must give, it gives through a port.
|
|
8
8
|
|
|
9
9
|
- [A bounded context](#a-bounded-context)
|
|
10
|
+
- [Domain events](#domain-events)
|
|
11
|
+
- [Extending the pipeline](#extending-the-pipeline)
|
|
10
12
|
- [Errors](#errors)
|
|
11
13
|
- [Observability](#observability)
|
|
12
14
|
- [Context](#context)
|
|
@@ -103,6 +105,164 @@ without a decorator transform, call them: `sales.feature(CommandTotal)(Total)`.
|
|
|
103
105
|
and no `reflect-metadata`. The tests compile both decorator forms with TypeScript; a bundler's
|
|
104
106
|
transform (Vite, Metro/Babel) is each application's to configure and test.
|
|
105
107
|
|
|
108
|
+
### Replacing a service
|
|
109
|
+
|
|
110
|
+
An application takes the place of a service a module registered, as the backend's `replaces=`:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
class PrintOnBixolon extends Print {
|
|
114
|
+
static override readonly serviceName = "PrintOnBixolon";
|
|
115
|
+
override execute(command: CommandPrint) { … }
|
|
116
|
+
}
|
|
117
|
+
printing.feature(CommandPrint, { replaces: Print })(PrintOnBixolon);
|
|
118
|
+
printing.handlerOf(CommandPrint); // PrintOnBixolon
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`replaces` names the service registered now for that class, or the registration is refused. The
|
|
122
|
+
next call answers with the new one; its spans say which it replaced (`sincpro.replaces`). A
|
|
123
|
+
service meant to be replaced by a subclass widens its name — `static readonly serviceName:
|
|
124
|
+
string = "Print"` —, or TypeScript keeps the literal and the subclass cannot rename itself. A
|
|
125
|
+
dependency is replaced the same way: `addDependency(name, next, { replaces: previous })`.
|
|
126
|
+
|
|
127
|
+
## Domain events
|
|
128
|
+
|
|
129
|
+
A fact that already happened — `OrderPaid` — is executed as a Command is: by the Feature or
|
|
130
|
+
ApplicationService registered for its class, its subscriber in this bounded context, through
|
|
131
|
+
the same pipeline (span, log lines, error handlers, report):
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
class OrderPaid extends DomainEvent {
|
|
135
|
+
static readonly eventName = "shop.order.v1.paid"; // its wire name, as the backend's `name`
|
|
136
|
+
constructor(readonly order_id: string) {
|
|
137
|
+
super();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
class ThankCustomer extends Feature<OrderPaid, void> {
|
|
142
|
+
static readonly serviceName = "ThankCustomer";
|
|
143
|
+
async execute(event: OrderPaid) { … }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
shop.feature(OrderPaid)(ThankCustomer);
|
|
147
|
+
await shop.execute(new OrderPaid("o-1")); // span shop/ThankCustomer, sincpro.event = its name
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
- **`eventName` is declared**, as `serviceName` is: a minifier renames classes. `event.name`
|
|
151
|
+
reads it; `name` is reserved, and a subclass cannot make it a field.
|
|
152
|
+
- **One subscriber per bounded context.** Another context that hears the event registers its
|
|
153
|
+
own; a `Subscriber` executes it in each.
|
|
154
|
+
- An ApplicationService can subscribe too, and compose Features — of Commands or of events —
|
|
155
|
+
through `featureBus`. `execute(event)` answers whatever the subscriber returns (`unknown`).
|
|
156
|
+
|
|
157
|
+
### Publishing and subscribing
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
import { Publisher, Subscriber, syncQueue } from "@sincpro/client-framework";
|
|
161
|
+
|
|
162
|
+
const subscriber = new Subscriber(mail, stock); // every bounded context that hears
|
|
163
|
+
const queue = syncQueue(subscriber); // in the call, in memory — or a platform's
|
|
164
|
+
shop.addDependency("publisher", new Publisher(queue));
|
|
165
|
+
|
|
166
|
+
class Pay extends Feature<CommandPay, string> {
|
|
167
|
+
declare readonly publisher: Publisher;
|
|
168
|
+
async execute(command: CommandPay) {
|
|
169
|
+
await this.publisher.publish(new OrderPaid(command.order_id)); // its Envelope
|
|
170
|
+
return command.order_id;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
The `Publisher` is bound to the run that holds it, so the event joins that run's flow: its
|
|
176
|
+
envelope carries the run's `correlationId` and names the run as its cause. The `Subscriber`
|
|
177
|
+
executes it in `mail` and in `stock` — `context.execute(event, envelope)` —, so their runs
|
|
178
|
+
continue the same flow; one that fails does not stop the others.
|
|
179
|
+
|
|
180
|
+
What is defined here, and what each platform plugs behind the ports:
|
|
181
|
+
|
|
182
|
+
| Piece | Here | The platform's |
|
|
183
|
+
| ------------------------- | -------------------------------------------------------------- | -------------------------------------- |
|
|
184
|
+
| `DomainEvent`, subscriber | the class, its `eventName`, its execution | — |
|
|
185
|
+
| `Envelope` | `name`, `id`, `correlationId`, `causationId`, `publishedAt` | storing it beside the event |
|
|
186
|
+
| `EventQueue` port | `syncQueue` (in the call); `storedQueue` (store, then deliver) | a broker's queue, `BroadcastChannel` |
|
|
187
|
+
| `EventStore` port | `memoryEventStore()` | mobile: a SQLite table; web: IndexedDB |
|
|
188
|
+
| `Subscribes` port | `Subscriber(...contexts)`, `eventTypeOf(name)` | — |
|
|
189
|
+
|
|
190
|
+
### Stored events: one way to carry them, any store
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
const queue = storedQueue({
|
|
194
|
+
store: sqliteEventStore(db), // the platform's EventStore; memoryEventStore() in a tab or a test
|
|
195
|
+
subscriber: new Subscriber(sales, stock, printing),
|
|
196
|
+
ready: (stored) => connectivity.online || !needsNetwork(stored.envelope.name),
|
|
197
|
+
onSettled: (stored) => activity.show(stored.envelope.name, stored.state),
|
|
198
|
+
});
|
|
199
|
+
shop.addDependency("publisher", new Publisher(queue));
|
|
200
|
+
connectivity.onBack(() => queue.drain()); // and on coming back to the foreground
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
`publish` returns once the event is stored. Then the queue delivers what waits, one at a time,
|
|
204
|
+
in the order stored — the backend's store-and-forward, over whatever store the platform plugs:
|
|
205
|
+
|
|
206
|
+
- It is rebuilt as its class — `static fromJSON` when it has one — and executed in every
|
|
207
|
+
bounded context that hears it and does not have it yet: a retry never runs one twice.
|
|
208
|
+
- A failure a retry may fix (`failureOf`: unavailable, no answer, a conflict…) is tried again
|
|
209
|
+
after the backoff or the wait it asks for, and what follows waits, so the order holds.
|
|
210
|
+
- A failure every attempt would repeat (`PERMANENT_KINDS`: a refusal, invalid, not found) sets
|
|
211
|
+
it aside — the dead-letter queue — and never holds back what follows.
|
|
212
|
+
- One that is not `ready` (no connection, no session) waits while the rest go on.
|
|
213
|
+
- After `maxAttempts` (10) a failure a retry might still fix is set aside too: a bug of the
|
|
214
|
+
process (`internal`) never holds the line forever.
|
|
215
|
+
|
|
216
|
+
The port only keeps records — `add`, `next(skip)`, `save` — so a SQLite table, IndexedDB or
|
|
217
|
+
memory carry events the same way, with the same rules.
|
|
218
|
+
|
|
219
|
+
Three things a platform keeps in mind:
|
|
220
|
+
|
|
221
|
+
- **At least once.** A process that stops after a context executed an event and before the store
|
|
222
|
+
saved it runs it there again. A subscriber that must not act twice keys what it does by the
|
|
223
|
+
envelope's id, `this.execution.causationId` in its run.
|
|
224
|
+
- **One drainer per store.** Two tabs over one IndexedDB claim it first (the Web Locks API).
|
|
225
|
+
- **Values come back as JSON.** An event that carries a `Money`, a `Decimal` or a date declares
|
|
226
|
+
`static fromJSON` to read them back as such.
|
|
227
|
+
|
|
228
|
+
The events a remote service publishes arrive as generated types (`DomainEvent` in
|
|
229
|
+
`generated/domain.ts`, a union narrowed by `name`); a local event is a class, like a local
|
|
230
|
+
Command.
|
|
231
|
+
|
|
232
|
+
## Extending the pipeline
|
|
233
|
+
|
|
234
|
+
The flow of a run is the core's — context, chain, span, log lines, outcome, report, handlers —,
|
|
235
|
+
and an application changes or hears it from outside, as the backend's `bus_pipeline`:
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
// control: around the service — look, veto (throw), adjust (same class), or answer
|
|
239
|
+
shop.interceptor(CommandPay)(async (command, next, run) => {
|
|
240
|
+
if (!credit.allows(command.customer_id)) throw new NoCredit();
|
|
241
|
+
return next(command);
|
|
242
|
+
});
|
|
243
|
+
shop.interceptor()((executable, next, run) => next(executable)); // every class
|
|
244
|
+
|
|
245
|
+
// observation: never changes the answer
|
|
246
|
+
shop.onStart((started) => activity.running(started.execution.useCase));
|
|
247
|
+
shop.onCompletion((done) => activity.succeeded(done.execution.useCase), CommandPay);
|
|
248
|
+
shop.onFailure((failed) => activity.failed(failed.execution?.useCase, failed.outcome));
|
|
249
|
+
|
|
250
|
+
// composition: a module or plugin wires its pieces in one place
|
|
251
|
+
shop.extend(auditing);
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
- **Interceptors** run inside the run, outermost first in the order registered: a veto is the
|
|
255
|
+
use case's failure — observed, answered by the error handlers. What an interceptor passes on
|
|
256
|
+
keeps its class (the bus routes by class); `run` gives it the use case, the execution and its
|
|
257
|
+
logger. `withoutInterceptor(fn)` stops one; registered later, it wraps from the next call.
|
|
258
|
+
- **Listeners** hear each run start and each service that answered, filtered by class when
|
|
259
|
+
named; a failure is heard once, when it escapes the call after every handler, from where it
|
|
260
|
+
first failed. One that throws is ignored. Each `on…` answers how to stop.
|
|
261
|
+
`processListeners.onStart / onCompletion / onFailure` hear every bounded context at once — a
|
|
262
|
+
progress bar, a toast —, after each one's own.
|
|
263
|
+
- **`extend(fn)`** is `fn(context)`, said as composition; `fresh()` copies everything an
|
|
264
|
+
extension registered.
|
|
265
|
+
|
|
106
266
|
## Errors
|
|
107
267
|
|
|
108
268
|
As the backend's, a bounded context has three chains of error handlers, run
|
|
@@ -126,18 +286,81 @@ the answer, not whether the failure is seen; an answered one adds an `error.hand
|
|
|
126
286
|
feature handler answers wherever the Feature ran, inside an ApplicationService too; the global
|
|
127
287
|
chain wraps only `execute` and `scoped(...).execute`.
|
|
128
288
|
|
|
129
|
-
|
|
289
|
+
### What kind of failure it is
|
|
290
|
+
|
|
291
|
+
`failureOf(error)` says what any error is, in the backend's words (`FailureKind`, `NextStep`
|
|
292
|
+
from @sincpro/criteria), whatever raised it:
|
|
293
|
+
|
|
294
|
+
```ts
|
|
295
|
+
const failure = failureOf(error);
|
|
296
|
+
failure.kind; // "conflict", "unavailable", "domain", "unknown_outcome"…
|
|
297
|
+
failure.nextStep; // "refresh_and_retry", "retry_later", "authenticate", "do_not_retry"…
|
|
298
|
+
failure.retryable; // may sending it again succeed
|
|
299
|
+
failure.retryAfter; // seconds to wait first
|
|
300
|
+
failure.permanent; // every attempt fails the same way: set it aside
|
|
301
|
+
failure.fault; // the system's problem: reported
|
|
302
|
+
failure.aborted; // whoever asked stopped waiting
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
It reads, in order: an `AbortError`; what the error declares (`failureKind`, `reason`,
|
|
306
|
+
`retryAfter` — on itself or as a static of its class, as the backend's errors do); the `Problem`
|
|
307
|
+
the server sent, the same over REST, JSON-RPC and gRPC; a REST status. Not every wire is REST: a
|
|
308
|
+
JSON-RPC or gRPC transport — or another backend's adapter, such as Odoo's — makes errors that
|
|
309
|
+
declare their kind, with the backend's own tables:
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
class RpcError extends Error {
|
|
313
|
+
constructor(
|
|
314
|
+
readonly code: number,
|
|
315
|
+
message: string,
|
|
316
|
+
) {
|
|
317
|
+
super(message);
|
|
318
|
+
}
|
|
319
|
+
get failureKind() {
|
|
320
|
+
return kindOfJsonRpcCode(this.code); // kindOfGrpcCode, kindOfHttpStatus
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
How an error ended a use case follows from it (`outcomeOf`):
|
|
130
326
|
|
|
131
|
-
| Outcome | When
|
|
132
|
-
| --------- |
|
|
133
|
-
| `ok` | it answered
|
|
134
|
-
| `refused` | the
|
|
135
|
-
| `aborted` | whoever asked stopped waiting
|
|
136
|
-
| `failed` |
|
|
327
|
+
| Outcome | When | Reported |
|
|
328
|
+
| --------- | -------------------------------------------------------------------------- | -------- |
|
|
329
|
+
| `ok` | it answered | — |
|
|
330
|
+
| `refused` | the caller's answer: a 4xx, a declared kind (`domain`…), or `expectErrors` | no |
|
|
331
|
+
| `aborted` | whoever asked stopped waiting: an `AbortError`, a cancelled run | no |
|
|
332
|
+
| `failed` | a fault: a bug, a 5xx, a server nobody reached | yes |
|
|
137
333
|
|
|
138
334
|
`configureProcess({ outcomeOf })` replaces the process-wide rule. `reportError(error, where)`
|
|
139
335
|
reports what fails outside a use case — a view, a worker —, with the same rule.
|
|
140
336
|
|
|
337
|
+
### Cancelling a run
|
|
338
|
+
|
|
339
|
+
```ts
|
|
340
|
+
const screen = new AbortController();
|
|
341
|
+
const answer = sales.execute(new CommandQuote(3), { signal: screen.signal });
|
|
342
|
+
screen.abort(); // the run ends `aborted` at once; nothing is reported
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
Its Features inherit the signal, a service reads it as `this.signal`, and every request its bound
|
|
346
|
+
transport or generated client sends is cancelled with it — with nothing passed by hand.
|
|
347
|
+
|
|
348
|
+
### A circuit breaker
|
|
349
|
+
|
|
350
|
+
```ts
|
|
351
|
+
const odoo = new CircuitBreaker("odoo", { failures: 5, openFor: 30_000 });
|
|
352
|
+
const transport = guardedTransport(rpcTransport, odoo); // every call through the circuit
|
|
353
|
+
await odoo.call(() => printer.print(receipt)); // or one call; odoo.wrap(fn) for a function
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
The backend's `CircuitBreaker`, in this process's memory. After `failures` counted failures in a
|
|
357
|
+
row — the dependency did not answer (`unavailable`, `unknown_outcome`, `exhausted`, `internal`) —
|
|
358
|
+
it opens: calls fail at once with `CircuitOpen`, `unavailable` with the time left as its
|
|
359
|
+
`retryAfter`, so `failureOf`, a stored queue (which waits that long) and a screen already know
|
|
360
|
+
what to do. After `openFor`, one call tries: an answer closes it, a counted failure opens it
|
|
361
|
+
again. A refusal is an answer and never counts. Put it where the dependency is — the transport, an
|
|
362
|
+
adapter —, not in each Feature.
|
|
363
|
+
|
|
141
364
|
## Observability
|
|
142
365
|
|
|
143
366
|
The platform plugs the implementations once, at startup; nothing breaks without them:
|
|
@@ -229,17 +452,67 @@ its own. `values` is the same object until it changes, so a screen can hand it t
|
|
|
229
452
|
|
|
230
453
|
## Talking to a server
|
|
231
454
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
455
|
+
A remote service is reached through a port. The generated client never names a wire: it says
|
|
456
|
+
which operation, with what input, and the adapter the application injects decides how it
|
|
457
|
+
travels.
|
|
458
|
+
|
|
459
|
+
```ts
|
|
460
|
+
import { clientOf } from "./generated/client";
|
|
461
|
+
|
|
462
|
+
// pick the wire; the client, the Features and the tests stay the same
|
|
463
|
+
const forge = clientOf(restOperations(restTransport({ base: "/v1", headers: session })));
|
|
464
|
+
// or clientOf(jsonRpcOperations({ endpoint: "/rpc", headers: session }))
|
|
465
|
+
// or clientOf(grpcOperations(channel)) — a platform's adapter, the same port
|
|
466
|
+
|
|
467
|
+
await forge.sendIssue({ issue_id: "acme-3", message: "hola" }); // ResponseSendIssue
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
| Piece | What it does |
|
|
471
|
+
| --------------------------------- | ------------------------------------------------------------------------------------- |
|
|
472
|
+
| `OperationTransport` port | `call(operation, input, options)`: an operation by who it is, over any wire |
|
|
473
|
+
| `restOperations(transport)` | over REST: the path from the input's fields, a Query's query, a Command's body |
|
|
474
|
+
| `jsonRpcOperations({ endpoint })` | over JSON-RPC 2.0: one `POST`, the operation's method, its input as named params |
|
|
475
|
+
| `restTransport({ base })` | REST over `fetch`: `Idempotency-Key`, the server's `Problem`, model versions, Parquet |
|
|
476
|
+
| `memoryTransport(tables)` | the same REST contract in memory, copying by the wire's rules — for tests and demos |
|
|
477
|
+
| `entity(key, routes)` | a model's routes and Commands, typed — what a generated `entities.ts` declares |
|
|
478
|
+
| `retryKeys()` | one `Idempotency-Key` per gesture, kept while a retry may repeat it |
|
|
479
|
+
| `guardedTransport(t, breaker)` | every call of a transport through a circuit breaker |
|
|
480
|
+
| `screensTransport(transport)` | screen reads and writes run as use cases of `screens`: traced and reported |
|
|
481
|
+
| `parquetDecoder` (`./parquet`) | large pages read as Parquet through the same transport |
|
|
482
|
+
|
|
483
|
+
Inside a Feature, inject the generated client as it is: the run binds it, so every call carries
|
|
484
|
+
its trace (`traceparent`), its context (`sincpro-context`) and its signal — as headers over HTTP,
|
|
485
|
+
as metadata where a platform's adapter puts them. `screensTransport` and `entity()` stay on the
|
|
486
|
+
REST `Transport`: they are how a screen reads pages of an entity, with its model and its drafts.
|
|
487
|
+
|
|
488
|
+
### Writing an adapter for another wire
|
|
489
|
+
|
|
490
|
+
An adapter is one function: read the operation's binding for its wire, send the input, answer the
|
|
491
|
+
answer, and fail with errors `failureOf` reads.
|
|
492
|
+
|
|
493
|
+
```ts
|
|
494
|
+
function grpcOperations(channel: Channel): OperationTransport {
|
|
495
|
+
return {
|
|
496
|
+
async call(operation, input, options) {
|
|
497
|
+
const method = operation.grpc?.method;
|
|
498
|
+
if (!method) throw new OperationNotPublished(operation.identity, "gRPC");
|
|
499
|
+
try {
|
|
500
|
+
return await channel.unary(method, input, {
|
|
501
|
+
metadata: options?.headers, // traceparent, sincpro-context
|
|
502
|
+
signal: options?.signal,
|
|
503
|
+
});
|
|
504
|
+
} catch (error) {
|
|
505
|
+
const code = (error as { code: number }).code;
|
|
506
|
+
throw Object.assign(error as Error, { failureKind: kindOfGrpcCode(code) });
|
|
507
|
+
}
|
|
508
|
+
},
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
```
|
|
240
512
|
|
|
241
|
-
|
|
242
|
-
|
|
513
|
+
Every operation in `operations` carries its identity and each wire's binding (`rest`, `rpc`,
|
|
514
|
+
`grpc`, `mcp`); an operation the adapter's wire does not publish is `OperationNotPublished`
|
|
515
|
+
(`not_found`), never guessed.
|
|
243
516
|
|
|
244
517
|
## Generated code
|
|
245
518
|
|
|
@@ -252,14 +525,18 @@ sincpro-client check --root src
|
|
|
252
525
|
|
|
253
526
|
`generate` writes, from the contract the backend exports — never edited, written again whole:
|
|
254
527
|
|
|
255
|
-
| File | What
|
|
256
|
-
| ------------- |
|
|
257
|
-
| `domain.ts` | a type per model, per `<Name>Input`, per operation's input and answer, per domain event
|
|
258
|
-
| `client.ts` | `clientOf(
|
|
259
|
-
| `entities.ts` | each entity's list, record, edit, export, Commands and history, for screens
|
|
528
|
+
| File | What |
|
|
529
|
+
| ------------- | --------------------------------------------------------------------------------------------- |
|
|
530
|
+
| `domain.ts` | a type per model, per `<Name>Input`, per operation's input and answer, per domain event |
|
|
531
|
+
| `client.ts` | `clientOf(operations)`: a method per operation, over any wire; `operations` and `events` data |
|
|
532
|
+
| `entities.ts` | each entity's list, record, edit, export, Commands and history, for screens |
|
|
260
533
|
|
|
261
|
-
|
|
262
|
-
|
|
534
|
+
Each method takes the operation's input and answers its answer, as `domain.ts` types them — a
|
|
535
|
+
search answers its `Response…` with its records and cursor, as the backend's callers get it, on
|
|
536
|
+
every wire. `scaffold` writes, once, the application's own files: its dependencies (the client
|
|
537
|
+
over the `operations` the application exports — `--operations <module>` —), its service bases
|
|
538
|
+
and `registerServices`. `check` enforces the application's layers and bounded-context
|
|
539
|
+
boundaries.
|
|
263
540
|
|
|
264
541
|
**Domain events, on demand.** A backend leaves its events out of the contract unless it is
|
|
265
542
|
exported with `--events`. Then each event is a type as it is received — every field present,
|
|
@@ -341,7 +618,8 @@ Each module is laid out as sincpro_framework's are, and says what it offers in i
|
|
|
341
618
|
|
|
342
619
|
```text
|
|
343
620
|
src/
|
|
344
|
-
common/ the values of the wire
|
|
621
|
+
common/ the values of the wire, and what kind of failure an error is
|
|
622
|
+
circuit_breaker/ CircuitBreaker, CircuitOpen
|
|
345
623
|
context/
|
|
346
624
|
domain/ ContextStore and ContextCodec ports, what travels, Execution
|
|
347
625
|
adapters/ memoryContexts, plainCodec
|
|
@@ -353,15 +631,22 @@ src/
|
|
|
353
631
|
infrastructure/ the ports this process runs with
|
|
354
632
|
services/ logger, loggerAdapter, reportError, outcomeOf
|
|
355
633
|
client/
|
|
356
|
-
domain/ Transport, TransportError, entity(),
|
|
357
|
-
|
|
358
|
-
|
|
634
|
+
domain/ OperationTransport, RemoteOperation, Transport, TransportError, entity(),
|
|
635
|
+
the client contract
|
|
636
|
+
adapters/ restOperations, jsonRpcOperations, rest, memory, parquet (optional entry)
|
|
637
|
+
services/ discovery, retry keys, propagation of trace, context and signal,
|
|
638
|
+
guardedTransport
|
|
359
639
|
model/ defineMeta, metaFromSchema, metaStore
|
|
360
640
|
core/
|
|
361
|
-
domain/ Command, Response, Feature, ApplicationService, FeatureBus,
|
|
362
|
-
|
|
641
|
+
domain/ Command, DomainEvent, Response, Feature, ApplicationService, FeatureBus,
|
|
642
|
+
ErrorHandler, the pipeline's extension points
|
|
643
|
+
services/ the execution pipeline, interceptors, dependency binding, the handler chain
|
|
363
644
|
entrypoint/ BoundedContext, screensTransport
|
|
364
|
-
infrastructure/ configureProcess, configureLogger
|
|
645
|
+
infrastructure/ configureProcess, configureLogger, processListeners
|
|
646
|
+
event_driven/
|
|
647
|
+
domain/ Envelope, the EventQueue, Subscribes and EventStore ports
|
|
648
|
+
adapters/ syncQueue, storedQueue, memoryEventStore
|
|
649
|
+
entrypoint/ Publisher, Subscriber
|
|
365
650
|
codegen/ the generator and its CLI — the only place Node is allowed
|
|
366
651
|
testing/ memoryTracer, memoryReporter, memoryLogger
|
|
367
652
|
```
|
|
@@ -397,8 +682,19 @@ The runtime uses `fetch`, `AbortSignal`, `URL`, `crypto.getRandomValues`, `Proxy
|
|
|
397
682
|
|
|
398
683
|
Read [`MANIFESTO.md`](MANIFESTO.md) (what this package is and is not), [`AGENTS.md`](AGENTS.md)
|
|
399
684
|
(how to change it), [`CONTRIBUTING.md`](CONTRIBUTING.md),
|
|
400
|
-
[`docs/UBIQUITOUS_LANGUAGE.md`](docs/UBIQUITOUS_LANGUAGE.md)
|
|
401
|
-
[`docs/MOBILE.md`](docs/MOBILE.md). Versions are written by the release pipeline.
|
|
685
|
+
[`docs/UBIQUITOUS_LANGUAGE.md`](docs/UBIQUITOUS_LANGUAGE.md),
|
|
686
|
+
[`docs/WEB.md`](docs/WEB.md) and [`docs/MOBILE.md`](docs/MOBILE.md). Versions are written by the release pipeline.
|
|
687
|
+
|
|
688
|
+
## For consumers: the skill
|
|
689
|
+
|
|
690
|
+
[`.claude/skills/sincpro-client-framework/SKILL.md`](.claude/skills/sincpro-client-framework/SKILL.md)
|
|
691
|
+
is the guide for whoever builds on this package — an application, @sincpro/web-framework,
|
|
692
|
+
@sincpro/mobile — and for the agents that help them: the rule for what goes here, in a platform's
|
|
693
|
+
framework or in an application; the vocabulary; how an application is laid out; how to start the
|
|
694
|
+
process, reach the services over any wire, write a use case, decide what a screen does with a
|
|
695
|
+
failure, publish and subscribe to events, extend the pipeline, write a platform's adapter for each
|
|
696
|
+
port, and test; and the mistakes to avoid. It follows the format of sincpro_framework's skills, so
|
|
697
|
+
the shared knowledge base can carry it beside them.
|
|
402
698
|
|
|
403
699
|
## License
|
|
404
700
|
|