@sincpro/client-framework 0.2.0 → 0.4.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 +51 -23
- package/MANIFESTO.md +44 -26
- package/README.md +304 -29
- package/dist/adapters/opentelemetry.d.ts +1 -1
- package/dist/adapters/opentelemetry.js +1 -1
- package/dist/adapters/sentry.d.ts +2 -2
- package/dist/adapters/sentry.js +2 -2
- package/dist/client/memory.js +14 -6
- package/dist/common/decimal.d.ts +49 -0
- package/dist/common/decimal.js +146 -0
- package/dist/{client → common}/ids.d.ts +7 -0
- package/dist/{client → common}/ids.js +12 -0
- package/dist/common/money.d.ts +74 -0
- package/dist/common/money.js +168 -0
- package/dist/common/serialization.d.ts +49 -0
- package/dist/common/serialization.js +155 -0
- package/dist/common/time.d.ts +40 -0
- package/dist/common/time.js +93 -0
- package/dist/context/codec.d.ts +24 -0
- package/dist/context/codec.js +28 -0
- 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 +17 -5
- package/dist/index.js +17 -5
- 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 +8 -8
- package/dist/services/execution.js +8 -6
- package/dist/services/feature.d.ts +70 -48
- package/dist/services/feature.js +57 -191
- 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 +2 -2
- package/dist/services/runtime.d.ts +0 -48
- package/dist/services/runtime.js +0 -30
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Any value as JSON values and back — what every message a client sends is written with, as
|
|
3
|
+
* sincpro_framework's `common/serialization` (`values_of`, `read_values`, `rebuilt`, `pack`,
|
|
4
|
+
* `unpack`).
|
|
5
|
+
*
|
|
6
|
+
* valuesOf({ total: new Money("12.50", "BOB"), qty: new Decimal("2.500") })
|
|
7
|
+
* → { total: { amount: "12.50", currency: "BOB" }, qty: "2.500" }
|
|
8
|
+
* valuesOf(new Uint8Array([104, 105])) → { $bytes: "aGk=" }
|
|
9
|
+
* readValues('{"photo":{"$bytes":"aGk="}}') → { photo: Uint8Array [104, 105] }
|
|
10
|
+
* unpack(text, Money) → a Money, or a TypeError naming what is wrong
|
|
11
|
+
*
|
|
12
|
+
* Context: the wire has one set of rules, the backend's, and every platform writes and reads it
|
|
13
|
+
* the same — that is why it lives here and not in each client:
|
|
14
|
+
*
|
|
15
|
+
* - a decimal is text (`"10.50"`): a `Decimal`, a `Money`'s amount, a `bigint` — never a float;
|
|
16
|
+
* - a day is text (`"2026-10-02"`) and stays text: a `Date` is always an instant, written as one;
|
|
17
|
+
* - an instant is ISO text (`Date` → `toISOString()`);
|
|
18
|
+
* - bytes are `{"$bytes": "<base64>"}`, so binary is never mistaken for text;
|
|
19
|
+
* - a value that says how it travels (`toJSON`) is written that way: `Money`, `Decimal`, `Date`;
|
|
20
|
+
* - a map is an object with text keys; a set or a list, a list.
|
|
21
|
+
*
|
|
22
|
+
* Not restrictive, like the backend: reading keeps every key it finds — what a reader does not
|
|
23
|
+
* know it ignores, never refuses — and a typed read is asked for, never imposed (`rebuilt(values,
|
|
24
|
+
* Money)`). What JSON cannot write (a function, `NaN`, a cycle) is refused at once, naming where,
|
|
25
|
+
* rather than sent as `null`.
|
|
26
|
+
*/
|
|
27
|
+
/** The tag bytes travel under. */
|
|
28
|
+
export const BYTES = "$bytes";
|
|
29
|
+
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
30
|
+
/** Base64 by hand: `btoa` is not on every platform this runs on. */
|
|
31
|
+
function base64Of(bytes) {
|
|
32
|
+
let text = "";
|
|
33
|
+
for (let at = 0; at < bytes.length; at += 3) {
|
|
34
|
+
const [a = 0, b = 0, c = 0] = [bytes[at], bytes[at + 1], bytes[at + 2]];
|
|
35
|
+
const triple = (a << 16) | (b << 8) | c;
|
|
36
|
+
const letter = (shift) => ALPHABET.charAt((triple >> shift) & 63);
|
|
37
|
+
text += letter(18) + letter(12);
|
|
38
|
+
text += at + 1 < bytes.length ? letter(6) : "=";
|
|
39
|
+
text += at + 2 < bytes.length ? letter(0) : "=";
|
|
40
|
+
}
|
|
41
|
+
return text;
|
|
42
|
+
}
|
|
43
|
+
function bytesOf(text) {
|
|
44
|
+
const clean = text.replace(/[^A-Za-z0-9+/]/g, "");
|
|
45
|
+
const bytes = new Uint8Array(Math.floor((clean.length * 3) / 4));
|
|
46
|
+
let filled = 0;
|
|
47
|
+
for (let at = 0; at < clean.length; at += 4) {
|
|
48
|
+
const [a = 0, b = 0, c = 0, d = 0] = [0, 1, 2, 3].map((one) => ALPHABET.indexOf(clean[at + one] ?? "A"));
|
|
49
|
+
const triple = (a << 18) | (b << 12) | (c << 6) | d;
|
|
50
|
+
for (const shift of [16, 8, 0])
|
|
51
|
+
if (filled < bytes.length)
|
|
52
|
+
bytes[filled++] = (triple >> shift) & 255;
|
|
53
|
+
}
|
|
54
|
+
return bytes;
|
|
55
|
+
}
|
|
56
|
+
function isBytes(value) {
|
|
57
|
+
return (value instanceof Uint8Array || value instanceof ArrayBuffer || value instanceof DataView);
|
|
58
|
+
}
|
|
59
|
+
function asBytes(value) {
|
|
60
|
+
if (value instanceof Uint8Array)
|
|
61
|
+
return value;
|
|
62
|
+
if (value instanceof ArrayBuffer)
|
|
63
|
+
return new Uint8Array(value);
|
|
64
|
+
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
|
65
|
+
}
|
|
66
|
+
function written(value, where, seen) {
|
|
67
|
+
if (value === null || typeof value === "string" || typeof value === "boolean")
|
|
68
|
+
return value;
|
|
69
|
+
if (value === undefined)
|
|
70
|
+
return undefined;
|
|
71
|
+
if (typeof value === "number") {
|
|
72
|
+
if (!Number.isFinite(value))
|
|
73
|
+
throw new TypeError(`${where}: ${value} cannot travel as JSON`);
|
|
74
|
+
return value;
|
|
75
|
+
}
|
|
76
|
+
if (typeof value === "bigint")
|
|
77
|
+
return value.toString();
|
|
78
|
+
if (typeof value === "function" || typeof value === "symbol")
|
|
79
|
+
throw new TypeError(`${where}: a ${typeof value} cannot travel as JSON`);
|
|
80
|
+
if (isBytes(value))
|
|
81
|
+
return { [BYTES]: base64Of(asBytes(value)) };
|
|
82
|
+
const object = value;
|
|
83
|
+
if (seen.has(object))
|
|
84
|
+
throw new TypeError(`${where}: a cycle cannot travel as JSON`);
|
|
85
|
+
seen.add(object);
|
|
86
|
+
try {
|
|
87
|
+
const own = object.toJSON;
|
|
88
|
+
if (typeof own === "function")
|
|
89
|
+
return written(own.call(object), where, seen);
|
|
90
|
+
if (object instanceof Map)
|
|
91
|
+
return Object.fromEntries([...object].flatMap(([key, one]) => {
|
|
92
|
+
const done = written(one, `${where}.${String(key)}`, seen);
|
|
93
|
+
return done === undefined ? [] : [[String(key), done]];
|
|
94
|
+
}));
|
|
95
|
+
if (object instanceof Set || Array.isArray(object))
|
|
96
|
+
return [...object].map((one, at) => written(one, `${where}[${at}]`, seen) ?? null);
|
|
97
|
+
const entries = [];
|
|
98
|
+
for (const [key, one] of Object.entries(object)) {
|
|
99
|
+
const done = written(one, `${where}.${key}`, seen);
|
|
100
|
+
if (done !== undefined)
|
|
101
|
+
entries.push([key, done]);
|
|
102
|
+
}
|
|
103
|
+
return Object.fromEntries(entries);
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
seen.delete(object);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* `value` as JSON values, by the wire's rules. `undefined` is left out of an object and written
|
|
111
|
+
* `null` in a list, as JSON does.
|
|
112
|
+
*/
|
|
113
|
+
export function valuesOf(value) {
|
|
114
|
+
return written(value, "value", new Set()) ?? null;
|
|
115
|
+
}
|
|
116
|
+
/** One JSON object back as what it stood for: a lone `$bytes` tag is its bytes. */
|
|
117
|
+
function restored(object) {
|
|
118
|
+
const keys = Object.keys(object);
|
|
119
|
+
if (keys.length === 1 && keys[0] === BYTES && typeof object[BYTES] === "string")
|
|
120
|
+
return bytesOf(object[BYTES]);
|
|
121
|
+
return object;
|
|
122
|
+
}
|
|
123
|
+
function walked(value) {
|
|
124
|
+
if (Array.isArray(value))
|
|
125
|
+
return value.map(walked);
|
|
126
|
+
if (value !== null && typeof value === "object")
|
|
127
|
+
return restored(Object.fromEntries(Object.entries(value).map(([key, one]) => [key, walked(one)])));
|
|
128
|
+
return value;
|
|
129
|
+
}
|
|
130
|
+
/** What `valuesOf` wrote, from JSON text or already parsed — each tagged bytes back as bytes. */
|
|
131
|
+
export function readValues(text) {
|
|
132
|
+
if (typeof text === "string")
|
|
133
|
+
return JSON.parse(text, (_key, value) => value !== null && typeof value === "object" && !Array.isArray(value)
|
|
134
|
+
? restored(value)
|
|
135
|
+
: value);
|
|
136
|
+
return walked(text);
|
|
137
|
+
}
|
|
138
|
+
/** `values` read as `of` — `Money`, a DTO's own reader — or as they are without one. */
|
|
139
|
+
export function rebuilt(values, of) {
|
|
140
|
+
if (of === undefined)
|
|
141
|
+
return values;
|
|
142
|
+
// A class is a function too: one that says how to read itself is read that way.
|
|
143
|
+
const reader = of.fromJSON;
|
|
144
|
+
if (typeof reader === "function")
|
|
145
|
+
return reader.call(of, values);
|
|
146
|
+
return of(values);
|
|
147
|
+
}
|
|
148
|
+
/** `value` as JSON text — see `valuesOf`. */
|
|
149
|
+
export function pack(value) {
|
|
150
|
+
return JSON.stringify(valuesOf(value));
|
|
151
|
+
}
|
|
152
|
+
/** What `pack` wrote, read as `of` — see `rebuilt`. */
|
|
153
|
+
export function unpack(text, of) {
|
|
154
|
+
return rebuilt(readValues(text), of);
|
|
155
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A day and an instant, as the server sends them — and the two conversions every client needs:
|
|
3
|
+
* the server's instant to the wall clock of a zone, and back.
|
|
4
|
+
*
|
|
5
|
+
* installTimeZone("America/La_Paz"); once, for the company in use
|
|
6
|
+
* wallTimeOf("2026-03-12T14:30:00Z", timeZoneNow()) → "2026-03-12T10:30"
|
|
7
|
+
* instantOf("2026-03-12T10:30", timeZoneNow()) → "2026-03-12T14:30:00.000Z"
|
|
8
|
+
*
|
|
9
|
+
* Context: sincpro_framework sends a `datetime` as an instant (`2026-03-12T14:30:00Z`) and a
|
|
10
|
+
* `date` as a day of the calendar (`2026-03-12`). A person reads and types the wall clock of
|
|
11
|
+
* where the business is (10:30 in La Paz), on a browser or on a phone alike, so the conversion
|
|
12
|
+
* is this package's and every platform draws with it. Which zone belongs to whoever is above the
|
|
13
|
+
* screens — the application, the company the user works in — so it is installed once and every
|
|
14
|
+
* instant follows it; without it, the platform's own. A day is never converted: turned into an
|
|
15
|
+
* instant it lands on the day before west of Greenwich.
|
|
16
|
+
*
|
|
17
|
+
* Only `Intl.DateTimeFormat` with time zones is asked of the platform: browsers, Node and
|
|
18
|
+
* Hermes (React Native) have it.
|
|
19
|
+
*/
|
|
20
|
+
/** Install the time zone dates and times are read and written in (IANA: `"America/La_Paz"`);
|
|
21
|
+
* `undefined` goes back to the platform's. */
|
|
22
|
+
export declare function installTimeZone(timeZone: string | undefined): void;
|
|
23
|
+
/** The time zone in use. */
|
|
24
|
+
export declare function timeZoneNow(): string;
|
|
25
|
+
/** `"2026-03-12T14:30:00Z"` → `"2026-03-12T10:30"` in `America/La_Paz`: the wall clock there,
|
|
26
|
+
* as a date-and-time control reads it. `null` when it is not an instant. */
|
|
27
|
+
export declare function wallTimeOf(instant: string, timeZone: string): string | null;
|
|
28
|
+
/**
|
|
29
|
+
* `"2026-03-12T10:30"` on the wall clock of `timeZone` → the instant, `"2026-03-12T14:30:00.000Z"`.
|
|
30
|
+
* `null` when it is not a wall time.
|
|
31
|
+
*
|
|
32
|
+
* Context: the offset depends on the instant (daylight saving), so it is asked twice: at the
|
|
33
|
+
* first guess, and again at the answer when the guess fell on the other side of a change.
|
|
34
|
+
*/
|
|
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. */
|
|
37
|
+
export declare function isDay(text: string): boolean;
|
|
38
|
+
/** The day it is at `instant` on the wall clock of `timeZone`: what «today» means for a company.
|
|
39
|
+
* `null` when it is not an instant. */
|
|
40
|
+
export declare function dayOf(instant: string, timeZone: string): string | null;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A day and an instant, as the server sends them — and the two conversions every client needs:
|
|
3
|
+
* the server's instant to the wall clock of a zone, and back.
|
|
4
|
+
*
|
|
5
|
+
* installTimeZone("America/La_Paz"); once, for the company in use
|
|
6
|
+
* wallTimeOf("2026-03-12T14:30:00Z", timeZoneNow()) → "2026-03-12T10:30"
|
|
7
|
+
* instantOf("2026-03-12T10:30", timeZoneNow()) → "2026-03-12T14:30:00.000Z"
|
|
8
|
+
*
|
|
9
|
+
* Context: sincpro_framework sends a `datetime` as an instant (`2026-03-12T14:30:00Z`) and a
|
|
10
|
+
* `date` as a day of the calendar (`2026-03-12`). A person reads and types the wall clock of
|
|
11
|
+
* where the business is (10:30 in La Paz), on a browser or on a phone alike, so the conversion
|
|
12
|
+
* is this package's and every platform draws with it. Which zone belongs to whoever is above the
|
|
13
|
+
* screens — the application, the company the user works in — so it is installed once and every
|
|
14
|
+
* instant follows it; without it, the platform's own. A day is never converted: turned into an
|
|
15
|
+
* instant it lands on the day before west of Greenwich.
|
|
16
|
+
*
|
|
17
|
+
* Only `Intl.DateTimeFormat` with time zones is asked of the platform: browsers, Node and
|
|
18
|
+
* Hermes (React Native) have it.
|
|
19
|
+
*/
|
|
20
|
+
let installed;
|
|
21
|
+
/** Install the time zone dates and times are read and written in (IANA: `"America/La_Paz"`);
|
|
22
|
+
* `undefined` goes back to the platform's. */
|
|
23
|
+
export function installTimeZone(timeZone) {
|
|
24
|
+
installed = timeZone;
|
|
25
|
+
}
|
|
26
|
+
/** The time zone in use. */
|
|
27
|
+
export function timeZoneNow() {
|
|
28
|
+
return installed ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
29
|
+
}
|
|
30
|
+
const clocks = new Map();
|
|
31
|
+
/** One formatter per zone, kept: building one costs far more than using it. */
|
|
32
|
+
function clockOf(timeZone) {
|
|
33
|
+
let clock = clocks.get(timeZone);
|
|
34
|
+
if (clock === undefined) {
|
|
35
|
+
clock = new Intl.DateTimeFormat("en-US", {
|
|
36
|
+
timeZone,
|
|
37
|
+
hourCycle: "h23",
|
|
38
|
+
year: "numeric",
|
|
39
|
+
month: "2-digit",
|
|
40
|
+
day: "2-digit",
|
|
41
|
+
hour: "2-digit",
|
|
42
|
+
minute: "2-digit",
|
|
43
|
+
second: "2-digit",
|
|
44
|
+
});
|
|
45
|
+
clocks.set(timeZone, clock);
|
|
46
|
+
}
|
|
47
|
+
return clock;
|
|
48
|
+
}
|
|
49
|
+
/** The wall clock of `timeZone` at `at` (ms), as the fields of a UTC date: what to compare. */
|
|
50
|
+
function wallAt(at, timeZone) {
|
|
51
|
+
const parts = Object.fromEntries(clockOf(timeZone)
|
|
52
|
+
.formatToParts(new Date(at))
|
|
53
|
+
.map((part) => [part.type, part.value]));
|
|
54
|
+
return Date.UTC(Number(parts["year"]), Number(parts["month"]) - 1, Number(parts["day"]), Number(parts["hour"]), Number(parts["minute"]), Number(parts["second"]));
|
|
55
|
+
}
|
|
56
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
57
|
+
/** `"2026-03-12T14:30:00Z"` → `"2026-03-12T10:30"` in `America/La_Paz`: the wall clock there,
|
|
58
|
+
* as a date-and-time control reads it. `null` when it is not an instant. */
|
|
59
|
+
export function wallTimeOf(instant, timeZone) {
|
|
60
|
+
const at = Date.parse(instant);
|
|
61
|
+
if (Number.isNaN(at))
|
|
62
|
+
return null;
|
|
63
|
+
const wall = new Date(wallAt(at, timeZone));
|
|
64
|
+
return (`${wall.getUTCFullYear()}-${pad(wall.getUTCMonth() + 1)}-${pad(wall.getUTCDate())}` +
|
|
65
|
+
`T${pad(wall.getUTCHours())}:${pad(wall.getUTCMinutes())}`);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* `"2026-03-12T10:30"` on the wall clock of `timeZone` → the instant, `"2026-03-12T14:30:00.000Z"`.
|
|
69
|
+
* `null` when it is not a wall time.
|
|
70
|
+
*
|
|
71
|
+
* Context: the offset depends on the instant (daylight saving), so it is asked twice: at the
|
|
72
|
+
* first guess, and again at the answer when the guess fell on the other side of a change.
|
|
73
|
+
*/
|
|
74
|
+
export function instantOf(wall, timeZone) {
|
|
75
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(wall);
|
|
76
|
+
if (match === null)
|
|
77
|
+
return null;
|
|
78
|
+
const [, year, month, day, hour, minute] = match.map(Number);
|
|
79
|
+
const guess = Date.UTC(year, month - 1, day, hour, minute);
|
|
80
|
+
const first = guess - (wallAt(guess, timeZone) - guess);
|
|
81
|
+
const second = guess - (wallAt(first, timeZone) - first);
|
|
82
|
+
return new Date(second).toISOString();
|
|
83
|
+
}
|
|
84
|
+
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. */
|
|
86
|
+
export function isDay(text) {
|
|
87
|
+
return DAY.test(text) && !Number.isNaN(Date.parse(`${text}T00:00:00Z`));
|
|
88
|
+
}
|
|
89
|
+
/** The day it is at `instant` on the wall clock of `timeZone`: what «today» means for a company.
|
|
90
|
+
* `null` when it is not an instant. */
|
|
91
|
+
export function dayOf(instant, timeZone) {
|
|
92
|
+
return wallTimeOf(instant, timeZone)?.slice(0, 10) ?? null;
|
|
93
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ContextValues } from "./store.js";
|
|
2
|
+
/**
|
|
3
|
+
* How a context is written to text and read back: what a `ContextStore` that persists uses
|
|
4
|
+
* (`sessionStorage`, MMKV), and what travels to the server in `sincpro-context`.
|
|
5
|
+
*
|
|
6
|
+
* plainCodec.dumps({ tenant_id: "acme", company_id: 2, session: socket })
|
|
7
|
+
* → '{"tenant_id":"acme","company_id":2}'
|
|
8
|
+
* plainCodec.loads('{"tenant_id":"acme"}') → { tenant_id: "acme" }
|
|
9
|
+
*
|
|
10
|
+
* Context: sincpro_framework's `ContextCodec`, and its `PlainCodec` — the one for anything outside
|
|
11
|
+
* the codebase: another language, a browser, a phone. Only the travelling values go — text,
|
|
12
|
+
* numbers, booleans, `null`, and lists of them; an object, a function or a connection held in the
|
|
13
|
+
* context stays where it is. Reading never fails a context whole: text that is not a JSON object
|
|
14
|
+
* reads as an empty one. A project with a schema of its own writes its own codec over this port.
|
|
15
|
+
*/
|
|
16
|
+
export interface ContextCodec {
|
|
17
|
+
dumps(values: ContextValues): string;
|
|
18
|
+
loads(text: string): ContextValues;
|
|
19
|
+
}
|
|
20
|
+
/** Whether a value travels: a plain value, or a list of them. */
|
|
21
|
+
export declare function travels(value: unknown): boolean;
|
|
22
|
+
/** The values of `context` that travel; the rest stays in this process. */
|
|
23
|
+
export declare function travelling(context: ContextValues): ContextValues;
|
|
24
|
+
export declare const plainCodec: ContextCodec;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
function isPlain(value) {
|
|
2
|
+
return (value === null ||
|
|
3
|
+
typeof value === "string" ||
|
|
4
|
+
typeof value === "boolean" ||
|
|
5
|
+
(typeof value === "number" && Number.isFinite(value)));
|
|
6
|
+
}
|
|
7
|
+
/** Whether a value travels: a plain value, or a list of them. */
|
|
8
|
+
export function travels(value) {
|
|
9
|
+
return isPlain(value) || (Array.isArray(value) && value.every(isPlain));
|
|
10
|
+
}
|
|
11
|
+
/** The values of `context` that travel; the rest stays in this process. */
|
|
12
|
+
export function travelling(context) {
|
|
13
|
+
return Object.fromEntries(Object.entries(context).filter(([, value]) => travels(value)));
|
|
14
|
+
}
|
|
15
|
+
export const plainCodec = {
|
|
16
|
+
dumps: (values) => JSON.stringify(travelling(values)),
|
|
17
|
+
loads(text) {
|
|
18
|
+
try {
|
|
19
|
+
const found = JSON.parse(text);
|
|
20
|
+
return found !== null && typeof found === "object" && !Array.isArray(found)
|
|
21
|
+
? found
|
|
22
|
+
: {};
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
};
|
|
@@ -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
|
@@ -6,10 +6,14 @@
|
|
|
6
6
|
* (`sideEffects: false`), so an application pays only for what it uses, and a use case, the
|
|
7
7
|
* client it calls and the entity it reads come from the same place.
|
|
8
8
|
*
|
|
9
|
+
* common/ the values of the wire, as the backend writes them: ids, a day and an instant,
|
|
10
|
+
* `Decimal`, `Money`, and how any value is written and read (`valuesOf`, `rebuilt`)
|
|
9
11
|
* client/ the seam with a server: the transport and its REST and in-memory implementations,
|
|
10
|
-
* entities with typed commands, the service's client contract
|
|
12
|
+
* entities with typed commands, the service's client contract
|
|
11
13
|
* model/ model definitions: written by hand, read from a JSON Schema, kept once known
|
|
12
|
-
* services/
|
|
14
|
+
* services/ the bounded context (`UseBoundedContext`), its `Feature` and `ApplicationService`,
|
|
15
|
+
* the process they run in (`configureProcess`), what each run carries
|
|
16
|
+
* context/ the context: one door (`Context`) over the store each platform plugs
|
|
13
17
|
* observability/ the ports a platform plugs: a tracer and an error reporter
|
|
14
18
|
*
|
|
15
19
|
* The generator is `@sincpro/client-framework/codegen`, and the in-memory tracer and reporter
|
|
@@ -17,15 +21,23 @@
|
|
|
17
21
|
*/
|
|
18
22
|
export { type ClientContract, entityKeyOf, kindOf, metasOf, versionsOf, } from "./client/contract.js";
|
|
19
23
|
export { bodyFor, type CommandAnswers, type CommandBodies, type CommandRoute, type Entity, entity, type EntityRef, type EntityRoutes, listRoute, NEW_PARAM, pathFor, PICK_PARAM, QUERY_PARAM, SCOPE_PARAM, scopeOfRef, startingValuesOf, type SummaryFields, type SummaryPart, TRAIL_PARAM, WITH_PARAM, type WriteRoute, writeRoute, } from "./client/entity.js";
|
|
20
|
-
export { isEntityId, newEntityId } from "./client/ids.js";
|
|
21
24
|
export { type MemoryOptions, type MemoryTable, memoryTransport, } from "./client/memory.js";
|
|
22
25
|
export { type RestOptions, restTransport, urlOf, type VersionDrift, } from "./client/rest.js";
|
|
23
26
|
export { type RetryKeys, retryKeys, unanswered, } from "./client/retry.js";
|
|
24
27
|
export { type CallOptions, type Draft, type Method, type One, readRoute, type SendOptions, type Transport, TransportError, } from "./client/transport.js";
|
|
28
|
+
export { Decimal, type DecimalLike } from "./common/decimal.js";
|
|
29
|
+
export { isEntityId, momentOf, newEntityId } from "./common/ids.js";
|
|
30
|
+
export { Money } from "./common/money.js";
|
|
31
|
+
export { BYTES, type JsonValue, pack, readValues, type Rebuild, rebuilt, unpack, valuesOf, } from "./common/serialization.js";
|
|
32
|
+
export { dayOf, installTimeZone, instantOf, isDay, timeZoneNow, wallTimeOf, } from "./common/time.js";
|
|
33
|
+
export { type ContextCodec, plainCodec, travelling, travels, } from "./context/codec.js";
|
|
34
|
+
export { type Context } from "./context/context.js";
|
|
35
|
+
export { type ContextStore, type ContextValues, memoryContexts, } from "./context/store.js";
|
|
25
36
|
export { defineMeta, field, type FieldOptions, type MetaSpec, type Words, } from "./model/meta.js";
|
|
26
37
|
export { type MetaStore, metaStore } from "./model/metas.js";
|
|
27
38
|
export { completeMeta, type JsonSchema, metaFromSchema, type SchemaComponents, } from "./model/schema.js";
|
|
28
39
|
export { type Attributes, type ErrorAbout, type ErrorReporter, NO_TRACER, type Outcome, type Span, type Tracer, } from "./observability/ports.js";
|
|
40
|
+
export { type BoundedContext, UseBoundedContext, } from "./services/bounded-context.js";
|
|
29
41
|
export { BIND, type Bindable, bindable, bindDependency, bindTransport, type Execution, headersOf, } from "./services/execution.js";
|
|
30
|
-
export { ApplicationService,
|
|
31
|
-
export {
|
|
42
|
+
export { ApplicationService, Feature, type ServiceClasses, type ServiceMethods, type Services, } from "./services/feature.js";
|
|
43
|
+
export { configureProcess, outcomeOf, processContext, type ProcessOptions, } from "./services/process.js";
|