@sincpro/client-framework 0.4.0 → 0.5.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/README.md +28 -0
- package/dist/client/memory.js +5 -3
- package/dist/codegen/check.d.ts +64 -0
- package/dist/codegen/check.js +195 -0
- package/dist/codegen/cli.js +73 -6
- package/dist/codegen/client.js +5 -5
- package/dist/codegen/domain.d.ts +2 -1
- package/dist/codegen/domain.js +17 -5
- package/dist/codegen/entities.js +10 -3
- package/dist/codegen/index.d.ts +2 -0
- package/dist/codegen/index.js +2 -0
- package/dist/codegen/names.d.ts +12 -0
- package/dist/codegen/names.js +58 -0
- package/dist/codegen/scaffold.d.ts +24 -0
- package/dist/codegen/scaffold.js +123 -0
- package/dist/common/decimal.js +8 -0
- package/dist/common/serialization.d.ts +5 -1
- package/dist/common/serialization.js +17 -4
- package/dist/common/time.d.ts +2 -1
- package/dist/common/time.js +9 -2
- package/dist/context/codec.d.ts +2 -0
- package/dist/context/codec.js +16 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/observability/ports.d.ts +2 -1
- package/dist/services/bounded-context.js +4 -15
- package/dist/services/execution.js +2 -2
- package/dist/services/feature.d.ts +4 -1
- package/dist/services/report.d.ts +15 -0
- package/dist/services/report.js +53 -0
- package/dist/services/screens.d.ts +2 -0
- package/dist/services/screens.js +67 -0
- package/package.json +6 -2
package/dist/codegen/names.js
CHANGED
|
@@ -52,6 +52,64 @@ export function localOf(metamodel, identity) {
|
|
|
52
52
|
const prefix = `${contextOf(metamodel)}.`;
|
|
53
53
|
return identifierOf(identity.startsWith(prefix) ? identity.slice(prefix.length) : identity);
|
|
54
54
|
}
|
|
55
|
+
const ANSWERS = new WeakMap();
|
|
56
|
+
/** The output's own name, when it has one a TypeScript type can take. */
|
|
57
|
+
function titleOf(output) {
|
|
58
|
+
if (output === null || output.$ref !== undefined)
|
|
59
|
+
return undefined;
|
|
60
|
+
const title = output.title;
|
|
61
|
+
return typeof title === "string" && /^[A-Za-z_$][\w$]*$/.test(title) ? title : undefined;
|
|
62
|
+
}
|
|
63
|
+
function answersOf(metamodel) {
|
|
64
|
+
const operations = Object.entries(metamodel.operations);
|
|
65
|
+
const taken = new Set([
|
|
66
|
+
...Object.keys(metamodel.models).map(identifierOf),
|
|
67
|
+
...Object.keys(metamodel.$defs ?? {}).map(identifierOf),
|
|
68
|
+
]);
|
|
69
|
+
for (const [identity] of operations) {
|
|
70
|
+
const local = localOf(metamodel, identity);
|
|
71
|
+
taken.add(local);
|
|
72
|
+
taken.add(`${local}Answer`);
|
|
73
|
+
}
|
|
74
|
+
// A title is one type when every output that carries it is the same schema — the backend
|
|
75
|
+
// answering two Commands with one Response class; two different schemas under one title are
|
|
76
|
+
// two types, and neither takes the name.
|
|
77
|
+
const shapes = new Map();
|
|
78
|
+
for (const [, operation] of operations) {
|
|
79
|
+
const title = titleOf(operation.output);
|
|
80
|
+
if (title === undefined)
|
|
81
|
+
continue;
|
|
82
|
+
const seen = shapes.get(title) ?? new Set();
|
|
83
|
+
seen.add(JSON.stringify(operation.output));
|
|
84
|
+
shapes.set(title, seen);
|
|
85
|
+
}
|
|
86
|
+
const names = new Map();
|
|
87
|
+
for (const [identity, operation] of operations) {
|
|
88
|
+
const title = titleOf(operation.output);
|
|
89
|
+
if (title !== undefined && shapes.get(title)?.size === 1 && !taken.has(title))
|
|
90
|
+
names.set(identity, title);
|
|
91
|
+
}
|
|
92
|
+
return names;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* What an operation's answer is called: the backend's own name for it — its output's title,
|
|
96
|
+
* `ResponseCancelIssue` — when that name is one type (every output with it is the same schema)
|
|
97
|
+
* and no other type has it; `<Name>Answer` when the output has no name of its own (a model, a
|
|
98
|
+
* list) or two different shapes share it. A new operation answering with the same Response keeps
|
|
99
|
+
* the name: regenerating never renames by itself.
|
|
100
|
+
*
|
|
101
|
+
* Context: the ubiquitous language. The backend writes `CommandCancelIssue` → `ResponseCancelIssue`,
|
|
102
|
+
* and a client that calls it `CommandCancelIssueAnswer` makes a person learn two words for one
|
|
103
|
+
* thing. The `<Name>Answer` it used to write stays as a deprecated alias of the backend's name.
|
|
104
|
+
*/
|
|
105
|
+
export function answerOf(metamodel, identity) {
|
|
106
|
+
let known = ANSWERS.get(metamodel);
|
|
107
|
+
if (known === undefined) {
|
|
108
|
+
known = answersOf(metamodel);
|
|
109
|
+
ANSWERS.set(metamodel, known);
|
|
110
|
+
}
|
|
111
|
+
return known.get(identity) ?? `${localOf(metamodel, identity)}Answer`;
|
|
112
|
+
}
|
|
55
113
|
/** The method an operation is called by: `CommandSendIssue` → `sendIssue`. */
|
|
56
114
|
export function methodOf(local) {
|
|
57
115
|
const bare = local.replace(/^(Command|Query)(?=[A-Z])/, "");
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Metamodel } from "./metamodel.js";
|
|
2
|
+
/**
|
|
3
|
+
* A bounded context's skeleton, written ONCE beside what is generated — the files that are the
|
|
4
|
+
* application's from then on:
|
|
5
|
+
*
|
|
6
|
+
* domains/project/
|
|
7
|
+
* generated/ `sincpro-client generate` — regenerated, never edited
|
|
8
|
+
* infrastructure/dependencies.ts what the services can count on: the generated client
|
|
9
|
+
* infrastructure/bounded_context.ts its Feature and ApplicationService, and how it is made
|
|
10
|
+
* services/index.ts the services, by the name each is reached by
|
|
11
|
+
* index.ts `export const project` — ready to use
|
|
12
|
+
*
|
|
13
|
+
* Context: the layout sincpro_framework gives a service (`dependencies.py`, `framework.py`,
|
|
14
|
+
* `services/`, `__init__.py`), so a bounded context starts the way every other does and nobody
|
|
15
|
+
* copies it by hand from another. It is the application's to change: the CLI writes a file only
|
|
16
|
+
* when it is not there, and never touches one that is — unlike `generated/`, which is written
|
|
17
|
+
* whole every time.
|
|
18
|
+
*/
|
|
19
|
+
export type ScaffoldOptions = {
|
|
20
|
+
/** Where the application's transport is exported (`export const transport`), from the
|
|
21
|
+
* bounded context's folder. `../common/infrastructure/transport` by default. */
|
|
22
|
+
transport?: string;
|
|
23
|
+
};
|
|
24
|
+
export declare function scaffoldFilesOf(metamodel: Metamodel, options?: ScaffoldOptions): Record<string, string>;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { aliasOf, FRAMEWORK } from "./names.js";
|
|
2
|
+
/** Names the skeleton's `index.ts` cannot give its constant. */
|
|
3
|
+
const TAKEN = new Set([
|
|
4
|
+
"transport",
|
|
5
|
+
"features",
|
|
6
|
+
"applicationServices",
|
|
7
|
+
..."break case catch class const continue debugger default delete do else enum export extends false finally for function if import in instanceof new null return super switch this throw true try typeof var void while with yield let static implements interface package private protected public await".split(" "),
|
|
8
|
+
]);
|
|
9
|
+
/** `sales-orders` → `SalesOrders`. */
|
|
10
|
+
function pascalOf(alias) {
|
|
11
|
+
const joined = alias
|
|
12
|
+
.split(/[^A-Za-z0-9]+/)
|
|
13
|
+
.filter((one) => one !== "")
|
|
14
|
+
.map((one) => `${one.charAt(0).toUpperCase()}${one.slice(1)}`)
|
|
15
|
+
.join("");
|
|
16
|
+
return joined === "" ? "Context" : /^[0-9]/.test(joined) ? `_${joined}` : joined;
|
|
17
|
+
}
|
|
18
|
+
/** A module the skeleton imports, built in parts — see `FRAMEWORK` (`codegen/names.ts`). */
|
|
19
|
+
const at = (...parts) => parts.join("/");
|
|
20
|
+
const HEADER = (what) => `// ${what} — written once by \`sincpro-client scaffold\`; the application's to change.`;
|
|
21
|
+
export function scaffoldFilesOf(metamodel, options = {}) {
|
|
22
|
+
const alias = aliasOf(metamodel);
|
|
23
|
+
const Name = pascalOf(alias);
|
|
24
|
+
const bare = `${Name.charAt(0).toLowerCase()}${Name.slice(1)}`;
|
|
25
|
+
// A word the language keeps (`import`, `default`) or a name `index.ts` already imports cannot
|
|
26
|
+
// be the constant's: it takes the bounded context's word instead.
|
|
27
|
+
const name = TAKEN.has(bare) ? `${bare}BoundedContext` : bare;
|
|
28
|
+
const Dependencies = `${Name}Dependencies`;
|
|
29
|
+
const client = metamodel.bindings !== undefined;
|
|
30
|
+
const transport = options.transport ?? at("..", "common", "infrastructure", "transport");
|
|
31
|
+
const dependencies = [
|
|
32
|
+
HEADER(`What the services of «${alias}» can count on`),
|
|
33
|
+
"",
|
|
34
|
+
`import type { Transport, UseBoundedContext } from "${FRAMEWORK}";`,
|
|
35
|
+
...(client ? ["", `import { clientOf } from "${at("..", "generated", "client")}";`] : []),
|
|
36
|
+
"",
|
|
37
|
+
`/** What every service of «${alias}» finds on \`this\`${client ? ": `this.client`, the service's operations" : ""}. */`,
|
|
38
|
+
`export interface ${Dependencies} {`,
|
|
39
|
+
...(client
|
|
40
|
+
? [" client: ReturnType<typeof clientOf>;"]
|
|
41
|
+
: [
|
|
42
|
+
" /** What the services need: a repository, an adapter, another bounded context. */",
|
|
43
|
+
]),
|
|
44
|
+
"}",
|
|
45
|
+
"",
|
|
46
|
+
"export function registerDependencies(",
|
|
47
|
+
` bc: UseBoundedContext<${Dependencies}>,`,
|
|
48
|
+
` ${client ? "transport" : "_transport"}: Transport,`,
|
|
49
|
+
") {",
|
|
50
|
+
...(client ? [` bc.addDependency("client", clientOf(transport));`] : []),
|
|
51
|
+
" return bc;",
|
|
52
|
+
"}",
|
|
53
|
+
"",
|
|
54
|
+
].join("\n");
|
|
55
|
+
const boundedContext = [
|
|
56
|
+
HEADER(`How «${alias}» is made`),
|
|
57
|
+
"",
|
|
58
|
+
"import {",
|
|
59
|
+
" ApplicationService as BaseApplicationService,",
|
|
60
|
+
" Feature as BaseFeature,",
|
|
61
|
+
" type Transport,",
|
|
62
|
+
" UseBoundedContext,",
|
|
63
|
+
`} from "${FRAMEWORK}";`,
|
|
64
|
+
"",
|
|
65
|
+
`import type { ${Name}Features } from "${at("..", "services")}";`,
|
|
66
|
+
`import { type ${Dependencies}, registerDependencies } from "${at(".", "dependencies")}";`,
|
|
67
|
+
"",
|
|
68
|
+
`/** A Feature of «${alias}»: its dependencies typed on \`this\`, and \`this.context\`. */`,
|
|
69
|
+
`export class Feature extends BaseFeature<${Dependencies}> {}`,
|
|
70
|
+
"",
|
|
71
|
+
`/** An ApplicationService of «${alias}»: the same, and \`this.feature.<Name>\` for each Feature. */`,
|
|
72
|
+
"export class ApplicationService extends BaseApplicationService<",
|
|
73
|
+
` ${Dependencies},`,
|
|
74
|
+
` ${Name}Features`,
|
|
75
|
+
"> {}",
|
|
76
|
+
"",
|
|
77
|
+
`export function config${Name}BoundedContext(name: string, transport: Transport) {`,
|
|
78
|
+
` return registerDependencies(new UseBoundedContext<${Dependencies}>(name), transport);`,
|
|
79
|
+
"}",
|
|
80
|
+
"",
|
|
81
|
+
].join("\n");
|
|
82
|
+
const services = [
|
|
83
|
+
HEADER(`What «${alias}» offers`),
|
|
84
|
+
"",
|
|
85
|
+
"/**",
|
|
86
|
+
` * Each service by the name it is reached by — \`${name}.Issues.close(…)\`. A Feature groups the`,
|
|
87
|
+
" * operations of one part of the domain; an ApplicationService composes the Features:",
|
|
88
|
+
" *",
|
|
89
|
+
" * // services/issues.ts",
|
|
90
|
+
` * export class Issues extends Feature {`,
|
|
91
|
+
" * close(input: dto.CommandCloseIssue) {",
|
|
92
|
+
" * return this.client.closeIssue(input);",
|
|
93
|
+
" * }",
|
|
94
|
+
" * }",
|
|
95
|
+
" */",
|
|
96
|
+
"export const features = {};",
|
|
97
|
+
"export const applicationServices = {};",
|
|
98
|
+
"",
|
|
99
|
+
`export type ${Name}Features = typeof features;`,
|
|
100
|
+
"",
|
|
101
|
+
].join("\n");
|
|
102
|
+
const index = [
|
|
103
|
+
HEADER(`«${alias}», ready to use`),
|
|
104
|
+
"",
|
|
105
|
+
`import { transport } from "${transport}";`,
|
|
106
|
+
"",
|
|
107
|
+
`import { config${Name}BoundedContext } from "${at(".", "infrastructure", "bounded_context")}";`,
|
|
108
|
+
`import { applicationServices, features } from "${at(".", "services")}";`,
|
|
109
|
+
"",
|
|
110
|
+
`/** «${alias}»: \`${name}.<Service>.<method>(…)\` — from a screen, a test, another bounded context. */`,
|
|
111
|
+
`export const ${name} = config${Name}BoundedContext(${JSON.stringify(alias)}, transport).register({`,
|
|
112
|
+
" features,",
|
|
113
|
+
" applicationServices,",
|
|
114
|
+
"});",
|
|
115
|
+
"",
|
|
116
|
+
].join("\n");
|
|
117
|
+
return {
|
|
118
|
+
"infrastructure/dependencies.ts": dependencies,
|
|
119
|
+
"infrastructure/bounded_context.ts": boundedContext,
|
|
120
|
+
"services/index.ts": services,
|
|
121
|
+
"index.ts": index,
|
|
122
|
+
};
|
|
123
|
+
}
|
package/dist/common/decimal.js
CHANGED
|
@@ -19,6 +19,12 @@ const TEN = 10n;
|
|
|
19
19
|
function pow10(exponent) {
|
|
20
20
|
return TEN ** BigInt(exponent);
|
|
21
21
|
}
|
|
22
|
+
/** How many digits after the point a result keeps: a whole number from 0 up. */
|
|
23
|
+
function decimalsGiven(decimals) {
|
|
24
|
+
if (!Number.isInteger(decimals) || decimals < 0)
|
|
25
|
+
throw new RangeError(`${decimals} decimals: a decimal keeps 0 or more whole digits after the point`);
|
|
26
|
+
return decimals;
|
|
27
|
+
}
|
|
22
28
|
/** `units / divisor` rounded half up, away from zero. */
|
|
23
29
|
function halfUp(units, divisor) {
|
|
24
30
|
const quotient = units / divisor;
|
|
@@ -97,6 +103,7 @@ export class Decimal {
|
|
|
97
103
|
}
|
|
98
104
|
/** This over `divisor`, with `decimals` digits after the point, rounded half up. */
|
|
99
105
|
dividedBy(divisor, decimals) {
|
|
106
|
+
decimalsGiven(decimals);
|
|
100
107
|
const by = Decimal.of(divisor);
|
|
101
108
|
if (by.units === 0n)
|
|
102
109
|
throw new RangeError("a decimal divided by zero");
|
|
@@ -106,6 +113,7 @@ export class Decimal {
|
|
|
106
113
|
}
|
|
107
114
|
/** With `decimals` digits after the point: rounded half up when it had more, padded when fewer. */
|
|
108
115
|
rounded(decimals) {
|
|
116
|
+
decimalsGiven(decimals);
|
|
109
117
|
if (decimals >= this.scale)
|
|
110
118
|
return Decimal.at(this.units * pow10(decimals - this.scale), decimals);
|
|
111
119
|
return Decimal.at(halfUp(this.units, pow10(this.scale - decimals)), decimals);
|
|
@@ -39,7 +39,11 @@ export type Rebuild<T> = {
|
|
|
39
39
|
* `null` in a list, as JSON does.
|
|
40
40
|
*/
|
|
41
41
|
export declare function valuesOf(value: unknown): JsonValue;
|
|
42
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* What `valuesOf` wrote, from JSON text or already parsed — each tagged bytes back as bytes. A
|
|
44
|
+
* string is JSON text: to read values that were written but never sent, go through `pack`
|
|
45
|
+
* (`readValues(pack(value))`), as the in-memory transport does.
|
|
46
|
+
*/
|
|
43
47
|
export declare function readValues(text: string | unknown): unknown;
|
|
44
48
|
/** `values` read as `of` — `Money`, a DTO's own reader — or as they are without one. */
|
|
45
49
|
export declare function rebuilt<T = unknown>(values: unknown, of?: Rebuild<T>): T;
|
|
@@ -54,8 +54,9 @@ function bytesOf(text) {
|
|
|
54
54
|
return bytes;
|
|
55
55
|
}
|
|
56
56
|
function isBytes(value) {
|
|
57
|
-
return
|
|
57
|
+
return value instanceof ArrayBuffer || ArrayBuffer.isView(value);
|
|
58
58
|
}
|
|
59
|
+
/** Any binary — a `Uint8Array`, another typed array, a `DataView`, a buffer — as its bytes. */
|
|
59
60
|
function asBytes(value) {
|
|
60
61
|
if (value instanceof Uint8Array)
|
|
61
62
|
return value;
|
|
@@ -85,8 +86,13 @@ function written(value, where, seen) {
|
|
|
85
86
|
seen.add(object);
|
|
86
87
|
try {
|
|
87
88
|
const own = object.toJSON;
|
|
88
|
-
if (typeof own === "function")
|
|
89
|
-
|
|
89
|
+
if (typeof own === "function") {
|
|
90
|
+
const said = own.call(object);
|
|
91
|
+
// A `toJSON` that answers the object itself says nothing new: it is written as an object.
|
|
92
|
+
// Still in `seen` while what it said is written: `{ self: this }` is a cycle, not a loop.
|
|
93
|
+
if (said !== object)
|
|
94
|
+
return written(said, where, seen);
|
|
95
|
+
}
|
|
90
96
|
if (object instanceof Map)
|
|
91
97
|
return Object.fromEntries([...object].flatMap(([key, one]) => {
|
|
92
98
|
const done = written(one, `${where}.${String(key)}`, seen);
|
|
@@ -96,6 +102,9 @@ function written(value, where, seen) {
|
|
|
96
102
|
return [...object].map((one, at) => written(one, `${where}[${at}]`, seen) ?? null);
|
|
97
103
|
const entries = [];
|
|
98
104
|
for (const [key, one] of Object.entries(object)) {
|
|
105
|
+
// Its own way to travel is not a value of it.
|
|
106
|
+
if (key === "toJSON" && typeof one === "function")
|
|
107
|
+
continue;
|
|
99
108
|
const done = written(one, `${where}.${key}`, seen);
|
|
100
109
|
if (done !== undefined)
|
|
101
110
|
entries.push([key, done]);
|
|
@@ -127,7 +136,11 @@ function walked(value) {
|
|
|
127
136
|
return restored(Object.fromEntries(Object.entries(value).map(([key, one]) => [key, walked(one)])));
|
|
128
137
|
return value;
|
|
129
138
|
}
|
|
130
|
-
/**
|
|
139
|
+
/**
|
|
140
|
+
* What `valuesOf` wrote, from JSON text or already parsed — each tagged bytes back as bytes. A
|
|
141
|
+
* string is JSON text: to read values that were written but never sent, go through `pack`
|
|
142
|
+
* (`readValues(pack(value))`), as the in-memory transport does.
|
|
143
|
+
*/
|
|
131
144
|
export function readValues(text) {
|
|
132
145
|
if (typeof text === "string")
|
|
133
146
|
return JSON.parse(text, (_key, value) => value !== null && typeof value === "object" && !Array.isArray(value)
|
package/dist/common/time.d.ts
CHANGED
|
@@ -33,7 +33,8 @@ export declare function wallTimeOf(instant: string, timeZone: string): string |
|
|
|
33
33
|
* first guess, and again at the answer when the guess fell on the other side of a change.
|
|
34
34
|
*/
|
|
35
35
|
export declare function instantOf(wall: string, timeZone: string): string | null;
|
|
36
|
-
/** A day of the calendar as the server writes it: `"2026-03-12"` — never an instant
|
|
36
|
+
/** A day of the calendar as the server writes it: `"2026-03-12"` — never an instant, never a day
|
|
37
|
+
* the calendar does not have (`"2026-02-30"`, which `Date` would roll into March). */
|
|
37
38
|
export declare function isDay(text: string): boolean;
|
|
38
39
|
/** The day it is at `instant` on the wall clock of `timeZone`: what «today» means for a company.
|
|
39
40
|
* `null` when it is not an instant. */
|
package/dist/common/time.js
CHANGED
|
@@ -57,6 +57,9 @@ const pad = (n) => String(n).padStart(2, "0");
|
|
|
57
57
|
/** `"2026-03-12T14:30:00Z"` → `"2026-03-12T10:30"` in `America/La_Paz`: the wall clock there,
|
|
58
58
|
* as a date-and-time control reads it. `null` when it is not an instant. */
|
|
59
59
|
export function wallTimeOf(instant, timeZone) {
|
|
60
|
+
// A day alone is not an instant: `Date` would read it as midnight in UTC, the day before here.
|
|
61
|
+
if (!/T\d{2}:\d{2}/.test(instant))
|
|
62
|
+
return null;
|
|
60
63
|
const at = Date.parse(instant);
|
|
61
64
|
if (Number.isNaN(at))
|
|
62
65
|
return null;
|
|
@@ -82,9 +85,13 @@ export function instantOf(wall, timeZone) {
|
|
|
82
85
|
return new Date(second).toISOString();
|
|
83
86
|
}
|
|
84
87
|
const DAY = /^\d{4}-\d{2}-\d{2}$/;
|
|
85
|
-
/** A day of the calendar as the server writes it: `"2026-03-12"` — never an instant
|
|
88
|
+
/** A day of the calendar as the server writes it: `"2026-03-12"` — never an instant, never a day
|
|
89
|
+
* the calendar does not have (`"2026-02-30"`, which `Date` would roll into March). */
|
|
86
90
|
export function isDay(text) {
|
|
87
|
-
|
|
91
|
+
if (!DAY.test(text))
|
|
92
|
+
return false;
|
|
93
|
+
const at = Date.parse(`${text}T00:00:00Z`);
|
|
94
|
+
return !Number.isNaN(at) && new Date(at).toISOString().slice(0, 10) === text;
|
|
88
95
|
}
|
|
89
96
|
/** The day it is at `instant` on the wall clock of `timeZone`: what «today» means for a company.
|
|
90
97
|
* `null` when it is not an instant. */
|
package/dist/context/codec.d.ts
CHANGED
|
@@ -13,6 +13,8 @@ import type { ContextValues } from "./store.js";
|
|
|
13
13
|
* context stays where it is. Reading never fails a context whole: text that is not a JSON object
|
|
14
14
|
* reads as an empty one. A project with a schema of its own writes its own codec over this port.
|
|
15
15
|
*/
|
|
16
|
+
/** The header the context travels in: the one sincpro_framework's entrances read. */
|
|
17
|
+
export declare const CONTEXT_HEADER = "sincpro-context";
|
|
16
18
|
export interface ContextCodec {
|
|
17
19
|
dumps(values: ContextValues): string;
|
|
18
20
|
loads(text: string): ContextValues;
|
package/dist/context/codec.js
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a context is written to text and read back: what a `ContextStore` that persists uses
|
|
3
|
+
* (`sessionStorage`, MMKV), and what travels to the server in `sincpro-context`.
|
|
4
|
+
*
|
|
5
|
+
* plainCodec.dumps({ tenant_id: "acme", company_id: 2, session: socket })
|
|
6
|
+
* → '{"tenant_id":"acme","company_id":2}'
|
|
7
|
+
* plainCodec.loads('{"tenant_id":"acme"}') → { tenant_id: "acme" }
|
|
8
|
+
*
|
|
9
|
+
* Context: sincpro_framework's `ContextCodec`, and its `PlainCodec` — the one for anything outside
|
|
10
|
+
* the codebase: another language, a browser, a phone. Only the travelling values go — text,
|
|
11
|
+
* numbers, booleans, `null`, and lists of them; an object, a function or a connection held in the
|
|
12
|
+
* context stays where it is. Reading never fails a context whole: text that is not a JSON object
|
|
13
|
+
* reads as an empty one. A project with a schema of its own writes its own codec over this port.
|
|
14
|
+
*/
|
|
15
|
+
/** The header the context travels in: the one sincpro_framework's entrances read. */
|
|
16
|
+
export const CONTEXT_HEADER = "sincpro-context";
|
|
1
17
|
function isPlain(value) {
|
|
2
18
|
return (value === null ||
|
|
3
19
|
typeof value === "string" ||
|
package/dist/index.d.ts
CHANGED
|
@@ -30,7 +30,7 @@ export { isEntityId, momentOf, newEntityId } from "./common/ids.js";
|
|
|
30
30
|
export { Money } from "./common/money.js";
|
|
31
31
|
export { BYTES, type JsonValue, pack, readValues, type Rebuild, rebuilt, unpack, valuesOf, } from "./common/serialization.js";
|
|
32
32
|
export { dayOf, installTimeZone, instantOf, isDay, timeZoneNow, wallTimeOf, } from "./common/time.js";
|
|
33
|
-
export { type ContextCodec, plainCodec, travelling, travels, } from "./context/codec.js";
|
|
33
|
+
export { CONTEXT_HEADER, type ContextCodec, plainCodec, travelling, travels, } from "./context/codec.js";
|
|
34
34
|
export { type Context } from "./context/context.js";
|
|
35
35
|
export { type ContextStore, type ContextValues, memoryContexts, } from "./context/store.js";
|
|
36
36
|
export { defineMeta, field, type FieldOptions, type MetaSpec, type Words, } from "./model/meta.js";
|
|
@@ -41,3 +41,5 @@ export { type BoundedContext, UseBoundedContext, } from "./services/bounded-cont
|
|
|
41
41
|
export { BIND, type Bindable, bindable, bindDependency, bindTransport, type Execution, headersOf, } from "./services/execution.js";
|
|
42
42
|
export { ApplicationService, Feature, type ServiceClasses, type ServiceMethods, type Services, } from "./services/feature.js";
|
|
43
43
|
export { configureProcess, outcomeOf, processContext, type ProcessOptions, } from "./services/process.js";
|
|
44
|
+
export { reportError } from "./services/report.js";
|
|
45
|
+
export { screensTransport } from "./services/screens.js";
|
package/dist/index.js
CHANGED
|
@@ -30,7 +30,7 @@ export { isEntityId, momentOf, newEntityId } from "./common/ids.js";
|
|
|
30
30
|
export { Money } from "./common/money.js";
|
|
31
31
|
export { BYTES, pack, readValues, rebuilt, unpack, valuesOf, } from "./common/serialization.js";
|
|
32
32
|
export { dayOf, installTimeZone, instantOf, isDay, timeZoneNow, wallTimeOf, } from "./common/time.js";
|
|
33
|
-
export { plainCodec, travelling, travels, } from "./context/codec.js";
|
|
33
|
+
export { CONTEXT_HEADER, plainCodec, travelling, travels, } from "./context/codec.js";
|
|
34
34
|
export {} from "./context/context.js";
|
|
35
35
|
export { memoryContexts, } from "./context/store.js";
|
|
36
36
|
export { defineMeta, field, } from "./model/meta.js";
|
|
@@ -41,3 +41,5 @@ export { UseBoundedContext, } from "./services/bounded-context.js";
|
|
|
41
41
|
export { BIND, bindable, bindDependency, bindTransport, headersOf, } from "./services/execution.js";
|
|
42
42
|
export { ApplicationService, Feature, } from "./services/feature.js";
|
|
43
43
|
export { configureProcess, outcomeOf, processContext, } from "./services/process.js";
|
|
44
|
+
export { reportError } from "./services/report.js";
|
|
45
|
+
export { screensTransport } from "./services/screens.js";
|
|
@@ -47,7 +47,8 @@ export interface Tracer {
|
|
|
47
47
|
}
|
|
48
48
|
/** What a report says about where the error happened. */
|
|
49
49
|
export type ErrorAbout = {
|
|
50
|
-
/** `<bounded context>.<Service>[.<method>]`: the use case's identity, as the backend's
|
|
50
|
+
/** `<bounded context>.<Service>[.<method>]`: the use case's identity, as the backend's — or,
|
|
51
|
+
* for what failed outside one (`reportError`), where it happened (`view project:issue/acme-3`). */
|
|
51
52
|
useCase: string;
|
|
52
53
|
/** The trace the error belongs to — the same id the server logged, when it got that far. */
|
|
53
54
|
traceId: string;
|
|
@@ -3,10 +3,11 @@ import { contextAt } from "../context/context.js";
|
|
|
3
3
|
import { bindDependency } from "./execution.js";
|
|
4
4
|
import { composes, isApplicationServiceClass, isFeatureClass, methodsOf, } from "./feature.js";
|
|
5
5
|
import { processContext, processNow, processStore, } from "./process.js";
|
|
6
|
+
import { firstReport } from "./report.js";
|
|
6
7
|
/** Names `project` answers itself: no service is registered under them. */
|
|
7
8
|
const RESERVED = new Set(["name", "context", "with", "scoped"]);
|
|
8
9
|
/** Names a run answers on `this` itself: no dependency is added under them. */
|
|
9
|
-
const ANSWERED = new Set(["context", "feature"]);
|
|
10
|
+
const ANSWERED = new Set(["context", "feature", "span"]);
|
|
10
11
|
export class UseBoundedContext {
|
|
11
12
|
name;
|
|
12
13
|
/** This bounded context's level of the context, saved in the process's store. */
|
|
@@ -142,6 +143,8 @@ function viewOf(target, binding, execution) {
|
|
|
142
143
|
get(held, property, receiver) {
|
|
143
144
|
if (property === "context")
|
|
144
145
|
return execution.context;
|
|
146
|
+
if (property === "span")
|
|
147
|
+
return execution.span;
|
|
145
148
|
if (property === "feature" && isComposer)
|
|
146
149
|
return (features ??= featuresOf(binding, execution));
|
|
147
150
|
if (typeof property === "string") {
|
|
@@ -191,20 +194,6 @@ function contextOf(binding) {
|
|
|
191
194
|
...binding.scope,
|
|
192
195
|
};
|
|
193
196
|
}
|
|
194
|
-
/**
|
|
195
|
-
* Whether this is the first run to report this error. A Feature that fails inside an
|
|
196
|
-
* ApplicationService throws through it: the error is reported once, by the run where it
|
|
197
|
-
* happened — its span is the one that says where — and every run around it still ends `failed`.
|
|
198
|
-
*/
|
|
199
|
-
const REPORTED = new WeakSet();
|
|
200
|
-
function firstReport(error) {
|
|
201
|
-
if (error === null || (typeof error !== "object" && typeof error !== "function"))
|
|
202
|
-
return true;
|
|
203
|
-
if (REPORTED.has(error))
|
|
204
|
-
return false;
|
|
205
|
-
REPORTED.add(error);
|
|
206
|
-
return true;
|
|
207
|
-
}
|
|
208
197
|
/** What watches a use case never changes what it answers: a tracer or reporter that throws is ignored. */
|
|
209
198
|
function quietly(work, otherwise) {
|
|
210
199
|
try {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { plainCodec, travels } from "../context/codec.js";
|
|
1
|
+
import { CONTEXT_HEADER, plainCodec, travels } from "../context/codec.js";
|
|
2
2
|
/**
|
|
3
3
|
* A dependency that has something to say about the execution it is used in: given that
|
|
4
4
|
* execution, it answers itself bound to it. The transport and every generated client are;
|
|
@@ -34,7 +34,7 @@ export function headersOf(execution) {
|
|
|
34
34
|
}
|
|
35
35
|
// Only what travels: an object or a function held in the context stays in this process.
|
|
36
36
|
if (Object.values(context).some(travels))
|
|
37
|
-
headers[
|
|
37
|
+
headers[CONTEXT_HEADER] = plainCodec.dumps(context);
|
|
38
38
|
return headers;
|
|
39
39
|
}
|
|
40
40
|
/**
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ContextValues } from "../context/store.js";
|
|
2
|
+
import type { Span } from "../observability/ports.js";
|
|
2
3
|
/**
|
|
3
4
|
* `Feature` and `ApplicationService`: the two kinds of service a bounded context holds, as in
|
|
4
5
|
* sincpro_framework. Each bounded context gives them its dependencies once, in its own base:
|
|
@@ -47,6 +48,8 @@ type Within<D extends object, C extends object> = Readonly<D> & {
|
|
|
47
48
|
* read once where the gesture started and the same for every Feature it runs.
|
|
48
49
|
*/
|
|
49
50
|
readonly context: Readonly<Partial<C>>;
|
|
51
|
+
/** This run's span: a use case says more about what it ran on (`this.span.set({ … })`). */
|
|
52
|
+
readonly span: Span;
|
|
50
53
|
};
|
|
51
54
|
/** Service classes by the name a bounded context gives them: `{ Issues, Notices }`. */
|
|
52
55
|
export type ServiceClasses = Readonly<Record<string, new () => object>>;
|
|
@@ -55,7 +58,7 @@ export type ServiceClasses = Readonly<Record<string, new () => object>>;
|
|
|
55
58
|
* methods, without its dependencies, its context or its `_` helpers.
|
|
56
59
|
*/
|
|
57
60
|
export type ServiceMethods<I, D> = {
|
|
58
|
-
readonly [M in keyof I as M extends keyof D | "context" | "feature" | `_${string}` ? never : I[M] extends (...args: never[]) => unknown ? M : never]: I[M];
|
|
61
|
+
readonly [M in keyof I as M extends keyof D | "context" | "feature" | "span" | `_${string}` ? never : I[M] extends (...args: never[]) => unknown ? M : never]: I[M];
|
|
59
62
|
};
|
|
60
63
|
/** The services of a set of classes, by their names. */
|
|
61
64
|
export type Services<S extends ServiceClasses, D> = {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare function firstReport(error: unknown): boolean;
|
|
2
|
+
/**
|
|
3
|
+
* Reports what nobody expected that is not a use case — a screen that broke while drawing, a
|
|
4
|
+
* subscriber, a worker — through the process's reporter, with the process's context.
|
|
5
|
+
*
|
|
6
|
+
* <Composer onViewError={(error, { resource }) => reportError(error, `view ${formatRef(resource)}`)} />
|
|
7
|
+
* ErrorUtils.setGlobalHandler((error) => reportError(error, "react-native global"));
|
|
8
|
+
*
|
|
9
|
+
* Context: a use case reports its own failures (the bounded context does it for it); what fails
|
|
10
|
+
* outside one would only reach a console. `where` takes the place of the use case's name in the
|
|
11
|
+
* report. Only what failed is reported (`outcomeOf`: a refusal the server explained or an abort is
|
|
12
|
+
* not); an error a use case already reported is not reported again; and a reporter that throws is
|
|
13
|
+
* ignored: telling about a failure never becomes a second one.
|
|
14
|
+
*/
|
|
15
|
+
export declare function reportError(error: unknown, where: string): void;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { newEntityId } from "../common/ids.js";
|
|
2
|
+
import { processContext, processNow } from "./process.js";
|
|
3
|
+
/**
|
|
4
|
+
* Whether this is the first time this error is reported. A Feature that fails inside an
|
|
5
|
+
* ApplicationService throws through it, and a screen that draws the failure may break on it:
|
|
6
|
+
* the error is reported once, by the first that saw it — its span is the one that says where.
|
|
7
|
+
*/
|
|
8
|
+
const REPORTED = new WeakSet();
|
|
9
|
+
export function firstReport(error) {
|
|
10
|
+
if (error === null || (typeof error !== "object" && typeof error !== "function"))
|
|
11
|
+
return true;
|
|
12
|
+
if (REPORTED.has(error))
|
|
13
|
+
return false;
|
|
14
|
+
REPORTED.add(error);
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Reports what nobody expected that is not a use case — a screen that broke while drawing, a
|
|
19
|
+
* subscriber, a worker — through the process's reporter, with the process's context.
|
|
20
|
+
*
|
|
21
|
+
* <Composer onViewError={(error, { resource }) => reportError(error, `view ${formatRef(resource)}`)} />
|
|
22
|
+
* ErrorUtils.setGlobalHandler((error) => reportError(error, "react-native global"));
|
|
23
|
+
*
|
|
24
|
+
* Context: a use case reports its own failures (the bounded context does it for it); what fails
|
|
25
|
+
* outside one would only reach a console. `where` takes the place of the use case's name in the
|
|
26
|
+
* report. Only what failed is reported (`outcomeOf`: a refusal the server explained or an abort is
|
|
27
|
+
* not); an error a use case already reported is not reported again; and a reporter that throws is
|
|
28
|
+
* ignored: telling about a failure never becomes a second one.
|
|
29
|
+
*/
|
|
30
|
+
export function reportError(error, where) {
|
|
31
|
+
const settings = processNow();
|
|
32
|
+
try {
|
|
33
|
+
// What the server explained, or what the caller stopped waiting for, is not a fault here either.
|
|
34
|
+
if (settings.outcomeOf(error) !== "failed")
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// An outcome that cannot be told is a failure.
|
|
39
|
+
}
|
|
40
|
+
if (!firstReport(error))
|
|
41
|
+
return;
|
|
42
|
+
try {
|
|
43
|
+
settings.reporter?.report(error, {
|
|
44
|
+
useCase: where,
|
|
45
|
+
traceId: "",
|
|
46
|
+
executionId: newEntityId(),
|
|
47
|
+
context: processContext.values,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// Ignored, as a reporter that fails inside a use case is.
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { UseBoundedContext } from "./bounded-context.js";
|
|
2
|
+
import { Feature } from "./feature.js";
|
|
3
|
+
/**
|
|
4
|
+
* What a screen reads of an entity — a page, a record, a read of its own: sincpro_framework's
|
|
5
|
+
* built-in `EntityReads`, on the client.
|
|
6
|
+
*/
|
|
7
|
+
class EntityReads extends Feature {
|
|
8
|
+
list(route, criteria, signal, options) {
|
|
9
|
+
this.span.set({ "http.route": route });
|
|
10
|
+
return this.transport.list(route, criteria, signal, options);
|
|
11
|
+
}
|
|
12
|
+
one(route, signal, options) {
|
|
13
|
+
this.span.set({ "http.route": route });
|
|
14
|
+
return this.transport.one(route, signal, options);
|
|
15
|
+
}
|
|
16
|
+
read(route, signal, options) {
|
|
17
|
+
this.span.set({ "http.route": route });
|
|
18
|
+
const read = this.transport.read;
|
|
19
|
+
if (read === undefined)
|
|
20
|
+
return Promise.reject(new Error(`this transport cannot read ${route}: it has no read()`));
|
|
21
|
+
return read.call(this.transport, route, signal, options);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** What a screen writes — a save, a draft, a preview, a Command: each request a use case. */
|
|
25
|
+
class EntityWrites extends Feature {
|
|
26
|
+
send(method, route, body, signal, options) {
|
|
27
|
+
this.span.set({ "http.method": method, "http.route": route });
|
|
28
|
+
return this.transport.send(method, route, body, signal, options);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The transport, every call a use case of a bounded context of its own — `screens` unless named:
|
|
33
|
+
* what a platform hands the screens it draws.
|
|
34
|
+
*
|
|
35
|
+
* const screens = screensTransport(restTransport({ base: "/v1" }));
|
|
36
|
+
* await screens.list("/facturas", criteria); // span screens/EntityReads.list
|
|
37
|
+
*
|
|
38
|
+
* Context: a use case is traced, carries its context and reports what nobody expected; a list a
|
|
39
|
+
* screen reads through the transport was none of that — no span, no `traceparent` (the backend's
|
|
40
|
+
* trace started there, with no client in it), no company, and a 500 reported to nobody. Here
|
|
41
|
+
* each read and write runs as a use case of `screens` — `EntityReads.list`, `.one`, `.read`,
|
|
42
|
+
* `EntityWrites.send` —, so it gets all three the way any other does: the span (its route said
|
|
43
|
+
* on it), the trace and the process's context on the request, and a report only when it failed
|
|
44
|
+
* (a 4xx the server explained is the screen's to show). A bounded context's own dependencies are
|
|
45
|
+
* never given this: there its use case is the one that runs.
|
|
46
|
+
*/
|
|
47
|
+
/** The transports this made: given one again, it is answered as it is — wrapped twice, each
|
|
48
|
+
* request would be two unrelated use cases, and the server would see only the outer's trace. */
|
|
49
|
+
const MADE = new WeakSet();
|
|
50
|
+
export function screensTransport(transport, name = "screens") {
|
|
51
|
+
if (MADE.has(transport))
|
|
52
|
+
return transport;
|
|
53
|
+
const screens = new UseBoundedContext(name)
|
|
54
|
+
.addDependency("transport", transport)
|
|
55
|
+
.register({ features: { EntityReads, EntityWrites } });
|
|
56
|
+
const reads = screens.EntityReads;
|
|
57
|
+
const writes = screens.EntityWrites;
|
|
58
|
+
const through = {
|
|
59
|
+
list: (route, criteria, signal, options) => reads.list(route, criteria, signal, options),
|
|
60
|
+
one: (route, signal, options) => reads.one(route, signal, options),
|
|
61
|
+
send: (method, route, body, signal, options) => writes.send(method, route, body, signal, options),
|
|
62
|
+
};
|
|
63
|
+
if (transport.read !== undefined)
|
|
64
|
+
through.read = (route, signal, options) => reads.read(route, signal, options);
|
|
65
|
+
MADE.add(through);
|
|
66
|
+
return through;
|
|
67
|
+
}
|