@sincpro/client-framework 0.3.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.
Files changed (41) hide show
  1. package/AGENTS.md +22 -11
  2. package/MANIFESTO.md +7 -3
  3. package/README.md +58 -0
  4. package/dist/client/memory.js +8 -5
  5. package/dist/codegen/check.d.ts +64 -0
  6. package/dist/codegen/check.js +195 -0
  7. package/dist/codegen/cli.js +73 -6
  8. package/dist/codegen/client.js +5 -5
  9. package/dist/codegen/domain.d.ts +2 -1
  10. package/dist/codegen/domain.js +17 -5
  11. package/dist/codegen/entities.js +10 -3
  12. package/dist/codegen/index.d.ts +2 -0
  13. package/dist/codegen/index.js +2 -0
  14. package/dist/codegen/names.d.ts +12 -0
  15. package/dist/codegen/names.js +58 -0
  16. package/dist/codegen/scaffold.d.ts +24 -0
  17. package/dist/codegen/scaffold.js +123 -0
  18. package/dist/common/decimal.d.ts +49 -0
  19. package/dist/common/decimal.js +154 -0
  20. package/dist/{client → common}/ids.d.ts +7 -0
  21. package/dist/{client → common}/ids.js +12 -0
  22. package/dist/common/money.d.ts +74 -0
  23. package/dist/common/money.js +168 -0
  24. package/dist/common/serialization.d.ts +53 -0
  25. package/dist/common/serialization.js +168 -0
  26. package/dist/common/time.d.ts +41 -0
  27. package/dist/common/time.js +100 -0
  28. package/dist/context/codec.d.ts +26 -0
  29. package/dist/context/codec.js +44 -0
  30. package/dist/index.d.ts +11 -2
  31. package/dist/index.js +11 -2
  32. package/dist/observability/ports.d.ts +2 -1
  33. package/dist/services/bounded-context.js +5 -16
  34. package/dist/services/execution.d.ts +2 -2
  35. package/dist/services/execution.js +6 -4
  36. package/dist/services/feature.d.ts +4 -1
  37. package/dist/services/report.d.ts +15 -0
  38. package/dist/services/report.js +53 -0
  39. package/dist/services/screens.d.ts +2 -0
  40. package/dist/services/screens.js +67 -0
  41. package/package.json +6 -2
