@aventara/client 1.0.0-rc.1 → 1.0.0-rc.11
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 +247 -0
- package/README.md +147 -11
- package/dist/cli/command.parser.d.ts +4 -3
- package/dist/cli/command.parser.js +3 -3
- package/dist/cli/generation-failure.renderer.d.ts +2 -1
- package/dist/cli/generation-success.renderer.d.ts +2 -1
- package/dist/cli/step.reporter.d.ts +2 -1
- package/dist/cli/terminal.prompter.d.ts +2 -1
- package/dist/config/client-config.interface.d.ts +17 -0
- package/dist/config/config.loader.d.ts +8 -3
- package/dist/config/config.loader.js +20 -2
- package/dist/config/config.resolver.d.ts +10 -3
- package/dist/config/config.resolver.js +28 -0
- package/dist/config/env.cascade.d.ts +2 -6
- package/dist/config/env.cascade.js +3 -3
- package/dist/config/module-style.resolver.d.ts +3 -6
- package/dist/config/module-style.resolver.js +2 -2
- package/dist/config/tsconfig.locator.d.ts +2 -1
- package/dist/config/tsconfig.locator.js +14 -2
- package/dist/contract/contract.acceptance.d.ts +0 -1
- package/dist/contract/contract.acceptance.js +1 -1
- package/dist/contract/contract.fetcher.d.ts +2 -1
- package/dist/contract/contract.loader.d.ts +3 -2
- package/dist/emit/access.emitter.d.ts +12 -0
- package/dist/emit/access.emitter.js +263 -0
- package/dist/emit/banner.emitter.d.ts +6 -1
- package/dist/emit/banner.emitter.js +12 -8
- package/dist/emit/cached-read.emitter.d.ts +3 -0
- package/dist/emit/cached-read.emitter.js +158 -0
- package/dist/emit/client-surface.emitter.js +130 -13
- package/dist/emit/client-tree.emitter.d.ts +9 -2
- package/dist/emit/client-tree.emitter.js +51 -15
- package/dist/emit/derivation.emitter.d.ts +3 -3
- package/dist/emit/derivation.emitter.js +6 -3
- package/dist/emit/descriptor.emitter.d.ts +2 -0
- package/dist/emit/descriptor.emitter.js +28 -1
- package/dist/emit/emitted-tree.interface.d.ts +1 -1
- package/dist/emit/field-check.emitter.d.ts +2 -1
- package/dist/emit/field-check.emitter.js +2 -13
- package/dist/emit/module-specifier.scanner.d.ts +2 -1
- package/dist/emit/name.deriver.d.ts +2 -3
- package/dist/emit/name.deriver.js +6 -1
- package/dist/emit/named-type.emitter.js +12 -2
- package/dist/emit/operation-set.emitter.d.ts +3 -0
- package/dist/emit/operation-set.emitter.js +604 -0
- package/dist/emit/runtime.emitter.d.ts +5 -4
- package/dist/emit/runtime.emitter.js +484 -42
- package/dist/emit/scalar.codec.js +223 -6
- package/dist/emit/transaction.emitter.js +7 -3
- package/dist/generate.d.ts +3 -2
- package/dist/generate.js +1 -1
- package/dist/hydration/hydration-tag.format.d.ts +11 -0
- package/dist/hydration/hydration-tag.format.js +6 -0
- package/dist/init/client-init.planner.d.ts +3 -2
- package/dist/init/client-init.questions.d.ts +1 -12
- package/dist/init/client-init.questions.js +2 -2
- package/dist/internal.d.ts +25 -0
- package/dist/internal.js +11 -0
- package/dist/output/output.validator.d.ts +3 -4
- package/dist/output/output.validator.js +2 -2
- package/dist/output/output.writer.d.ts +3 -2
- package/package.json +9 -5
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { FRAMEWORK_ERROR_CLASS_OF, OPERATION_CODES, VALIDATION_CODES, } from "@aventara/core/internal";
|
|
2
2
|
import { AvProtocol } from "@aventara/core/protocol";
|
|
3
3
|
import { entrypointHref } from "../config/config.resolver.js";
|
|
4
|
+
import { AVCLIENT_REGENERATE, } from "./banner.emitter.js";
|
|
4
5
|
import { moduleSpecifierWriter, } from "./module-style.interface.js";
|
|
5
6
|
import { wireGrammarLiteral } from "./scalar.codec.js";
|
|
6
7
|
export function emitDecimalModule() {
|
|
@@ -51,10 +52,13 @@ export function emitMetadataModule(protocol, entrypoint) {
|
|
|
51
52
|
`export const DEFAULT_ENTRYPOINT = ${JSON.stringify(entrypointHref(entrypoint))};\n`,
|
|
52
53
|
};
|
|
53
54
|
}
|
|
55
|
+
function contractMismatchSummary(regenerate) {
|
|
56
|
+
return `This client was generated against a ClientContract other than the one the server serves. Regenerate it with ${regenerate}.`;
|
|
57
|
+
}
|
|
54
58
|
const FRAMEWORK_ERROR_CLASSES = {
|
|
55
59
|
AvAuthError: "The caller is not authenticated, or is not permitted to run the operation.",
|
|
56
60
|
AvConflictError: "The operation conflicted with stored state, and nothing was written. A2008 fails the same way if sent again; A2013 and A2014 may succeed if sent again, and no retry is automatic.",
|
|
57
|
-
AvContractMismatchError:
|
|
61
|
+
AvContractMismatchError: contractMismatchSummary(AVCLIENT_REGENERATE),
|
|
58
62
|
AvInternalError: "The server failed. Nothing in its cause is actionable; the diagnostics are in the server's logs.",
|
|
59
63
|
AvNotFoundError: "A strict unique target does not exist.",
|
|
60
64
|
AvProtocolError: "The request was not one the server could route or interpret: an unknown Resource or operation, a protocol version it does not speak, or a body, media type, size or method it refuses.",
|
|
@@ -152,8 +156,12 @@ export class AvTransportError extends Error {
|
|
|
152
156
|
}
|
|
153
157
|
}
|
|
154
158
|
`;
|
|
155
|
-
export function emitErrorsModule() {
|
|
156
|
-
const
|
|
159
|
+
export function emitErrorsModule(regenerate = AVCLIENT_REGENERATE) {
|
|
160
|
+
const summaries = {
|
|
161
|
+
...FRAMEWORK_ERROR_CLASSES,
|
|
162
|
+
AvContractMismatchError: contractMismatchSummary(regenerate),
|
|
163
|
+
};
|
|
164
|
+
const classes = Object.entries(summaries).map(([name, summary]) => {
|
|
157
165
|
const codes = ERROR_CODES.filter((code) => FRAMEWORK_ERROR_CLASS_OF[code] === name);
|
|
158
166
|
return (`\n/** ${summary} Thrown for ${codes.join(", ")}. */\n` +
|
|
159
167
|
`export class ${name} extends AvError<${codes.map((code) => JSON.stringify(code)).join(" | ")}> {\n` +
|
|
@@ -186,12 +194,49 @@ export function emitErrorsModule() {
|
|
|
186
194
|
"}\n",
|
|
187
195
|
};
|
|
188
196
|
}
|
|
189
|
-
export function emitTransportModule(style) {
|
|
197
|
+
export function emitTransportModule(style, access = false) {
|
|
190
198
|
return {
|
|
191
199
|
path: "runtime/transport.ts",
|
|
192
|
-
source: transportModuleSource(moduleSpecifierWriter(style))
|
|
200
|
+
source: transportModuleSource(moduleSpecifierWriter(style)) +
|
|
201
|
+
(access ? ASK_CAN_SOURCE : ""),
|
|
193
202
|
};
|
|
194
203
|
}
|
|
204
|
+
const ASK_CAN_SOURCE = `
|
|
205
|
+
/**
|
|
206
|
+
* Asks the policy every question in one request (\`POST /_access/can\`), each
|
|
207
|
+
* question's \`where\` in its wire forms, and resolves one boolean each, in order.
|
|
208
|
+
*
|
|
209
|
+
* @throws as {@link execute} does: the AvError subclass of a refusal (A2001 for a
|
|
210
|
+
* Resource this client cannot reach, A2004 for a malformed question, A2009 past
|
|
211
|
+
* the batch limit), AvTransportError when no framework envelope arrived.
|
|
212
|
+
*/
|
|
213
|
+
export async function askCan(
|
|
214
|
+
connection: TransportConnection,
|
|
215
|
+
questions: readonly unknown[],
|
|
216
|
+
options: AvCallOptions = {},
|
|
217
|
+
): Promise<boolean[]> {
|
|
218
|
+
const operation = "$access.can";
|
|
219
|
+
if (!Array.isArray(questions)) {
|
|
220
|
+
throw new TypeError("$access.can takes a list of questions.");
|
|
221
|
+
}
|
|
222
|
+
const headers = requestHeaders(options);
|
|
223
|
+
const body = serializeWireBody({
|
|
224
|
+
questions: questions.map((question: unknown) =>
|
|
225
|
+
isRecord(question) && Object.hasOwn(question, "where")
|
|
226
|
+
? { ...question, where: encodeArguments(question["where"]) }
|
|
227
|
+
: question,
|
|
228
|
+
),
|
|
229
|
+
});
|
|
230
|
+
const response = await post(connection, "/_access/can", operation, headers, body, options.signal);
|
|
231
|
+
return decoded(operation, response.status, () => {
|
|
232
|
+
const data: unknown = response.data;
|
|
233
|
+
if (!Array.isArray(data) || data.length !== questions.length || !data.every((answer) => typeof answer === "boolean")) {
|
|
234
|
+
throw new TypeError("The answer must hold one boolean per question.");
|
|
235
|
+
}
|
|
236
|
+
return data as boolean[];
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
`;
|
|
195
240
|
const IDENTITY_HEADERS = (() => {
|
|
196
241
|
const { protocolVersion, contractHash, requestId } = AvProtocol.headers;
|
|
197
242
|
const prefix = protocolVersion.slice(0, protocolVersion.indexOf("-"));
|
|
@@ -209,7 +254,7 @@ const IDENTITY_HEADERS = (() => {
|
|
|
209
254
|
})();
|
|
210
255
|
function transportModuleSource(from) {
|
|
211
256
|
return `import { CLIENT_CONTRACT_HASH, PROTOCOL_VERSION } from ${from("../metadata")};
|
|
212
|
-
import { decodeResult, encodeArguments, readArgument, serializeWireBody } from ${from("./codec")};
|
|
257
|
+
import { decodeResult, encodeArguments, readArgument, serializeWireBody, unsentValues } from ${from("./codec")};
|
|
213
258
|
import { DECODE_TABLE } from ${from("./descriptor")};
|
|
214
259
|
import {
|
|
215
260
|
type AvCause,
|
|
@@ -224,7 +269,8 @@ import {
|
|
|
224
269
|
} from ${from("./errors")};
|
|
225
270
|
|
|
226
271
|
/**
|
|
227
|
-
* The transport of Aventara protocol version 1: one POST per operation,
|
|
272
|
+
* The transport of Aventara protocol version 1: one POST per operation, or one
|
|
273
|
+
* POST /_all for an Operation Set of them,
|
|
228
274
|
* sent with the protocol version and ClientContract hash, its response
|
|
229
275
|
* read by the protocol's rule: A1xxx resolves with the envelope's data, every other
|
|
230
276
|
* code throws its AvError subclass, and anything that is not a framework
|
|
@@ -252,6 +298,9 @@ const FRAMEWORK_HEADERS: Readonly<Record<string, string>> = {
|
|
|
252
298
|
[WIRE_PREFIX + ${JSON.stringify(IDENTITY_HEADERS.contractHash)}]: CLIENT_CONTRACT_HASH,
|
|
253
299
|
};
|
|
254
300
|
|
|
301
|
+
/** The media type of a streamed Operation Set answer: one JSON line per entry. */
|
|
302
|
+
const NDJSON = "application/x-ndjson";
|
|
303
|
+
|
|
255
304
|
/** The request id header: AvCallOptions.requestId sends it. */
|
|
256
305
|
const REQUEST_ID_HEADER = WIRE_PREFIX + ${JSON.stringify(IDENTITY_HEADERS.requestId)};
|
|
257
306
|
|
|
@@ -262,9 +311,11 @@ export interface StructuralSignal {
|
|
|
262
311
|
|
|
263
312
|
/** The request the transport hands to fetch. */
|
|
264
313
|
export interface FetchInit {
|
|
265
|
-
|
|
314
|
+
/** POST, or GET for a cached read. */
|
|
315
|
+
readonly method: "POST" | "GET";
|
|
266
316
|
readonly headers: Readonly<Record<string, string>>;
|
|
267
|
-
|
|
317
|
+
/** The JSON body of a POST; a GET has none. */
|
|
318
|
+
readonly body?: string;
|
|
268
319
|
readonly signal?: PlatformSignal;
|
|
269
320
|
}
|
|
270
321
|
|
|
@@ -272,6 +323,16 @@ export interface FetchInit {
|
|
|
272
323
|
export interface FetchResponse {
|
|
273
324
|
readonly status: number;
|
|
274
325
|
text(): Promise<string>;
|
|
326
|
+
/** Read for an Operation Set's answer: its media type. */
|
|
327
|
+
readonly headers?: { get(name: string): string | null };
|
|
328
|
+
/** Read for a streamed Operation Set answer, where the runtime gives one. */
|
|
329
|
+
readonly body?: { getReader(): BodyReader } | null;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** What the transport reads of a response body's stream. */
|
|
333
|
+
export interface BodyReader {
|
|
334
|
+
read(): Promise<{ readonly done: boolean; readonly value?: Uint8Array | undefined }>;
|
|
335
|
+
cancel(reason?: unknown): Promise<void>;
|
|
275
336
|
}
|
|
276
337
|
|
|
277
338
|
/** The AvFetch API as far as the transport calls it, where the consumer's lib declares none. */
|
|
@@ -301,15 +362,30 @@ export interface TransportConnection {
|
|
|
301
362
|
|
|
302
363
|
/** Per-call options. Never serialized into the operation body. */
|
|
303
364
|
export interface AvCallOptions {
|
|
304
|
-
/**
|
|
365
|
+
/**
|
|
366
|
+
* Aborts this call, and only this call. Sent in an Operation Set, the call
|
|
367
|
+
* rejects with the signal's reason (an AbortError) and the others go on; sent
|
|
368
|
+
* alone, it aborts the request, and the call rejects with what fetch rejected
|
|
369
|
+
* with. Aborted before it is sent, it is never sent.
|
|
370
|
+
*/
|
|
305
371
|
readonly signal?: PlatformSignal;
|
|
306
|
-
/**
|
|
372
|
+
/**
|
|
373
|
+
* Extra request headers. The framework's own headers cannot be set here. A
|
|
374
|
+
* call with headers is sent alone, never in an Operation Set.
|
|
375
|
+
*/
|
|
307
376
|
readonly headers?: Readonly<Record<string, string>>;
|
|
308
377
|
/**
|
|
309
378
|
* Sent as the request id header (Aventara-Request-Id); the empty string is
|
|
310
|
-
* absent. It wins over that header in headers, which it overwrites.
|
|
379
|
+
* absent. It wins over that header in headers, which it overwrites. A call
|
|
380
|
+
* with a request id is sent alone, never in an Operation Set.
|
|
311
381
|
*/
|
|
312
382
|
readonly requestId?: string;
|
|
383
|
+
/**
|
|
384
|
+
* false sends this operation call to its own route, never in an Operation Set
|
|
385
|
+
* (POST /_all) with the calls made beside it. A transaction is always
|
|
386
|
+
* sent alone.
|
|
387
|
+
*/
|
|
388
|
+
readonly grouping?: boolean;
|
|
313
389
|
}
|
|
314
390
|
|
|
315
391
|
type SuccessCode = Exclude<AvOperationCode, AvOperationErrorCode>;
|
|
@@ -319,12 +395,132 @@ type Envelope =
|
|
|
319
395
|
| { readonly outcome: "data"; readonly code: SuccessCode; readonly data: unknown }
|
|
320
396
|
| { readonly outcome: "failure"; readonly code: AvOperationErrorCode; readonly cause: AvCause };
|
|
321
397
|
|
|
398
|
+
/** What a refusal of an operation's arguments says. */
|
|
399
|
+
const REFUSAL_MESSAGE = "Operation arguments failed framework validation.";
|
|
400
|
+
|
|
401
|
+
/** What an undefined value the request would leave out is told. */
|
|
402
|
+
const UNSENT_MESSAGE =
|
|
403
|
+
"Value must not be undefined: sent as JSON it would be left out, and the request would reach more rows.";
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Refuses arguments that hold an undefined value whose leaving out would reach more
|
|
407
|
+
* rows than the caller wrote (in a filter, a unique selector or a cursor, or a where,
|
|
408
|
+
* cursor, limit or offset left undefined), before anything is sent: JSON would drop
|
|
409
|
+
* it. Throws the AvValidationError (A2004) the server answers when it is handed such
|
|
410
|
+
* a value: one issue at each, with the server's code, its path under \`at\`
|
|
411
|
+
* (["arguments"] for a call, ["operations", i, "args"] for a plan step, whose index
|
|
412
|
+
* the cause names).
|
|
413
|
+
*/
|
|
414
|
+
export function refuseUnsentValues(
|
|
415
|
+
resource: string,
|
|
416
|
+
family: string,
|
|
417
|
+
variant: string,
|
|
418
|
+
args: unknown,
|
|
419
|
+
at: readonly (string | number)[],
|
|
420
|
+
operation?: number,
|
|
421
|
+
): void {
|
|
422
|
+
const unsent = unsentValues(DECODE_TABLE, resource, family, variant, args);
|
|
423
|
+
if (unsent.length === 0) {
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
const issues: readonly AvValidationIssue[] = Object.freeze(
|
|
427
|
+
unsent.map(({ path, code }) =>
|
|
428
|
+
Object.freeze({ path: Object.freeze([...at, ...path]), code, message: UNSENT_MESSAGE }),
|
|
429
|
+
),
|
|
430
|
+
);
|
|
431
|
+
throw frameworkErrorOf(
|
|
432
|
+
"A2004",
|
|
433
|
+
Object.freeze(
|
|
434
|
+
operation === undefined ? { message: REFUSAL_MESSAGE, issues } : { message: REFUSAL_MESSAGE, issues, operation },
|
|
435
|
+
),
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** One operation, checked and encoded when it was called: what any request for it sends. */
|
|
440
|
+
export interface PreparedOperation {
|
|
441
|
+
readonly resource: string;
|
|
442
|
+
readonly family: string;
|
|
443
|
+
readonly variant: string;
|
|
444
|
+
/** How a failure names it: \`"Post" find.many\`. */
|
|
445
|
+
readonly label: string;
|
|
446
|
+
/** Its request headers: the framework's, and the caller's when it is sent alone. */
|
|
447
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
448
|
+
/** The caller's headers alone (its request id among them): what a cached read's GET sends. */
|
|
449
|
+
readonly callerHeaders: Readonly<Record<string, string>>;
|
|
450
|
+
/** Its arguments in wire form, as JSON text: its own route's body. */
|
|
451
|
+
readonly body: string;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Checks and encodes one operation call, sending nothing: the checks
|
|
456
|
+
* {@link execute} runs before it sends, in its order, and with its errors.
|
|
457
|
+
*/
|
|
458
|
+
export function prepareOperation(
|
|
459
|
+
resource: string,
|
|
460
|
+
family: string,
|
|
461
|
+
variant: string,
|
|
462
|
+
args: Readonly<Record<string, unknown>>,
|
|
463
|
+
options: AvCallOptions = {},
|
|
464
|
+
check?: (resource: string, family: string, args: unknown) => void,
|
|
465
|
+
): PreparedOperation {
|
|
466
|
+
refuseUnsentValues(resource, family, variant, args, ["arguments"]);
|
|
467
|
+
check?.(resource, family, args);
|
|
468
|
+
const label = JSON.stringify(resource) + " " + family + "." + variant;
|
|
469
|
+
const callerHeaders = callerHeadersOf(options);
|
|
470
|
+
const headers = { ...FRAMEWORK_HEADERS, ...callerHeaders };
|
|
471
|
+
const body = serializeWireBody(encodeArguments(argumentObject(args)));
|
|
472
|
+
return { resource, family, variant, label, headers, callerHeaders, body };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Sends one prepared operation to its own route and settles its envelope.
|
|
477
|
+
*
|
|
478
|
+
* @throws as {@link execute} does once its arguments are checked.
|
|
479
|
+
*/
|
|
480
|
+
export async function sendOperation(
|
|
481
|
+
connection: TransportConnection,
|
|
482
|
+
operation: PreparedOperation,
|
|
483
|
+
signal?: PlatformSignal,
|
|
484
|
+
): Promise<unknown> {
|
|
485
|
+
const response = await post(connection, routeOf(operation), operation.label, operation.headers, operation.body, signal);
|
|
486
|
+
return decoded(operation.label, response.status, () => decodeResult(DECODE_TABLE, operation.resource, response.data));
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** An operation's own route: \`/_resources/<resource>/<family>/<variant>\`. */
|
|
490
|
+
export function routeOf(operation: PreparedOperation): string {
|
|
491
|
+
return "/_resources/" + [operation.resource, operation.family, operation.variant].map(encodeURIComponent).join("/");
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Sends one cached read as a GET to its own route with \`query\` (its \`q\` and
|
|
496
|
+
* \`v\`) and only the caller's headers, and settles its envelope as
|
|
497
|
+
* {@link sendOperation} does.
|
|
498
|
+
*/
|
|
499
|
+
export async function sendRead(
|
|
500
|
+
connection: TransportConnection,
|
|
501
|
+
operation: PreparedOperation,
|
|
502
|
+
query: string,
|
|
503
|
+
signal?: PlatformSignal,
|
|
504
|
+
): Promise<unknown> {
|
|
505
|
+
const init: FetchInit =
|
|
506
|
+
signal === undefined
|
|
507
|
+
? { method: "GET", headers: operation.callerHeaders }
|
|
508
|
+
: { method: "GET", headers: operation.callerHeaders, signal };
|
|
509
|
+
const response = await received(connection, routeOf(operation) + "?" + query, operation.label, init);
|
|
510
|
+
return decoded(operation.label, response.status, () => decodeResult(DECODE_TABLE, operation.resource, response.data));
|
|
511
|
+
}
|
|
512
|
+
|
|
322
513
|
/**
|
|
323
514
|
* Runs one operation: POSTs its arguments and settles its envelope.
|
|
324
515
|
*
|
|
516
|
+
* @param check the client's own checks of the arguments, run before anything is sent
|
|
517
|
+
* (the ClientContract's field validators, when it has any).
|
|
325
518
|
* @returns the envelope's data when the code is A1xxx; null for A1001.
|
|
326
519
|
* @throws the AvError subclass of any other code; AvTransportError when no
|
|
327
|
-
* framework envelope arrived;
|
|
520
|
+
* framework envelope arrived; AvValidationError (A2004), before anything is sent,
|
|
521
|
+
* when the arguments hold an undefined value JSON would leave out to widen the
|
|
522
|
+
* request (see refuseUnsentValues), or when
|
|
523
|
+
* \`check\` refuses the arguments; TypeError, before anything is sent, when the
|
|
328
524
|
* arguments are not a JSON-safe object or a request header is the framework's.
|
|
329
525
|
* A caller's abort rejects with what fetch rejected with.
|
|
330
526
|
*/
|
|
@@ -335,13 +531,9 @@ export async function execute(
|
|
|
335
531
|
variant: string,
|
|
336
532
|
args: Readonly<Record<string, unknown>>,
|
|
337
533
|
options: AvCallOptions = {},
|
|
534
|
+
check?: (resource: string, family: string, args: unknown) => void,
|
|
338
535
|
): Promise<unknown> {
|
|
339
|
-
|
|
340
|
-
const headers = requestHeaders(options);
|
|
341
|
-
const body = serializeWireBody(encodeArguments(argumentObject(args)));
|
|
342
|
-
const path = "/_resources/" + [resource, family, variant].map(encodeURIComponent).join("/");
|
|
343
|
-
const response = await post(connection, path, operation, headers, body, options);
|
|
344
|
-
return decoded(operation, response.status, () => decodeResult(DECODE_TABLE, resource, response.data));
|
|
536
|
+
return sendOperation(connection, prepareOperation(resource, family, variant, args, options, check), options.signal);
|
|
345
537
|
}
|
|
346
538
|
|
|
347
539
|
/**
|
|
@@ -361,7 +553,7 @@ export async function executePlan(
|
|
|
361
553
|
const operation = "the transaction";
|
|
362
554
|
const headers = requestHeaders(options);
|
|
363
555
|
const body = serializeWireBody(plan);
|
|
364
|
-
const response = await post(connection, "/_transactions", operation, headers, body, options);
|
|
556
|
+
const response = await post(connection, "/_transactions", operation, headers, body, options.signal);
|
|
365
557
|
return decoded(operation, response.status, () => {
|
|
366
558
|
if (!Array.isArray(response.data) || response.data.length !== resources.length) {
|
|
367
559
|
throw new TypeError("A committed plan's data must hold one result per operation.");
|
|
@@ -377,38 +569,279 @@ async function post(
|
|
|
377
569
|
operation: string,
|
|
378
570
|
headers: Readonly<Record<string, string>>,
|
|
379
571
|
body: string,
|
|
380
|
-
|
|
572
|
+
signal: PlatformSignal | undefined,
|
|
381
573
|
): Promise<{ readonly status: number; readonly data: unknown }> {
|
|
574
|
+
const init: FetchInit = signal === undefined ? { method: "POST", headers, body } : { method: "POST", headers, body, signal };
|
|
575
|
+
return received(connection, path, operation, init);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** One request and its settled envelope data, still in wire form. */
|
|
579
|
+
async function received(
|
|
580
|
+
connection: TransportConnection,
|
|
581
|
+
path: string,
|
|
582
|
+
operation: string,
|
|
583
|
+
init: FetchInit,
|
|
584
|
+
): Promise<{ readonly status: number; readonly data: unknown }> {
|
|
585
|
+
const signal = init.signal;
|
|
586
|
+
const response = await send(connection, path, operation, init);
|
|
587
|
+
let text: string;
|
|
588
|
+
try {
|
|
589
|
+
text = await response.text();
|
|
590
|
+
} catch (error) {
|
|
591
|
+
if (signal?.aborted === true) {
|
|
592
|
+
throw error;
|
|
593
|
+
}
|
|
594
|
+
throw new AvTransportError(
|
|
595
|
+
"The response to " + operation + " (HTTP " + response.status + ") could not be read.",
|
|
596
|
+
response.status,
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
return { status: response.status, data: settle(operation, response.status, text) };
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** One request, until its response arrives; a caller's abort rejects with what fetch rejected with. */
|
|
603
|
+
async function send(
|
|
604
|
+
connection: TransportConnection,
|
|
605
|
+
path: string,
|
|
606
|
+
operation: string,
|
|
607
|
+
init: FetchInit,
|
|
608
|
+
): Promise<FetchResponse> {
|
|
382
609
|
const url = connection.entrypoint + path;
|
|
383
|
-
const
|
|
384
|
-
options.signal === undefined
|
|
385
|
-
? { method: "POST", headers, body }
|
|
386
|
-
: { method: "POST", headers, body, signal: options.signal };
|
|
610
|
+
const signal = init.signal;
|
|
387
611
|
|
|
388
612
|
// Called unbound: a browser's fetch refuses a this that is not the global.
|
|
389
613
|
const fetch = connection.fetch as StructuralFetch;
|
|
390
|
-
let response: FetchResponse;
|
|
391
614
|
try {
|
|
392
|
-
|
|
615
|
+
return await fetch(url, init);
|
|
393
616
|
} catch (error) {
|
|
394
|
-
if (
|
|
617
|
+
if (signal?.aborted === true) {
|
|
395
618
|
throw error;
|
|
396
619
|
}
|
|
397
620
|
throw new AvTransportError("The request for " + operation + " failed before any response arrived.", null);
|
|
398
621
|
}
|
|
399
|
-
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** How one entry of an Operation Set settled: its decoded data, or what its call rejects with. */
|
|
625
|
+
export type EntryOutcome =
|
|
626
|
+
| { readonly kind: "value"; readonly value: unknown }
|
|
627
|
+
| { readonly kind: "error"; readonly error: unknown };
|
|
628
|
+
|
|
629
|
+
/** What an Operation Set request came to: its entries answered, or no /_all on the server (A2002, A2000). */
|
|
630
|
+
export type OperationSetAnswer = "answered" | "absent";
|
|
631
|
+
|
|
632
|
+
/** How errors name an Operation Set request. */
|
|
633
|
+
const SET_LABEL = "the Operation Set";
|
|
634
|
+
|
|
635
|
+
/** The key the client gives the entry at \`index\`: an identifier, never the caller's. */
|
|
636
|
+
export function operationSetKey(index: number): string {
|
|
637
|
+
return "k" + index;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** One entry of an Operation Set's body, as JSON text, under \`key\`. */
|
|
641
|
+
export function operationSetEntry(key: string, operation: PreparedOperation): string {
|
|
642
|
+
return (
|
|
643
|
+
JSON.stringify(key) +
|
|
644
|
+
':{"resource":' +
|
|
645
|
+
JSON.stringify(operation.resource) +
|
|
646
|
+
',"family":' +
|
|
647
|
+
JSON.stringify(operation.family) +
|
|
648
|
+
',"variant":' +
|
|
649
|
+
JSON.stringify(operation.variant) +
|
|
650
|
+
',"args":' +
|
|
651
|
+
operation.body +
|
|
652
|
+
"}"
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** The JSON text of an Operation Set's body, around its entries' text. */
|
|
657
|
+
export function operationSetBody(entries: readonly string[]): string {
|
|
658
|
+
return '{"operations":{' + entries.join(",") + "}}";
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Sends prepared operations as one Operation Set (\`POST /_all\`), each under a key
|
|
663
|
+
* the client makes, and hands each entry's outcome to \`answer\` as it arrives: line
|
|
664
|
+
* by line when the answer is streamed (NDJSON, asked for where the runtime can
|
|
665
|
+
* stream), all at once from a plain JSON answer. Each entry decodes and fails as
|
|
666
|
+
* its own route would answer it. A request refused whole answers every entry with
|
|
667
|
+
* that refusal, except A2002 or A2000, which answer none: the server has no /_all.
|
|
668
|
+
* A response that is not an Aventara answer answers each entry still waiting with
|
|
669
|
+
* AvTransportError. Never rejects.
|
|
670
|
+
*/
|
|
671
|
+
export async function sendOperationSet(
|
|
672
|
+
connection: TransportConnection,
|
|
673
|
+
operations: readonly PreparedOperation[],
|
|
674
|
+
signal: PlatformSignal | undefined,
|
|
675
|
+
answer: (index: number, outcome: EntryOutcome) => void,
|
|
676
|
+
): Promise<OperationSetAnswer> {
|
|
677
|
+
const keys = new Map(operations.map((_, index) => [operationSetKey(index), index]));
|
|
678
|
+
const waiting = new Set(keys.values());
|
|
679
|
+
const settleEntry = (index: number, outcome: EntryOutcome): void => {
|
|
680
|
+
if (waiting.delete(index)) {
|
|
681
|
+
answer(index, outcome);
|
|
682
|
+
}
|
|
683
|
+
};
|
|
684
|
+
const failWaiting = (error: unknown): OperationSetAnswer => {
|
|
685
|
+
for (const index of [...waiting]) {
|
|
686
|
+
settleEntry(index, { kind: "error", error });
|
|
687
|
+
}
|
|
688
|
+
return "answered";
|
|
689
|
+
};
|
|
690
|
+
const entryOf = (index: number, status: number, envelope: unknown): EntryOutcome => {
|
|
691
|
+
const operation = operations[index] as PreparedOperation;
|
|
692
|
+
try {
|
|
693
|
+
const data = settleEnvelope(operation.label, status, envelope);
|
|
694
|
+
return { kind: "value", value: decoded(operation.label, status, () => decodeResult(DECODE_TABLE, operation.resource, data)) };
|
|
695
|
+
} catch (error) {
|
|
696
|
+
return { kind: "error", error };
|
|
697
|
+
}
|
|
698
|
+
};
|
|
699
|
+
const streaming = canStream();
|
|
700
|
+
const headers: Record<string, string> = streaming ? { ...FRAMEWORK_HEADERS, Accept: NDJSON } : { ...FRAMEWORK_HEADERS };
|
|
701
|
+
const body = operationSetBody(operations.map((operation, index) => operationSetEntry(operationSetKey(index), operation)));
|
|
702
|
+
|
|
703
|
+
let response: FetchResponse;
|
|
400
704
|
try {
|
|
401
|
-
|
|
705
|
+
response = await send(
|
|
706
|
+
connection,
|
|
707
|
+
"/_all",
|
|
708
|
+
SET_LABEL,
|
|
709
|
+
signal === undefined ? { method: "POST", headers, body } : { method: "POST", headers, body, signal },
|
|
710
|
+
);
|
|
402
711
|
} catch (error) {
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
712
|
+
return failWaiting(error);
|
|
713
|
+
}
|
|
714
|
+
const unreadable = (): AvTransportError =>
|
|
715
|
+
new AvTransportError("The response to " + SET_LABEL + " (HTTP " + response.status + ") could not be read.", response.status);
|
|
716
|
+
const malformed = (): AvTransportError =>
|
|
717
|
+
new AvTransportError(
|
|
718
|
+
"The response to " + SET_LABEL + " (HTTP " + response.status + ") is not an Aventara Operation Set answer.",
|
|
408
719
|
response.status,
|
|
409
720
|
);
|
|
721
|
+
|
|
722
|
+
const mediaType = (response.headers?.get("content-type") ?? "").split(";")[0]?.trim().toLowerCase();
|
|
723
|
+
if (mediaType !== NDJSON) {
|
|
724
|
+
let text: string;
|
|
725
|
+
try {
|
|
726
|
+
text = await response.text();
|
|
727
|
+
} catch {
|
|
728
|
+
return failWaiting(unreadable());
|
|
729
|
+
}
|
|
730
|
+
const envelope = envelopeOf(parseJson(text));
|
|
731
|
+
if (envelope === undefined) {
|
|
732
|
+
return failWaiting(
|
|
733
|
+
new AvTransportError(
|
|
734
|
+
"The response to " + SET_LABEL + " (HTTP " + response.status + ") is not an Aventara response envelope.",
|
|
735
|
+
response.status,
|
|
736
|
+
),
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
if (envelope.outcome === "failure") {
|
|
740
|
+
// A server without /_all: A2002 (no such route), or A2000 from a server older
|
|
741
|
+
// than Operation Sets, which reads /_all as a malformed operation path.
|
|
742
|
+
return envelope.code === "A2002" || envelope.code === "A2000"
|
|
743
|
+
? "absent"
|
|
744
|
+
: failWaiting(frameworkErrorOf(envelope.code, envelope.cause));
|
|
745
|
+
}
|
|
746
|
+
const entries = envelope.data;
|
|
747
|
+
if (!isRecord(entries)) {
|
|
748
|
+
return failWaiting(malformed());
|
|
749
|
+
}
|
|
750
|
+
for (const [key, index] of keys) {
|
|
751
|
+
if (Object.hasOwn(entries, key)) {
|
|
752
|
+
settleEntry(index, entryOf(index, response.status, entries[key]));
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
return failWaiting(
|
|
756
|
+
new AvTransportError(
|
|
757
|
+
"The response to " + SET_LABEL + " (HTTP " + response.status + ") holds no answer for this call.",
|
|
758
|
+
response.status,
|
|
759
|
+
),
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// One line: {"key": …, "envelope": …}. false when it is not one of this request's.
|
|
764
|
+
const readLine = (line: string): boolean => {
|
|
765
|
+
if (line.trim() === "") {
|
|
766
|
+
return true;
|
|
767
|
+
}
|
|
768
|
+
const parsed = parseJson(line);
|
|
769
|
+
if (!isRecord(parsed) || !Object.hasOwn(parsed, "envelope") || typeof parsed["key"] !== "string") {
|
|
770
|
+
return false;
|
|
771
|
+
}
|
|
772
|
+
const index = keys.get(parsed["key"]);
|
|
773
|
+
if (index === undefined) {
|
|
774
|
+
return false;
|
|
775
|
+
}
|
|
776
|
+
settleEntry(index, entryOf(index, response.status, parsed["envelope"]));
|
|
777
|
+
return true;
|
|
778
|
+
};
|
|
779
|
+
const ended = (): OperationSetAnswer =>
|
|
780
|
+
failWaiting(
|
|
781
|
+
new AvTransportError(
|
|
782
|
+
"The response to " + SET_LABEL + " (HTTP " + response.status + ") ended before it answered this call.",
|
|
783
|
+
response.status,
|
|
784
|
+
),
|
|
785
|
+
);
|
|
786
|
+
|
|
787
|
+
const reader = streaming ? response.body?.getReader() : undefined;
|
|
788
|
+
if (reader === undefined) {
|
|
789
|
+
let text: string;
|
|
790
|
+
try {
|
|
791
|
+
text = await response.text();
|
|
792
|
+
} catch {
|
|
793
|
+
return failWaiting(unreadable());
|
|
794
|
+
}
|
|
795
|
+
for (const line of text.split("\\n")) {
|
|
796
|
+
if (!readLine(line)) {
|
|
797
|
+
return failWaiting(malformed());
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
return ended();
|
|
801
|
+
}
|
|
802
|
+
const decoder = textDecoder();
|
|
803
|
+
let buffer = "";
|
|
804
|
+
for (;;) {
|
|
805
|
+
let chunk: { readonly done: boolean; readonly value?: Uint8Array | undefined };
|
|
806
|
+
try {
|
|
807
|
+
chunk = await reader.read();
|
|
808
|
+
} catch {
|
|
809
|
+
return failWaiting(unreadable());
|
|
810
|
+
}
|
|
811
|
+
buffer += decoder.decode(chunk.value, { stream: !chunk.done });
|
|
812
|
+
let newline = buffer.indexOf("\\n");
|
|
813
|
+
while (newline !== -1) {
|
|
814
|
+
if (!readLine(buffer.slice(0, newline))) {
|
|
815
|
+
reader.cancel().catch((): void => undefined);
|
|
816
|
+
return failWaiting(malformed());
|
|
817
|
+
}
|
|
818
|
+
buffer = buffer.slice(newline + 1);
|
|
819
|
+
newline = buffer.indexOf("\\n");
|
|
820
|
+
}
|
|
821
|
+
if (chunk.done) {
|
|
822
|
+
return readLine(buffer) ? ended() : failWaiting(malformed());
|
|
823
|
+
}
|
|
410
824
|
}
|
|
411
|
-
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/** What the transport reads of a TextDecoder, where the consumer's lib declares none. */
|
|
828
|
+
interface StreamDecoder {
|
|
829
|
+
decode(input?: Uint8Array, options?: { readonly stream?: boolean }): string;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/** Whether this runtime can read an answer as it streams: a stream reader and a text decoder. */
|
|
833
|
+
function canStream(): boolean {
|
|
834
|
+
const platform = globalThis as {
|
|
835
|
+
readonly ReadableStream?: { readonly prototype?: { readonly getReader?: unknown } };
|
|
836
|
+
readonly TextDecoder?: unknown;
|
|
837
|
+
};
|
|
838
|
+
return typeof platform.ReadableStream?.prototype?.getReader === "function" && typeof platform.TextDecoder === "function";
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/** The platform's TextDecoder, for UTF-8 (canStream says one exists). */
|
|
842
|
+
function textDecoder(): StreamDecoder {
|
|
843
|
+
const Platform = (globalThis as unknown as { readonly TextDecoder: new () => StreamDecoder }).TextDecoder;
|
|
844
|
+
return new Platform();
|
|
412
845
|
}
|
|
413
846
|
|
|
414
847
|
/**
|
|
@@ -435,7 +868,12 @@ function decoded<T>(operation: string, status: number, decode: () => T): T {
|
|
|
435
868
|
* this client cannot read, which is what AvTransportError means.
|
|
436
869
|
*/
|
|
437
870
|
function settle(operation: string, status: number, text: string): unknown {
|
|
438
|
-
|
|
871
|
+
return settleEnvelope(operation, status, parseJson(text));
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/** Settles one parsed response body (or one Operation Set entry's envelope) by the same rule. */
|
|
875
|
+
function settleEnvelope(operation: string, status: number, body: unknown): unknown {
|
|
876
|
+
const envelope = envelopeOf(body);
|
|
439
877
|
if (envelope === undefined) {
|
|
440
878
|
throw new AvTransportError(
|
|
441
879
|
"The response to " + operation + " (HTTP " + status + ") is not an Aventara response envelope.",
|
|
@@ -586,13 +1024,17 @@ function argumentObject(args: unknown): unknown {
|
|
|
586
1024
|
return args;
|
|
587
1025
|
}
|
|
588
1026
|
|
|
1027
|
+
/** The framework's headers plus the caller's (see callerHeadersOf). */
|
|
1028
|
+
function requestHeaders(options: AvCallOptions): Record<string, string> {
|
|
1029
|
+
return { ...FRAMEWORK_HEADERS, ...callerHeadersOf(options) };
|
|
1030
|
+
}
|
|
1031
|
+
|
|
589
1032
|
/**
|
|
590
|
-
* The
|
|
591
|
-
*
|
|
592
|
-
* caller set.
|
|
1033
|
+
* The caller's headers, refusing one that names one of the framework's; then
|
|
1034
|
+
* requestId, which overwrites any request id header the caller set.
|
|
593
1035
|
*/
|
|
594
|
-
function
|
|
595
|
-
const headers: Record<string, string> = {
|
|
1036
|
+
function callerHeadersOf(options: AvCallOptions): Record<string, string> {
|
|
1037
|
+
const headers: Record<string, string> = {};
|
|
596
1038
|
const owned = new Set(Object.keys(FRAMEWORK_HEADERS).map((name) => name.toLowerCase()));
|
|
597
1039
|
for (const [name, value] of Object.entries(options.headers ?? {})) {
|
|
598
1040
|
if (owned.has(name.toLowerCase())) {
|