@sincpro/client-framework 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +85 -0
- package/LICENSE +21 -0
- package/MANIFESTO.md +127 -0
- package/README.md +100 -2
- package/dist/adapters/opentelemetry.d.ts +21 -0
- package/dist/adapters/opentelemetry.js +68 -0
- package/dist/adapters/sentry.d.ts +28 -0
- package/dist/adapters/sentry.js +36 -0
- package/dist/client/contract.d.ts +34 -0
- package/dist/client/contract.js +41 -0
- package/dist/client/discovery.d.ts +47 -0
- package/dist/client/discovery.js +68 -0
- package/dist/client/entity.d.ts +242 -0
- package/dist/client/entity.js +187 -0
- package/dist/client/ids.d.ts +17 -0
- package/dist/client/ids.js +52 -0
- package/dist/client/memory.d.ts +46 -0
- package/dist/client/memory.js +108 -0
- package/dist/client/rest.d.ts +49 -0
- package/dist/client/rest.js +143 -0
- package/dist/client/retry.d.ts +24 -0
- package/dist/client/retry.js +25 -0
- package/dist/client/transport.d.ts +89 -0
- package/dist/client/transport.js +26 -0
- package/dist/codegen/cli.d.ts +2 -0
- package/dist/codegen/cli.js +82 -0
- package/dist/codegen/client.d.ts +14 -0
- package/dist/codegen/client.js +180 -0
- package/dist/codegen/domain.d.ts +14 -0
- package/dist/codegen/domain.js +195 -0
- package/dist/codegen/entities.d.ts +14 -0
- package/dist/codegen/entities.js +158 -0
- package/dist/codegen/index.d.ts +10 -0
- package/dist/codegen/index.js +8 -0
- package/dist/codegen/metamodel.d.ts +89 -0
- package/dist/codegen/metamodel.js +1 -0
- package/dist/codegen/names.d.ts +39 -0
- package/dist/codegen/names.js +113 -0
- package/dist/codegen/rest.d.ts +31 -0
- package/dist/codegen/rest.js +27 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +31 -0
- package/dist/model/meta.d.ts +121 -0
- package/dist/model/meta.js +130 -0
- package/dist/model/metas.d.ts +17 -0
- package/dist/model/metas.js +7 -0
- package/dist/model/schema.d.ts +39 -0
- package/dist/model/schema.js +128 -0
- package/dist/observability/ports.d.ts +64 -0
- package/dist/observability/ports.js +21 -0
- package/dist/services/execution.d.ts +63 -0
- package/dist/services/execution.js +88 -0
- package/dist/services/feature.d.ts +60 -0
- package/dist/services/feature.js +220 -0
- package/dist/services/runtime.d.ts +48 -0
- package/dist/services/runtime.js +30 -0
- package/dist/testing/index.d.ts +35 -0
- package/dist/testing/index.js +37 -0
- package/package.json +92 -4
|
@@ -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;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { claimed, contextOf, deprecationOf, docOf, headerOf, identifierOf, keyOf, localOf, sorted, } from "./names.js";
|
|
2
|
+
/**
|
|
3
|
+
* The DTOs of a bounded context as TypeScript, out of the contract sincpro_framework exports:
|
|
4
|
+
* a type per model, per shared definition a model or an operation reaches, and per operation's
|
|
5
|
+
* input and answer. Only types — nothing in the file runs.
|
|
6
|
+
*
|
|
7
|
+
* Context: the file is a snapshot for whoever writes code — a person, or an agent reading the
|
|
8
|
+
* repository —, never the truth at runtime: the transport keeps reading the live definition and
|
|
9
|
+
* revalidates it by `model_version`. So it is written the same way for the same contract (every
|
|
10
|
+
* key sorted, byte for byte, which is what `make verify-client` compares) and never edited; an
|
|
11
|
+
* application extends a type by composition (`Wire & {…}`).
|
|
12
|
+
*
|
|
13
|
+
* criteria's grammar comes with every metamodel (`$defs.Criteria`); `@sincpro/criteria` already
|
|
14
|
+
* types it, so it is imported, never written a second time.
|
|
15
|
+
*
|
|
16
|
+
* domainFileOf(JSON.parse(readFileSync("project.contract.json", "utf8")))
|
|
17
|
+
*/
|
|
18
|
+
const MODELS = "#/models/";
|
|
19
|
+
const DEFINITIONS = "#/$defs/";
|
|
20
|
+
/** A shared definition a package of the stack already types: imported, never written again. */
|
|
21
|
+
const IMPORTED = {
|
|
22
|
+
Criteria: { name: "Criteria", from: "@sincpro/criteria" },
|
|
23
|
+
};
|
|
24
|
+
/** The field types whose value is the record itself, not a key that names it. */
|
|
25
|
+
const RELATIONAL = new Set(["many2one", "one2many", "many2many", "embedded"]);
|
|
26
|
+
/** A related record: in an answer only when the read expands it. */
|
|
27
|
+
const EXPANDED = new Set(["many2one", "one2many", "many2many"]);
|
|
28
|
+
const SIMPLE_TYPE = /^[A-Za-z0-9_$.]+$/;
|
|
29
|
+
function union(members) {
|
|
30
|
+
const distinct = [...new Set(members)];
|
|
31
|
+
if (distinct.includes("unknown"))
|
|
32
|
+
return "unknown";
|
|
33
|
+
return distinct.join(" | ");
|
|
34
|
+
}
|
|
35
|
+
function arrayOf(item) {
|
|
36
|
+
return SIMPLE_TYPE.test(item) ? `${item}[]` : `Array<${item}>`;
|
|
37
|
+
}
|
|
38
|
+
/** Whose record a key names — `EntityID[Customer]` on the server —, said where it is written. */
|
|
39
|
+
function keyNote(schema) {
|
|
40
|
+
const said = schema["x-sincpro"];
|
|
41
|
+
if (said?.relation == null || RELATIONAL.has(said.type ?? ""))
|
|
42
|
+
return undefined;
|
|
43
|
+
return `The key of a \`${said.relation}\`.`;
|
|
44
|
+
}
|
|
45
|
+
function scopeOf(metamodel) {
|
|
46
|
+
const definitions = metamodel.$defs ?? {};
|
|
47
|
+
const scope = {
|
|
48
|
+
reached: new Set(),
|
|
49
|
+
imports: new Set(),
|
|
50
|
+
refer: (ref) => {
|
|
51
|
+
const model = ref.startsWith(MODELS);
|
|
52
|
+
if (!model && !ref.startsWith(DEFINITIONS))
|
|
53
|
+
throw new Error(`"${ref}" points outside the metamodel of ${contextOf(metamodel)}`);
|
|
54
|
+
const name = ref.slice(model ? MODELS.length : DEFINITIONS.length);
|
|
55
|
+
// A model's own contract names a related model as a definition (`#/$defs/Run`).
|
|
56
|
+
if (model || name in metamodel.models) {
|
|
57
|
+
if (!(name in metamodel.models))
|
|
58
|
+
throw new Error(`"${ref}" names no model of ${contextOf(metamodel)}`);
|
|
59
|
+
return identifierOf(name);
|
|
60
|
+
}
|
|
61
|
+
const imported = IMPORTED[name];
|
|
62
|
+
if (imported !== undefined) {
|
|
63
|
+
scope.imports.add(name);
|
|
64
|
+
return imported.name;
|
|
65
|
+
}
|
|
66
|
+
if (!(name in definitions))
|
|
67
|
+
throw new Error(`"${ref}" names no definition of ${contextOf(metamodel)}`);
|
|
68
|
+
scope.reached.add(name);
|
|
69
|
+
return identifierOf(name);
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
return scope;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Whether a property may be absent. In a record a model describes, every field comes but a
|
|
76
|
+
* related record, which comes only when the read expands it: the server writes the whole record,
|
|
77
|
+
* and its contract lists as required only what has no default (most fields have one). Anywhere
|
|
78
|
+
* else — an operation's input or answer, a shared definition — `required` says it.
|
|
79
|
+
*/
|
|
80
|
+
function optional(name, property, schema, record) {
|
|
81
|
+
if (record)
|
|
82
|
+
return EXPANDED.has(property["x-sincpro"]?.type ?? "");
|
|
83
|
+
return !(schema.required ?? []).includes(name);
|
|
84
|
+
}
|
|
85
|
+
function objectOf(schema, scope, depth, record) {
|
|
86
|
+
const properties = sorted(schema.properties ?? {});
|
|
87
|
+
if (properties.length === 0) {
|
|
88
|
+
const extra = schema.additionalProperties;
|
|
89
|
+
if (extra === false)
|
|
90
|
+
return "Record<string, never>";
|
|
91
|
+
const value = extra === undefined || extra === true ? "unknown" : typeOf(extra, scope, depth, record);
|
|
92
|
+
return `Record<string, ${value}>`;
|
|
93
|
+
}
|
|
94
|
+
const pad = " ".repeat(depth + 1);
|
|
95
|
+
const lines = properties.map(([name, property]) => {
|
|
96
|
+
const doc = docOf([property.description, keyNote(property)], pad);
|
|
97
|
+
const mark = optional(name, property, schema, record) ? "?" : "";
|
|
98
|
+
return `${doc}${pad}${keyOf(name)}${mark}: ${typeOf(property, scope, depth + 1, record)};`;
|
|
99
|
+
});
|
|
100
|
+
return `{\n${lines.join("\n")}\n${" ".repeat(depth)}}`;
|
|
101
|
+
}
|
|
102
|
+
/** The TypeScript of `schema`; `record` when it is (inside) a record a model describes. */
|
|
103
|
+
function typeOf(schema, scope, depth, record) {
|
|
104
|
+
if (schema.$ref !== undefined)
|
|
105
|
+
return scope.refer(schema.$ref);
|
|
106
|
+
const options = schema.anyOf ?? schema.oneOf;
|
|
107
|
+
if (options !== undefined)
|
|
108
|
+
return union(options.map((one) => typeOf(one, scope, depth, record)));
|
|
109
|
+
if ("const" in schema)
|
|
110
|
+
return JSON.stringify(schema.const);
|
|
111
|
+
if (schema.enum !== undefined)
|
|
112
|
+
return union(schema.enum.map((one) => JSON.stringify(one)));
|
|
113
|
+
const types = typeof schema.type === "string" ? [schema.type] : (schema.type ?? []);
|
|
114
|
+
if (types.length > 1)
|
|
115
|
+
return union(types.map((one) => typeOf({ ...schema, type: one }, scope, depth, record)));
|
|
116
|
+
switch (types[0]) {
|
|
117
|
+
case "string":
|
|
118
|
+
return "string";
|
|
119
|
+
case "integer":
|
|
120
|
+
case "number":
|
|
121
|
+
return "number";
|
|
122
|
+
case "boolean":
|
|
123
|
+
return "boolean";
|
|
124
|
+
case "null":
|
|
125
|
+
return "null";
|
|
126
|
+
case "array":
|
|
127
|
+
return arrayOf(schema.items === undefined ? "unknown" : typeOf(schema.items, scope, depth, record));
|
|
128
|
+
case "object":
|
|
129
|
+
return objectOf(schema, scope, depth, record);
|
|
130
|
+
default:
|
|
131
|
+
return schema.properties === undefined
|
|
132
|
+
? "unknown"
|
|
133
|
+
: objectOf(schema, scope, depth, record);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/** What the server says of an operation: its words, then what it is, for whom, on which field. */
|
|
137
|
+
function operationDoc(metamodel, local, operation) {
|
|
138
|
+
const runsOn = sorted(operation.targets).map(([entity, field]) => `\`${entity}\` in \`${field}\``);
|
|
139
|
+
const whose = operation.entities.map((one) => `\`${one}\``).join(", ");
|
|
140
|
+
const facts = [
|
|
141
|
+
`\`${operation.role}\`${whose === "" ? "" : ` of ${whose}`}`,
|
|
142
|
+
operation.query ? "reads only" : undefined,
|
|
143
|
+
operation.dry_run ? "may run dry" : undefined,
|
|
144
|
+
runsOn.length === 0 ? undefined : `its record: ${runsOn.join(", ")}`,
|
|
145
|
+
].filter((one) => one !== undefined);
|
|
146
|
+
return (docOf([operation.description, `${facts.join(" · ")}.`, deprecationOf(metamodel, operation)], "") || `/** ${local} */\n`);
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* The TypeScript of every DTO of `metamodel`, as the text of one file.
|
|
150
|
+
*
|
|
151
|
+
* 1. The models, by name: an entity's contract, a related model by its own name.
|
|
152
|
+
* 2. Each operation, by its local name (its identity without `<context>.`): its input as
|
|
153
|
+
* `<Name>` and its answer as `<Name>Answer`.
|
|
154
|
+
* 3. The shared definitions those reach — and the ones those reach, until none is new —, but
|
|
155
|
+
* never one a package of the stack already types (`Criteria`).
|
|
156
|
+
* Final: a header that says where it came from and not to edit it, the imports, then models,
|
|
157
|
+
* definitions and operations, each sorted by name; two of them with one TypeScript name are
|
|
158
|
+
* refused, naming both.
|
|
159
|
+
*/
|
|
160
|
+
export function domainFileOf(metamodel) {
|
|
161
|
+
const scope = scopeOf(metamodel);
|
|
162
|
+
const definitions = metamodel.$defs ?? {};
|
|
163
|
+
const claim = claimed();
|
|
164
|
+
const models = sorted(metamodel.models).map(([name, schema]) => {
|
|
165
|
+
claim(identifierOf(name), `model ${name}`);
|
|
166
|
+
return `${docOf([schema.description], "")}export type ${identifierOf(name)} = ${typeOf(schema, scope, 0, true)};`;
|
|
167
|
+
});
|
|
168
|
+
const operations = sorted(metamodel.operations).flatMap(([identity, operation]) => {
|
|
169
|
+
const local = localOf(metamodel, identity);
|
|
170
|
+
claim(local, `operation ${identity}`);
|
|
171
|
+
claim(`${local}Answer`, `the answer of ${identity}`);
|
|
172
|
+
const answer = operation.output === null ? "unknown" : typeOf(operation.output, scope, 0, false);
|
|
173
|
+
return [
|
|
174
|
+
`${operationDoc(metamodel, local, operation)}export type ${local} = ${typeOf(operation.input, scope, 0, false)};`,
|
|
175
|
+
`/** What \`${local}\` answers. */\nexport type ${local}Answer = ${answer};`,
|
|
176
|
+
];
|
|
177
|
+
});
|
|
178
|
+
const written = new Map();
|
|
179
|
+
for (let pending = [...scope.reached]; pending.length > 0;) {
|
|
180
|
+
for (const name of pending) {
|
|
181
|
+
const schema = definitions[name];
|
|
182
|
+
written.set(name, `${docOf([schema.description], "")}export type ${identifierOf(name)} = ${typeOf(schema, scope, 0, false)};`);
|
|
183
|
+
}
|
|
184
|
+
pending = [...scope.reached].filter((name) => !written.has(name));
|
|
185
|
+
}
|
|
186
|
+
const shared = [...written.keys()].sort().map((name) => {
|
|
187
|
+
claim(identifierOf(name), `definition ${name}`);
|
|
188
|
+
return written.get(name);
|
|
189
|
+
});
|
|
190
|
+
const imports = [...scope.imports].sort().map((name) => {
|
|
191
|
+
const imported = IMPORTED[name];
|
|
192
|
+
return `import type { ${imported.name} } from "${imported.from}";`;
|
|
193
|
+
});
|
|
194
|
+
return `${[headerOf("The DTOs", metamodel), ...(imports.length === 0 ? [] : [imports.join("\n")]), ...models, ...shared, ...operations].join("\n\n")}\n`;
|
|
195
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Metamodel } from "./metamodel.js";
|
|
2
|
+
import { type GenerateOptions } from "./rest.js";
|
|
3
|
+
/**
|
|
4
|
+
* The TypeScript of the entities of `metamodel`, as the text of one file next to its `domain.ts`.
|
|
5
|
+
*
|
|
6
|
+
* 1. Per model, by name: the one `search` a transport reads for it (`own` when there are
|
|
7
|
+
* several) — none, and the model gets no entity, said in the file's own doc.
|
|
8
|
+
* 2. Its `get`, `edit` and `preview` the same way, the record's field written `{id}`.
|
|
9
|
+
* 3. Every write of role `command` that concerns it: with `target` when its body names the
|
|
10
|
+
* record, as it is when it creates one; its body typed without that field.
|
|
11
|
+
* Final: an `entity<T, C>()` per model listed, keyed `<domain>:<kind>`, and `entities`, all of
|
|
12
|
+
* them, for `FrameworkProvider entities`.
|
|
13
|
+
*/
|
|
14
|
+
export declare function entitiesFileOf(metamodel: Metamodel, options?: GenerateOptions): string;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { entityKeyOf } from "../client/contract.js";
|
|
2
|
+
import { aliasOf, claimed, DOMAIN_MODULE, FRAMEWORK, headerOf, identifierOf, isEntity, localOf, methodOf, pluralOf, sorted, } from "./names.js";
|
|
3
|
+
import { ownerOf, restOf, WRITES, } from "./rest.js";
|
|
4
|
+
/** The one operation of `role` for `entity` a transport can call, by `own` when there are many. */
|
|
5
|
+
function oneOf(candidates, role) {
|
|
6
|
+
if (candidates.length === 0)
|
|
7
|
+
return undefined;
|
|
8
|
+
if (candidates.length === 1)
|
|
9
|
+
return { found: candidates[0] };
|
|
10
|
+
const own = candidates.filter((one) => one.operation.own === true);
|
|
11
|
+
if (own.length === 1)
|
|
12
|
+
return { found: own[0] };
|
|
13
|
+
const names = candidates.map((one) => `\`${one.local}\``).join(", ");
|
|
14
|
+
return { missing: `${candidates.length} of role \`${role}\` (${names}) and none \`own\`` };
|
|
15
|
+
}
|
|
16
|
+
/** A route for `entity()`: the record's field in the path written `{id}`, as `entity()` fills it. */
|
|
17
|
+
function recordPath(rest, field) {
|
|
18
|
+
return field === undefined || field === "id"
|
|
19
|
+
? rest.path
|
|
20
|
+
: rest.path.replace(`{${field}}`, "{id}");
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The field of a Command's body that names the one record of `model` it runs on. A list of keys
|
|
24
|
+
* (`list[EntityID[Issue]]`, `issue_ids`) names many: `run(id, body)` has one id to give, so the
|
|
25
|
+
* screen sends that list in the body itself.
|
|
26
|
+
*/
|
|
27
|
+
function singleTarget(operation, model) {
|
|
28
|
+
const field = operation.targets[model];
|
|
29
|
+
if (field === undefined)
|
|
30
|
+
return undefined;
|
|
31
|
+
const schema = operation.input.properties?.[field];
|
|
32
|
+
const held = schema?.anyOf?.find((one) => one.type !== "null") ?? schema;
|
|
33
|
+
return held?.type === "array" ? undefined : field;
|
|
34
|
+
}
|
|
35
|
+
function entitySourceOf(metamodel, model, domain, options, claim) {
|
|
36
|
+
const reached = sorted(metamodel.operations).flatMap(([identity, operation]) => {
|
|
37
|
+
const rest = restOf(metamodel, identity, options);
|
|
38
|
+
return rest === undefined
|
|
39
|
+
? []
|
|
40
|
+
: [{ local: localOf(metamodel, identity), operation, rest }];
|
|
41
|
+
});
|
|
42
|
+
const reads = (role, method) => reached.filter((one) => one.operation.role === role &&
|
|
43
|
+
method(one.rest.method) &&
|
|
44
|
+
ownerOf(metamodel, one.operation) === model);
|
|
45
|
+
const isGet = (verb) => verb === "GET";
|
|
46
|
+
const isWrite = (verb) => WRITES.has(verb);
|
|
47
|
+
const list = oneOf(reads("search", isGet), "search");
|
|
48
|
+
if (list === undefined)
|
|
49
|
+
return { missing: "no `search` a transport reads" };
|
|
50
|
+
if ("missing" in list)
|
|
51
|
+
return { missing: list.missing };
|
|
52
|
+
// A list under a record (`/tenants/{tenant_id}/members`) keeps its keys: the screen gives
|
|
53
|
+
// them as the list's parameters (`useListing(entity, { params })`), and `listRoute` fills them.
|
|
54
|
+
const missing = [];
|
|
55
|
+
const routes = [` list: ${JSON.stringify(list.found.rest.path)},`];
|
|
56
|
+
const one = oneOf(reads("get", isGet), "get");
|
|
57
|
+
if (one !== undefined && "found" in one) {
|
|
58
|
+
const field = one.found.operation.targets[model];
|
|
59
|
+
if (field !== undefined && one.found.rest.fields.length === 1)
|
|
60
|
+
routes.push(` one: ${JSON.stringify(recordPath(one.found.rest, field))},`);
|
|
61
|
+
else
|
|
62
|
+
missing.push(`one: \`${one.found.local}\` is at ${one.found.rest.path}`);
|
|
63
|
+
}
|
|
64
|
+
else if (one !== undefined)
|
|
65
|
+
missing.push(`one: ${one.missing}`);
|
|
66
|
+
for (const role of ["edit", "preview"]) {
|
|
67
|
+
const write = oneOf(reads(role, isWrite), role);
|
|
68
|
+
if (write === undefined)
|
|
69
|
+
continue;
|
|
70
|
+
if ("missing" in write) {
|
|
71
|
+
missing.push(`${role}: ${write.missing}`);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const field = write.found.operation.targets[model];
|
|
75
|
+
const route = `${write.found.rest.method} ${recordPath(write.found.rest, field)}`;
|
|
76
|
+
routes.push(` ${role}: ${JSON.stringify(route)},`);
|
|
77
|
+
}
|
|
78
|
+
const commands = reached.filter((one) => one.operation.role === "command" &&
|
|
79
|
+
isWrite(one.rest.method) &&
|
|
80
|
+
one.operation.entities.includes(model));
|
|
81
|
+
const bodies = [];
|
|
82
|
+
const answers = [];
|
|
83
|
+
const named = [];
|
|
84
|
+
for (const command of commands) {
|
|
85
|
+
const method = methodOf(command.local);
|
|
86
|
+
const target = singleTarget(command.operation, model);
|
|
87
|
+
const route = `${command.rest.method} ${command.rest.path}`;
|
|
88
|
+
bodies.push(target === undefined
|
|
89
|
+
? ` ${method}: dto.${command.local};`
|
|
90
|
+
: ` ${method}: Omit<dto.${command.local}, ${JSON.stringify(target)}>;`);
|
|
91
|
+
answers.push(` ${method}: dto.${command.local}Answer;`);
|
|
92
|
+
named.push(target === undefined
|
|
93
|
+
? ` ${method}: ${JSON.stringify(route)},`
|
|
94
|
+
: ` ${method}: { route: ${JSON.stringify(route)}, target: ${JSON.stringify(target)} },`);
|
|
95
|
+
}
|
|
96
|
+
if (named.length > 0)
|
|
97
|
+
routes.push(` commands: {\n${named.join("\n")}\n },`);
|
|
98
|
+
const name = pluralOf(identifierOf(model));
|
|
99
|
+
claim(name, `the entity of ${model}`);
|
|
100
|
+
const types = bodies.length === 0
|
|
101
|
+
? `dto.${identifierOf(model)}`
|
|
102
|
+
: `\n dto.${identifierOf(model)},\n {\n${bodies.join("\n")}\n },\n {\n${answers.join("\n")}\n }\n`;
|
|
103
|
+
const doc = [
|
|
104
|
+
"/**",
|
|
105
|
+
` * \`${model}\`: its list, its record and the Commands that run on it or create it.`,
|
|
106
|
+
...(missing.length === 0
|
|
107
|
+
? []
|
|
108
|
+
: [" *", " * Not declared:", ...missing.map((one) => ` * - ${one}.`)]),
|
|
109
|
+
" */",
|
|
110
|
+
].join("\n");
|
|
111
|
+
const key = JSON.stringify(entityKeyOf(domain, model));
|
|
112
|
+
return {
|
|
113
|
+
name,
|
|
114
|
+
missing,
|
|
115
|
+
source: `${doc}\nexport const ${name} = entity<${types}>(${key}, {\n${routes.join("\n")}\n});`,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The TypeScript of the entities of `metamodel`, as the text of one file next to its `domain.ts`.
|
|
120
|
+
*
|
|
121
|
+
* 1. Per model, by name: the one `search` a transport reads for it (`own` when there are
|
|
122
|
+
* several) — none, and the model gets no entity, said in the file's own doc.
|
|
123
|
+
* 2. Its `get`, `edit` and `preview` the same way, the record's field written `{id}`.
|
|
124
|
+
* 3. Every write of role `command` that concerns it: with `target` when its body names the
|
|
125
|
+
* record, as it is when it creates one; its body typed without that field.
|
|
126
|
+
* Final: an `entity<T, C>()` per model listed, keyed `<domain>:<kind>`, and `entities`, all of
|
|
127
|
+
* them, for `FrameworkProvider entities`.
|
|
128
|
+
*/
|
|
129
|
+
export function entitiesFileOf(metamodel, options = {}) {
|
|
130
|
+
const domain = options.domain ?? aliasOf(metamodel);
|
|
131
|
+
const claim = claimed();
|
|
132
|
+
claim("entities", "the list of every entity");
|
|
133
|
+
const declared = [];
|
|
134
|
+
const left = [];
|
|
135
|
+
// A value a record holds (no identity) is no entity: it is never listed nor opened alone.
|
|
136
|
+
for (const [model] of sorted(metamodel.models).filter(([name]) => isEntity(metamodel, name))) {
|
|
137
|
+
const one = entitySourceOf(metamodel, model, domain, options, claim);
|
|
138
|
+
if ("source" in one)
|
|
139
|
+
declared.push(one);
|
|
140
|
+
else
|
|
141
|
+
left.push(`\`${model}\`: ${one.missing}`);
|
|
142
|
+
}
|
|
143
|
+
const imports = [
|
|
144
|
+
`import { entity } from "${FRAMEWORK}";`,
|
|
145
|
+
"",
|
|
146
|
+
`import type * as dto from "${DOMAIN_MODULE}";`,
|
|
147
|
+
].join("\n");
|
|
148
|
+
const all = [
|
|
149
|
+
"/**",
|
|
150
|
+
` * Every entity of «${aliasOf(metamodel)}», for \`FrameworkProvider entities\`.`,
|
|
151
|
+
...(left.length === 0
|
|
152
|
+
? []
|
|
153
|
+
: [" *", " * Without an entity here:", ...left.map((one) => ` * - ${one}.`)]),
|
|
154
|
+
" */",
|
|
155
|
+
`export const entities = [${declared.map((one) => one.name).join(", ")}] as const;`,
|
|
156
|
+
].join("\n");
|
|
157
|
+
return `${[headerOf("The entities", metamodel), imports, ...declared.map((one) => one.source), all].join("\n\n")}\n`;
|
|
158
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The generator: a bounded context's DTOs, client and entities out of the client contract
|
|
3
|
+
* sincpro_framework exports (PRD_01 phase B). A build step that runs in Node — nothing in
|
|
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
|
+
*/
|
|
6
|
+
export { clientFileOf } from "./client.js";
|
|
7
|
+
export { domainFileOf } from "./domain.js";
|
|
8
|
+
export { entitiesFileOf } from "./entities.js";
|
|
9
|
+
export type { Bindings, Metamodel, Operation, RestBinding, Schema, } from "./metamodel.js";
|
|
10
|
+
export type { GenerateOptions } from "./rest.js";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The generator: a bounded context's DTOs, client and entities out of the client contract
|
|
3
|
+
* sincpro_framework exports (PRD_01 phase B). A build step that runs in Node — nothing in
|
|
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
|
+
*/
|
|
6
|
+
export { clientFileOf } from "./client.js";
|
|
7
|
+
export { domainFileOf } from "./domain.js";
|
|
8
|
+
export { entitiesFileOf } from "./entities.js";
|