@aventara/client 1.0.0-rc.0 → 1.0.0-rc.2
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/CHANGELOG.md +504 -0
- package/README.md +159 -141
- package/dist/cli/command.parser.d.ts +5 -5
- package/dist/cli/command.parser.js +13 -13
- package/dist/cli/generation-failure.renderer.js +24 -24
- package/dist/config/client-config.interface.d.ts +7 -7
- package/dist/config/config.loader.d.ts +10 -4
- package/dist/config/config.loader.js +16 -7
- package/dist/config/config.resolver.d.ts +7 -7
- package/dist/config/config.resolver.js +12 -12
- package/dist/config/env.cascade.d.ts +2 -2
- package/dist/config/env.cascade.js +4 -4
- package/dist/config/module-style.resolver.d.ts +1 -1
- package/dist/config/module-style.resolver.js +2 -2
- package/dist/config/tsconfig.locator.d.ts +1 -1
- package/dist/config/tsconfig.locator.js +2 -2
- package/dist/contract/contract.acceptance.d.ts +4 -4
- package/dist/contract/contract.acceptance.js +4 -4
- package/dist/contract/contract.fetcher.d.ts +3 -3
- package/dist/contract/contract.fetcher.js +6 -6
- package/dist/contract/contract.loader.d.ts +6 -6
- package/dist/contract/contract.loader.js +2 -2
- package/dist/emit/client-surface.emitter.d.ts +2 -2
- package/dist/emit/client-surface.emitter.js +61 -40
- package/dist/emit/client-tree.emitter.d.ts +3 -3
- package/dist/emit/client-tree.emitter.js +8 -5
- package/dist/emit/contract-carrier.emitter.d.ts +3 -3
- package/dist/emit/derivation.emitter.js +1 -0
- package/dist/emit/descriptor.emitter.d.ts +2 -2
- package/dist/emit/descriptor.emitter.js +1 -1
- package/dist/emit/emitted-tree.interface.d.ts +1 -1
- package/dist/emit/enum.emitter.d.ts +2 -2
- package/dist/emit/field-check.emitter.d.ts +21 -0
- package/dist/emit/field-check.emitter.js +592 -0
- package/dist/emit/json-shape.emitter.d.ts +2 -2
- package/dist/emit/name.deriver.d.ts +6 -6
- package/dist/emit/name.deriver.js +14 -14
- package/dist/emit/named-type.emitter.d.ts +2 -2
- package/dist/emit/runtime.emitter.d.ts +2 -2
- package/dist/emit/runtime.emitter.js +166 -112
- package/dist/emit/scalar.codec.d.ts +1 -1
- package/dist/emit/scalar.codec.js +246 -29
- package/dist/emit/transaction.emitter.js +30 -14
- package/dist/generate.d.ts +2 -2
- package/dist/generate.js +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/init/client-init.errors.d.ts +4 -4
- package/dist/init/client-init.errors.js +4 -4
- package/dist/init/client-init.orchestrator.js +7 -7
- package/dist/init/client-init.questions.d.ts +5 -5
- package/dist/init/client-init.questions.js +10 -10
- package/dist/init/client-project.inspector.d.ts +2 -2
- package/dist/init/client-project.inspector.js +3 -3
- package/dist/output/output.validator.d.ts +3 -4
- package/dist/output/output.validator.js +2 -2
- package/dist/output/output.writer.d.ts +5 -5
- package/dist/output/output.writer.js +12 -12
- package/package.json +4 -4
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { canonicalizeJson } from "@aventara/core/internal";
|
|
5
|
+
import { scanModuleSpecifiers } from "./module-specifier.scanner.js";
|
|
6
|
+
import { moduleSpecifierWriter, } from "./module-style.interface.js";
|
|
7
|
+
import { ownPropertyKey } from "./name.deriver.js";
|
|
8
|
+
export const EVALUATOR_SOURCE = "src/runtime/field-check.evaluator.ts";
|
|
9
|
+
const EVALUATOR_MODULE = "runtime/field-check.evaluator";
|
|
10
|
+
export function publishedCoreEvaluator() {
|
|
11
|
+
const root = path.dirname(path.dirname(fileURLToPath(import.meta.resolve("@aventara/core"))));
|
|
12
|
+
return () => {
|
|
13
|
+
try {
|
|
14
|
+
return readFileSync(path.join(root, EVALUATOR_SOURCE), "utf8");
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
if (error.code === "ENOENT") {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function schemaHasChecks(schema) {
|
|
25
|
+
if (Array.isArray(schema)) {
|
|
26
|
+
return schema.some(schemaHasChecks);
|
|
27
|
+
}
|
|
28
|
+
if (typeof schema !== "object" || schema === null) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
const record = schema;
|
|
32
|
+
return ((Array.isArray(record.checks) && record.checks.length > 0) ||
|
|
33
|
+
Object.values(record).some(schemaHasChecks));
|
|
34
|
+
}
|
|
35
|
+
function fieldHasChecks(field) {
|
|
36
|
+
return (field?.kind === "scalar" &&
|
|
37
|
+
((field.validators?.length ?? 0) > 0 ||
|
|
38
|
+
(field.json !== undefined && schemaHasChecks(field.json))));
|
|
39
|
+
}
|
|
40
|
+
export function contractHasFieldChecks(contract) {
|
|
41
|
+
return Object.values(contract.resources).some((resource) => Object.values(resource.fields).some(fieldHasChecks));
|
|
42
|
+
}
|
|
43
|
+
function codeUnitOrder(values) {
|
|
44
|
+
return [...values].sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
|
|
45
|
+
}
|
|
46
|
+
export function emitFieldCheckModules(contract, style, derivation, read = publishedCoreEvaluator()) {
|
|
47
|
+
if (!contractHasFieldChecks(contract)) {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
const from = moduleSpecifierWriter(style);
|
|
51
|
+
return [
|
|
52
|
+
{
|
|
53
|
+
path: "runtime/field-check.evaluator.ts",
|
|
54
|
+
source: copiedEvaluator(read, derivation, from),
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
path: "runtime/field-validators.ts",
|
|
58
|
+
source: fieldValidatorsSource(contract, from),
|
|
59
|
+
},
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
function uncopiable(reason) {
|
|
63
|
+
return new Error(`@aventara/core's field check evaluator cannot be copied into the generated client: ${reason}.`);
|
|
64
|
+
}
|
|
65
|
+
const TYPE_IMPORT = /^import type \{[^}]*\} from (\S+);$/gm;
|
|
66
|
+
function copiedEvaluator(read, derivation, from) {
|
|
67
|
+
const text = read();
|
|
68
|
+
if (text === undefined) {
|
|
69
|
+
throw uncopiable(`it does not publish ${EVALUATOR_SOURCE}`);
|
|
70
|
+
}
|
|
71
|
+
const scanned = scanModuleSpecifiers(text);
|
|
72
|
+
if (scanned.references) {
|
|
73
|
+
throw uncopiable("it carries a triple-slash reference");
|
|
74
|
+
}
|
|
75
|
+
const rewritten = [];
|
|
76
|
+
const copy = text.replace(TYPE_IMPORT, (statement, quoted) => {
|
|
77
|
+
let specifier;
|
|
78
|
+
try {
|
|
79
|
+
specifier = JSON.parse(quoted);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
specifier = undefined;
|
|
83
|
+
}
|
|
84
|
+
if (typeof specifier !== "string") {
|
|
85
|
+
throw uncopiable(`it imports ${quoted}`);
|
|
86
|
+
}
|
|
87
|
+
rewritten.push(specifier);
|
|
88
|
+
const target = derivationTarget(specifier, derivation);
|
|
89
|
+
return statement.replace(quoted, from(target));
|
|
90
|
+
});
|
|
91
|
+
const expected = [...scanned.specifiers].sort();
|
|
92
|
+
if (rewritten.sort().join("\n") !== expected.join("\n")) {
|
|
93
|
+
throw uncopiable("it names a module other than through a type-only import, which the copy cannot carry");
|
|
94
|
+
}
|
|
95
|
+
return copy;
|
|
96
|
+
}
|
|
97
|
+
function derivationTarget(specifier, derivation) {
|
|
98
|
+
if (!(specifier.startsWith("./") || specifier.startsWith("../")) ||
|
|
99
|
+
!specifier.endsWith(".js")) {
|
|
100
|
+
throw uncopiable(`it imports ${JSON.stringify(specifier)}`);
|
|
101
|
+
}
|
|
102
|
+
const target = path.posix
|
|
103
|
+
.normalize(path.posix.join(path.posix.dirname(EVALUATOR_MODULE), specifier))
|
|
104
|
+
.replace(/\.js$/, "");
|
|
105
|
+
if (target.startsWith("../") ||
|
|
106
|
+
!derivation.has(`derivation/${target}.d.ts`)) {
|
|
107
|
+
throw uncopiable(`it imports ${JSON.stringify(specifier)}, whose declaration the generated client does not copy`);
|
|
108
|
+
}
|
|
109
|
+
return `../derivation/${target}`;
|
|
110
|
+
}
|
|
111
|
+
function fieldRulesSource(field) {
|
|
112
|
+
const members = [];
|
|
113
|
+
if ("scalar" in field.type) {
|
|
114
|
+
members.push(`scalar: ${JSON.stringify(field.type.scalar)}`);
|
|
115
|
+
}
|
|
116
|
+
members.push(`list: ${field.list === true}`);
|
|
117
|
+
if ((field.validators?.length ?? 0) > 0) {
|
|
118
|
+
members.push(`validators: ${canonicalizeJson(field.validators)}`);
|
|
119
|
+
}
|
|
120
|
+
if (field.json !== undefined) {
|
|
121
|
+
members.push(`json: ${canonicalizeJson(field.json)}`);
|
|
122
|
+
}
|
|
123
|
+
return `{ ${members.join(", ")} }`;
|
|
124
|
+
}
|
|
125
|
+
function rulesTableSource(contract) {
|
|
126
|
+
const rows = codeUnitOrder(Object.keys(contract.resources)).flatMap((resourceName) => {
|
|
127
|
+
const fields = contract.resources[resourceName]?.fields ?? {};
|
|
128
|
+
const entries = codeUnitOrder(Object.keys(fields)).flatMap((name) => {
|
|
129
|
+
const field = fields[name];
|
|
130
|
+
return field?.kind === "scalar" &&
|
|
131
|
+
((field.validators?.length ?? 0) > 0 || field.json !== undefined)
|
|
132
|
+
? [`\t\t${ownPropertyKey(name)}: ${fieldRulesSource(field)},\n`]
|
|
133
|
+
: [];
|
|
134
|
+
});
|
|
135
|
+
return entries.length === 0
|
|
136
|
+
? []
|
|
137
|
+
: [`\t${ownPropertyKey(resourceName)}: {\n${entries.join("")}\t},\n`];
|
|
138
|
+
});
|
|
139
|
+
return `{${rows.length === 0 ? "" : `\n${rows.join("")}`}}`;
|
|
140
|
+
}
|
|
141
|
+
function fieldValidatorsSource(contract, from) {
|
|
142
|
+
return `import type { AvFieldCheck } from ${from("../derivation/contracts/field-check")};
|
|
143
|
+
import type { JsonSchema } from ${from("../derivation/contracts/json-shape")};
|
|
144
|
+
import { RECORD_SITES, writeKeysOf } from ${from("./codec")};
|
|
145
|
+
import { DECODE_TABLE } from ${from("./descriptor")};
|
|
146
|
+
import { type AvCause, type AvValidationIssue, frameworkErrorOf } from ${from("./errors")};
|
|
147
|
+
import {
|
|
148
|
+
entryCheckIssues,
|
|
149
|
+
FIELD_CHECK_SUBJECTS,
|
|
150
|
+
type FieldCheckSubject,
|
|
151
|
+
fieldCheckIssue,
|
|
152
|
+
fieldCheckIssues,
|
|
153
|
+
passesFieldCheck,
|
|
154
|
+
} from ${from("./field-check.evaluator")};
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The field validators of this client's ClientContract, run before a request is
|
|
158
|
+
* sent: a field's validators, and the checks of a shaped Json field's shape. A
|
|
159
|
+
* call whose values break one is refused with the AvValidationError the server
|
|
160
|
+
* would answer (A2004, with the same issues: each one's code, path, check,
|
|
161
|
+
* params and message), and nothing is sent. The server checks every value again.
|
|
162
|
+
*
|
|
163
|
+
* The client checks a value as the caller wrote it; the server checks it after
|
|
164
|
+
* its field pipes, so a field pipe that normalizes a value (trims it,
|
|
165
|
+
* lower-cases it) must not turn a value this client accepted into one the server
|
|
166
|
+
* refuses. A $ref value in a transaction is the server's to check, when it is
|
|
167
|
+
* resolved at its step. A value that does not match its Json Shape is left to the
|
|
168
|
+
* server too, which answers it with issues of its own.
|
|
169
|
+
*/
|
|
170
|
+
|
|
171
|
+
/** One field's rules: its validators and, for a shaped Json field, its shape. */
|
|
172
|
+
interface FieldRules {
|
|
173
|
+
/** The field's scalar type; absent for an enum. */
|
|
174
|
+
readonly scalar?: string;
|
|
175
|
+
readonly list: boolean;
|
|
176
|
+
readonly validators?: readonly AvFieldCheck[];
|
|
177
|
+
readonly json?: JsonSchema;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Per Resource, the fields with validators or a Json Shape. */
|
|
181
|
+
const FIELD_RULES: {
|
|
182
|
+
readonly [resource: string]: { readonly [field: string]: FieldRules };
|
|
183
|
+
} = ${rulesTableSource(contract)};
|
|
184
|
+
${FIELD_VALIDATORS_RUNTIME}`;
|
|
185
|
+
}
|
|
186
|
+
const FIELD_VALIDATORS_RUNTIME = `
|
|
187
|
+
type Path = readonly (string | number)[];
|
|
188
|
+
|
|
189
|
+
/** What a refusal of an operation's arguments says. */
|
|
190
|
+
const REFUSAL_MESSAGE = "Operation arguments failed framework validation.";
|
|
191
|
+
|
|
192
|
+
/** Thrown inside a walk when the server would answer with issues this client does not compute. */
|
|
193
|
+
const UNDECIDED: object = Object.freeze({});
|
|
194
|
+
|
|
195
|
+
/** One step of a transaction plan, as the plan's checks read it. */
|
|
196
|
+
export interface CheckedStep {
|
|
197
|
+
readonly resource: string;
|
|
198
|
+
readonly family: string;
|
|
199
|
+
readonly args: unknown;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Refuses an operation whose arguments break the Contract's field checks, with
|
|
204
|
+
* the AvValidationError (A2004) the server would answer; returns when they
|
|
205
|
+
* break none, or when the server is the one to judge them.
|
|
206
|
+
*/
|
|
207
|
+
export function checkOperation(resource: string, family: string, args: unknown): void {
|
|
208
|
+
const issues = decided(() => validatorIssues(resource, family, args, ["arguments"]));
|
|
209
|
+
if (issues !== undefined && issues.length > 0) {
|
|
210
|
+
throw frameworkErrorOf("A2004", refusal(issues));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Refuses a transaction plan one of whose steps breaks the Contract's field
|
|
216
|
+
* checks, as the server would before the transaction opens: step by step, the
|
|
217
|
+
* first failing step named.
|
|
218
|
+
*/
|
|
219
|
+
export function checkPlan(steps: readonly CheckedStep[]): void {
|
|
220
|
+
const failed = decided(() => {
|
|
221
|
+
for (const [index, step] of steps.entries()) {
|
|
222
|
+
const issues = validatorIssues(step.resource, step.family, step.args, ["operations", index, "args"]);
|
|
223
|
+
if (issues.length > 0) {
|
|
224
|
+
return { operation: index, issues };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return undefined;
|
|
228
|
+
});
|
|
229
|
+
if (failed !== undefined) {
|
|
230
|
+
throw frameworkErrorOf("A2004", refusal(failed.issues, failed.operation));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The walk's verdict, or undefined when it cannot decide or cannot read the arguments. */
|
|
235
|
+
function decided<T>(walk: () => T): T | undefined {
|
|
236
|
+
try {
|
|
237
|
+
return walk();
|
|
238
|
+
} catch {
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function refusal(issues: readonly AvValidationIssue[], operation?: number): AvCause {
|
|
244
|
+
const frozen = Object.freeze(issues.map(frozenIssue));
|
|
245
|
+
return Object.freeze(
|
|
246
|
+
operation === undefined
|
|
247
|
+
? { message: REFUSAL_MESSAGE, issues: frozen }
|
|
248
|
+
: { message: REFUSAL_MESSAGE, issues: frozen, operation },
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function frozenIssue(issue: AvValidationIssue): AvValidationIssue {
|
|
253
|
+
return deepFreeze({ ...issue, path: [...(issue.path ?? [])] });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function deepFreeze<T>(value: T): T {
|
|
257
|
+
if (typeof value === "object" && value !== null) {
|
|
258
|
+
for (const member of Object.values(value)) {
|
|
259
|
+
deepFreeze(member);
|
|
260
|
+
}
|
|
261
|
+
Object.freeze(value);
|
|
262
|
+
}
|
|
263
|
+
return value;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function isPlainRecord(value: unknown): value is Readonly<Record<string, unknown>> {
|
|
267
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
const prototype: unknown = Object.getPrototypeOf(value);
|
|
271
|
+
return prototype === Object.prototype || prototype === null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function own<T>(record: { readonly [key: string]: T } | undefined, key: string): T | undefined {
|
|
275
|
+
return record !== undefined && Object.hasOwn(record, key) ? record[key] : undefined;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** A mutation directive ({ $increment: 1 }), or a transaction reference ({ $ref: … }). */
|
|
279
|
+
function isDirective(value: unknown): value is Readonly<Record<string, unknown>> {
|
|
280
|
+
return isPlainRecord(value) && Object.keys(value).some((key) => key.startsWith("$"));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** What a relation field of a Resource reaches: its target, and whether it is to-many. */
|
|
284
|
+
function relationOf(resource: string, field: string): { readonly target: string; readonly many: boolean } | undefined {
|
|
285
|
+
const decoding = own(own(DECODE_TABLE, resource), field);
|
|
286
|
+
return typeof decoding === "object" ? { target: decoding.relation, many: decoding.many } : undefined;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function valueAt(args: unknown, path: Path): unknown {
|
|
290
|
+
let current = args;
|
|
291
|
+
for (const key of path) {
|
|
292
|
+
if (Array.isArray(current) && typeof key === "number") {
|
|
293
|
+
current = current[key];
|
|
294
|
+
} else if (isPlainRecord(current) && typeof key === "string" && Object.hasOwn(current, key)) {
|
|
295
|
+
current = current[key];
|
|
296
|
+
} else {
|
|
297
|
+
return undefined;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return current;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ---- Json Shapes: a shaped field's shape is one of its validators ----
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* One value against a shape: aborted (the server reports a type, value or
|
|
307
|
+
* missing key there), soft (a key the shape does not hold, or an integer outside
|
|
308
|
+
* the safe range: reported, but the value's checks still run), or ok; with the
|
|
309
|
+
* failed checks' issues, at paths inside the value.
|
|
310
|
+
*/
|
|
311
|
+
interface Judged {
|
|
312
|
+
readonly state: "aborted" | "soft" | "ok";
|
|
313
|
+
readonly issues: readonly AvValidationIssue[];
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const ABORTED: Judged = { state: "aborted", issues: [] };
|
|
317
|
+
const PASSED: Judged = { state: "ok", issues: [] };
|
|
318
|
+
|
|
319
|
+
function combined(results: readonly Judged[]): Judged {
|
|
320
|
+
const state = results.some((result) => result.state === "aborted")
|
|
321
|
+
? "aborted"
|
|
322
|
+
: results.some((result) => result.state === "soft")
|
|
323
|
+
? "soft"
|
|
324
|
+
: "ok";
|
|
325
|
+
return { state, issues: results.flatMap((result) => result.issues) };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function checked(
|
|
329
|
+
judged: Judged,
|
|
330
|
+
checks: readonly AvFieldCheck[] | undefined,
|
|
331
|
+
value: unknown,
|
|
332
|
+
subject: FieldCheckSubject,
|
|
333
|
+
path: Path,
|
|
334
|
+
): Judged {
|
|
335
|
+
if (judged.state === "aborted" || checks === undefined) {
|
|
336
|
+
return judged;
|
|
337
|
+
}
|
|
338
|
+
const issues = checks
|
|
339
|
+
.filter((check) => !passesFieldCheck(check, value, subject))
|
|
340
|
+
.map((check) => fieldCheckIssue(check, subject, path));
|
|
341
|
+
return { state: judged.state, issues: [...judged.issues, ...issues] };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function judge(schema: JsonSchema, value: unknown, path: Path): Judged {
|
|
345
|
+
if ("const" in schema) {
|
|
346
|
+
return value === schema.const ? PASSED : ABORTED;
|
|
347
|
+
}
|
|
348
|
+
if ("enum" in schema) {
|
|
349
|
+
return typeof value === "string" && schema.enum.some((member) => member === value) ? PASSED : ABORTED;
|
|
350
|
+
}
|
|
351
|
+
if ("anyOf" in schema) {
|
|
352
|
+
return union(schema.anyOf, value, path);
|
|
353
|
+
}
|
|
354
|
+
if ("oneOf" in schema) {
|
|
355
|
+
return union(schema.oneOf, value, path);
|
|
356
|
+
}
|
|
357
|
+
switch (schema.type) {
|
|
358
|
+
case "null":
|
|
359
|
+
return value === null ? PASSED : ABORTED;
|
|
360
|
+
case "boolean":
|
|
361
|
+
return typeof value === "boolean" ? PASSED : ABORTED;
|
|
362
|
+
case "string":
|
|
363
|
+
return typeof value === "string" ? checked(PASSED, schema.checks, value, "string", path) : ABORTED;
|
|
364
|
+
case "number":
|
|
365
|
+
return typeof value === "number" && Number.isFinite(value)
|
|
366
|
+
? checked(PASSED, schema.checks, value, "number", path)
|
|
367
|
+
: ABORTED;
|
|
368
|
+
case "integer":
|
|
369
|
+
if (typeof value !== "number" || !Number.isInteger(value)) {
|
|
370
|
+
return ABORTED;
|
|
371
|
+
}
|
|
372
|
+
return checked(
|
|
373
|
+
Number.isSafeInteger(value) ? PASSED : { state: "soft", issues: [] },
|
|
374
|
+
schema.checks,
|
|
375
|
+
value,
|
|
376
|
+
"number",
|
|
377
|
+
path,
|
|
378
|
+
);
|
|
379
|
+
case "array": {
|
|
380
|
+
if (!Array.isArray(value)) {
|
|
381
|
+
return ABORTED;
|
|
382
|
+
}
|
|
383
|
+
const list: readonly unknown[] = value;
|
|
384
|
+
if ("prefixItems" in schema) {
|
|
385
|
+
const items = schema.prefixItems;
|
|
386
|
+
return list.length === items.length
|
|
387
|
+
? combined(items.map((item, index) => judge(item, list[index], [...path, index])))
|
|
388
|
+
: ABORTED;
|
|
389
|
+
}
|
|
390
|
+
const item = schema.items;
|
|
391
|
+
const entries = combined(list.map((entry, index) => judge(item, entry, [...path, index])));
|
|
392
|
+
return checked(entries, schema.checks, list, "number", path);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
if (!isPlainRecord(value)) {
|
|
396
|
+
return ABORTED;
|
|
397
|
+
}
|
|
398
|
+
const record = value;
|
|
399
|
+
const present = (key: string): boolean => Object.hasOwn(record, key) && record[key] !== undefined;
|
|
400
|
+
if (!("properties" in schema)) {
|
|
401
|
+
const member = schema.additionalProperties;
|
|
402
|
+
return combined(
|
|
403
|
+
Object.keys(record)
|
|
404
|
+
.filter(present)
|
|
405
|
+
.map((key) => judge(member, record[key], [...path, key])),
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
const properties = schema.properties;
|
|
409
|
+
const required: readonly string[] = schema.required ?? [];
|
|
410
|
+
const results = Object.keys(properties).map((key): Judged => {
|
|
411
|
+
const property = properties[key];
|
|
412
|
+
if (property === undefined) {
|
|
413
|
+
return PASSED;
|
|
414
|
+
}
|
|
415
|
+
if (!present(key)) {
|
|
416
|
+
return required.includes(key) ? ABORTED : PASSED;
|
|
417
|
+
}
|
|
418
|
+
return judge(property, record[key], [...path, key]);
|
|
419
|
+
});
|
|
420
|
+
const unknown = Object.keys(record).some((key) => present(key) && !Object.hasOwn(properties, key));
|
|
421
|
+
return combined(unknown ? [...results, { state: "soft", issues: [] }] : results);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** A union, as Zod settles one: the option that passes, else the one option not aborted. */
|
|
425
|
+
function union(options: readonly JsonSchema[], value: unknown, path: Path): Judged {
|
|
426
|
+
const results = options.map((option) => judge(option, value, path));
|
|
427
|
+
if (results.some((result) => result.state === "ok" && result.issues.length === 0)) {
|
|
428
|
+
return PASSED;
|
|
429
|
+
}
|
|
430
|
+
const live = results.filter((result) => result.state !== "aborted");
|
|
431
|
+
return live.length === 1 ? (live[0] as Judged) : ABORTED;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** One written value's shape issues; undecided when the server would report others. */
|
|
435
|
+
function valueShapeIssues(schema: JsonSchema, value: unknown, path: Path, issues: AvValidationIssue[]): void {
|
|
436
|
+
if (value === null || value === undefined) {
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
const judged = judge(schema, value, path);
|
|
440
|
+
if (judged.state !== "ok") {
|
|
441
|
+
throw UNDECIDED;
|
|
442
|
+
}
|
|
443
|
+
issues.push(...judged.issues);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** A shaped field's whole value: each entry of a list field, else the value. */
|
|
447
|
+
function shapedValueIssues(schema: JsonSchema, list: boolean, value: unknown, path: Path, issues: AvValidationIssue[]): void {
|
|
448
|
+
if (list && value !== null && value !== undefined) {
|
|
449
|
+
if (!Array.isArray(value)) {
|
|
450
|
+
throw UNDECIDED;
|
|
451
|
+
}
|
|
452
|
+
value.forEach((entry: unknown, index) => valueShapeIssues(schema, entry, [...path, index], issues));
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
valueShapeIssues(schema, value, path, issues);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ---- field validators, judged as the server judges them after its field pipes ----
|
|
459
|
+
|
|
460
|
+
/** The top-level data records of a create, update or upsert. */
|
|
461
|
+
function writeRecordPaths(family: string, args: unknown): Path[] {
|
|
462
|
+
const keys = writeKeysOf(family);
|
|
463
|
+
const records: Path[] = [];
|
|
464
|
+
if (!isPlainRecord(args)) {
|
|
465
|
+
return records;
|
|
466
|
+
}
|
|
467
|
+
for (const key of keys) {
|
|
468
|
+
if (!Object.hasOwn(args, key)) {
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
const value = args[key];
|
|
472
|
+
if (Array.isArray(value)) {
|
|
473
|
+
value.forEach((entry: unknown, index) => {
|
|
474
|
+
if (isPlainRecord(entry)) {
|
|
475
|
+
records.push([key, index]);
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
} else if (isPlainRecord(value)) {
|
|
479
|
+
records.push([key]);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return records;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** Each nested write's target and its records, depth first in data order. */
|
|
486
|
+
function nestedTargets(
|
|
487
|
+
args: unknown,
|
|
488
|
+
resource: string,
|
|
489
|
+
path: Path,
|
|
490
|
+
targets: { readonly resource: string; readonly records: readonly Path[] }[],
|
|
491
|
+
): void {
|
|
492
|
+
const record = valueAt(args, path);
|
|
493
|
+
if (!isPlainRecord(record)) {
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
for (const [key, value] of Object.entries(record)) {
|
|
497
|
+
const relation = relationOf(resource, key);
|
|
498
|
+
if (relation === undefined || !isPlainRecord(value)) {
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
for (const [directive, operand] of Object.entries(value)) {
|
|
502
|
+
const sites = own(RECORD_SITES, directive);
|
|
503
|
+
if (sites === undefined) {
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
const entries: readonly Path[] =
|
|
507
|
+
directive !== "$set" && Array.isArray(operand) ? operand.map((_entry: unknown, index) => [index]) : [[]];
|
|
508
|
+
for (const entry of entries) {
|
|
509
|
+
const entryPath = [...path, key, directive, ...entry];
|
|
510
|
+
const records = sites
|
|
511
|
+
.map((site) => (site === undefined ? entryPath : [...entryPath, site]))
|
|
512
|
+
.filter((site) => isPlainRecord(valueAt(args, site)));
|
|
513
|
+
targets.push({ resource: relation.target, records });
|
|
514
|
+
for (const site of records) {
|
|
515
|
+
nestedTargets(args, relation.target, site, targets);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function recordValidatorIssues(
|
|
523
|
+
resource: string,
|
|
524
|
+
records: readonly Path[],
|
|
525
|
+
args: unknown,
|
|
526
|
+
at: Path,
|
|
527
|
+
issues: AvValidationIssue[],
|
|
528
|
+
): void {
|
|
529
|
+
const fields = own(FIELD_RULES, resource);
|
|
530
|
+
for (const path of records) {
|
|
531
|
+
const record = valueAt(args, path);
|
|
532
|
+
if (!isPlainRecord(record)) {
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
for (const name of Object.keys(record)) {
|
|
536
|
+
const rules = own(fields, name);
|
|
537
|
+
const json = rules?.json;
|
|
538
|
+
const validators = rules?.validators;
|
|
539
|
+
if (rules === undefined || (json === undefined && validators === undefined)) {
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
const value = record[name];
|
|
543
|
+
const fieldPath = [...at, ...path, name];
|
|
544
|
+
const subject = own(FIELD_CHECK_SUBJECTS, rules.scalar ?? "") ?? "string";
|
|
545
|
+
if (!isDirective(value)) {
|
|
546
|
+
if (json !== undefined) {
|
|
547
|
+
shapedValueIssues(json, rules.list, value, fieldPath, issues);
|
|
548
|
+
} else if (validators !== undefined) {
|
|
549
|
+
issues.push(...fieldCheckIssues(validators, value, subject, fieldPath, rules.list));
|
|
550
|
+
}
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
if (!rules.list || !Object.hasOwn(value, "$push")) {
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
const operand = value["$push"];
|
|
557
|
+
const pushed: readonly { readonly entry: unknown; readonly path: Path }[] = Array.isArray(operand)
|
|
558
|
+
? operand.map((entry: unknown, index) => ({ entry, path: [...fieldPath, "$push", index] }))
|
|
559
|
+
: [{ entry: operand, path: [...fieldPath, "$push"] }];
|
|
560
|
+
for (const { entry, path: entryPath } of pushed) {
|
|
561
|
+
if (isDirective(entry)) {
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
if (json !== undefined) {
|
|
565
|
+
valueShapeIssues(json, entry, entryPath, issues);
|
|
566
|
+
} else if (validators !== undefined) {
|
|
567
|
+
issues.push(...entryCheckIssues(validators, entry, subject, entryPath));
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Every validator issue in an operation's write data, a shaped Json field's
|
|
576
|
+
* shape among them, in the order the call wrote each record's fields: its own
|
|
577
|
+
* records first, then each nested target's.
|
|
578
|
+
*/
|
|
579
|
+
function validatorIssues(resource: string, family: string, args: unknown, at: Path): AvValidationIssue[] {
|
|
580
|
+
const issues: AvValidationIssue[] = [];
|
|
581
|
+
const records = writeRecordPaths(family, args);
|
|
582
|
+
recordValidatorIssues(resource, records, args, at, issues);
|
|
583
|
+
const targets: { readonly resource: string; readonly records: readonly Path[] }[] = [];
|
|
584
|
+
for (const path of records) {
|
|
585
|
+
nestedTargets(args, resource, path, targets);
|
|
586
|
+
}
|
|
587
|
+
for (const target of targets) {
|
|
588
|
+
recordValidatorIssues(target.resource, target.records, args, at, issues);
|
|
589
|
+
}
|
|
590
|
+
return issues;
|
|
591
|
+
}
|
|
592
|
+
`;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { AvContractClient } from "@aventara/core";
|
|
2
2
|
type Schema = Readonly<Record<string, unknown>>;
|
|
3
3
|
/**
|
|
4
4
|
* The TypeScript type a Json Shape schema describes, as source text.
|
|
@@ -12,5 +12,5 @@ export declare function jsonSchemaType(schema: Schema): string;
|
|
|
12
12
|
* contract shapes a Json field — the carrier with each shaped field's type — or
|
|
13
13
|
* `undefined` when it shapes none.
|
|
14
14
|
*/
|
|
15
|
-
export declare function shapedContractType(contract:
|
|
15
|
+
export declare function shapedContractType(contract: AvContractClient): string | undefined;
|
|
16
16
|
export {};
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { AvContractClient, AvOperationFamily } from "@aventara/core";
|
|
2
2
|
/**
|
|
3
3
|
* Name derivation and the rename ladder for the emitted tree.
|
|
4
4
|
*/
|
|
5
5
|
export declare const RESERVED_EMITTED_NAMES: readonly string[];
|
|
6
6
|
export interface ResourceNamedType {
|
|
7
7
|
readonly suffix: string;
|
|
8
|
-
readonly family:
|
|
8
|
+
readonly family: AvOperationFamily;
|
|
9
9
|
readonly variant: string;
|
|
10
10
|
/** The argument it names; absent for `X`, the default record. */
|
|
11
11
|
readonly argument?: "where" | "orderBy" | "data" | "select" | "include";
|
|
@@ -43,12 +43,12 @@ export declare function isIdentifierName(text: string): boolean;
|
|
|
43
43
|
* The emitted names of a Contract's enums and Resources, renamed where a name
|
|
44
44
|
* cannot be declared as-is.
|
|
45
45
|
*
|
|
46
|
-
* @throws
|
|
46
|
+
* @throws AvGeneratedNameError naming every name no rung of its ladder can repair.
|
|
47
47
|
*/
|
|
48
|
-
export declare function deriveEmittedNames(contract: Pick<
|
|
48
|
+
export declare function deriveEmittedNames(contract: Pick<AvContractClient, "enums" | "resources">): EmittedNames;
|
|
49
49
|
/** The refusal that stops generation over names no rename can repair. */
|
|
50
|
-
export declare class
|
|
51
|
-
readonly name = "
|
|
50
|
+
export declare class AvGeneratedNameError extends Error {
|
|
51
|
+
readonly name = "AvGeneratedNameError";
|
|
52
52
|
constructor(problems: readonly string[]);
|
|
53
53
|
}
|
|
54
54
|
export declare function ownPropertyKey(value: string): string;
|
|
@@ -4,19 +4,19 @@ export const RESERVED_EMITTED_NAMES = [
|
|
|
4
4
|
...new Set([
|
|
5
5
|
"AvClient",
|
|
6
6
|
"AvClientOptions",
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
"
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
"
|
|
7
|
+
"AvCallOptions",
|
|
8
|
+
"AvCause",
|
|
9
|
+
"AvDecimal",
|
|
10
|
+
"AvFetch",
|
|
11
|
+
"AvError",
|
|
12
|
+
"AvNotFoundError",
|
|
13
|
+
"AvOperation",
|
|
14
|
+
"AvOperationCode",
|
|
15
15
|
"ResourceArgument",
|
|
16
16
|
"ResourceRecord",
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
17
|
+
"AvTransportError",
|
|
18
|
+
"AvValidationCode",
|
|
19
|
+
"AvValidationIssue",
|
|
20
20
|
"avClient",
|
|
21
21
|
...FRAMEWORK_ERROR_CLASS_NAMES,
|
|
22
22
|
]),
|
|
@@ -214,7 +214,7 @@ export function deriveEmittedNames(contract) {
|
|
|
214
214
|
const enums = enumNames.map((name) => resolve("enum", name));
|
|
215
215
|
const properties = resourceProperties(resourceNames, warnings, refusals);
|
|
216
216
|
if (refusals.length > 0) {
|
|
217
|
-
throw new
|
|
217
|
+
throw new AvGeneratedNameError(byName(refusals));
|
|
218
218
|
}
|
|
219
219
|
return { enums, resources, properties, warnings: byName(warnings) };
|
|
220
220
|
}
|
|
@@ -262,8 +262,8 @@ function byName(lines) {
|
|
|
262
262
|
function compareCodeUnits(left, right) {
|
|
263
263
|
return left < right ? -1 : left > right ? 1 : 0;
|
|
264
264
|
}
|
|
265
|
-
export class
|
|
266
|
-
name = "
|
|
265
|
+
export class AvGeneratedNameError extends Error {
|
|
266
|
+
name = "AvGeneratedNameError";
|
|
267
267
|
constructor(problems) {
|
|
268
268
|
super(`the ClientContract's names cannot all be emitted: ${problems.join("; ")}. ` +
|
|
269
269
|
`Rename ${problems.length === 1 ? "it" : "them"} on the server, then generate again.`);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { AvContractClient } from "@aventara/core";
|
|
2
2
|
import type { EmittedModule } from "./emitted-tree.interface.js";
|
|
3
3
|
import { type ClientModuleStyle } from "./module-style.interface.js";
|
|
4
4
|
import { type EmittedNames } from "./name.deriver.js";
|
|
5
|
-
export declare function emitNamedTypesModule(contract:
|
|
5
|
+
export declare function emitNamedTypesModule(contract: AvContractClient, names: EmittedNames, style: ClientModuleStyle): EmittedModule;
|