@sincpro/client-framework 0.4.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/README.md +28 -0
- package/dist/client/memory.js +5 -3
- 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.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 +3 -1
- package/dist/index.js +3 -1
- package/dist/observability/ports.d.ts +2 -1
- package/dist/services/bounded-context.js +4 -15
- package/dist/services/execution.js +2 -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 +67 -0
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -179,6 +179,23 @@ await project.Issues.close(input); // span project/Issues.close
|
|
|
179
179
|
await project.CloseAndNotify.execute(input); // project/CloseAndNotify → its Features under it
|
|
180
180
|
```
|
|
181
181
|
|
|
182
|
+
The generator writes this skeleton for you, once, from the service's contract — and never touches
|
|
183
|
+
a file that is already there, because from then on it is yours:
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
sincpro-client generate project.contract.json --out src/domains/project/generated --domain project
|
|
187
|
+
sincpro-client scaffold project.contract.json --out src/domains/project # dependencies, bases, services, index
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
And the layers stay layers: `sincpro-client check` resolves every import of the application with
|
|
191
|
+
its own TypeScript and `tsconfig` (aliases included) and fails when a bounded context reaches into
|
|
192
|
+
another's insides, a service imports React or web-ui, `common` imports a bounded context, or the
|
|
193
|
+
entrypoints skip a bounded context's `index.ts` — put it in `make doctor`:
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
sincpro-client check --root src
|
|
197
|
+
```
|
|
198
|
+
|
|
182
199
|
Every public method is a use case: a span named as the backend names its own
|
|
183
200
|
(`project/Issues.close`, identity `project.Issues.close`), and a report when it fails in a way
|
|
184
201
|
nobody expected. One gesture is one trace, from the click to the database:
|
|
@@ -224,6 +241,10 @@ processContext.set({ tenant_id: "acme" }); // every use case runs with it and se
|
|
|
224
241
|
project.context.set({ warehouse_id: 4 }); // only what «project» runs
|
|
225
242
|
```
|
|
226
243
|
|
|
244
|
+
What a screen reads and writes is a use case too when it goes through `screensTransport(transport)`:
|
|
245
|
+
each request is `screens/EntityReads.list`, `…/EntityWrites.send` — traced, with the context on
|
|
246
|
+
it, and reported when it failed.
|
|
247
|
+
|
|
227
248
|
A service reads it as `this.context` — the process's values, under them its bounded context's,
|
|
228
249
|
under them a scope's, read once where the gesture started — and every request it sends carries
|
|
229
250
|
it in `sincpro-context`. A screen hears it change with `processContext.watch(…)`.
|
|
@@ -263,6 +284,10 @@ const tracer = openTelemetryTracer(provider.getTracer("my-app")); // the app's p
|
|
|
263
284
|
const reporter = sentryReporter(client); // the app's Sentry-compatible client (GlitchTip too)
|
|
264
285
|
```
|
|
265
286
|
|
|
287
|
+
What fails outside a use case — a screen that broke while drawing, a subscriber, a worker — goes to
|
|
288
|
+
the same reporter with `reportError(error, where)`: once (an error a use case already reported is
|
|
289
|
+
not sent again), with the process's context.
|
|
290
|
+
|
|
266
291
|
Nothing is installed or shipped unless it is imported, and without a tracer or a reporter every use
|
|
267
292
|
case runs the same — nothing traces, nothing reports. In a test, `memoryTracer()` and
|
|
268
293
|
`memoryReporter()` (`@sincpro/client-framework/testing`).
|
|
@@ -344,6 +369,9 @@ three ports, in its `main`:
|
|
|
344
369
|
| `ContextStore` | memory, `sessionStorage`, or the screens' store | MMKV, or memory hydrated from AsyncStorage | `memoryContexts` |
|
|
345
370
|
| `Transport` | `restTransport` over `fetch` | `restTransport`, or SQLite offline | `memoryTransport` |
|
|
346
371
|
|
|
372
|
+
How @sincpro/mobile adopts all of this — what maps to what, the MMKV store, the order to move
|
|
373
|
+
in — is [`docs/MOBILE.md`](docs/MOBILE.md).
|
|
374
|
+
|
|
347
375
|
A store's reads are synchronous — a use case reads its context where a gesture starts and never
|
|
348
376
|
waits for it — so an asynchronous storage is held in memory and written through.
|
|
349
377
|
|
package/dist/client/memory.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { TransportError, } from "./transport.js";
|
|
2
|
-
import {
|
|
2
|
+
import { pack, readValues } from "../common/serialization.js";
|
|
3
3
|
import { answer, answerGroups } from "@sincpro/criteria/engine";
|
|
4
4
|
/**
|
|
5
|
-
* A deep copy written and read by the wire's rules (`
|
|
5
|
+
* A deep copy written and read by the wire's rules (`pack`, `readValues`): what a real server
|
|
6
6
|
* would have received and answered — a `Money` as `{ amount, currency }`, a `Decimal` as its text,
|
|
7
7
|
* a `Date` as an instant, bytes as bytes — the same on every platform. `structuredClone` kept a
|
|
8
8
|
* `Date` a `Date` in a browser and a JSON round trip made it text on a phone; neither is a wire.
|
|
9
9
|
*/
|
|
10
|
-
const copyOf = (value) =>
|
|
10
|
+
const copyOf = (value) =>
|
|
11
|
+
// Through text, as a wire is: an answer that is a string («"2.50"», an instant) stays one.
|
|
12
|
+
value === undefined ? value : readValues(pack(value));
|
|
11
13
|
function wait(ms, signal) {
|
|
12
14
|
if (ms <= 0)
|
|
13
15
|
return Promise.resolve();
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The import rules of a Sincpro application, checked — what `sincpro-client check` runs in an
|
|
3
|
+
* application's `make doctor`, on the web and on a phone alike.
|
|
4
|
+
*
|
|
5
|
+
* src/domains/<bounded context>/{generated,domain,adapters,services,infrastructure,components,entrypoints}
|
|
6
|
+
* src/domains/common/ what bounded contexts share
|
|
7
|
+
* src/entrypoints/ where the outside comes in
|
|
8
|
+
*
|
|
9
|
+
* | Layer | Never |
|
|
10
|
+
* | ----------------- | ---------------------------------------------------------------------- |
|
|
11
|
+
* | generated, domain | React, the DOM's libraries (web-ui, composer), its own adapters |
|
|
12
|
+
* | adapters | React, web-ui, composer |
|
|
13
|
+
* | services | React, web-ui, composer |
|
|
14
|
+
* | infrastructure | its own components |
|
|
15
|
+
* | components | composer, a router |
|
|
16
|
+
* | common | any bounded context |
|
|
17
|
+
* | any layer | another bounded context — but its `index.ts`, from `infrastructure/` |
|
|
18
|
+
* | entrypoints/ root | a bounded context's insides — only its `index.ts` (and `common`) |
|
|
19
|
+
*
|
|
20
|
+
* Context: the architecture says these rules are enforced, not recommended; a rule only read in a
|
|
21
|
+
* page is the first to go under a deadline. Each import is resolved the way the application's
|
|
22
|
+
* compiler resolves it — its own `tsconfig.json`, its path aliases (`@/domains/…`) — with the
|
|
23
|
+
* application's TypeScript, so a relative path and an alias are the same import. Nothing here is
|
|
24
|
+
* a platform's: a React Native app has the same layers (`react-native` counts as React).
|
|
25
|
+
*/
|
|
26
|
+
export type Violation = {
|
|
27
|
+
/** The importing file, from the root. */
|
|
28
|
+
file: string;
|
|
29
|
+
/** What it imports, as written. */
|
|
30
|
+
imports: string;
|
|
31
|
+
why: string;
|
|
32
|
+
};
|
|
33
|
+
/** Where a file is in the application's tree. */
|
|
34
|
+
export type Place = {
|
|
35
|
+
kind: "context";
|
|
36
|
+
context: string;
|
|
37
|
+
layer: string | null;
|
|
38
|
+
index: boolean;
|
|
39
|
+
} | {
|
|
40
|
+
kind: "entrypoint";
|
|
41
|
+
} | {
|
|
42
|
+
kind: "other";
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* `domains/sales/services/close.ts` → the bounded context `sales`, the layer `services` — wherever
|
|
46
|
+
* the `domains/` folder is (`src/domains/…`, a workspace's `domains/sales/src/services/…`).
|
|
47
|
+
*/
|
|
48
|
+
export declare function placeOf(path: string): Place;
|
|
49
|
+
/**
|
|
50
|
+
* Why `from` may not import `specifier`, which resolves to `to` (`null`: a package), or nothing
|
|
51
|
+
* when it may.
|
|
52
|
+
*/
|
|
53
|
+
export declare function violationOf(from: Place, specifier: string, to: Place | null): string | undefined;
|
|
54
|
+
/** What stops the check before it starts: no root, no config, a config that does not parse. */
|
|
55
|
+
export declare class CheckError extends Error {
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Every import under `root` that breaks a rule, resolved by the application's own TypeScript and
|
|
59
|
+
* `tsconfig` (its path aliases and its references included). An alias that does not resolve is a
|
|
60
|
+
* break too — read as a package it would switch every rule off.
|
|
61
|
+
*/
|
|
62
|
+
export declare function checkImports(root: string, options?: {
|
|
63
|
+
tsconfig?: string;
|
|
64
|
+
}): Promise<Violation[]>;
|
|
@@ -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
|
|
@@ -71,7 +71,7 @@ function dataOf(metamodel, identity, operation, rest) {
|
|
|
71
71
|
lines.push(` mcp: { tool: ${JSON.stringify(mcp.tool)} },`);
|
|
72
72
|
return ` ${localOf(metamodel, identity)}: {\n${lines.join("\n")}\n },`;
|
|
73
73
|
}
|
|
74
|
-
function methodSourceOf(local, method, call, rest, doc) {
|
|
74
|
+
function methodSourceOf(local, answer, method, call, rest, doc) {
|
|
75
75
|
if (call.kind === "list") {
|
|
76
76
|
const entity = `dto.${identifierOf(call.entity)}`;
|
|
77
77
|
const params = paramsOf(call);
|
|
@@ -80,7 +80,7 @@ function methodSourceOf(local, method, call, rest, doc) {
|
|
|
80
80
|
}
|
|
81
81
|
if (call.kind === "read") {
|
|
82
82
|
const params = paramsOf(call);
|
|
83
|
-
return `${doc} ${method}: (${params.signature}) =>\n readRoute<dto.${
|
|
83
|
+
return `${doc} ${method}: (${params.signature}) =>\n readRoute<dto.${answer}>(transport, ${params.route(rest.path)}),`;
|
|
84
84
|
}
|
|
85
85
|
if (call.kind === "one") {
|
|
86
86
|
const path = rest.path.replace(`{${call.field}}`, "${encodeURIComponent(id)}");
|
|
@@ -91,7 +91,7 @@ function methodSourceOf(local, method, call, rest, doc) {
|
|
|
91
91
|
: "undefined";
|
|
92
92
|
return [
|
|
93
93
|
`${doc} ${method}: (body: dto.${local}, options?: SendOptions) =>`,
|
|
94
|
-
` transport.send<dto.${
|
|
94
|
+
` transport.send<dto.${answer}>(`,
|
|
95
95
|
` ${JSON.stringify(rest.method)},`,
|
|
96
96
|
` pathFor(routes.${method}, ${id}, body),`,
|
|
97
97
|
` body,`,
|
|
@@ -134,7 +134,7 @@ export function clientFileOf(metamodel, options = {}) {
|
|
|
134
134
|
const method = methodOf(local);
|
|
135
135
|
claim(method, `operation ${identity}`);
|
|
136
136
|
const doc = docOf([operation.description, deprecationOf(metamodel, operation)], " ");
|
|
137
|
-
methods.push(methodSourceOf(local, method, call, rest, doc));
|
|
137
|
+
methods.push(methodSourceOf(local, answerOf(metamodel, identity), method, call, rest, doc));
|
|
138
138
|
if (call.kind === "list" || call.kind === "read") {
|
|
139
139
|
paged ||= call.kind === "list";
|
|
140
140
|
reads ||= call.kind === "read";
|
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
|
|
@@ -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`. */
|