Skip to main content

metaxy_cli/codegen/
client.rs

1use super::common::{GENERATED_HEADER, is_void_input};
2use super::typescript::{emit_jsdoc, rust_type_to_ts};
3use crate::model::{Manifest, ProcedureKind};
4
5/// Standard RPC error class with status code and structured error data.
6const ERROR_CLASS: &str = r#"export class RpcError extends Error {
7  readonly status: number;
8  readonly data: unknown;
9
10  constructor(status: number, message: string, data?: unknown) {
11    super(message);
12    this.name = "RpcError";
13    this.status = status;
14    this.data = data;
15  }
16}"#;
17
18/// Context passed to the `onRequest` lifecycle hook.
19const REQUEST_CONTEXT_INTERFACE: &str = r#"export interface RequestContext {
20  procedure: string;
21  method: "GET" | "POST";
22  url: string;
23  headers: Record<string, string>;
24  input?: unknown;
25}"#;
26
27/// Context passed to the `onResponse` lifecycle hook.
28const RESPONSE_CONTEXT_INTERFACE: &str = r#"export interface ResponseContext {
29  procedure: string;
30  method: "GET" | "POST";
31  url: string;
32  response: Response;
33  data: unknown;
34  duration: number;
35}"#;
36
37/// Context passed to the `onError` lifecycle hook.
38const ERROR_CONTEXT_INTERFACE: &str = r#"export interface ErrorContext {
39  procedure: string;
40  method: "GET" | "POST";
41  url: string;
42  error: unknown;
43  attempt: number;
44  willRetry: boolean;
45}"#;
46
47/// Retry policy configuration.
48const RETRY_POLICY_INTERFACE: &str = r#"export interface RetryPolicy {
49  attempts: number;
50  delay: number | ((attempt: number) => number);
51  retryOn?: number[];
52}"#;
53
54/// Configuration interface for the RPC client.
55const CONFIG_INTERFACE: &str = r#"export interface RpcClientConfig {
56  baseUrl: string;
57  fetch?: typeof globalThis.fetch;
58  headers?:
59    | Record<string, string>
60    | (() => Record<string, string> | Promise<Record<string, string>>);
61  onRequest?: (ctx: RequestContext) => void | Promise<void>;
62  onResponse?: (ctx: ResponseContext) => void | Promise<void>;
63  onError?: (ctx: ErrorContext) => void | Promise<void>;
64  retry?: RetryPolicy;
65  timeout?: number;
66  serialize?: (input: unknown) => string;
67  deserialize?: (text: string) => unknown;
68  // AbortSignal for cancelling all requests made by this client.
69  signal?: AbortSignal;
70  dedupe?: boolean;
71}"#;
72
73/// Per-call options that override client-level defaults for a single request.
74const CALL_OPTIONS_INTERFACE: &str = r#"export interface CallOptions {
75  headers?: Record<string, string>;
76  timeout?: number;
77  signal?: AbortSignal;
78  dedupe?: boolean;
79}"#;
80
81/// Computes a dedup map key from procedure name and serialized input.
82const DEDUP_KEY_FN: &str = r#"function dedupKey(procedure: string, input: unknown, config: RpcClientConfig): string {
83  const serialized = input === undefined
84    ? ""
85    : config.serialize
86      ? config.serialize(input)
87      : JSON.stringify(input);
88  return procedure + ":" + serialized;
89}"#;
90
91/// Wraps a shared promise so that a per-caller AbortSignal can reject independently.
92const WRAP_WITH_SIGNAL_FN: &str = r#"function wrapWithSignal<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
93  if (!signal) return promise;
94  if (signal.aborted) return Promise.reject(signal.reason);
95  return new Promise<T>((resolve, reject) => {
96    const onAbort = () => reject(signal.reason);
97    signal.addEventListener("abort", onAbort, { once: true });
98    promise.then(
99      (value) => { signal.removeEventListener("abort", onAbort); resolve(value); },
100      (error) => { signal.removeEventListener("abort", onAbort); reject(error); },
101    );
102  });
103}"#;
104
105/// Internal fetch helper shared by query and mutate methods.
106const FETCH_HELPER: &str = r#"const DEFAULT_RETRY_ON = [408, 429, 500, 502, 503, 504];
107
108async function rpcFetch(
109  config: RpcClientConfig,
110  method: "GET" | "POST",
111  procedure: string,
112  input?: unknown,
113  callOptions?: CallOptions,
114): Promise<unknown> {
115  let url = `${config.baseUrl}/${procedure}`;
116  const customHeaders = typeof config.headers === "function"
117    ? await config.headers()
118    : config.headers;
119  const baseHeaders: Record<string, string> = { ...customHeaders, ...callOptions?.headers };
120
121  if (method === "GET" && input !== undefined) {
122    const serialized = config.serialize ? config.serialize(input) : JSON.stringify(input);
123    url += `?input=${encodeURIComponent(serialized)}`;
124  } else if (method === "POST" && input !== undefined) {
125    baseHeaders["Content-Type"] = "application/json";
126  }
127
128  const fetchFn = config.fetch ?? globalThis.fetch;
129  const maxAttempts = 1 + (config.retry?.attempts ?? 0);
130  const retryOn = config.retry?.retryOn ?? DEFAULT_RETRY_ON;
131  const effectiveTimeout = callOptions?.timeout ?? PROCEDURE_TIMEOUTS[procedure] ?? config.timeout;
132  const start = Date.now();
133
134  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
135    const reqCtx: RequestContext = { procedure, method, url, headers: { ...baseHeaders }, input };
136    await config.onRequest?.(reqCtx);
137
138    const init: RequestInit = { method, headers: reqCtx.headers };
139    if (method === "POST" && input !== undefined) {
140      init.body = config.serialize ? config.serialize(input) : JSON.stringify(input);
141    }
142
143    let timeoutId: ReturnType<typeof setTimeout> | undefined;
144    const signals: AbortSignal[] = [];
145    if (config.signal) signals.push(config.signal);
146    if (callOptions?.signal) signals.push(callOptions.signal);
147    if (effectiveTimeout) {
148      const controller = new AbortController();
149      timeoutId = setTimeout(() => controller.abort(), effectiveTimeout);
150      signals.push(controller.signal);
151    }
152    if (signals.length > 0) {
153      init.signal = signals.length === 1 ? signals[0] : AbortSignal.any(signals);
154    }
155
156    const isRetryable = attempt < maxAttempts && (method === "GET" || IDEMPOTENT_MUTATIONS.has(procedure));
157
158    try {
159      const res = await fetchFn(url, init);
160
161      if (!res.ok) {
162        let data: unknown;
163        try {
164          data = await res.json();
165        } catch {
166          data = await res.text().catch(() => null);
167        }
168        const rpcError = new RpcError(
169          res.status,
170          `RPC error on "${procedure}": ${res.status} ${res.statusText}`,
171          data,
172        );
173        const canRetry = retryOn.includes(res.status) && isRetryable;
174        await config.onError?.({ procedure, method, url, error: rpcError, attempt, willRetry: canRetry });
175        if (!canRetry) throw rpcError;
176      } else {
177        const json = config.deserialize ? config.deserialize(await res.text()) : await res.json();
178        const result = json?.result?.data ?? json;
179        const duration = Date.now() - start;
180        await config.onResponse?.({ procedure, method, url, response: res, data: result, duration });
181        return result;
182      }
183    } catch (err) {
184      if (err instanceof RpcError) throw err;
185      await config.onError?.({ procedure, method, url, error: err, attempt, willRetry: isRetryable });
186      if (!isRetryable) throw err;
187    } finally {
188      if (timeoutId !== undefined) clearTimeout(timeoutId);
189    }
190
191    if (config.retry) {
192      const d = typeof config.retry.delay === "function"
193        ? config.retry.delay(attempt) : config.retry.delay;
194      await new Promise(r => setTimeout(r, d));
195    }
196  }
197}"#;
198
199/// Internal SSE stream helper for the `stream()` method.
200const STREAM_HELPER: &str = r#"async function* rpcStream<T>(
201  config: RpcClientConfig,
202  procedure: string,
203  input?: unknown,
204  callOptions?: CallOptions,
205): AsyncGenerator<T> {
206  let url = `${config.baseUrl}/${procedure}`;
207  const customHeaders = typeof config.headers === "function"
208    ? await config.headers()
209    : config.headers;
210  const headers: Record<string, string> = {
211    "Content-Type": "application/json",
212    ...customHeaders,
213    ...callOptions?.headers,
214  };
215
216  const fetchFn = config.fetch ?? globalThis.fetch;
217  const init: RequestInit = { method: "POST", headers };
218  if (input !== undefined) {
219    init.body = config.serialize ? config.serialize(input) : JSON.stringify(input);
220  }
221
222  const signals: AbortSignal[] = [];
223  if (config.signal) signals.push(config.signal);
224  if (callOptions?.signal) signals.push(callOptions.signal);
225  if (signals.length > 0) {
226    init.signal = signals.length === 1 ? signals[0] : AbortSignal.any(signals);
227  }
228
229  const res = await fetchFn(url, init);
230  if (!res.ok) {
231    let data: unknown;
232    try { data = await res.json(); } catch { data = null; }
233    throw new RpcError(res.status, `RPC stream error on "${procedure}": ${res.status} ${res.statusText}`, data);
234  }
235
236  const reader = res.body!.getReader();
237  const decoder = new TextDecoder();
238  let buffer = "";
239
240  try {
241    while (true) {
242      const { done, value } = await reader.read();
243      if (done) break;
244      buffer += decoder.decode(value, { stream: true });
245      const parts = buffer.split("\n\n");
246      buffer = parts.pop()!;
247      for (const part of parts) {
248        for (const line of part.split("\n")) {
249          if (line.startsWith("data: ")) {
250            const payload = line.slice(6);
251            yield (config.deserialize ? config.deserialize(payload) : JSON.parse(payload)) as T;
252          }
253        }
254      }
255    }
256  } finally {
257    reader.releaseLock();
258  }
259}"#;
260
261/// Generates the complete `rpc-client.ts` file content from a manifest.
262///
263/// The output includes:
264/// 1. Auto-generation header
265/// 2. Re-export of `Procedures` type from the types file
266/// 3. `RpcError` class for structured error handling
267/// 4. Internal `rpcFetch` helper
268/// 5. `createRpcClient` factory function with fully typed `query` / `mutate` methods
269pub fn generate_client_file(
270    manifest: &Manifest,
271    types_import_path: &str,
272    preserve_docs: bool,
273) -> String {
274    let mut out = String::with_capacity(2048);
275
276    // Header
277    out.push_str(GENERATED_HEADER);
278    out.push('\n');
279
280    // Collect all user-defined type names (structs + enums) for import
281    let type_names: Vec<&str> = manifest
282        .structs
283        .iter()
284        .map(|s| s.name.as_str())
285        .chain(manifest.enums.iter().map(|e| e.name.as_str()))
286        .collect();
287
288    // Import Procedures type (and any referenced types) from the types file
289    if type_names.is_empty() {
290        emit!(
291            out,
292            "import type {{ Procedures }} from \"{types_import_path}\";\n"
293        );
294        emit!(out, "export type {{ Procedures }};\n");
295    } else {
296        let types_csv = type_names.join(", ");
297        emit!(
298            out,
299            "import type {{ Procedures, {types_csv} }} from \"{types_import_path}\";\n"
300        );
301        emit!(out, "export type {{ Procedures, {types_csv} }};\n");
302    }
303
304    // Error class
305    emit!(out, "{ERROR_CLASS}\n");
306
307    // Lifecycle hook context interfaces
308    emit!(out, "{REQUEST_CONTEXT_INTERFACE}\n");
309    emit!(out, "{RESPONSE_CONTEXT_INTERFACE}\n");
310    emit!(out, "{ERROR_CONTEXT_INTERFACE}\n");
311
312    // Retry policy interface
313    emit!(out, "{RETRY_POLICY_INTERFACE}\n");
314
315    // Client config interface
316    emit!(out, "{CONFIG_INTERFACE}\n");
317
318    // Per-call options interface
319    emit!(out, "{CALL_OPTIONS_INTERFACE}\n");
320
321    // Per-procedure timeout defaults (ms)
322    generate_procedure_timeouts(manifest, &mut out);
323
324    // Idempotent mutations set (for retry gating)
325    generate_idempotent_mutations(manifest, &mut out);
326
327    // Internal fetch helper
328    emit!(out, "{FETCH_HELPER}\n");
329
330    // Dedup helpers (only when the manifest has queries)
331    let has_queries = manifest
332        .procedures
333        .iter()
334        .any(|p| p.kind == ProcedureKind::Query);
335    if has_queries {
336        emit!(out, "{DEDUP_KEY_FN}\n");
337        emit!(out, "{WRAP_WITH_SIGNAL_FN}\n");
338    }
339
340    // Stream helper (only when the manifest has streams)
341    let has_streams = manifest
342        .procedures
343        .iter()
344        .any(|p| p.kind == ProcedureKind::Stream);
345    if has_streams {
346        emit!(out, "{STREAM_HELPER}\n");
347    }
348
349    // Type helpers for ergonomic API
350    generate_type_helpers(&mut out);
351    out.push('\n');
352
353    // Client factory
354    generate_client_factory(manifest, preserve_docs, &mut out);
355
356    out
357}
358
359/// Emits the `PROCEDURE_TIMEOUTS` record mapping procedure names to their default timeout in ms.
360fn generate_procedure_timeouts(manifest: &Manifest, out: &mut String) {
361    let entries: Vec<_> = manifest
362        .procedures
363        .iter()
364        .filter_map(|p| p.timeout_ms.map(|ms| format!("  \"{}\": {}", p.name, ms)))
365        .collect();
366
367    if entries.is_empty() {
368        emit!(
369            out,
370            "const PROCEDURE_TIMEOUTS: Record<string, number> = {{}};\n"
371        );
372    } else {
373        emit!(out, "const PROCEDURE_TIMEOUTS: Record<string, number> = {{");
374        for entry in &entries {
375            emit!(out, "{entry},");
376        }
377        emit!(out, "}};\n");
378    }
379}
380
381/// Emits the `IDEMPOTENT_MUTATIONS` set listing mutations marked as safe to retry.
382fn generate_idempotent_mutations(manifest: &Manifest, out: &mut String) {
383    let names: Vec<_> = manifest
384        .procedures
385        .iter()
386        .filter(|p| p.idempotent)
387        .map(|p| format!("\"{}\"", p.name))
388        .collect();
389
390    if names.is_empty() {
391        emit!(
392            out,
393            "const IDEMPOTENT_MUTATIONS: Set<string> = new Set();\n"
394        );
395    } else {
396        emit!(
397            out,
398            "const IDEMPOTENT_MUTATIONS: Set<string> = new Set([{}]);\n",
399            names.join(", ")
400        );
401    }
402}
403
404/// Emits utility types that power the typed client API.
405fn generate_type_helpers(out: &mut String) {
406    emit!(out, "type QueryKey = keyof Procedures[\"queries\"];");
407    emit!(out, "type MutationKey = keyof Procedures[\"mutations\"];");
408    emit!(out, "type StreamKey = keyof Procedures[\"streams\"];");
409    emit!(
410        out,
411        "type QueryInput<K extends QueryKey> = Procedures[\"queries\"][K][\"input\"];"
412    );
413    emit!(
414        out,
415        "type QueryOutput<K extends QueryKey> = Procedures[\"queries\"][K][\"output\"];"
416    );
417    emit!(
418        out,
419        "type MutationInput<K extends MutationKey> = Procedures[\"mutations\"][K][\"input\"];"
420    );
421    emit!(
422        out,
423        "type MutationOutput<K extends MutationKey> = Procedures[\"mutations\"][K][\"output\"];"
424    );
425    emit!(
426        out,
427        "type StreamInput<K extends StreamKey> = Procedures[\"streams\"][K][\"input\"];"
428    );
429    emit!(
430        out,
431        "type StreamOutput<K extends StreamKey> = Procedures[\"streams\"][K][\"output\"];"
432    );
433}
434
435/// Generates the `createRpcClient` factory using an interface for typed overloads.
436fn generate_client_factory(manifest: &Manifest, preserve_docs: bool, out: &mut String) {
437    let queries: Vec<_> = manifest
438        .procedures
439        .iter()
440        .filter(|p| p.kind == ProcedureKind::Query)
441        .collect();
442    let mutations: Vec<_> = manifest
443        .procedures
444        .iter()
445        .filter(|p| p.kind == ProcedureKind::Mutation)
446        .collect();
447    let streams: Vec<_> = manifest
448        .procedures
449        .iter()
450        .filter(|p| p.kind == ProcedureKind::Stream)
451        .collect();
452    let has_queries = !queries.is_empty();
453    let has_mutations = !mutations.is_empty();
454    let has_streams = !streams.is_empty();
455
456    // Partition queries and mutations by void/non-void input
457    let void_queries: Vec<_> = queries.iter().filter(|p| is_void_input(p)).collect();
458    let non_void_queries: Vec<_> = queries.iter().filter(|p| !is_void_input(p)).collect();
459    let void_mutations: Vec<_> = mutations.iter().filter(|p| is_void_input(p)).collect();
460    let non_void_mutations: Vec<_> = mutations.iter().filter(|p| !is_void_input(p)).collect();
461
462    let void_streams: Vec<_> = streams.iter().filter(|p| is_void_input(p)).collect();
463    let non_void_streams: Vec<_> = streams.iter().filter(|p| !is_void_input(p)).collect();
464
465    let query_mixed = !void_queries.is_empty() && !non_void_queries.is_empty();
466    let mutation_mixed = !void_mutations.is_empty() && !non_void_mutations.is_empty();
467    let stream_mixed = !void_streams.is_empty() && !non_void_streams.is_empty();
468
469    // Emit VOID_QUERIES/VOID_MUTATIONS sets when mixed void/non-void exists
470    if query_mixed {
471        let names: Vec<_> = void_queries
472            .iter()
473            .map(|p| format!("\"{}\"", p.name))
474            .collect();
475        emit!(
476            out,
477            "const VOID_QUERIES: Set<string> = new Set([{}]);",
478            names.join(", ")
479        );
480        out.push('\n');
481    }
482    if mutation_mixed {
483        let names: Vec<_> = void_mutations
484            .iter()
485            .map(|p| format!("\"{}\"", p.name))
486            .collect();
487        emit!(
488            out,
489            "const VOID_MUTATIONS: Set<string> = new Set([{}]);",
490            names.join(", ")
491        );
492        out.push('\n');
493    }
494    if stream_mixed {
495        let names: Vec<_> = void_streams
496            .iter()
497            .map(|p| format!("\"{}\"", p.name))
498            .collect();
499        emit!(
500            out,
501            "const VOID_STREAMS: Set<string> = new Set([{}]);",
502            names.join(", ")
503        );
504        out.push('\n');
505    }
506
507    // Emit the RpcClient interface with overloaded method signatures
508    emit!(out, "export interface RpcClient {{");
509
510    if has_queries {
511        generate_query_overloads(manifest, preserve_docs, out);
512    }
513
514    if has_mutations {
515        if has_queries {
516            out.push('\n');
517        }
518        generate_mutation_overloads(manifest, preserve_docs, out);
519    }
520
521    if has_streams {
522        if has_queries || has_mutations {
523            out.push('\n');
524        }
525        generate_stream_overloads(manifest, preserve_docs, out);
526    }
527
528    emit!(out, "}}");
529    out.push('\n');
530
531    // Emit the factory function
532    emit!(
533        out,
534        "export function createRpcClient(config: RpcClientConfig): RpcClient {{"
535    );
536
537    if has_queries {
538        emit!(
539            out,
540            "  const inflight = new Map<string, Promise<unknown>>();\n"
541        );
542    }
543
544    emit!(out, "  return {{");
545
546    if has_queries {
547        emit!(
548            out,
549            "    query(key: QueryKey, ...args: unknown[]): Promise<unknown> {{"
550        );
551
552        // Extract input and callOptions into locals based on void/non-void branching
553        if query_mixed {
554            emit!(out, "      let input: unknown;");
555            emit!(out, "      let callOptions: CallOptions | undefined;");
556            emit!(out, "      if (VOID_QUERIES.has(key)) {{");
557            emit!(out, "        input = undefined;");
558            emit!(
559                out,
560                "        callOptions = args[0] as CallOptions | undefined;"
561            );
562            emit!(out, "      }} else {{");
563            emit!(out, "        input = args[0];");
564            emit!(
565                out,
566                "        callOptions = args[1] as CallOptions | undefined;"
567            );
568            emit!(out, "      }}");
569        } else if !void_queries.is_empty() {
570            emit!(out, "      const input = undefined;");
571            emit!(
572                out,
573                "      const callOptions = args[0] as CallOptions | undefined;"
574            );
575        } else {
576            emit!(out, "      const input = args[0];");
577            emit!(
578                out,
579                "      const callOptions = args[1] as CallOptions | undefined;"
580            );
581        }
582
583        // Dedup logic
584        emit!(
585            out,
586            "      const shouldDedupe = callOptions?.dedupe ?? config.dedupe ?? true;"
587        );
588        emit!(out, "      if (shouldDedupe) {{");
589        emit!(out, "        const k = dedupKey(key, input, config);");
590        emit!(out, "        const existing = inflight.get(k);");
591        emit!(
592            out,
593            "        if (existing) return wrapWithSignal(existing, callOptions?.signal);"
594        );
595        emit!(
596            out,
597            "        const promise = rpcFetch(config, \"GET\", key, input, callOptions)"
598        );
599        emit!(out, "          .finally(() => inflight.delete(k));");
600        emit!(out, "        inflight.set(k, promise);");
601        emit!(
602            out,
603            "        return wrapWithSignal(promise, callOptions?.signal);"
604        );
605        emit!(out, "      }}");
606        emit!(
607            out,
608            "      return rpcFetch(config, \"GET\", key, input, callOptions);"
609        );
610        emit!(out, "    }},");
611    }
612
613    if has_mutations {
614        emit!(
615            out,
616            "    mutate(key: MutationKey, ...args: unknown[]): Promise<unknown> {{"
617        );
618        if mutation_mixed {
619            // Mixed: use VOID_MUTATIONS set to branch at runtime
620            emit!(out, "      if (VOID_MUTATIONS.has(key)) {{");
621            emit!(
622                out,
623                "        return rpcFetch(config, \"POST\", key, undefined, args[0] as CallOptions | undefined);"
624            );
625            emit!(out, "      }}");
626            emit!(
627                out,
628                "      return rpcFetch(config, \"POST\", key, args[0], args[1] as CallOptions | undefined);"
629            );
630        } else if !void_mutations.is_empty() {
631            // All void: args[0] is always CallOptions
632            emit!(
633                out,
634                "      return rpcFetch(config, \"POST\", key, undefined, args[0] as CallOptions | undefined);"
635            );
636        } else {
637            // All non-void: args[0] is input, args[1] is CallOptions
638            emit!(
639                out,
640                "      return rpcFetch(config, \"POST\", key, args[0], args[1] as CallOptions | undefined);"
641            );
642        }
643        emit!(out, "    }},");
644    }
645
646    if has_streams {
647        emit!(
648            out,
649            "    stream(key: StreamKey, ...args: unknown[]): AsyncGenerator<unknown> {{"
650        );
651        if stream_mixed {
652            emit!(out, "      if (VOID_STREAMS.has(key)) {{");
653            emit!(
654                out,
655                "        return rpcStream(config, key, undefined, args[0] as CallOptions | undefined);"
656            );
657            emit!(out, "      }}");
658            emit!(
659                out,
660                "      return rpcStream(config, key, args[0], args[1] as CallOptions | undefined);"
661            );
662        } else if !void_streams.is_empty() {
663            emit!(
664                out,
665                "      return rpcStream(config, key, undefined, args[0] as CallOptions | undefined);"
666            );
667        } else {
668            emit!(
669                out,
670                "      return rpcStream(config, key, args[0], args[1] as CallOptions | undefined);"
671            );
672        }
673        emit!(out, "    }},");
674    }
675
676    emit!(out, "  }} as RpcClient;");
677    emit!(out, "}}");
678}
679
680/// Generates query overload signatures for the RpcClient interface.
681fn generate_query_overloads(manifest: &Manifest, preserve_docs: bool, out: &mut String) {
682    let (void_queries, non_void_queries): (Vec<_>, Vec<_>) = manifest
683        .procedures
684        .iter()
685        .filter(|p| p.kind == ProcedureKind::Query)
686        .partition(|p| is_void_input(p));
687
688    // Overload signatures for void-input queries (no input argument required)
689    for proc in &void_queries {
690        if preserve_docs && let Some(doc) = &proc.docs {
691            emit_jsdoc(doc, "  ", out);
692        }
693        let output_ts = proc
694            .output
695            .as_ref()
696            .map(rust_type_to_ts)
697            .unwrap_or_else(|| "void".to_string());
698        emit!(
699            out,
700            "  query(key: \"{}\"): Promise<{}>;",
701            proc.name,
702            output_ts,
703        );
704        emit!(
705            out,
706            "  query(key: \"{}\", options: CallOptions): Promise<{}>;",
707            proc.name,
708            output_ts,
709        );
710    }
711
712    // Overload signatures for non-void-input queries
713    for proc in &non_void_queries {
714        if preserve_docs && let Some(doc) = &proc.docs {
715            emit_jsdoc(doc, "  ", out);
716        }
717        let input_ts = proc
718            .input
719            .as_ref()
720            .map(rust_type_to_ts)
721            .unwrap_or_else(|| "void".to_string());
722        let output_ts = proc
723            .output
724            .as_ref()
725            .map(rust_type_to_ts)
726            .unwrap_or_else(|| "void".to_string());
727        emit!(
728            out,
729            "  query(key: \"{}\", input: {}): Promise<{}>;",
730            proc.name,
731            input_ts,
732            output_ts,
733        );
734        emit!(
735            out,
736            "  query(key: \"{}\", input: {}, options: CallOptions): Promise<{}>;",
737            proc.name,
738            input_ts,
739            output_ts,
740        );
741    }
742}
743
744/// Generates mutation overload signatures for the RpcClient interface.
745fn generate_mutation_overloads(manifest: &Manifest, preserve_docs: bool, out: &mut String) {
746    let (void_mutations, non_void_mutations): (Vec<_>, Vec<_>) = manifest
747        .procedures
748        .iter()
749        .filter(|p| p.kind == ProcedureKind::Mutation)
750        .partition(|p| is_void_input(p));
751
752    // Overload signatures for void-input mutations
753    for proc in &void_mutations {
754        if preserve_docs && let Some(doc) = &proc.docs {
755            emit_jsdoc(doc, "  ", out);
756        }
757        let output_ts = proc
758            .output
759            .as_ref()
760            .map(rust_type_to_ts)
761            .unwrap_or_else(|| "void".to_string());
762        emit!(
763            out,
764            "  mutate(key: \"{}\"): Promise<{}>;",
765            proc.name,
766            output_ts,
767        );
768        emit!(
769            out,
770            "  mutate(key: \"{}\", options: CallOptions): Promise<{}>;",
771            proc.name,
772            output_ts,
773        );
774    }
775
776    // Overload signatures for non-void-input mutations
777    for proc in &non_void_mutations {
778        if preserve_docs && let Some(doc) = &proc.docs {
779            emit_jsdoc(doc, "  ", out);
780        }
781        let input_ts = proc
782            .input
783            .as_ref()
784            .map(rust_type_to_ts)
785            .unwrap_or_else(|| "void".to_string());
786        let output_ts = proc
787            .output
788            .as_ref()
789            .map(rust_type_to_ts)
790            .unwrap_or_else(|| "void".to_string());
791        emit!(
792            out,
793            "  mutate(key: \"{}\", input: {}): Promise<{}>;",
794            proc.name,
795            input_ts,
796            output_ts,
797        );
798        emit!(
799            out,
800            "  mutate(key: \"{}\", input: {}, options: CallOptions): Promise<{}>;",
801            proc.name,
802            input_ts,
803            output_ts,
804        );
805    }
806}
807
808/// Generates stream overload signatures for the RpcClient interface.
809fn generate_stream_overloads(manifest: &Manifest, preserve_docs: bool, out: &mut String) {
810    let (void_streams, non_void_streams): (Vec<_>, Vec<_>) = manifest
811        .procedures
812        .iter()
813        .filter(|p| p.kind == ProcedureKind::Stream)
814        .partition(|p| is_void_input(p));
815
816    // Overload signatures for void-input streams
817    for proc in &void_streams {
818        if preserve_docs && let Some(doc) = &proc.docs {
819            emit_jsdoc(doc, "  ", out);
820        }
821        let output_ts = proc
822            .output
823            .as_ref()
824            .map(rust_type_to_ts)
825            .unwrap_or_else(|| "void".to_string());
826        emit!(
827            out,
828            "  stream(key: \"{}\"): AsyncGenerator<{}>;",
829            proc.name,
830            output_ts,
831        );
832        emit!(
833            out,
834            "  stream(key: \"{}\", options: CallOptions): AsyncGenerator<{}>;",
835            proc.name,
836            output_ts,
837        );
838    }
839
840    // Overload signatures for non-void-input streams
841    for proc in &non_void_streams {
842        if preserve_docs && let Some(doc) = &proc.docs {
843            emit_jsdoc(doc, "  ", out);
844        }
845        let input_ts = proc
846            .input
847            .as_ref()
848            .map(rust_type_to_ts)
849            .unwrap_or_else(|| "void".to_string());
850        let output_ts = proc
851            .output
852            .as_ref()
853            .map(rust_type_to_ts)
854            .unwrap_or_else(|| "void".to_string());
855        emit!(
856            out,
857            "  stream(key: \"{}\", input: {}): AsyncGenerator<{}>;",
858            proc.name,
859            input_ts,
860            output_ts,
861        );
862        emit!(
863            out,
864            "  stream(key: \"{}\", input: {}, options: CallOptions): AsyncGenerator<{}>;",
865            proc.name,
866            input_ts,
867            output_ts,
868        );
869    }
870}