@@ -0,0 +1,168 @@
1
+ import { Decimal } from "./decimal.js";
2
+ /**
3
+ * An amount and its currency, as sincpro_framework's `Money` travels: `{"amount": "10.50",
4
+ * "currency": "BOB"}`.
5
+ *
6
+ * const line = new Money("19.90", "BOB").times("3"); → 59.70 BOB
7
+ * const total = Money.total([line, delivery], "BOB");
8
+ * total.rounded().toJSON() → { amount: "69.70", currency: "BOB" }
9
+ * Money.fromJSON(answer.total) ← what the server sent
10
+ *
11
+ * Context: an amount never travels without its currency, never as a float and never rounds by
12
+ * accident — the backend's three rules, kept here so a cart, a POS line or a portal's quote adds
13
+ * the way the invoice will. Adding two currencies is refused; `total` sums one, `totals` each.
14
+ * The arithmetic is `Decimal`'s, exact; `rounded()` rounds to the currency's decimals (ISO 4217:
15
+ * `BOB` 2, `JPY` 0, `KWD` 3), and `isZero` / `isNegative` read it at those decimals, as an
16
+ * accountant does.
17
+ *
18
+ * Not restrictive: any code is read (a crypto, a loyalty point), and its decimals are 2 unless
19
+ * `Money.decimals` says otherwise — an application adds its own (`Money.decimals["USDT"] = 6`).
20
+ * Showing it to a person is the platform's (`Intl.NumberFormat` with `style: "currency"`).
21
+ */
22
+ export class Money {
23
+ /** Decimals by currency where they are not 2: ISO 4217's, and what an application adds. */
24
+ static decimals = {
25
+ BHD: 3,
26
+ BIF: 0,
27
+ BTC: 8,
28
+ CLF: 4,
29
+ CLP: 0,
30
+ DJF: 0,
31
+ GNF: 0,
32
+ IQD: 3,
33
+ ISK: 0,
34
+ JOD: 3,
35
+ JPY: 0,
36
+ KMF: 0,
37
+ KRW: 0,
38
+ KWD: 3,
39
+ LYD: 3,
40
+ OMR: 3,
41
+ PYG: 0,
42
+ RWF: 0,
43
+ TND: 3,
44
+ UGX: 0,
45
+ UYI: 0,
46
+ VND: 0,
47
+ VUV: 0,
48
+ XAF: 0,
49
+ XOF: 0,
50
+ XPF: 0,
51
+ };
52
+ /** The amount as the wire carries it: every digit, the scale kept (`"10.50"`). */
53
+ amount;
54
+ /** The currency's code, upper case (`"BOB"`). */
55
+ currency;
56
+ constructor(amount, currency) {
57
+ const code = currency.trim().toUpperCase();
58
+ if (!/^[A-Z][A-Z0-9]{1,9}$/.test(code))
59
+ throw new TypeError(`"${currency}" is not a currency code`);
60
+ this.amount = Decimal.of(amount).toString();
61
+ this.currency = code;
62
+ }
63
+ /**
64
+ * What the server sent — `{ amount, currency }`, the amount as text or as a JSON number taken
65
+ * by its digits. Anything else is refused, naming what is wrong.
66
+ */
67
+ static fromJSON(value) {
68
+ if (value instanceof Money)
69
+ return value;
70
+ if (value === null || typeof value !== "object")
71
+ throw new TypeError(`${JSON.stringify(value)} is not a money: { amount, currency }`);
72
+ const { amount, currency } = value;
73
+ if (typeof currency !== "string")
74
+ throw new TypeError(`a money without its currency: ${JSON.stringify(value)}`);
75
+ if (typeof amount !== "string" && typeof amount !== "number")
76
+ throw new TypeError(`a money without its amount: ${JSON.stringify(value)}`);
77
+ return new Money(amount, currency);
78
+ }
79
+ /** Whether `value` has the shape of a money on the wire: `{ amount, currency }`. */
80
+ static isMoney(value) {
81
+ if (value instanceof Money)
82
+ return true;
83
+ if (value === null || typeof value !== "object")
84
+ return false;
85
+ const { amount, currency } = value;
86
+ return (typeof currency === "string" &&
87
+ (typeof amount === "string" || typeof amount === "number"));
88
+ }
89
+ /** The decimals `currency` is counted in: 2 unless `Money.decimals` says otherwise. */
90
+ static decimalsOf(currency) {
91
+ return Money.decimals[currency.toUpperCase()] ?? 2;
92
+ }
93
+ /** The sum of `items`, all in `currency` — zero when there are none. Another currency is refused. */
94
+ static total(items, currency) {
95
+ let sum = new Money("0", currency);
96
+ for (const item of items)
97
+ sum = sum.plus(item);
98
+ return sum;
99
+ }
100
+ /** The sums by currency, each currency's own. */
101
+ static totals(items) {
102
+ const sums = {};
103
+ for (const item of items) {
104
+ const held = sums[item.currency];
105
+ sums[item.currency] = held === undefined ? item : held.plus(item);
106
+ }
107
+ return sums;
108
+ }
109
+ /** The amount as an exact decimal. */
110
+ get value() {
111
+ return new Decimal(this.amount);
112
+ }
113
+ /** The decimals this currency is counted in. */
114
+ get decimals() {
115
+ return Money.decimalsOf(this.currency);
116
+ }
117
+ same(other) {
118
+ if (other.currency !== this.currency)
119
+ throw new TypeError(`${other.currency} cannot be added to ${this.currency} — sum each currency apart (Money.totals)`);
120
+ return other;
121
+ }
122
+ plus(other) {
123
+ return new Money(this.value.plus(this.same(other).amount), this.currency);
124
+ }
125
+ minus(other) {
126
+ return new Money(this.value.minus(this.same(other).amount), this.currency);
127
+ }
128
+ /** Times a quantity, a rate or a percentage as a fraction: exact, never rounded. */
129
+ times(factor) {
130
+ return new Money(this.value.times(factor), this.currency);
131
+ }
132
+ /** Over `divisor`, rounded half up to the currency's decimals (or `decimals`). */
133
+ dividedBy(divisor, decimals = this.decimals) {
134
+ return new Money(this.value.dividedBy(divisor, decimals), this.currency);
135
+ }
136
+ /** Rounded half up to the currency's decimals (or `decimals`): what an invoice shows. */
137
+ rounded(decimals = this.decimals) {
138
+ return new Money(this.value.rounded(decimals), this.currency);
139
+ }
140
+ negated() {
141
+ return new Money(this.value.negated(), this.currency);
142
+ }
143
+ /** `-1`, `0` or `1`, in the same currency. */
144
+ compare(other) {
145
+ return this.value.compare(this.same(other).amount);
146
+ }
147
+ /** The same currency and the same amount: `10.5 BOB` equals `10.50 BOB`. */
148
+ equals(other) {
149
+ return other.currency === this.currency && this.value.equals(other.amount);
150
+ }
151
+ /** Zero at the currency's decimals: `0.004 BOB` is. */
152
+ get isZero() {
153
+ return this.rounded().value.isZero;
154
+ }
155
+ get isNegative() {
156
+ return this.rounded().value.isNegative;
157
+ }
158
+ get isPositive() {
159
+ return this.rounded().value.isPositive;
160
+ }
161
+ toJSON() {
162
+ return { amount: this.amount, currency: this.currency };
163
+ }
164
+ /** `"10.50 BOB"` — for a log or a test; a person reads it through `Intl.NumberFormat`. */
165
+ toString() {
166
+ return `${this.amount} ${this.currency}`;
167
+ }
168
+ }
@@ -0,0 +1,53 @@
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
+ /** A value JSON writes as it is. */
28
+ export type JsonValue = null | boolean | number | string | JsonValue[] | {
29
+ [key: string]: JsonValue;
30
+ };
31
+ /** The tag bytes travel under. */
32
+ export declare const BYTES = "$bytes";
33
+ /** What `rebuilt` reads values as: a class with `fromJSON` (`Money`), or a function. */
34
+ export type Rebuild<T> = {
35
+ fromJSON(values: unknown): T;
36
+ } | ((values: unknown) => T);
37
+ /**
38
+ * `value` as JSON values, by the wire's rules. `undefined` is left out of an object and written
39
+ * `null` in a list, as JSON does.
40
+ */
41
+ export declare function valuesOf(value: unknown): JsonValue;
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
+ */
47
+ export declare function readValues(text: string | unknown): unknown;
48
+ /** `values` read as `of` — `Money`, a DTO's own reader — or as they are without one. */
49
+ export declare function rebuilt<T = unknown>(values: unknown, of?: Rebuild<T>): T;
50
+ /** `value` as JSON text — see `valuesOf`. */
51
+ export declare function pack(value: unknown): string;
52
+ /** What `pack` wrote, read as `of` — see `rebuilt`. */
53
+ export declare function unpack<T = unknown>(text: string, of?: Rebuild<T>): T;
@@ -0,0 +1,168 @@
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 ArrayBuffer || ArrayBuffer.isView(value);
58
+ }
59
+ /** Any binary — a `Uint8Array`, another typed array, a `DataView`, a buffer — as its bytes. */
60
+ function asBytes(value) {
61
+ if (value instanceof Uint8Array)
62
+ return value;
63
+ if (value instanceof ArrayBuffer)
64
+ return new Uint8Array(value);
65
+ return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
66
+ }
67
+ function written(value, where, seen) {
68
+ if (value === null || typeof value === "string" || typeof value === "boolean")
69
+ return value;
70
+ if (value === undefined)
71
+ return undefined;
72
+ if (typeof value === "number") {
73
+ if (!Number.isFinite(value))
74
+ throw new TypeError(`${where}: ${value} cannot travel as JSON`);
75
+ return value;
76
+ }
77
+ if (typeof value === "bigint")
78
+ return value.toString();
79
+ if (typeof value === "function" || typeof value === "symbol")
80
+ throw new TypeError(`${where}: a ${typeof value} cannot travel as JSON`);
81
+ if (isBytes(value))
82
+ return { [BYTES]: base64Of(asBytes(value)) };
83
+ const object = value;
84
+ if (seen.has(object))
85
+ throw new TypeError(`${where}: a cycle cannot travel as JSON`);
86
+ seen.add(object);
87
+ try {
88
+ const own = object.toJSON;
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
+ }
96
+ if (object instanceof Map)
97
+ return Object.fromEntries([...object].flatMap(([key, one]) => {
98
+ const done = written(one, `${where}.${String(key)}`, seen);
99
+ return done === undefined ? [] : [[String(key), done]];
100
+ }));
101
+ if (object instanceof Set || Array.isArray(object))
102
+ return [...object].map((one, at) => written(one, `${where}[${at}]`, seen) ?? null);
103
+ const entries = [];
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;
108
+ const done = written(one, `${where}.${key}`, seen);
109
+ if (done !== undefined)
110
+ entries.push([key, done]);
111
+ }
112
+ return Object.fromEntries(entries);
113
+ }
114
+ finally {
115
+ seen.delete(object);
116
+ }
117
+ }
118
+ /**
119
+ * `value` as JSON values, by the wire's rules. `undefined` is left out of an object and written
120
+ * `null` in a list, as JSON does.
121
+ */
122
+ export function valuesOf(value) {
123
+ return written(value, "value", new Set()) ?? null;
124
+ }
125
+ /** One JSON object back as what it stood for: a lone `$bytes` tag is its bytes. */
126
+ function restored(object) {
127
+ const keys = Object.keys(object);
128
+ if (keys.length === 1 && keys[0] === BYTES && typeof object[BYTES] === "string")
129
+ return bytesOf(object[BYTES]);
130
+ return object;
131
+ }
132
+ function walked(value) {
133
+ if (Array.isArray(value))
134
+ return value.map(walked);
135
+ if (value !== null && typeof value === "object")
136
+ return restored(Object.fromEntries(Object.entries(value).map(([key, one]) => [key, walked(one)])));
137
+ return value;
138
+ }
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
+ */
144
+ export function readValues(text) {
145
+ if (typeof text === "string")
146
+ return JSON.parse(text, (_key, value) => value !== null && typeof value === "object" && !Array.isArray(value)
147
+ ? restored(value)
148
+ : value);
149
+ return walked(text);
150
+ }
151
+ /** `values` read as `of` — `Money`, a DTO's own reader — or as they are without one. */
152
+ export function rebuilt(values, of) {
153
+ if (of === undefined)
154
+ return values;
155
+ // A class is a function too: one that says how to read itself is read that way.
156
+ const reader = of.fromJSON;
157
+ if (typeof reader === "function")
158
+ return reader.call(of, values);
159
+ return of(values);
160
+ }
161
+ /** `value` as JSON text — see `valuesOf`. */
162
+ export function pack(value) {
163
+ return JSON.stringify(valuesOf(value));
164
+ }
165
+ /** What `pack` wrote, read as `of` — see `rebuilt`. */
166
+ export function unpack(text, of) {
167
+ return rebuilt(readValues(text), of);
168
+ }
@@ -0,0 +1,41 @@
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, never a day
37
+ * the calendar does not have (`"2026-02-30"`, which `Date` would roll into March). */
38
+ export declare function isDay(text: string): boolean;
39
+ /** The day it is at `instant` on the wall clock of `timeZone`: what «today» means for a company.
40
+ * `null` when it is not an instant. */
41
+ export declare function dayOf(instant: string, timeZone: string): string | null;
@@ -0,0 +1,100 @@
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
+ // 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;
63
+ const at = Date.parse(instant);
64
+ if (Number.isNaN(at))
65
+ return null;
66
+ const wall = new Date(wallAt(at, timeZone));
67
+ return (`${wall.getUTCFullYear()}-${pad(wall.getUTCMonth() + 1)}-${pad(wall.getUTCDate())}` +
68
+ `T${pad(wall.getUTCHours())}:${pad(wall.getUTCMinutes())}`);
69
+ }
70
+ /**
71
+ * `"2026-03-12T10:30"` on the wall clock of `timeZone` → the instant, `"2026-03-12T14:30:00.000Z"`.
72
+ * `null` when it is not a wall time.
73
+ *
74
+ * Context: the offset depends on the instant (daylight saving), so it is asked twice: at the
75
+ * first guess, and again at the answer when the guess fell on the other side of a change.
76
+ */
77
+ export function instantOf(wall, timeZone) {
78
+ const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(wall);
79
+ if (match === null)
80
+ return null;
81
+ const [, year, month, day, hour, minute] = match.map(Number);
82
+ const guess = Date.UTC(year, month - 1, day, hour, minute);
83
+ const first = guess - (wallAt(guess, timeZone) - guess);
84
+ const second = guess - (wallAt(first, timeZone) - first);
85
+ return new Date(second).toISOString();
86
+ }
87
+ const DAY = /^\d{4}-\d{2}-\d{2}$/;
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). */
90
+ export function isDay(text) {
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;
95
+ }
96
+ /** The day it is at `instant` on the wall clock of `timeZone`: what «today» means for a company.
97
+ * `null` when it is not an instant. */
98
+ export function dayOf(instant, timeZone) {
99
+ return wallTimeOf(instant, timeZone)?.slice(0, 10) ?? null;
100
+ }
@@ -0,0 +1,26 @@
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
+ /** The header the context travels in: the one sincpro_framework's entrances read. */
17
+ export declare const CONTEXT_HEADER = "sincpro-context";
18
+ export interface ContextCodec {
19
+ dumps(values: ContextValues): string;
20
+ loads(text: string): ContextValues;
21
+ }
22
+ /** Whether a value travels: a plain value, or a list of them. */
23
+ export declare function travels(value: unknown): boolean;
24
+ /** The values of `context` that travel; the rest stays in this process. */
25
+ export declare function travelling(context: ContextValues): ContextValues;
26
+ export declare const plainCodec: ContextCodec;
@@ -0,0 +1,44 @@
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";
17
+ function isPlain(value) {
18
+ return (value === null ||
19
+ typeof value === "string" ||
20
+ typeof value === "boolean" ||
21
+ (typeof value === "number" && Number.isFinite(value)));
22
+ }
23
+ /** Whether a value travels: a plain value, or a list of them. */
24
+ export function travels(value) {
25
+ return isPlain(value) || (Array.isArray(value) && value.every(isPlain));
26
+ }
27
+ /** The values of `context` that travel; the rest stays in this process. */
28
+ export function travelling(context) {
29
+ return Object.fromEntries(Object.entries(context).filter(([, value]) => travels(value)));
30
+ }
31
+ export const plainCodec = {
32
+ dumps: (values) => JSON.stringify(travelling(values)),
33
+ loads(text) {
34
+ try {
35
+ const found = JSON.parse(text);
36
+ return found !== null && typeof found === "object" && !Array.isArray(found)
37
+ ? found
38
+ : {};
39
+ }
40
+ catch {
41
+ return {};
42
+ }
43
+ },
44
+ };
package/dist/index.d.ts CHANGED
@@ -6,8 +6,10 @@
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, client-side ids
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
14
  * services/ the bounded context (`UseBoundedContext`), its `Feature` and `ApplicationService`,
