@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
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Meta } from "@sincpro/criteria";
|
|
2
|
+
/**
|
|
3
|
+
* A model definition read from a JSON Schema — what an OpenAPI document holds.
|
|
4
|
+
*
|
|
5
|
+
* Context: a model a READ answers is published by sincpro_framework as its contract — JSON
|
|
6
|
+
* Schema with `x-sincpro` — and read by the transport (`metaFromContract`, `core/discovery`).
|
|
7
|
+
* What has no `x-sincpro` is a plain JSON Schema: a COMMAND's body, a value object it carries.
|
|
8
|
+
*
|
|
9
|
+
* So this reads such a component schema into a `Meta` — the form of a Command, a value object —
|
|
10
|
+
* and `completeMeta` fills, from it, what a definition left `unknown`.
|
|
11
|
+
*
|
|
12
|
+
* const spec = metaFromSchema("ProjectSpecification", doc.components.schemas);
|
|
13
|
+
* const fixed = completeMeta(project.fields.specification.definition, spec);
|
|
14
|
+
*/
|
|
15
|
+
export type JsonSchema = {
|
|
16
|
+
type?: string | string[];
|
|
17
|
+
format?: string;
|
|
18
|
+
enum?: readonly unknown[];
|
|
19
|
+
items?: JsonSchema;
|
|
20
|
+
$ref?: string;
|
|
21
|
+
anyOf?: readonly JsonSchema[];
|
|
22
|
+
oneOf?: readonly JsonSchema[];
|
|
23
|
+
properties?: Readonly<Record<string, JsonSchema>>;
|
|
24
|
+
required?: readonly string[];
|
|
25
|
+
title?: string;
|
|
26
|
+
description?: string;
|
|
27
|
+
default?: unknown;
|
|
28
|
+
};
|
|
29
|
+
export type SchemaComponents = Readonly<Record<string, JsonSchema>>;
|
|
30
|
+
/** The definition a component schema describes. `identity` empty: a value object has none. */
|
|
31
|
+
export declare function metaFromSchema(name: string, components: SchemaComponents, options?: {
|
|
32
|
+
identity?: string;
|
|
33
|
+
}): Meta;
|
|
34
|
+
/**
|
|
35
|
+
* The server's definition, with what it left `unknown` taken from the schema — recursively
|
|
36
|
+
* into value objects. What the server DID say always wins: its labels, its operators, its
|
|
37
|
+
* hints. A field the schema does not know is left as the server sent it.
|
|
38
|
+
*/
|
|
39
|
+
export declare function completeMeta(meta: Meta, schema: Meta): Meta;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { field } from "./meta.js";
|
|
2
|
+
const UNKNOWN = {
|
|
3
|
+
type: "unknown",
|
|
4
|
+
kind: "scalar",
|
|
5
|
+
nullable: true,
|
|
6
|
+
sortable: false,
|
|
7
|
+
ops: [],
|
|
8
|
+
};
|
|
9
|
+
function nameOfRef(ref) {
|
|
10
|
+
return ref.slice(ref.lastIndexOf("/") + 1);
|
|
11
|
+
}
|
|
12
|
+
/** `anyOf: [X, {type: "null"}]` is `X`, nullable — how pydantic writes `X | None`. */
|
|
13
|
+
function unwrap(schema) {
|
|
14
|
+
const options = schema.anyOf ?? schema.oneOf;
|
|
15
|
+
if (options === undefined)
|
|
16
|
+
return { schema, nullable: false };
|
|
17
|
+
const real = options.filter((one) => one.type !== "null");
|
|
18
|
+
const [only] = real;
|
|
19
|
+
return real.length === 1 && only !== undefined
|
|
20
|
+
? { schema: { ...only, ...pick(schema) }, nullable: real.length < options.length }
|
|
21
|
+
: { schema, nullable: real.length < options.length };
|
|
22
|
+
}
|
|
23
|
+
/** The words of the outer schema win over the ones of what it wraps. */
|
|
24
|
+
function pick(schema) {
|
|
25
|
+
return {
|
|
26
|
+
...(schema.title === undefined ? {} : { title: schema.title }),
|
|
27
|
+
...(schema.description === undefined ? {} : { description: schema.description }),
|
|
28
|
+
...(schema.default === undefined ? {} : { default: schema.default }),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function fieldFrom(raw, required, components, seen) {
|
|
32
|
+
const { schema: unwrapped, nullable } = unwrap(raw);
|
|
33
|
+
const target = unwrapped.$ref !== undefined
|
|
34
|
+
? { ...components[nameOfRef(unwrapped.$ref)], ...pick(unwrapped) }
|
|
35
|
+
: unwrapped;
|
|
36
|
+
const options = {
|
|
37
|
+
...(target.title === undefined ? {} : { label: target.title }),
|
|
38
|
+
...(target.description === undefined ? {} : { help: target.description }),
|
|
39
|
+
...(target.default === undefined ? {} : { default: target.default }),
|
|
40
|
+
...(nullable ? { nullable } : {}),
|
|
41
|
+
required: required && target.default === undefined && !nullable,
|
|
42
|
+
};
|
|
43
|
+
if (unwrapped.$ref !== undefined) {
|
|
44
|
+
const name = nameOfRef(unwrapped.$ref);
|
|
45
|
+
if (components[name] === undefined || seen.has(name))
|
|
46
|
+
return UNKNOWN;
|
|
47
|
+
if (target.properties === undefined)
|
|
48
|
+
return target.enum ? field.choice(target.enum.map(String), options) : UNKNOWN;
|
|
49
|
+
return field.embedded(metaOf(name, target, components, new Set([...seen, name])), options);
|
|
50
|
+
}
|
|
51
|
+
if (target.enum)
|
|
52
|
+
return field.choice(target.enum.map(String), options);
|
|
53
|
+
const type = Array.isArray(target.type)
|
|
54
|
+
? target.type.find((t) => t !== "null")
|
|
55
|
+
: target.type;
|
|
56
|
+
switch (type) {
|
|
57
|
+
case "string":
|
|
58
|
+
if (target.format === "date")
|
|
59
|
+
return field.date(options);
|
|
60
|
+
if (target.format === "date-time")
|
|
61
|
+
return field.datetime(options);
|
|
62
|
+
return field.text(options);
|
|
63
|
+
case "integer":
|
|
64
|
+
return field.integer(options);
|
|
65
|
+
case "number":
|
|
66
|
+
return field.number(options);
|
|
67
|
+
case "boolean":
|
|
68
|
+
return field.boolean(options);
|
|
69
|
+
case "array": {
|
|
70
|
+
const item = target.items === undefined ? undefined : unwrap(target.items).schema;
|
|
71
|
+
if (item?.$ref !== undefined) {
|
|
72
|
+
const inner = fieldFrom(item, false, components, seen);
|
|
73
|
+
return inner.kind === "embedded" && inner.definition
|
|
74
|
+
? field.embedded(inner.definition, { ...options, many: true })
|
|
75
|
+
: field.tags(options);
|
|
76
|
+
}
|
|
77
|
+
return item?.type === "string" || item?.enum ? field.tags(options) : UNKNOWN;
|
|
78
|
+
}
|
|
79
|
+
default:
|
|
80
|
+
return UNKNOWN;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function metaOf(name, schema, components, seen) {
|
|
84
|
+
const required = new Set(schema.required ?? []);
|
|
85
|
+
const fields = Object.fromEntries(Object.entries(schema.properties ?? {}).map(([key, property]) => [
|
|
86
|
+
key,
|
|
87
|
+
fieldFrom(property, required.has(key), components, seen),
|
|
88
|
+
]));
|
|
89
|
+
return {
|
|
90
|
+
aggregate: name,
|
|
91
|
+
identity: "",
|
|
92
|
+
default_order: "",
|
|
93
|
+
fields,
|
|
94
|
+
...(schema.title === undefined ? {} : { name: { default: schema.title } }),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/** The definition a component schema describes. `identity` empty: a value object has none. */
|
|
98
|
+
export function metaFromSchema(name, components, options = {}) {
|
|
99
|
+
const schema = components[name];
|
|
100
|
+
if (schema === undefined)
|
|
101
|
+
throw new Error(`no schema named ${name}`);
|
|
102
|
+
const meta = metaOf(name, schema, components, new Set([name]));
|
|
103
|
+
return options.identity
|
|
104
|
+
? { ...meta, identity: options.identity, get_id: options.identity }
|
|
105
|
+
: meta;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The server's definition, with what it left `unknown` taken from the schema — recursively
|
|
109
|
+
* into value objects. What the server DID say always wins: its labels, its operators, its
|
|
110
|
+
* hints. A field the schema does not know is left as the server sent it.
|
|
111
|
+
*/
|
|
112
|
+
export function completeMeta(meta, schema) {
|
|
113
|
+
const fields = Object.fromEntries(Object.entries(meta.fields).map(([name, own]) => {
|
|
114
|
+
const known = schema.fields[name];
|
|
115
|
+
if (known === undefined)
|
|
116
|
+
return [name, own];
|
|
117
|
+
if (own.type === "unknown")
|
|
118
|
+
return [name, { ...known, ...labelsOf(own) }];
|
|
119
|
+
if (own.kind === "embedded" && own.definition && known.definition)
|
|
120
|
+
return [name, { ...own, definition: completeMeta(own.definition, known.definition) }];
|
|
121
|
+
return [name, own];
|
|
122
|
+
}));
|
|
123
|
+
return { ...meta, fields };
|
|
124
|
+
}
|
|
125
|
+
/** A label the server declared is kept over the schema's generated title. */
|
|
126
|
+
function labelsOf(own) {
|
|
127
|
+
return own.label && Object.keys(own.label).length > 0 ? { label: own.label } : {};
|
|
128
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a platform plugs so every use case is traced and every unexpected error reported.
|
|
3
|
+
*
|
|
4
|
+
* Context: sincpro_framework opens one span per use case and reports what nobody expected; a
|
|
5
|
+
* client has to do the same, and continue the backend's trace. The vendor — OpenTelemetry in a
|
|
6
|
+
* browser, a queue on a phone, GlitchTip, memory in a test — is the platform's, so this package
|
|
7
|
+
* defines the contract and calls it, and bundles none. Two implementations at least (a
|
|
8
|
+
* platform's and a test's) are why these are ports.
|
|
9
|
+
*
|
|
10
|
+
* Nothing here may break a use case: a tracer or a reporter that throws is ignored, and the use
|
|
11
|
+
* case answers as it would have.
|
|
12
|
+
*/
|
|
13
|
+
/** What is said about a span: plain values only, the way OpenTelemetry takes them. */
|
|
14
|
+
export type Attributes = Readonly<Record<string, string | number | boolean>>;
|
|
15
|
+
/**
|
|
16
|
+
* How a use case ended.
|
|
17
|
+
*
|
|
18
|
+
* - `ok`: it answered.
|
|
19
|
+
* - `refused`: the server said no and explained why — a validation, a conflict, a permission.
|
|
20
|
+
* Expected; never reported.
|
|
21
|
+
* - `aborted`: whoever asked stopped waiting (a screen that closed). Never reported.
|
|
22
|
+
* - `failed`: anything else — a bug, a server that broke, a server nobody could reach. Reported.
|
|
23
|
+
*/
|
|
24
|
+
export type Outcome = "ok" | "refused" | "aborted" | "failed";
|
|
25
|
+
/** One span: a use case, open while it runs. */
|
|
26
|
+
export interface Span {
|
|
27
|
+
/** 32 hex — the id the server continues and a person quotes as a support code. Empty when
|
|
28
|
+
* nothing traces. */
|
|
29
|
+
readonly traceId: string;
|
|
30
|
+
/** 16 hex. Empty when nothing traces. */
|
|
31
|
+
readonly spanId: string;
|
|
32
|
+
/** Whether this trace is kept: the server follows the same decision. */
|
|
33
|
+
readonly sampled: boolean;
|
|
34
|
+
/** Vendor state that travels beside the trace (`tracestate`), when there is any. */
|
|
35
|
+
readonly state?: string;
|
|
36
|
+
/** Says more about the use case while it runs. */
|
|
37
|
+
set(attributes: Attributes): void;
|
|
38
|
+
/** Closes it, once. `error` is what it failed or was refused with. */
|
|
39
|
+
end(outcome: Outcome, error?: unknown): void;
|
|
40
|
+
}
|
|
41
|
+
/** Opens spans. One per platform; `NO_TRACER` when nothing traces. */
|
|
42
|
+
export interface Tracer {
|
|
43
|
+
start(name: string, options: {
|
|
44
|
+
parent?: Span;
|
|
45
|
+
attributes?: Attributes;
|
|
46
|
+
}): Span;
|
|
47
|
+
}
|
|
48
|
+
/** What a report says about where the error happened. */
|
|
49
|
+
export type ErrorAbout = {
|
|
50
|
+
/** `<domain>/<Feature>`, the span's name. */
|
|
51
|
+
useCase: string;
|
|
52
|
+
/** The trace the error belongs to — the same id the server logged, when it got that far. */
|
|
53
|
+
traceId: string;
|
|
54
|
+
/** The execution's own id. */
|
|
55
|
+
executionId: string;
|
|
56
|
+
/** What the use case ran with (`Runtime` `context`). */
|
|
57
|
+
context: Readonly<Record<string, unknown>>;
|
|
58
|
+
};
|
|
59
|
+
/** Reports what nobody expected. One per platform; none when nothing reports. */
|
|
60
|
+
export interface ErrorReporter {
|
|
61
|
+
report(error: unknown, about: ErrorAbout): void;
|
|
62
|
+
}
|
|
63
|
+
/** Traces nothing: what a use case runs with until a platform plugs a tracer. */
|
|
64
|
+
export declare const NO_TRACER: Tracer;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a platform plugs so every use case is traced and every unexpected error reported.
|
|
3
|
+
*
|
|
4
|
+
* Context: sincpro_framework opens one span per use case and reports what nobody expected; a
|
|
5
|
+
* client has to do the same, and continue the backend's trace. The vendor — OpenTelemetry in a
|
|
6
|
+
* browser, a queue on a phone, GlitchTip, memory in a test — is the platform's, so this package
|
|
7
|
+
* defines the contract and calls it, and bundles none. Two implementations at least (a
|
|
8
|
+
* platform's and a test's) are why these are ports.
|
|
9
|
+
*
|
|
10
|
+
* Nothing here may break a use case: a tracer or a reporter that throws is ignored, and the use
|
|
11
|
+
* case answers as it would have.
|
|
12
|
+
*/
|
|
13
|
+
const EMPTY_SPAN = {
|
|
14
|
+
traceId: "",
|
|
15
|
+
spanId: "",
|
|
16
|
+
sampled: false,
|
|
17
|
+
set: () => undefined,
|
|
18
|
+
end: () => undefined,
|
|
19
|
+
};
|
|
20
|
+
/** Traces nothing: what a use case runs with until a platform plugs a tracer. */
|
|
21
|
+
export const NO_TRACER = { start: () => EMPTY_SPAN };
|
|
@@ -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;
|