@sincpro/client-framework 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/AGENTS.md +22 -11
  2. package/MANIFESTO.md +7 -3
  3. package/README.md +58 -0
  4. package/dist/client/memory.js +8 -5
  5. package/dist/codegen/check.d.ts +64 -0
  6. package/dist/codegen/check.js +195 -0
  7. package/dist/codegen/cli.js +73 -6
  8. package/dist/codegen/client.js +5 -5
  9. package/dist/codegen/domain.d.ts +2 -1
  10. package/dist/codegen/domain.js +17 -5
  11. package/dist/codegen/entities.js +10 -3
  12. package/dist/codegen/index.d.ts +2 -0
  13. package/dist/codegen/index.js +2 -0
  14. package/dist/codegen/names.d.ts +12 -0
  15. package/dist/codegen/names.js +58 -0
  16. package/dist/codegen/scaffold.d.ts +24 -0
  17. package/dist/codegen/scaffold.js +123 -0
  18. package/dist/common/decimal.d.ts +49 -0
  19. package/dist/common/decimal.js +154 -0
  20. package/dist/{client → common}/ids.d.ts +7 -0
  21. package/dist/{client → common}/ids.js +12 -0
  22. package/dist/common/money.d.ts +74 -0
  23. package/dist/common/money.js +168 -0
  24. package/dist/common/serialization.d.ts +53 -0
  25. package/dist/common/serialization.js +168 -0
  26. package/dist/common/time.d.ts +41 -0
  27. package/dist/common/time.js +100 -0
  28. package/dist/context/codec.d.ts +26 -0
  29. package/dist/context/codec.js +44 -0
  30. package/dist/index.d.ts +11 -2
  31. package/dist/index.js +11 -2
  32. package/dist/observability/ports.d.ts +2 -1
  33. package/dist/services/bounded-context.js +5 -16
  34. package/dist/services/execution.d.ts +2 -2
  35. package/dist/services/execution.js +6 -4
  36. package/dist/services/feature.d.ts +4 -1
  37. package/dist/services/report.d.ts +15 -0
  38. package/dist/services/report.js +53 -0
  39. package/dist/services/screens.d.ts +2 -0
  40. package/dist/services/screens.js +67 -0
  41. package/package.json +6 -2
package/AGENTS.md CHANGED
@@ -20,17 +20,18 @@ it says what this package is responsible for and what it is not. This file is ho
20
20
 
21
21
  ## Where things go
22
22
 
23
- | Folder | What |
24
- | -------------------- | ------------------------------------------------------------------------------------------- |
25
- | `src/client/` | The seam with a server: `Transport`, REST, memory, `entity()`, the client contract, ids |
26
- | `src/model/` | Model definitions: `defineMeta`, from a JSON Schema, the store of known ones |
27
- | `src/services/` | `UseBoundedContext`, `Feature`, `ApplicationService`, the process, the execution |
28
- | `src/context/` | The `ContextStore` port, `memoryContexts`, and `Context` — one level of it |
29
- | `src/observability/` | The ports a platform plugs: `Tracer`, `ErrorReporter` |
30
- | `src/codegen/` | The generator and its CLI (`sincpro-client generate`) — the only place Node is allowed |
31
- | `src/adapters/` | Optional entries over a vendor (`./opentelemetry`, `./sentry`): the only place one is named |
32
- | `src/testing/` | `memoryTracer`, `memoryReporter` — the ports in memory, for tests |
33
- | `tests/` | vitest in Node; `tests/support/` holds the fake domains and forge's real contracts |
23
+ | Folder | What |
24
+ | -------------------- | --------------------------------------------------------------------------------------------- |
25
+ | `src/common/` | The values of the wire: ids, a day and an instant, `Decimal`, `Money`, `valuesOf` / `rebuilt` |
26
+ | `src/client/` | The seam with a server: `Transport`, REST, memory, `entity()`, the client contract |
27
+ | `src/model/` | Model definitions: `defineMeta`, from a JSON Schema, the store of known ones |
28
+ | `src/services/` | `UseBoundedContext`, `Feature`, `ApplicationService`, the process, the execution |
29
+ | `src/context/` | The `ContextStore` port, `memoryContexts`, and `Context` — one level of it |
30
+ | `src/observability/` | The ports a platform plugs: `Tracer`, `ErrorReporter` |
31
+ | `src/codegen/` | The generator and its CLI (`sincpro-client generate`) — the only place Node is allowed |
32
+ | `src/adapters/` | Optional entries over a vendor (`./opentelemetry`, `./sentry`): the only place one is named |
33
+ | `src/testing/` | `memoryTracer`, `memoryReporter` — the ports in memory, for tests |
34
+ | `tests/` | vitest in Node; `tests/support/` holds the fake domains and forge's real contracts |
34
35
 
