@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,108 @@
|
|
|
1
|
+
import { TransportError, } from "./transport.js";
|
|
2
|
+
import { answer, answerGroups } from "@sincpro/criteria/engine";
|
|
3
|
+
function wait(ms, signal) {
|
|
4
|
+
if (ms <= 0)
|
|
5
|
+
return Promise.resolve();
|
|
6
|
+
return new Promise((done, failed) => {
|
|
7
|
+
const timer = setTimeout(done, ms);
|
|
8
|
+
signal?.addEventListener("abort", () => {
|
|
9
|
+
clearTimeout(timer);
|
|
10
|
+
failed(signal.reason);
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
export function memoryTransport(tables, options = {}) {
|
|
15
|
+
const answered = new Map();
|
|
16
|
+
async function check(route, signal) {
|
|
17
|
+
await wait(options.latency ?? 0, signal);
|
|
18
|
+
const refused = options.refuse?.(route);
|
|
19
|
+
if (refused !== undefined)
|
|
20
|
+
throw new TransportError(route, refused.status ?? 400, refused);
|
|
21
|
+
}
|
|
22
|
+
function tableAt(route) {
|
|
23
|
+
const path = route.split("?")[0] ?? route;
|
|
24
|
+
const table = tables[path];
|
|
25
|
+
if (table === undefined)
|
|
26
|
+
throw new TransportError(route, 404, {
|
|
27
|
+
kind: "not_found",
|
|
28
|
+
reason: "ROUTE_NOT_FOUND",
|
|
29
|
+
next_step: "fix_request",
|
|
30
|
+
retryable: false,
|
|
31
|
+
errors: [],
|
|
32
|
+
extensions: {},
|
|
33
|
+
detail: `No table answers ${path}.`,
|
|
34
|
+
});
|
|
35
|
+
return table;
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
async list(route, criteria, signal) {
|
|
39
|
+
options.observe?.(route, criteria);
|
|
40
|
+
await check(route, signal);
|
|
41
|
+
const table = tableAt(route);
|
|
42
|
+
// A grouped question is answered with the groups — counted over EVERY row that matches,
|
|
43
|
+
// each with the criteria that opens it — the way the server's aggregation answers it.
|
|
44
|
+
const grouped = (criteria.grouping?.group_by?.length ?? 0) > 0;
|
|
45
|
+
const page = grouped
|
|
46
|
+
? answerGroups(table.rows, criteria, table.meta)
|
|
47
|
+
: answer(table.rows, criteria, table.meta);
|
|
48
|
+
// A copy, as over a wire: a screen holding a row must not see the server change it.
|
|
49
|
+
return { ...page, rows: structuredClone(page.rows) };
|
|
50
|
+
},
|
|
51
|
+
async one(route, signal) {
|
|
52
|
+
await check(route, signal);
|
|
53
|
+
const slash = route.lastIndexOf("/");
|
|
54
|
+
const table = tableAt(route.slice(0, slash));
|
|
55
|
+
const id = decodeURIComponent(route.slice(slash + 1));
|
|
56
|
+
const key = table.meta.get_id || table.meta.identity;
|
|
57
|
+
const record = table.rows.find((row) => String(row[key]) === id);
|
|
58
|
+
if (record === undefined)
|
|
59
|
+
throw new TransportError(route, 404, {
|
|
60
|
+
kind: "not_found",
|
|
61
|
+
reason: "AGGREGATE_NOT_FOUND",
|
|
62
|
+
next_step: "do_not_retry",
|
|
63
|
+
retryable: false,
|
|
64
|
+
errors: [],
|
|
65
|
+
extensions: {},
|
|
66
|
+
detail: `${table.meta.aggregate} ${id} does not exist.`,
|
|
67
|
+
});
|
|
68
|
+
return {
|
|
69
|
+
record: structuredClone(record),
|
|
70
|
+
meta: table.meta,
|
|
71
|
+
dropped: [],
|
|
72
|
+
draft: options.draft?.(route) ?? null,
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
async send(method, route, body, signal, sending) {
|
|
76
|
+
await check(route, signal);
|
|
77
|
+
// A key already answered is answered again, without running the Command twice — what
|
|
78
|
+
// sincpro_framework's `@idempotency` does with a repeated `Idempotency-Key`.
|
|
79
|
+
const key = sending?.idempotencyKey;
|
|
80
|
+
if (key !== undefined && answered.has(key))
|
|
81
|
+
return structuredClone(answered.get(key));
|
|
82
|
+
if (options.handle === undefined)
|
|
83
|
+
throw new TransportError(route, 404, {
|
|
84
|
+
kind: "not_found",
|
|
85
|
+
reason: "ROUTE_NOT_FOUND",
|
|
86
|
+
next_step: "fix_request",
|
|
87
|
+
retryable: false,
|
|
88
|
+
errors: [],
|
|
89
|
+
extensions: {},
|
|
90
|
+
detail: `Nothing answers ${method} ${route}.`,
|
|
91
|
+
});
|
|
92
|
+
// A deep copy: what the caller sent is not what the server keeps.
|
|
93
|
+
const answer = await options.handle(method, route, structuredClone(body));
|
|
94
|
+
if (key !== undefined)
|
|
95
|
+
answered.set(key, structuredClone(answer));
|
|
96
|
+
return answer;
|
|
97
|
+
},
|
|
98
|
+
...(options.read === undefined
|
|
99
|
+
? {}
|
|
100
|
+
: {
|
|
101
|
+
async read(route, signal) {
|
|
102
|
+
await check(route, signal);
|
|
103
|
+
// A copy, as every answer: over a wire nobody holds the server's object.
|
|
104
|
+
return structuredClone(await options.read(route));
|
|
105
|
+
},
|
|
106
|
+
}),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -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,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;
|