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

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.
@@ -1,4 +1,4 @@
1
- import { isOperationVariantAvailable, } from "@aventara/core";
1
+ import { isOperationVariantAvailable } from "@aventara/core/internal";
2
2
  import { ownPropertyKey } from "./name.deriver.js";
3
3
  const REVIVED_SCALARS = new Set([
4
4
  "bigint",
@@ -0,0 +1,16 @@
1
+ import type { ClientContract } from "@aventara/core";
2
+ type Schema = Readonly<Record<string, unknown>>;
3
+ /**
4
+ * The TypeScript type a Json Shape schema describes, as source text.
5
+ *
6
+ * @throws Error on a schema outside the accepted forms — unreachable for a
7
+ * ClientContract that passed the structure check.
8
+ */
9
+ export declare function jsonSchemaType(schema: Schema): string;
10
+ /**
11
+ * The type `client.ts` gives its derivation in place of the carrier when the
12
+ * contract shapes a Json field — the carrier with each shaped field's type — or
13
+ * `undefined` when it shapes none.
14
+ */
15
+ export declare function shapedContractType(contract: ClientContract): string | undefined;
16
+ export {};
@@ -0,0 +1,88 @@
1
+ const SCALAR_TYPES = {
2
+ string: "string",
3
+ number: "number",
4
+ integer: "number",
5
+ boolean: "boolean",
6
+ null: "null",
7
+ };
8
+ const plain = (text) => ({ text, union: false });
9
+ function unionOf(members) {
10
+ return members.length === 1
11
+ ? members[0]
12
+ : { text: members.map((member) => member.text).join(" | "), union: true };
13
+ }
14
+ function operand(type) {
15
+ return type.union ? `(${type.text})` : type.text;
16
+ }
17
+ function emitted(schema) {
18
+ if (Object.hasOwn(schema, "const")) {
19
+ return plain(JSON.stringify(schema.const));
20
+ }
21
+ if (Array.isArray(schema.enum)) {
22
+ return unionOf(schema.enum.map((value) => plain(JSON.stringify(value))));
23
+ }
24
+ for (const keyword of ["anyOf", "oneOf"]) {
25
+ const options = schema[keyword];
26
+ if (Array.isArray(options)) {
27
+ return unionOf(options.map((option) => emitted(option)));
28
+ }
29
+ }
30
+ const type = schema.type;
31
+ if (typeof type === "string" && Object.hasOwn(SCALAR_TYPES, type)) {
32
+ return plain(SCALAR_TYPES[type]);
33
+ }
34
+ if (type === "array") {
35
+ if (Array.isArray(schema.prefixItems)) {
36
+ return plain(`[${schema.prefixItems.map((item) => emitted(item).text).join(", ")}]`);
37
+ }
38
+ return plain(`${operand(emitted(schema.items))}[]`);
39
+ }
40
+ if (type === "object") {
41
+ const properties = schema.properties;
42
+ if (properties === undefined) {
43
+ return plain(`{ [key: string]: ${emitted(schema.additionalProperties).text} }`);
44
+ }
45
+ const required = new Set(Array.isArray(schema.required) ? schema.required : []);
46
+ const members = Object.keys(properties).map((key) => {
47
+ const member = emitted(properties[key]).text;
48
+ return required.has(key)
49
+ ? `${JSON.stringify(key)}: ${member}`
50
+ : `${JSON.stringify(key)}?: ${member} | undefined`;
51
+ });
52
+ return plain(members.length === 0 ? "{}" : `{ ${members.join("; ")} }`);
53
+ }
54
+ throw new Error(`a Json Shape schema outside the accepted forms reached the emitter: ${JSON.stringify(schema)}`);
55
+ }
56
+ export function jsonSchemaType(schema) {
57
+ return emitted(schema).text;
58
+ }
59
+ export function shapedContractType(contract) {
60
+ const order = (keys) => [...keys].sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
61
+ const resources = order(Object.keys(contract.resources)).flatMap((resourceName) => {
62
+ const fields = contract.resources[resourceName]?.fields ?? {};
63
+ const shaped = order(Object.keys(fields)).flatMap((fieldName) => {
64
+ const field = fields[fieldName];
65
+ return field?.kind === "scalar" && field.json !== undefined
66
+ ? [
67
+ `\t\t\t\treadonly ${JSON.stringify(fieldName)}: { readonly json: JsonFieldType<${jsonSchemaType(field.json)}> };\n`,
68
+ ]
69
+ : [];
70
+ });
71
+ return shaped.length === 0
72
+ ? []
73
+ : [
74
+ `\t\treadonly ${JSON.stringify(resourceName)}: {\n` +
75
+ "\t\t\treadonly fields: {\n" +
76
+ shaped.join("") +
77
+ "\t\t\t};\n" +
78
+ "\t\t};\n",
79
+ ];
80
+ });
81
+ return resources.length === 0
82
+ ? undefined
83
+ : "ClientContractShape & {\n" +
84
+ "\treadonly resources: {\n" +
85
+ resources.join("") +
86
+ "\t};\n" +
87
+ "}";
88
+ }
@@ -1,4 +1,4 @@
1
- import { type ClientContract, type OperationFamily } from "@aventara/core";
1
+ import type { ClientContract, OperationFamily } from "@aventara/core";
2
2
  /**
3
3
  * Name derivation and the rename ladder for the emitted tree.
4
4
  */
@@ -1,4 +1,4 @@
1
- import { isOperationVariantAvailable, } from "@aventara/core";
1
+ 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([
@@ -1,4 +1,4 @@
1
- import { type ClientContract } from "@aventara/core";
1
+ import type { ClientContract } from "@aventara/core";
2
2
  import type { ClientEntrypoint } from "../config/client-config.interface.js";
3
3
  import type { EmittedModule } from "./emitted-tree.interface.js";
4
4
  import { type ClientModuleStyle } from "./module-style.interface.js";
@@ -1,4 +1,4 @@
1
- import { OPERATION_CODES, VALIDATION_CODES, } from "@aventara/core";
1
+ import { 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
4
  import { moduleSpecifierWriter, } from "./module-style.interface.js";
@@ -227,7 +227,7 @@ const IDENTITY_HEADERS = (() => {
227
227
  })();
228
228
  function transportModuleSource(from) {
229
229
  return `import { CLIENT_CONTRACT_HASH, PROTOCOL_VERSION } from ${from("../metadata")};
230
- import { decodeResult, encodeArguments, serializeWireBody } from ${from("./codec")};
230
+ import { decodeResult, encodeArguments, readArgument, serializeWireBody } from ${from("./codec")};
231
231
  import { DECODE_TABLE } from ${from("./descriptor")};
232
232
  import {
233
233
  type Cause,
@@ -575,7 +575,7 @@ function issueOf(value: unknown): ValidationIssue | undefined {
575
575
 
576
576
  /** The arguments, refused unless they are an object: the body is always one. */
577
577
  function argumentObject(args: unknown): unknown {
578
- if (!isRecord(args)) {
578
+ if (!readArgument([], () => isRecord(args))) {
579
579
  throw new TypeError("An operation's arguments must be an object; send {} when there are none.");
580
580
  }
581
581
  return args;
@@ -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) {
@@ -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 (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
355
  if (value instanceof Decimal) {
342
- return encodeScalar("decimal", value);
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
  }
@@ -209,7 +209,7 @@ function base64Url(bytes: Uint8Array): string {
209
209
  }
210
210
  `;
211
211
  function transactionModuleSource(from) {
212
- return `import { encodeArguments, serializeWireBody } from ${from("./codec")};
212
+ return `import { encodeArguments, readArgument, serializeWireBody } from ${from("./codec")};
213
213
  import { type Cause, type FrameworkError, frameworkErrorOf } from ${from("./errors")};
214
214
  import { fingerprintOf } from ${from("./fingerprint")};
215
215
  import { type CallOptions, executePlan, type TransportConnection } from ${from("./transport")};
@@ -222,12 +222,16 @@ import { type CallOptions, executePlan, type TransportConnection } from ${from("
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;
275
288
  }
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 }] : []));
289
+ const array = readArgument(path, () => Array.isArray(value));
290
+ if ((!array && !readArgument(path, () => isPlainRecord(value))) || ancestors.has(value)) {
291
+ return value;
292
+ }
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,12 +343,19 @@ 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
 
@@ -325,7 +363,7 @@ export function deferOperation(resource: string, family: string, variant: string
325
363
  function entryRefusal(operation: number, message: string): FrameworkError {
326
364
  const cause: Cause = {
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);
@@ -336,8 +374,9 @@ function entryRefusal(operation: number, message: string): FrameworkError {
336
374
  * step. Refused before anything is sent, with what core answers in
337
375
  * process: a list that is not one, an entry that is not a handle of this
338
376
  * 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\`).
377
+ * ["operations", i]: the entry itself); a reference to a handle not in the list (ValidationError A2007 /
378
+ * V1010 at its \`$ref\`); a handle whose arguments could not be read when it was
379
+ * built (the TypeError naming where).
341
380
  */
342
381
  export async function runTransaction(
343
382
  connection: TransportConnection,
@@ -364,22 +403,22 @@ export async function runTransaction(
364
403
  records.push(record);
365
404
  }
366
405
  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
- }
406
+ if (record.unreadable !== undefined) {
407
+ throw record.unreadable;
408
+ }
409
+ for (const { path, placeholder } of placeholderSites(record.args)) {
410
+ if (!positions.has(placeholder.source)) {
411
+ throw frameworkErrorOf("A2007", {
412
+ message: "Operation arguments failed framework validation.",
413
+ issues: [
414
+ {
415
+ path: ["operations", index, "args", ...path, "$ref"],
416
+ code: "V1010",
417
+ message: "Transaction reference names an operation that is not in this transaction.",
418
+ },
419
+ ],
420
+ operation: index,
421
+ });
383
422
  }
384
423
  }
385
424
  }
@@ -394,17 +433,14 @@ export async function runTransaction(
394
433
  return built;
395
434
  }
396
435
  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
- });
436
+ const references = new Map<object, unknown>();
437
+ for (const { object, placeholder } of placeholderSites(record.args)) {
438
+ const operation = positions.get(placeholder.source) as number;
439
+ references.set(object, { $ref: { operation, fingerprint: nodeAt(operation).fingerprint, path: [placeholder.field] } });
440
+ }
441
+ const bound = rebuild(record.args, [], (value) =>
442
+ typeof value === "object" && value !== null && references.has(value) ? references.get(value) : KEEP,
443
+ );
408
444
  const args: unknown = JSON.parse(serializeWireBody(encodeArguments(bound)));
409
445
  const node: PlanNode = {
410
446
  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. */
package/dist/generate.js CHANGED
@@ -4,11 +4,13 @@ import { loadClientConfigFile } from "./config/config.loader.js";
4
4
  import { resolveClientConfig } from "./config/config.resolver.js";
5
5
  import { resolveEnvCascade, } from "./config/env.cascade.js";
6
6
  import { resolveModuleStyle } from "./config/module-style.resolver.js";
7
+ import { contractUrlOf, displayUrl, } from "./contract/contract.fetcher.js";
7
8
  import { loadClientContractSince } from "./contract/contract.loader.js";
8
9
  import { emitClientTree } from "./emit/client-tree.emitter.js";
9
10
  import { parseContractCarrier } from "./emit/contract-carrier.emitter.js";
10
11
  import { CLIENT_ENTRY_FILE, CONTRACT_CARRIER_MODULE, GENERATED_DIRECTORY, } from "./emit/emitted-tree.interface.js";
11
12
  import { findForeignOutputContent, OutputWriteError, ownedOutputMatches, precedeDefect, writeClientOutput, } from "./output/output.writer.js";
13
+ const runPhase = async (_title, body) => await body();
12
14
  async function storedContract(generateAt) {
13
15
  try {
14
16
  if ((await findForeignOutputContent(generateAt)).length > 0) {
@@ -21,32 +23,42 @@ async function storedContract(generateAt) {
21
23
  }
22
24
  }
23
25
  export async function generateClient(input) {
24
- const cascade = resolveEnvCascade({
25
- directory: input.directory,
26
- processEnv: input.processEnv,
27
- });
28
- const loaded = await loadClientConfigFile(input.directory);
29
- const config = resolveClientConfig({
30
- config: loaded.config,
31
- cascade,
32
- configDirectory: path.dirname(loaded.file),
33
- });
34
- const style = resolveModuleStyle({
35
- generateAt: config.generateAt,
36
- entryFile: path.join(config.generateAt, CLIENT_ENTRY_FILE),
37
- ...(config.tsconfigFile === undefined
38
- ? {}
39
- : { tsconfigFile: config.tsconfigFile }),
26
+ const step = input.step ?? runPhase;
27
+ const { config, style } = await step("Loading the client config", async () => {
28
+ const cascade = resolveEnvCascade({
29
+ directory: input.directory,
30
+ processEnv: input.processEnv,
31
+ });
32
+ const loaded = await loadClientConfigFile(input.directory);
33
+ const config = resolveClientConfig({
34
+ config: loaded.config,
35
+ cascade,
36
+ configDirectory: path.dirname(loaded.file),
37
+ });
38
+ const style = resolveModuleStyle({
39
+ generateAt: config.generateAt,
40
+ entryFile: path.join(config.generateAt, CLIENT_ENTRY_FILE),
41
+ ...(config.tsconfigFile === undefined
42
+ ? {}
43
+ : { tsconfigFile: config.tsconfigFile }),
44
+ });
45
+ return { config, style };
40
46
  });
41
- const { contract, notModified } = await loadClientContractSince(input.fetch === undefined
47
+ const { contract, notModified } = await step(`Fetching the ClientContract (${displayUrl(contractUrlOf(config.entrypoint))})`, async () => loadClientContractSince(input.fetch === undefined
42
48
  ? { entrypoint: config.entrypoint }
43
- : { entrypoint: config.entrypoint, fetch: input.fetch }, await storedContract(config.generateAt));
44
- const emission = emitClientTree(contract, config.entrypoint, {
45
- importFileExtension: style.importFileExtension,
46
- moduleFormat: style.moduleFormat,
49
+ : { entrypoint: config.entrypoint, fetch: input.fetch }, await storedContract(config.generateAt)));
50
+ const { emission, upToDate } = await step("Emitting the client", async () => {
51
+ const emission = emitClientTree(contract, config.entrypoint, {
52
+ importFileExtension: style.importFileExtension,
53
+ moduleFormat: style.moduleFormat,
54
+ });
55
+ return {
56
+ emission,
57
+ upToDate: notModified &&
58
+ (await ownedOutputMatches(config.generateAt, emission.tree)),
59
+ };
47
60
  });
48
- if (notModified &&
49
- (await ownedOutputMatches(config.generateAt, emission.tree))) {
61
+ if (upToDate) {
50
62
  return {
51
63
  kind: "up-to-date",
52
64
  generateAt: config.generateAt,
@@ -54,7 +66,7 @@ export async function generateClient(input) {
54
66
  warnings: emission.warnings,
55
67
  };
56
68
  }
57
- const write = async (overrideForeign) => {
69
+ const writeInto = async (overrideForeign) => {
58
70
  const written = await writeClientOutput(input.resolveTypeScript === undefined
59
71
  ? { emission, generateAt: config.generateAt, overrideForeign }
60
72
  : {
@@ -72,6 +84,8 @@ export async function generateClient(input) {
72
84
  warnings: written.warnings,
73
85
  };
74
86
  };
87
+ const into = path.relative(input.directory, config.generateAt) || ".";
88
+ const write = (overrideForeign) => step(`Checking and writing the client into ${into}`, () => writeInto(overrideForeign));
75
89
  const foreign = await findForeignOutputContent(config.generateAt).catch((error) => {
76
90
  throw error instanceof OutputWriteError
77
91
  ? error.precededBy(emission.warnings)