@aventara/client 1.0.0-rc.1 → 1.0.0-rc.10

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 (56) hide show
  1. package/CHANGELOG.md +194 -0
  2. package/README.md +37 -1
  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/client-surface.emitter.js +79 -7
  29. package/dist/emit/client-tree.emitter.d.ts +9 -2
  30. package/dist/emit/client-tree.emitter.js +45 -14
  31. package/dist/emit/derivation.emitter.d.ts +3 -3
  32. package/dist/emit/derivation.emitter.js +6 -3
  33. package/dist/emit/emitted-tree.interface.d.ts +1 -1
  34. package/dist/emit/field-check.emitter.d.ts +2 -1
  35. package/dist/emit/field-check.emitter.js +2 -13
  36. package/dist/emit/module-specifier.scanner.d.ts +2 -1
  37. package/dist/emit/name.deriver.d.ts +2 -3
  38. package/dist/emit/name.deriver.js +4 -1
  39. package/dist/emit/named-type.emitter.js +12 -2
  40. package/dist/emit/runtime.emitter.d.ts +5 -4
  41. package/dist/emit/runtime.emitter.js +101 -7
  42. package/dist/emit/scalar.codec.js +223 -6
  43. package/dist/emit/transaction.emitter.js +7 -3
  44. package/dist/generate.d.ts +3 -2
  45. package/dist/generate.js +1 -1
  46. package/dist/hydration/hydration-tag.format.d.ts +11 -0
  47. package/dist/hydration/hydration-tag.format.js +6 -0
  48. package/dist/init/client-init.planner.d.ts +3 -2
  49. package/dist/init/client-init.questions.d.ts +1 -12
  50. package/dist/init/client-init.questions.js +2 -2
  51. package/dist/internal.d.ts +25 -0
  52. package/dist/internal.js +11 -0
  53. package/dist/output/output.validator.d.ts +3 -4
  54. package/dist/output/output.validator.js +2 -2
  55. package/dist/output/output.writer.d.ts +3 -2
  56. package/package.json +9 -5
@@ -1,7 +1,9 @@
1
- import { withGeneratedBanner } from "./banner.emitter.js";
1
+ import { AvClientConfigError } from "../config/config.resolver.js";
2
+ import { accessVerbsOf, emitAccessModules } from "./access.emitter.js";
3
+ import { AVCLIENT_REGENERATE, withGeneratedBanner, } from "./banner.emitter.js";
2
4
  import { emitClientSurfaceModule } from "./client-surface.emitter.js";
3
5
  import { emitContractCarrierModule } from "./contract-carrier.emitter.js";
4
- import { emitDerivationModules } from "./derivation.emitter.js";
6
+ import { ACCESS_DERIVATION_ROOTS, DERIVATION_ROOTS, emitDerivationModules, } from "./derivation.emitter.js";
5
7
  import { emitDescriptorModule } from "./descriptor.emitter.js";
6
8
  import { CLIENT_ENTRY_FILE, GENERATED_DIRECTORY, } from "./emitted-tree.interface.js";
7
9
  import { emitEnumsModule } from "./enum.emitter.js";
@@ -12,9 +14,12 @@ import { emitNamedTypesModule } from "./named-type.emitter.js";
12
14
  import { emitDecimalModule, emitErrorsModule, emitMetadataModule, emitTransportModule, errorsModuleExports, } from "./runtime.emitter.js";
13
15
  import { emitScalarCodecModule } from "./scalar.codec.js";
14
16
  import { emitTransactionModules } from "./transaction.emitter.js";