35
36
  ## Hard rules (the build enforces them — `make doctor`)
36
37
 
@@ -90,6 +91,16 @@ it says what this package is responsible for and what it is not. This file is ho
90
91
  an `AbortError` is `aborted`; neither is reported (`outcomeOf`). Change that with
91
92
  `ProcessOptions.outcomeOf`, never by catching in a Feature.
92
93
 
94
+ - **Only what is transversal comes here.** A value's wire form (a decimal as text, a day never
95
+ an instant, a money with its currency) is the backend's contract, the same on every platform,
96
+ so it lives in `src/common/`. How a value is shown to a person, typed in an input, or kept in
97
+ SQLite or IndexedDB is the platform's — @sincpro/web-framework's or @sincpro/mobile's.
98
+ - **A decimal never becomes a `number`.** It travels as text and is computed with `Decimal` or
99
+ `Money` (exact, on `BigInt`); `Number(...)` or `parseFloat` on an amount is the float the
100
+ backend sends text to avoid. And a day is never a `Date`: a `Date` is an instant.
101
+ - **The in-memory server copies by the wire's rules** (`valuesOf` → `readValues`): what it receives
102
+ and answers is what a real server would — a `Money` as `{ amount, currency }`, a `Date` as text.
103
+
93
104
  ## Before you hand it back
94
105
 
95
106
  ```bash
package/MANIFESTO.md CHANGED
@@ -48,10 +48,14 @@ server-side render, in a script and in a test, unchanged.**
48
48
  never import each other's insides.
49
49
  6. **The client.** The `Transport` port and its implementations (REST, memory), `entity()` with
50
50
  typed commands and answers, the service's client contract, model definitions read once from
51
- the server, client-side ids (UUID v7) and the server's `Problem`.
52
- 7. **The generator.** From a service's client contract to each domain's types, client and
51
+ the server and the server's `Problem`.
52
+ 7. **The values of the wire.** How every value travels, as the backend writes it — a decimal as
53
+ text, a money with its currency, a day that is never an instant, bytes tagged, ids as UUID v7 —
54
+ and the exact arithmetic a client needs on them (`Decimal`, `Money`). Only the wire form and
55
+ what follows from it: showing a value to a person is each platform's.
56
+ 8. **The generator.** From a service's client contract to each domain's types, client and
53
57
  entities — generated, never edited, and as platform-neutral as everything else here.
54
- 8. **Testing.** An in-memory transport, tracer and reporter, so a use case is proven in Node with
58
+ 9. **Testing.** An in-memory transport, tracer and reporter, so a use case is proven in Node with
55
59
  nothing else.
56
60
 
57
61
  ## What it is not
package/README.md CHANGED
@@ -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,10 +284,44 @@ 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`).
269
294
 
