@sincpro/client-framework 0.0.0-stage → 0.1.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 (55) hide show
  1. package/AGENTS.md +78 -0
  2. package/LICENSE +21 -0
  3. package/MANIFESTO.md +124 -0
  4. package/README.md +88 -2
  5. package/dist/client/contract.d.ts +34 -0
  6. package/dist/client/contract.js +41 -0
  7. package/dist/client/discovery.d.ts +47 -0
  8. package/dist/client/discovery.js +68 -0
  9. package/dist/client/entity.d.ts +242 -0
  10. package/dist/client/entity.js +187 -0
  11. package/dist/client/ids.d.ts +17 -0
  12. package/dist/client/ids.js +52 -0
  13. package/dist/client/memory.d.ts +46 -0
  14. package/dist/client/memory.js +108 -0
  15. package/dist/client/rest.d.ts +49 -0
  16. package/dist/client/rest.js +143 -0
  17. package/dist/client/retry.d.ts +24 -0
  18. package/dist/client/retry.js +25 -0
  19. package/dist/client/transport.d.ts +89 -0
  20. package/dist/client/transport.js +26 -0
  21. package/dist/codegen/cli.d.ts +2 -0
  22. package/dist/codegen/cli.js +82 -0
  23. package/dist/codegen/client.d.ts +14 -0
  24. package/dist/codegen/client.js +180 -0
  25. package/dist/codegen/domain.d.ts +14 -0
  26. package/dist/codegen/domain.js +195 -0
  27. package/dist/codegen/entities.d.ts +14 -0
  28. package/dist/codegen/entities.js +158 -0
  29. package/dist/codegen/index.d.ts +10 -0
  30. package/dist/codegen/index.js +8 -0
  31. package/dist/codegen/metamodel.d.ts +89 -0
  32. package/dist/codegen/metamodel.js +1 -0
  33. package/dist/codegen/names.d.ts +39 -0
  34. package/dist/codegen/names.js +113 -0
  35. package/dist/codegen/rest.d.ts +31 -0
  36. package/dist/codegen/rest.js +27 -0
  37. package/dist/index.d.ts +31 -0
  38. package/dist/index.js +31 -0
  39. package/dist/model/meta.d.ts +121 -0
  40. package/dist/model/meta.js +130 -0
  41. package/dist/model/metas.d.ts +17 -0
  42. package/dist/model/metas.js +7 -0
  43. package/dist/model/schema.d.ts +39 -0
  44. package/dist/model/schema.js +128 -0
  45. package/dist/observability/ports.d.ts +64 -0
  46. package/dist/observability/ports.js +21 -0
  47. package/dist/services/execution.d.ts +63 -0
  48. package/dist/services/execution.js +88 -0
  49. package/dist/services/feature.d.ts +60 -0
  50. package/dist/services/feature.js +206 -0
  51. package/dist/services/runtime.d.ts +48 -0
  52. package/dist/services/runtime.js +30 -0
  53. package/dist/testing/index.d.ts +35 -0
  54. package/dist/testing/index.js +37 -0
  55. package/package.json +76 -4
