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

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 CHANGED
@@ -1,5 +1,64 @@
1
1
  # @aventara/client
2
2
 
3
+ ## 1.0.0-rc.3
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [b38034d]
8
+ - Updated dependencies [b38034d]
9
+ - Updated dependencies [b38034d]
10
+ - Updated dependencies [b38034d]
11
+ - Updated dependencies [b38034d]
12
+ - Updated dependencies [b38034d]
13
+ - @aventara/core@1.0.0-rc.3
14
+
15
+ ## 1.0.0-rc.2
16
+
17
+ ### Patch Changes
18
+
19
+ - The optional `typescript` peer range is `>=5.7.0` (it was `>=5.5.0`), the lowest TypeScript Aventara's types
20
+ compile under. `avclient generate` type-checks the client with TypeScript 5.7 or later; under an older one it parses
21
+ the output with Node's TypeScript parser (`checked: syntax`) and says so in one warning, as it did below 5.5.
22
+ - A generated client sends an `undefined` array element (or a hole) as `null`, as `JSON.stringify` writes it, and its
23
+ `json` codec leaves out a key holding `undefined` and writes such an element as `null`. A `json` value
24
+ `{ a: undefined, b: [undefined] }` is sent as `{"b":[null]}`, the value the server stores when an in-process caller
25
+ passes it. In rc.1 the client refused the array element with a `TypeError` before sending. Regenerate the client.
26
+ - A generated client's `json` values admit a key holding `undefined` in their type, as core's `AvJsonValue` now does,
27
+ so an object with optional properties can be passed where a `json` value is taken; the key is not sent. In rc.1 it
28
+ was a compile error. Regenerate the client.
29
+ - A generated client refuses at compile time a filter `mode` or `path` that qualifies no operator: one alone, or one
30
+ whose only neighbour is a `not` holding operators (`{ mode: "insensitive", not: { contains: "x" } }`). Write it
31
+ inside the `not`. The server refuses the same filters (`A2004`, `V1001`). Regenerate the client.
32
+ - A generated client types a filter's `mode` by the field: `"default"` always, and `"sensitive"` or `"insensitive"`
33
+ only where the ClientContract's `filterModes` lists them; any other mode is a compile error. On a `json` field an
34
+ operator object that names `path` takes the modes `pathFilterModes` lists instead, so on Postgres
35
+ `where: { meta: { stringContains: "x", mode: "insensitive" } }` is a compile error and
36
+ `where: { meta: { path: ["label"], stringContains: "x", mode: "insensitive" } }` compiles. `isEmpty` is typed as a boolean, `path` as an array of strings and a `json` field's `string*`
37
+ operand as a string. The server refuses the same values (`A2004`), so the client adds no runtime check of its own.
38
+ Regenerate the client: the Contract hash changes.
39
+ - The generated client refuses an `undefined` value in a filter before anything is sent. JSON drops `undefined`, so
40
+ `avClient.Post.update.many({ where: { id: undefined }, data })` reached the server as `where: {}` and updated every
41
+ row; the same went for `find`, `count`, `delete` and a transaction step. Now an `undefined` anywhere inside a
42
+ `where` (`AND`, `OR`, `NOT`, relation filters, operator objects such as `{ equals: undefined }` or
43
+ `{ isEmpty: undefined }`, `in` lists), a `cursor`, a unique selector, the `where` or `cursor` of a relation in
44
+ `select` or `include`, or a nested write's selectors (`$connect`, `$disconnect`, `$set`, `$delete`, the `where` of
45
+ `$update`, `$upsert` and `$connectOrCreate`) throws `AvValidationError` (`A2004`) with a `V1001` issue at the path
46
+ the server uses (`["arguments", "where", "id"]`, or `["operations", 0, "args", "where", "id"]` with the step's
47
+ `operation` for a transaction), and no request is made. A `where`, `cursor`, `limit` or `offset` that is itself
48
+ `undefined` is refused the same way, with the server's code (`V1001`; `V1007` for a unique selector, `V1009` for a
49
+ cursor), at the top level, in a relation under `select` or `include`, and in a nested write's `where`. Leave the
50
+ key out to mean no filter. Write data is unchanged: an `undefined` field there is not sent. Regenerate the client
51
+ to get the check.
52
+ - `avclient generate` in a directory with no config names the extensions it looks for as the docs list them:
53
+ `no framework.client.ts, .mts, .cts, .js, .mjs or .cjs in <directory>; …`. In rc.1 it read
54
+ `no framework.client.{js,ts,mjs,cjs,mts,cts} in <directory>`. `avclient --help` and `avclient generate --help` list
55
+ them in the same order: `framework.client.ts (or .mts, .cts, .js, .mjs, .cjs)`. Which files are read is unchanged.
56
+ - Updated dependencies [9366e16]
57
+ - Updated dependencies [9366e16]
58
+ - Updated dependencies [9366e16]
59
+ - Updated dependencies [9366e16]
60
+ - @aventara/core@1.0.0-rc.2
61
+
3
62
  ## 1.0.0-rc.1
4
63
 
5
64
  ### Patch Changes