295
+ ## Values on the wire
296
+
297
+ The backend writes every message with one set of rules, and every client reads and writes them
298
+ the same — so they live here, not in each platform:
299
+
300
+ | Value | Travels as | Here |
301
+ | ----------- | ------------------------------------------ | ------------------------------------------------ |
302
+ | a decimal | text, every digit: `"10.50"` | `Decimal` — exact, on `BigInt`; rounds half up |
303
+ | a money | `{ "amount": "10.50", "currency": "BOB" }` | `Money` — never adds two currencies |
304
+ | a day | `"2026-10-02"`, never converted | `isDay`, `dayOf(instant, zone)` for «today» |
305
+ | an instant | ISO text: `"2026-10-02T14:30:00Z"` | `wallTimeOf` / `instantOf` on the company's zone |
306
+ | an id | a UUID v7 as 32 hex | `newEntityId`, `isEntityId`, `momentOf` |
307
+ | bytes | `{ "$bytes": "<base64>" }` | `valuesOf` / `readValues` |
308
+ | the context | only what travels: text, numbers, lists | `plainCodec`, `travelling` |
309
+
310
+ ```ts
311
+ import { Money, pack, unpack, valuesOf } from "@sincpro/client-framework";
312
+
313
+ const line = new Money("19.90", "BOB").times("3"); // 59.70 BOB, exact
314
+ const total = Money.total([line, delivery], "BOB").rounded(); // to the currency's decimals
315
+ valuesOf({ total, at: new Date() }); // { total: { amount, currency }, at: "…Z" }
316
+ const answer = unpack(text, Money); // a typed read when asked for — never imposed
317
+ ```
318
+
319
+ Not restrictive, like the backend: reading keeps every key it finds, a typed read is asked for
320
+ (`rebuilt(values, Money)`), any currency code is read, and an application adds its own decimals
321
+ (`Money.decimals["USDT"] = 6`). What JSON cannot write (`NaN`, a function, a cycle) is refused at
322
+ once, naming where. Showing a value to a person — `Intl.NumberFormat`, a date picker — is each
323
+ platform's, never this package's.
324
+
270
325
  ## Where it sits
271
326
 
272
327
  ```mermaid
@@ -314,6 +369,9 @@ three ports, in its `main`:
314
369
  | `ContextStore` | memory, `sessionStorage`, or the screens' store | MMKV, or memory hydrated from AsyncStorage | `memoryContexts` |
315
370
  | `Transport` | `restTransport` over `fetch` | `restTransport`, or SQLite offline | `memoryTransport` |
316
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
+
317
375
  A store's reads are synchronous — a use case reads its context where a gesture starts and never
318
376
  waits for it — so an asynchronous storage is held in memory and written through.
319
377
 
@@ -1,12 +1,15 @@
1
1
  import { TransportError, } from "./transport.js";
2
+ import { pack, readValues } from "../common/serialization.js";
2
3
  import { answer, answerGroups } from "@sincpro/criteria/engine";
3
4
  /**
4
- * A deep copy, as over a wire: `structuredClone` where the platform has it, a JSON round trip
5
- * where it does not (some React Native engines) — the rows a fake server holds are plain JSON.
5
+ * A deep copy written and read by the wire's rules (`pack`, `readValues`): what a real server
6
+ * would have received and answered — a `Money` as `{ amount, currency }`, a `Decimal` as its text,
7
+ * a `Date` as an instant, bytes as bytes — the same on every platform. `structuredClone` kept a
8
+ * `Date` a `Date` in a browser and a JSON round trip made it text on a phone; neither is a wire.
6
9
  */
7
- const copyOf = typeof globalThis.structuredClone === "function"
8
- ? (value) => globalThis.structuredClone(value)
9
- : (value) => (value === undefined ? value : JSON.parse(JSON.stringify(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));
10
13
  function wait(ms, signal) {
11
14
  if (ms <= 0)
12
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
+ }
@@ -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 = "usage: sincpro-client generate <contract.json> --out <dir> [--domain <name>] [--base <path>]";
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
- process.exitCode = main(process.argv.slice(2));
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);
@@ -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.${local}Answer>(transport, ${params.route(rest.path)}),`;
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.${local}Answer>(`,
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";
@@ -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 as `<Name>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,