@aventara/client 0.1.0-pilot.4 → 1.0.0-rc.1

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 (65) hide show
  1. package/CHANGELOG.md +679 -0
  2. package/README.md +168 -132
  3. package/dist/cli/command.parser.d.ts +6 -4
  4. package/dist/cli/command.parser.js +22 -11
  5. package/dist/cli/generate.command.d.ts +6 -1
  6. package/dist/cli/generate.command.js +12 -1
  7. package/dist/cli/generation-failure.renderer.js +24 -24
  8. package/dist/cli/step.reporter.d.ts +31 -0
  9. package/dist/cli/step.reporter.js +87 -0
  10. package/dist/cli.js +2 -0
  11. package/dist/config/client-config.interface.d.ts +7 -7
  12. package/dist/config/config.loader.d.ts +4 -4
  13. package/dist/config/config.loader.js +7 -7
  14. package/dist/config/config.resolver.d.ts +7 -7
  15. package/dist/config/config.resolver.js +12 -12
  16. package/dist/config/env.cascade.d.ts +2 -2
  17. package/dist/config/env.cascade.js +4 -4
  18. package/dist/config/module-style.resolver.d.ts +1 -1
  19. package/dist/config/module-style.resolver.js +2 -2
  20. package/dist/config/tsconfig.locator.d.ts +1 -1
  21. package/dist/config/tsconfig.locator.js +2 -2
  22. package/dist/contract/contract.acceptance.d.ts +4 -4
  23. package/dist/contract/contract.acceptance.js +4 -4
  24. package/dist/contract/contract.fetcher.d.ts +3 -3
  25. package/dist/contract/contract.fetcher.js +6 -6
  26. package/dist/contract/contract.loader.d.ts +6 -6
  27. package/dist/contract/contract.loader.js +2 -2
  28. package/dist/emit/client-surface.emitter.d.ts +2 -2
  29. package/dist/emit/client-surface.emitter.js +77 -43
  30. package/dist/emit/client-tree.emitter.d.ts +3 -3
  31. package/dist/emit/client-tree.emitter.js +8 -5
  32. package/dist/emit/contract-carrier.emitter.d.ts +3 -3
  33. package/dist/emit/contract-carrier.emitter.js +1 -1
  34. package/dist/emit/derivation.emitter.js +1 -0
  35. package/dist/emit/descriptor.emitter.d.ts +2 -2
  36. package/dist/emit/descriptor.emitter.js +2 -2
  37. package/dist/emit/emitted-tree.interface.d.ts +1 -1
  38. package/dist/emit/enum.emitter.d.ts +2 -2
  39. package/dist/emit/field-check.emitter.d.ts +21 -0
  40. package/dist/emit/field-check.emitter.js +603 -0
  41. package/dist/emit/json-shape.emitter.d.ts +16 -0
  42. package/dist/emit/json-shape.emitter.js +88 -0
  43. package/dist/emit/name.deriver.d.ts +6 -6
  44. package/dist/emit/name.deriver.js +15 -15
  45. package/dist/emit/named-type.emitter.d.ts +2 -2
  46. package/dist/emit/runtime.emitter.d.ts +2 -2
  47. package/dist/emit/runtime.emitter.js +117 -112
  48. package/dist/emit/scalar.codec.d.ts +1 -1
  49. package/dist/emit/scalar.codec.js +78 -35
  50. package/dist/emit/transaction.emitter.js +131 -83
  51. package/dist/generate.d.ts +11 -3
  52. package/dist/generate.js +40 -26
  53. package/dist/index.d.ts +1 -1
  54. package/dist/init/client-init.errors.d.ts +4 -4
  55. package/dist/init/client-init.errors.js +4 -4
  56. package/dist/init/client-init.orchestrator.js +37 -22
  57. package/dist/init/client-init.questions.d.ts +5 -5
  58. package/dist/init/client-init.questions.js +10 -10
  59. package/dist/init/client-project.inspector.d.ts +2 -2
  60. package/dist/init/client-project.inspector.js +3 -3
  61. package/dist/init/command.runner.d.ts +2 -0
  62. package/dist/init/command.runner.js +4 -1
  63. package/dist/output/output.writer.d.ts +5 -5
  64. package/dist/output/output.writer.js +12 -12
  65. package/package.json +6 -5