package/README.md CHANGED
@@ -96,7 +96,7 @@ A failed run leaves the previous output exactly as it was. The whole tree is wri
96
96
  `generateAt`, validated there as one program, and only then moved into place. A run killed part-way is repaired by the
97
97
  next run that writes, before it writes anything. A first run that fails removes the directories it created.
98
98
 
99
- The validate step is a real type check when your project has the optional peer `typescript` (5.5 to 6). It is resolved
99
+ The validate step is a real type check when your project has the optional peer `typescript` (5.7 to 6). It is resolved
100
100
  from your project, so `npx @aventara/client@rc init` type-checks too, under a consumer's strictest plausible settings
101
101
  and with nothing outside the tree resolvable. Without it, or under TypeScript 7 (which has no classic compiler API to
102
102
  check with), the check falls back to a parse with Node's own TypeScript parser and says so in one warning. It is never
@@ -1,5 +1,5 @@
1
- export declare const USAGE = "avclient \u2014 generate a typed Aventara client from a deployed ClientContract.\n\nUsage:\n avclient generate [--yes] Read framework.client.ts (or .js, .mjs, .cjs, .mts,\n .cts) in the current directory, fetch\n <entrypoint>/_contract, and write AvClient.ts and\n generated/ into its generateAt directory, its imports\n spelled as the project's tsconfig.json says.\n avclient init [options] Set up this frontend: write framework.client.ts, the\n .env entry, the avclient:generate script and the\n @aventara/client devDependency; install; generate.\n --entrypoint <url> The server's entrypoint [http://localhost:3000/api].\n --env-var <NAME> Read it from this variable [AVENTARA_API_URL].\n --no-env-var Write it into framework.client.ts as a literal.\n --generate-at <dir> Where the client goes [./src/api].\n --package-manager <npm|pnpm> [the lockfile's, else the launching one, else npm]\n --skip-install Print the install instead of running it.\n --skip-generate Do not generate now.\n -y, --yes Accept every default, and replace differing content.\n avclient --help Print this and exit 0.\n avclient <command> --help Print that command's usage and exit 0.\n avclient --version, -v Print this generator's version and exit 0.\n\nOptions:\n -y, --yes Overwrite or remove content in AvClient.ts and generated/ that the\n generator did not produce, without asking. Without it, such content\n is listed and you are asked; where nobody can answer (stdin is not a\n terminal, as in CI), the run is refused and nothing is touched.\n\nThe generator owns AvClient.ts and generated/ in generateAt, and nothing else\nthere: your own files beside them are never read, moved or removed. Generated\nfiles are replaced on every run; do not edit them.\n\nBefore framework.client.ts is evaluated, the .env cascade is read from the\ncurrent directory, highest precedence first: the process environment,\n.env.<mode>.local, .env.<mode>, .env.local, .env \u2014 where mode is NODE_ENV, or\n\"development\".\n";
2
- export declare const GENERATE_USAGE = "Usage: avclient generate [--yes]\n\nRead framework.client.ts (or .js, .mjs, .cjs, .mts, .cts) in the current directory,\nfetch <entrypoint>/_contract, and write AvClient.ts and generated/ into its\ngenerateAt directory, its imports spelled as the project's tsconfig.json says.\n\nOptions:\n -y, --yes Overwrite or remove content in AvClient.ts and generated/ that the\n generator did not produce, without asking. Without it, such content\n is listed and you are asked; where nobody can answer (stdin is not a\n terminal, as in CI), the run is refused and nothing is touched.\n\nThe generator owns AvClient.ts and generated/ in generateAt, and nothing else\nthere: your own files beside them are never read, moved or removed.\n\nBefore the config is evaluated, the .env cascade is read from the current\ndirectory, highest precedence first: the process environment, .env.<mode>.local,\n.env.<mode>, .env.local, .env \u2014 where mode is NODE_ENV, or \"development\".\n";
1
+ export declare const USAGE = "avclient \u2014 generate a typed Aventara client from a deployed ClientContract.\n\nUsage:\n avclient generate [--yes] Read framework.client.ts (or .mts, .cts, .js, .mjs,\n .cjs) in the current directory, fetch\n <entrypoint>/_contract, and write AvClient.ts and\n generated/ into its generateAt directory, its imports\n spelled as the project's tsconfig.json says.\n avclient init [options] Set up this frontend: write framework.client.ts, the\n .env entry, the avclient:generate script and the\n @aventara/client devDependency; install; generate.\n --entrypoint <url> The server's entrypoint [http://localhost:3000/api].\n --env-var <NAME> Read it from this variable [AVENTARA_API_URL].\n --no-env-var Write it into framework.client.ts as a literal.\n --generate-at <dir> Where the client goes [./src/api].\n --package-manager <npm|pnpm> [the lockfile's, else the launching one, else npm]\n --skip-install Print the install instead of running it.\n --skip-generate Do not generate now.\n -y, --yes Accept every default, and replace differing content.\n avclient --help Print this and exit 0.\n avclient <command> --help Print that command's usage and exit 0.\n avclient --version, -v Print this generator's version and exit 0.\n\nOptions:\n -y, --yes Overwrite or remove content in AvClient.ts and generated/ that the\n generator did not produce, without asking. Without it, such content\n is listed and you are asked; where nobody can answer (stdin is not a\n terminal, as in CI), the run is refused and nothing is touched.\n\nThe generator owns AvClient.ts and generated/ in generateAt, and nothing else\nthere: your own files beside them are never read, moved or removed. Generated\nfiles are replaced on every run; do not edit them.\n\nBefore framework.client.ts is evaluated, the .env cascade is read from the\ncurrent directory, highest precedence first: the process environment,\n.env.<mode>.local, .env.<mode>, .env.local, .env \u2014 where mode is NODE_ENV, or\n\"development\".\n";
2
+ export declare const GENERATE_USAGE = "Usage: avclient generate [--yes]\n\nRead framework.client.ts (or .mts, .cts, .js, .mjs, .cjs) in the current directory,\nfetch <entrypoint>/_contract, and write AvClient.ts and generated/ into its\ngenerateAt directory, its imports spelled as the project's tsconfig.json says.\n\nOptions:\n -y, --yes Overwrite or remove content in AvClient.ts and generated/ that the\n generator did not produce, without asking. Without it, such content\n is listed and you are asked; where nobody can answer (stdin is not a\n terminal, as in CI), the run is refused and nothing is touched.\n\nThe generator owns AvClient.ts and generated/ in generateAt, and nothing else\nthere: your own files beside them are never read, moved or removed.\n\nBefore the config is evaluated, the .env cascade is read from the current\ndirectory, highest precedence first: the process environment, .env.<mode>.local,\n.env.<mode>, .env.local, .env \u2014 where mode is NODE_ENV, or \"development\".\n";
3
3
  export declare const INIT_USAGE = "Usage: avclient init [options]\n\nSet up this frontend: write framework.client.ts, the .env entry, the avclient:generate\nscript and the @aventara/client devDependency; install; generate.\n\nOptions:\n --entrypoint <url> The server's entrypoint [http://localhost:3000/api].\n --env-var <NAME> Read it from this variable [AVENTARA_API_URL].\n --no-env-var Write it into framework.client.ts as a literal.\n --generate-at <dir> Where the client goes [./src/api].\n --package-manager <npm|pnpm> [the lockfile's, else the launching one, else npm]\n --skip-install Print the install instead of running it.\n --skip-generate Do not generate now.\n --verbose Show the install's output as it runs.\n -y, --yes Accept every default, and replace differing content.\n\nOn a terminal every unanswered question is asked; anywhere else, pass its flag\nor --yes, or the run stops before writing anything.\n";
