Skip to main content

metaxy_cli/codegen/
vue.rs

1use crate::model::Manifest;
2
3use super::common::{self, FrameworkConfig};
4
5const QUERY_OPTIONS_INTERFACE: &str = r#"export interface QueryOptions<K extends QueryKey> {
6  /**
7   * Whether to execute the query. @default true
8   *
9   * Pass a getter `() => bool` for reactive updates — a plain `boolean` is
10   * read once when `useQuery` is called and will not trigger re-fetches.
11   */
12  enabled?: boolean | (() => boolean);
13
14  /** Auto-refetch interval in milliseconds. Set to 0 or omit to disable. */
15  refetchInterval?: number;
16
17  /** Initial data shown before the first fetch completes. */
18  placeholderData?: QueryOutput<K>;
19
20  /** Per-call options forwarded to client.query(). */
21  callOptions?: CallOptions;
22
23  /** Called when the query succeeds. */
24  onSuccess?: (data: QueryOutput<K>) => void;
25
26  /** Called when the query fails. */
27  onError?: (error: RpcError) => void;
28
29  /** Called when the query settles (success or failure). */
30  onSettled?: () => void;
31}"#;
32
33const QUERY_RESULT_INTERFACE: &str = r#"export interface QueryResult<K extends QueryKey> {
34  /** The latest successfully resolved data, or placeholderData. */
35  readonly data: Ref<QueryOutput<K> | undefined>;
36
37  /** The error from the most recent failed fetch, cleared on next attempt. */
38  readonly error: Ref<RpcError | undefined>;
39
40  /** True while a fetch is in-flight (including the initial fetch). */
41  readonly isLoading: Ref<boolean>;
42
43  /** True after the first successful fetch. Stays true even if a later refetch fails. */
44  readonly isSuccess: ComputedRef<boolean>;
45
46  /** True when the most recent fetch failed. */
47  readonly isError: ComputedRef<boolean>;
48
49  /** Manually trigger a refetch. No-op when `enabled` is false. Resets the polling interval. */
50  refetch: () => Promise<void>;
51}"#;
52
53const MUTATION_OPTIONS_INTERFACE: &str = r#"export interface MutationOptions<K extends MutationKey> {
54  /** Per-call options forwarded to client.mutate(). */
55  callOptions?: CallOptions;
56
57  /** Called when the mutation succeeds. */
58  onSuccess?: (data: MutationOutput<K>) => void;
59
60  /** Called when the mutation fails. */
61  onError?: (error: RpcError) => void;
62
63  /** Called when the mutation settles (success or failure). */
64  onSettled?: () => void;
65}"#;
66
67const MUTATION_RESULT_INTERFACE: &str = r#"export interface MutationResult<K extends MutationKey> {
68  /** Execute the mutation. Rejects on error. */
69  mutate: (...args: MutationArgs<K>) => Promise<void>;
70
71  /** Execute the mutation and return the result. Rejects on error. */
72  mutateAsync: (...args: MutationArgs<K>) => Promise<MutationOutput<K>>;
73
74  /** The latest successfully resolved data. */
75  readonly data: Ref<MutationOutput<K> | undefined>;
76
77  /** The error from the most recent failed mutation, cleared on next attempt. */
78  readonly error: Ref<RpcError | undefined>;
79
80  /** True while a mutation is in-flight. */
81  readonly isLoading: Ref<boolean>;
82
83  /** True after the most recent mutation succeeded. */
84  readonly isSuccess: ComputedRef<boolean>;
85
86  /** True when the most recent mutation failed. */
87  readonly isError: ComputedRef<boolean>;
88
89  /** Reset state back to idle (clear data, error, status). */
90  reset: () => void;
91}"#;
92
93const USE_QUERY_IMPL: &str = r#"export function useQuery<K extends QueryKey>(
94  client: RpcClient,
95  ...args: unknown[]
96): QueryResult<K> {
97  const key = args[0] as K;
98
99  let inputFn: (() => QueryInput<K>) | undefined;
100  let optionsArg: QueryOptions<K> | (() => QueryOptions<K>) | undefined;
101
102  if (typeof args[1] === "function" && args[2] !== undefined) {
103    inputFn = args[1] as () => QueryInput<K>;
104    optionsArg = args[2] as QueryOptions<K> | (() => QueryOptions<K>) | undefined;
105  } else if (typeof args[1] === "function") {
106    if (VOID_QUERY_KEYS.has(key)) {
107      optionsArg = args[1] as () => QueryOptions<K>;
108    } else {
109      inputFn = args[1] as () => QueryInput<K>;
110    }
111  } else if (typeof args[1] === "object") {
112    optionsArg = args[1] as QueryOptions<K>;
113  }
114
115  function resolveOptions(): QueryOptions<K> | undefined {
116    return typeof optionsArg === "function" ? optionsArg() : optionsArg;
117  }
118
119  function resolveEnabled(): boolean {
120    const opts = resolveOptions();
121    return typeof opts?.enabled === "function"
122      ? opts.enabled()
123      : (opts?.enabled ?? true);
124  }
125
126  const data = ref<QueryOutput<K> | undefined>(resolveOptions()?.placeholderData) as Ref<QueryOutput<K> | undefined>;
127  const error = ref<RpcError | undefined>();
128  const hasFetched = ref(false);
129  const isLoading = ref(false);
130  const isSuccess = computed(() => hasFetched.value);
131  const isError = computed(() => error.value !== undefined);
132
133  let generation = 0;
134  let controller: AbortController | undefined;
135  let intervalId: ReturnType<typeof setInterval> | undefined;
136
137  async function fetchData(input: QueryInput<K> | undefined, signal: AbortSignal, gen: number) {
138    const opts = resolveOptions();
139    isLoading.value = true;
140    error.value = undefined;
141    try {
142      const callArgs: unknown[] = [key];
143      if (input !== undefined) callArgs.push(input);
144      const mergedCallOptions = { ...opts?.callOptions, signal: opts?.callOptions?.signal
145          ? AbortSignal.any([signal, opts.callOptions.signal])
146          : signal };
147      callArgs.push(mergedCallOptions);
148      const result = await (client.query as (...a: unknown[]) => Promise<unknown>)(
149        ...callArgs
150      ) as QueryOutput<K>;
151      if (gen !== generation) return;
152      data.value = result;
153      hasFetched.value = true;
154      opts?.onSuccess?.(data.value!);
155    } catch (e) {
156      if (gen !== generation) return;
157      error.value = e as RpcError;
158      opts?.onError?.(error.value);
159    } finally {
160      if (gen === generation) {
161        isLoading.value = false;
162        opts?.onSettled?.();
163      }
164    }
165  }
166
167  function setupInterval(enabled: boolean, refetchInterval: number | undefined) {
168    if (intervalId) { clearInterval(intervalId); intervalId = undefined; }
169    if (enabled && refetchInterval) {
170      intervalId = setInterval(() => {
171        if (controller && !controller.signal.aborted) {
172          void fetchData(inputFn?.(), controller.signal, generation);
173        }
174      }, refetchInterval);
175    }
176  }
177
178  const stopWatch = watch(
179    () => {
180      const enabled = resolveEnabled();
181      const input = inputFn?.();
182      return { enabled, input, serialized: JSON.stringify(input), refetchInterval: resolveOptions()?.refetchInterval };
183    },
184    (curr, prev) => {
185      const inputChanged = !prev || curr.enabled !== prev.enabled || curr.serialized !== prev.serialized;
186
187      if (inputChanged) {
188        if (controller) { controller.abort(); controller = undefined; }
189        if (curr.enabled) {
190          generation++;
191          const gen = generation;
192          controller = new AbortController();
193          void fetchData(curr.input, controller.signal, gen);
194        } else {
195          isLoading.value = false;
196        }
197      }
198
199      setupInterval(curr.enabled, curr.refetchInterval);
200    },
201    { immediate: true },
202  );
203
204  onScopeDispose(() => {
205    stopWatch();
206    generation++;
207    if (controller) controller.abort();
208    if (intervalId) clearInterval(intervalId);
209  });
210
211  return {
212    data,
213    error,
214    isLoading,
215    isSuccess,
216    isError,
217    refetch: async () => {
218      const enabled = resolveEnabled();
219      if (!enabled) return;
220      generation++;
221      const gen = generation;
222      const localController = new AbortController();
223      if (controller) controller.abort();
224      controller = localController;
225      setupInterval(enabled, resolveOptions()?.refetchInterval);
226      await fetchData(inputFn?.(), localController.signal, gen);
227    },
228  };
229}"#;
230
231const USE_MUTATION_IMPL: &str = r#"export function useMutation<K extends MutationKey>(
232  client: RpcClient,
233  key: K,
234  options?: MutationOptions<K>,
235): MutationResult<K> {
236  const data = ref<MutationOutput<K> | undefined>() as Ref<MutationOutput<K> | undefined>;
237  const error = ref<RpcError | undefined>();
238  const isLoading = ref(false);
239  const hasSucceeded = ref(false);
240  const isSuccess = computed(() => hasSucceeded.value);
241  const isError = computed(() => error.value !== undefined);
242
243  async function execute(...input: MutationArgs<K>): Promise<MutationOutput<K>> {
244    isLoading.value = true;
245    error.value = undefined;
246    hasSucceeded.value = false;
247    try {
248      const callArgs: unknown[] = [key];
249      if (input.length > 0) callArgs.push(input[0]);
250      if (options?.callOptions) callArgs.push(options.callOptions);
251      const result = await (client.mutate as (...a: unknown[]) => Promise<unknown>)(
252        ...callArgs
253      ) as MutationOutput<K>;
254      data.value = result;
255      hasSucceeded.value = true;
256      options?.onSuccess?.(result);
257      return result;
258    } catch (e) {
259      error.value = e as RpcError;
260      options?.onError?.(error.value);
261      throw e;
262    } finally {
263      isLoading.value = false;
264      options?.onSettled?.();
265    }
266  }
267
268  return {
269    mutate: async (...args: MutationArgs<K>) => { await execute(...args); },
270    mutateAsync: (...args: MutationArgs<K>) => execute(...args),
271    data,
272    error,
273    isLoading,
274    isSuccess,
275    isError,
276    reset: () => { data.value = undefined; error.value = undefined; isLoading.value = false; hasSucceeded.value = false; },
277  };
278}"#;
279
280const FRAMEWORK_IMPORT: &str =
281    "import { ref, computed, watch, onScopeDispose, type Ref, type ComputedRef } from \"vue\";";
282
283/// Generates the complete Vue 3 Composition API wrapper file content from a manifest.
284///
285/// Returns an empty string when the manifest contains no procedures (the caller
286/// should skip writing the file in that case).
287pub fn generate_vue_file(
288    manifest: &Manifest,
289    client_import_path: &str,
290    types_import_path: &str,
291    preserve_docs: bool,
292) -> String {
293    common::generate_framework_file(
294        manifest,
295        client_import_path,
296        types_import_path,
297        preserve_docs,
298        &FrameworkConfig {
299            framework_import: Some(FRAMEWORK_IMPORT),
300            query_fn_name: "useQuery",
301            input_as_getter: true,
302            query_interfaces: &[QUERY_OPTIONS_INTERFACE, QUERY_RESULT_INTERFACE],
303            mutation_interfaces: &[MUTATION_OPTIONS_INTERFACE, MUTATION_RESULT_INTERFACE],
304            query_impl: USE_QUERY_IMPL,
305            mutation_impl: USE_MUTATION_IMPL,
306        },
307    )
308}