@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
@@ -1,4 +1,4 @@
1
- import { claimed, contextOf, deprecationOf, docOf, headerOf, identifierOf, keyOf, localOf, sorted, } from "./names.js";
1
+ import { answerOf, claimed, contextOf, deprecationOf, docOf, headerOf, identifierOf, keyOf, localOf, sorted, } from "./names.js";
2
2
  /**
3
3
  * The DTOs of a bounded context as TypeScript, out of the contract sincpro_framework exports:
4
4
  * a type per model, per shared definition a model or an operation reaches, and per operation's
@@ -150,7 +150,8 @@ function operationDoc(metamodel, local, operation) {
150
150
  *
151
151
  * 1. The models, by name: an entity's contract, a related model by its own name.
152
152
  * 2. Each operation, by its local name (its identity without `<context>.`): its input as
153
- * `<Name>` and its answer as `<Name>Answer`.
153
+ * `<Name>` and its answer by the backend's own name for it (`answerOf`: `ResponseX`), with
154
+ * `<Name>Answer` kept as a deprecated alias — or as `<Name>Answer` when it has no name.
154
155
  * 3. The shared definitions those reach — and the ones those reach, until none is new —, but
155
156
  * never one a package of the stack already types (`Criteria`).
156
157
  * Final: a header that says where it came from and not to edit it, the imports, then models,
@@ -165,15 +166,26 @@ export function domainFileOf(metamodel) {
165
166
  claim(identifierOf(name), `model ${name}`);
166
167
  return `${docOf([schema.description], "")}export type ${identifierOf(name)} = ${typeOf(schema, scope, 0, true)};`;
167
168
  });
169
+ // A Response two Commands share is written once, under the first that answers with it.
170
+ const answered = new Set();
168
171
  const operations = sorted(metamodel.operations).flatMap(([identity, operation]) => {
169
172
  const local = localOf(metamodel, identity);
173
+ const named = answerOf(metamodel, identity);
170
174
  claim(local, `operation ${identity}`);
171
- claim(`${local}Answer`, `the answer of ${identity}`);
172
175
  const answer = operation.output === null ? "unknown" : typeOf(operation.output, scope, 0, false);
173
- return [
176
+ const written = [
174
177
  `${operationDoc(metamodel, local, operation)}export type ${local} = ${typeOf(operation.input, scope, 0, false)};`,
175
- `/** What \`${local}\` answers. */\nexport type ${local}Answer = ${answer};`,
176
178
  ];
179
+ if (!answered.has(named)) {
180
+ answered.add(named);
181
+ claim(named, `the answer of ${identity}`);
182
+ written.push(`/** What \`${local}\` answers. */\nexport type ${named} = ${answer};`);
183
+ }
184
+ if (named !== `${local}Answer`) {
185
+ claim(`${local}Answer`, `the old name of the answer of ${identity}`);
186
+ written.push(`/** @deprecated The backend calls it \`${named}\`: use that name. */\nexport type ${local}Answer = ${named};`);
187
+ }
188
+ return written;
177
189
  });
178
190
  const written = new Map();
179
191
  for (let pending = [...scope.reached]; pending.length > 0;) {
@@ -1,5 +1,5 @@
1
1
  import { entityKeyOf } from "../client/contract.js";
2
- import { aliasOf, claimed, DOMAIN_MODULE, FRAMEWORK, headerOf, identifierOf, isEntity, localOf, methodOf, pluralOf, sorted, } from "./names.js";
2
+ import { aliasOf, answerOf, claimed, DOMAIN_MODULE, FRAMEWORK, headerOf, identifierOf, isEntity, localOf, methodOf, pluralOf, sorted, } from "./names.js";
3
3
  import { ownerOf, restOf, WRITES, } from "./rest.js";
4
4
  /** The one operation of `role` for `entity` a transport can call, by `own` when there are many. */
5
5
  function oneOf(candidates, role) {
@@ -37,7 +37,14 @@ function entitySourceOf(metamodel, model, domain, options, claim) {
37
37
  const rest = restOf(metamodel, identity, options);
38
38
  return rest === undefined
39
39
  ? []
40
- : [{ local: localOf(metamodel, identity), operation, rest }];
40
+ : [
41
+ {
42
+ local: localOf(metamodel, identity),
43
+ answer: answerOf(metamodel, identity),
44
+ operation,
45
+ rest,
46
+ },
47
+ ];
41
48
  });
42
49
  const reads = (role, method) => reached.filter((one) => one.operation.role === role &&
43
50
  method(one.rest.method) &&
@@ -88,7 +95,7 @@ function entitySourceOf(metamodel, model, domain, options, claim) {
88
95
  bodies.push(target === undefined
89
96
  ? ` ${method}: dto.${command.local};`
90
97
  : ` ${method}: Omit<dto.${command.local}, ${JSON.stringify(target)}>;`);
91
- answers.push(` ${method}: dto.${command.local}Answer;`);
98
+ answers.push(` ${method}: dto.${command.answer};`);
92
99
  named.push(target === undefined
93
100
  ? ` ${method}: ${JSON.stringify(route)},`
94
101
  : ` ${method}: { route: ${JSON.stringify(route)}, target: ${JSON.stringify(target)} },`);
@@ -3,8 +3,10 @@
3
3
  * sincpro_framework exports (PRD_01 phase B). A build step that runs in Node — nothing in
4
4
  * the runtime imports it (`make doctor`), and the package's root entry does not re-export it. `sincpro-client generate` (`cli.ts`) is the same, from a terminal.
5
5
  */
6
+ export { CheckError, checkImports, type Place, placeOf, type Violation, violationOf, } from "./check.js";
6
7
  export { clientFileOf } from "./client.js";
7
8
  export { domainFileOf } from "./domain.js";
8
9
  export { entitiesFileOf } from "./entities.js";
9
10
  export type { Bindings, Metamodel, Operation, RestBinding, Schema, } from "./metamodel.js";
10
11
  export type { GenerateOptions } from "./rest.js";
12
+ export { scaffoldFilesOf, type ScaffoldOptions, } from "./scaffold.js";
@@ -3,6 +3,8 @@
3
3
  * sincpro_framework exports (PRD_01 phase B). A build step that runs in Node — nothing in
4
4
  * the runtime imports it (`make doctor`), and the package's root entry does not re-export it. `sincpro-client generate` (`cli.ts`) is the same, from a terminal.
5
5
  */
6
+ export { CheckError, checkImports, placeOf, violationOf, } from "./check.js";
6
7
  export { clientFileOf } from "./client.js";
7
8
  export { domainFileOf } from "./domain.js";
8
9
  export { entitiesFileOf } from "./entities.js";
10
+ export { scaffoldFilesOf, } from "./scaffold.js";
@@ -21,6 +21,18 @@ export declare function aliasOf(metamodel: Metamodel): string;
21
21
  export declare function isEntity(metamodel: Metamodel, model: string): boolean;
22
22
  /** An operation's name in its context: its identity without `<bounded_context>.`. */
23
23
  export declare function localOf(metamodel: Metamodel, identity: string): string;
24
+ /**
25
+ * What an operation's answer is called: the backend's own name for it — its output's title,
26
+ * `ResponseCancelIssue` — when that name is one type (every output with it is the same schema)
27
+ * and no other type has it; `<Name>Answer` when the output has no name of its own (a model, a
28
+ * list) or two different shapes share it. A new operation answering with the same Response keeps
29
+ * the name: regenerating never renames by itself.
30
+ *
31
+ * Context: the ubiquitous language. The backend writes `CommandCancelIssue` → `ResponseCancelIssue`,
32
+ * and a client that calls it `CommandCancelIssueAnswer` makes a person learn two words for one
33
+ * thing. The `<Name>Answer` it used to write stays as a deprecated alias of the backend's name.
34
+ */
35
+ export declare function answerOf(metamodel: Metamodel, identity: string): string;
24
36
  /** The method an operation is called by: `CommandSendIssue` → `sendIssue`. */
25
37
  export declare function methodOf(local: string): string;
26
38
  /** `Issue` → `issues`, `ProjectEvent` → `projectEvents`, `Policy` → `policies`. */
@@ -52,6 +52,64 @@ export function localOf(metamodel, identity) {
52
52
  const prefix = `${contextOf(metamodel)}.`;
53
53
  return identifierOf(identity.startsWith(prefix) ? identity.slice(prefix.length) : identity);
54
54
  }
55
+ const ANSWERS = new WeakMap();
56
+ /** The output's own name, when it has one a TypeScript type can take. */
57
+ function titleOf(output) {
58
+ if (output === null || output.$ref !== undefined)
59
+ return undefined;
60
+ const title = output.title;
61
+ return typeof title === "string" && /^[A-Za-z_$][\w$]*$/.test(title) ? title : undefined;
62
+ }
63
+ function answersOf(metamodel) {
64
+ const operations = Object.entries(metamodel.operations);
65
+ const taken = new Set([
66
+ ...Object.keys(metamodel.models).map(identifierOf),
67
+ ...Object.keys(metamodel.$defs ?? {}).map(identifierOf),
68
+ ]);
69
+ for (const [identity] of operations) {
70
+ const local = localOf(metamodel, identity);
71
+ taken.add(local);
72
+ taken.add(`${local}Answer`);
73
+ }
74
+ // A title is one type when every output that carries it is the same schema — the backend
75
+ // answering two Commands with one Response class; two different schemas under one title are
76
+ // two types, and neither takes the name.
77
+ const shapes = new Map();
78
+ for (const [, operation] of operations) {
79
+ const title = titleOf(operation.output);
80
+ if (title === undefined)
81
+ continue;
82
+ const seen = shapes.get(title) ?? new Set();
83
+ seen.add(JSON.stringify(operation.output));
84
+ shapes.set(title, seen);
85
+ }
86
+ const names = new Map();
87
+ for (const [identity, operation] of operations) {
88
+ const title = titleOf(operation.output);
89
+ if (title !== undefined && shapes.get(title)?.size === 1 && !taken.has(title))
90
+ names.set(identity, title);
91
+ }
92
+ return names;
93
+ }
94
+ /**
95
+ * What an operation's answer is called: the backend's own name for it — its output's title,
96
+ * `ResponseCancelIssue` — when that name is one type (every output with it is the same schema)
97
+ * and no other type has it; `<Name>Answer` when the output has no name of its own (a model, a
98
+ * list) or two different shapes share it. A new operation answering with the same Response keeps
99
+ * the name: regenerating never renames by itself.
100
+ *
101
+ * Context: the ubiquitous language. The backend writes `CommandCancelIssue` → `ResponseCancelIssue`,
102
+ * and a client that calls it `CommandCancelIssueAnswer` makes a person learn two words for one
103
+ * thing. The `<Name>Answer` it used to write stays as a deprecated alias of the backend's name.
104
+ */
105
+ export function answerOf(metamodel, identity) {
106
+ let known = ANSWERS.get(metamodel);
107
+ if (known === undefined) {
108
+ known = answersOf(metamodel);
109
+ ANSWERS.set(metamodel, known);
110
+ }
111
+ return known.get(identity) ?? `${localOf(metamodel, identity)}Answer`;
112
+ }
55
113
  /** The method an operation is called by: `CommandSendIssue` → `sendIssue`. */
56
114
  export function methodOf(local) {
57
115
  const bare = local.replace(/^(Command|Query)(?=[A-Z])/, "");
@@ -0,0 +1,24 @@
1
+ import type { Metamodel } from "./metamodel.js";
2
+ /**
3
+ * A bounded context's skeleton, written ONCE beside what is generated — the files that are the
4
+ * application's from then on:
5
+ *
6
+ * domains/project/
7
+ * generated/ `sincpro-client generate` — regenerated, never edited
8
+ * infrastructure/dependencies.ts what the services can count on: the generated client
9
+ * infrastructure/bounded_context.ts its Feature and ApplicationService, and how it is made
10
+ * services/index.ts the services, by the name each is reached by
11
+ * index.ts `export const project` — ready to use
12
+ *
13
+ * Context: the layout sincpro_framework gives a service (`dependencies.py`, `framework.py`,
14
+ * `services/`, `__init__.py`), so a bounded context starts the way every other does and nobody
15
+ * copies it by hand from another. It is the application's to change: the CLI writes a file only
16
+ * when it is not there, and never touches one that is — unlike `generated/`, which is written
17
+ * whole every time.
18
+ */
19
+ export type ScaffoldOptions = {
20
+ /** Where the application's transport is exported (`export const transport`), from the
21
+ * bounded context's folder. `../common/infrastructure/transport` by default. */
22
+ transport?: string;
23
+ };
24
+ export declare function scaffoldFilesOf(metamodel: Metamodel, options?: ScaffoldOptions): Record<string, string>;
@@ -0,0 +1,123 @@
1
+ import { aliasOf, FRAMEWORK } from "./names.js";
2
+ /** Names the skeleton's `index.ts` cannot give its constant. */
3
+ const TAKEN = new Set([
4
+ "transport",
5
+ "features",
6
+ "applicationServices",
7
+ ..."break case catch class const continue debugger default delete do else enum export extends false finally for function if import in instanceof new null return super switch this throw true try typeof var void while with yield let static implements interface package private protected public await".split(" "),
8
+ ]);
9
+ /** `sales-orders` → `SalesOrders`. */
10
+ function pascalOf(alias) {
11
+ const joined = alias
12
+ .split(/[^A-Za-z0-9]+/)
13
+ .filter((one) => one !== "")
14
+ .map((one) => `${one.charAt(0).toUpperCase()}${one.slice(1)}`)
15
+ .join("");
16
+ return joined === "" ? "Context" : /^[0-9]/.test(joined) ? `_${joined}` : joined;
17
+ }
18
+ /** A module the skeleton imports, built in parts — see `FRAMEWORK` (`codegen/names.ts`). */
19
+ const at = (...parts) => parts.join("/");
20
+ const HEADER = (what) => `// ${what} — written once by \`sincpro-client scaffold\`; the application's to change.`;
21
+ export function scaffoldFilesOf(metamodel, options = {}) {
22
+ const alias = aliasOf(metamodel);
23
+ const Name = pascalOf(alias);
24
+ const bare = `${Name.charAt(0).toLowerCase()}${Name.slice(1)}`;
25
+ // A word the language keeps (`import`, `default`) or a name `index.ts` already imports cannot
26
+ // be the constant's: it takes the bounded context's word instead.
27
+ const name = TAKEN.has(bare) ? `${bare}BoundedContext` : bare;
28
+ const Dependencies = `${Name}Dependencies`;
29
+ const client = metamodel.bindings !== undefined;
30
+ const transport = options.transport ?? at("..", "common", "infrastructure", "transport");
31
+ const dependencies = [
32
+ HEADER(`What the services of «${alias}» can count on`),
33
+ "",
34
+ `import type { Transport, UseBoundedContext } from "${FRAMEWORK}";`,
35
+ ...(client ? ["", `import { clientOf } from "${at("..", "generated", "client")}";`] : []),
36
+ "",
37
+ `/** What every service of «${alias}» finds on \`this\`${client ? ": `this.client`, the service's operations" : ""}. */`,
38
+ `export interface ${Dependencies} {`,
39
+ ...(client
40
+ ? [" client: ReturnType<typeof clientOf>;"]
41
+ : [
42
+ " /** What the services need: a repository, an adapter, another bounded context. */",
43
+ ]),
44
+ "}",
45
+ "",
46
+ "export function registerDependencies(",
47
+ ` bc: UseBoundedContext<${Dependencies}>,`,
48
+ ` ${client ? "transport" : "_transport"}: Transport,`,
49
+ ") {",
50
+ ...(client ? [` bc.addDependency("client", clientOf(transport));`] : []),
51
+ " return bc;",
52
+ "}",
53
+ "",
54
+ ].join("\n");
55
+ const boundedContext = [
56
+ HEADER(`How «${alias}» is made`),
57
+ "",
58
+ "import {",
59
+ " ApplicationService as BaseApplicationService,",
60
+ " Feature as BaseFeature,",
61
+ " type Transport,",
62
+ " UseBoundedContext,",
63
+ `} from "${FRAMEWORK}";`,
64
+ "",
65
+ `import type { ${Name}Features } from "${at("..", "services")}";`,
66
+ `import { type ${Dependencies}, registerDependencies } from "${at(".", "dependencies")}";`,
67
+ "",
68
+ `/** A Feature of «${alias}»: its dependencies typed on \`this\`, and \`this.context\`. */`,
69
+ `export class Feature extends BaseFeature<${Dependencies}> {}`,
70
+ "",
71
+ `/** An ApplicationService of «${alias}»: the same, and \`this.feature.<Name>\` for each Feature. */`,
72
+ "export class ApplicationService extends BaseApplicationService<",
73
+ ` ${Dependencies},`,
74
+ ` ${Name}Features`,
75
+ "> {}",
76
+ "",
77
+ `export function config${Name}BoundedContext(name: string, transport: Transport) {`,
78
+ ` return registerDependencies(new UseBoundedContext<${Dependencies}>(name), transport);`,
79
+ "}",
80
+ "",
81
+ ].join("\n");
82
+ const services = [
83
+ HEADER(`What «${alias}» offers`),
84
+ "",
85
+ "/**",
86
+ ` * Each service by the name it is reached by — \`${name}.Issues.close(…)\`. A Feature groups the`,
87
+ " * operations of one part of the domain; an ApplicationService composes the Features:",
88
+ " *",
89
+ " * // services/issues.ts",
90
+ ` * export class Issues extends Feature {`,
91
+ " * close(input: dto.CommandCloseIssue) {",
92
+ " * return this.client.closeIssue(input);",
93
+ " * }",
94
+ " * }",
95
+ " */",
96
+ "export const features = {};",
97
+ "export const applicationServices = {};",
98
+ "",
99
+ `export type ${Name}Features = typeof features;`,
100
+ "",
101
+ ].join("\n");
102
+ const index = [
103
+ HEADER(`«${alias}», ready to use`),
104
+ "",
105
+ `import { transport } from "${transport}";`,
106
+ "",
107
+ `import { config${Name}BoundedContext } from "${at(".", "infrastructure", "bounded_context")}";`,
108
+ `import { applicationServices, features } from "${at(".", "services")}";`,
109
+ "",
110
+ `/** «${alias}»: \`${name}.<Service>.<method>(…)\` — from a screen, a test, another bounded context. */`,
111
+ `export const ${name} = config${Name}BoundedContext(${JSON.stringify(alias)}, transport).register({`,
112
+ " features,",
113
+ " applicationServices,",
114
+ "});",
115
+ "",
116
+ ].join("\n");
117
+ return {
118
+ "infrastructure/dependencies.ts": dependencies,
119
+ "infrastructure/bounded_context.ts": boundedContext,
120
+ "services/index.ts": services,
121
+ "index.ts": index,
122
+ };
123
+ }
@@ -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,154 @@
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
+ /** How many digits after the point a result keeps: a whole number from 0 up. */
23
+ function decimalsGiven(decimals) {
24
+ if (!Number.isInteger(decimals) || decimals < 0)
25
+ throw new RangeError(`${decimals} decimals: a decimal keeps 0 or more whole digits after the point`);
26
+ return decimals;
27
+ }
28
+ /** `units / divisor` rounded half up, away from zero. */
29
+ function halfUp(units, divisor) {
30
+ const quotient = units / divisor;
31
+ const rest = units % divisor;
32
+ const twice = (rest < 0n ? -rest : rest) * 2n;
33
+ const whole = divisor < 0n ? -divisor : divisor;
34
+ if (twice < whole)
35
+ return quotient;
36
+ return units < 0n !== divisor < 0n ? quotient - 1n : quotient + 1n;
37
+ }
38
+ export class Decimal {
39
+ /** The digits, as an integer: `10.50` is `1050n` at scale 2. */
40
+ units;
41
+ /** How many of the digits are after the point. */
42
+ scale;
43
+ /**
44
+ * A decimal from its text (`"10.50"`, `"-0.5"`, `"1e3"`), a number — taken by its digits,
45
+ * `0.1` is `"0.1"` — or a whole `bigint`. Anything else is refused, naming it.
46
+ */
47
+ constructor(value) {
48
+ if (value instanceof Decimal) {
49
+ this.units = value.units;
50
+ this.scale = value.scale;
51
+ return;
52
+ }
53
+ if (typeof value === "bigint") {
54
+ this.units = value;
55
+ this.scale = 0;
56
+ return;
57
+ }
58
+ if (typeof value === "number" && !Number.isFinite(value))
59
+ throw new TypeError(`${value} is not a decimal`);
60
+ const text = typeof value === "number" ? String(value) : String(value).trim();
61
+ const match = NUMBER.exec(text);
62
+ const [, sign = "", whole = "", fraction = "", exponent = "0"] = match ?? [];
63
+ if (match === null || whole + fraction === "")
64
+ throw new TypeError(`"${text}" is not a decimal`);
65
+ let digits = whole + fraction;
66
+ let scale = fraction.length - Number(exponent);
67
+ if (scale < 0) {
68
+ digits += "0".repeat(-scale);
69
+ scale = 0;
70
+ }
71
+ this.units = BigInt(digits) * (sign === "-" ? -1n : 1n);
72
+ this.scale = scale;
73
+ }
74
+ static of(value) {
75
+ return value instanceof Decimal ? value : new Decimal(value);
76
+ }
77
+ static at(units, scale) {
78
+ const made = Object.create(Decimal.prototype);
79
+ made.units = units;
80
+ made.scale = scale;
81
+ return made;
82
+ }
83
+ /** Both at the larger scale, so their units compare and add. */
84
+ static aligned(one, other) {
85
+ const scale = Math.max(one.scale, other.scale);
86
+ return [
87
+ one.units * pow10(scale - one.scale),
88
+ other.units * pow10(scale - other.scale),
89
+ scale,
90
+ ];
91
+ }
92
+ plus(other) {
93
+ const [one, two, scale] = Decimal.aligned(this, Decimal.of(other));
94
+ return Decimal.at(one + two, scale);
95
+ }
96
+ minus(other) {
97
+ const [one, two, scale] = Decimal.aligned(this, Decimal.of(other));
98
+ return Decimal.at(one - two, scale);
99
+ }
100
+ times(factor) {
101
+ const by = Decimal.of(factor);
102
+ return Decimal.at(this.units * by.units, this.scale + by.scale);
103
+ }
104
+ /** This over `divisor`, with `decimals` digits after the point, rounded half up. */
105
+ dividedBy(divisor, decimals) {
106
+ decimalsGiven(decimals);
107
+ const by = Decimal.of(divisor);
108
+ if (by.units === 0n)
109
+ throw new RangeError("a decimal divided by zero");
110
+ const numerator = this.units * pow10(by.scale + decimals);
111
+ const denominator = by.units * pow10(this.scale);
112
+ return Decimal.at(halfUp(numerator, denominator), decimals);
113
+ }
114
+ /** With `decimals` digits after the point: rounded half up when it had more, padded when fewer. */
115
+ rounded(decimals) {
116
+ decimalsGiven(decimals);
117
+ if (decimals >= this.scale)
118
+ return Decimal.at(this.units * pow10(decimals - this.scale), decimals);
119
+ return Decimal.at(halfUp(this.units, pow10(this.scale - decimals)), decimals);
120
+ }
121
+ negated() {
122
+ return Decimal.at(-this.units, this.scale);
123
+ }
124
+ /** `-1`, `0` or `1`: `10.5` and `10.50` are the same amount. */
125
+ compare(other) {
126
+ const [one, two] = Decimal.aligned(this, Decimal.of(other));
127
+ return one < two ? -1 : one > two ? 1 : 0;
128
+ }
129
+ equals(other) {
130
+ return this.compare(other) === 0;
131
+ }
132
+ get isZero() {
133
+ return this.units === 0n;
134
+ }
135
+ get isNegative() {
136
+ return this.units < 0n;
137
+ }
138
+ get isPositive() {
139
+ return this.units > 0n;
140
+ }
141
+ /** The text the wire carries: every digit, the scale kept (`"10.50"`, never `"10.5"`). */
142
+ toString() {
143
+ const negative = this.units < 0n;
144
+ const digits = (negative ? -this.units : this.units)
145
+ .toString()
146
+ .padStart(this.scale + 1, "0");
147
+ const whole = digits.slice(0, digits.length - this.scale);
148
+ const fraction = digits.slice(digits.length - this.scale);
149
+ return `${negative ? "-" : ""}${whole}${this.scale > 0 ? `.${fraction}` : ""}`;
150
+ }
151
+ toJSON() {
152
+ return this.toString();
153
+ }
154
+ }
@@ -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
+ }