13
15
  * the process they run in (`configureProcess`), what each run carries
@@ -19,11 +21,16 @@
19
21
  */
20
22
  export { type ClientContract, entityKeyOf, kindOf, metasOf, versionsOf, } from "./client/contract.js";
21
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";
22
- export { isEntityId, newEntityId } from "./client/ids.js";
23
24
  export { type MemoryOptions, type MemoryTable, memoryTransport, } from "./client/memory.js";
24
25
  export { type RestOptions, restTransport, urlOf, type VersionDrift, } from "./client/rest.js";
25
26
  export { type RetryKeys, retryKeys, unanswered, } from "./client/retry.js";
26
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 { CONTEXT_HEADER, type ContextCodec, plainCodec, travelling, travels, } from "./context/codec.js";
27
34
  export { type Context } from "./context/context.js";
28
35
  export { type ContextStore, type ContextValues, memoryContexts, } from "./context/store.js";
29
36
  export { defineMeta, field, type FieldOptions, type MetaSpec, type Words, } from "./model/meta.js";
@@ -34,3 +41,5 @@ export { type BoundedContext, UseBoundedContext, } from "./services/bounded-cont
34
41
  export { BIND, type Bindable, bindable, bindDependency, bindTransport, type Execution, headersOf, } from "./services/execution.js";
35
42
  export { ApplicationService, Feature, type ServiceClasses, type ServiceMethods, type Services, } from "./services/feature.js";
36
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";