@sincpro/client-framework 0.0.0-stage → 0.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 +78 -0
- package/LICENSE +21 -0
- package/MANIFESTO.md +124 -0
- package/README.md +88 -2
- 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 +206 -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 +76 -4
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { Transport } from "../client/transport.js";
|
|
2
|
+
import type { Span } from "../observability/ports.js";
|
|
3
|
+
/**
|
|
4
|
+
* One run of a use case: its span, what it runs with, and who ran it.
|
|
5
|
+
*
|
|
6
|
+
* Context: sincpro_framework keeps the running use case in a context variable; a browser has
|
|
7
|
+
* nothing like it that survives an `await` (AsyncContext is a proposal, Zone.js patches the
|
|
8
|
+
* world), and a global stack mixes the parents of two use cases running at once — which a
|
|
9
|
+
* screen with several windows does all the time. So an execution is a value, handed to what
|
|
10
|
+
* the use case calls: its Features (`ApplicationService`) and its dependencies (`BIND`).
|
|
11
|
+
*/
|
|
12
|
+
export type Execution = {
|
|
13
|
+
/** UUID v7, as 32 hex: this run. */
|
|
14
|
+
readonly id: string;
|
|
15
|
+
/** `<domain>/<Feature>` — the span's name, as the backend names its own. */
|
|
16
|
+
readonly useCase: string;
|
|
17
|
+
/** The bounded context the use case belongs to, when it was bound to one. */
|
|
18
|
+
readonly domain: string | undefined;
|
|
19
|
+
/** The run that executed this one: an ApplicationService, for its Features. */
|
|
20
|
+
readonly parent: Execution | undefined;
|
|
21
|
+
readonly span: Span;
|
|
22
|
+
/** What it runs with, read once where the gesture started and inherited by every child. */
|
|
23
|
+
readonly context: Readonly<Record<string, unknown>>;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* A dependency that has something to say about the execution it is used in: given that
|
|
27
|
+
* execution, it answers itself bound to it. The transport and every generated client are;
|
|
28
|
+
* anything else in a Feature's `deps` is handed as it is.
|
|
29
|
+
*/
|
|
30
|
+
export declare const BIND: unique symbol;
|
|
31
|
+
export type Bindable<T> = {
|
|
32
|
+
[BIND]: (execution: Execution) => T;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* What every request of an execution carries: its trace (`traceparent`, W3C) and its context
|
|
36
|
+
* (`sincpro-context`, the header sincpro_framework's entrances read). Nothing when there is no
|
|
37
|
+
* trace and no context.
|
|
38
|
+
*/
|
|
39
|
+
export declare function headersOf(execution: Execution): Record<string, string>;
|
|
40
|
+
/**
|
|
41
|
+
* The transport, writing the execution's headers on every call — the caller's own headers of a
|
|
42
|
+
* call still win.
|
|
43
|
+
*
|
|
44
|
+
* Context: the transport's own headers (`restTransport({ headers })`) are the session's — the
|
|
45
|
+
* token, the tenant — the same for every call; these are the use case's, different for two use
|
|
46
|
+
* cases running at once, so they travel per call (`CallOptions`).
|
|
47
|
+
*/
|
|
48
|
+
export declare function bindTransport(transport: Transport, execution: Execution): Transport;
|
|
49
|
+
/**
|
|
50
|
+
* A client made over a transport, made again over the transport bound to each execution it is
|
|
51
|
+
* used in. What a generated `clientOf(transport)` answers, so a Feature that calls
|
|
52
|
+
* `this.deps.project.sendIssue(…)` sends that request with its own trace.
|
|
53
|
+
*
|
|
54
|
+
* export function clientOf(transport: Transport) {
|
|
55
|
+
* return bindable(transport, (transport) => ({ sendIssue: (body) => transport.send(…) }));
|
|
56
|
+
* }
|
|
57
|
+
*
|
|
58
|
+
* Context: it takes the function that makes the client, not the client — rebuilding through
|
|
59
|
+
* `clientOf` itself would make TypeScript infer `clientOf` from its own return.
|
|
60
|
+
*/
|
|
61
|
+
export declare function bindable<T extends object>(transport: Transport, make: (transport: Transport) => T): T;
|
|
62
|
+
/** A dependency as the execution should see it: bound when it can be, as it is otherwise. */
|
|
63
|
+
export declare function bindDependency<T>(value: T, execution: Execution): T;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A dependency that has something to say about the execution it is used in: given that
|
|
3
|
+
* execution, it answers itself bound to it. The transport and every generated client are;
|
|
4
|
+
* anything else in a Feature's `deps` is handed as it is.
|
|
5
|
+
*/
|
|
6
|
+
export const BIND = Symbol.for("sincpro.client-framework.bind");
|
|
7
|
+
function isBindable(value) {
|
|
8
|
+
return (value !== null &&
|
|
9
|
+
(typeof value === "object" || typeof value === "function") &&
|
|
10
|
+
typeof value[BIND] === "function");
|
|
11
|
+
}
|
|
12
|
+
function isTransport(value) {
|
|
13
|
+
if (value === null || typeof value !== "object")
|
|
14
|
+
return false;
|
|
15
|
+
const one = value;
|
|
16
|
+
return (typeof one.list === "function" &&
|
|
17
|
+
typeof one.one === "function" &&
|
|
18
|
+
typeof one.send === "function");
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* What every request of an execution carries: its trace (`traceparent`, W3C) and its context
|
|
22
|
+
* (`sincpro-context`, the header sincpro_framework's entrances read). Nothing when there is no
|
|
23
|
+
* trace and no context.
|
|
24
|
+
*/
|
|
25
|
+
export function headersOf(execution) {
|
|
26
|
+
const headers = {};
|
|
27
|
+
const { span, context } = execution;
|
|
28
|
+
if (span.traceId !== "" && span.spanId !== "") {
|
|
29
|
+
headers["traceparent"] =
|
|
30
|
+
`00-${span.traceId}-${span.spanId}-${span.sampled ? "01" : "00"}`;
|
|
31
|
+
if (span.state)
|
|
32
|
+
headers["tracestate"] = span.state;
|
|
33
|
+
}
|
|
34
|
+
if (Object.keys(context).length > 0)
|
|
35
|
+
headers["sincpro-context"] = JSON.stringify(context);
|
|
36
|
+
return headers;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The transport, writing the execution's headers on every call — the caller's own headers of a
|
|
40
|
+
* call still win.
|
|
41
|
+
*
|
|
42
|
+
* Context: the transport's own headers (`restTransport({ headers })`) are the session's — the
|
|
43
|
+
* token, the tenant — the same for every call; these are the use case's, different for two use
|
|
44
|
+
* cases running at once, so they travel per call (`CallOptions`).
|
|
45
|
+
*/
|
|
46
|
+
export function bindTransport(transport, execution) {
|
|
47
|
+
const own = headersOf(execution);
|
|
48
|
+
if (Object.keys(own).length === 0)
|
|
49
|
+
return transport;
|
|
50
|
+
const call = (options) => ({ ...options, headers: { ...own, ...options?.headers } });
|
|
51
|
+
const bound = {
|
|
52
|
+
list: (route, criteria, signal, options) => transport.list(route, criteria, signal, call(options)),
|
|
53
|
+
one: (route, signal, options) => transport.one(route, signal, call(options)),
|
|
54
|
+
send: (method, route, body, signal, options) => transport.send(method, route, body, signal, call(options)),
|
|
55
|
+
};
|
|
56
|
+
const read = transport.read;
|
|
57
|
+
if (read !== undefined)
|
|
58
|
+
bound.read = (route, signal, options) => read.call(transport, route, signal, call(options));
|
|
59
|
+
return bound;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* A client made over a transport, made again over the transport bound to each execution it is
|
|
63
|
+
* used in. What a generated `clientOf(transport)` answers, so a Feature that calls
|
|
64
|
+
* `this.deps.project.sendIssue(…)` sends that request with its own trace.
|
|
65
|
+
*
|
|
66
|
+
* export function clientOf(transport: Transport) {
|
|
67
|
+
* return bindable(transport, (transport) => ({ sendIssue: (body) => transport.send(…) }));
|
|
68
|
+
* }
|
|
69
|
+
*
|
|
70
|
+
* Context: it takes the function that makes the client, not the client — rebuilding through
|
|
71
|
+
* `clientOf` itself would make TypeScript infer `clientOf` from its own return.
|
|
72
|
+
*/
|
|
73
|
+
export function bindable(transport, make) {
|
|
74
|
+
const client = make(transport);
|
|
75
|
+
Object.defineProperty(client, BIND, {
|
|
76
|
+
value: (execution) => bindable(bindTransport(transport, execution), make),
|
|
77
|
+
enumerable: false,
|
|
78
|
+
});
|
|
79
|
+
return client;
|
|
80
|
+
}
|
|
81
|
+
/** A dependency as the execution should see it: bound when it can be, as it is otherwise. */
|
|
82
|
+
export function bindDependency(value, execution) {
|
|
83
|
+
if (isBindable(value))
|
|
84
|
+
return value[BIND](execution);
|
|
85
|
+
if (isTransport(value))
|
|
86
|
+
return bindTransport(value, execution);
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { type Runtime } from "./runtime.js";
|
|
2
|
+
/**
|
|
3
|
+
* One use case. What it needs from outside — the generated client of its domain, another
|
|
4
|
+
* adapter — comes in `deps`, typed by the subclass.
|
|
5
|
+
*
|
|
6
|
+
* export class SendIssue extends Feature<{ project: ProjectClient }> {
|
|
7
|
+
* execute(input: dto.CommandSendIssue) {
|
|
8
|
+
* return this.deps.project.sendIssue(input);
|
|
9
|
+
* }
|
|
10
|
+
* }
|
|
11
|
+
*
|
|
12
|
+
* Context: all of a client's logic lives in Features and ApplicationServices, as in
|
|
13
|
+
* sincpro_framework and @sincpro/mobile, and none of them writes a span or catches an error to
|
|
14
|
+
* report it: `new` answers the instance behind interceptors, and every public method runs
|
|
15
|
+
* inside its own execution — a span named `<domain>/<name>`, the context it runs with, its
|
|
16
|
+
* report if nobody expected how it failed. `execute` is a use case's entry; another public
|
|
17
|
+
* method is traced as `<name>.<method>`, and a name that starts with `_` is not traced at all.
|
|
18
|
+
*
|
|
19
|
+
* Inside a method, `this.deps` and `this.context` are this run's own: each dependency that can
|
|
20
|
+
* be (the transport, a generated client) is bound to the execution, so its requests carry this
|
|
21
|
+
* run's trace. So a Feature keeps nothing of a request on `this`, and declares no `#private`
|
|
22
|
+
* fields — the run sees the instance through a view of its own, and `#private` refuses views;
|
|
23
|
+
* TypeScript's `private` is the same promise without that.
|
|
24
|
+
*/
|
|
25
|
+
export declare class Feature<Deps extends object = Record<string, never>> {
|
|
26
|
+
protected readonly deps: Deps;
|
|
27
|
+
/** What this run executes with (`RuntimeOptions.context`): empty outside a run. */
|
|
28
|
+
protected readonly context: Readonly<Record<string, unknown>>;
|
|
29
|
+
constructor(deps?: Deps);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* A use case that composes the Features of its own domain — the only difference with a
|
|
33
|
+
* Feature. Each call to one of them is a child of this run: its span under this span, the same
|
|
34
|
+
* context, the same trace on the wire.
|
|
35
|
+
*
|
|
36
|
+
* export class CloseIssue extends ApplicationService<{ preview: PreviewClose; send: SendIssue }> {
|
|
37
|
+
* async execute(input: CloseIssueInput) {
|
|
38
|
+
* const preview = await this.features.preview.execute(input);
|
|
39
|
+
* if (preview.warnings.length > 0) return { blocked: preview };
|
|
40
|
+
* return this.features.send.execute({ ...input, message: "cerrado" });
|
|
41
|
+
* }
|
|
42
|
+
* }
|
|
43
|
+
*
|
|
44
|
+
* Context: it never executes another ApplicationService — sincpro_framework's rule, refused here
|
|
45
|
+
* when it is constructed rather than found in a trace later.
|
|
46
|
+
*/
|
|
47
|
+
export declare class ApplicationService<Features extends Readonly<Record<string, Feature<object>>>, Deps extends object = Record<string, never>> extends Feature<Deps> {
|
|
48
|
+
protected readonly features: Features;
|
|
49
|
+
constructor(features: Features, deps?: Deps);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The services of a bounded context, each running in this runtime under that domain: traced as
|
|
53
|
+
* `<domain>/<key>`, reported by its reporter, with its context.
|
|
54
|
+
*
|
|
55
|
+
* const project = bindServices(runtime, "project", { sendIssue, closeIssue });
|
|
56
|
+
*
|
|
57
|
+
* Context: a span is named by the key it is bound with, not by the class — a production build
|
|
58
|
+
* renames classes, never the keys of an object.
|
|
59
|
+
*/
|
|
60
|
+
export declare function bindServices<Services extends Readonly<Record<string, Feature<object>>>>(runtime: Runtime, domain: string, services: Services): Services;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { newEntityId } from "../client/ids.js";
|
|
2
|
+
import { bindDependency } from "./execution.js";
|
|
3
|
+
import { DETACHED } from "./runtime.js";
|
|
4
|
+
/**
|
|
5
|
+
* One use case. What it needs from outside — the generated client of its domain, another
|
|
6
|
+
* adapter — comes in `deps`, typed by the subclass.
|
|
7
|
+
*
|
|
8
|
+
* export class SendIssue extends Feature<{ project: ProjectClient }> {
|
|
9
|
+
* execute(input: dto.CommandSendIssue) {
|
|
10
|
+
* return this.deps.project.sendIssue(input);
|
|
11
|
+
* }
|
|
12
|
+
* }
|
|
13
|
+
*
|
|
14
|
+
* Context: all of a client's logic lives in Features and ApplicationServices, as in
|
|
15
|
+
* sincpro_framework and @sincpro/mobile, and none of them writes a span or catches an error to
|
|
16
|
+
* report it: `new` answers the instance behind interceptors, and every public method runs
|
|
17
|
+
* inside its own execution — a span named `<domain>/<name>`, the context it runs with, its
|
|
18
|
+
* report if nobody expected how it failed. `execute` is a use case's entry; another public
|
|
19
|
+
* method is traced as `<name>.<method>`, and a name that starts with `_` is not traced at all.
|
|
20
|
+
*
|
|
21
|
+
* Inside a method, `this.deps` and `this.context` are this run's own: each dependency that can
|
|
22
|
+
* be (the transport, a generated client) is bound to the execution, so its requests carry this
|
|
23
|
+
* run's trace. So a Feature keeps nothing of a request on `this`, and declares no `#private`
|
|
24
|
+
* fields — the run sees the instance through a view of its own, and `#private` refuses views;
|
|
25
|
+
* TypeScript's `private` is the same promise without that.
|
|
26
|
+
*/
|
|
27
|
+
export class Feature {
|
|
28
|
+
deps;
|
|
29
|
+
/** What this run executes with (`RuntimeOptions.context`): empty outside a run. */
|
|
30
|
+
context = NO_CONTEXT;
|
|
31
|
+
constructor(deps) {
|
|
32
|
+
this.deps = deps ?? {};
|
|
33
|
+
// A class constructor that answers another object hands THAT to `new` and to every subclass
|
|
34
|
+
// constructor after it: their fields land on the instance through the proxy.
|
|
35
|
+
return intercepted(this, { runtime: DETACHED, domain: undefined, name: undefined });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* A use case that composes the Features of its own domain — the only difference with a
|
|
40
|
+
* Feature. Each call to one of them is a child of this run: its span under this span, the same
|
|
41
|
+
* context, the same trace on the wire.
|
|
42
|
+
*
|
|
43
|
+
* export class CloseIssue extends ApplicationService<{ preview: PreviewClose; send: SendIssue }> {
|
|
44
|
+
* async execute(input: CloseIssueInput) {
|
|
45
|
+
* const preview = await this.features.preview.execute(input);
|
|
46
|
+
* if (preview.warnings.length > 0) return { blocked: preview };
|
|
47
|
+
* return this.features.send.execute({ ...input, message: "cerrado" });
|
|
48
|
+
* }
|
|
49
|
+
* }
|
|
50
|
+
*
|
|
51
|
+
* Context: it never executes another ApplicationService — sincpro_framework's rule, refused here
|
|
52
|
+
* when it is constructed rather than found in a trace later.
|
|
53
|
+
*/
|
|
54
|
+
export class ApplicationService extends Feature {
|
|
55
|
+
features;
|
|
56
|
+
constructor(features, deps) {
|
|
57
|
+
super(deps);
|
|
58
|
+
for (const [name, feature] of Object.entries(features))
|
|
59
|
+
if (targetOf(feature) instanceof ApplicationService)
|
|
60
|
+
throw new Error(`${this.constructor.name}: "${name}" is an ApplicationService — an ApplicationService executes Features, never another ApplicationService`);
|
|
61
|
+
this.features = features;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The services of a bounded context, each running in this runtime under that domain: traced as
|
|
66
|
+
* `<domain>/<key>`, reported by its reporter, with its context.
|
|
67
|
+
*
|
|
68
|
+
* const project = bindServices(runtime, "project", { sendIssue, closeIssue });
|
|
69
|
+
*
|
|
70
|
+
* Context: a span is named by the key it is bound with, not by the class — a production build
|
|
71
|
+
* renames classes, never the keys of an object.
|
|
72
|
+
*/
|
|
73
|
+
export function bindServices(runtime, domain, services) {
|
|
74
|
+
return Object.fromEntries(Object.entries(services).map(([name, service]) => [
|
|
75
|
+
name,
|
|
76
|
+
intercepted(targetOf(service), { runtime, domain, name }),
|
|
77
|
+
]));
|
|
78
|
+
}
|
|
79
|
+
const NO_CONTEXT = Object.freeze({});
|
|
80
|
+
const TARGETS = new WeakMap();
|
|
81
|
+
function targetOf(feature) {
|
|
82
|
+
return TARGETS.get(feature) ?? feature;
|
|
83
|
+
}
|
|
84
|
+
/** The methods a class adds over Feature — what is traced — read once per class. */
|
|
85
|
+
const METHODS = new WeakMap();
|
|
86
|
+
function methodsOf(target) {
|
|
87
|
+
const prototype = Object.getPrototypeOf(target);
|
|
88
|
+
const known = METHODS.get(prototype);
|
|
89
|
+
if (known !== undefined)
|
|
90
|
+
return known;
|
|
91
|
+
const names = new Set();
|
|
92
|
+
for (let level = prototype; level !== null && level !== Feature.prototype && level !== Object.prototype; level = Object.getPrototypeOf(level))
|
|
93
|
+
for (const name of Object.getOwnPropertyNames(level)) {
|
|
94
|
+
const descriptor = Object.getOwnPropertyDescriptor(level, name);
|
|
95
|
+
if (name !== "constructor" &&
|
|
96
|
+
!name.startsWith("_") &&
|
|
97
|
+
typeof descriptor?.value === "function")
|
|
98
|
+
names.add(name);
|
|
99
|
+
}
|
|
100
|
+
METHODS.set(prototype, names);
|
|
101
|
+
return names;
|
|
102
|
+
}
|
|
103
|
+
function intercepted(target, binding) {
|
|
104
|
+
const proxy = new Proxy(target, {
|
|
105
|
+
get(held, property) {
|
|
106
|
+
const value = Reflect.get(held, property, held);
|
|
107
|
+
if (typeof property !== "string" || typeof value !== "function")
|
|
108
|
+
return value;
|
|
109
|
+
if (!methodsOf(held).has(property))
|
|
110
|
+
return value;
|
|
111
|
+
return (...args) => run(held, property, value, args, binding);
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
TARGETS.set(proxy, target);
|
|
115
|
+
return proxy;
|
|
116
|
+
}
|
|
117
|
+
function quietly(work, otherwise) {
|
|
118
|
+
try {
|
|
119
|
+
return work();
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return otherwise;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const NO_SPAN = {
|
|
126
|
+
traceId: "",
|
|
127
|
+
spanId: "",
|
|
128
|
+
sampled: false,
|
|
129
|
+
set: () => undefined,
|
|
130
|
+
end: () => undefined,
|
|
131
|
+
};
|
|
132
|
+
function run(target, method, body, args, binding) {
|
|
133
|
+
const { runtime, domain, parent } = binding;
|
|
134
|
+
const name = binding.name ?? target.constructor.name;
|
|
135
|
+
const useCase = `${domain === undefined ? "" : `${domain}/`}${name}${method === "execute" ? "" : `.${method}`}`;
|
|
136
|
+
const context = parent?.context ?? quietly(() => runtime.context(), NO_CONTEXT);
|
|
137
|
+
const composes = target instanceof ApplicationService;
|
|
138
|
+
const id = newEntityId();
|
|
139
|
+
const attributes = {
|
|
140
|
+
"sincpro.use_case": useCase,
|
|
141
|
+
"sincpro.layer": composes ? "application_service" : "feature",
|
|
142
|
+
"sincpro.execution_id": id,
|
|
143
|
+
...(domain === undefined ? {} : { "sincpro.domain": domain }),
|
|
144
|
+
};
|
|
145
|
+
const span = quietly(() => runtime.tracer.start(useCase, {
|
|
146
|
+
...(parent === undefined ? {} : { parent: parent.span }),
|
|
147
|
+
attributes,
|
|
148
|
+
}), NO_SPAN);
|
|
149
|
+
const execution = { id, useCase, domain, parent, span, context };
|
|
150
|
+
// The run's own view of the instance: its dependencies bound to this execution, its context,
|
|
151
|
+
// and — for an ApplicationService — its Features as children of this run.
|
|
152
|
+
const own = target;
|
|
153
|
+
const view = Object.create(target, {
|
|
154
|
+
deps: {
|
|
155
|
+
value: Object.fromEntries(Object.entries(own.deps ?? {}).map(([key, dependency]) => [
|
|
156
|
+
key,
|
|
157
|
+
bindDependency(dependency, execution),
|
|
158
|
+
])),
|
|
159
|
+
},
|
|
160
|
+
context: { value: context },
|
|
161
|
+
...(composes
|
|
162
|
+
? {
|
|
163
|
+
features: {
|
|
164
|
+
value: Object.fromEntries(Object.entries(own.features ?? {}).map(([key, feature]) => [
|
|
165
|
+
key,
|
|
166
|
+
intercepted(targetOf(feature), {
|
|
167
|
+
runtime,
|
|
168
|
+
domain,
|
|
169
|
+
name: key,
|
|
170
|
+
parent: execution,
|
|
171
|
+
}),
|
|
172
|
+
])),
|
|
173
|
+
},
|
|
174
|
+
}
|
|
175
|
+
: {}),
|
|
176
|
+
});
|
|
177
|
+
const finish = (failed, error) => {
|
|
178
|
+
const outcome = failed ? quietly(() => runtime.outcomeOf(error), "failed") : "ok";
|
|
179
|
+
quietly(() => span.end(outcome, error), undefined);
|
|
180
|
+
if (outcome === "failed")
|
|
181
|
+
quietly(() => runtime.reporter?.report(error, {
|
|
182
|
+
useCase,
|
|
183
|
+
traceId: span.traceId,
|
|
184
|
+
executionId: id,
|
|
185
|
+
context,
|
|
186
|
+
}), undefined);
|
|
187
|
+
};
|
|
188
|
+
let answer;
|
|
189
|
+
try {
|
|
190
|
+
answer = body.apply(view, args);
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
finish(true, error);
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
if (answer instanceof Promise)
|
|
197
|
+
return answer.then((value) => {
|
|
198
|
+
finish(false);
|
|
199
|
+
return value;
|
|
200
|
+
}, (error) => {
|
|
201
|
+
finish(true, error);
|
|
202
|
+
throw error;
|
|
203
|
+
});
|
|
204
|
+
finish(false);
|
|
205
|
+
return answer;
|
|
206
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type ErrorReporter, type Outcome, type Tracer } from "../observability/ports.js";
|
|
2
|
+
/**
|
|
3
|
+
* What every use case of an application runs with: who traces, who reports, and the context
|
|
4
|
+
* each one carries to the server.
|
|
5
|
+
*
|
|
6
|
+
* const runtime = createRuntime({
|
|
7
|
+
* tracer, // the platform's (OpenTelemetry, …)
|
|
8
|
+
* reporter, // the platform's (GlitchTip, …)
|
|
9
|
+
* context: () => ({ tenant_id: session.tenant }),
|
|
10
|
+
* });
|
|
11
|
+
* const project = bindServices(runtime, "project", { sendIssue, closeIssue });
|
|
12
|
+
* await project.closeIssue.execute({ issue_id: "acme-3" });
|
|
13
|
+
*
|
|
14
|
+
* Context: sincpro_framework's bus is what gives every use case its span, its error report and
|
|
15
|
+
* its context; here the same comes from the interceptors of `Feature`, and this is what they
|
|
16
|
+
* read. A use case that was never bound runs too — with nothing tracing and nothing reporting —
|
|
17
|
+
* so a Feature is tested by constructing it.
|
|
18
|
+
*/
|
|
19
|
+
export type RuntimeOptions = {
|
|
20
|
+
tracer?: Tracer;
|
|
21
|
+
reporter?: ErrorReporter;
|
|
22
|
+
/**
|
|
23
|
+
* What a use case runs with, read once where a gesture starts and inherited by every use case
|
|
24
|
+
* it executes. It is what services read (`this.context`) and what travels to the server in
|
|
25
|
+
* `sincpro-context` — never a secret: the token goes in the transport's headers.
|
|
26
|
+
*/
|
|
27
|
+
context?: () => Readonly<Record<string, unknown>>;
|
|
28
|
+
/** How an error ended a use case (`outcomeOf` by default). Only `failed` is reported. */
|
|
29
|
+
outcomeOf?: (error: unknown) => Outcome;
|
|
30
|
+
};
|
|
31
|
+
export type Runtime = {
|
|
32
|
+
readonly tracer: Tracer;
|
|
33
|
+
readonly reporter: ErrorReporter | undefined;
|
|
34
|
+
readonly context: () => Readonly<Record<string, unknown>>;
|
|
35
|
+
readonly outcomeOf: (error: unknown) => Outcome;
|
|
36
|
+
};
|
|
37
|
+
export declare function createRuntime(options?: RuntimeOptions): Runtime;
|
|
38
|
+
/** Where a use case that was never bound runs: nothing traces, nothing reports. */
|
|
39
|
+
export declare const DETACHED: Runtime;
|
|
40
|
+
/**
|
|
41
|
+
* How an error ended a use case, by default.
|
|
42
|
+
*
|
|
43
|
+
* Context: the backend reports what nobody expected and only that — a refusal it explained is
|
|
44
|
+
* the business answering, not a fault. Here the same: a `TransportError` the server answered
|
|
45
|
+
* with a 4xx is `refused`; a request the caller stopped waiting for is `aborted`; everything
|
|
46
|
+
* else — a bug, a 5xx, a server nobody could reach — is `failed`, and reported.
|
|
47
|
+
*/
|
|
48
|
+
export declare function outcomeOf(error: unknown): Outcome;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { TransportError } from "../client/transport.js";
|
|
2
|
+
import { NO_TRACER, } from "../observability/ports.js";
|
|
3
|
+
const NO_CONTEXT = Object.freeze({});
|
|
4
|
+
export function createRuntime(options = {}) {
|
|
5
|
+
return {
|
|
6
|
+
tracer: options.tracer ?? NO_TRACER,
|
|
7
|
+
reporter: options.reporter,
|
|
8
|
+
context: options.context ?? (() => NO_CONTEXT),
|
|
9
|
+
outcomeOf: options.outcomeOf ?? outcomeOf,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
/** Where a use case that was never bound runs: nothing traces, nothing reports. */
|
|
13
|
+
export const DETACHED = createRuntime();
|
|
14
|
+
/**
|
|
15
|
+
* How an error ended a use case, by default.
|
|
16
|
+
*
|
|
17
|
+
* Context: the backend reports what nobody expected and only that — a refusal it explained is
|
|
18
|
+
* the business answering, not a fault. Here the same: a `TransportError` the server answered
|
|
19
|
+
* with a 4xx is `refused`; a request the caller stopped waiting for is `aborted`; everything
|
|
20
|
+
* else — a bug, a 5xx, a server nobody could reach — is `failed`, and reported.
|
|
21
|
+
*/
|
|
22
|
+
export function outcomeOf(error) {
|
|
23
|
+
if (error instanceof TransportError && error.status !== null)
|
|
24
|
+
return error.status >= 400 && error.status < 500 ? "refused" : "failed";
|
|
25
|
+
if (error !== null &&
|
|
26
|
+
typeof error === "object" &&
|
|
27
|
+
error.name === "AbortError")
|
|
28
|
+
return "aborted";
|
|
29
|
+
return "failed";
|
|
30
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ErrorAbout, ErrorReporter, Outcome, Tracer } from "../observability/ports.js";
|
|
2
|
+
/**
|
|
3
|
+
* A tracer and a reporter that keep what they were told, so a test reads it back.
|
|
4
|
+
*
|
|
5
|
+
* const tracer = memoryTracer();
|
|
6
|
+
* const project = bindServices(createRuntime({ tracer }), "project", { closeIssue });
|
|
7
|
+
* await project.closeIssue.execute(input);
|
|
8
|
+
* tracer.spans.map((one) => one.name); // ["project/closeIssue", "project/send", …]
|
|
9
|
+
*
|
|
10
|
+
* Context: a use case is proven in Node with nothing of a platform; these are the platform's
|
|
11
|
+
* part, held in memory. Not test doubles of this package — real implementations of its ports.
|
|
12
|
+
*/
|
|
13
|
+
/** A span as the memory tracer kept it. */
|
|
14
|
+
export type RecordedSpan = {
|
|
15
|
+
name: string;
|
|
16
|
+
traceId: string;
|
|
17
|
+
spanId: string;
|
|
18
|
+
/** The span it was opened under, when it was. */
|
|
19
|
+
parentSpanId: string | undefined;
|
|
20
|
+
attributes: Record<string, string | number | boolean>;
|
|
21
|
+
/** How it ended; `undefined` while it is open. */
|
|
22
|
+
outcome: Outcome | undefined;
|
|
23
|
+
error: unknown;
|
|
24
|
+
};
|
|
25
|
+
export type MemoryTracer = Tracer & {
|
|
26
|
+
readonly spans: RecordedSpan[];
|
|
27
|
+
};
|
|
28
|
+
export declare function memoryTracer(): MemoryTracer;
|
|
29
|
+
export type MemoryReporter = ErrorReporter & {
|
|
30
|
+
readonly reports: {
|
|
31
|
+
error: unknown;
|
|
32
|
+
about: ErrorAbout;
|
|
33
|
+
}[];
|
|
34
|
+
};
|
|
35
|
+
export declare function memoryReporter(): MemoryReporter;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
function hex(bytes) {
|
|
2
|
+
return Array.from(globalThis.crypto.getRandomValues(new Uint8Array(bytes)), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
3
|
+
}
|
|
4
|
+
export function memoryTracer() {
|
|
5
|
+
const spans = [];
|
|
6
|
+
return {
|
|
7
|
+
spans,
|
|
8
|
+
start(name, options) {
|
|
9
|
+
const recorded = {
|
|
10
|
+
name,
|
|
11
|
+
traceId: options.parent?.traceId || hex(16),
|
|
12
|
+
spanId: hex(8),
|
|
13
|
+
parentSpanId: options.parent?.spanId || undefined,
|
|
14
|
+
attributes: { ...options.attributes },
|
|
15
|
+
outcome: undefined,
|
|
16
|
+
error: undefined,
|
|
17
|
+
};
|
|
18
|
+
spans.push(recorded);
|
|
19
|
+
return {
|
|
20
|
+
traceId: recorded.traceId,
|
|
21
|
+
spanId: recorded.spanId,
|
|
22
|
+
sampled: true,
|
|
23
|
+
set: (attributes) => void Object.assign(recorded.attributes, attributes),
|
|
24
|
+
end: (outcome, error) => {
|
|
25
|
+
if (recorded.outcome !== undefined)
|
|
26
|
+
return;
|
|
27
|
+
recorded.outcome = outcome;
|
|
28
|
+
recorded.error = error;
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export function memoryReporter() {
|
|
35
|
+
const reports = [];
|
|
36
|
+
return { reports, report: (error, about) => void reports.push({ error, about }) };
|
|
37
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,78 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sincpro/client-framework",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The client side of sincpro_framework, for every platform: Feature and ApplicationService traced and reported by default, applications made of domains, and the typed client generated from a service's contract. No React, no DOM.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./codegen": {
|
|
14
|
+
"types": "./dist/codegen/index.d.ts",
|
|
15
|
+
"default": "./dist/codegen/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./testing": {
|
|
18
|
+
"types": "./dist/testing/index.d.ts",
|
|
19
|
+
"default": "./dist/testing/index.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"bin": {
|
|
23
|
+
"sincpro-client": "./dist/codegen/cli.js"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"MANIFESTO.md",
|
|
28
|
+
"AGENTS.md",
|
|
29
|
+
"README.md"
|
|
30
|
+
],
|
|
31
|
+
"sideEffects": false,
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"keywords": [
|
|
36
|
+
"sincpro",
|
|
37
|
+
"sincpro-framework",
|
|
38
|
+
"ddd",
|
|
39
|
+
"use-case",
|
|
40
|
+
"feature",
|
|
41
|
+
"application-service",
|
|
42
|
+
"opentelemetry",
|
|
43
|
+
"codegen"
|
|
44
|
+
],
|
|
45
|
+
"repository": {
|
|
46
|
+
"type": "git",
|
|
47
|
+
"url": "git+https://github.com/Sincpro-SRL/sincpro_client_framework.git"
|
|
48
|
+
},
|
|
49
|
+
"homepage": "https://github.com/Sincpro-SRL/sincpro_client_framework#readme",
|
|
50
|
+
"bugs": {
|
|
51
|
+
"url": "https://github.com/Sincpro-SRL/sincpro_client_framework/issues"
|
|
52
|
+
},
|
|
53
|
+
"author": "Gutierrez, Andres <andres.gutierrez@sincpro.com.bo>",
|
|
54
|
+
"license": "MIT",
|
|
55
|
+
"engines": {
|
|
56
|
+
"node": ">=26"
|
|
57
|
+
},
|
|
58
|
+
"prettier": {
|
|
59
|
+
"printWidth": 94
|
|
60
|
+
},
|
|
61
|
+
"peerDependencies": {
|
|
62
|
+
"@sincpro/criteria": "^1.1.0"
|
|
63
|
+
},
|
|
64
|
+
"devDependencies": {
|
|
65
|
+
"@eslint/js": "^10.0.1",
|
|
66
|
+
"@sincpro/criteria": "^1.1.0",
|
|
67
|
+
"@types/node": "^26.6.6",
|
|
68
|
+
"eslint": "^10.14.0",
|
|
69
|
+
"eslint-config-prettier": "^10.1.8",
|
|
70
|
+
"eslint-plugin-simple-import-sort": "^14.0.0",
|
|
71
|
+
"prettier": "^3.9.10",
|
|
72
|
+
"tsc-alias": "^1.9.9",
|
|
73
|
+
"typescript": "~6.0.3",
|
|
74
|
+
"typescript-eslint": "^8.72.1",
|
|
75
|
+
"vite": "8.3.5",
|
|
76
|
+
"vitest": "5.0.5"
|
|
77
|
+
}
|
|
78
|
+
}
|