@@ -0,0 +1,49 @@
1
+ import { type Transport } from "./transport.js";
2
+ import { type Criteria } from "@sincpro/criteria";
3
+ /**
4
+ * The REST wire of sincpro_framework (`FastApiGateway`).
5
+ *
6
+ * Context: a list is criteria's `restSource` (0.3.1 sends the criteria as plain JSON and joins
7
+ * a route that carries its own query, `/projects?tenant_id=acme` — the two things forge's
8
+ * console found wrong in 0.3.0, fixed there). What this adds is what criteria does not know:
9
+ * one record (`one()`), a write (`send()`, with its `Idempotency-Key`), and its refusals as a
10
+ * `TransportError`, the one error every screen explains.
11
+ *
12
+ * The definition of each model — what a list, a form and a select are built from — is read
13
+ * from the bounded context's discovery document (`document`, `/openapi.json` by default), once,
14
+ * and read again only when an answer says another version of its model (`model_version`); the
15
+ * document is revalidated with its `ETag`, so an unchanged one costs a `304`.
16
+ *
17
+ * const transport = restTransport({
18
+ * base: "/v1",
19
+ * headers: () => ({ Authorization: `Bearer ${session.secret}` }),
20
+ * });
21
+ */
22
+ export type RestOptions = {
23
+ /** Prepended to every route: `"/v1"`, `"https://forge.sincpro.com.bo/v1"`. */
24
+ base?: string;
25
+ /** Sent with every request. A function is called fresh each time — a token that rotates. */
26
+ headers?: Record<string, string> | (() => Record<string, string>);
27
+ /** Defaults to the global `fetch`. Inject one for tests. */
28
+ fetch?: typeof fetch;
29
+ /** Where the bounded context's discovery document is: `"/openapi.json"` by default, at the
30
+ * origin of `base`. `null`: none is read, and the definitions come only from `metas`. */
31
+ document?: string | null;
32
+ /**
33
+ * The version of each model the application was written against — the snapshot it was
34
+ * generated from (`versionsOf(contract)`). With it, an answer of another version is told to
35
+ * `onVersionDrift`: the server moved, the generated code did not.
36
+ */
37
+ snapshot?: Readonly<Record<string, string>>;
38
+ /** Called once per model and version the server answers that the snapshot does not hold —
39
+ * never an error: the screen keeps working on the live definition. */
40
+ onVersionDrift?: (drift: VersionDrift) => void;
41
+ };
42
+ /** A model the server answers at another version than the snapshot the code came from. */
43
+ export type VersionDrift = {
44
+ model: string;
45
+ snapshot: string;
46
+ live: string;
47
+ };
48
+ export declare function urlOf<T>(base: string, route: string, criteria?: Criteria<T>): string;
49
+ export declare function restTransport(options?: RestOptions): Transport;
@@ -0,0 +1,143 @@
1
+ import { definitionOf, modelAt, } from "./discovery.js";
2
+ import { TransportError, } from "./transport.js";
3
+ import { readProblem } from "@sincpro/criteria";
4
+ import { RestError, restSource } from "@sincpro/criteria/rest";
5
+ /** The keys of an answer that are not the record: what is left is the record's own field. */
6
+ const ENVELOPE = new Set(["cursor", "count", "model_version", "dropped", "draft", "missing"]);
7
+ export function urlOf(base, route, criteria) {
8
+ const url = `${base}${route}`;
9
+ if (criteria === undefined)
10
+ return url;
11
+ const joiner = url.includes("?") ? "&" : "?";
12
+ return `${url}${joiner}criteria=${encodeURIComponent(JSON.stringify(criteria))}`;
13
+ }
14
+ /** The path a route is asked at, without its query: what a document path is matched with. */
15
+ function pathOf(url) {
16
+ const path = url.split("?")[0] ?? url;
17
+ return /^https?:\/\//.test(path) ? new URL(path).pathname : path;
18
+ }
19
+ /** Where the document is: `document` as given when absolute, else at the origin of `base`. */
20
+ function documentUrl(base, document) {
21
+ if (/^https?:\/\//.test(document) || !/^https?:\/\//.test(base))
22
+ return document;
23
+ return new URL(document, base).toString();
24
+ }
25
+ export function restTransport(options = {}) {
26
+ const base = options.base ?? "";
27
+ const doFetch = options.fetch ?? ((input, init) => fetch(input, init));
28
+ const own = () => typeof options.headers === "function" ? options.headers() : (options.headers ?? {});
29
+ // The discovery document, read once; asked again — with its ETag — when an answer says a
30
+ // version of its model the document did not.
31
+ const located = options.document === null ? null : (options.document ?? "/openapi.json");
32
+ let held;
33
+ let tag = null;
34
+ function documentRead(fresh) {
35
+ if (located === null)
36
+ return Promise.resolve(null);
37
+ if (held !== undefined && !fresh)
38
+ return held;
39
+ const before = held;
40
+ held = (async () => {
41
+ try {
42
+ const response = await doFetch(documentUrl(base, located), {
43
+ headers: { ...own(), ...(tag === null ? {} : { "If-None-Match": tag }) },
44
+ });
45
+ if (response.status === 304 && before !== undefined)
46
+ return await before;
47
+ if (!response.ok)
48
+ return null;
49
+ tag = response.headers.get("etag");
50
+ return (await response.json());
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ })();
56
+ return held;
57
+ }
58
+ /** The definition of the model `url` answers; read again when `version` is not the one held. */
59
+ // Each drift is told once: a list read every few seconds would say it on every page.
60
+ const told = new Set();
61
+ function noticeDrift(model, live) {
62
+ const snapshot = options.snapshot?.[model];
63
+ if (snapshot === undefined || !live || live === snapshot)
64
+ return;
65
+ const key = `${model}\u0000${live}`;
66
+ if (told.has(key))
67
+ return;
68
+ told.add(key);
69
+ options.onVersionDrift?.({ model, snapshot, live });
70
+ }
71
+ async function definitionAt(url, method, version) {
72
+ const published = await documentRead(false);
73
+ if (published === null)
74
+ return null;
75
+ const name = modelAt(published, pathOf(url), method);
76
+ if (name === undefined)
77
+ return null;
78
+ noticeDrift(name, version);
79
+ const known = definitionOf(published, name);
80
+ if (known === undefined || !version || known.version === version)
81
+ return known?.meta ?? null;
82
+ const again = await documentRead(true);
83
+ return (again === null ? undefined : definitionOf(again, name))?.meta ?? known.meta;
84
+ }
85
+ async function get(route, url, signal, write, call) {
86
+ const headers = write
87
+ ? {
88
+ ...own(),
89
+ ...call?.headers,
90
+ "Content-Type": "application/json",
91
+ ...(write.key === undefined ? {} : { "Idempotency-Key": write.key }),
92
+ }
93
+ : { ...own(), ...call?.headers };
94
+ const response = await doFetch(url, {
95
+ headers,
96
+ ...(write ? { method: write.method, body: JSON.stringify(write.body) } : {}),
97
+ ...(signal ? { signal } : {}),
98
+ });
99
+ const text = await response.text();
100
+ if (!response.ok)
101
+ throw new TransportError(route, response.status, readProblem(text), undefined);
102
+ return text ? JSON.parse(text) : null;
103
+ }
104
+ return {
105
+ async list(route, criteria, signal, call) {
106
+ const source = restSource(`${base}${route}`, {
107
+ headers: () => ({ ...own(), ...call?.headers }),
108
+ fetch: doFetch,
109
+ });
110
+ try {
111
+ const page = await source.read(criteria, signal);
112
+ return {
113
+ ...page,
114
+ meta: page.meta ?? (await definitionAt(`${base}${route}`, "get", page.version)),
115
+ };
116
+ }
117
+ catch (error) {
118
+ if (error instanceof RestError)
119
+ throw new TransportError(route, error.status, error.problem, undefined);
120
+ throw error;
121
+ }
122
+ },
123
+ async read(route, signal, call) {
124
+ return (await get(route, urlOf(base, route), signal, undefined, call));
125
+ },
126
+ async one(route, signal, call) {
127
+ const answer = (await get(route, urlOf(base, route), signal, undefined, call));
128
+ const own = Object.keys(answer).filter((key) => !ENVELOPE.has(key));
129
+ const [key] = own;
130
+ if (own.length !== 1 || key === undefined)
131
+ throw new TransportError(route, 200, null, `${route} answered ${own.length} fields of its own (${own.join(", ")}); a record answers one`);
132
+ return {
133
+ record: answer[key],
134
+ meta: await definitionAt(urlOf(base, route), "get", answer["model_version"]),
135
+ dropped: answer["dropped"] ?? [],
136
+ draft: answer["draft"] ?? null,
137
+ };
138
+ },
139
+ async send(method, route, body, signal, sending) {
140
+ return (await get(route, urlOf(base, route), signal, { method, body, key: sending?.idempotencyKey }, sending));
141
+ },
142
+ };
143
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The `Idempotency-Key` of a gesture: one per thing a person meant to do, the SAME one while
3
+ * that thing is retried.
4
+ *
5
+ * Context: a Command that got no answer — the network dropped, a proxy answered 502 — may have
6
+ * run. Sending it again with a new key could pay twice; with the same key the server answers
7
+ * what it already did (sincpro_framework's `@idempotency`). So a key lives until the server
8
+ * gives a definite answer: done, or refused for a reason a retry does not change. Changing the
9
+ * body is a different gesture, and gets a new key — the server refuses a key that comes back
10
+ * with another payload.
11
+ *
12
+ * const keys = retryKeys();
13
+ * const key = keys.keyFor(body); // the same while the previous one went unanswered
14
+ * try { await send(…, { idempotencyKey: key }); keys.settle(null); }
15
+ * catch (error) { keys.settle(error); } // kept when nobody knows what happened
16
+ */
17
+ export type RetryKeys = {
18
+ keyFor: (body: unknown) => string;
19
+ /** What the last attempt ended in: `null` when it was answered. */
20
+ settle: (error: unknown) => void;
21
+ };
22
+ /** Whether a failure leaves it unknown if the Command ran — so a retry must reuse its key. */
23
+ export declare function unanswered(error: unknown): boolean;
24
+ export declare function retryKeys(make?: () => string): RetryKeys;
@@ -0,0 +1,25 @@
1
+ import { TransportError } from "./transport.js";
2
+ /** Whether a failure leaves it unknown if the Command ran — so a retry must reuse its key. */
3
+ export function unanswered(error) {
4
+ if (!(error instanceof TransportError))
5
+ return true;
6
+ if (error.status === null || error.status >= 500)
7
+ return true;
8
+ // The same key is still running elsewhere: retry once it completes, with that key.
9
+ return error.problem?.kind === "in_progress";
10
+ }
11
+ export function retryKeys(make = () => globalThis.crypto.randomUUID()) {
12
+ let pending = null;
13
+ return {
14
+ keyFor: (body) => {
15
+ const sent = JSON.stringify(body ?? null);
16
+ if (pending === null || pending.body !== sent)
17
+ pending = { body: sent, key: make() };
18
+ return pending.key;
19
+ },
20
+ settle: (error) => {
21
+ if (error === null || !unanswered(error))
22
+ pending = null;
23
+ },
24
+ };
25
+ }
@@ -0,0 +1,89 @@
1
+ import type { Criteria, Dropped, Meta, Page, Problem } from "@sincpro/criteria";
2
+ /**
3
+ * The one seam between this package and a server.
4
+ *
5
+ * Context: a list and a record are the same two questions whatever the wire is — «a page of
6
+ * these, under this criteria» and «this one». REST today; JSON-RPC and gRPC are the other
7
+ * wires sincpro_framework serves. Each of those is
8
+ * one adapter implementing this, and nothing above it changes. A route is a string the adapter
9
+ * understands: a path for REST, a method for RPC.
10
+ *
11
+ * Every adapter throws `TransportError` for a refusal, so a screen explains a failure once
12
+ * whichever wire it came over.
13
+ */
14
+ export interface Transport {
15
+ /** A page of records under the criteria. */
16
+ list<T>(route: string, criteria: Criteria<T>, signal?: AbortSignal, options?: CallOptions): Promise<Page<T>>;
17
+ /** One record, with the definition of its model. */
18
+ one<T>(route: string, signal?: AbortSignal, options?: CallOptions): Promise<One<T>>;
19
+ /** A Command — a write, an action, a preview: `method` and a JSON body, its answer back. */
20
+ send<T>(method: Method, route: string, body: unknown, signal?: AbortSignal, options?: SendOptions): Promise<T>;
21
+ /**
22
+ * A read that is neither a page nor one record — a Query of the context's own, `GET /me` —:
23
+ * the route with its parameters already in it (`listRoute`), its answer as it came. Optional,
24
+ * so a transport written before it keeps compiling; call it through `readRoute`.
25
+ */
26
+ read?<T>(route: string, signal?: AbortSignal, options?: CallOptions): Promise<T>;
27
+ }
28
+ /**
29
+ * A read through `transport.read`, or the reason it cannot be: a transport that has no `read`
30
+ * is said so by name, never a `TypeError` from somewhere inside a screen.
31
+ */
32
+ export declare function readRoute<T>(transport: Transport, route: string, signal?: AbortSignal, options?: CallOptions): Promise<T>;
33
+ export type Method = "POST" | "PUT" | "PATCH" | "DELETE";
34
+ /**
35
+ * How a Command travels besides its body.
36
+ *
37
+ * Context: sincpro_framework runs a Command declared `@idempotency` once per key (REST: the
38
+ * `Idempotency-Key` header; 400 `IDEMPOTENCY_KEY_MISSING` without it) and answers a repeat with
39
+ * the first answer. That is what makes «retry» safe after a request that got no answer: the
40
+ * server may have created the invoice, and the same key cannot create it twice.
41
+ */
42
+ export type SendOptions = CallOptions & {
43
+ /** One per gesture, kept while it is retried — `retryKeys()`. */
44
+ idempotencyKey?: string;
45
+ };
46
+ /**
47
+ * What one call carries besides its question: headers of that call alone.
48
+ *
49
+ * Context: the trace and the context of the use case that asked travel with each request
50
+ * (`traceparent`, `sincpro-context`) — and two use cases run at once, so they cannot be the
51
+ * transport's own headers. A transport bound to a use case's execution (`bindTransport`) writes
52
+ * them here; a transport that has nowhere to send headers ignores them.
53
+ */
54
+ export type CallOptions = {
55
+ headers?: Readonly<Record<string, string>>;
56
+ };
57
+ /** One record as sincpro_framework answers it (`ResponseRecord`): the record, its model. */
58
+ export type One<T> = {
59
+ record: T;
60
+ meta: Meta | null;
61
+ dropped: Dropped[];
62
+ /** What this caller kept of an edit between requests (sincpro_framework `Drafts`). */
63
+ draft: Draft | null;
64
+ };
65
+ /** A kept edit: the values typed, the version of the draft, the record version it started on. */
66
+ /**
67
+ * The caller's draft of a record (sincpro_framework's `DraftState`): the version the next edit
68
+ * names, the stored version it started from, and whether the record moved on since (`stale`).
69
+ * `values` only when a server answers them apart from the record — sincpro_framework answers the
70
+ * record FROM the draft instead.
71
+ */
72
+ export type Draft = {
73
+ values?: Record<string, unknown>;
74
+ version: number;
75
+ origin_version?: number;
76
+ kept_at?: string;
77
+ stale?: boolean;
78
+ };
79
+ /**
80
+ * A refusal, from any wire. `problem` is what the server said about it (RFC 9457 plus the
81
+ * engine's `kind`, `next_step`, `guidance`) or `null` when it said nothing readable — a proxy's
82
+ * HTML page. `status` is `null` when the request never got an answer.
83
+ */
84
+ export declare class TransportError extends Error {
85
+ readonly route: string;
86
+ readonly status: number | null;
87
+ readonly problem: Problem | null;
88
+ constructor(route: string, status: number | null, problem: Problem | null, message?: string);
89
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * A read through `transport.read`, or the reason it cannot be: a transport that has no `read`
3
+ * is said so by name, never a `TypeError` from somewhere inside a screen.
4
+ */
5
+ export function readRoute(transport, route, signal, options) {
6
+ if (transport.read === undefined)
7
+ return Promise.reject(new Error(`this transport has no read(): it cannot ask GET ${route} — give it one`));
8
+ return transport.read(route, signal, options);
9
+ }
10
+ /**
11
+ * A refusal, from any wire. `problem` is what the server said about it (RFC 9457 plus the
12
+ * engine's `kind`, `next_step`, `guidance`) or `null` when it said nothing readable — a proxy's
13
+ * HTML page. `status` is `null` when the request never got an answer.
14
+ */
15
+ export class TransportError extends Error {
16
+ route;
17
+ status;
18
+ problem;
19
+ constructor(route, status, problem, message) {
20
+ super(message ?? problem?.detail ?? problem?.title ?? `${status ?? "no answer"} at ${route}`);
21
+ this.route = route;
22
+ this.status = status;
23
+ this.problem = problem;
24
+ this.name = "TransportError";
25
+ }
26
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { clientFileOf } from "./client.js";
5
+ import { domainFileOf } from "./domain.js";
6
+ import { entitiesFileOf } from "./entities.js";
7
+ import { aliasOf } from "./names.js";
8
+ /**
9
+ * `sincpro-client generate <contract.json> --out <dir> [--domain <name>] [--base <path>]` — what an
10
+ * application's `make client` runs, once per bounded context, over the client contract
11
+ * sincpro_framework exported (`export-client-contract`):
12
+ *
13
+ * - `domain.ts`: the DTOs, only types;
14
+ * - `client.ts`: every operation as data, and `clientOf(transport)`;
15
+ * - `entities.ts`: an `entity<T, C>()` per model a screen can list;
16
+ * - `contract.json`: the snapshot they were written from, for `metasOf`.
17
+ *
18
+ * A bare metamodel (`export_metamodel`, no bindings) writes `domain.ts` and `contract.json` only.
19
+ *
20
+ * Context: it writes only into `--out`, and the same bytes for the same contract, so `make
21
+ * verify-client` regenerates and fails on any difference. The folder is the generator's: keep
22
+ * it out of the application's prettier and eslint, or a formatter rewrites what was generated.
23
+ */
24
+ const USAGE = "usage: sincpro-client generate <contract.json> --out <dir> [--domain <name>] [--base <path>]";
25
+ /** JSON with every key sorted: the snapshot reads the same however the server ordered it. */
26
+ function canonical(value) {
27
+ if (Array.isArray(value))
28
+ return value.map(canonical);
29
+ if (value === null || typeof value !== "object")
30
+ return value;
31
+ return Object.fromEntries(Object.keys(value)
32
+ .sort()
33
+ .map((key) => [key, canonical(value[key])]));
34
+ }
35
+ /** The command, its source and its flags — or `undefined` when they do not read as usage says. */
36
+ function parsed(args) {
37
+ const [command, source, ...rest] = args;
38
+ if (command !== "generate" || source === undefined)
39
+ return undefined;
40
+ const flags = new Map();
41
+ for (let at = 0; at < rest.length; at += 2) {
42
+ const flag = rest[at];
43
+ const value = rest[at + 1];
44
+ if (flag === undefined || value === undefined || !flag.startsWith("--"))
45
+ return undefined;
46
+ flags.set(flag, value);
47
+ }
48
+ const out = flags.get("--out");
49
+ const known = new Set(["--out", "--domain", "--base"]);
50
+ if (out === undefined || [...flags.keys()].some((one) => !known.has(one)))
51
+ return undefined;
52
+ const domain = flags.get("--domain");
53
+ const base = flags.get("--base");
54
+ return {
55
+ source,
56
+ out,
57
+ options: {
58
+ ...(domain === undefined ? {} : { domain }),
59
+ ...(base === undefined ? {} : { base }),
60
+ },
61
+ };
62
+ }
63
+ function main(args) {
64
+ const asked = parsed(args);
65
+ if (asked === undefined) {
66
+ console.error(USAGE);
67
+ return 2;
68
+ }
69
+ const metamodel = JSON.parse(readFileSync(asked.source, "utf8"));
70
+ const files = { "domain.ts": domainFileOf(metamodel) };
71
+ if (metamodel.bindings !== undefined) {
72
+ files["client.ts"] = clientFileOf(metamodel, asked.options);
73
+ files["entities.ts"] = entitiesFileOf(metamodel, asked.options);
74
+ }
75
+ files["contract.json"] = `${JSON.stringify(canonical(metamodel), null, 2)}\n`;
76
+ mkdirSync(asked.out, { recursive: true });
77
+ for (const [name, source] of Object.entries(files))
78
+ writeFileSync(join(asked.out, name), source);
79
+ console.log(`✓ ${aliasOf(metamodel)} → ${asked.out} (${Object.keys(files).join(", ")})`);
80
+ return 0;
81
+ }
82
+ process.exitCode = main(process.argv.slice(2));
@@ -0,0 +1,14 @@
1
+ import type { Metamodel } from "./metamodel.js";
2
+ import { type GenerateOptions } from "./rest.js";
3
+ /**
4
+ * The TypeScript of the client of `metamodel`, as the text of one file next to its `domain.ts`.
5
+ *
6
+ * 1. Every operation, by its local name, as data: identity, role, `query`, entities, targets
7
+ * and the binding of each wire that publishes it.
8
+ * 2. Per operation REST reaches, how a transport calls it (`callOf`): a page, a record, a write
9
+ * — or none, said in the client's own doc.
10
+ * 3. The routes of the writes, read once (`writeRoute`).
11
+ * Final: `clientOf(transport)`, a method per operation reached, named by `methodOf`; two with
12
+ * one name are refused, naming both.
13
+ */
14
+ export declare function clientFileOf(metamodel: Metamodel, options?: GenerateOptions): string;
@@ -0,0 +1,180 @@
1
+ import { aliasOf, claimed, deprecationOf, docOf, DOMAIN_MODULE, FRAMEWORK, headerOf, identifierOf, localOf, methodOf, recordLiteral, sorted, } from "./names.js";
2
+ import { ownerOf, restOf, WRITES, } from "./rest.js";
3
+ /**
4
+ * The client of a bounded context as TypeScript: every operation as data, and a method for each
5
+ * one REST reaches the way a `Transport` sends — over `@sincpro/client-framework`, so a script,
6
+ * a test or a hook of its own calls the service typed, with no screen.
7
+ *
8
+ * Context: a method only writes down the route the contract published and hands the call to the
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`), or — a Query of the context's own, `GET /me` —
11
+ * a read of its answer as it came (`readRoute`, over a transport that has `read`). A GET's
12
+ * keys in its path are required parameters; every other field is an optional one.
13
+ */
14
+ /** A search's fields a page asks with, not the screen: the criteria, and its cursor inside it. */
15
+ const PAGED = new Set(["criteria", "cursor"]);
16
+ /** 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) {
18
+ const path = [...rest.fields].sort();
19
+ const query = Object.keys(operation.input.properties ?? {})
20
+ .filter((one) => !path.includes(one) && !(paged && PAGED.has(one)))
21
+ .sort();
22
+ return { path, query };
23
+ }
24
+ function callOf(metamodel, operation, rest) {
25
+ if (rest === undefined)
26
+ return { kind: "none", why: "REST does not publish it" };
27
+ if (WRITES.has(rest.method))
28
+ return { kind: "send" };
29
+ const owner = ownerOf(metamodel, operation);
30
+ if (operation.role === "search" && owner !== undefined && owner in metamodel.models)
31
+ return { kind: "list", entity: owner, ...askedOf(operation, rest, true) };
32
+ const field = owner === undefined ? undefined : operation.targets[owner];
33
+ const record = rest.fields.length === 1 && rest.fields[0] === field;
34
+ if (operation.role === "get" && owner !== undefined && field !== undefined && record)
35
+ return { kind: "one", entity: owner, field };
36
+ // Any other GET — a Query of the context's own, `GET /me` — is read as it answers.
37
+ return { kind: "read", ...askedOf(operation, rest, false) };
38
+ }
39
+ /** A parameter list's TypeScript: the path's keys required, the rest optional, all as text. */
40
+ function paramsOf(asked) {
41
+ const union = (names) => names.map((one) => JSON.stringify(one)).join(" | ");
42
+ if (asked.path.length === 0 && asked.query.length === 0)
43
+ return { signature: "", route: (path) => JSON.stringify(path) };
44
+ const required = asked.path.length === 0 ? "" : `Readonly<Record<${union(asked.path)}, string>>`;
45
+ const optional = asked.query.length === 0 ? "" : `Partial<Record<${union(asked.query)}, string>>`;
46
+ const type = [required, optional].filter((one) => one !== "").join(" & ");
47
+ return {
48
+ signature: `params${asked.path.length === 0 ? "?" : ""}: ${type}`,
49
+ route: (path) => `listRoute(${JSON.stringify(path)}, params)`,
50
+ };
51
+ }
52
+ /** The data of one operation: what it is, and how each wire reaches it. */
53
+ function dataOf(metamodel, identity, operation, rest) {
54
+ const lines = [
55
+ ` identity: ${JSON.stringify(identity)},`,
56
+ ` role: ${JSON.stringify(operation.role)},`,
57
+ ` query: ${operation.query},`,
58
+ ` entities: ${JSON.stringify(operation.entities)},`,
59
+ ` targets: ${recordLiteral(operation.targets)},`,
60
+ ];
61
+ if (rest !== undefined)
62
+ lines.push(` rest: { method: ${JSON.stringify(rest.method)}, path: ${JSON.stringify(rest.path)} },`);
63
+ const rpc = metamodel.bindings?.rpc?.[identity];
64
+ if (rpc !== undefined)
65
+ lines.push(` rpc: { method: ${JSON.stringify(rpc.method)} },`);
66
+ const grpc = metamodel.bindings?.grpc?.[identity];
67
+ if (grpc !== undefined)
68
+ lines.push(` grpc: { method: ${JSON.stringify(grpc.method)} },`);
69
+ const mcp = metamodel.bindings?.mcp?.[identity];
70
+ if (mcp !== undefined)
71
+ lines.push(` mcp: { tool: ${JSON.stringify(mcp.tool)} },`);
72
+ return ` ${localOf(metamodel, identity)}: {\n${lines.join("\n")}\n },`;
73
+ }
74
+ function methodSourceOf(local, method, call, rest, doc) {
75
+ if (call.kind === "list") {
76
+ const entity = `dto.${identifierOf(call.entity)}`;
77
+ const params = paramsOf(call);
78
+ const signature = params.signature === "" ? "" : `, ${params.signature}`;
79
+ return `${doc} ${method}: (criteria: Criteria<${entity}>${signature}) =>\n transport.list<${entity}>(${params.route(rest.path)}, criteria),`;
80
+ }
81
+ if (call.kind === "read") {
82
+ const params = paramsOf(call);
83
+ return `${doc} ${method}: (${params.signature}) =>\n readRoute<dto.${local}Answer>(transport, ${params.route(rest.path)}),`;
84
+ }
85
+ if (call.kind === "one") {
86
+ const path = rest.path.replace(`{${call.field}}`, "${encodeURIComponent(id)}");
87
+ return `${doc} ${method}: (id: string) =>\n transport.one<dto.${identifierOf(call.entity)}>(\`${path}\`),`;
88
+ }
89
+ const id = rest.fields.includes("id")
90
+ ? "body.id === undefined ? undefined : String(body.id)"
91
+ : "undefined";
92
+ return [
93
+ `${doc} ${method}: (body: dto.${local}, options?: SendOptions) =>`,
94
+ ` transport.send<dto.${local}Answer>(`,
95
+ ` ${JSON.stringify(rest.method)},`,
96
+ ` pathFor(routes.${method}, ${id}, body),`,
97
+ ` body,`,
98
+ ` undefined,`,
99
+ ` options,`,
100
+ ` ),`,
101
+ ].join("\n");
102
+ }
103
+ /**
104
+ * The TypeScript of the client of `metamodel`, as the text of one file next to its `domain.ts`.
105
+ *
106
+ * 1. Every operation, by its local name, as data: identity, role, `query`, entities, targets
107
+ * and the binding of each wire that publishes it.
108
+ * 2. Per operation REST reaches, how a transport calls it (`callOf`): a page, a record, a write
109
+ * — or none, said in the client's own doc.
110
+ * 3. The routes of the writes, read once (`writeRoute`).
111
+ * Final: `clientOf(transport)`, a method per operation reached, named by `methodOf`; two with
112
+ * one name are refused, naming both.
113
+ */
114
+ export function clientFileOf(metamodel, options = {}) {
115
+ const claim = claimed();
116
+ const operations = sorted(metamodel.operations);
117
+ const data = [];
118
+ const routes = [];
119
+ const methods = [];
120
+ const unreached = [];
121
+ let lists = false;
122
+ let paged = false;
123
+ let reads = false;
124
+ let sends = false;
125
+ for (const [identity, operation] of operations) {
126
+ const local = localOf(metamodel, identity);
127
+ const rest = restOf(metamodel, identity, options);
128
+ data.push(dataOf(metamodel, identity, operation, rest));
129
+ const call = callOf(metamodel, operation, rest);
130
+ if (call.kind === "none" || rest === undefined) {
131
+ unreached.push(`\`${local}\`: ${call.kind === "none" ? call.why : ""}.`);
132
+ continue;
133
+ }
134
+ const method = methodOf(local);
135
+ claim(method, `operation ${identity}`);
136
+ const doc = docOf([operation.description, deprecationOf(metamodel, operation)], " ");
137
+ methods.push(methodSourceOf(local, method, call, rest, doc));
138
+ if (call.kind === "list" || call.kind === "read") {
139
+ paged ||= call.kind === "list";
140
+ reads ||= call.kind === "read";
141
+ lists ||= call.path.length + call.query.length > 0;
142
+ }
143
+ if (call.kind === "send") {
144
+ sends = true;
145
+ routes.push(` ${method}: writeRoute(${JSON.stringify(`${rest.method} ${rest.path}`)}),`);
146
+ }
147
+ }
148
+ const core = [
149
+ "bindable",
150
+ ...(lists ? ["listRoute"] : []),
151
+ ...(sends ? ["pathFor"] : []),
152
+ ...(reads ? ["readRoute"] : []),
153
+ ...(sends ? ["type SendOptions"] : []),
154
+ "type Transport",
155
+ ...(sends ? ["writeRoute"] : []),
156
+ ];
157
+ const imports = [
158
+ ...(paged ? ['import type { Criteria } from "@sincpro/criteria";'] : []),
159
+ `import { ${core.join(", ")} } from "${FRAMEWORK}";`,
160
+ "",
161
+ `import type * as dto from "${DOMAIN_MODULE}";`,
162
+ ].join("\n");
163
+ const client = [
164
+ "/**",
165
+ ` * «${aliasOf(metamodel)}» over a transport: a method for each operation REST reaches as a`,
166
+ " * page, a record, a read of its own or a write. Used by a Feature, each request carries the",
167
+ " * trace and the context of the use case that sent it (`bindable`).",
168
+ ...(unreached.length === 0
169
+ ? []
170
+ : [" *", " * Without a method here:", ...unreached.map((one) => ` * - ${one}`)]),
171
+ " */",
172
+ ].join("\n");
173
+ return `${[
174
+ headerOf("The client", metamodel),
175
+ imports,
176
+ `/** Every operation of «${aliasOf(metamodel)}», as data: what it is, and how each wire reaches it. */\nexport const operations = {\n${data.join("\n")}\n} as const;`,
177
+ ...(routes.length === 0 ? [] : [`const routes = {\n${routes.join("\n")}\n};`]),
178
+ `${client}\nexport function clientOf(transport: Transport) {\n return bindable(transport, (transport) => ({\n${methods.join("\n")}\n }));\n}`,
179
+ ].join("\n\n")}\n`;
180
+ }
@@ -0,0 +1,14 @@
1
+ import type { Metamodel } from "./metamodel.js";
2
+ /**
3
+ * The TypeScript of every DTO of `metamodel`, as the text of one file.
4
+ *
5
+ * 1. The models, by name: an entity's contract, a related model by its own name.
6
+ * 2. Each operation, by its local name (its identity without `<context>.`): its input as
7
+ * `<Name>` and its answer as `<Name>Answer`.
8
+ * 3. The shared definitions those reach — and the ones those reach, until none is new —, but
9
+ * never one a package of the stack already types (`Criteria`).
10
+ * Final: a header that says where it came from and not to edit it, the imports, then models,
11
+ * definitions and operations, each sorted by name; two of them with one TypeScript name are
12
+ * refused, naming both.
13
+ */
14
+ export declare function domainFileOf(metamodel: Metamodel): string;