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  // callOptions.timeout aborts the connection; per-procedure default timeouts are intentionally
226  // not applied for streams — the server manages stream duration via #[rpc_stream(timeout = "...")].
227  if (callOptions?.timeout) {
228    const timeoutController = new AbortController();
229    setTimeout(() => timeoutController.abort(), callOptions.timeout);
230    signals.push(timeoutController.signal);
231  }
232  if (signals.length > 0) {
233    init.signal = signals.length === 1 ? signals[0] : AbortSignal.any(signals);
234  }
235
236  await config.onRequest?.({ procedure, method: "POST", url, headers: { ...headers }, input });
237
238  try {
239    const res = await fetchFn(url, init);
240    if (!res.ok) {
241      let data: unknown;
242      try { data = await res.json(); } catch { data = null; }
243      const err = new RpcError(res.status, `RPC stream error on "${procedure}": ${res.status} ${res.statusText}`, data);
244      await config.onError?.({ procedure, method: "POST", url, error: err, attempt: 1, willRetry: false });
245      throw err;
246    }
247
248    const reader = res.body!.getReader();
249    const decoder = new TextDecoder();
250    let buffer = "";
251
252    try {
253      while (true) {
254        const { done, value } = await reader.read();
255        if (done) break;
256        buffer += decoder.decode(value, { stream: true });
257        const parts = buffer.split("\n\n");
258        buffer = parts.pop()!;
259        for (const part of parts) {
260          let eventType = "message";
261          const dataLines: string[] = [];
262          for (const line of part.split("\n")) {
263            if (line.startsWith("event: ")) {
264              eventType = line.slice(7).trim();
265            } else if (line.startsWith("data: ")) {
266              dataLines.push(line.slice(6));
267            }
268          }
269          for (const payload of dataLines) {
270            if (eventType === "error") {
271              throw new RpcError(500, `RPC stream error on "${procedure}": ${payload}`, null);
272            }
273            yield (config.deserialize ? config.deserialize(payload) : JSON.parse(payload)) as T;
274          }
275        }
276      }
277    } finally {
278      reader.releaseLock();
279    }
280  } catch (err) {
281    if (!(err instanceof RpcError)) {
282      await config.onError?.({ procedure, method: "POST", url, error: err, attempt: 1, willRetry: false });
283    }
284    throw err;
285  }
286}"#;
287
288/// Generates the complete `rpc-client.ts` file content from a manifest.
289///
290/// The output includes:
291/// 1. Auto-generation header
292/// 2. Re-export of `Procedures` type from the types file
293/// 3. `RpcError` class for structured error handling
294/// 4. Internal `rpcFetch` helper
295/// 5. `createRpcClient` factory function with fully typed `query` / `mutate` methods
296pub fn generate_client_file(
297    manifest: &Manifest,
298    types_import_path: &str,
299    preserve_docs: bool,
300) -> String {
301    let mut out = String::with_capacity(2048);
302
303    // Header
304    out.push_str(GENERATED_HEADER);
305    out.push('\n');
306
307    // Collect all user-defined type names (structs + enums) for import
308    let type_names: Vec<&str> = manifest
309        .structs
310        .iter()
311        .map(|s| s.name.as_str())
312        .chain(manifest.enums.iter().map(|e| e.name.as_str()))
313        .collect();
314
315    // Import Procedures type (and any referenced types) from the types file
316    if type_names.is_empty() {
317        emit!(
318            out,
319            "import type {{ Procedures }} from \"{types_import_path}\";\n"
320        );
321        emit!(out, "export type {{ Procedures }};\n");
322    } else {
323        let types_csv = type_names.join(", ");
324        emit!(
325            out,
326            "import type {{ Procedures, {types_csv} }} from \"{types_import_path}\";\n"
327        );
328        emit!(out, "export type {{ Procedures, {types_csv} }};\n");
329    }
330
331    // Error class
332    emit!(out, "{ERROR_CLASS}\n");
333
334    // Lifecycle hook context interfaces
335    emit!(out, "{REQUEST_CONTEXT_INTERFACE}\n");
336    emit!(out, "{RESPONSE_CONTEXT_INTERFACE}\n");
337    emit!(out, "{ERROR_CONTEXT_INTERFACE}\n");
338
339    // Retry policy interface
340    emit!(out, "{RETRY_POLICY_INTERFACE}\n");
341
342    // Client config interface
343    emit!(out, "{CONFIG_INTERFACE}\n");
344
345    // Per-call options interface
346    emit!(out, "{CALL_OPTIONS_INTERFACE}\n");
347
348    // Per-procedure timeout defaults (ms)
349    generate_procedure_timeouts(manifest, &mut out);
350
351    // Idempotent mutations set (for retry gating)
352    generate_idempotent_mutations(manifest, &mut out);
353
354    // Internal fetch helper
355    emit!(out, "{FETCH_HELPER}\n");
356
357    // Dedup helpers (only when the manifest has queries)
358    let has_queries = manifest
359        .procedures
360        .iter()
361        .any(|p| p.kind == ProcedureKind::Query);
362    if has_queries {
363        emit!(out, "{DEDUP_KEY_FN}\n");
364        emit!(out, "{WRAP_WITH_SIGNAL_FN}\n");
365    }
366
367    // Stream helper (only when the manifest has streams)
368    let has_streams = manifest
369        .procedures
370        .iter()
371        .any(|p| p.kind == ProcedureKind::Stream);
372    if has_streams {
373        emit!(out, "{STREAM_HELPER}\n");
374    }
375
376    // Type helpers for ergonomic API
377    generate_type_helpers(&mut out);
378    out.push('\n');
379
380    // Client factory
381    generate_client_factory(manifest, preserve_docs, &mut out);
382
383    out
384}
385
386/// Emits the `PROCEDURE_TIMEOUTS` record mapping procedure names to their default timeout in ms.
387fn generate_procedure_timeouts(manifest: &Manifest, out: &mut String) {
388    let entries: Vec<_> = manifest
389        .procedures
390        .iter()
391        .filter_map(|p| p.timeout_ms.map(|ms| format!("  \"{}\": {}", p.name, ms)))
392        .collect();
393
394    if entries.is_empty() {
395        emit!(
396            out,
397            "const PROCEDURE_TIMEOUTS: Record<string, number> = {{}};\n"
398        );
399    } else {
400        emit!(out, "const PROCEDURE_TIMEOUTS: Record<string, number> = {{");
401        for entry in &entries {
402            emit!(out, "{entry},");
403        }
404        emit!(out, "}};\n");
405    }
406}
407
408/// Emits the `IDEMPOTENT_MUTATIONS` set listing mutations marked as safe to retry.
409fn generate_idempotent_mutations(manifest: &Manifest, out: &mut String) {
410    let names: Vec<_> = manifest
411        .procedures
412        .iter()
413        .filter(|p| p.idempotent)
414        .map(|p| format!("\"{}\"", p.name))
415        .collect();
416
417    if names.is_empty() {
418        emit!(
419            out,
420            "const IDEMPOTENT_MUTATIONS: Set<string> = new Set();\n"
421        );
422    } else {
423        emit!(
424            out,
425            "const IDEMPOTENT_MUTATIONS: Set<string> = new Set([{}]);\n",
426            names.join(", ")
427        );
428    }
429}
430
431/// Emits utility types that power the typed client API.
432fn generate_type_helpers(out: &mut String) {
433    emit!(out, "type QueryKey = keyof Procedures[\"queries\"];");
434    emit!(out, "type MutationKey = keyof Procedures[\"mutations\"];");
435    emit!(out, "type StreamKey = keyof Procedures[\"streams\"];");
436    emit!(
437        out,
438        "type QueryInput<K extends QueryKey> = Procedures[\"queries\"][K][\"input\"];"
439    );
440    emit!(
441        out,
442        "type QueryOutput<K extends QueryKey> = Procedures[\"queries\"][K][\"output\"];"
443    );
444    emit!(
445        out,
446        "type MutationInput<K extends MutationKey> = Procedures[\"mutations\"][K][\"input\"];"
447    );
448    emit!(
449        out,
450        "type MutationOutput<K extends MutationKey> = Procedures[\"mutations\"][K][\"output\"];"
451    );
452    emit!(
453        out,
454        "type StreamInput<K extends StreamKey> = Procedures[\"streams\"][K][\"input\"];"
455    );
456    emit!(
457        out,
458        "type StreamOutput<K extends StreamKey> = Procedures[\"streams\"][K][\"output\"];"
459    );
460}
461
462/// Generates the `createRpcClient` factory using an interface for typed overloads.
463fn generate_client_factory(manifest: &Manifest, preserve_docs: bool, out: &mut String) {
464    let queries: Vec<_> = manifest
465        .procedures
466        .iter()
467        .filter(|p| p.kind == ProcedureKind::Query)
468        .collect();
469    let mutations: Vec<_> = manifest
470        .procedures
471        .iter()
472        .filter(|p| p.kind == ProcedureKind::Mutation)
473        .collect();
474    let streams: Vec<_> = manifest
475        .procedures
476        .iter()
477        .filter(|p| p.kind == ProcedureKind::Stream)
478        .collect();
479    let has_queries = !queries.is_empty();
480    let has_mutations = !mutations.is_empty();
481    let has_streams = !streams.is_empty();
482
483    // Partition queries and mutations by void/non-void input
484    let void_queries: Vec<_> = queries.iter().filter(|p| is_void_input(p)).collect();
485    let non_void_queries: Vec<_> = queries.iter().filter(|p| !is_void_input(p)).collect();
486    let void_mutations: Vec<_> = mutations.iter().filter(|p| is_void_input(p)).collect();
487    let non_void_mutations: Vec<_> = mutations.iter().filter(|p| !is_void_input(p)).collect();
488
489    let void_streams: Vec<_> = streams.iter().filter(|p| is_void_input(p)).collect();
490    let non_void_streams: Vec<_> = streams.iter().filter(|p| !is_void_input(p)).collect();
491
492    let query_mixed = !void_queries.is_empty() && !non_void_queries.is_empty();
493    let mutation_mixed = !void_mutations.is_empty() && !non_void_mutations.is_empty();
494    let stream_mixed = !void_streams.is_empty() && !non_void_streams.is_empty();
495
496    // Emit VOID_QUERIES/VOID_MUTATIONS sets when mixed void/non-void exists
497    if query_mixed {
498        let names: Vec<_> = void_queries
499            .iter()
500            .map(|p| format!("\"{}\"", p.name))
501            .collect();
502        emit!(
503            out,
504            "const VOID_QUERIES: Set<string> = new Set([{}]);",
505            names.join(", ")
506        );
507        out.push('\n');
508    }
509    if mutation_mixed {
510        let names: Vec<_> = void_mutations
511            .iter()
512            .map(|p| format!("\"{}\"", p.name))
513            .collect();
514        emit!(
515            out,
516            "const VOID_MUTATIONS: Set<string> = new Set([{}]);",
517            names.join(", ")
518        );
519        out.push('\n');
520    }
521    if stream_mixed {
522        let names: Vec<_> = void_streams
523            .iter()
524            .map(|p| format!("\"{}\"", p.name))
525            .collect();
526        emit!(
527            out,
528            "const VOID_STREAMS: Set<string> = new Set([{}]);",
529            names.join(", ")
530        );
531        out.push('\n');
532    }
533
534    // Emit the RpcClient interface with overloaded method signatures
535    emit!(out, "export interface RpcClient {{");
536
537    if has_queries {
538        generate_query_overloads(manifest, preserve_docs, out);
539    }
540
541    if has_mutations {
542        if has_queries {
543            out.push('\n');
544        }
545        generate_mutation_overloads(manifest, preserve_docs, out);
546    }
547
548    if has_streams {
549        if has_queries || has_mutations {
550            out.push('\n');
551        }
552        generate_stream_overloads(manifest, preserve_docs, out);
553    }
554
555    emit!(out, "}}");
556    out.push('\n');
557
558    // Emit the factory function
559    emit!(
560        out,
561        "export function createRpcClient(config: RpcClientConfig): RpcClient {{"
562    );
563
564    if has_queries {
565        emit!(
566            out,
567            "  const inflight = new Map<string, Promise<unknown>>();\n"
568        );
569    }
570
571    emit!(out, "  return {{");
572
573    if has_queries {
574        emit!(
575            out,
576            "    query(key: QueryKey, ...args: unknown[]): Promise<unknown> {{"
577        );
578
579        // Extract input and callOptions into locals based on void/non-void branching
580        if query_mixed {
581            emit!(out, "      let input: unknown;");
582            emit!(out, "      let callOptions: CallOptions | undefined;");
583            emit!(out, "      if (VOID_QUERIES.has(key)) {{");
584            emit!(out, "        input = undefined;");
585            emit!(
586                out,
587                "        callOptions = args[0] as CallOptions | undefined;"
588            );
589            emit!(out, "      }} else {{");
590            emit!(out, "        input = args[0];");
591            emit!(
592                out,
593                "        callOptions = args[1] as CallOptions | undefined;"
594            );
595            emit!(out, "      }}");
596        } else if !void_queries.is_empty() {
597            emit!(out, "      const input = undefined;");
598            emit!(
599                out,
600                "      const callOptions = args[0] as CallOptions | undefined;"
601            );
602        } else {
603            emit!(out, "      const input = args[0];");
604            emit!(
605                out,
606                "      const callOptions = args[1] as CallOptions | undefined;"
607            );
608        }
609
610        // Dedup logic
611        emit!(
612            out,
613            "      const shouldDedupe = callOptions?.dedupe ?? config.dedupe ?? true;"
614        );
615        emit!(out, "      if (shouldDedupe) {{");
616        emit!(out, "        const k = dedupKey(key, input, config);");
617        emit!(out, "        const existing = inflight.get(k);");
618        emit!(
619            out,
620            "        if (existing) return wrapWithSignal(existing, callOptions?.signal);"
621        );
622        emit!(
623            out,
624            "        const promise = rpcFetch(config, \"GET\", key, input, callOptions)"
625        );
626        emit!(out, "          .finally(() => inflight.delete(k));");
627        emit!(out, "        inflight.set(k, promise);");
628        emit!(
629            out,
630            "        return wrapWithSignal(promise, callOptions?.signal);"
631        );
632        emit!(out, "      }}");
633        emit!(
634            out,
635            "      return rpcFetch(config, \"GET\", key, input, callOptions);"
636        );
637        emit!(out, "    }},");
638    }
639
640    if has_mutations {
641        emit!(
642            out,
643            "    mutate(key: MutationKey, ...args: unknown[]): Promise<unknown> {{"
644        );
645        if mutation_mixed {
646            // Mixed: use VOID_MUTATIONS set to branch at runtime
647            emit!(out, "      if (VOID_MUTATIONS.has(key)) {{");
648            emit!(
649                out,
650                "        return rpcFetch(config, \"POST\", key, undefined, args[0] as CallOptions | undefined);"
651            );
652            emit!(out, "      }}");
653            emit!(
654                out,
655                "      return rpcFetch(config, \"POST\", key, args[0], args[1] as CallOptions | undefined);"
656            );
657        } else if !void_mutations.is_empty() {
658            // All void: args[0] is always CallOptions
659            emit!(
660                out,
661                "      return rpcFetch(config, \"POST\", key, undefined, args[0] as CallOptions | undefined);"
662            );
663        } else {
664            // All non-void: args[0] is input, args[1] is CallOptions
665            emit!(
666                out,
667                "      return rpcFetch(config, \"POST\", key, args[0], args[1] as CallOptions | undefined);"
668            );
669        }
670        emit!(out, "    }},");
671    }
672
673    if has_streams {
674        emit!(
675            out,
676            "    stream(key: StreamKey, ...args: unknown[]): AsyncGenerator<unknown> {{"
677        );
678        if stream_mixed {
679            emit!(out, "      if (VOID_STREAMS.has(key)) {{");
680            emit!(
681                out,
682                "        return rpcStream(config, key, undefined, args[0] as CallOptions | undefined);"
683            );
684            emit!(out, "      }}");
685            emit!(
686                out,
687                "      return rpcStream(config, key, args[0], args[1] as CallOptions | undefined);"
688            );
689        } else if !void_streams.is_empty() {
690            emit!(
691                out,
692                "      return rpcStream(config, key, undefined, args[0] as CallOptions | undefined);"
693            );
694        } else {
695            emit!(
696                out,
697                "      return rpcStream(config, key, args[0], args[1] as CallOptions | undefined);"
698            );
699        }
700        emit!(out, "    }},");
701    }
702
703    emit!(out, "  }} as RpcClient;");
704    emit!(out, "}}");
705}
706
707/// Generates query overload signatures for the RpcClient interface.
708fn generate_query_overloads(manifest: &Manifest, preserve_docs: bool, out: &mut String) {
709    let (void_queries, non_void_queries): (Vec<_>, Vec<_>) = manifest
710        .procedures
711        .iter()
712        .filter(|p| p.kind == ProcedureKind::Query)
713        .partition(|p| is_void_input(p));
714
715    // Overload signatures for void-input queries (no input argument required)
716    for proc in &void_queries {
717        if preserve_docs && let Some(doc) = &proc.docs {
718            emit_jsdoc(doc, "  ", out);
719        }
720        let output_ts = proc
721            .output
722            .as_ref()
723            .map(rust_type_to_ts)
724            .unwrap_or_else(|| "void".to_string());
725        emit!(
726            out,
727            "  query(key: \"{}\"): Promise<{}>;",
728            proc.name,
729            output_ts,
730        );
731        emit!(
732            out,
733            "  query(key: \"{}\", options: CallOptions): Promise<{}>;",
734            proc.name,
735            output_ts,
736        );
737    }
738
739    // Overload signatures for non-void-input queries
740    for proc in &non_void_queries {
741        if preserve_docs && let Some(doc) = &proc.docs {
742            emit_jsdoc(doc, "  ", out);
743        }
744        let input_ts = proc
745            .input
746            .as_ref()
747            .map(rust_type_to_ts)
748            .unwrap_or_else(|| "void".to_string());
749        let output_ts = proc
750            .output
751            .as_ref()
752            .map(rust_type_to_ts)
753            .unwrap_or_else(|| "void".to_string());
754        emit!(
755            out,
756            "  query(key: \"{}\", input: {}): Promise<{}>;",
757            proc.name,
758            input_ts,
759            output_ts,
760        );
761        emit!(
762            out,
763            "  query(key: \"{}\", input: {}, options: CallOptions): Promise<{}>;",
764            proc.name,
765            input_ts,
766            output_ts,
767        );
768    }
769}
770
771/// Generates mutation overload signatures for the RpcClient interface.
772fn generate_mutation_overloads(manifest: &Manifest, preserve_docs: bool, out: &mut String) {
773    let (void_mutations, non_void_mutations): (Vec<_>, Vec<_>) = manifest
774        .procedures
775        .iter()
776        .filter(|p| p.kind == ProcedureKind::Mutation)
777        .partition(|p| is_void_input(p));
778
779    // Overload signatures for void-input mutations
780    for proc in &void_mutations {
781        if preserve_docs && let Some(doc) = &proc.docs {
782            emit_jsdoc(doc, "  ", out);
783        }
784        let output_ts = proc
785            .output
786            .as_ref()
787            .map(rust_type_to_ts)
788            .unwrap_or_else(|| "void".to_string());
789        emit!(
790            out,
791            "  mutate(key: \"{}\"): Promise<{}>;",
792            proc.name,
793            output_ts,
794        );
795        emit!(
796            out,
797            "  mutate(key: \"{}\", options: CallOptions): Promise<{}>;",
798            proc.name,
799            output_ts,
800        );
801    }
802
803    // Overload signatures for non-void-input mutations
804    for proc in &non_void_mutations {
805        if preserve_docs && let Some(doc) = &proc.docs {
806            emit_jsdoc(doc, "  ", out);
807        }
808        let input_ts = proc
809            .input
810            .as_ref()
811            .map(rust_type_to_ts)
812            .unwrap_or_else(|| "void".to_string());
813        let output_ts = proc
814            .output
815            .as_ref()
816            .map(rust_type_to_ts)
817            .unwrap_or_else(|| "void".to_string());
818        emit!(
819            out,
820            "  mutate(key: \"{}\", input: {}): Promise<{}>;",
821            proc.name,
822            input_ts,
823            output_ts,
824        );
825        emit!(
826            out,
827            "  mutate(key: \"{}\", input: {}, options: CallOptions): Promise<{}>;",
828            proc.name,
829            input_ts,
830            output_ts,
831        );
832    }
833}
834
835/// Generates stream overload signatures for the RpcClient interface.
836fn generate_stream_overloads(manifest: &Manifest, preserve_docs: bool, out: &mut String) {
837    let (void_streams, non_void_streams): (Vec<_>, Vec<_>) = manifest
838        .procedures
839        .iter()
840        .filter(|p| p.kind == ProcedureKind::Stream)
841        .partition(|p| is_void_input(p));
842
843    // Overload signatures for void-input streams
844    for proc in &void_streams {
845        if preserve_docs && let Some(doc) = &proc.docs {
846            emit_jsdoc(doc, "  ", out);
847        }
848        let output_ts = proc
849            .output
850            .as_ref()
851            .map(rust_type_to_ts)
852            .unwrap_or_else(|| "void".to_string());
853        emit!(
854            out,
855            "  stream(key: \"{}\"): AsyncGenerator<{}>;",
856            proc.name,
857            output_ts,
858        );
859        emit!(
860            out,
861            "  stream(key: \"{}\", options: CallOptions): AsyncGenerator<{}>;",
862            proc.name,
863            output_ts,
864        );
865    }
866
867    // Overload signatures for non-void-input streams
868    for proc in &non_void_streams {
869        if preserve_docs && let Some(doc) = &proc.docs {
870            emit_jsdoc(doc, "  ", out);
871        }
872        let input_ts = proc
873            .input
874            .as_ref()
875            .map(rust_type_to_ts)
876            .unwrap_or_else(|| "void".to_string());
877        let output_ts = proc
878            .output
879            .as_ref()
880            .map(rust_type_to_ts)
881            .unwrap_or_else(|| "void".to_string());
882        emit!(
883            out,
884            "  stream(key: \"{}\", input: {}): AsyncGenerator<{}>;",
885            proc.name,
886            input_ts,
887            output_ts,
888        );
889        emit!(
890            out,
891            "  stream(key: \"{}\", input: {}, options: CallOptions): AsyncGenerator<{}>;",
892            proc.name,
893            input_ts,
894            output_ts,
895        );
896    }
897}