4
4
  export type ClientInitCommand = {
5
5
  readonly command: "init";
@@ -2,8 +2,8 @@ import { CLIENT_CONFIG_FILE } from "../config/config.loader.js";
2
2
  export const USAGE = `avclient — generate a typed Aventara client from a deployed ClientContract.
3
3
 
4
4
  Usage:
5
- avclient generate [--yes] Read ${CLIENT_CONFIG_FILE} (or .js, .mjs, .cjs, .mts,
6
- .cts) in the current directory, fetch
5
+ avclient generate [--yes] Read ${CLIENT_CONFIG_FILE} (or .mts, .cts, .js, .mjs,
6
+ .cjs) in the current directory, fetch
7
7
  <entrypoint>/_contract, and write AvClient.ts and
8
8
  generated/ into its generateAt directory, its imports
9
9
  spelled as the project's tsconfig.json says.
@@ -39,7 +39,7 @@ current directory, highest precedence first: the process environment,
39
39
  `;
40
40
  export const GENERATE_USAGE = `Usage: avclient generate [--yes]
41
41
 
42
- Read ${CLIENT_CONFIG_FILE} (or .js, .mjs, .cjs, .mts, .cts) in the current directory,
42
+ Read ${CLIENT_CONFIG_FILE} (or .mts, .cts, .js, .mjs, .cjs) in the current directory,
43
43
  fetch <entrypoint>/_contract, and write AvClient.ts and generated/ into its
44
44
  generateAt directory, its imports spelled as the project's tsconfig.json says.
45
45
 
@@ -1,6 +1,12 @@
1
1
  import type { AvClientConfigInput } from "./client-config.interface.js";
2
2
  /** Prisma's `SUPPORTED_EXTENSIONS`, in its order. */
3
3
  export declare const CLIENT_CONFIG_FILE_EXTENSIONS: readonly [".js", ".ts", ".mjs", ".cjs", ".mts", ".cts"];
4
+ /**
5
+ * The same extensions as the docs and `avclient --help` list them, TypeScript
6
+ * first: the order a sentence to the developer names them in. Lookup keeps
7
+ * {@link CLIENT_CONFIG_FILE_EXTENSIONS}' order, Prisma's.
8
+ */
9
+ export declare const CLIENT_CONFIG_FILE_EXTENSIONS_AS_LISTED: readonly [".ts", ".mts", ".cts", ".js", ".mjs", ".cjs"];
4
10
  /** The config's name, before its extension. */
5
11
  export declare const CLIENT_CONFIG_BASENAME = "framework.client";
6
12
  /** The config file `avclient init` writes when the project has none. */
@@ -9,6 +9,14 @@ export const CLIENT_CONFIG_FILE_EXTENSIONS = [
9
9
  ".mts",
10
10
  ".cts",
11
11
  ];
12
+ export const CLIENT_CONFIG_FILE_EXTENSIONS_AS_LISTED = [
13
+ ".ts",
14
+ ".mts",
15
+ ".cts",
16
+ ".js",
17
+ ".mjs",
18
+ ".cjs",
19
+ ];
12
20
  export const CLIENT_CONFIG_BASENAME = "framework.client";
13
21
  export const CLIENT_CONFIG_FILE = `${CLIENT_CONFIG_BASENAME}.ts`;
14
22
  export function clientConfigFilesIn(directory) {
@@ -27,7 +35,8 @@ export function findClientConfigFile(directory) {
27
35
  export async function loadClientConfigFile(directory) {
28
36
  const name = findClientConfigFile(directory);
29
37
  if (name === undefined) {
30
- throw new AvClientConfigError(`no ${CLIENT_CONFIG_BASENAME}.{${CLIENT_CONFIG_FILE_EXTENSIONS.map((extension) => extension.slice(1)).join(",")}} in ${path.resolve(directory)}; create ${CLIENT_CONFIG_FILE} with ` +
38
+ const [first, ...others] = CLIENT_CONFIG_FILE_EXTENSIONS_AS_LISTED;
39
+ throw new AvClientConfigError(`no ${CLIENT_CONFIG_BASENAME}${first}, ${others.slice(0, -1).join(", ")} or ${others.at(-1)} in ${path.resolve(directory)}; create ${CLIENT_CONFIG_FILE} with ` +
31
40
  "`export default defineClientConfig({ entrypoint, generateAt })` (or run `avclient init`) and run the generator from its directory.");
32
41
  }
33
42
  const file = path.resolve(directory, name);
@@ -134,10 +134,8 @@ export function emitClientSurfaceModule(contract, names, style) {
134
134
  "\t\tfor (const [resource, families] of operationTree(\n" +
135
135
  "\t\t\t(resource, family, variant): OperationMethod =>\n" +
136
136
  (checked
137
- ? "\t\t\t\tasync (args = {}, callOptions = {}) => {\n" +
138
- "\t\t\t\t\tcheckOperation(resource, family, args);\n" +
139
- "\t\t\t\t\treturn execute(connect(), resource, family, variant, args, callOptions);\n" +
140
- "\t\t\t\t},\n"
137
+ ? "\t\t\t\tasync (args = {}, callOptions = {}) =>\n" +
138
+ "\t\t\t\t\texecute(connect(), resource, family, variant, args, callOptions, checkOperation),\n"
141
139
  : "\t\t\t\tasync (args = {}, callOptions = {}) =>\n" +
142
140
  "\t\t\t\t\texecute(connect(), resource, family, variant, args, callOptions),\n") +
143
141
  "\t\t)) {\n" +
@@ -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;
@@ -209,7 +209,7 @@ const IDENTITY_HEADERS = (() => {
209
209
  })();
210
210
  function transportModuleSource(from) {
211
211
  return `import { CLIENT_CONTRACT_HASH, PROTOCOL_VERSION } from ${from("../metadata")};
212
- import { decodeResult, encodeArguments, readArgument, serializeWireBody } from ${from("./codec")};
212
+ import { decodeResult, encodeArguments, readArgument, serializeWireBody, unsentValues } from ${from("./codec")};
213
213
  import { DECODE_TABLE } from ${from("./descriptor")};
214
214
  import {
215
215
  type AvCause,
@@ -319,12 +319,58 @@ type Envelope =
319
319
  | { readonly outcome: "data"; readonly code: SuccessCode; readonly data: unknown }
320
320
  | { readonly outcome: "failure"; readonly code: AvOperationErrorCode; readonly cause: AvCause };
321
321
 
322
+ /** What a refusal of an operation's arguments says. */
323
+ const REFUSAL_MESSAGE = "Operation arguments failed framework validation.";
324
+
325
+ /** What an undefined value the request would leave out is told. */
326
+ const UNSENT_MESSAGE =
327
+ "Value must not be undefined: sent as JSON it would be left out, and the request would reach more rows.";
328
+
329
+ /**
330
+ * Refuses arguments that hold an undefined value whose leaving out would reach more
331
+ * rows than the caller wrote (in a filter, a unique selector or a cursor, or a where,
332
+ * cursor, limit or offset left undefined), before anything is sent: JSON would drop
333
+ * it. Throws the AvValidationError (A2004) the server answers when it is handed such
334
+ * a value: one issue at each, with the server's code, its path under \`at\`
335
+ * (["arguments"] for a call, ["operations", i, "args"] for a plan step, whose index
336
+ * the cause names).
337
+ */
338
+ export function refuseUnsentValues(
339
+ resource: string,
340
+ family: string,
341
+ variant: string,
342
+ args: unknown,
343
+ at: readonly (string | number)[],
344
+ operation?: number,
345
+ ): void {
346
+ const unsent = unsentValues(DECODE_TABLE, resource, family, variant, args);
347
+ if (unsent.length === 0) {
348
+ return;
349
+ }
350
+ const issues: readonly AvValidationIssue[] = Object.freeze(
351
+ unsent.map(({ path, code }) =>
352
+ Object.freeze({ path: Object.freeze([...at, ...path]), code, message: UNSENT_MESSAGE }),
353
+ ),
354
+ );
355
+ throw frameworkErrorOf(
356
+ "A2004",
357
+ Object.freeze(
358
+ operation === undefined ? { message: REFUSAL_MESSAGE, issues } : { message: REFUSAL_MESSAGE, issues, operation },
359
+ ),
360
+ );
361
+ }
362
+
322
363
  /**
323
364
  * Runs one operation: POSTs its arguments and settles its envelope.
324
365
  *
366
+ * @param check the client's own checks of the arguments, run before anything is sent
367
+ * (the ClientContract's field validators, when it has any).
325
368
  * @returns the envelope's data when the code is A1xxx; null for A1001.
326
369
  * @throws the AvError subclass of any other code; AvTransportError when no
327
- * framework envelope arrived; TypeError, before anything is sent, when the
370
+ * framework envelope arrived; AvValidationError (A2004), before anything is sent,
371
+ * when the arguments hold an undefined value JSON would leave out to widen the
372
+ * request (see refuseUnsentValues), or when
373
+ * \`check\` refuses the arguments; TypeError, before anything is sent, when the
328
374
  * arguments are not a JSON-safe object or a request header is the framework's.
329
375
  * A caller's abort rejects with what fetch rejected with.
330
376
  */
@@ -335,7 +381,10 @@ export async function execute(
335
381
  variant: string,
336
382
  args: Readonly<Record<string, unknown>>,
337
383
  options: AvCallOptions = {},
384
+ check?: (resource: string, family: string, args: unknown) => void,
338
385
  ): Promise<unknown> {
386
+ refuseUnsentValues(resource, family, variant, args, ["arguments"]);
387
+ check?.(resource, family, args);
339
388
  const operation = JSON.stringify(resource) + " " + family + "." + variant;
340
389
  const headers = requestHeaders(options);
341
390
  const body = serializeWireBody(encodeArguments(argumentObject(args)));
@@ -231,13 +231,18 @@ function copyJson(side: Side, value: unknown, path: Path, ancestors: Set<object>
231
231
  if (Array.isArray(value)) {
232
232
  const items: unknown[] = [];
233
233
  for (let index = 0; index < value.length; index += 1) {
234
- items.push(copyJson(side, value[index], [...path, index], ancestors));
234
+ // An undefined element (or a hole) is null, as JSON writes it (rc.2).
235
+ const item = value[index];
236
+ items.push(item === undefined ? null : copyJson(side, item, [...path, index], ancestors));
235
237
  }
236
238
  copy = items;
237
239
  } else {
238
- // fromEntries defines each key, so "__proto__" stays an own key.
240
+ // fromEntries defines each key, so "__proto__" stays an own key. A key holding
241
+ // undefined is left out, as JSON leaves it out (rc.2).
239
242
  copy = Object.fromEntries(
240
- Object.entries(value).map(([key, member]) => [key, copyJson(side, member, [...path, key], ancestors)]),
243
+ Object.entries(value)
244
+ .filter(([, member]) => member !== undefined)
245
+ .map(([key, member]) => [key, copyJson(side, member, [...path, key], ancestors)]),
241
246
  );
242
247
  }
243
248
  ancestors.delete(value);
@@ -271,8 +276,10 @@ function writeWireJson(value: unknown, path: Path, ancestors: Set<object>): stri
271
276
  if (Array.isArray(value)) {
272
277
  const items: string[] = [];
273
278
  for (let index = 0; index < value.length; index += 1) {
274
- // An undefined element is refused, not stripped: JSON would make it null.
275
- items.push(writeWireJson(value[index], [...path, index], ancestors));
279
+ // An undefined element (or a hole) is null, as JSON writes it (rc.2): the
280
+ // server reads the same value from an in-process caller.
281
+ const item = value[index];
282
+ items.push(item === undefined ? "null" : writeWireJson(item, [...path, index], ancestors));
276
283
  }
277
284
  text = "[" + items.join(",") + "]";
278
285
  } else {
@@ -310,7 +317,8 @@ export function decodeScalar(scalar: AvBuiltInScalar, wire: unknown): unknown {
310
317
  /**
311
318
  * The JSON text of a request body whose scalars are already encoded. Every
312
319
  * undefined property is stripped, at every depth (an optional property is
313
- * omitted, never sent), and null is kept as the explicit value it is.
320
+ * omitted, never sent), an undefined array element is null, as JSON.stringify
321
+ * writes both, and null is kept as the explicit value it is.
314
322
  * Anything else that is not JSON-safe is refused rather than coerced: it means a
315
323
  * value escaped its scalar codec.
316
324
  */
@@ -347,6 +355,215 @@ export function readArgument<T>(path: Path, read: () => T): T {
347
355
  }
348
356
  }
349
357
 
358
+ /** Where each relation directive holds the records its target writes; its other members select. */
359
+ export const RECORD_SITES: { readonly [directive: string]: readonly (string | undefined)[] } = {
360
+ $create: [undefined],
361
+ $connect: [],
362
+ $connectOrCreate: ["create"],
363
+ $disconnect: [],
364
+ $set: [],
365
+ $update: ["data"],
366
+ $delete: [],
367
+ $upsert: ["create", "update"],
368
+ };
369
+
370
+ /** The members of an operation's arguments that hold its write records. */
371
+ export function writeKeysOf(family: string): readonly string[] {
372
+ return family === "create" || family === "update" ? ["data"] : family === "upsert" ? ["create", "update"] : [];
373
+ }
374
+
375
+ /** The code an unsent value is refused with: the server's for that place. */
376
+ export type UnsentCode = "V1001" | "V1007" | "V1009";
377
+
378
+ /** One value a request would leave out: where it is, and the server's code for it. */
379
+ export interface UnsentValue {
380
+ readonly path: Path;
381
+ readonly code: UnsentCode;
382
+ }
383
+
384
+ /**
385
+ * Every undefined value an operation's arguments hold where leaving it out would
386
+ * reach more rows than the caller wrote, in argument order, each with the code the
387
+ * server answers it with:
388
+ *
389
+ * - inside a filter, a unique selector or a cursor, at any depth: a where (AND, OR,
390
+ * NOT, a relation filter, an operator object, an in list), a cursor, the where and
391
+ * cursor of a relation that select or include reads, and a nested write's
392
+ * selectors (what $connect, $disconnect, $set and $delete name, and the where of
393
+ * $update, $upsert and $connectOrCreate) (V1001);
394
+ * - a where, cursor, limit or offset that is itself undefined, as a call's or a
395
+ * relation's argument (V1001; a unique variant's where V1007, a cursor V1009),
396
+ * and the where of $update (V1001), $upsert or $connectOrCreate (V1007).
397
+ *
398
+ * JSON drops an undefined value, so such a value is never sent. An undefined field
399
+ * of a write record is not sent, which is what leaving it out means; a projection,
400
+ * an order or a directive left undefined is left out as well.
401
+ */
402
+ export function unsentValues(
403
+ table: DecodeTable,
404
+ resource: string,
405
+ family: string,
406
+ variant: string,
407
+ args: unknown,
408
+ ): UnsentValue[] {
409
+ const found: UnsentValue[] = [];
410
+ const ancestors = new Set<object>();
411
+
412
+ const listAt = (value: unknown, path: Path): readonly unknown[] | undefined =>
413
+ typeof value === "object" && value !== null && readArgument(path, () => Array.isArray(value))
414
+ ? (value as readonly unknown[])
415
+ : undefined;
416
+ const recordAt = (value: unknown, path: Path): Readonly<Record<string, unknown>> | undefined =>
417
+ typeof value === "object" && value !== null && readArgument(path, () => !Array.isArray(value) && isPlainRecord(value))
418
+ ? (value as Readonly<Record<string, unknown>>)
419
+ : undefined;
420
+
421
+ // Visits each member of a container in order (each entry of a list, each key of a
422
+ // record), undefined ones only when asked; a container met again on its own
423
+ // ancestry is not walked again, and the encoder refuses it.
424
+ const each = (
425
+ container: readonly unknown[] | Readonly<Record<string, unknown>>,
426
+ path: Path,
427
+ withUndefined: boolean,
428
+ visit: (key: string | number, value: unknown, at: Path) => void,
429
+ ): void => {
430
+ if (ancestors.has(container)) {
431
+ return;
432
+ }
433
+ ancestors.add(container);
434
+ const keys: readonly (string | number)[] = Array.isArray(container)
435
+ ? Array.from({ length: readArgument(path, () => container.length) }, (_, index) => index)
436
+ : readArgument(path, () => Object.keys(container));
437
+ for (const key of keys) {
438
+ const at = [...path, key];
439
+ const value = readArgument(at, () => (container as Readonly<Record<string | number, unknown>>)[key]);
440
+ if (withUndefined || value !== undefined) {
441
+ visit(key, value, at);
442
+ }
443
+ }
444
+ ancestors.delete(container);
445
+ };
446
+ // One entry, or each entry of a list.
447
+ const entries = (value: unknown, path: Path, visit: (entry: unknown, at: Path) => void): void => {
448
+ const list = listAt(value, path);
449
+ if (list === undefined) {
450
+ visit(value, path);
451
+ } else {
452
+ each(list, path, true, (_index, entry, at) => visit(entry, at));
453
+ }
454
+ };
455
+
456
+ const filter = (value: unknown, path: Path): void => {
457
+ if (value === undefined) {
458
+ found.push({ path, code: "V1001" });
459
+ return;
460
+ }
461
+ const container = listAt(value, path) ?? recordAt(value, path);
462
+ if (container !== undefined) {
463
+ each(container, path, true, (_key, member, at) => filter(member, at));
464
+ }
465
+ };
466
+ // A read's own arguments (a call's, or a relation's under select or include):
467
+ // its where and cursor are filters, its limit and offset bound what it returns.
468
+ const read = (key: string | number, value: unknown, at: Path, whereCode: UnsentCode, cursorCode: UnsentCode): void => {
469
+ if (key === "where" || key === "cursor") {
470
+ if (value === undefined) {
471
+ found.push({ path: at, code: key === "where" ? whereCode : cursorCode });
472
+ } else {
473
+ filter(value, at);
474
+ }
475
+ } else if (key === "limit" || key === "offset") {
476
+ if (value === undefined) {
477
+ found.push({ path: at, code: "V1001" });
478
+ }
479
+ } else if ((key === "select" || key === "include") && value !== undefined) {
480
+ projection(value, at);
481
+ }
482
+ };
483
+ const projection = (value: unknown, path: Path): void => {
484
+ const list = listAt(value, path);
485
+ if (list === undefined) {
486
+ return;
487
+ }
488
+ each(list, path, false, (_index, entry, at) => {
489
+ const relations = recordAt(entry, at);
490
+ if (relations === undefined) {
491
+ return;
492
+ }
493
+ each(relations, at, false, (_relation, nested, nestedAt) => {
494
+ const args = recordAt(nested, nestedAt);
495
+ if (args !== undefined) {
496
+ each(args, nestedAt, true, (key, member, memberAt) => read(key, member, memberAt, "V1001", "V1009"));
497
+ }
498
+ });
499
+ });
500
+ };
501
+ const writeRecord = (target: string, value: unknown, path: Path): void => {
502
+ const record = recordAt(value, path);
503
+ if (record === undefined) {
504
+ return;
505
+ }
506
+ const fields = Object.hasOwn(table, target) ? table[target] : undefined;
507
+ each(record, path, false, (key, member, at) => {
508
+ const decoding = fields !== undefined && Object.hasOwn(fields, key) ? fields[key as string] : undefined;
509
+ if (typeof decoding === "object") {
510
+ relationWrite(decoding.relation, member, at);
511
+ }
512
+ });
513
+ };
514
+ const relationWrite = (target: string, value: unknown, path: Path): void => {
515
+ const directives = recordAt(value, path);
516
+ if (directives === undefined) {
517
+ return;
518
+ }
519
+ each(directives, path, false, (directive, operand, at) => {
520
+ const sites = Object.hasOwn(RECORD_SITES, directive) ? RECORD_SITES[directive as string] : undefined;
521
+ if (sites === undefined) {
522
+ return;
523
+ }
524
+ if (sites.length === 0) {
525
+ filter(operand, at);
526
+ return;
527
+ }
528
+ entries(operand, at, (entry, entryAt) => {
529
+ if (sites.includes(undefined)) {
530
+ writeRecord(target, entry, entryAt);
531
+ return;
532
+ }
533
+ const parts = recordAt(entry, entryAt);
534
+ if (parts !== undefined) {
535
+ each(parts, entryAt, true, (key, member, memberAt) => {
536
+ if (sites.includes(key as string)) {
537
+ writeRecord(target, member, memberAt);
538
+ } else if (member !== undefined) {
539
+ filter(member, memberAt);
540
+ } else if (key === "where") {
541
+ found.push({ path: memberAt, code: directive === "$update" ? "V1001" : "V1007" });
542
+ }
543
+ });
544
+ }
545
+ });
546
+ });
547
+ };
548
+
549
+ const root = recordAt(args, []);
550
+ if (root !== undefined) {
551
+ const writeKeys = writeKeysOf(family);
552
+ const whereCode: UnsentCode = variant === "unique" ? "V1007" : "V1001";
553
+ const cursorCode: UnsentCode = family === "find" && variant === "many" ? "V1009" : "V1001";
554
+ each(root, [], true, (key, value, at) => {
555
+ if (writeKeys.includes(key as string)) {
556
+ if (value !== undefined) {
557
+ entries(value, at, (entry, entryAt) => writeRecord(resource, entry, entryAt));
558
+ }
559
+ } else {
560
+ read(key, value, at, whereCode, cursorCode);
561
+ }
562
+ });
563
+ }
564
+ return found;
565
+ }
566
+
350
567
  /** How encodeValue treats an object: a scalar's runtime class, a container, or neither. */
351
568
  function objectKind(value: object): "datetime" | "decimal" | "bytes" | "container" | "other" {
352
569
  if (value instanceof Date) {
@@ -212,7 +212,7 @@ function transactionModuleSource(from) {
212
212
  return `import { encodeArguments, readArgument, serializeWireBody } from ${from("./codec")};
213
213
  import { type AvCause, type AvError, frameworkErrorOf } from ${from("./errors")};
214
214
  import { fingerprintOf } from ${from("./fingerprint")};
215
- import { type AvCallOptions, executePlan, type TransportConnection } from ${from("./transport")};
215
+ import { type AvCallOptions, executePlan, refuseUnsentValues, type TransportConnection } from ${from("./transport")};
216
216
 
217
217
  /**
218
218
  * The deferred transaction builder and runner: \`avClient.tx\` defers an operation
@@ -384,8 +384,11 @@ export type PlanCheck = (
384
384
  * generated client, one handle twice (AvValidationError A2004 / V1001 at
385
385
  * ["operations", i]: the entry itself); a reference to a handle not in the list (AvValidationError A2007 /
386
386
  * V1010 at its \`$ref\`); a handle whose arguments could not be read when it was
387
- * built (the TypeError naming where); and, when \`check\` is given, a step whose
388
- * values break a field check (AvValidationError A2004, as the server answers it).
387
+ * built (the TypeError naming where); a step holding an undefined value JSON would
388
+ * leave out to widen it (AvValidationError A2004, as refuseUnsentValues states it,
389
+ * the step named);
390
+ * and, when \`check\` is given, a step whose values break a field check
391
+ * (AvValidationError A2004, as the server answers it).
389
392
  */
390
393
  export async function runTransaction(
391
394
  connection: TransportConnection,
@@ -431,6 +434,7 @@ export async function runTransaction(
431
434
  });
432
435
  }
433
436
  }
437
+ refuseUnsentValues(record.resource, record.family, record.variant, record.args, ["operations", index, "args"], index);
434
438
  }
435
439
 
436
440
  check?.(records);
@@ -14,7 +14,7 @@ export type TypeScriptResolver = () => Promise<TypeScriptCompiler | undefined>;
14
14
  */
15
15
  export declare function typeScriptResolverFor(specifier: string, directory: string): TypeScriptResolver;
16
16
  export declare function projectTypeScriptResolver(generateAt: string): TypeScriptResolver;
17
- export declare const MINIMUM_TYPESCRIPT = "5.5.0";
17
+ export declare const MINIMUM_TYPESCRIPT = "5.7.0";
18
18
  /** How deeply the tree was judged. */
19
19
  export type OutputCheck = "types" | "syntax" | "shape";
20
20
  export interface OutputAccepted {
@@ -45,9 +45,8 @@ export declare const SYNTAX_CHECK_UNAVAILABLE_WARNING: string;
45
45
  export declare function typeScriptWithoutCompilerApiWarning(version: string, checked: "syntax" | "shape"): string;
46
46
  /**
47
47
  * The warning a run carries when the project's `typescript` is older than the
48
- * peer range: its compiler API lacks options the check sets (`Bundler`
49
- * resolution, `verbatimModuleSyntax`), so its verdict would not be the one a
50
- * supported compiler gives.
48
+ * peer range: Aventara's types are not measured under it, so its verdict
49
+ * would not be the one a supported compiler gives.
51
50
  */
52
51
  export declare function typeScriptBelowRangeWarning(version: string, checked: "syntax" | "shape"): string;
53
52
  /**
@@ -24,7 +24,7 @@ export function typeScriptResolverFor(specifier, directory) {
24
24
  export function projectTypeScriptResolver(generateAt) {
25
25
  return typeScriptResolverFor("typescript", generateAt);
26
26
  }
27
- export const MINIMUM_TYPESCRIPT = "5.5.0";
27
+ export const MINIMUM_TYPESCRIPT = "5.7.0";
28
28
  function meetsMinimum(version) {
29
29
  const parts = /^(\d+)\.(\d+)\.(\d+)/.exec(version);
30
30
  const minimum = MINIMUM_TYPESCRIPT.split(".").map(Number);
@@ -57,7 +57,7 @@ export const SYNTAX_CHECK_UNAVAILABLE_WARNING = "neither `typescript` nor Node's
57
57
  export function typeScriptWithoutCompilerApiWarning(version, checked) {
58
58
  return (`the installed \`typescript\` ${version} has no classic compiler API (\`createProgram\`), so the generated ` +
59
59
  `output was NOT type-checked${checked === "shape" ? " or parsed — only its shape was" : " — only its shape and syntax were"}. ` +
60
- "Your project's own `tsc` checks it when it compiles; a `typescript` 5.5 to 6 restores the generator's own type check.");
60
+ "Your project's own `tsc` checks it when it compiles; a `typescript` 5.7 to 6 restores the generator's own type check.");
61
61
  }
62
62
  export function typeScriptBelowRangeWarning(version, checked) {
63
63
  return (`the installed \`typescript\` ${version} is older than ${MINIMUM_TYPESCRIPT}, the lowest @aventara/client supports, so the generated ` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aventara/client",
3
- "version": "1.0.0-rc.1",
3
+ "version": "1.0.0-rc.3",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "Development-time generator for Aventara typed remote clients.",
6
6
  "type": "module",
@@ -24,12 +24,12 @@
24
24
  "CHANGELOG.md"
25
25
  ],
26
26
  "dependencies": {
27
- "@aventara/core": "1.0.0-rc.1",
27
+ "@aventara/core": "1.0.0-rc.3",
28
28
  "c12": "3.3.4",
29
29
  "get-tsconfig": "4.10.0"
30
30
  },
31
31
  "peerDependencies": {
32
- "typescript": ">=5.5.0"
32
+ "typescript": ">=5.7.0"
33
33
  },
34
34
  "peerDependenciesMeta": {
35
35
  "typescript": {
@@ -40,7 +40,7 @@
40
40
  "access": "public"
41
41
  },
42
42
  "devDependencies": {
43
- "@aventara/testing": "1.0.0-rc.1",
43
+ "@aventara/testing": "1.0.0-rc.3",
44
44
  "@types/node": "24.10.1",
45
45
  "typescript": "^5.9.2"
46
46
  },