@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.
@@ -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
+ }
@@ -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
- /** What `valuesOf` wrote, from JSON text or already parsed — each tagged bytes back as bytes. */
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 (value instanceof Uint8Array || value instanceof ArrayBuffer || value instanceof DataView);
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
- return written(own.call(object), where, seen);
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
- /** What `valuesOf` wrote, from JSON text or already parsed — each tagged bytes back as bytes. */
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)
@@ -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. */
@@ -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
- return DAY.test(text) && !Number.isNaN(Date.parse(`${text}T00:00:00Z`));
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. */
@@ -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;
@@ -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["sincpro-context"] = plainCodec.dumps(context);
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,2 @@
1
+ import type { Transport } from "../client/transport.js";
2
+ export declare function screensTransport(transport: Transport, name?: string): Transport;
@@ -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
+ }