15
- export function emitClientTree(contract, entrypoint, style) {
17
+ export function emitClientTree(contract, entrypoint, style, regenerate = AVCLIENT_REGENERATE, accessOrigins = []) {
18
+ refuseForeignAccessOrigin(contract, entrypoint, accessOrigins);
16
19
  const names = deriveEmittedNames(contract);
17
- const derivation = emitDerivationModules();
20
+ const derivation = emitDerivationModules(undefined, contract.access === undefined
21
+ ? DERIVATION_ROOTS
22
+ : [...DERIVATION_ROOTS, ...ACCESS_DERIVATION_ROOTS]);
18
23
  const generated = [
19
24
  ...derivation,
20
25
  emitClientSurfaceModule(contract, names, style),
@@ -24,8 +29,9 @@ export function emitClientTree(contract, entrypoint, style) {
24
29
  emitMetadataModule(contract.protocol, entrypoint),
25
30
  emitScalarCodecModule(style),
26
31
  emitDecimalModule(),
27
- emitErrorsModule(),
28
- emitTransportModule(style),
32
+ emitErrorsModule(regenerate),
33
+ emitTransportModule(style, contract.access !== undefined),
34
+ ...emitAccessModules(contract, style),
29
35
  emitNamedTypesModule(contract, names, style),
30
36
  ...(contract.transactions === "interactive"
31
37
  ? emitTransactionModules(style)
@@ -33,7 +39,10 @@ export function emitClientTree(contract, entrypoint, style) {
33
39
  ...emitFieldCheckModules(contract, style, new Set(derivation.map((module) => module.path))),
34
40
  ];
35
41
  const files = [
36
- { path: CLIENT_ENTRY_FILE, source: clientEntrySource(style) },
42
+ {
43
+ path: CLIENT_ENTRY_FILE,
44
+ source: clientEntrySource(style, accessVerbsOf(contract).length > 0),
45
+ },
37
46
  ...generated.map((module) => ({
38
47
  path: `${GENERATED_DIRECTORY}/${module.path}`,
39
48
  source: module.source,
@@ -44,15 +53,22 @@ export function emitClientTree(contract, entrypoint, style) {
44
53
  .sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0)
45
54
  .map((file) => ({
46
55
  path: file.path,
47
- bytes: encoder.encode(withGeneratedBanner(file.source)),
56
+ bytes: encoder.encode(withGeneratedBanner(file.source, regenerate)),
48
57
  }));
49
58
  return { tree, style, warnings: names.warnings };
50
59
  }
51
- function clientEntrySource(style) {
52
- const specifier = moduleSpecifierWriter(style);
53
- const from = (module) => specifier(`./${GENERATED_DIRECTORY}/${module.replace(/\.d\.ts$|\.ts$/, "")}`);
54
- return (`export { default } from ${from("client.ts")};\n` +
55
- `export {\n\tAvClient,\n\ttype AvClientOptions,\n\ttype AvCallOptions,\n} from ${from("client.ts")};\n` +
60
+ function clientEntrySource(style, accessVerbs) {
61
+ return (`export { default } from ${generatedModuleSpecifier(style, "client.ts")};\n` +
62
+ clientEntryReExports(style, "value", accessVerbs));
63
+ }
64
+ function generatedModuleSpecifier(style, module) {
65
+ return moduleSpecifierWriter(style)(`./${GENERATED_DIRECTORY}/${module.replace(/\.d\.ts$|\.ts$/, "")}`);
66
+ }
67
+ export function clientEntryReExports(style, clientClass = "value", accessVerbs = false) {
68
+ const from = (module) => generatedModuleSpecifier(style, module);
69
+ return ((clientClass === "value"
70
+ ? `export {\n\tAvClient,\n\ttype AvClientOptions,\n\ttype AvCallOptions,\n} from ${from("client.ts")};\n`
71
+ : `export type { AvClient, AvClientOptions, AvCallOptions } from ${from("client.ts")};\n`) +
56
72
  `export type { AvFetch } from ${from("runtime/transport.ts")};\n` +
57
73
  `export type { AvOperation } from ${from("derivation/transactions/deferred-operation.d.ts")};\n` +
58
74
  `export type * from ${from("types.d.ts")};\n` +
@@ -60,5 +76,20 @@ function clientEntrySource(style) {
60
76
  `export { AvDecimal } from ${from("runtime/decimal.ts")};\n` +
61
77
  `export {\n${errorsModuleExports()
62
78
  .map((name) => `\t${name},\n`)
63
- .join("")}} from ${from("runtime/errors.ts")};\n`);
79
+ .join("")}} from ${from("runtime/errors.ts")};\n` +
80
+ (accessVerbs
81
+ ? `export { AvAccessError } from ${from("runtime/access.ts")};\n`
82
+ : ""));
83
+ }
84
+ function refuseForeignAccessOrigin(contract, entrypoint, accessOrigins) {
85
+ const url = contract.access?.url;
86
+ if (url === undefined || url.startsWith("/")) {
87
+ return;
88
+ }
89
+ const origin = new URL(url).origin;
90
+ if (origin === entrypoint.deployment?.origin ||
91
+ accessOrigins.includes(origin)) {
92
+ return;
93
+ }
94
+ throw new AvClientConfigError(`the ClientContract's Aventara Access URL is on ${origin}, not on the entrypoint's origin; $access sends sign-in credentials there, so it is emitted only when avclient's framework.client.ts lists that origin in accessOrigins (a same-origin client from aventara-build --client takes none: mount Access on the app's own origin).`);
64
95
  }
@@ -5,8 +5,6 @@ export declare const DERIVATION_ROOTS: readonly string[];
5
5
  * (POSIX-separated); `undefined` when there is none.
6
6
  */
7
7
  export type DeclarationReader = (relative: string) => string | undefined;
8
- /** The published declarations of the `@aventara/core` this package resolves. */
9
- export declare function publishedCoreDeclarations(): DeclarationReader;
10
8
  /**
11
9
  * Core's declaration closure from {@link DERIVATION_ROOTS}, as modules under
12
10
  * `derivation/`, in UTF-16 code-unit order of their path.
@@ -14,4 +12,6 @@ export declare function publishedCoreDeclarations(): DeclarationReader;
14
12
  * @throws Error when the closure cannot be transported — a defect of the
15
13
  * installed `@aventara/core`, never of the consumer's input.
16
14
  */
17
- export declare function emitDerivationModules(read?: DeclarationReader): readonly EmittedModule[];
15
+ /** The roots a client with the `access` section copies besides {@link DERIVATION_ROOTS}. */
16
+ export declare const ACCESS_DERIVATION_ROOTS: readonly string[];
17
+ export declare function emitDerivationModules(read?: DeclarationReader, roots?: readonly string[]): readonly EmittedModule[];
@@ -13,7 +13,7 @@ export const DERIVATION_ROOTS = [
13
13
  "transactions/deferred-operation",
14
14
  "transactions/transaction-reference",
15
15
  ];
16
- export function publishedCoreDeclarations() {
16
+ function publishedCoreDeclarations() {
17
17
  const dist = path.dirname(fileURLToPath(import.meta.resolve("@aventara/core")));
18
18
  return (relative) => {
19
19
  try {
@@ -27,9 +27,12 @@ export function publishedCoreDeclarations() {
27
27
  }
28
28
  };
29
29
  }
30
- export function emitDerivationModules(read = publishedCoreDeclarations()) {
30
+ export const ACCESS_DERIVATION_ROOTS = [
31
+ "policy/access-verbs",
32
+ ];
33
+ export function emitDerivationModules(read = publishedCoreDeclarations(), roots = DERIVATION_ROOTS) {
31
34
  const copied = new Map();
32
- const queue = DERIVATION_ROOTS.map((root) => `${root}.d.ts`);
35
+ const queue = roots.map((root) => `${root}.d.ts`);
33
36
  while (queue.length > 0) {
34
37
  const file = queue.shift();
35
38
  if (copied.has(file)) {
@@ -8,7 +8,7 @@ export declare const LEGACY_CLIENT_ENTRY_FILES: readonly string[];
8
8
  /** The directory holding every other emitted module, under `generateAt`. */
9
9
  export declare const GENERATED_DIRECTORY = "generated";
10
10
  export type DerivationModulePath = `derivation/${string}.d.ts`;
11
- export type GeneratedModulePath = DerivationModulePath | "client.ts" | "contract.ts" | "enums.ts" | "metadata.ts" | "runtime/codec.ts" | "runtime/decimal.ts" | "runtime/descriptor.ts" | "runtime/errors.ts" | "runtime/field-check.evaluator.ts" | "runtime/field-validators.ts" | "runtime/fingerprint.ts" | "runtime/transaction.ts" | "runtime/transport.ts" | "types.d.ts";
11
+ export type GeneratedModulePath = DerivationModulePath | "client.ts" | "contract.ts" | "enums.ts" | "metadata.ts" | "runtime/access.ts" | "runtime/codec.ts" | "runtime/decimal.ts" | "runtime/descriptor.ts" | "runtime/errors.ts" | "runtime/field-check.evaluator.ts" | "runtime/field-validators.ts" | "runtime/fingerprint.ts" | "runtime/transaction.ts" | "runtime/transport.ts" | "types.d.ts";
12
12
  export declare const CONTRACT_CARRIER_MODULE = "contract.ts";
13
13
  /** A path in the emitted tree: relative to `generateAt`, POSIX-separated. */
14
14
  export type EmittedFilePath = typeof CLIENT_ENTRY_FILE | `${typeof GENERATED_DIRECTORY}/${GeneratedModulePath}`;
@@ -4,7 +4,7 @@ import { type ClientModuleStyle } from "./module-style.interface.js";
4
4
  /** Where `@aventara/core` publishes the evaluator's source, relative to its package root. */
5
5
  export declare const EVALUATOR_SOURCE = "src/runtime/field-check.evaluator.ts";
6
6
  /** Reads the evaluator's published source; `undefined` when core publishes none. */
7
- export type EvaluatorSourceReader = () => string | undefined;
7
+ type EvaluatorSourceReader = () => string | undefined;
8
8
  /** The evaluator's source as the `@aventara/core` this package resolves publishes it. */
9
9
  export declare function publishedCoreEvaluator(): EvaluatorSourceReader;
10
10
  /** Whether the ClientContract carries any check: a field's validators or a Json Shape node's. */
@@ -19,3 +19,4 @@ export declare function contractHasFieldChecks(contract: AvContractClient): bool
19
19
  * `@aventara/core`, never of the consumer's input.
20
20
  */
21
21
  export declare function emitFieldCheckModules(contract: AvContractClient, style: ClientModuleStyle, derivation: ReadonlySet<string>, read?: EvaluatorSourceReader): readonly EmittedModule[];
22
+ export {};
@@ -141,6 +141,7 @@ function rulesTableSource(contract) {
141
141
  function fieldValidatorsSource(contract, from) {
142
142
  return `import type { AvFieldCheck } from ${from("../derivation/contracts/field-check")};
143
143
  import type { JsonSchema } from ${from("../derivation/contracts/json-shape")};
144
+ import { RECORD_SITES, writeKeysOf } from ${from("./codec")};
144
145
  import { DECODE_TABLE } from ${from("./descriptor")};
145
146
  import { type AvCause, type AvValidationIssue, frameworkErrorOf } from ${from("./errors")};
146
147
  import {
@@ -456,21 +457,9 @@ function shapedValueIssues(schema: JsonSchema, list: boolean, value: unknown, pa
456
457
 
457
458
  // ---- field validators, judged as the server judges them after its field pipes ----
458
459
 
459
- /** Where each relation directive holds the records its target writes. */
460
- const RECORD_SITES: { readonly [directive: string]: readonly (string | undefined)[] } = {
461
- $create: [undefined],
462
- $connect: [],
463
- $connectOrCreate: ["create"],
464
- $disconnect: [],
465
- $set: [],
466
- $update: ["data"],
467
- $delete: [],
468
- $upsert: ["create", "update"],
469
- };
470
-
471
460
  /** The top-level data records of a create, update or upsert. */
472
461
  function writeRecordPaths(family: string, args: unknown): Path[] {
473
- const keys = family === "create" || family === "update" ? ["data"] : family === "upsert" ? ["create", "update"] : [];
462
+ const keys = writeKeysOf(family);
474
463
  const records: Path[] = [];
475
464
  if (!isPlainRecord(args)) {
476
465
  return records;
@@ -1,4 +1,4 @@
1
- export interface ScannedModule {
1
+ interface ScannedModule {
2
2
  /**
3
3
  * Every module specifier: a `from` clause's, a side-effect `import`'s, and an
4
4
  * `import()` type query's.
@@ -8,3 +8,4 @@ export interface ScannedModule {
8
8
  readonly references: boolean;
9
9
  }
10
10
  export declare function scanModuleSpecifiers(text: string): ScannedModule;
11
+ export {};
@@ -3,7 +3,7 @@ import type { AvContractClient, AvOperationFamily } from "@aventara/core";
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
- export interface ResourceNamedType {
6
+ interface ResourceNamedType {
7
7
  readonly suffix: string;
8
8
  readonly family: AvOperationFamily;
9
9
  readonly variant: string;
@@ -37,8 +37,6 @@ export interface EmittedNames {
37
37
  readonly properties: readonly EmittedProperty[];
38
38
  readonly warnings: readonly string[];
39
39
  }
40
- /** Whether `text` is an IdentifierName — the rule above, for every emitter. */
41
- export declare function isIdentifierName(text: string): boolean;
42
40
  /**
43
41
  * The emitted names of a Contract's enums and Resources, renamed where a name
44
42
  * cannot be declared as-is.
@@ -52,3 +50,4 @@ export declare class AvGeneratedNameError extends Error {
52
50
  constructor(problems: readonly string[]);
53
51
  }
54
52
  export declare function ownPropertyKey(value: string): string;
53
+ export {};
@@ -2,6 +2,8 @@ import { isOperationVariantAvailable } from "@aventara/core/internal";
2
2
  import { FRAMEWORK_ERROR_CLASS_NAMES } from "./runtime.emitter.js";
3
3
  export const RESERVED_EMITTED_NAMES = [
4
4
  ...new Set([
5
+ "AvAccessError",
6
+ "AvAccessMe",
5
7
  "AvClient",
6
8
  "AvClientOptions",
7
9
  "AvCallOptions",
@@ -46,6 +48,7 @@ export function namedTypesOf(operations) {
46
48
  return RESOURCE_NAMED_TYPES.filter((named) => isOperationVariantAvailable(families[named.family]?.[named.variant]));
47
49
  }
48
50
  export const CLIENT_MEMBER_NAMES = [
51
+ "$access",
49
52
  "__defineGetter__",
50
53
  "__defineSetter__",
51
54
  "__lookupGetter__",
@@ -63,7 +66,7 @@ export const CLIENT_MEMBER_NAMES = [
63
66
  "valueOf",
64
67
  ];
65
68
  const IDENTIFIER = /^[\p{ID_Start}$_][\p{ID_Continue}$\u200C\u200D]*$/u;
66
- export function isIdentifierName(text) {
69
+ function isIdentifierName(text) {
67
70
  return IDENTIFIER.test(text);
68
71
  }
69
72
  const RESERVED_WORDS = new Set([
@@ -1,3 +1,4 @@
1
+ import { jsonSchemaType } from "./json-shape.emitter.js";
1
2
  import { importSpecifier, } from "./module-style.interface.js";
2
3
  import { namedTypesOf } from "./name.deriver.js";
3
4
  export function emitNamedTypesModule(contract, names, style) {
@@ -12,12 +13,21 @@ export function emitNamedTypesModule(contract, names, style) {
12
13
  `export type ${identifier}${named.suffix} = ResourceArgument<${resource}, ${JSON.stringify(named.family)}, ${JSON.stringify(named.variant)}, ${JSON.stringify(named.argument)}>;\n`;
13
14
  });
14
15
  });
16
+ const me = contract.access?.verbs?.me?.result;
17
+ const identity = me === undefined
18
+ ? []
19
+ : [
20
+ "/** What `avClient.$access.me()` resolves when someone is signed in: the token identity. */\n" +
21
+ `export type AvAccessMe = ${jsonSchemaType(me)};\n`,
22
+ ];
15
23
  const uses = (helper) => declarations.some((declaration) => declaration.includes(` = ${helper}<`));
16
24
  const imported = ["ResourceArgument", "ResourceRecord"].filter(uses);
17
25
  return {
18
26
  path: "types.d.ts",
19
27
  source: declarations.length === 0
20
- ? "export {};\n"
21
- : `import type { ${imported.join(", ")} } from ${JSON.stringify(importSpecifier("./client", style))};\n\n${declarations.join("")}`,
28
+ ? identity.length === 0
29
+ ? "export {};\n"
30
+ : identity.join("")
31
+ : `import type { ${imported.join(", ")} } from ${JSON.stringify(importSpecifier("./client", style))};\n\n${declarations.join("")}${identity.join("")}`,
22
32
  };
23
33
  }
@@ -1,10 +1,11 @@
1
1
  import type { AvContractClient } from "@aventara/core";
2
- import type { ClientEntrypoint } from "../config/client-config.interface.js";
2
+ import type { EmittedEntrypoint } from "../config/client-config.interface.js";
3
+ import { type RegenerateCommand } from "./banner.emitter.js";
3
4
  import type { EmittedModule } from "./emitted-tree.interface.js";
4
5
  import { type ClientModuleStyle } from "./module-style.interface.js";
5
6
  export declare function emitDecimalModule(): EmittedModule;
6
- export declare function emitMetadataModule(protocol: AvContractClient["protocol"], entrypoint: ClientEntrypoint): EmittedModule;
7
+ export declare function emitMetadataModule(protocol: AvContractClient["protocol"], entrypoint: EmittedEntrypoint): EmittedModule;
7
8
  export declare const FRAMEWORK_ERROR_CLASS_NAMES: readonly string[];
8
9
  export declare function errorsModuleExports(): readonly string[];
9
- export declare function emitErrorsModule(): EmittedModule;
10
- export declare function emitTransportModule(style: ClientModuleStyle): EmittedModule;
10
+ export declare function emitErrorsModule(regenerate?: RegenerateCommand): EmittedModule;
11
+ export declare function emitTransportModule(style: ClientModuleStyle, access?: boolean): EmittedModule;
@@ -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);
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,
@@ -319,12 +364,58 @@ type Envelope =
319
364
  | { readonly outcome: "data"; readonly code: SuccessCode; readonly data: unknown }
320
365
  | { readonly outcome: "failure"; readonly code: AvOperationErrorCode; readonly cause: AvCause };
321
366
 
367
+ /** What a refusal of an operation's arguments says. */
368
+ const REFUSAL_MESSAGE = "Operation arguments failed framework validation.";
369
+
370
+ /** What an undefined value the request would leave out is told. */
371
+ const UNSENT_MESSAGE =
372
+ "Value must not be undefined: sent as JSON it would be left out, and the request would reach more rows.";
373
+
374
+ /**
375
+ * Refuses arguments that hold an undefined value whose leaving out would reach more
376
+ * rows than the caller wrote (in a filter, a unique selector or a cursor, or a where,
377
+ * cursor, limit or offset left undefined), before anything is sent: JSON would drop
378
+ * it. Throws the AvValidationError (A2004) the server answers when it is handed such
379
+ * a value: one issue at each, with the server's code, its path under \`at\`
380
+ * (["arguments"] for a call, ["operations", i, "args"] for a plan step, whose index
381
+ * the cause names).
382
+ */
383
+ export function refuseUnsentValues(
384
+ resource: string,
385
+ family: string,
386
+ variant: string,
387
+ args: unknown,
388
+ at: readonly (string | number)[],
389
+ operation?: number,
390
+ ): void {
391
+ const unsent = unsentValues(DECODE_TABLE, resource, family, variant, args);
392
+ if (unsent.length === 0) {
393
+ return;
394
+ }
395
+ const issues: readonly AvValidationIssue[] = Object.freeze(
396
+ unsent.map(({ path, code }) =>
397
+ Object.freeze({ path: Object.freeze([...at, ...path]), code, message: UNSENT_MESSAGE }),
398
+ ),
399
+ );
400
+ throw frameworkErrorOf(
401
+ "A2004",
402
+ Object.freeze(
403
+ operation === undefined ? { message: REFUSAL_MESSAGE, issues } : { message: REFUSAL_MESSAGE, issues, operation },
404
+ ),
405
+ );
406
+ }
407
+
322
408
  /**
323
409
  * Runs one operation: POSTs its arguments and settles its envelope.
324
410
  *
411
+ * @param check the client's own checks of the arguments, run before anything is sent
412
+ * (the ClientContract's field validators, when it has any).
325
413
  * @returns the envelope's data when the code is A1xxx; null for A1001.
326
414
  * @throws the AvError subclass of any other code; AvTransportError when no
327
- * framework envelope arrived; TypeError, before anything is sent, when the
415
+ * framework envelope arrived; AvValidationError (A2004), before anything is sent,
416
+ * when the arguments hold an undefined value JSON would leave out to widen the
417
+ * request (see refuseUnsentValues), or when
418
+ * \`check\` refuses the arguments; TypeError, before anything is sent, when the
328
419
  * arguments are not a JSON-safe object or a request header is the framework's.
329
420
  * A caller's abort rejects with what fetch rejected with.
330
421
  */
@@ -335,7 +426,10 @@ export async function execute(
335
426
  variant: string,
336
427
  args: Readonly<Record<string, unknown>>,
337
428
  options: AvCallOptions = {},
429
+ check?: (resource: string, family: string, args: unknown) => void,
338
430
  ): Promise<unknown> {
431
+ refuseUnsentValues(resource, family, variant, args, ["arguments"]);
432
+ check?.(resource, family, args);
339
433
  const operation = JSON.stringify(resource) + " " + family + "." + variant;
340
434
  const headers = requestHeaders(options);
341
435
  const body = serializeWireBody(encodeArguments(argumentObject(args)));