@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.
- package/AGENTS.md +78 -0
- package/LICENSE +21 -0
- package/MANIFESTO.md +124 -0
- package/README.md +88 -2
- 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 +206 -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 +76 -4
|
@@ -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";
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client contract of a bounded context, as sincpro_framework exports it
|
|
3
|
+
* (`export_client_contract`, PRD_33): its metamodel — models, the definitions they share, every
|
|
4
|
+
* operation a client may call — and how each wire reaches each operation (`bindings`). A bare
|
|
5
|
+
* metamodel (`export_metamodel`) is the same without `format` and `bindings`.
|
|
6
|
+
*
|
|
7
|
+
* Context: only what the generator reads is typed here. The file is the server's, written with
|
|
8
|
+
* its keys sorted, so the same code writes the same bytes; a key this does not name is kept in
|
|
9
|
+
* the snapshot and ignored by the generator.
|
|
10
|
+
*/
|
|
11
|
+
export type Schema = {
|
|
12
|
+
type?: string | readonly string[];
|
|
13
|
+
format?: string;
|
|
14
|
+
enum?: readonly unknown[];
|
|
15
|
+
const?: unknown;
|
|
16
|
+
items?: Schema;
|
|
17
|
+
$ref?: string;
|
|
18
|
+
anyOf?: readonly Schema[];
|
|
19
|
+
oneOf?: readonly Schema[];
|
|
20
|
+
properties?: Readonly<Record<string, Schema>>;
|
|
21
|
+
required?: readonly string[];
|
|
22
|
+
additionalProperties?: boolean | Schema;
|
|
23
|
+
title?: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
"x-sincpro"?: {
|
|
26
|
+
type?: string;
|
|
27
|
+
relation?: string | null;
|
|
28
|
+
identity?: string;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
export type Operation = {
|
|
32
|
+
/** What it is for its entity: `search`, `get`, `edit`, `command`… */
|
|
33
|
+
role: string;
|
|
34
|
+
/** It writes nothing: retrying or caching it is safe. */
|
|
35
|
+
query: boolean;
|
|
36
|
+
entities: readonly string[];
|
|
37
|
+
/** The field of `input` that names the record of each entity it runs on. */
|
|
38
|
+
targets: Readonly<Record<string, string>>;
|
|
39
|
+
dry_run: boolean;
|
|
40
|
+
description?: string | null;
|
|
41
|
+
input: Schema;
|
|
42
|
+
/** What it answers; `null` when the server declares nothing. */
|
|
43
|
+
output: Schema | null;
|
|
44
|
+
/** The entity's own operation of its role, when it has several (`@entity_tags(own=True)`). */
|
|
45
|
+
own?: boolean;
|
|
46
|
+
deprecated?: boolean;
|
|
47
|
+
/** The day it stops being served, `YYYY-MM-DD`. */
|
|
48
|
+
sunset?: string | null;
|
|
49
|
+
/** The operation that replaces it, by identity. */
|
|
50
|
+
replacement?: string | null;
|
|
51
|
+
};
|
|
52
|
+
/** How REST reaches an operation: its method and its whole path, `{field}` where a field goes. */
|
|
53
|
+
export type RestBinding = {
|
|
54
|
+
method: string;
|
|
55
|
+
path: string;
|
|
56
|
+
/** The fields of the input the path carries. */
|
|
57
|
+
path_fields: readonly string[];
|
|
58
|
+
};
|
|
59
|
+
export type Bindings = {
|
|
60
|
+
rest?: Readonly<Record<string, RestBinding>>;
|
|
61
|
+
rpc?: Readonly<Record<string, {
|
|
62
|
+
method: string;
|
|
63
|
+
}>>;
|
|
64
|
+
grpc?: Readonly<Record<string, {
|
|
65
|
+
method: string;
|
|
66
|
+
}>>;
|
|
67
|
+
mcp?: Readonly<Record<string, {
|
|
68
|
+
tool: string;
|
|
69
|
+
}>>;
|
|
70
|
+
};
|
|
71
|
+
export type Metamodel = {
|
|
72
|
+
/** `sincpro.client-contract/1` for an exported client contract. */
|
|
73
|
+
format?: string;
|
|
74
|
+
/** The bounded context's own name: what every operation's identity starts with. */
|
|
75
|
+
bounded_context?: string;
|
|
76
|
+
/** The name the gateways publish it under (`FastApiGateway({"project": …})`): what a client
|
|
77
|
+
* names the context by. */
|
|
78
|
+
alias?: string;
|
|
79
|
+
/** The bounded context's name, as sincpro_framework 3.32 wrote it before `bounded_context`. */
|
|
80
|
+
context?: string;
|
|
81
|
+
version: string;
|
|
82
|
+
/** Every shape the contract names: an entity (it has an `identity`) or a value it holds. */
|
|
83
|
+
models: Readonly<Record<string, Schema>>;
|
|
84
|
+
/** By the Command's identity, `"<bounded_context>.<Class>"`. */
|
|
85
|
+
operations: Readonly<Record<string, Operation>>;
|
|
86
|
+
$defs?: Readonly<Record<string, Schema>>;
|
|
87
|
+
/** By wire, then by the Command's identity. */
|
|
88
|
+
bindings?: Bindings;
|
|
89
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Metamodel } from "./metamodel.js";
|
|
2
|
+
/**
|
|
3
|
+
* What a generated file imports, built in parts. The build rewrites this package's own
|
|
4
|
+
* specifiers to relative paths (`tsc-alias`), and it reads `from "…"` inside a string too: written
|
|
5
|
+
* whole, the generated files came out importing `"../index.js"` and `"./domain.js"`.
|
|
6
|
+
*/
|
|
7
|
+
export declare const FRAMEWORK: string;
|
|
8
|
+
export declare const DOMAIN_MODULE: string;
|
|
9
|
+
/** A name of the server's as a TypeScript identifier: `shop__orders__Line` stays, `-` is `_`. */
|
|
10
|
+
export declare function identifierOf(name: string): string;
|
|
11
|
+
/** A property key as TypeScript writes it: bare when it can be, quoted otherwise. */
|
|
12
|
+
export declare function keyOf(name: string): string;
|
|
13
|
+
export declare function sorted<V>(record: Readonly<Record<string, V>>): [string, V][];
|
|
14
|
+
/** `{ Issue: "issue_id" }`: a record of texts as TypeScript writes it, keys sorted. */
|
|
15
|
+
export declare function recordLiteral(record: Readonly<Record<string, string>>): string;
|
|
16
|
+
/** The bounded context's own name: `bounded_context`, or `context` in a 3.32 contract. */
|
|
17
|
+
export declare function contextOf(metamodel: Metamodel): string;
|
|
18
|
+
/** What a client names the context by: its `alias`, else its own name. */
|
|
19
|
+
export declare function aliasOf(metamodel: Metamodel): string;
|
|
20
|
+
/** A model the contract names is an entity when it has an identity; else a value it holds. */
|
|
21
|
+
export declare function isEntity(metamodel: Metamodel, model: string): boolean;
|
|
22
|
+
/** An operation's name in its context: its identity without `<bounded_context>.`. */
|
|
23
|
+
export declare function localOf(metamodel: Metamodel, identity: string): string;
|
|
24
|
+
/** The method an operation is called by: `CommandSendIssue` → `sendIssue`. */
|
|
25
|
+
export declare function methodOf(local: string): string;
|
|
26
|
+
/** `Issue` → `issues`, `ProjectEvent` → `projectEvents`, `Policy` → `policies`. */
|
|
27
|
+
export declare function pluralOf(name: string): string;
|
|
28
|
+
/** A JSDoc block, or nothing: the paragraphs given, each one kept. */
|
|
29
|
+
export declare function docOf(lines: readonly (string | null | undefined)[], pad: string): string;
|
|
30
|
+
/** The first lines of every generated file: what it is, where it came from, not to edit it. */
|
|
31
|
+
export declare function headerOf(what: string, metamodel: Metamodel): string;
|
|
32
|
+
/** What a deprecated operation says: since when it goes and what replaces it. */
|
|
33
|
+
export declare function deprecationOf(metamodel: Metamodel, operation: {
|
|
34
|
+
deprecated?: boolean;
|
|
35
|
+
sunset?: string | null;
|
|
36
|
+
replacement?: string | null;
|
|
37
|
+
}): string | undefined;
|
|
38
|
+
/** Refuses a second thing under a name already given, naming both. */
|
|
39
|
+
export declare function claimed(): (name: string, by: string) => void;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the generator names what the server names: a TypeScript identifier for a class, a method
|
|
3
|
+
* for an operation, a value for an entity, the key the workspace refers to an entity by.
|
|
4
|
+
*
|
|
5
|
+
* Context: every name here is derived, the same way every time, from what the contract already
|
|
6
|
+
* says — never chosen — so regenerating never renames by itself. Two things that come out with
|
|
7
|
+
* one name are refused by whoever writes them, naming both (`claimed`): a silent rename would
|
|
8
|
+
* break whoever imported the first.
|
|
9
|
+
*/
|
|
10
|
+
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
11
|
+
/**
|
|
12
|
+
* What a generated file imports, built in parts. The build rewrites this package's own
|
|
13
|
+
* specifiers to relative paths (`tsc-alias`), and it reads `from "…"` inside a string too: written
|
|
14
|
+
* whole, the generated files came out importing `"../index.js"` and `"./domain.js"`.
|
|
15
|
+
*/
|
|
16
|
+
export const FRAMEWORK = ["@sincpro", "client-framework"].join("/");
|
|
17
|
+
export const DOMAIN_MODULE = [".", "domain"].join("/");
|
|
18
|
+
/** A name of the server's as a TypeScript identifier: `shop__orders__Line` stays, `-` is `_`. */
|
|
19
|
+
export function identifierOf(name) {
|
|
20
|
+
const cleaned = name.replace(/[^A-Za-z0-9_$]/g, "_");
|
|
21
|
+
return /^[0-9]/.test(cleaned) ? `_${cleaned}` : cleaned;
|
|
22
|
+
}
|
|
23
|
+
/** A property key as TypeScript writes it: bare when it can be, quoted otherwise. */
|
|
24
|
+
export function keyOf(name) {
|
|
25
|
+
return IDENTIFIER.test(name) ? name : JSON.stringify(name);
|
|
26
|
+
}
|
|
27
|
+
export function sorted(record) {
|
|
28
|
+
return Object.entries(record).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
29
|
+
}
|
|
30
|
+
/** `{ Issue: "issue_id" }`: a record of texts as TypeScript writes it, keys sorted. */
|
|
31
|
+
export function recordLiteral(record) {
|
|
32
|
+
const entries = sorted(record).map(([key, value]) => `${keyOf(key)}: ${JSON.stringify(value)}`);
|
|
33
|
+
return entries.length === 0 ? "{}" : `{ ${entries.join(", ")} }`;
|
|
34
|
+
}
|
|
35
|
+
/** The bounded context's own name: `bounded_context`, or `context` in a 3.32 contract. */
|
|
36
|
+
export function contextOf(metamodel) {
|
|
37
|
+
const name = metamodel.bounded_context ?? metamodel.context;
|
|
38
|
+
if (name === undefined)
|
|
39
|
+
throw new Error("the contract names no bounded context");
|
|
40
|
+
return name;
|
|
41
|
+
}
|
|
42
|
+
/** What a client names the context by: its `alias`, else its own name. */
|
|
43
|
+
export function aliasOf(metamodel) {
|
|
44
|
+
return metamodel.alias ?? contextOf(metamodel);
|
|
45
|
+
}
|
|
46
|
+
/** A model the contract names is an entity when it has an identity; else a value it holds. */
|
|
47
|
+
export function isEntity(metamodel, model) {
|
|
48
|
+
return metamodel.models[model]?.["x-sincpro"]?.identity !== undefined;
|
|
49
|
+
}
|
|
50
|
+
/** An operation's name in its context: its identity without `<bounded_context>.`. */
|
|
51
|
+
export function localOf(metamodel, identity) {
|
|
52
|
+
const prefix = `${contextOf(metamodel)}.`;
|
|
53
|
+
return identifierOf(identity.startsWith(prefix) ? identity.slice(prefix.length) : identity);
|
|
54
|
+
}
|
|
55
|
+
/** The method an operation is called by: `CommandSendIssue` → `sendIssue`. */
|
|
56
|
+
export function methodOf(local) {
|
|
57
|
+
const bare = local.replace(/^(Command|Query)(?=[A-Z])/, "");
|
|
58
|
+
return `${bare.charAt(0).toLowerCase()}${bare.slice(1)}`;
|
|
59
|
+
}
|
|
60
|
+
/** `Issue` → `issues`, `ProjectEvent` → `projectEvents`, `Policy` → `policies`. */
|
|
61
|
+
export function pluralOf(name) {
|
|
62
|
+
const value = `${name.charAt(0).toLowerCase()}${name.slice(1)}`;
|
|
63
|
+
if (/[^aeiou]y$/.test(value))
|
|
64
|
+
return `${value.slice(0, -1)}ies`;
|
|
65
|
+
if (/(s|x|z|ch|sh)$/.test(value))
|
|
66
|
+
return `${value}es`;
|
|
67
|
+
return `${value}s`;
|
|
68
|
+
}
|
|
69
|
+
/** A JSDoc block, or nothing: the paragraphs given, each one kept. */
|
|
70
|
+
export function docOf(lines, pad) {
|
|
71
|
+
const text = lines
|
|
72
|
+
.filter((one) => typeof one === "string" && one.trim() !== "")
|
|
73
|
+
.join("\n\n")
|
|
74
|
+
.replace(/\*\//g, "*\\/")
|
|
75
|
+
.trim();
|
|
76
|
+
if (text === "")
|
|
77
|
+
return "";
|
|
78
|
+
const parts = text.split("\n");
|
|
79
|
+
if (parts.length === 1)
|
|
80
|
+
return `${pad}/** ${parts[0]} */\n`;
|
|
81
|
+
const body = parts.map((one) => `${pad} *${one === "" ? "" : ` ${one}`}`).join("\n");
|
|
82
|
+
return `${pad}/**\n${body}\n${pad} */\n`;
|
|
83
|
+
}
|
|
84
|
+
/** The first lines of every generated file: what it is, where it came from, not to edit it. */
|
|
85
|
+
export function headerOf(what, metamodel) {
|
|
86
|
+
const alias = aliasOf(metamodel);
|
|
87
|
+
const own = contextOf(metamodel);
|
|
88
|
+
return [
|
|
89
|
+
`// ${what} of «${alias}»${alias === own ? "" : ` (${own})`}, generated by @sincpro/client-framework.`,
|
|
90
|
+
`// Version ${metamodel.version}.`,
|
|
91
|
+
`// Do not edit: regenerate it (\`sincpro-client generate\`).`,
|
|
92
|
+
].join("\n");
|
|
93
|
+
}
|
|
94
|
+
/** What a deprecated operation says: since when it goes and what replaces it. */
|
|
95
|
+
export function deprecationOf(metamodel, operation) {
|
|
96
|
+
if (operation.deprecated !== true)
|
|
97
|
+
return undefined;
|
|
98
|
+
const sunset = operation.sunset ? ` It stops being served on ${operation.sunset}.` : "";
|
|
99
|
+
const replacement = operation.replacement
|
|
100
|
+
? ` Use \`${localOf(metamodel, operation.replacement)}\`.`
|
|
101
|
+
: "";
|
|
102
|
+
return `@deprecated${sunset}${replacement}`;
|
|
103
|
+
}
|
|
104
|
+
/** Refuses a second thing under a name already given, naming both. */
|
|
105
|
+
export function claimed() {
|
|
106
|
+
const named = new Map();
|
|
107
|
+
return (name, by) => {
|
|
108
|
+
const before = named.get(name);
|
|
109
|
+
if (before !== undefined)
|
|
110
|
+
throw new Error(`${by} and ${before} are both "${name}" in TypeScript: rename one`);
|
|
111
|
+
named.set(name, by);
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Metamodel, Operation } from "./metamodel.js";
|
|
2
|
+
/**
|
|
3
|
+
* How REST reaches each operation of a contract, as the generated client and entities write it.
|
|
4
|
+
*
|
|
5
|
+
* Context: the export names each path whole (`/v1/issues/{issue_id}`), version included, while
|
|
6
|
+
* an application's transport already prepends it (`restTransport({ base: "/v1" })`). `base`
|
|
7
|
+
* takes it off every path, and a path outside it is refused — a client written with half its
|
|
8
|
+
* routes under the wrong base would fail one call at a time.
|
|
9
|
+
*/
|
|
10
|
+
export type GenerateOptions = {
|
|
11
|
+
/** The first part of every entity's key: `project` makes `project:issue`. The contract's
|
|
12
|
+
* `alias` by default. */
|
|
13
|
+
domain?: string;
|
|
14
|
+
/** What every REST path starts with and the transport prepends: `/v1`. */
|
|
15
|
+
base?: string;
|
|
16
|
+
};
|
|
17
|
+
export type Reached = {
|
|
18
|
+
method: string;
|
|
19
|
+
/** The path under `base`, `{field}` where a field of the input goes. */
|
|
20
|
+
path: string;
|
|
21
|
+
/** The fields of the input the path carries. */
|
|
22
|
+
fields: readonly string[];
|
|
23
|
+
};
|
|
24
|
+
/** What a transport can send: GET is read by `list` (a page) or `one` (a record) only. */
|
|
25
|
+
export declare const WRITES: Set<string>;
|
|
26
|
+
export declare function restOf(metamodel: Metamodel, identity: string, options: GenerateOptions): Reached | undefined;
|
|
27
|
+
/**
|
|
28
|
+
* The one entity a read belongs to: its only entity, or — when it concerns several — the one
|
|
29
|
+
* whose model its answer carries. `undefined` when that still leaves more than one.
|
|
30
|
+
*/
|
|
31
|
+
export declare function ownerOf(metamodel: Metamodel, operation: Operation): string | undefined;
|