@@ -1,4 +1,4 @@
1
- import { BUILT_IN_SCALARS } from "@aventara/core";
1
+ import { BUILT_IN_SCALARS } from "@aventara/core/internal";
2
2
  import { AvProtocol } from "@aventara/core/protocol";
3
3
  import { moduleSpecifierWriter, } from "./module-style.interface.js";
4
4
  export function wireGrammarLiteral(scalar) {
@@ -29,7 +29,7 @@ const SCALAR_CODEC_SOURCES = {
29
29
  : refuse("bigint", "wire", "a base-10 integer string")`,
30
30
  },
31
31
  decimal: {
32
- encode: `(value) => (value instanceof Decimal ? value.toString() : refuse("decimal", "runtime", "a Decimal"))`,
32
+ encode: `(value) => (value instanceof AvDecimal ? value.toString() : refuse("decimal", "runtime", "an AvDecimal"))`,
33
33
  decode: "decodeDecimal",
34
34
  },
35
35
  datetime: {
@@ -48,13 +48,13 @@ const SCALAR_CODEC_SOURCES = {
48
48
  },
49
49
  };
50
50
  function codecModuleHead(from) {
51
- return `import { Decimal } from ${from("./decimal")};
51
+ return `import { AvDecimal } from ${from("./decimal")};
52
52
  import type { FieldDecoding } from ${from("./descriptor")};
53
53
 
54
54
  /**
55
55
  * The scalar codecs of Aventara protocol version 1: one per built-in
56
56
  * scalar, converting between the generated runtime value and the JSON wire value.
57
- * A value is never wrapped per field — the Contract names the scalar, so the
57
+ * A value is never wrapped per field: the Contract names the scalar, so the
58
58
  * caller says which scalar a value is.
59
59
  *
60
60
  * null is explicit in both directions and passes through; whether a field admits
@@ -82,7 +82,7 @@ const DATETIME_WIRE = ${wireGrammarLiteral("datetime")};
82
82
  const BASE64_WIRE = ${wireGrammarLiteral("bytes")};
83
83
  const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
84
84
 
85
- function refuse(scalar: BuiltInScalar, side: Side, expected: string): never {
85
+ function refuse(scalar: AvBuiltInScalar, side: Side, expected: string): never {
86
86
  throw new TypeError('A "' + scalar + '" ' + side + " value must be " + expected + ".");
87
87
  }
88
88
 
@@ -94,10 +94,10 @@ function isFiniteNumber(value: unknown): value is number {
94
94
  return typeof value === "number" && Number.isFinite(value);
95
95
  }
96
96
 
97
- function decodeDecimal(wire: unknown): Decimal {
97
+ function decodeDecimal(wire: unknown): AvDecimal {
98
98
  if (typeof wire === "string") {
99
99
  try {
100
- return new Decimal(wire);
100
+ return new AvDecimal(wire);
101
101
  } catch {
102
102
  // Refused below, naming the scalar.
103
103
  }
@@ -195,8 +195,8 @@ function kindOf(value: unknown): string {
195
195
  if (value instanceof Uint8Array) {
196
196
  return "a Uint8Array";
197
197
  }
198
- if (value instanceof Decimal) {
199
- return "a Decimal";
198
+ if (value instanceof AvDecimal) {
199
+ return "an AvDecimal";
200
200
  }
201
201
  if (typeof value === "object" && value !== null && isPlainRecord(value)) {
202
202
  return "a symbol-keyed property";
@@ -288,7 +288,7 @@ function writeWireJson(value: unknown, path: Path, ancestors: Set<object>): stri
288
288
  return text;
289
289
  }
290
290
 
291
- function codecOf(scalar: BuiltInScalar): ScalarCodec {
291
+ function codecOf(scalar: AvBuiltInScalar): ScalarCodec {
292
292
  if (!Object.hasOwn(SCALAR_CODECS, scalar)) {
293
293
  throw new TypeError('"' + String(scalar) + '" is not a built-in scalar of this protocol version.');
294
294
  }
@@ -296,21 +296,21 @@ function codecOf(scalar: BuiltInScalar): ScalarCodec {
296
296
  }
297
297
 
298
298
  /** The JSON wire value of a scalar's runtime value; null passes through. */
299
- export function encodeScalar(scalar: BuiltInScalar, value: unknown): unknown {
299
+ export function encodeScalar(scalar: AvBuiltInScalar, value: unknown): unknown {
300
300
  const codec = codecOf(scalar);
301
301
  return value === null ? null : codec.encode(value);
302
302
  }
303
303
 
304
304
  /** The runtime value of a scalar's JSON wire value; null passes through. */
305
- export function decodeScalar(scalar: BuiltInScalar, wire: unknown): unknown {
305
+ export function decodeScalar(scalar: AvBuiltInScalar, wire: unknown): unknown {
306
306
  const codec = codecOf(scalar);
307
307
  return wire === null ? null : codec.decode(wire);
308
308
  }
309
309
 
310
310
  /**
311
311
  * The JSON text of a request body whose scalars are already encoded. Every
312
- * 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.
312
+ * 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.
314
314
  * Anything else that is not JSON-safe is refused rather than coerced: it means a
315
315
  * value escaped its scalar codec.
316
316
  */
@@ -321,8 +321,8 @@ export function serializeWireBody(body: unknown): string {
321
321
  /**
322
322
  * An operation's arguments with every scalar in its wire form, read off each
323
323
  * value's runtime class: a
324
- * bigint, a Date, a Decimal or a Uint8Array is encoded wherever it stands — a
325
- * filter operand, a data value, a cursor — and plain objects and arrays are
324
+ * bigint, a Date, an AvDecimal or a Uint8Array is encoded wherever it stands (a
325
+ * filter operand, a data value, a cursor), and plain objects and arrays are
326
326
  * walked. Every other value is left for serializeWireBody, which refuses what is
327
327
  * not JSON-safe. A json value cannot hold one of the four classes (the types
328
328
  * exclude them), so nothing inside one is ever re-read as a scalar.
@@ -331,31 +331,74 @@ export function encodeArguments(value: unknown): unknown {
331
331
  return encodeValue(value, [], new Set());
332
332
  }
333
333
 
334
- function encodeValue(value: unknown, path: Path, ancestors: Set<object>): unknown {
335
- if (typeof value === "bigint") {
336
- return encodeScalar("bigint", value);
334
+ /**
335
+ * One read of a caller's arguments. Every native read on them happens through it,
336
+ * here and in the transaction builder, so a value that cannot be read (a revoked
337
+ * Proxy such as an Immer draft after produce, a throwing getter or trap) is refused at its
338
+ * path with a TypeError before anything is sent, never with the engine's own error.
339
+ */
340
+ export function readArgument<T>(path: Path, read: () => T): T {
341
+ try {
342
+ return read();
343
+ } catch {
344
+ throw new TypeError(
345
+ "An operation's arguments must be readable plain data, and the value at " + pointerOf(path) + " could not be read.",
346
+ );
337
347
  }
348
+ }
349
+
350
+ /** How encodeValue treats an object: a scalar's runtime class, a container, or neither. */
351
+ function objectKind(value: object): "datetime" | "decimal" | "bytes" | "container" | "other" {
338
352
  if (value instanceof Date) {
339
- return encodeScalar("datetime", value);
353
+ return "datetime";
340
354
  }
341
- if (value instanceof Decimal) {
342
- return encodeScalar("decimal", value);
355
+ if (value instanceof AvDecimal) {
356
+ return "decimal";
343
357
  }
344
358
  if (value instanceof Uint8Array) {
345
- return encodeScalar("bytes", value);
359
+ return "bytes";
346
360
  }
347
- if (typeof value !== "object" || value === null || !isJsonContainer(value)) {
361
+ return isJsonContainer(value) ? "container" : "other";
362
+ }
363
+
364
+ function encodeValue(value: unknown, path: Path, ancestors: Set<object>): unknown {
365
+ if (typeof value === "bigint") {
366
+ return encodeScalar("bigint", value);
367
+ }
368
+ if (typeof value !== "object" || value === null) {
369
+ return value;
370
+ }
371
+ const kind = readArgument(path, () => objectKind(value));
372
+ if (kind === "other") {
348
373
  return value;
349
374
  }
375
+ if (kind !== "container") {
376
+ return encodeScalar(kind, value);
377
+ }
350
378
  if (ancestors.has(value)) {
351
379
  return refuseBody(path, "a cycle");
352
380
  }
353
381
  ancestors.add(value);
354
- const copy = Array.isArray(value)
355
- ? value.map((item, index) => encodeValue(item, [...path, index], ancestors))
356
- : Object.fromEntries(
357
- Object.entries(value).map(([key, member]) => [key, encodeValue(member, [...path, key], ancestors)]),
358
- );
382
+ let copy: unknown;
383
+ if (readArgument(path, () => Array.isArray(value))) {
384
+ const items = value as readonly unknown[];
385
+ const length = readArgument(path, () => items.length);
386
+ const list: unknown[] = [];
387
+ for (let index = 0; index < length; index += 1) {
388
+ const itemPath = [...path, index];
389
+ list.push(encodeValue(readArgument(itemPath, () => items[index]), itemPath, ancestors));
390
+ }
391
+ copy = list;
392
+ } else {
393
+ const record = value as Readonly<Record<string, unknown>>;
394
+ const members: [string, unknown][] = [];
395
+ for (const key of readArgument(path, () => Object.keys(record))) {
396
+ const memberPath = [...path, key];
397
+ members.push([key, encodeValue(readArgument(memberPath, () => record[key]), memberPath, ancestors)]);
398
+ }
399
+ // fromEntries defines each key, so "__proto__" stays an own key.
400
+ copy = Object.fromEntries(members);
401
+ }
359
402
  ancestors.delete(value);
360
403
  return copy;
361
404
  }
@@ -370,13 +413,13 @@ export type DecodeTable = {
370
413
  * table: a number (a count), null, and the data of a Resource the table
371
414
  * does not name pass through; a list is a list of
372
415
  * records, anything else is one record of \`resource\`. In a record, a field the
373
- * table names is revived — each item of a list field — a relation recurses with
416
+ * table names is revived (each item of a list field), a relation recurses with
374
417
  * its target, and every other key passes through untouched: json is never
375
418
  * revived. A to-many relation's value is a list, \`{ data, count }\` or
376
- * \`{ count }\` — the three shapes the derivation gives it.
419
+ * \`{ count }\`, the three shapes the derivation gives it.
377
420
  *
378
- * @throws TypeError when a value is not the wire form its field's scalar takes —
379
- * which the transport answers as a TransportError.
421
+ * @throws TypeError when a value is not the wire form its field's scalar takes,
422
+ * which the transport answers as an AvTransportError.
380
423
  */
381
424
  export function decodeResult(table: DecodeTable, resource: string, value: unknown): unknown {
382
425
  // A Resource the table does not name has nothing to revive: its data passes.
@@ -434,9 +477,9 @@ export function emitScalarCodecModule(style) {
434
477
  path: "runtime/codec.ts",
435
478
  source: codecModuleHead(moduleSpecifierWriter(style)) +
436
479
  "\n/** The built-in scalars of the protocol. */\n" +
437
- `export type BuiltInScalar =\n${union};\n` +
480
+ `export type AvBuiltInScalar =\n${union};\n` +
438
481
  CODEC_MODULE_BODY +
439
- "\nconst SCALAR_CODECS: { readonly [S in BuiltInScalar]: ScalarCodec } = {\n" +
482
+ "\nconst SCALAR_CODECS: { readonly [S in AvBuiltInScalar]: ScalarCodec } = {\n" +
440
483
  `${table}\n};\n`,
441
484
  };
442
485
  }
@@ -24,8 +24,8 @@ export interface FingerprintNode {
24
24
  }
25
25
 
26
26
  /**
27
- * The node's fingerprint, or null when its arguments are not canonical JSON — a
28
- * lone surrogate, a non-finite number — exactly when core cannot compute one
27
+ * The node's fingerprint, or null when its arguments are not canonical JSON (a
28
+ * lone surrogate, a non-finite number), exactly when core cannot compute one
29
29
  * either; the server then answers the operation itself.
30
30
  */
31
31
  export function fingerprintOf(node: FingerprintNode): string | null {
@@ -209,25 +209,29 @@ function base64Url(bytes: Uint8Array): string {
209
209
  }
210
210
  `;
211
211
  function transactionModuleSource(from) {
212
- return `import { encodeArguments, serializeWireBody } from ${from("./codec")};
213
- import { type Cause, type FrameworkError, frameworkErrorOf } from ${from("./errors")};
212
+ return `import { encodeArguments, readArgument, serializeWireBody } from ${from("./codec")};
213
+ import { type AvCause, type AvError, frameworkErrorOf } from ${from("./errors")};
214
214
  import { fingerprintOf } from ${from("./fingerprint")};
215
- import { type CallOptions, executePlan, type TransportConnection } from ${from("./transport")};
215
+ import { type AvCallOptions, executePlan, type TransportConnection } from ${from("./transport")};
216
216
 
217
217
  /**
218
218
  * The deferred transaction builder and runner: \`avClient.tx\` defers an operation
219
- * into a handle — pure data, no request — and \`avClient.transaction\` binds a
219
+ * into a handle (pure data, no request), and \`avClient.transaction\` binds a
220
220
  * list of handles into one transaction plan, sends it once, and resolves one
221
221
  * result per handle in list order. A handle has no connection, so any client of
222
222
  * this generated tree may run it.
223
223
  */
224
224
 
225
- /** What a handle defers. */
225
+ /**
226
+ * What a handle defers, or why its arguments could not be read: the builder
227
+ * never throws into app code, and running the handle refuses it instead.
228
+ */
226
229
  interface DeferredRecord {
227
230
  readonly resource: string;
228
231
  readonly family: string;
229
232
  readonly variant: string;
230
233
  readonly args: unknown;
234
+ readonly unreadable?: TypeError;
231
235
  }
232
236
 
233
237
  /** What a \`$ref\` placeholder stands for until the plan is assembled. */
@@ -236,8 +240,6 @@ interface Placeholder {
236
240
  readonly field: string;
237
241
  }
238
242
 
239
- type MutableRecord = Record<string, unknown>;
240
-
241
243
  /** One wire node of a transaction plan, its arguments in wire form. */
242
244
  interface PlanNode {
243
245
  readonly resource: string;
@@ -247,10 +249,11 @@ interface PlanNode {
247
249
  readonly fingerprint: string | null;
248
250
  }
249
251
 
250
- /** One reference container: a mutation-data record and its path inside the arguments. */
251
- interface Container {
252
+ /** One placeholder an argument tree holds: where, the object itself, and its meaning. */
253
+ interface PlaceholderSite {
252
254
  readonly path: readonly (string | number)[];
253
- readonly record: Readonly<Record<string, unknown>>;
255
+ readonly object: object;
256
+ readonly placeholder: Placeholder;
254
257
  }
255
258
 
256
259
  const HANDLES = new WeakMap<object, DeferredRecord>();
@@ -264,45 +267,73 @@ function isPlainRecord(value: unknown): value is Readonly<Record<string, unknown
264
267
  return prototype === Object.prototype || prototype === null;
265
268
  }
266
269
 
267
- /** The argument keys holding reference positions: the top-level mutation data. */
268
- function referenceKeys(family: string): readonly string[] {
269
- return family === "create" || family === "update" ? ["data"] : family === "upsert" ? ["create", "update"] : [];
270
- }
270
+ const KEEP: unique symbol = Symbol("keep");
271
271
 
272
- function containers(family: string, args: unknown): readonly Container[] {
273
- if (!isPlainRecord(args)) {
274
- return [];
272
+ /**
273
+ * Rebuilds an argument tree: every plain record and array is copied, every other
274
+ * value kept, and \`leaf\` may replace any value first (a placeholder, by identity).
275
+ * A placeholder is bound wherever the arguments hold one; the server answers a
276
+ * position that takes no reference. A container met again on its own ancestry is
277
+ * kept as it is; the codec refuses it.
278
+ */
279
+ function rebuild(
280
+ value: unknown,
281
+ path: readonly (string | number)[],
282
+ leaf: (value: unknown, path: readonly (string | number)[]) => unknown,
283
+ ancestors: Set<object> = new Set(),
284
+ ): unknown {
285
+ const replaced = leaf(value, path);
286
+ if (replaced !== KEEP || typeof value !== "object" || value === null) {
287
+ return replaced === KEEP ? value : replaced;
288
+ }
289
+ const array = readArgument(path, () => Array.isArray(value));
290
+ if ((!array && !readArgument(path, () => isPlainRecord(value))) || ancestors.has(value)) {
291
+ return value;
275
292
  }
276
- return referenceKeys(family).flatMap((key): Container[] => {
277
- const value = args[key];
278
- if (Array.isArray(value)) {
279
- return value.flatMap((entry: unknown, index) => (isPlainRecord(entry) ? [{ path: [key, index], record: entry }] : []));
293
+ ancestors.add(value);
294
+ let copy: unknown;
295
+ if (array) {
296
+ const items = value as readonly unknown[];
297
+ const length = readArgument(path, () => items.length);
298
+ const list: unknown[] = [];
299
+ for (let index = 0; index < length; index += 1) {
300
+ const itemPath = [...path, index];
301
+ list.push(rebuild(readArgument(itemPath, () => items[index]), itemPath, leaf, ancestors));
280
302
  }
281
- return isPlainRecord(value) ? [{ path: [key], record: value }] : [];
282
- });
303
+ copy = list;
304
+ } else {
305
+ const record = value as Readonly<Record<string, unknown>>;
306
+ const members: [string, unknown][] = [];
307
+ for (const key of readArgument(path, () => Object.keys(record))) {
308
+ const memberPath = [...path, key];
309
+ members.push([key, rebuild(readArgument(memberPath, () => record[key]), memberPath, leaf, ancestors)]);
310
+ }
311
+ copy = Object.fromEntries(members);
312
+ }
313
+ ancestors.delete(value);
314
+ return copy;
283
315
  }
284
316
 
285
- /** \`args\` with each reference container replaced by \`rebuild\`, cloning only the containing path. */
286
- function replaceContainers(family: string, args: unknown, rebuild: (container: Container) => MutableRecord): unknown {
287
- if (!isPlainRecord(args)) {
288
- return args;
289
- }
290
- const result: MutableRecord = { ...args };
291
- for (const key of referenceKeys(family)) {
292
- const value = args[key];
293
- if (Array.isArray(value)) {
294
- result[key] = value.map((entry: unknown, index) => (isPlainRecord(entry) ? rebuild({ path: [key, index], record: entry }) : entry));
295
- } else if (isPlainRecord(value)) {
296
- result[key] = rebuild({ path: [key], record: value });
317
+ /** Every placeholder \`args\` holds, with its path inside the arguments. */
318
+ function placeholderSites(args: unknown): readonly PlaceholderSite[] {
319
+ const sites: PlaceholderSite[] = [];
320
+ rebuild(args, [], (value, path) => {
321
+ const placeholder = typeof value === "object" && value !== null ? PLACEHOLDERS.get(value) : undefined;
322
+ if (placeholder === undefined) {
323
+ return KEEP;
297
324
  }
298
- }
299
- return result;
325
+ sites.push({ path, object: value as object, placeholder });
326
+ return value;
327
+ });
328
+ return sites;
300
329
  }
301
330
 
302
331
  /**
303
- * Defers one operation into a handle: nothing is sent. Its reference
304
- * containers are copied now, so a later change to the caller's data cannot move
305
- * a reference — and a handle can reference only handles built before it.
332
+ * Defers one operation into a handle: nothing is sent, and nothing is thrown.
333
+ * Its arguments are copied now, so a later change to the caller's data cannot
334
+ * move a reference, and a handle can reference only handles built before it.
335
+ * Arguments that cannot be read are kept as their refusal, which running the
336
+ * handle rejects with.
306
337
  */
307
338
  export function deferOperation(resource: string, family: string, variant: string, args: unknown): object {
308
339
  const handle: object = Object.freeze({
@@ -312,37 +343,55 @@ export function deferOperation(resource: string, family: string, variant: string
312
343
  return placeholder;
313
344
  },
314
345
  });
315
- HANDLES.set(handle, {
316
- resource,
317
- family,
318
- variant,
319
- args: replaceContainers(family, args, ({ record }) => ({ ...record })),
320
- });
346
+ let record: DeferredRecord;
347
+ try {
348
+ record = {
349
+ resource,
350
+ family,
351
+ variant,
352
+ args: rebuild(args, [], (value) => (typeof value === "object" && value !== null && PLACEHOLDERS.has(value) ? value : KEEP)),
353
+ };
354
+ } catch (error) {
355
+ // Only readArgument throws here: an argument that could not be read.
356
+ record = { resource, family, variant, args: {}, unreadable: error as TypeError };
357
+ }
358
+ HANDLES.set(handle, record);
321
359
  return handle;
322
360
  }
323
361
 
324
362
  /** What core answers in process for a plan entry it cannot run (A2004 / V1001). */
325
- function entryRefusal(operation: number, message: string): FrameworkError {
326
- const cause: Cause = {
363
+ function entryRefusal(operation: number, message: string): AvError {
364
+ const cause: AvCause = {
327
365
  message: "Transaction plan failed framework validation.",
328
- issues: [{ path: ["operation"], code: "V1001", message }],
366
+ issues: [{ path: ["operations", operation], code: "V1001", message }],
329
367
  operation,
330
368
  };
331
369
  return frameworkErrorOf("A2004", cause);
332
370
  }
333
371
 
372
+ /**
373
+ * The checks a plan's steps run before it is sent (the ClientContract's field
374
+ * validators, when it has any). Throws the AvError the server would answer.
375
+ */
376
+ export type PlanCheck = (
377
+ steps: readonly { readonly resource: string; readonly family: string; readonly args: unknown }[],
378
+ ) => void;
379
+
334
380
  /**
335
381
  * Binds \`steps\` into the plan, sends it once, and resolves one decoded result per
336
382
  * step. Refused before anything is sent, with what core answers in
337
383
  * process: a list that is not one, an entry that is not a handle of this
338
- * generated client, one handle twice (ValidationError A2004 / V1001 at
339
- * ["operation"]); a reference to a handle not in the list (ValidationError A2007 /
340
- * V1010 at its \`$ref\`).
384
+ * generated client, one handle twice (AvValidationError A2004 / V1001 at
385
+ * ["operations", i]: the entry itself); a reference to a handle not in the list (AvValidationError A2007 /
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).
341
389
  */
342
390
  export async function runTransaction(
343
391
  connection: TransportConnection,
344
392
  steps: unknown,
345
- options: CallOptions = {},
393
+ options: AvCallOptions = {},
394
+ check?: PlanCheck,
346
395
  ): Promise<unknown[]> {
347
396
  if (!Array.isArray(steps)) {
348
397
  throw frameworkErrorOf("A2004", {
@@ -364,29 +413,31 @@ export async function runTransaction(
364
413
  records.push(record);
365
414
  }
366
415
  for (const [index, record] of records.entries()) {
367
- for (const { path, record: container } of containers(record.family, record.args)) {
368
- for (const [field, value] of Object.entries(container)) {
369
- const placeholder = typeof value === "object" && value !== null ? PLACEHOLDERS.get(value) : undefined;
370
- if (placeholder !== undefined && !positions.has(placeholder.source)) {
371
- throw frameworkErrorOf("A2007", {
372
- message: "Operation arguments failed framework validation.",
373
- issues: [
374
- {
375
- path: ["operations", index, "args", ...path, field, "$ref"],
376
- code: "V1010",
377
- message: "Transaction reference names an operation that is not in this transaction.",
378
- },
379
- ],
380
- operation: index,
381
- });
382
- }
416
+ if (record.unreadable !== undefined) {
417
+ throw record.unreadable;
418
+ }
419
+ for (const { path, placeholder } of placeholderSites(record.args)) {
420
+ if (!positions.has(placeholder.source)) {
421
+ throw frameworkErrorOf("A2007", {
422
+ message: "Operation arguments failed framework validation.",
423
+ issues: [
424
+ {
425
+ path: ["operations", index, "args", ...path, "$ref"],
426
+ code: "V1010",
427
+ message: "Transaction reference names an operation that is not in this transaction.",
428
+ },
429
+ ],
430
+ operation: index,
431
+ });
383
432
  }
384
433
  }
385
434
  }
386
435
 
436
+ check?.(records);
437
+
387
438
  // Bound lazily: a node's references need their source's
388
439
  // fingerprint, so each node is built once, on first need, wherever its source
389
- // stands in the list — the server, not this client, refuses a later one.
440
+ // stands in the list. The server, not this client, refuses a later one.
390
441
  const nodes = new Map<number, PlanNode>();
391
442
  const nodeAt = (index: number): PlanNode => {
392
443
  const built = nodes.get(index);
@@ -394,17 +445,14 @@ export async function runTransaction(
394
445
  return built;
395
446
  }
396
447
  const record = records[index] as DeferredRecord;
397
- const bound = replaceContainers(record.family, record.args, ({ record: container }) => {
398
- const copy: MutableRecord = { ...container };
399
- for (const [field, value] of Object.entries(container)) {
400
- const placeholder = typeof value === "object" && value !== null ? PLACEHOLDERS.get(value) : undefined;
401
- if (placeholder !== undefined) {
402
- const operation = positions.get(placeholder.source) as number;
403
- copy[field] = { $ref: { operation, fingerprint: nodeAt(operation).fingerprint, path: [placeholder.field] } };
404
- }
405
- }
406
- return copy;
407
- });
448
+ const references = new Map<object, unknown>();
449
+ for (const { object, placeholder } of placeholderSites(record.args)) {
450
+ const operation = positions.get(placeholder.source) as number;
451
+ references.set(object, { $ref: { operation, fingerprint: nodeAt(operation).fingerprint, path: [placeholder.field] } });
452
+ }
453
+ const bound = rebuild(record.args, [], (value) =>
454
+ typeof value === "object" && value !== null && references.has(value) ? references.get(value) : KEEP,
455
+ );
408
456
  const args: unknown = JSON.parse(serializeWireBody(encodeArguments(bound)));
409
457
  const node: PlanNode = {
410
458
  resource: record.resource,
@@ -1,6 +1,6 @@
1
1
  import type { AbsolutePath } from "./config/client-config.interface.js";
2
2
  import { type ProcessEnvInput } from "./config/env.cascade.js";
3
- import type { ContractFetch } from "./contract/contract.fetcher.js";
3
+ import { type ContractFetch } from "./contract/contract.fetcher.js";
4
4
  import { type EmittedFilePath } from "./emit/emitted-tree.interface.js";
5
5
  import type { OutputCheck, TypeScriptResolver } from "./output/output.validator.js";
6
6
  export interface ClientGenerationInput {
@@ -17,7 +17,15 @@ export interface ClientGenerationInput {
17
17
  readonly overrideForeign?: boolean;
18
18
  readonly fetch?: ContractFetch;
19
19
  readonly resolveTypeScript?: TypeScriptResolver;
20
+ /**
21
+ * Runs each of the run's four phases — the config, the contract, the
22
+ * emission, the write — under a title, so whoever owns the terminal can show
23
+ * them (`cli/step.reporter.ts`); each phase simply runs when omitted.
24
+ */
25
+ readonly step?: GenerationStep;
20
26
  }
27
+ /** Runs one phase of a generation under its title, resolving to what it returns. */
28
+ export type GenerationStep = <T>(title: string, body: () => Promise<T> | T) => Promise<T>;
21
29
  export interface ClientGenerated {
22
30
  readonly kind: "generated";
23
31
  /** The shared directory the client was generated into. */
@@ -65,8 +73,8 @@ export type ClientGenerationOutcome = ClientGenerated | ClientUpToDate | Foreign
65
73
  * generator did not produce stands in its way and `overrideForeign` is not set,
66
74
  * says what it is and writes nothing.
67
75
  *
68
- * @throws a refusal (`EnvFileError`, `ClientConfigError`, `ContractTransportError`,
69
- * `ContractProtocolError`, `GeneratedNameError`, `OutputWriteError`) whose
76
+ * @throws a refusal (`AvEnvFileError`, `AvClientConfigError`, `AvContractTransportError`,
77
+ * `AvContractProtocolError`, `AvGeneratedNameError`, `AvOutputWriteError`) whose
70
78
  * message is the whole diagnosis; the previous output is intact. Anything else
71
79
  * is a defect.
72
80
  */