@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.
- package/AGENTS.md +22 -11
- package/MANIFESTO.md +7 -3
- package/README.md +58 -0
- package/dist/client/memory.js +8 -5
- package/dist/codegen/check.d.ts +64 -0
- package/dist/codegen/check.js +195 -0
- package/dist/codegen/cli.js +73 -6
- package/dist/codegen/client.js +5 -5
- package/dist/codegen/domain.d.ts +2 -1
- package/dist/codegen/domain.js +17 -5
- package/dist/codegen/entities.js +10 -3
- package/dist/codegen/index.d.ts +2 -0
- package/dist/codegen/index.js +2 -0
- package/dist/codegen/names.d.ts +12 -0
- package/dist/codegen/names.js +58 -0
- package/dist/codegen/scaffold.d.ts +24 -0
- package/dist/codegen/scaffold.js +123 -0
- package/dist/common/decimal.d.ts +49 -0
- package/dist/common/decimal.js +154 -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 +53 -0
- package/dist/common/serialization.js +168 -0
- package/dist/common/time.d.ts +41 -0
- package/dist/common/time.js +100 -0
- package/dist/context/codec.d.ts +26 -0
- package/dist/context/codec.js +44 -0
- package/dist/index.d.ts +11 -2
- package/dist/index.js +11 -2
- package/dist/observability/ports.d.ts +2 -1
- package/dist/services/bounded-context.js +5 -16
- package/dist/services/execution.d.ts +2 -2
- package/dist/services/execution.js +6 -4
- package/dist/services/feature.d.ts +4 -1
- package/dist/services/report.d.ts +15 -0
- package/dist/services/report.js +53 -0
- package/dist/services/screens.d.ts +2 -0
- package/dist/services/screens.js +67 -0
- package/package.json +6 -2
package/dist/codegen/domain.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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;) {
|
package/dist/codegen/entities.js
CHANGED
|
@@ -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
|
-
: [
|
|
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.
|
|
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)} },`);
|
package/dist/codegen/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/codegen/index.js
CHANGED
|
@@ -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";
|
package/dist/codegen/names.d.ts
CHANGED
|
@@ -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`. */
|
package/dist/codegen/names.js
CHANGED
|
@@ -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
|
+
}
|