@sincpro/client-framework 0.1.0 → 0.3.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 +46 -22
- package/MANIFESTO.md +42 -25
- package/README.md +289 -32
- 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/memory.js +13 -6
- package/dist/context/context.d.ts +45 -0
- package/dist/context/context.js +70 -0
- package/dist/context/store.d.ts +51 -0
- package/dist/context/store.js +59 -0
- package/dist/index.d.ts +8 -3
- package/dist/index.js +8 -3
- package/dist/observability/ports.d.ts +2 -2
- package/dist/services/bounded-context.d.ts +79 -0
- package/dist/services/bounded-context.js +272 -0
- package/dist/services/execution.d.ts +6 -6
- package/dist/services/execution.js +2 -2
- package/dist/services/feature.d.ts +70 -48
- package/dist/services/feature.js +57 -177
- package/dist/services/process.d.ts +60 -0
- package/dist/services/process.js +57 -0
- package/dist/testing/index.d.ts +3 -3
- package/package.json +19 -3
- package/dist/services/runtime.d.ts +0 -48
- package/dist/services/runtime.js +0 -30
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { isSpanContextValid, ROOT_CONTEXT, SpanKind, SpanStatusCode, trace, TraceFlags, } from "@opentelemetry/api";
|
|
2
|
+
/**
|
|
3
|
+
* The `Tracer` port over OpenTelemetry's API: every use case a span of whatever provider the
|
|
4
|
+
* platform registered.
|
|
5
|
+
*
|
|
6
|
+
* // a browser (@sincpro/web-framework/telemetry does this), Node, React Native…
|
|
7
|
+
* const tracer = openTelemetryTracer(provider.getTracer("forge-web"));
|
|
8
|
+
* configureProcess({ tracer });
|
|
9
|
+
*
|
|
10
|
+
* Context: only `@opentelemetry/api`, which runs the same on every platform — the provider, its
|
|
11
|
+
* exporter and where it sends are the platform's (the browser's OTLP over fetch, a phone's
|
|
12
|
+
* queue, a server's SDK). An optional entry with an optional peer: an application that does not
|
|
13
|
+
* import it installs nothing and ships nothing.
|
|
14
|
+
*
|
|
15
|
+
* The parent is given, never read from OpenTelemetry's context: a run carries its own execution
|
|
16
|
+
* (`Execution`), so two use cases at once never take each other's span, and no context manager
|
|
17
|
+
* (Zone.js) is needed. A span nothing records (a sampler said no, no provider registered)
|
|
18
|
+
* answers no ids — so no `traceparent` claims a trace that does not exist.
|
|
19
|
+
*/
|
|
20
|
+
export function openTelemetryTracer(tracer) {
|
|
21
|
+
return {
|
|
22
|
+
start(name, options) {
|
|
23
|
+
const parent = options.parent;
|
|
24
|
+
const parentContext = parent !== undefined && parent.traceId !== "" && parent.spanId !== ""
|
|
25
|
+
? trace.setSpanContext(ROOT_CONTEXT, {
|
|
26
|
+
traceId: parent.traceId,
|
|
27
|
+
spanId: parent.spanId,
|
|
28
|
+
traceFlags: parent.sampled ? TraceFlags.SAMPLED : TraceFlags.NONE,
|
|
29
|
+
isRemote: false,
|
|
30
|
+
})
|
|
31
|
+
: ROOT_CONTEXT;
|
|
32
|
+
const span = tracer.startSpan(name, {
|
|
33
|
+
kind: SpanKind.INTERNAL,
|
|
34
|
+
...(options.attributes ? { attributes: options.attributes } : {}),
|
|
35
|
+
}, parentContext);
|
|
36
|
+
const own = span.spanContext();
|
|
37
|
+
const valid = isSpanContextValid(own);
|
|
38
|
+
const state = own.traceState?.serialize();
|
|
39
|
+
return {
|
|
40
|
+
traceId: valid ? own.traceId : "",
|
|
41
|
+
spanId: valid ? own.spanId : "",
|
|
42
|
+
sampled: valid && (own.traceFlags & TraceFlags.SAMPLED) === TraceFlags.SAMPLED,
|
|
43
|
+
...(state ? { state } : {}),
|
|
44
|
+
set: (attributes) => void span.setAttributes(attributes),
|
|
45
|
+
end: (outcome, error) => close(span, outcome, error),
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* How the use case ended, said the way OpenTelemetry reads it: `failed` is an error with its
|
|
52
|
+
* exception; `refused` and `aborted` are expected answers — said in `sincpro.outcome`, never as
|
|
53
|
+
* an error, the way the backend leaves a refusal out of its error rate.
|
|
54
|
+
*/
|
|
55
|
+
function close(span, outcome, error) {
|
|
56
|
+
span.setAttribute("sincpro.outcome", outcome);
|
|
57
|
+
if (outcome === "failed") {
|
|
58
|
+
if (error instanceof Error)
|
|
59
|
+
span.recordException(error);
|
|
60
|
+
span.setStatus({
|
|
61
|
+
code: SpanStatusCode.ERROR,
|
|
62
|
+
...(error instanceof Error ? { message: error.message } : {}),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
else if (outcome === "ok")
|
|
66
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
67
|
+
span.end();
|
|
68
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ErrorReporter } from "../observability/ports.js";
|
|
2
|
+
/**
|
|
3
|
+
* What this adapter needs of a Sentry-compatible client: its `captureException`. A browser's
|
|
4
|
+
* `BrowserClient`, Node's, React Native's — and GlitchTip, which speaks Sentry's protocol.
|
|
5
|
+
*/
|
|
6
|
+
export type ExceptionClient = {
|
|
7
|
+
captureException(error: unknown, hint?: {
|
|
8
|
+
captureContext?: {
|
|
9
|
+
tags?: Record<string, string>;
|
|
10
|
+
contexts?: Record<string, Record<string, unknown>>;
|
|
11
|
+
};
|
|
12
|
+
}): unknown;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* The `ErrorReporter` port over a Sentry-compatible client: an unexpected error reported with the
|
|
16
|
+
* use case it happened in, its trace — the id the backend logged too — and its context.
|
|
17
|
+
*
|
|
18
|
+
* // @sincpro/web-framework/errors builds the browser's client with GlitchTip's DSN
|
|
19
|
+
* const reporter = sentryReporter(client);
|
|
20
|
+
* configureProcess({ reporter });
|
|
21
|
+
*
|
|
22
|
+
* Context: it imports nothing — it takes the client the platform made (with its DSN, release
|
|
23
|
+
* and data rules), so it costs no dependency and works on every platform. What is reported is
|
|
24
|
+
* already decided by the process (`outcomeOf`: only `failed`); this only says where it happened.
|
|
25
|
+
* The context goes as a context, not as tags: a tag is indexed and searched, and an execution's
|
|
26
|
+
* values are not the place for that.
|
|
27
|
+
*/
|
|
28
|
+
export declare function sentryReporter(client: ExceptionClient): ErrorReporter;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `ErrorReporter` port over a Sentry-compatible client: an unexpected error reported with the
|
|
3
|
+
* use case it happened in, its trace — the id the backend logged too — and its context.
|
|
4
|
+
*
|
|
5
|
+
* // @sincpro/web-framework/errors builds the browser's client with GlitchTip's DSN
|
|
6
|
+
* const reporter = sentryReporter(client);
|
|
7
|
+
* configureProcess({ reporter });
|
|
8
|
+
*
|
|
9
|
+
* Context: it imports nothing — it takes the client the platform made (with its DSN, release
|
|
10
|
+
* and data rules), so it costs no dependency and works on every platform. What is reported is
|
|
11
|
+
* already decided by the process (`outcomeOf`: only `failed`); this only says where it happened.
|
|
12
|
+
* The context goes as a context, not as tags: a tag is indexed and searched, and an execution's
|
|
13
|
+
* values are not the place for that.
|
|
14
|
+
*/
|
|
15
|
+
export function sentryReporter(client) {
|
|
16
|
+
return {
|
|
17
|
+
report(error, about) {
|
|
18
|
+
client.captureException(error, {
|
|
19
|
+
captureContext: {
|
|
20
|
+
tags: {
|
|
21
|
+
"sincpro.use_case": about.useCase,
|
|
22
|
+
...(about.traceId === "" ? {} : { trace_id: about.traceId }),
|
|
23
|
+
},
|
|
24
|
+
contexts: {
|
|
25
|
+
sincpro: {
|
|
26
|
+
use_case: about.useCase,
|
|
27
|
+
execution_id: about.executionId,
|
|
28
|
+
...(about.traceId === "" ? {} : { trace_id: about.traceId }),
|
|
29
|
+
context: about.context,
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
package/dist/client/memory.js
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { TransportError, } from "./transport.js";
|
|
2
2
|
import { answer, answerGroups } from "@sincpro/criteria/engine";
|
|
3
|
+
/**
|
|
4
|
+
* A deep copy, as over a wire: `structuredClone` where the platform has it, a JSON round trip
|
|
5
|
+
* where it does not (some React Native engines) — the rows a fake server holds are plain JSON.
|
|
6
|
+
*/
|
|
7
|
+
const copyOf = typeof globalThis.structuredClone === "function"
|
|
8
|
+
? (value) => globalThis.structuredClone(value)
|
|
9
|
+
: (value) => (value === undefined ? value : JSON.parse(JSON.stringify(value)));
|
|
3
10
|
function wait(ms, signal) {
|
|
4
11
|
if (ms <= 0)
|
|
5
12
|
return Promise.resolve();
|
|
@@ -46,7 +53,7 @@ export function memoryTransport(tables, options = {}) {
|
|
|
46
53
|
? answerGroups(table.rows, criteria, table.meta)
|
|
47
54
|
: answer(table.rows, criteria, table.meta);
|
|
48
55
|
// A copy, as over a wire: a screen holding a row must not see the server change it.
|
|
49
|
-
return { ...page, rows:
|
|
56
|
+
return { ...page, rows: copyOf(page.rows) };
|
|
50
57
|
},
|
|
51
58
|
async one(route, signal) {
|
|
52
59
|
await check(route, signal);
|
|
@@ -66,7 +73,7 @@ export function memoryTransport(tables, options = {}) {
|
|
|
66
73
|
detail: `${table.meta.aggregate} ${id} does not exist.`,
|
|
67
74
|
});
|
|
68
75
|
return {
|
|
69
|
-
record:
|
|
76
|
+
record: copyOf(record),
|
|
70
77
|
meta: table.meta,
|
|
71
78
|
dropped: [],
|
|
72
79
|
draft: options.draft?.(route) ?? null,
|
|
@@ -78,7 +85,7 @@ export function memoryTransport(tables, options = {}) {
|
|
|
78
85
|
// sincpro_framework's `@idempotency` does with a repeated `Idempotency-Key`.
|
|
79
86
|
const key = sending?.idempotencyKey;
|
|
80
87
|
if (key !== undefined && answered.has(key))
|
|
81
|
-
return
|
|
88
|
+
return copyOf(answered.get(key));
|
|
82
89
|
if (options.handle === undefined)
|
|
83
90
|
throw new TransportError(route, 404, {
|
|
84
91
|
kind: "not_found",
|
|
@@ -90,9 +97,9 @@ export function memoryTransport(tables, options = {}) {
|
|
|
90
97
|
detail: `Nothing answers ${method} ${route}.`,
|
|
91
98
|
});
|
|
92
99
|
// A deep copy: what the caller sent is not what the server keeps.
|
|
93
|
-
const answer = await options.handle(method, route,
|
|
100
|
+
const answer = await options.handle(method, route, copyOf(body));
|
|
94
101
|
if (key !== undefined)
|
|
95
|
-
answered.set(key,
|
|
102
|
+
answered.set(key, copyOf(answer));
|
|
96
103
|
return answer;
|
|
97
104
|
},
|
|
98
105
|
...(options.read === undefined
|
|
@@ -101,7 +108,7 @@ export function memoryTransport(tables, options = {}) {
|
|
|
101
108
|
async read(route, signal) {
|
|
102
109
|
await check(route, signal);
|
|
103
110
|
// A copy, as every answer: over a wire nobody holds the server's object.
|
|
104
|
-
return
|
|
111
|
+
return copyOf(await options.read(route));
|
|
105
112
|
},
|
|
106
113
|
}),
|
|
107
114
|
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ContextStore, ContextValues } from "./store.js";
|
|
2
|
+
/**
|
|
3
|
+
* One level of the context: the process's (`processContext`) or a bounded context's
|
|
4
|
+
* (`project.context`), read and written the same way on every platform.
|
|
5
|
+
*
|
|
6
|
+
* processContext.set({ tenant_id: "acme" }); the company changed — every screen hears it
|
|
7
|
+
* processContext.get("tenant_id") → "acme"
|
|
8
|
+
* project.context.set({ warehouse_id: 4 }); only what `project` runs
|
|
9
|
+
* const stop = processContext.watch(() => redraw());
|
|
10
|
+
*
|
|
11
|
+
* Context: sincpro_framework reads its context from a tree — the process, the bounded context,
|
|
12
|
+
* the scope, the execution — and the nearest wins. A client has the same levels: a use case runs
|
|
13
|
+
* with the process's values, under them its bounded context's, under them a scope's
|
|
14
|
+
* (`project.scoped({...})`), read once where the gesture starts and inherited by every Feature
|
|
15
|
+
* it runs. That snapshot is `this.context` and what travels to the server in `sincpro-context`.
|
|
16
|
+
*
|
|
17
|
+
* Where the values live is the platform's (`ContextStore`): this is only the door. `values` is
|
|
18
|
+
* the same object until something changes it, so a screen can hand it straight to React's
|
|
19
|
+
* `useSyncExternalStore`, and `watch` is bound — pass it as it is.
|
|
20
|
+
*/
|
|
21
|
+
export type Context<C extends object = ContextValues> = {
|
|
22
|
+
/** Its values now: the same object until they change. Never change it — `set` does. */
|
|
23
|
+
readonly values: Readonly<Partial<C>>;
|
|
24
|
+
get<K extends keyof C & string>(name: K): C[K] | undefined;
|
|
25
|
+
/** Adds or replaces these names; the others stay. */
|
|
26
|
+
set(values: Partial<C>): void;
|
|
27
|
+
/** Removes these names — every name when none is given. */
|
|
28
|
+
clear(...names: (keyof C & string)[]): void;
|
|
29
|
+
/** Calls `listener` whenever its values change; answers the way to stop. */
|
|
30
|
+
watch(listener: () => void): () => void;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Where a level finds its store: the one in use now, and word when another replaces it — the
|
|
34
|
+
* process's, which `configureProcess` may change after a screen started listening.
|
|
35
|
+
*/
|
|
36
|
+
export type StoreSource = {
|
|
37
|
+
current(): ContextStore;
|
|
38
|
+
replaced(listener: () => void): () => void;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* The level saved under `key` in whatever store `source` holds now — asked again on every call,
|
|
42
|
+
* so a store the process configures after this level was made is the one used, and whoever
|
|
43
|
+
* listens moves to it and hears the change.
|
|
44
|
+
*/
|
|
45
|
+
export declare function contextAt<C extends object = ContextValues>(key: string, source: StoreSource): Context<C>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const NOTHING = {};
|
|
2
|
+
/**
|
|
3
|
+
* The level saved under `key` in whatever store `source` holds now — asked again on every call,
|
|
4
|
+
* so a store the process configures after this level was made is the one used, and whoever
|
|
5
|
+
* listens moves to it and hears the change.
|
|
6
|
+
*/
|
|
7
|
+
export function contextAt(key, source) {
|
|
8
|
+
const storeOf = () => source.current();
|
|
9
|
+
// The values last read, by store and version: a store that answers a new object on every
|
|
10
|
+
// `load` still gives one object per change.
|
|
11
|
+
let seen;
|
|
12
|
+
// Who listens when the store cannot say it changed (`watch` is optional).
|
|
13
|
+
const listeners = new Set();
|
|
14
|
+
const read = () => {
|
|
15
|
+
const store = storeOf();
|
|
16
|
+
const version = store.version(key);
|
|
17
|
+
if (seen?.store !== store || seen.version !== version)
|
|
18
|
+
seen = { store, version, values: store.load(key) ?? NOTHING };
|
|
19
|
+
return seen.values;
|
|
20
|
+
};
|
|
21
|
+
const write = (values) => {
|
|
22
|
+
const store = storeOf();
|
|
23
|
+
if (values === undefined)
|
|
24
|
+
store.delete(key);
|
|
25
|
+
else
|
|
26
|
+
store.save(key, values);
|
|
27
|
+
if (store.watch === undefined)
|
|
28
|
+
for (const listener of [...listeners])
|
|
29
|
+
listener();
|
|
30
|
+
};
|
|
31
|
+
return {
|
|
32
|
+
get values() {
|
|
33
|
+
return read();
|
|
34
|
+
},
|
|
35
|
+
get: (name) => read()[name],
|
|
36
|
+
set: (values) => write({ ...read(), ...values }),
|
|
37
|
+
clear(...names) {
|
|
38
|
+
if (names.length === 0)
|
|
39
|
+
return write(undefined);
|
|
40
|
+
const kept = { ...read() };
|
|
41
|
+
for (const name of names)
|
|
42
|
+
delete kept[name];
|
|
43
|
+
write(kept);
|
|
44
|
+
},
|
|
45
|
+
watch(listener) {
|
|
46
|
+
let watching;
|
|
47
|
+
let unwatch;
|
|
48
|
+
const follow = () => {
|
|
49
|
+
const store = storeOf();
|
|
50
|
+
if (store === watching)
|
|
51
|
+
return;
|
|
52
|
+
unwatch?.();
|
|
53
|
+
watching = store;
|
|
54
|
+
unwatch = store.watch?.(key, listener);
|
|
55
|
+
};
|
|
56
|
+
follow();
|
|
57
|
+
listeners.add(listener);
|
|
58
|
+
// Another store is other values: listen to it, and say so.
|
|
59
|
+
const unreplaced = source.replaced(() => {
|
|
60
|
+
follow();
|
|
61
|
+
listener();
|
|
62
|
+
});
|
|
63
|
+
return () => {
|
|
64
|
+
unwatch?.();
|
|
65
|
+
unreplaced();
|
|
66
|
+
listeners.delete(listener);
|
|
67
|
+
};
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ContextStore`: where the context lives — the port each platform implements with its own
|
|
3
|
+
* technology, so every use case reads the same context the same way.
|
|
4
|
+
*
|
|
5
|
+
* store.save("process", { tenant_id: "acme", lang: "es" });
|
|
6
|
+
* store.load("process") → { tenant_id: "acme", lang: "es" } undefined when never saved
|
|
7
|
+
* store.version("process") → 1 0 when never saved
|
|
8
|
+
* store.watch?.("process", () => …) a change anywhere
|
|
9
|
+
*
|
|
10
|
+
* Context: sincpro_framework's `ContextStore` with the same four methods — the process's memory by
|
|
11
|
+
* default, a Redis when a project says so — and one more, optional, a client needs: `watch`, so
|
|
12
|
+
* a screen redraws when the tenant changes. The store never decides what the context is: it keeps
|
|
13
|
+
* values under a key (`process`, `bounded_context:<name>`), and `Context` reads and writes them.
|
|
14
|
+
*
|
|
15
|
+
* One per platform, chosen by the application (`configureProcess({ context })`):
|
|
16
|
+
*
|
|
17
|
+
* - `memoryContexts()` — this package's default, everywhere.
|
|
18
|
+
* - a browser: the same, or one over `sessionStorage` to survive a reload, or over the screens'
|
|
19
|
+
* own store (Zustand, Redux) so they share one source.
|
|
20
|
+
* - a phone: MMKV (synchronous), or memory hydrated from AsyncStorage at start and written through.
|
|
21
|
+
* - a server that renders: one over `AsyncLocalStorage`, so two requests never see each other's.
|
|
22
|
+
*
|
|
23
|
+
* Reads are synchronous on purpose: a use case reads its context where a gesture starts, and an
|
|
24
|
+
* asynchronous store would make every use case wait for it.
|
|
25
|
+
*/
|
|
26
|
+
/** What a context holds: plain values, the way JSON carries them to the server. */
|
|
27
|
+
export type ContextValues = Readonly<Record<string, unknown>>;
|
|
28
|
+
export interface ContextStore {
|
|
29
|
+
/** What `key` holds, or `undefined` when nothing was saved there. */
|
|
30
|
+
load(key: string): ContextValues | undefined;
|
|
31
|
+
/** `values` under `key`, replacing what was there. Each save moves the key's version. */
|
|
32
|
+
save(key: string, values: ContextValues): void;
|
|
33
|
+
/** `key` is gone. Its version moves too. */
|
|
34
|
+
delete(key: string): void;
|
|
35
|
+
/**
|
|
36
|
+
* How many times `key` changed — what a reader compares to know it changed. `load` may answer
|
|
37
|
+
* a new object each time (a store that parses JSON does); the version is what says it is new.
|
|
38
|
+
*/
|
|
39
|
+
version(key: string): number;
|
|
40
|
+
/**
|
|
41
|
+
* Calls `listener` whenever `key` changes, from here or from elsewhere (another tab, a native
|
|
42
|
+
* module); answers the way to stop. Optional: without it, only the changes made through this
|
|
43
|
+
* package's `Context` are heard.
|
|
44
|
+
*/
|
|
45
|
+
watch?(key: string, listener: () => void): () => void;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The context in this process's memory: the default, on every platform. A test gets a fresh one
|
|
49
|
+
* from `configureProcess()`.
|
|
50
|
+
*/
|
|
51
|
+
export declare function memoryContexts(): ContextStore;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ContextStore`: where the context lives — the port each platform implements with its own
|
|
3
|
+
* technology, so every use case reads the same context the same way.
|
|
4
|
+
*
|
|
5
|
+
* store.save("process", { tenant_id: "acme", lang: "es" });
|
|
6
|
+
* store.load("process") → { tenant_id: "acme", lang: "es" } undefined when never saved
|
|
7
|
+
* store.version("process") → 1 0 when never saved
|
|
8
|
+
* store.watch?.("process", () => …) a change anywhere
|
|
9
|
+
*
|
|
10
|
+
* Context: sincpro_framework's `ContextStore` with the same four methods — the process's memory by
|
|
11
|
+
* default, a Redis when a project says so — and one more, optional, a client needs: `watch`, so
|
|
12
|
+
* a screen redraws when the tenant changes. The store never decides what the context is: it keeps
|
|
13
|
+
* values under a key (`process`, `bounded_context:<name>`), and `Context` reads and writes them.
|
|
14
|
+
*
|
|
15
|
+
* One per platform, chosen by the application (`configureProcess({ context })`):
|
|
16
|
+
*
|
|
17
|
+
* - `memoryContexts()` — this package's default, everywhere.
|
|
18
|
+
* - a browser: the same, or one over `sessionStorage` to survive a reload, or over the screens'
|
|
19
|
+
* own store (Zustand, Redux) so they share one source.
|
|
20
|
+
* - a phone: MMKV (synchronous), or memory hydrated from AsyncStorage at start and written through.
|
|
21
|
+
* - a server that renders: one over `AsyncLocalStorage`, so two requests never see each other's.
|
|
22
|
+
*
|
|
23
|
+
* Reads are synchronous on purpose: a use case reads its context where a gesture starts, and an
|
|
24
|
+
* asynchronous store would make every use case wait for it.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* The context in this process's memory: the default, on every platform. A test gets a fresh one
|
|
28
|
+
* from `configureProcess()`.
|
|
29
|
+
*/
|
|
30
|
+
export function memoryContexts() {
|
|
31
|
+
const held = new Map();
|
|
32
|
+
const versions = new Map();
|
|
33
|
+
const listeners = new Map();
|
|
34
|
+
const changed = (key) => {
|
|
35
|
+
versions.set(key, (versions.get(key) ?? 0) + 1);
|
|
36
|
+
for (const listener of [...(listeners.get(key) ?? [])])
|
|
37
|
+
listener();
|
|
38
|
+
};
|
|
39
|
+
return {
|
|
40
|
+
load: (key) => held.get(key),
|
|
41
|
+
save(key, values) {
|
|
42
|
+
// A copy: whoever handed the values may keep changing their object.
|
|
43
|
+
held.set(key, { ...values });
|
|
44
|
+
changed(key);
|
|
45
|
+
},
|
|
46
|
+
delete(key) {
|
|
47
|
+
if (!held.delete(key))
|
|
48
|
+
return;
|
|
49
|
+
changed(key);
|
|
50
|
+
},
|
|
51
|
+
version: (key) => versions.get(key) ?? 0,
|
|
52
|
+
watch(key, listener) {
|
|
53
|
+
const own = listeners.get(key) ?? new Set();
|
|
54
|
+
listeners.set(key, own);
|
|
55
|
+
own.add(listener);
|
|
56
|
+
return () => void own.delete(listener);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
* client/ the seam with a server: the transport and its REST and in-memory implementations,
|
|
10
10
|
* entities with typed commands, the service's client contract, client-side ids
|
|
11
11
|
* model/ model definitions: written by hand, read from a JSON Schema, kept once known
|
|
12
|
-
* services/
|
|
12
|
+
* services/ the bounded context (`UseBoundedContext`), its `Feature` and `ApplicationService`,
|
|
13
|
+
* the process they run in (`configureProcess`), what each run carries
|
|
14
|
+
* context/ the context: one door (`Context`) over the store each platform plugs
|
|
13
15
|
* observability/ the ports a platform plugs: a tracer and an error reporter
|
|
14
16
|
*
|
|
15
17
|
* The generator is `@sincpro/client-framework/codegen`, and the in-memory tracer and reporter
|
|
@@ -22,10 +24,13 @@ export { type MemoryOptions, type MemoryTable, memoryTransport, } from "./client
|
|
|
22
24
|
export { type RestOptions, restTransport, urlOf, type VersionDrift, } from "./client/rest.js";
|
|
23
25
|
export { type RetryKeys, retryKeys, unanswered, } from "./client/retry.js";
|
|
24
26
|
export { type CallOptions, type Draft, type Method, type One, readRoute, type SendOptions, type Transport, TransportError, } from "./client/transport.js";
|
|
27
|
+
export { type Context } from "./context/context.js";
|
|
28
|
+
export { type ContextStore, type ContextValues, memoryContexts, } from "./context/store.js";
|
|
25
29
|
export { defineMeta, field, type FieldOptions, type MetaSpec, type Words, } from "./model/meta.js";
|
|
26
30
|
export { type MetaStore, metaStore } from "./model/metas.js";
|
|
27
31
|
export { completeMeta, type JsonSchema, metaFromSchema, type SchemaComponents, } from "./model/schema.js";
|
|
28
32
|
export { type Attributes, type ErrorAbout, type ErrorReporter, NO_TRACER, type Outcome, type Span, type Tracer, } from "./observability/ports.js";
|
|
33
|
+
export { type BoundedContext, UseBoundedContext, } from "./services/bounded-context.js";
|
|
29
34
|
export { BIND, type Bindable, bindable, bindDependency, bindTransport, type Execution, headersOf, } from "./services/execution.js";
|
|
30
|
-
export { ApplicationService,
|
|
31
|
-
export {
|
|
35
|
+
export { ApplicationService, Feature, type ServiceClasses, type ServiceMethods, type Services, } from "./services/feature.js";
|
|
36
|
+
export { configureProcess, outcomeOf, processContext, type ProcessOptions, } from "./services/process.js";
|
package/dist/index.js
CHANGED
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
* client/ the seam with a server: the transport and its REST and in-memory implementations,
|
|
10
10
|
* entities with typed commands, the service's client contract, client-side ids
|
|
11
11
|
* model/ model definitions: written by hand, read from a JSON Schema, kept once known
|
|
12
|
-
* services/
|
|
12
|
+
* services/ the bounded context (`UseBoundedContext`), its `Feature` and `ApplicationService`,
|
|
13
|
+
* the process they run in (`configureProcess`), what each run carries
|
|
14
|
+
* context/ the context: one door (`Context`) over the store each platform plugs
|
|
13
15
|
* observability/ the ports a platform plugs: a tracer and an error reporter
|
|
14
16
|
*
|
|
15
17
|
* The generator is `@sincpro/client-framework/codegen`, and the in-memory tracer and reporter
|
|
@@ -22,10 +24,13 @@ export { memoryTransport, } from "./client/memory.js";
|
|
|
22
24
|
export { restTransport, urlOf, } from "./client/rest.js";
|
|
23
25
|
export { retryKeys, unanswered, } from "./client/retry.js";
|
|
24
26
|
export { readRoute, TransportError, } from "./client/transport.js";
|
|
27
|
+
export {} from "./context/context.js";
|
|
28
|
+
export { memoryContexts, } from "./context/store.js";
|
|
25
29
|
export { defineMeta, field, } from "./model/meta.js";
|
|
26
30
|
export { metaStore } from "./model/metas.js";
|
|
27
31
|
export { completeMeta, metaFromSchema, } from "./model/schema.js";
|
|
28
32
|
export { NO_TRACER, } from "./observability/ports.js";
|
|
33
|
+
export { UseBoundedContext, } from "./services/bounded-context.js";
|
|
29
34
|
export { BIND, bindable, bindDependency, bindTransport, headersOf, } from "./services/execution.js";
|
|
30
|
-
export { ApplicationService,
|
|
31
|
-
export {
|
|
35
|
+
export { ApplicationService, Feature, } from "./services/feature.js";
|
|
36
|
+
export { configureProcess, outcomeOf, processContext, } from "./services/process.js";
|
|
@@ -47,13 +47,13 @@ export interface Tracer {
|
|
|
47
47
|
}
|
|
48
48
|
/** What a report says about where the error happened. */
|
|
49
49
|
export type ErrorAbout = {
|
|
50
|
-
/** `<
|
|
50
|
+
/** `<bounded context>.<Service>[.<method>]`: the use case's identity, as the backend's. */
|
|
51
51
|
useCase: string;
|
|
52
52
|
/** The trace the error belongs to — the same id the server logged, when it got that far. */
|
|
53
53
|
traceId: string;
|
|
54
54
|
/** The execution's own id. */
|
|
55
55
|
executionId: string;
|
|
56
|
-
/** What the use case ran with
|
|
56
|
+
/** What the use case ran with: the process's context, its bounded context's, its scope's. */
|
|
57
57
|
context: Readonly<Record<string, unknown>>;
|
|
58
58
|
};
|
|
59
59
|
/** Reports what nobody expected. One per platform; none when nothing reports. */
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { type Context } from "../context/context.js";
|
|
2
|
+
import type { ContextValues } from "../context/store.js";
|
|
3
|
+
import { type ServiceClasses, type Services } from "./feature.js";
|
|
4
|
+
/**
|
|
5
|
+
* A bounded context: its dependencies, its services, and the one object everything else uses
|
|
6
|
+
* them through — set up the way sincpro_framework sets one up.
|
|
7
|
+
*
|
|
8
|
+
* // domains/project/infrastructure/dependencies.ts ← what the services can count on
|
|
9
|
+
* export interface ProjectDependencies {
|
|
10
|
+
* client: ProjectClient;
|
|
11
|
+
* session: Session;
|
|
12
|
+
* }
|
|
13
|
+
* export function registerDependencies(bc: UseBoundedContext<ProjectDependencies>) {
|
|
14
|
+
* bc.addDependency("client", clientOf(transport));
|
|
15
|
+
* bc.addDependency("session", session);
|
|
16
|
+
* return bc;
|
|
17
|
+
* }
|
|
18
|
+
*
|
|
19
|
+
* // domains/project/infrastructure/bounded_context.ts
|
|
20
|
+
* export class Feature extends BaseFeature<ProjectDependencies> {}
|
|
21
|
+
* export class ApplicationService extends BaseApplicationService<ProjectDependencies, ProjectFeatures> {}
|
|
22
|
+
* export function configProjectBoundedContext(name: string) {
|
|
23
|
+
* return registerDependencies(new UseBoundedContext<ProjectDependencies>(name));
|
|
24
|
+
* }
|
|
25
|
+
*
|
|
26
|
+
* // domains/project/index.ts
|
|
27
|
+
* export const project = configProjectBoundedContext("project").register({
|
|
28
|
+
* features, // { Issues, Notices }
|
|
29
|
+
* applicationServices, // { CloseAndNotify }
|
|
30
|
+
* });
|
|
31
|
+
*
|
|
32
|
+
* // anywhere — a screen, another bounded context's adapter, a test
|
|
33
|
+
* await project.Issues.close(input); // span "project/Issues.close"
|
|
34
|
+
* await project.CloseAndNotify.execute(input); // span "project/CloseAndNotify", its Features under it
|
|
35
|
+
*
|
|
36
|
+
* Context: sincpro_framework's `UseFramework` (being renamed to bounded context) with
|
|
37
|
+
* `add_dependency`, and `project(Command(...))` written as a property: a service is reached by
|
|
38
|
+
* the name it is registered under, so its span is named by that name — a production build renames
|
|
39
|
+
* classes, never the keys of an object — and the type of `project` is the services it holds.
|
|
40
|
+
*
|
|
41
|
+
* `project` is light and never frozen. It holds references: the classes, the dependencies, and
|
|
42
|
+
* one instance of each service, made the first time it is asked for and shared by everything
|
|
43
|
+
* derived from it. Each call makes a small view of that instance — a few pointers — that carries
|
|
44
|
+
* the call's trace. A screen or a test that needs its own variant derives one, with everything
|
|
45
|
+
* else shared:
|
|
46
|
+
*
|
|
47
|
+
* project.with({ client: fake }) other dependencies — a test, a demo
|
|
48
|
+
* project.scoped({ company_id: 2 }) more context for what runs through it — a window
|
|
49
|
+
*
|
|
50
|
+
* A service is a singleton of its bounded context: what a method writes on `this` stays for the
|
|
51
|
+
* next call and for the call running beside it, as in sincpro_framework. What belongs to one
|
|
52
|
+
* request goes in its arguments or its context, never on `this`.
|
|
53
|
+
*/
|
|
54
|
+
export type BoundedContext<D extends object, C extends object, F extends ServiceClasses, A extends ServiceClasses> = Services<F, D> & Services<A, D> & {
|
|
55
|
+
readonly name: string;
|
|
56
|
+
/** This bounded context's level of the context: under the process's, over a scope's. */
|
|
57
|
+
readonly context: Context<C>;
|
|
58
|
+
/** The same bounded context with other dependencies — the rest, and the services, shared. */
|
|
59
|
+
with(dependencies: Partial<D>): BoundedContext<D, C, F, A>;
|
|
60
|
+
/** The same bounded context with more context for what runs through it. */
|
|
61
|
+
scoped(context: Partial<C>): BoundedContext<D, C, F, A>;
|
|
62
|
+
};
|
|
63
|
+
export declare class UseBoundedContext<D extends object, C extends object = ContextValues> {
|
|
64
|
+
#private;
|
|
65
|
+
readonly name: string;
|
|
66
|
+
/** This bounded context's level of the context, saved in the process's store. */
|
|
67
|
+
readonly context: Context<C>;
|
|
68
|
+
constructor(name: string);
|
|
69
|
+
/** `value` is what every service of this bounded context finds at `this.<name>`. */
|
|
70
|
+
addDependency<K extends keyof D & string>(name: K, value: D[K]): this;
|
|
71
|
+
/**
|
|
72
|
+
* The object the bounded context is used through: each service under its name. A dependency
|
|
73
|
+
* added after this is seen too — the services read them on every call.
|
|
74
|
+
*/
|
|
75
|
+
register<F extends ServiceClasses, A extends ServiceClasses = Record<never, never>>(services: {
|
|
76
|
+
features: F;
|
|
77
|
+
applicationServices?: A;
|
|
78
|
+
}): BoundedContext<D, C, F, A>;
|
|
79
|
+
}
|