@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.
Files changed (62) hide show
  1. package/CHANGELOG.md +247 -0
  2. package/README.md +147 -11
  3. package/dist/cli/command.parser.d.ts +4 -3
  4. package/dist/cli/command.parser.js +3 -3
  5. package/dist/cli/generation-failure.renderer.d.ts +2 -1
  6. package/dist/cli/generation-success.renderer.d.ts +2 -1
  7. package/dist/cli/step.reporter.d.ts +2 -1
  8. package/dist/cli/terminal.prompter.d.ts +2 -1
  9. package/dist/config/client-config.interface.d.ts +17 -0
  10. package/dist/config/config.loader.d.ts +8 -3
  11. package/dist/config/config.loader.js +20 -2
  12. package/dist/config/config.resolver.d.ts +10 -3
  13. package/dist/config/config.resolver.js +28 -0
  14. package/dist/config/env.cascade.d.ts +2 -6
  15. package/dist/config/env.cascade.js +3 -3
  16. package/dist/config/module-style.resolver.d.ts +3 -6
  17. package/dist/config/module-style.resolver.js +2 -2
  18. package/dist/config/tsconfig.locator.d.ts +2 -1
  19. package/dist/config/tsconfig.locator.js +14 -2
  20. package/dist/contract/contract.acceptance.d.ts +0 -1
  21. package/dist/contract/contract.acceptance.js +1 -1
  22. package/dist/contract/contract.fetcher.d.ts +2 -1
  23. package/dist/contract/contract.loader.d.ts +3 -2
  24. package/dist/emit/access.emitter.d.ts +12 -0
  25. package/dist/emit/access.emitter.js +263 -0
  26. package/dist/emit/banner.emitter.d.ts +6 -1
  27. package/dist/emit/banner.emitter.js +12 -8
  28. package/dist/emit/cached-read.emitter.d.ts +3 -0
  29. package/dist/emit/cached-read.emitter.js +158 -0
  30. package/dist/emit/client-surface.emitter.js +130 -13
  31. package/dist/emit/client-tree.emitter.d.ts +9 -2
  32. package/dist/emit/client-tree.emitter.js +51 -15
  33. package/dist/emit/derivation.emitter.d.ts +3 -3
  34. package/dist/emit/derivation.emitter.js +6 -3
  35. package/dist/emit/descriptor.emitter.d.ts +2 -0
  36. package/dist/emit/descriptor.emitter.js +28 -1
  37. package/dist/emit/emitted-tree.interface.d.ts +1 -1
  38. package/dist/emit/field-check.emitter.d.ts +2 -1
  39. package/dist/emit/field-check.emitter.js +2 -13
  40. package/dist/emit/module-specifier.scanner.d.ts +2 -1
  41. package/dist/emit/name.deriver.d.ts +2 -3
  42. package/dist/emit/name.deriver.js +6 -1
  43. package/dist/emit/named-type.emitter.js +12 -2
  44. package/dist/emit/operation-set.emitter.d.ts +3 -0
  45. package/dist/emit/operation-set.emitter.js +604 -0
  46. package/dist/emit/runtime.emitter.d.ts +5 -4
  47. package/dist/emit/runtime.emitter.js +484 -42
  48. package/dist/emit/scalar.codec.js +223 -6
  49. package/dist/emit/transaction.emitter.js +7 -3
  50. package/dist/generate.d.ts +3 -2
  51. package/dist/generate.js +1 -1
  52. package/dist/hydration/hydration-tag.format.d.ts +11 -0
  53. package/dist/hydration/hydration-tag.format.js +6 -0
  54. package/dist/init/client-init.planner.d.ts +3 -2
  55. package/dist/init/client-init.questions.d.ts +1 -12
  56. package/dist/init/client-init.questions.js +2 -2
  57. package/dist/internal.d.ts +25 -0
  58. package/dist/internal.js +11 -0
  59. package/dist/output/output.validator.d.ts +3 -4
  60. package/dist/output/output.validator.js +2 -2
  61. package/dist/output/output.writer.d.ts +3 -2
  62. 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: "This client was generated against a ClientContract other than the one the server serves. Regenerate it with `avclient generate`.",
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 classes = Object.entries(FRAMEWORK_ERROR_CLASSES).map(([name, summary]) => {
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
- readonly method: "POST";
314
+ /** POST, or GET for a cached read. */
315
+ readonly method: "POST" | "GET";
266
316
  readonly headers: Readonly<Record<string, string>>;
267
- readonly body: string;
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
- /** Aborts the request; the call rejects with what fetch rejected with. */
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
- /** Extra request headers. The framework's own headers cannot be set here. */
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; TypeError, before anything is sent, when the
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
- const operation = JSON.stringify(resource) + " " + family + "." + variant;
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
- options: AvCallOptions,
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 init: FetchInit =
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
- response = await fetch(url, init);
615
+ return await fetch(url, init);
393
616
  } catch (error) {
394
- if (options.signal?.aborted === true) {
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
- let text: string;
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
- text = await response.text();
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
- if (options.signal?.aborted === true) {
404
- throw error;
405
- }
406
- throw new AvTransportError(
407
- "The response to " + operation + " (HTTP " + response.status + ") could not be read.",
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
- return { status: response.status, data: settle(operation, response.status, text) };
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
- const envelope = envelopeOf(parseJson(text));
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 framework's headers plus the caller's, refusing a caller's that names one of
591
- * the framework's; then requestId, which overwrites any request id header the
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 requestHeaders(options: AvCallOptions): Record<string, string> {
595
- const headers: Record<string, string> = { ...FRAMEWORK_HEADERS };
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())) {