@sincpro/client-framework 0.3.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 +22 -11
- package/MANIFESTO.md +7 -3
- package/README.md +30 -0
- package/dist/client/memory.js +6 -5
- 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/index.d.ts +9 -2
- package/dist/index.js +9 -2
- package/dist/services/bounded-context.js +1 -1
- package/dist/services/execution.d.ts +2 -2
- package/dist/services/execution.js +6 -4
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -20,17 +20,18 @@ it says what this package is responsible for and what it is not. This file is ho
|
|
|
20
20
|
|
|
21
21
|
## Where things go
|
|
22
22
|
|
|
23
|
-
| Folder | What
|
|
24
|
-
| -------------------- |
|
|
25
|
-
| `src/
|
|
26
|
-
| `src/
|
|
27
|
-
| `src/
|
|
28
|
-
| `src/
|
|
29
|
-
| `src/
|
|
30
|
-
| `src/
|
|
31
|
-
| `src/
|
|
32
|
-
| `src/
|
|
33
|
-
| `
|
|
23
|
+
| Folder | What |
|
|
24
|
+
| -------------------- | --------------------------------------------------------------------------------------------- |
|
|
25
|
+
| `src/common/` | The values of the wire: ids, a day and an instant, `Decimal`, `Money`, `valuesOf` / `rebuilt` |
|
|
26
|
+
| `src/client/` | The seam with a server: `Transport`, REST, memory, `entity()`, the client contract |
|
|
27
|
+
| `src/model/` | Model definitions: `defineMeta`, from a JSON Schema, the store of known ones |
|
|
28
|
+
| `src/services/` | `UseBoundedContext`, `Feature`, `ApplicationService`, the process, the execution |
|
|
29
|
+
| `src/context/` | The `ContextStore` port, `memoryContexts`, and `Context` — one level of it |
|
|
30
|
+
| `src/observability/` | The ports a platform plugs: `Tracer`, `ErrorReporter` |
|
|
31
|
+
| `src/codegen/` | The generator and its CLI (`sincpro-client generate`) — the only place Node is allowed |
|
|
32
|
+
| `src/adapters/` | Optional entries over a vendor (`./opentelemetry`, `./sentry`): the only place one is named |
|
|
33
|
+
| `src/testing/` | `memoryTracer`, `memoryReporter` — the ports in memory, for tests |
|
|
34
|
+
| `tests/` | vitest in Node; `tests/support/` holds the fake domains and forge's real contracts |
|
|
34
35
|
|
|
35
36
|
## Hard rules (the build enforces them — `make doctor`)
|
|
36
37
|
|
|
@@ -90,6 +91,16 @@ it says what this package is responsible for and what it is not. This file is ho
|
|
|
90
91
|
an `AbortError` is `aborted`; neither is reported (`outcomeOf`). Change that with
|
|
91
92
|
`ProcessOptions.outcomeOf`, never by catching in a Feature.
|
|
92
93
|
|
|
94
|
+
- **Only what is transversal comes here.** A value's wire form (a decimal as text, a day never
|
|
95
|
+
an instant, a money with its currency) is the backend's contract, the same on every platform,
|
|
96
|
+
so it lives in `src/common/`. How a value is shown to a person, typed in an input, or kept in
|
|
97
|
+
SQLite or IndexedDB is the platform's — @sincpro/web-framework's or @sincpro/mobile's.
|
|
98
|
+
- **A decimal never becomes a `number`.** It travels as text and is computed with `Decimal` or
|
|
99
|
+
`Money` (exact, on `BigInt`); `Number(...)` or `parseFloat` on an amount is the float the
|
|
100
|
+
backend sends text to avoid. And a day is never a `Date`: a `Date` is an instant.
|
|
101
|
+
- **The in-memory server copies by the wire's rules** (`valuesOf` → `readValues`): what it receives
|
|
102
|
+
and answers is what a real server would — a `Money` as `{ amount, currency }`, a `Date` as text.
|
|
103
|
+
|
|
93
104
|
## Before you hand it back
|
|
94
105
|
|
|
95
106
|
```bash
|
package/MANIFESTO.md
CHANGED
|
@@ -48,10 +48,14 @@ server-side render, in a script and in a test, unchanged.**
|
|
|
48
48
|
never import each other's insides.
|
|
49
49
|
6. **The client.** The `Transport` port and its implementations (REST, memory), `entity()` with
|
|
50
50
|
typed commands and answers, the service's client contract, model definitions read once from
|
|
51
|
-
the server
|
|
52
|
-
7. **The
|
|
51
|
+
the server and the server's `Problem`.
|
|
52
|
+
7. **The values of the wire.** How every value travels, as the backend writes it — a decimal as
|
|
53
|
+
text, a money with its currency, a day that is never an instant, bytes tagged, ids as UUID v7 —
|
|
54
|
+
and the exact arithmetic a client needs on them (`Decimal`, `Money`). Only the wire form and
|
|
55
|
+
what follows from it: showing a value to a person is each platform's.
|
|
56
|
+
8. **The generator.** From a service's client contract to each domain's types, client and
|
|
53
57
|
entities — generated, never edited, and as platform-neutral as everything else here.
|
|
54
|
-
|
|
58
|
+
9. **Testing.** An in-memory transport, tracer and reporter, so a use case is proven in Node with
|
|
55
59
|
nothing else.
|
|
56
60
|
|
|
57
61
|
## What it is not
|
package/README.md
CHANGED
|
@@ -267,6 +267,36 @@ Nothing is installed or shipped unless it is imported, and without a tracer or a
|
|
|
267
267
|
case runs the same — nothing traces, nothing reports. In a test, `memoryTracer()` and
|
|
268
268
|
`memoryReporter()` (`@sincpro/client-framework/testing`).
|
|
269
269
|
|
|
270
|
+
## Values on the wire
|
|
271
|
+
|
|
272
|
+
The backend writes every message with one set of rules, and every client reads and writes them
|
|
273
|
+
the same — so they live here, not in each platform:
|
|
274
|
+
|
|
275
|
+
| Value | Travels as | Here |
|
|
276
|
+
| ----------- | ------------------------------------------ | ------------------------------------------------ |
|
|
277
|
+
| a decimal | text, every digit: `"10.50"` | `Decimal` — exact, on `BigInt`; rounds half up |
|
|
278
|
+
| a money | `{ "amount": "10.50", "currency": "BOB" }` | `Money` — never adds two currencies |
|
|
279
|
+
| a day | `"2026-10-02"`, never converted | `isDay`, `dayOf(instant, zone)` for «today» |
|
|
280
|
+
| an instant | ISO text: `"2026-10-02T14:30:00Z"` | `wallTimeOf` / `instantOf` on the company's zone |
|
|
281
|
+
| an id | a UUID v7 as 32 hex | `newEntityId`, `isEntityId`, `momentOf` |
|
|
282
|
+
| bytes | `{ "$bytes": "<base64>" }` | `valuesOf` / `readValues` |
|
|
283
|
+
| the context | only what travels: text, numbers, lists | `plainCodec`, `travelling` |
|
|
284
|
+
|
|
285
|
+
```ts
|
|
286
|
+
import { Money, pack, unpack, valuesOf } from "@sincpro/client-framework";
|
|
287
|
+
|
|
288
|
+
const line = new Money("19.90", "BOB").times("3"); // 59.70 BOB, exact
|
|
289
|
+
const total = Money.total([line, delivery], "BOB").rounded(); // to the currency's decimals
|
|
290
|
+
valuesOf({ total, at: new Date() }); // { total: { amount, currency }, at: "…Z" }
|
|
291
|
+
const answer = unpack(text, Money); // a typed read when asked for — never imposed
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
Not restrictive, like the backend: reading keeps every key it finds, a typed read is asked for
|
|
295
|
+
(`rebuilt(values, Money)`), any currency code is read, and an application adds its own decimals
|
|
296
|
+
(`Money.decimals["USDT"] = 6`). What JSON cannot write (`NaN`, a function, a cycle) is refused at
|
|
297
|
+
once, naming where. Showing a value to a person — `Intl.NumberFormat`, a date picker — is each
|
|
298
|
+
platform's, never this package's.
|
|
299
|
+
|
|
270
300
|
## Where it sits
|
|
271
301
|
|
|
272
302
|
```mermaid
|
package/dist/client/memory.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { TransportError, } from "./transport.js";
|
|
2
|
+
import { readValues, valuesOf } from "../common/serialization.js";
|
|
2
3
|
import { answer, answerGroups } from "@sincpro/criteria/engine";
|
|
3
4
|
/**
|
|
4
|
-
* A deep copy
|
|
5
|
-
*
|
|
5
|
+
* A deep copy written and read by the wire's rules (`valuesOf`, `readValues`): what a real server
|
|
6
|
+
* would have received and answered — a `Money` as `{ amount, currency }`, a `Decimal` as its text,
|
|
7
|
+
* a `Date` as an instant, bytes as bytes — the same on every platform. `structuredClone` kept a
|
|
8
|
+
* `Date` a `Date` in a browser and a JSON round trip made it text on a phone; neither is a wire.
|
|
6
9
|
*/
|
|
7
|
-
const copyOf =
|
|
8
|
-
? (value) => globalThis.structuredClone(value)
|
|
9
|
-
: (value) => (value === undefined ? value : JSON.parse(JSON.stringify(value)));
|
|
10
|
+
const copyOf = (value) => value === undefined ? value : readValues(valuesOf(value));
|
|
10
11
|
function wait(ms, signal) {
|
|
11
12
|
if (ms <= 0)
|
|
12
13
|
return Promise.resolve();
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An exact decimal — what sincpro_framework's `Decimal` is on the wire: text, every digit kept.
|
|
3
|
+
*
|
|
4
|
+
* new Decimal("10.50").plus("0.25") → 10.75
|
|
5
|
+
* new Decimal("2.500").times("19.90") → 49.75000
|
|
6
|
+
* new Decimal("10").dividedBy("3", 2) → 3.33 half up, as the backend rounds
|
|
7
|
+
* JSON.stringify({ qty: new Decimal("2.500") }) → {"qty":"2.500"}
|
|
8
|
+
*
|
|
9
|
+
* Context: the backend sends a decimal as text (`"10.50"`) so no client reads it as a float —
|
|
10
|
+
* `0.1 + 0.2` is `0.30000000000000004` in every JavaScript engine, and an invoice cannot be. A
|
|
11
|
+
* client that adds, multiplies or rounds an amount (a cart, a line, a discount) does it here,
|
|
12
|
+
* on `BigInt`, which browsers, Node and Hermes have; it depends on nothing. It never rounds by
|
|
13
|
+
* accident: `plus`, `minus` and `times` are exact, and only `rounded` and `dividedBy` round —
|
|
14
|
+
* half up, away from zero, the backend's `ROUND_HALF_UP`. Formatting it for a person is the
|
|
15
|
+
* platform's (`Intl.NumberFormat`), never this.
|
|
16
|
+
*/
|
|
17
|
+
export type DecimalLike = Decimal | string | number | bigint;
|
|
18
|
+
export declare class Decimal {
|
|
19
|
+
/** The digits, as an integer: `10.50` is `1050n` at scale 2. */
|
|
20
|
+
readonly units: bigint;
|
|
21
|
+
/** How many of the digits are after the point. */
|
|
22
|
+
readonly scale: number;
|
|
23
|
+
/**
|
|
24
|
+
* A decimal from its text (`"10.50"`, `"-0.5"`, `"1e3"`), a number — taken by its digits,
|
|
25
|
+
* `0.1` is `"0.1"` — or a whole `bigint`. Anything else is refused, naming it.
|
|
26
|
+
*/
|
|
27
|
+
constructor(value: DecimalLike);
|
|
28
|
+
static of(value: DecimalLike): Decimal;
|
|
29
|
+
private static at;
|
|
30
|
+
/** Both at the larger scale, so their units compare and add. */
|
|
31
|
+
private static aligned;
|
|
32
|
+
plus(other: DecimalLike): Decimal;
|
|
33
|
+
minus(other: DecimalLike): Decimal;
|
|
34
|
+
times(factor: DecimalLike): Decimal;
|
|
35
|
+
/** This over `divisor`, with `decimals` digits after the point, rounded half up. */
|
|
36
|
+
dividedBy(divisor: DecimalLike, decimals: number): Decimal;
|
|
37
|
+
/** With `decimals` digits after the point: rounded half up when it had more, padded when fewer. */
|
|
38
|
+
rounded(decimals: number): Decimal;
|
|
39
|
+
negated(): Decimal;
|
|
40
|
+
/** `-1`, `0` or `1`: `10.5` and `10.50` are the same amount. */
|
|
41
|
+
compare(other: DecimalLike): -1 | 0 | 1;
|
|
42
|
+
equals(other: DecimalLike): boolean;
|
|
43
|
+
get isZero(): boolean;
|
|
44
|
+
get isNegative(): boolean;
|
|
45
|
+
get isPositive(): boolean;
|
|
46
|
+
/** The text the wire carries: every digit, the scale kept (`"10.50"`, never `"10.5"`). */
|
|
47
|
+
toString(): string;
|
|
48
|
+
toJSON(): string;
|
|
49
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An exact decimal — what sincpro_framework's `Decimal` is on the wire: text, every digit kept.
|
|
3
|
+
*
|
|
4
|
+
* new Decimal("10.50").plus("0.25") → 10.75
|
|
5
|
+
* new Decimal("2.500").times("19.90") → 49.75000
|
|
6
|
+
* new Decimal("10").dividedBy("3", 2) → 3.33 half up, as the backend rounds
|
|
7
|
+
* JSON.stringify({ qty: new Decimal("2.500") }) → {"qty":"2.500"}
|
|
8
|
+
*
|
|
9
|
+
* Context: the backend sends a decimal as text (`"10.50"`) so no client reads it as a float —
|
|
10
|
+
* `0.1 + 0.2` is `0.30000000000000004` in every JavaScript engine, and an invoice cannot be. A
|
|
11
|
+
* client that adds, multiplies or rounds an amount (a cart, a line, a discount) does it here,
|
|
12
|
+
* on `BigInt`, which browsers, Node and Hermes have; it depends on nothing. It never rounds by
|
|
13
|
+
* accident: `plus`, `minus` and `times` are exact, and only `rounded` and `dividedBy` round —
|
|
14
|
+
* half up, away from zero, the backend's `ROUND_HALF_UP`. Formatting it for a person is the
|
|
15
|
+
* platform's (`Intl.NumberFormat`), never this.
|
|
16
|
+
*/
|
|
17
|
+
const NUMBER = /^([+-]?)(\d*)(?:\.(\d*))?(?:[eE]([+-]?\d{1,4}))?$/;
|
|
18
|
+
const TEN = 10n;
|
|
19
|
+
function pow10(exponent) {
|
|
20
|
+
return TEN ** BigInt(exponent);
|
|
21
|
+
}
|
|
22
|
+
/** `units / divisor` rounded half up, away from zero. */
|
|
23
|
+
function halfUp(units, divisor) {
|
|
24
|
+
const quotient = units / divisor;
|
|
25
|
+
const rest = units % divisor;
|
|
26
|
+
const twice = (rest < 0n ? -rest : rest) * 2n;
|
|
27
|
+
const whole = divisor < 0n ? -divisor : divisor;
|
|
28
|
+
if (twice < whole)
|
|
29
|
+
return quotient;
|
|
30
|
+
return units < 0n !== divisor < 0n ? quotient - 1n : quotient + 1n;
|
|
31
|
+
}
|
|
32
|
+
export class Decimal {
|
|
33
|
+
/** The digits, as an integer: `10.50` is `1050n` at scale 2. */
|
|
34
|
+
units;
|
|
35
|
+
/** How many of the digits are after the point. */
|
|
36
|
+
scale;
|
|
37
|
+
/**
|
|
38
|
+
* A decimal from its text (`"10.50"`, `"-0.5"`, `"1e3"`), a number — taken by its digits,
|
|
39
|
+
* `0.1` is `"0.1"` — or a whole `bigint`. Anything else is refused, naming it.
|
|
40
|
+
*/
|
|
41
|
+
constructor(value) {
|
|
42
|
+
if (value instanceof Decimal) {
|
|
43
|
+
this.units = value.units;
|
|
44
|
+
this.scale = value.scale;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (typeof value === "bigint") {
|
|
48
|
+
this.units = value;
|
|
49
|
+
this.scale = 0;
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (typeof value === "number" && !Number.isFinite(value))
|
|
53
|
+
throw new TypeError(`${value} is not a decimal`);
|
|
54
|
+
const text = typeof value === "number" ? String(value) : String(value).trim();
|
|
55
|
+
const match = NUMBER.exec(text);
|
|
56
|
+
const [, sign = "", whole = "", fraction = "", exponent = "0"] = match ?? [];
|
|
57
|
+
if (match === null || whole + fraction === "")
|
|
58
|
+
throw new TypeError(`"${text}" is not a decimal`);
|
|
59
|
+
let digits = whole + fraction;
|
|
60
|
+
let scale = fraction.length - Number(exponent);
|
|
61
|
+
if (scale < 0) {
|
|
62
|
+
digits += "0".repeat(-scale);
|
|
63
|
+
scale = 0;
|
|
64
|
+
}
|
|
65
|
+
this.units = BigInt(digits) * (sign === "-" ? -1n : 1n);
|
|
66
|
+
this.scale = scale;
|
|
67
|
+
}
|
|
68
|
+
static of(value) {
|
|
69
|
+
return value instanceof Decimal ? value : new Decimal(value);
|
|
70
|
+
}
|
|
71
|
+
static at(units, scale) {
|
|
72
|
+
const made = Object.create(Decimal.prototype);
|
|
73
|
+
made.units = units;
|
|
74
|
+
made.scale = scale;
|
|
75
|
+
return made;
|
|
76
|
+
}
|
|
77
|
+
/** Both at the larger scale, so their units compare and add. */
|
|
78
|
+
static aligned(one, other) {
|
|
79
|
+
const scale = Math.max(one.scale, other.scale);
|
|
80
|
+
return [
|
|
81
|
+
one.units * pow10(scale - one.scale),
|
|
82
|
+
other.units * pow10(scale - other.scale),
|
|
83
|
+
scale,
|
|
84
|
+
];
|
|
85
|
+
}
|
|
86
|
+
plus(other) {
|
|
87
|
+
const [one, two, scale] = Decimal.aligned(this, Decimal.of(other));
|
|
88
|
+
return Decimal.at(one + two, scale);
|
|
89
|
+
}
|
|
90
|
+
minus(other) {
|
|
91
|
+
const [one, two, scale] = Decimal.aligned(this, Decimal.of(other));
|
|
92
|
+
return Decimal.at(one - two, scale);
|
|
93
|
+
}
|
|
94
|
+
times(factor) {
|
|
95
|
+
const by = Decimal.of(factor);
|
|
96
|
+
return Decimal.at(this.units * by.units, this.scale + by.scale);
|
|
97
|
+
}
|
|
98
|
+
/** This over `divisor`, with `decimals` digits after the point, rounded half up. */
|
|
99
|
+
dividedBy(divisor, decimals) {
|
|
100
|
+
const by = Decimal.of(divisor);
|
|
101
|
+
if (by.units === 0n)
|
|
102
|
+
throw new RangeError("a decimal divided by zero");
|
|
103
|
+
const numerator = this.units * pow10(by.scale + decimals);
|
|
104
|
+
const denominator = by.units * pow10(this.scale);
|
|
105
|
+
return Decimal.at(halfUp(numerator, denominator), decimals);
|
|
106
|
+
}
|
|
107
|
+
/** With `decimals` digits after the point: rounded half up when it had more, padded when fewer. */
|
|
108
|
+
rounded(decimals) {
|
|
109
|
+
if (decimals >= this.scale)
|
|
110
|
+
return Decimal.at(this.units * pow10(decimals - this.scale), decimals);
|
|
111
|
+
return Decimal.at(halfUp(this.units, pow10(this.scale - decimals)), decimals);
|
|
112
|
+
}
|
|
113
|
+
negated() {
|
|
114
|
+
return Decimal.at(-this.units, this.scale);
|
|
115
|
+
}
|
|
116
|
+
/** `-1`, `0` or `1`: `10.5` and `10.50` are the same amount. */
|
|
117
|
+
compare(other) {
|
|
118
|
+
const [one, two] = Decimal.aligned(this, Decimal.of(other));
|
|
119
|
+
return one < two ? -1 : one > two ? 1 : 0;
|
|
120
|
+
}
|
|
121
|
+
equals(other) {
|
|
122
|
+
return this.compare(other) === 0;
|
|
123
|
+
}
|
|
124
|
+
get isZero() {
|
|
125
|
+
return this.units === 0n;
|
|
126
|
+
}
|
|
127
|
+
get isNegative() {
|
|
128
|
+
return this.units < 0n;
|
|
129
|
+
}
|
|
130
|
+
get isPositive() {
|
|
131
|
+
return this.units > 0n;
|
|
132
|
+
}
|
|
133
|
+
/** The text the wire carries: every digit, the scale kept (`"10.50"`, never `"10.5"`). */
|
|
134
|
+
toString() {
|
|
135
|
+
const negative = this.units < 0n;
|
|
136
|
+
const digits = (negative ? -this.units : this.units)
|
|
137
|
+
.toString()
|
|
138
|
+
.padStart(this.scale + 1, "0");
|
|
139
|
+
const whole = digits.slice(0, digits.length - this.scale);
|
|
140
|
+
const fraction = digits.slice(digits.length - this.scale);
|
|
141
|
+
return `${negative ? "-" : ""}${whole}${this.scale > 0 ? `.${fraction}` : ""}`;
|
|
142
|
+
}
|
|
143
|
+
toJSON() {
|
|
144
|
+
return this.toString();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -15,3 +15,10 @@
|
|
|
15
15
|
export declare function newEntityId(now?: number): string;
|
|
16
16
|
/** A key that is a UUID v7 as sincpro_framework writes it: 32 hex, version 7, variant `10`. */
|
|
17
17
|
export declare function isEntityId(text: string): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* When a UUID v7 was minted, read off its leading 48 bits — sincpro_framework's `moment_of`;
|
|
20
|
+
* `null` for a key that is not one (a project's own format, a UUID v4).
|
|
21
|
+
*
|
|
22
|
+
* momentOf(newEntityId()) → "2026-11-06T22:41:07.512Z", the moment it was minted
|
|
23
|
+
*/
|
|
24
|
+
export declare function momentOf(identity: string): string | null;
|
|
@@ -50,3 +50,15 @@ export function newEntityId(now = Date.now()) {
|
|
|
50
50
|
export function isEntityId(text) {
|
|
51
51
|
return /^[0-9a-f]{12}7[0-9a-f]{3}[89ab][0-9a-f]{15}$/.test(text);
|
|
52
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* When a UUID v7 was minted, read off its leading 48 bits — sincpro_framework's `moment_of`;
|
|
55
|
+
* `null` for a key that is not one (a project's own format, a UUID v4).
|
|
56
|
+
*
|
|
57
|
+
* momentOf(newEntityId()) → "2026-11-06T22:41:07.512Z", the moment it was minted
|
|
58
|
+
*/
|
|
59
|
+
export function momentOf(identity) {
|
|
60
|
+
const hex = identity.replaceAll("-", "").toLowerCase();
|
|
61
|
+
if (!isEntityId(hex))
|
|
62
|
+
return null;
|
|
63
|
+
return new Date(Number.parseInt(hex.slice(0, 12), 16)).toISOString();
|
|
64
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Decimal, type DecimalLike } 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 declare class Money {
|
|
23
|
+
/** Decimals by currency where they are not 2: ISO 4217's, and what an application adds. */
|
|
24
|
+
static readonly decimals: Record<string, number>;
|
|
25
|
+
/** The amount as the wire carries it: every digit, the scale kept (`"10.50"`). */
|
|
26
|
+
readonly amount: string;
|
|
27
|
+
/** The currency's code, upper case (`"BOB"`). */
|
|
28
|
+
readonly currency: string;
|
|
29
|
+
constructor(amount: DecimalLike, currency: string);
|
|
30
|
+
/**
|
|
31
|
+
* What the server sent — `{ amount, currency }`, the amount as text or as a JSON number taken
|
|
32
|
+
* by its digits. Anything else is refused, naming what is wrong.
|
|
33
|
+
*/
|
|
34
|
+
static fromJSON(value: unknown): Money;
|
|
35
|
+
/** Whether `value` has the shape of a money on the wire: `{ amount, currency }`. */
|
|
36
|
+
static isMoney(value: unknown): value is {
|
|
37
|
+
amount: string | number;
|
|
38
|
+
currency: string;
|
|
39
|
+
};
|
|
40
|
+
/** The decimals `currency` is counted in: 2 unless `Money.decimals` says otherwise. */
|
|
41
|
+
static decimalsOf(currency: string): number;
|
|
42
|
+
/** The sum of `items`, all in `currency` — zero when there are none. Another currency is refused. */
|
|
43
|
+
static total(items: Iterable<Money>, currency: string): Money;
|
|
44
|
+
/** The sums by currency, each currency's own. */
|
|
45
|
+
static totals(items: Iterable<Money>): Readonly<Record<string, Money>>;
|
|
46
|
+
/** The amount as an exact decimal. */
|
|
47
|
+
get value(): Decimal;
|
|
48
|
+
/** The decimals this currency is counted in. */
|
|
49
|
+
get decimals(): number;
|
|
50
|
+
private same;
|
|
51
|
+
plus(other: Money): Money;
|
|
52
|
+
minus(other: Money): Money;
|
|
53
|
+
/** Times a quantity, a rate or a percentage as a fraction: exact, never rounded. */
|
|
54
|
+
times(factor: DecimalLike): Money;
|
|
55
|
+
/** Over `divisor`, rounded half up to the currency's decimals (or `decimals`). */
|
|
56
|
+
dividedBy(divisor: DecimalLike, decimals?: number): Money;
|
|
57
|
+
/** Rounded half up to the currency's decimals (or `decimals`): what an invoice shows. */
|
|
58
|
+
rounded(decimals?: number): Money;
|
|
59
|
+
negated(): Money;
|
|
60
|
+
/** `-1`, `0` or `1`, in the same currency. */
|
|
61
|
+
compare(other: Money): -1 | 0 | 1;
|
|
62
|
+
/** The same currency and the same amount: `10.5 BOB` equals `10.50 BOB`. */
|
|
63
|
+
equals(other: Money): boolean;
|
|
64
|
+
/** Zero at the currency's decimals: `0.004 BOB` is. */
|
|
65
|
+
get isZero(): boolean;
|
|
66
|
+
get isNegative(): boolean;
|
|
67
|
+
get isPositive(): boolean;
|
|
68
|
+
toJSON(): {
|
|
69
|
+
amount: string;
|
|
70
|
+
currency: string;
|
|
71
|
+
};
|
|
72
|
+
/** `"10.50 BOB"` — for a log or a test; a person reads it through `Intl.NumberFormat`. */
|
|
73
|
+
toString(): string;
|
|
74
|
+
}
|
|
@@ -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,49 @@
|
|
|
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
|
+
/** What `valuesOf` wrote, from JSON text or already parsed — each tagged bytes back as bytes. */
|
|
43
|
+
export declare function readValues(text: string | unknown): unknown;
|
|
44
|
+
/** `values` read as `of` — `Money`, a DTO's own reader — or as they are without one. */
|
|
45
|
+
export declare function rebuilt<T = unknown>(values: unknown, of?: Rebuild<T>): T;
|
|
46
|
+
/** `value` as JSON text — see `valuesOf`. */
|
|
47
|
+
export declare function pack(value: unknown): string;
|
|
48
|
+
/** What `pack` wrote, read as `of` — see `rebuilt`. */
|
|
49
|
+
export declare function unpack<T = unknown>(text: string, of?: Rebuild<T>): T;
|
|
@@ -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
|
+
};
|
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
|
|
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 { 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";
|
package/dist/index.js
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
|
|
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 { entityKeyOf, kindOf, metasOf, versionsOf, } from "./client/contract.js";
|
|
21
23
|
export { bodyFor, entity, listRoute, NEW_PARAM, pathFor, PICK_PARAM, QUERY_PARAM, SCOPE_PARAM, scopeOfRef, startingValuesOf, TRAIL_PARAM, WITH_PARAM, writeRoute, } from "./client/entity.js";
|
|
22
|
-
export { isEntityId, newEntityId } from "./client/ids.js";
|
|
23
24
|
export { memoryTransport, } from "./client/memory.js";
|
|
24
25
|
export { restTransport, urlOf, } from "./client/rest.js";
|
|
25
26
|
export { retryKeys, unanswered, } from "./client/retry.js";
|
|
26
27
|
export { readRoute, TransportError, } from "./client/transport.js";
|
|
28
|
+
export { Decimal } from "./common/decimal.js";
|
|
29
|
+
export { isEntityId, momentOf, newEntityId } from "./common/ids.js";
|
|
30
|
+
export { Money } from "./common/money.js";
|
|
31
|
+
export { BYTES, pack, readValues, rebuilt, unpack, valuesOf, } from "./common/serialization.js";
|
|
32
|
+
export { dayOf, installTimeZone, instantOf, isDay, timeZoneNow, wallTimeOf, } from "./common/time.js";
|
|
33
|
+
export { plainCodec, travelling, travels, } from "./context/codec.js";
|
|
27
34
|
export {} from "./context/context.js";
|
|
28
35
|
export { memoryContexts, } from "./context/store.js";
|
|
29
36
|
export { defineMeta, field, } from "./model/meta.js";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { newEntityId } from "../
|
|
1
|
+
import { newEntityId } from "../common/ids.js";
|
|
2
2
|
import { contextAt } from "../context/context.js";
|
|
3
3
|
import { bindDependency } from "./execution.js";
|
|
4
4
|
import { composes, isApplicationServiceClass, isFeatureClass, methodsOf, } from "./feature.js";
|
|
@@ -33,8 +33,8 @@ export type Bindable<T> = {
|
|
|
33
33
|
};
|
|
34
34
|
/**
|
|
35
35
|
* What every request of an execution carries: its trace (`traceparent`, W3C) and its context
|
|
36
|
-
* (`sincpro-context`, the header sincpro_framework's entrances read
|
|
37
|
-
* trace and no context.
|
|
36
|
+
* (`sincpro-context`, the header sincpro_framework's entrances read, written with `plainCodec`).
|
|
37
|
+
* Nothing when there is no trace and no context that travels.
|
|
38
38
|
*/
|
|
39
39
|
export declare function headersOf(execution: Execution): Record<string, string>;
|
|
40
40
|
/**
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { plainCodec, travels } from "../context/codec.js";
|
|
1
2
|
/**
|
|
2
3
|
* A dependency that has something to say about the execution it is used in: given that
|
|
3
4
|
* execution, it answers itself bound to it. The transport and every generated client are;
|
|
@@ -19,8 +20,8 @@ function isTransport(value) {
|
|
|
19
20
|
}
|
|
20
21
|
/**
|
|
21
22
|
* What every request of an execution carries: its trace (`traceparent`, W3C) and its context
|
|
22
|
-
* (`sincpro-context`, the header sincpro_framework's entrances read
|
|
23
|
-
* trace and no context.
|
|
23
|
+
* (`sincpro-context`, the header sincpro_framework's entrances read, written with `plainCodec`).
|
|
24
|
+
* Nothing when there is no trace and no context that travels.
|
|
24
25
|
*/
|
|
25
26
|
export function headersOf(execution) {
|
|
26
27
|
const headers = {};
|
|
@@ -31,8 +32,9 @@ export function headersOf(execution) {
|
|
|
31
32
|
if (span.state)
|
|
32
33
|
headers["tracestate"] = span.state;
|
|
33
34
|
}
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
// Only what travels: an object or a function held in the context stays in this process.
|
|
36
|
+
if (Object.values(context).some(travels))
|
|
37
|
+
headers["sincpro-context"] = plainCodec.dumps(context);
|
|
36
38
|
return headers;
|
|
37
39
|
}
|
|
38
40
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sincpro/client-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "The client side of sincpro_framework, for every platform: Feature and ApplicationService traced and reported by default, applications made of domains, and the typed client generated from a service's contract. No React, no DOM.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|