@sincpro/client-framework 0.4.0 → 0.6.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/README.md +59 -9
- package/dist/adapters/parquet.d.ts +50 -0
- package/dist/adapters/parquet.js +151 -0
- package/dist/client/entity.d.ts +16 -1
- package/dist/client/entity.js +16 -1
- package/dist/client/memory.js +10 -6
- package/dist/client/rest.d.ts +7 -1
- package/dist/client/rest.js +46 -1
- package/dist/client/transport.d.ts +37 -1
- package/dist/client/transport.js +6 -0
- 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 +30 -9
- package/dist/codegen/domain.d.ts +2 -1
- package/dist/codegen/domain.js +63 -10
- package/dist/codegen/entities.d.ts +2 -1
- package/dist/codegen/entities.js +28 -5
- package/dist/codegen/index.d.ts +2 -0
- package/dist/codegen/index.js +2 -0
- package/dist/codegen/metamodel.d.ts +4 -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/collection.d.ts +110 -0
- package/dist/common/collection.js +169 -0
- package/dist/common/decimal.js +8 -0
- package/dist/common/serialization.d.ts +5 -1
- package/dist/common/serialization.js +17 -4
- package/dist/common/time.d.ts +2 -1
- package/dist/common/time.js +9 -2
- package/dist/context/codec.d.ts +2 -0
- package/dist/context/codec.js +16 -0
- package/dist/index.d.ts +8 -4
- package/dist/index.js +8 -4
- package/dist/model/meta.d.ts +1 -0
- package/dist/observability/ports.d.ts +2 -1
- package/dist/services/bounded-context.js +4 -15
- package/dist/services/execution.js +5 -2
- 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 +76 -0
- package/package.json +16 -3
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, relative, sep } from "node:path";
|
|
3
|
+
const LAYERS = new Set([
|
|
4
|
+
"generated",
|
|
5
|
+
"domain",
|
|
6
|
+
"adapters",
|
|
7
|
+
"services",
|
|
8
|
+
"infrastructure",
|
|
9
|
+
"components",
|
|
10
|
+
"entrypoints",
|
|
11
|
+
]);
|
|
12
|
+
/**
|
|
13
|
+
* `domains/sales/services/close.ts` → the bounded context `sales`, the layer `services` — wherever
|
|
14
|
+
* the `domains/` folder is (`src/domains/…`, a workspace's `domains/sales/src/services/…`).
|
|
15
|
+
*/
|
|
16
|
+
export function placeOf(path) {
|
|
17
|
+
const parts = path.split(/[\\/]/).filter((one) => one !== "");
|
|
18
|
+
const start = parts[0] === "src" ? 1 : 0;
|
|
19
|
+
if (parts[start] === "entrypoints")
|
|
20
|
+
return { kind: "entrypoint" };
|
|
21
|
+
const at = parts.indexOf("domains");
|
|
22
|
+
const context = at === -1 ? undefined : parts[at + 1];
|
|
23
|
+
if (context === undefined || parts.length < at + 3)
|
|
24
|
+
return { kind: "other" };
|
|
25
|
+
// A bounded context that is a package of its own keeps its code under `src/`.
|
|
26
|
+
const rest = parts.slice(at + 2);
|
|
27
|
+
const inside = rest[0] === "src" && rest.length > 1 ? rest.slice(1) : rest;
|
|
28
|
+
const first = inside[0] ?? "";
|
|
29
|
+
const index = inside.length === 1 && /^index\.(ts|tsx|js|jsx|mts)$/.test(first);
|
|
30
|
+
return { kind: "context", context, layer: LAYERS.has(first) ? first : null, index };
|
|
31
|
+
}
|
|
32
|
+
const REACT = /^(react|react-dom|react-native)(\/|$)/;
|
|
33
|
+
const DRAWING = /^@sincpro\/(web-ui|web-composer)(\/|$)/;
|
|
34
|
+
const COMPOSER = /^@sincpro\/web-composer(\/|$)/;
|
|
35
|
+
const ROUTER = /^(react-router|react-router-dom|@tanstack\/react-router|next\/navigation|next\/router|expo-router)(\/|$)/;
|
|
36
|
+
/** web-framework draws; only its `core` is plain logic a service may use. */
|
|
37
|
+
const SCREENS = /^@sincpro\/web-framework(?!\/core(\/|$))(\/|$)/;
|
|
38
|
+
const PLAIN = new Set(["generated", "domain", "adapters", "services"]);
|
|
39
|
+
/** Within a bounded context, what each layer never imports of its own. */
|
|
40
|
+
const NEVER_OWN = {
|
|
41
|
+
generated: ["adapters", "services", "infrastructure", "components", "entrypoints"],
|
|
42
|
+
domain: ["adapters", "services", "infrastructure", "components", "entrypoints"],
|
|
43
|
+
adapters: ["services", "components", "entrypoints"],
|
|
44
|
+
services: ["components", "entrypoints"],
|
|
45
|
+
infrastructure: ["components", "entrypoints"],
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Why `from` may not import `specifier`, which resolves to `to` (`null`: a package), or nothing
|
|
49
|
+
* when it may.
|
|
50
|
+
*/
|
|
51
|
+
export function violationOf(from, specifier, to) {
|
|
52
|
+
if (to === null) {
|
|
53
|
+
if (from.kind !== "context" || from.layer === null)
|
|
54
|
+
return undefined;
|
|
55
|
+
if (PLAIN.has(from.layer) &&
|
|
56
|
+
(REACT.test(specifier) || DRAWING.test(specifier) || SCREENS.test(specifier)))
|
|
57
|
+
return `${from.layer}/ is plain logic: it never imports a screen's library (React, web-ui, composer, web-framework but its core)`;
|
|
58
|
+
if (from.layer === "components" && (COMPOSER.test(specifier) || ROUTER.test(specifier)))
|
|
59
|
+
return "a component never decides where things open: no composer, no router";
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
// The application's entrypoints compose everything; nothing imports them back.
|
|
63
|
+
if (to.kind === "entrypoint")
|
|
64
|
+
return from.kind === "entrypoint"
|
|
65
|
+
? undefined
|
|
66
|
+
: "nothing imports the application's entrypoints";
|
|
67
|
+
if (to.kind !== "context")
|
|
68
|
+
return undefined;
|
|
69
|
+
// Outside the bounded contexts (the entrypoints, `main.tsx`, a `lib/`): only their index.ts.
|
|
70
|
+
if (from.kind !== "context")
|
|
71
|
+
return to.context === "common" || to.index
|
|
72
|
+
? undefined
|
|
73
|
+
: `outside a bounded context, it is used through its index.ts, never its insides («${to.context}»)`;
|
|
74
|
+
if (from.context === "common")
|
|
75
|
+
return to.context === "common" ? undefined : "common never imports a bounded context";
|
|
76
|
+
if (to.context === "common")
|
|
77
|
+
return undefined;
|
|
78
|
+
if (to.context === from.context) {
|
|
79
|
+
const never = from.layer === null ? undefined : NEVER_OWN[from.layer];
|
|
80
|
+
return to.layer !== null && never?.includes(to.layer) === true
|
|
81
|
+
? `${from.layer}/ never imports its own ${to.layer}/`
|
|
82
|
+
: undefined;
|
|
83
|
+
}
|
|
84
|
+
if (to.index && from.layer === "infrastructure")
|
|
85
|
+
return undefined;
|
|
86
|
+
return to.index
|
|
87
|
+
? `another bounded context («${to.context}») is a dependency: added in infrastructure/, never imported here`
|
|
88
|
+
: `a bounded context never imports another's insides («${to.context}»): only its index.ts, from infrastructure/`;
|
|
89
|
+
}
|
|
90
|
+
function sources(root) {
|
|
91
|
+
const found = [];
|
|
92
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
93
|
+
if (entry.name === "node_modules" || entry.name.startsWith("."))
|
|
94
|
+
continue;
|
|
95
|
+
const path = join(root, entry.name);
|
|
96
|
+
if (entry.isDirectory())
|
|
97
|
+
found.push(...sources(path));
|
|
98
|
+
else if (/\.(ts|tsx|mts|cts|js|jsx)$/.test(entry.name) && !entry.name.endsWith(".d.ts"))
|
|
99
|
+
found.push(path);
|
|
100
|
+
}
|
|
101
|
+
return found;
|
|
102
|
+
}
|
|
103
|
+
/** What stops the check before it starts: no root, no config, a config that does not parse. */
|
|
104
|
+
export class CheckError extends Error {
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The compiler options the application resolves with: its config's, and those of every config it
|
|
108
|
+
* references — Vite writes `files: []` in `tsconfig.json` and keeps its aliases in
|
|
109
|
+
* `tsconfig.app.json`.
|
|
110
|
+
*/
|
|
111
|
+
function optionsOf(ts, path, seen) {
|
|
112
|
+
if (seen.has(path))
|
|
113
|
+
return [];
|
|
114
|
+
seen.add(path);
|
|
115
|
+
const read = ts.readConfigFile(path, ts.sys.readFile);
|
|
116
|
+
if (read.error !== undefined)
|
|
117
|
+
throw new CheckError(`${path}: ${ts.flattenDiagnosticMessageText(read.error.messageText, " ")}`);
|
|
118
|
+
const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, dirname(path), undefined, path);
|
|
119
|
+
// «No inputs were found» is what a config of references only says: not an error here.
|
|
120
|
+
const broken = parsed.errors.find((one) => one.category === ts.DiagnosticCategory.Error && one.code !== 18003);
|
|
121
|
+
if (broken !== undefined)
|
|
122
|
+
throw new CheckError(`${path}: ${ts.flattenDiagnosticMessageText(broken.messageText, " ")}`);
|
|
123
|
+
return [
|
|
124
|
+
parsed.options,
|
|
125
|
+
...(parsed.projectReferences ?? []).flatMap((one) => optionsOf(ts, ts.resolveProjectReferencePath(one), seen)),
|
|
126
|
+
];
|
|
127
|
+
}
|
|
128
|
+
/** A style, an image, a font, data: what a bundler loads and TypeScript does not resolve. */
|
|
129
|
+
const ASSET = /\.(css|scss|sass|less|svg|png|jpe?g|gif|webp|avif|ico|woff2?|ttf|otf|json|md|mdx)(\?.*)?$/;
|
|
130
|
+
/** `@acme/ventas`, `ventas` — a package named whole, never a path inside it. */
|
|
131
|
+
const BARE = /^(@[^/]+\/)?[^/.@][^/]*$/;
|
|
132
|
+
/**
|
|
133
|
+
* Every import under `root` that breaks a rule, resolved by the application's own TypeScript and
|
|
134
|
+
* `tsconfig` (its path aliases and its references included). An alias that does not resolve is a
|
|
135
|
+
* break too — read as a package it would switch every rule off.
|
|
136
|
+
*/
|
|
137
|
+
export async function checkImports(root, options = {}) {
|
|
138
|
+
if (!existsSync(root))
|
|
139
|
+
throw new CheckError(`${root} does not exist: say where the application's code is (--root)`);
|
|
140
|
+
const { default: ts } = await import("typescript");
|
|
141
|
+
if (options.tsconfig !== undefined && !ts.sys.fileExists(options.tsconfig))
|
|
142
|
+
throw new CheckError(`${options.tsconfig} does not exist`);
|
|
143
|
+
const configPath = options.tsconfig ?? ts.findConfigFile(root, ts.sys.fileExists);
|
|
144
|
+
const compilers = configPath === undefined ? [{}] : optionsOf(ts, configPath, new Set());
|
|
145
|
+
const aliases = compilers
|
|
146
|
+
.flatMap((one) => Object.keys(one.paths ?? {}))
|
|
147
|
+
.map((pattern) => pattern.replace(/\*.*$/, ""))
|
|
148
|
+
.filter((prefix) => prefix !== "");
|
|
149
|
+
const own = (path) => {
|
|
150
|
+
const from = relative(root, path);
|
|
151
|
+
return !from.startsWith("..") && !from.split(sep).includes("node_modules");
|
|
152
|
+
};
|
|
153
|
+
const violations = [];
|
|
154
|
+
for (const file of sources(root)) {
|
|
155
|
+
const name = relative(root, file).split(sep).join("/");
|
|
156
|
+
const from = placeOf(name);
|
|
157
|
+
for (const { fileName: specifier } of ts.preProcessFile(readFileSync(file, "utf8"), true, true).importedFiles) {
|
|
158
|
+
if (ASSET.test(specifier))
|
|
159
|
+
continue;
|
|
160
|
+
// The first config that finds it inside the application, else any that finds it.
|
|
161
|
+
let resolved;
|
|
162
|
+
for (const compiler of compilers) {
|
|
163
|
+
const found = ts.resolveModuleName(specifier, file, compiler, ts.sys).resolvedModule;
|
|
164
|
+
if (found === undefined)
|
|
165
|
+
continue;
|
|
166
|
+
resolved ??= found;
|
|
167
|
+
if (own(found.resolvedFileName)) {
|
|
168
|
+
resolved = found;
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (resolved === undefined && aliases.some((prefix) => specifier.startsWith(prefix))) {
|
|
173
|
+
violations.push({
|
|
174
|
+
file: name,
|
|
175
|
+
imports: specifier,
|
|
176
|
+
why: "it does not resolve: an alias of the application that no tsconfig finds",
|
|
177
|
+
});
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const inside = resolved !== undefined && own(resolved.resolvedFileName);
|
|
181
|
+
let to = inside
|
|
182
|
+
? placeOf(relative(root, resolved.resolvedFileName).split(sep).join("/"))
|
|
183
|
+
: specifier.startsWith(".")
|
|
184
|
+
? { kind: "other" }
|
|
185
|
+
: null;
|
|
186
|
+
// A bounded context that is a package, named whole (`@acme/ventas`): its index.
|
|
187
|
+
if (to?.kind === "context" && BARE.test(specifier))
|
|
188
|
+
to = { ...to, index: true };
|
|
189
|
+
const why = violationOf(from, specifier, to);
|
|
190
|
+
if (why !== undefined)
|
|
191
|
+
violations.push({ file: name, imports: specifier, why });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return violations;
|
|
195
|
+
}
|
package/dist/codegen/cli.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { CheckError, checkImports } from "./check.js";
|
|
4
5
|
import { clientFileOf } from "./client.js";
|
|
5
6
|
import { domainFileOf } from "./domain.js";
|
|
6
7
|
import { entitiesFileOf } from "./entities.js";
|
|
7
8
|
import { aliasOf } from "./names.js";
|
|
9
|
+
import { scaffoldFilesOf } from "./scaffold.js";
|
|
8
10
|
/**
|
|
9
11
|
* `sincpro-client generate <contract.json> --out <dir> [--domain <name>] [--base <path>]` — what an
|
|
10
12
|
* application's `make client` runs, once per bounded context, over the client contract
|
|
@@ -21,7 +23,11 @@ import { aliasOf } from "./names.js";
|
|
|
21
23
|
* verify-client` regenerates and fails on any difference. The folder is the generator's: keep
|
|
22
24
|
* it out of the application's prettier and eslint, or a formatter rewrites what was generated.
|
|
23
25
|
*/
|
|
24
|
-
const USAGE =
|
|
26
|
+
const USAGE = [
|
|
27
|
+
"usage: sincpro-client generate <contract.json> --out <dir> [--domain <name>] [--base <path>]",
|
|
28
|
+
" sincpro-client scaffold <contract.json> --out <dir> [--transport <module>]",
|
|
29
|
+
" sincpro-client check [--root src] [--tsconfig tsconfig.json]",
|
|
30
|
+
].join("\n");
|
|
25
31
|
/** JSON with every key sorted: the snapshot reads the same however the server ordered it. */
|
|
26
32
|
function canonical(value) {
|
|
27
33
|
if (Array.isArray(value))
|
|
@@ -35,7 +41,7 @@ function canonical(value) {
|
|
|
35
41
|
/** The command, its source and its flags — or `undefined` when they do not read as usage says. */
|
|
36
42
|
function parsed(args) {
|
|
37
43
|
const [command, source, ...rest] = args;
|
|
38
|
-
if (command !== "generate" || source === undefined)
|
|
44
|
+
if ((command !== "generate" && command !== "scaffold") || source === undefined)
|
|
39
45
|
return undefined;
|
|
40
46
|
const flags = new Map();
|
|
41
47
|
for (let at = 0; at < rest.length; at += 2) {
|
|
@@ -46,20 +52,73 @@ function parsed(args) {
|
|
|
46
52
|
flags.set(flag, value);
|
|
47
53
|
}
|
|
48
54
|
const out = flags.get("--out");
|
|
49
|
-
const known = new Set(["--out", "--domain", "--base"]);
|
|
55
|
+
const known = new Set(command === "generate" ? ["--out", "--domain", "--base"] : ["--out", "--transport"]);
|
|
50
56
|
if (out === undefined || [...flags.keys()].some((one) => !known.has(one)))
|
|
51
57
|
return undefined;
|
|
52
58
|
const domain = flags.get("--domain");
|
|
53
59
|
const base = flags.get("--base");
|
|
54
60
|
return {
|
|
61
|
+
command,
|
|
55
62
|
source,
|
|
56
63
|
out,
|
|
57
64
|
options: {
|
|
58
65
|
...(domain === undefined ? {} : { domain }),
|
|
59
66
|
...(base === undefined ? {} : { base }),
|
|
60
67
|
},
|
|
68
|
+
transport: flags.get("--transport"),
|
|
61
69
|
};
|
|
62
70
|
}
|
|
71
|
+
/** The skeleton, each file only when it is not there: what is there is the application's. */
|
|
72
|
+
function scaffold(metamodel, out, transport) {
|
|
73
|
+
const files = scaffoldFilesOf(metamodel, transport === undefined ? {} : { transport });
|
|
74
|
+
const written = [];
|
|
75
|
+
const kept = [];
|
|
76
|
+
for (const [name, source] of Object.entries(files)) {
|
|
77
|
+
const path = join(out, name);
|
|
78
|
+
if (existsSync(path)) {
|
|
79
|
+
kept.push(name);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
83
|
+
writeFileSync(path, source);
|
|
84
|
+
written.push(name);
|
|
85
|
+
}
|
|
86
|
+
console.log(`✓ ${aliasOf(metamodel)} → ${out}: written ${written.join(", ") || "nothing"}${kept.length === 0 ? "" : `; kept, already yours: ${kept.join(", ")}`}`);
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
/** The import rules (`codegen/check.ts`): every break said, and a failing exit when any. */
|
|
90
|
+
async function check(args) {
|
|
91
|
+
const flags = new Map();
|
|
92
|
+
for (let at = 0; at < args.length; at += 2) {
|
|
93
|
+
const flag = args[at];
|
|
94
|
+
const value = args[at + 1];
|
|
95
|
+
if (flag === undefined ||
|
|
96
|
+
value === undefined ||
|
|
97
|
+
!["--root", "--tsconfig"].includes(flag)) {
|
|
98
|
+
console.error(USAGE);
|
|
99
|
+
return 2;
|
|
100
|
+
}
|
|
101
|
+
flags.set(flag, value);
|
|
102
|
+
}
|
|
103
|
+
const root = flags.get("--root") ?? "src";
|
|
104
|
+
const tsconfig = flags.get("--tsconfig");
|
|
105
|
+
let found;
|
|
106
|
+
try {
|
|
107
|
+
found = await checkImports(root, tsconfig === undefined ? {} : { tsconfig });
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
if (!(error instanceof CheckError))
|
|
111
|
+
throw error;
|
|
112
|
+
console.error(`✘ ${error.message}`);
|
|
113
|
+
return 2;
|
|
114
|
+
}
|
|
115
|
+
for (const one of found)
|
|
116
|
+
console.error(`✘ ${root}/${one.file} imports "${one.imports}": ${one.why}`);
|
|
117
|
+
if (found.length > 0)
|
|
118
|
+
return 1;
|
|
119
|
+
console.log(`✓ ${root}: every import keeps the layers`);
|
|
120
|
+
return 0;
|
|
121
|
+
}
|
|
63
122
|
function main(args) {
|
|
64
123
|
const asked = parsed(args);
|
|
65
124
|
if (asked === undefined) {
|
|
@@ -67,6 +126,8 @@ function main(args) {
|
|
|
67
126
|
return 2;
|
|
68
127
|
}
|
|
69
128
|
const metamodel = JSON.parse(readFileSync(asked.source, "utf8"));
|
|
129
|
+
if (asked.command === "scaffold")
|
|
130
|
+
return scaffold(metamodel, asked.out, asked.transport);
|
|
70
131
|
const files = { "domain.ts": domainFileOf(metamodel) };
|
|
71
132
|
if (metamodel.bindings !== undefined) {
|
|
72
133
|
files["client.ts"] = clientFileOf(metamodel, asked.options);
|
|
@@ -79,4 +140,10 @@ function main(args) {
|
|
|
79
140
|
console.log(`✓ ${aliasOf(metamodel)} → ${asked.out} (${Object.keys(files).join(", ")})`);
|
|
80
141
|
return 0;
|
|
81
142
|
}
|
|
82
|
-
|
|
143
|
+
const args = process.argv.slice(2);
|
|
144
|
+
if (args[0] === "check")
|
|
145
|
+
void check(args.slice(1)).then((code) => {
|
|
146
|
+
process.exitCode = code;
|
|
147
|
+
});
|
|
148
|
+
else
|
|
149
|
+
process.exitCode = main(args);
|
package/dist/codegen/client.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { aliasOf, claimed, deprecationOf, docOf, DOMAIN_MODULE, FRAMEWORK, headerOf, identifierOf, localOf, methodOf, recordLiteral, sorted, } from "./names.js";
|
|
1
|
+
import { aliasOf, answerOf, claimed, deprecationOf, docOf, DOMAIN_MODULE, FRAMEWORK, headerOf, identifierOf, localOf, methodOf, recordLiteral, sorted, } from "./names.js";
|
|
2
2
|
import { ownerOf, restOf, WRITES, } from "./rest.js";
|
|
3
3
|
/**
|
|
4
4
|
* The client of a bounded context as TypeScript: every operation as data, and a method for each
|
|
@@ -7,17 +7,20 @@ import { ownerOf, restOf, WRITES, } from "./rest.js";
|
|
|
7
7
|
*
|
|
8
8
|
* Context: a method only writes down the route the contract published and hands the call to the
|
|
9
9
|
* transport, which keeps the rules — the `Idempotency-Key`, the `Problem`, the model's version.
|
|
10
|
-
* A GET is a page (`list`), one record (`one`),
|
|
11
|
-
* a
|
|
10
|
+
* A GET is a page (`list`), one record (`one`), a file (`export`: `downloadRoute`, its bytes as
|
|
11
|
+
* they arrive), or — a Query of the context's own, `GET /me` — a read of its answer as it came
|
|
12
|
+
* (`readRoute`, over a transport that has `read`). A GET's
|
|
12
13
|
* keys in its path are required parameters; every other field is an optional one.
|
|
13
14
|
*/
|
|
14
15
|
/** A search's fields a page asks with, not the screen: the criteria, and its cursor inside it. */
|
|
15
16
|
const PAGED = new Set(["criteria", "cursor"]);
|
|
17
|
+
/** An export's fields its method takes apart: the criteria and the format. */
|
|
18
|
+
const EXPORTED = new Set(["criteria", "cursor", "format"]);
|
|
16
19
|
/** A GET's parameters: the path's keys, and the rest of its input but what a page asks with. */
|
|
17
|
-
function askedOf(operation, rest, paged) {
|
|
20
|
+
function askedOf(operation, rest, paged, apart = PAGED) {
|
|
18
21
|
const path = [...rest.fields].sort();
|
|
19
22
|
const query = Object.keys(operation.input.properties ?? {})
|
|
20
|
-
.filter((one) => !path.includes(one) && !(paged &&
|
|
23
|
+
.filter((one) => !path.includes(one) && !(paged && apart.has(one)))
|
|
21
24
|
.sort();
|
|
22
25
|
return { path, query };
|
|
23
26
|
}
|
|
@@ -29,6 +32,9 @@ function callOf(metamodel, operation, rest) {
|
|
|
29
32
|
const owner = ownerOf(metamodel, operation);
|
|
30
33
|
if (operation.role === "search" && owner !== undefined && owner in metamodel.models)
|
|
31
34
|
return { kind: "list", entity: owner, ...askedOf(operation, rest, true) };
|
|
35
|
+
// An export answers bytes, never JSON: it is downloaded.
|
|
36
|
+
if (operation.role === "export" && owner !== undefined && owner in metamodel.models)
|
|
37
|
+
return { kind: "download", entity: owner, ...askedOf(operation, rest, true, EXPORTED) };
|
|
32
38
|
const field = owner === undefined ? undefined : operation.targets[owner];
|
|
33
39
|
const record = rest.fields.length === 1 && rest.fields[0] === field;
|
|
34
40
|
if (operation.role === "get" && owner !== undefined && field !== undefined && record)
|
|
@@ -71,16 +77,25 @@ function dataOf(metamodel, identity, operation, rest) {
|
|
|
71
77
|
lines.push(` mcp: { tool: ${JSON.stringify(mcp.tool)} },`);
|
|
72
78
|
return ` ${localOf(metamodel, identity)}: {\n${lines.join("\n")}\n },`;
|
|
73
79
|
}
|
|
74
|
-
function methodSourceOf(local, method, call, rest, doc) {
|
|
80
|
+
function methodSourceOf(local, answer, method, call, rest, doc) {
|
|
75
81
|
if (call.kind === "list") {
|
|
76
82
|
const entity = `dto.${identifierOf(call.entity)}`;
|
|
77
83
|
const params = paramsOf(call);
|
|
78
84
|
const signature = params.signature === "" ? "" : `, ${params.signature}`;
|
|
79
85
|
return `${doc} ${method}: (criteria: Criteria<${entity}>${signature}) =>\n transport.list<${entity}>(${params.route(rest.path)}, criteria),`;
|
|
80
86
|
}
|
|
87
|
+
if (call.kind === "download") {
|
|
88
|
+
const entity = `dto.${identifierOf(call.entity)}`;
|
|
89
|
+
const params = paramsOf(call);
|
|
90
|
+
// The path's keys are required, so they come first: a required parameter never follows an
|
|
91
|
+
// optional one.
|
|
92
|
+
const keys = params.signature === "" ? "" : `${params.signature.replace("params?:", "params:")}, `;
|
|
93
|
+
const given = params.signature === "" ? "" : ", params";
|
|
94
|
+
return `${doc} ${method}: (${keys}criteria?: Criteria<${entity}>, format?: ExportFormat) =>\n downloadRoute(transport, exportRoute(${JSON.stringify(rest.path)}, criteria, format${given})),`;
|
|
95
|
+
}
|
|
81
96
|
if (call.kind === "read") {
|
|
82
97
|
const params = paramsOf(call);
|
|
83
|
-
return `${doc} ${method}: (${params.signature}) =>\n readRoute<dto.${
|
|
98
|
+
return `${doc} ${method}: (${params.signature}) =>\n readRoute<dto.${answer}>(transport, ${params.route(rest.path)}),`;
|
|
84
99
|
}
|
|
85
100
|
if (call.kind === "one") {
|
|
86
101
|
const path = rest.path.replace(`{${call.field}}`, "${encodeURIComponent(id)}");
|
|
@@ -91,7 +106,7 @@ function methodSourceOf(local, method, call, rest, doc) {
|
|
|
91
106
|
: "undefined";
|
|
92
107
|
return [
|
|
93
108
|
`${doc} ${method}: (body: dto.${local}, options?: SendOptions) =>`,
|
|
94
|
-
` transport.send<dto.${
|
|
109
|
+
` transport.send<dto.${answer}>(`,
|
|
95
110
|
` ${JSON.stringify(rest.method)},`,
|
|
96
111
|
` pathFor(routes.${method}, ${id}, body),`,
|
|
97
112
|
` body,`,
|
|
@@ -122,6 +137,7 @@ export function clientFileOf(metamodel, options = {}) {
|
|
|
122
137
|
let paged = false;
|
|
123
138
|
let reads = false;
|
|
124
139
|
let sends = false;
|
|
140
|
+
let downloads = false;
|
|
125
141
|
for (const [identity, operation] of operations) {
|
|
126
142
|
const local = localOf(metamodel, identity);
|
|
127
143
|
const rest = restOf(metamodel, identity, options);
|
|
@@ -134,7 +150,11 @@ export function clientFileOf(metamodel, options = {}) {
|
|
|
134
150
|
const method = methodOf(local);
|
|
135
151
|
claim(method, `operation ${identity}`);
|
|
136
152
|
const doc = docOf([operation.description, deprecationOf(metamodel, operation)], " ");
|
|
137
|
-
methods.push(methodSourceOf(local, method, call, rest, doc));
|
|
153
|
+
methods.push(methodSourceOf(local, answerOf(metamodel, identity), method, call, rest, doc));
|
|
154
|
+
if (call.kind === "download") {
|
|
155
|
+
downloads = true;
|
|
156
|
+
paged = true;
|
|
157
|
+
}
|
|
138
158
|
if (call.kind === "list" || call.kind === "read") {
|
|
139
159
|
paged ||= call.kind === "list";
|
|
140
160
|
reads ||= call.kind === "read";
|
|
@@ -147,6 +167,7 @@ export function clientFileOf(metamodel, options = {}) {
|
|
|
147
167
|
}
|
|
148
168
|
const core = [
|
|
149
169
|
"bindable",
|
|
170
|
+
...(downloads ? ["downloadRoute", "type ExportFormat", "exportRoute"] : []),
|
|
150
171
|
...(lists ? ["listRoute"] : []),
|
|
151
172
|
...(sends ? ["pathFor"] : []),
|
|
152
173
|
...(reads ? ["readRoute"] : []),
|
package/dist/codegen/domain.d.ts
CHANGED
|
@@ -4,7 +4,8 @@ import type { Metamodel } from "./metamodel.js";
|
|
|
4
4
|
*
|
|
5
5
|
* 1. The models, by name: an entity's contract, a related model by its own name.
|
|
6
6
|
* 2. Each operation, by its local name (its identity without `<context>.`): its input as
|
|
7
|
-
* `<Name>` and its answer
|
|
7
|
+
* `<Name>` and its answer by the backend's own name for it (`answerOf`: `ResponseX`), with
|
|
8
|
+
* `<Name>Answer` kept as a deprecated alias — or as `<Name>Answer` when it has no name.
|
|
8
9
|
* 3. The shared definitions those reach — and the ones those reach, until none is new —, but
|
|
9
10
|
* never one a package of the stack already types (`Criteria`).
|
|
10
11
|
* Final: a header that says where it came from and not to edit it, the imports, then models,
|
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
|
|
@@ -20,7 +20,11 @@ const DEFINITIONS = "#/$defs/";
|
|
|
20
20
|
/** A shared definition a package of the stack already types: imported, never written again. */
|
|
21
21
|
const IMPORTED = {
|
|
22
22
|
Criteria: { name: "Criteria", from: "@sincpro/criteria" },
|
|
23
|
+
Download: { name: "Download", from: "@sincpro/client-framework" },
|
|
24
|
+
EntityCollection: { name: "EntityCollection", from: "@sincpro/client-framework" },
|
|
23
25
|
};
|
|
26
|
+
/** A relation that holds several records: a reading answers it as a page of them. */
|
|
27
|
+
const TO_MANY = new Set(["one2many", "many2many"]);
|
|
24
28
|
/** The field types whose value is the record itself, not a key that names it. */
|
|
25
29
|
const RELATIONAL = new Set(["many2one", "one2many", "many2many", "embedded"]);
|
|
26
30
|
/** A related record: in an answer only when the read expands it. */
|
|
@@ -82,6 +86,29 @@ function optional(name, property, schema, record) {
|
|
|
82
86
|
return EXPANDED.has(property["x-sincpro"]?.type ?? "");
|
|
83
87
|
return !(schema.required ?? []).includes(name);
|
|
84
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* The records a to-many holds, when `property` is one — however the contract writes it: the
|
|
91
|
+
* collection a reading answers (`{items, count, cursor}`, sincpro_framework 3.33 on) or, before
|
|
92
|
+
* that, a bare array, which said less than the wire sent.
|
|
93
|
+
*/
|
|
94
|
+
function heldRecords(property) {
|
|
95
|
+
if (!TO_MANY.has(property["x-sincpro"]?.type ?? ""))
|
|
96
|
+
return undefined;
|
|
97
|
+
const held = property.properties?.["items"] ?? property;
|
|
98
|
+
return held.type === "array" ? held.items : undefined;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* A property's TypeScript. A to-many inside a record is an `EntityCollection` of its records —
|
|
102
|
+
* a page with its count and cursor, as the read answers it — never an array, which would say the
|
|
103
|
+
* page is all there is.
|
|
104
|
+
*/
|
|
105
|
+
function propertyTypeOf(property, scope, depth, record) {
|
|
106
|
+
const records = record ? heldRecords(property) : undefined;
|
|
107
|
+
if (records === undefined)
|
|
108
|
+
return typeOf(property, scope, depth, record);
|
|
109
|
+
scope.imports.add("EntityCollection");
|
|
110
|
+
return `EntityCollection<${typeOf(records, scope, depth, record)}>`;
|
|
111
|
+
}
|
|
85
112
|
function objectOf(schema, scope, depth, record) {
|
|
86
113
|
const properties = sorted(schema.properties ?? {});
|
|
87
114
|
if (properties.length === 0) {
|
|
@@ -95,7 +122,7 @@ function objectOf(schema, scope, depth, record) {
|
|
|
95
122
|
const lines = properties.map(([name, property]) => {
|
|
96
123
|
const doc = docOf([property.description, keyNote(property)], pad);
|
|
97
124
|
const mark = optional(name, property, schema, record) ? "?" : "";
|
|
98
|
-
return `${doc}${pad}${keyOf(name)}${mark}: ${
|
|
125
|
+
return `${doc}${pad}${keyOf(name)}${mark}: ${propertyTypeOf(property, scope, depth + 1, record)};`;
|
|
99
126
|
});
|
|
100
127
|
return `{\n${lines.join("\n")}\n${" ".repeat(depth)}}`;
|
|
101
128
|
}
|
|
@@ -133,6 +160,13 @@ function typeOf(schema, scope, depth, record) {
|
|
|
133
160
|
: objectOf(schema, scope, depth, record);
|
|
134
161
|
}
|
|
135
162
|
}
|
|
163
|
+
/** What a streamed answer is on the client — an export's bytes (`Download`) —, else nothing. */
|
|
164
|
+
function streamed(output, scope) {
|
|
165
|
+
if (output["x-sincpro-streamed"] === undefined)
|
|
166
|
+
return undefined;
|
|
167
|
+
scope.imports.add("Download");
|
|
168
|
+
return "Download";
|
|
169
|
+
}
|
|
136
170
|
/** What the server says of an operation: its words, then what it is, for whom, on which field. */
|
|
137
171
|
function operationDoc(metamodel, local, operation) {
|
|
138
172
|
const runsOn = sorted(operation.targets).map(([entity, field]) => `\`${entity}\` in \`${field}\``);
|
|
@@ -150,7 +184,8 @@ function operationDoc(metamodel, local, operation) {
|
|
|
150
184
|
*
|
|
151
185
|
* 1. The models, by name: an entity's contract, a related model by its own name.
|
|
152
186
|
* 2. Each operation, by its local name (its identity without `<context>.`): its input as
|
|
153
|
-
* `<Name>` and its answer
|
|
187
|
+
* `<Name>` and its answer by the backend's own name for it (`answerOf`: `ResponseX`), with
|
|
188
|
+
* `<Name>Answer` kept as a deprecated alias — or as `<Name>Answer` when it has no name.
|
|
154
189
|
* 3. The shared definitions those reach — and the ones those reach, until none is new —, but
|
|
155
190
|
* never one a package of the stack already types (`Criteria`).
|
|
156
191
|
* Final: a header that says where it came from and not to edit it, the imports, then models,
|
|
@@ -165,15 +200,28 @@ export function domainFileOf(metamodel) {
|
|
|
165
200
|
claim(identifierOf(name), `model ${name}`);
|
|
166
201
|
return `${docOf([schema.description], "")}export type ${identifierOf(name)} = ${typeOf(schema, scope, 0, true)};`;
|
|
167
202
|
});
|
|
203
|
+
// A Response two Commands share is written once, under the first that answers with it.
|
|
204
|
+
const answered = new Set();
|
|
168
205
|
const operations = sorted(metamodel.operations).flatMap(([identity, operation]) => {
|
|
169
206
|
const local = localOf(metamodel, identity);
|
|
207
|
+
const named = answerOf(metamodel, identity);
|
|
170
208
|
claim(local, `operation ${identity}`);
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
209
|
+
const answer = operation.output === null
|
|
210
|
+
? "unknown"
|
|
211
|
+
: (streamed(operation.output, scope) ?? typeOf(operation.output, scope, 0, false));
|
|
212
|
+
const written = [
|
|
174
213
|
`${operationDoc(metamodel, local, operation)}export type ${local} = ${typeOf(operation.input, scope, 0, false)};`,
|
|
175
|
-
`/** What \`${local}\` answers. */\nexport type ${local}Answer = ${answer};`,
|
|
176
214
|
];
|
|
215
|
+
if (!answered.has(named)) {
|
|
216
|
+
answered.add(named);
|
|
217
|
+
claim(named, `the answer of ${identity}`);
|
|
218
|
+
written.push(`/** What \`${local}\` answers. */\nexport type ${named} = ${answer};`);
|
|
219
|
+
}
|
|
220
|
+
if (named !== `${local}Answer`) {
|
|
221
|
+
claim(`${local}Answer`, `the old name of the answer of ${identity}`);
|
|
222
|
+
written.push(`/** @deprecated The backend calls it \`${named}\`: use that name. */\nexport type ${local}Answer = ${named};`);
|
|
223
|
+
}
|
|
224
|
+
return written;
|
|
177
225
|
});
|
|
178
226
|
const written = new Map();
|
|
179
227
|
for (let pending = [...scope.reached]; pending.length > 0;) {
|
|
@@ -187,9 +235,14 @@ export function domainFileOf(metamodel) {
|
|
|
187
235
|
claim(identifierOf(name), `definition ${name}`);
|
|
188
236
|
return written.get(name);
|
|
189
237
|
});
|
|
190
|
-
|
|
238
|
+
// One line per package, its names sorted.
|
|
239
|
+
const byPackage = new Map();
|
|
240
|
+
for (const name of [...scope.imports].sort()) {
|
|
191
241
|
const imported = IMPORTED[name];
|
|
192
|
-
|
|
193
|
-
}
|
|
242
|
+
byPackage.set(imported.from, [...(byPackage.get(imported.from) ?? []), imported.name]);
|
|
243
|
+
}
|
|
244
|
+
const imports = [...byPackage.keys()]
|
|
245
|
+
.sort()
|
|
246
|
+
.map((from) => `import type { ${byPackage.get(from).join(", ")} } from "${from}";`);
|
|
194
247
|
return `${[headerOf("The DTOs", metamodel), ...(imports.length === 0 ? [] : [imports.join("\n")]), ...models, ...shared, ...operations].join("\n\n")}\n`;
|
|
195
248
|
}
|
|
@@ -5,7 +5,8 @@ import { type GenerateOptions } from "./rest.js";
|
|
|
5
5
|
*
|
|
6
6
|
* 1. Per model, by name: the one `search` a transport reads for it (`own` when there are
|
|
7
7
|
* several) — none, and the model gets no entity, said in the file's own doc.
|
|
8
|
-
* 2. Its `get`, `edit` and `preview` the same way, the record's field written `{id}
|
|
8
|
+
* 2. Its `get`, `edit` and `preview` the same way, the record's field written `{id}`, and its
|
|
9
|
+
* `export` — every record of a criteria as a file — by its path.
|
|
9
10
|
* 3. Every write of role `command` that concerns it: with `target` when its body names the
|
|
10
11
|
* record, as it is when it creates one; its body typed without that field.
|
|
11
12
|
* Final: an `entity<T, C>()` per model listed, keyed `<domain>:<kind>`, and `entities`, all of
|
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) &&
|
|
@@ -54,10 +61,18 @@ function entitySourceOf(metamodel, model, domain, options, claim) {
|
|
|
54
61
|
const missing = [];
|
|
55
62
|
const routes = [` list: ${JSON.stringify(list.found.rest.path)},`];
|
|
56
63
|
const one = oneOf(reads("get", isGet), "get");
|
|
64
|
+
const edit = oneOf(reads("edit", isWrite), "edit");
|
|
57
65
|
if (one !== undefined && "found" in one) {
|
|
58
66
|
const field = one.found.operation.targets[model];
|
|
67
|
+
// sincpro_framework's `Get` answers the caller's own draft only when asked (`draft`), and an
|
|
68
|
+
// entity edited through `Edit` keeps its drafts there: its record is read with them, or a
|
|
69
|
+
// draft kept on one visit never comes back on the next.
|
|
70
|
+
const drafts = edit !== undefined &&
|
|
71
|
+
"found" in edit &&
|
|
72
|
+
one.found.operation.input.properties?.["draft"]?.type === "boolean";
|
|
73
|
+
const path = `${recordPath(one.found.rest, field)}${drafts ? "?draft=true" : ""}`;
|
|
59
74
|
if (field !== undefined && one.found.rest.fields.length === 1)
|
|
60
|
-
routes.push(` one: ${JSON.stringify(
|
|
75
|
+
routes.push(` one: ${JSON.stringify(path)},`);
|
|
61
76
|
else
|
|
62
77
|
missing.push(`one: \`${one.found.local}\` is at ${one.found.rest.path}`);
|
|
63
78
|
}
|
|
@@ -75,6 +90,13 @@ function entitySourceOf(metamodel, model, domain, options, claim) {
|
|
|
75
90
|
const route = `${write.found.rest.method} ${recordPath(write.found.rest, field)}`;
|
|
76
91
|
routes.push(` ${role}: ${JSON.stringify(route)},`);
|
|
77
92
|
}
|
|
93
|
+
const exported = oneOf(reads("export", isGet), "export");
|
|
94
|
+
if (exported !== undefined) {
|
|
95
|
+
if ("missing" in exported)
|
|
96
|
+
missing.push(`export: ${exported.missing}`);
|
|
97
|
+
else
|
|
98
|
+
routes.push(` export: ${JSON.stringify(exported.found.rest.path)},`);
|
|
99
|
+
}
|
|
78
100
|
const commands = reached.filter((one) => one.operation.role === "command" &&
|
|
79
101
|
isWrite(one.rest.method) &&
|
|
80
102
|
one.operation.entities.includes(model));
|
|
@@ -88,7 +110,7 @@ function entitySourceOf(metamodel, model, domain, options, claim) {
|
|
|
88
110
|
bodies.push(target === undefined
|
|
89
111
|
? ` ${method}: dto.${command.local};`
|
|
90
112
|
: ` ${method}: Omit<dto.${command.local}, ${JSON.stringify(target)}>;`);
|
|
91
|
-
answers.push(` ${method}: dto.${command.
|
|
113
|
+
answers.push(` ${method}: dto.${command.answer};`);
|
|
92
114
|
named.push(target === undefined
|
|
93
115
|
? ` ${method}: ${JSON.stringify(route)},`
|
|
94
116
|
: ` ${method}: { route: ${JSON.stringify(route)}, target: ${JSON.stringify(target)} },`);
|
|
@@ -120,7 +142,8 @@ function entitySourceOf(metamodel, model, domain, options, claim) {
|
|
|
120
142
|
*
|
|
121
143
|
* 1. Per model, by name: the one `search` a transport reads for it (`own` when there are
|
|
122
144
|
* several) — none, and the model gets no entity, said in the file's own doc.
|
|
123
|
-
* 2. Its `get`, `edit` and `preview` the same way, the record's field written `{id}
|
|
145
|
+
* 2. Its `get`, `edit` and `preview` the same way, the record's field written `{id}`, and its
|
|
146
|
+
* `export` — every record of a criteria as a file — by its path.
|
|
124
147
|
* 3. Every write of role `command` that concerns it: with `target` when its body names the
|
|
125
148
|
* record, as it is when it creates one; its body typed without that field.
|
|
126
149
|
* Final: an `entity<T, C>()` per model listed, keyed `<domain>:<kind>`, and `entities`, all of
|