Skip to main content

metaxy_cli/codegen/
svelte.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 `createQuery` 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 type QueryStatus = "idle" | "loading" | "success" | "error";
34
35export interface QueryResult<K extends QueryKey> {
36  /** The latest successfully resolved data, or placeholderData. */
37  readonly data: QueryOutput<K> | undefined;
38
39  /** The error from the most recent failed fetch, cleared on next attempt. */
40  readonly error: RpcError | undefined;
41
42  /** Current status of the query. Derived: loading > error > success > idle. */
43  readonly status: QueryStatus;
44
45  /** True while a fetch is in-flight (including the initial fetch). */
46  readonly isLoading: boolean;
47
48  /** True after the first successful fetch. Stays true even if a later refetch fails. */
49  readonly isSuccess: boolean;
50
51  /** True when the most recent fetch failed. */
52  readonly isError: boolean;
53
54  /** True when placeholderData is being shown and no real fetch has completed yet. */
55  readonly isPlaceholderData: boolean;
56
57  /** Manually trigger a refetch. No-op when `enabled` is false. Resets the polling interval. */
58  refetch: () => Promise<void>;
59}"#;
60
61const MUTATION_OPTIONS_INTERFACE: &str = r#"export interface MutationOptions<K extends MutationKey> {
62  /** Per-call options forwarded to client.mutate(). */
63  callOptions?: CallOptions;
64
65  /** Called when the mutation succeeds. */
66  onSuccess?: (data: MutationOutput<K>) => void;
67
68  /** Called when the mutation fails. */
69  onError?: (error: RpcError) => void;
70
71  /** Called when the mutation settles (success or failure). */
72  onSettled?: () => void;
73}"#;
74
75const MUTATION_RESULT_INTERFACE: &str = r#"export interface MutationResult<K extends MutationKey> {
76  /** Execute the mutation. Rejects on error. */
77  mutate: (...args: MutationArgs<K>) => Promise<void>;
78
79  /** Execute the mutation and return the result. Rejects on error. */
80  mutateAsync: (...args: MutationArgs<K>) => Promise<MutationOutput<K>>;
81
82  /** The latest successfully resolved data. */
83  readonly data: MutationOutput<K> | undefined;
84
85  /** The error from the most recent failed mutation, cleared on next attempt. */
86  readonly error: RpcError | undefined;
87
88  /** True while a mutation is in-flight. */
89  readonly isLoading: boolean;
90
91  /** True after the most recent mutation succeeded. */
92  readonly isSuccess: boolean;
93
94  /** True when the most recent mutation failed. */
95  readonly isError: boolean;
96
97  /** Reset state back to idle (clear data, error, status). */
98  reset: () => void;
99}"#;
100
101const CREATE_QUERY_IMPL: &str = r#"export function createQuery<K extends QueryKey>(
102  client: RpcClient,
103  ...args: unknown[]
104): QueryResult<K> {
105  const key = args[0] as K;
106
107  let inputFn: (() => QueryInput<K>) | undefined;
108  let optionsArg: QueryOptions<K> | (() => QueryOptions<K>) | undefined;
109
110  if (typeof args[1] === "function" && args[2] !== undefined) {
111    inputFn = args[1] as () => QueryInput<K>;
112    optionsArg = args[2] as QueryOptions<K> | (() => QueryOptions<K>) | undefined;
113  } else if (typeof args[1] === "function") {
114    if (VOID_QUERY_KEYS.has(key)) {
115      optionsArg = args[1] as () => QueryOptions<K>;
116    } else {
117      inputFn = args[1] as () => QueryInput<K>;
118    }
119  } else if (typeof args[1] === "object") {
120    optionsArg = args[1] as QueryOptions<K>;
121  }
122
123  function resolveOptions(): QueryOptions<K> | undefined {
124    return typeof optionsArg === "function" ? optionsArg() : optionsArg;
125  }
126
127  function resolveEnabled(): boolean {
128    const opts = resolveOptions();
129    return typeof opts?.enabled === "function"
130      ? opts.enabled()
131      : (opts?.enabled ?? true);
132  }
133
134  let data = $state<QueryOutput<K> | undefined>(resolveOptions()?.placeholderData);
135  let error = $state<RpcError | undefined>();
136  let hasFetched = $state(false);
137  let loading = $state(false);
138
139  let generation = 0;
140  let controller: AbortController | undefined;
141  let intervalId: ReturnType<typeof setInterval> | undefined;
142  async function fetchData(input: QueryInput<K> | undefined, signal: AbortSignal, gen: number) {
143    const opts = resolveOptions();
144    loading = true;
145    error = undefined;
146    try {
147      const callArgs: unknown[] = [key];
148      if (input !== undefined) callArgs.push(input);
149      const mergedCallOptions = { ...opts?.callOptions, signal: opts?.callOptions?.signal
150          ? AbortSignal.any([signal, opts.callOptions.signal])
151          : signal };
152      callArgs.push(mergedCallOptions);
153      const result = await (client.query as (...a: unknown[]) => Promise<unknown>)(...callArgs) as QueryOutput<K>;
154      if (gen !== generation) return;
155      data = result;
156      hasFetched = true;
157      opts?.onSuccess?.(result);
158    } catch (e) {
159      if (gen !== generation) return;
160      error = e as RpcError;
161      opts?.onError?.(error);
162    } finally {
163      if (gen === generation) {
164        loading = false;
165        opts?.onSettled?.();
166      }
167    }
168  }
169
170  function setupInterval(enabled: boolean, refetchInterval: number | undefined) {
171    if (intervalId) { clearInterval(intervalId); intervalId = undefined; }
172    if (enabled && refetchInterval) {
173      intervalId = setInterval(() => {
174        if (controller && !controller.signal.aborted) {
175          void fetchData(inputFn?.(), controller.signal, generation);
176        }
177      }, refetchInterval);
178    }
179  }
180
181  $effect(() => {
182    const enabled = resolveEnabled();
183    const input = inputFn?.();
184    const refetchInterval = resolveOptions()?.refetchInterval;
185
186    if (controller) controller.abort();
187    if (enabled) {
188      generation++;
189      const gen = generation;
190      controller = new AbortController();
191      void fetchData(input, controller.signal, gen);
192    } else {
193      loading = false;
194      controller = undefined;
195    }
196
197    setupInterval(enabled, refetchInterval);
198
199    return () => {
200      if (intervalId) { clearInterval(intervalId); intervalId = undefined; }
201    };
202  });
203
204  $effect(() => {
205    return () => {
206      generation++;
207      if (controller) { controller.abort(); controller = undefined; }
208    };
209  });
210
211  return {
212    get data() { return data; },
213    get error() { return error; },
214    get status(): QueryStatus {
215      if (loading) return "loading";
216      if (error !== undefined) return "error";
217      if (hasFetched) return "success";
218      return "idle";
219    },
220    get isLoading() { return loading; },
221    get isSuccess() { return hasFetched; },
222    get isError() { return error !== undefined; },
223    get isPlaceholderData() { return !hasFetched && data !== undefined; },
224    refetch: async () => {
225      const enabled = resolveEnabled();
226      if (!enabled) return;
227      generation++;
228      const gen = generation;
229      const localController = new AbortController();
230      if (controller) controller.abort();
231      controller = localController;
232      setupInterval(enabled, resolveOptions()?.refetchInterval);
233      await fetchData(inputFn?.(), localController.signal, gen);
234    },
235  };
236}"#;
237
238const CREATE_MUTATION_IMPL: &str = r#"export function createMutation<K extends MutationKey>(
239  client: RpcClient,
240  key: K,
241  options?: MutationOptions<K>,
242): MutationResult<K> {
243  let data = $state<MutationOutput<K> | undefined>();
244  let error = $state<RpcError | undefined>();
245  let loading = $state(false);
246  let hasSucceeded = $state(false);
247
248  async function execute(...input: MutationArgs<K>): Promise<MutationOutput<K>> {
249    loading = true;
250    error = undefined;
251    hasSucceeded = false;
252    try {
253      const callArgs: unknown[] = [key];
254      if (input.length > 0) callArgs.push(input[0]);
255      if (options?.callOptions) callArgs.push(options.callOptions);
256      const result = await (client.mutate as (...a: unknown[]) => Promise<unknown>)(...callArgs) as MutationOutput<K>;
257      data = result;
258      hasSucceeded = true;
259      options?.onSuccess?.(result);
260      return result;
261    } catch (e) {
262      error = e as RpcError;
263      options?.onError?.(error);
264      throw e;
265    } finally {
266      loading = false;
267      options?.onSettled?.();
268    }
269  }
270
271  return {
272    mutate: async (...args: MutationArgs<K>) => { await execute(...args); },
273    mutateAsync: (...args: MutationArgs<K>) => execute(...args),
274    get data() { return data; },
275    get error() { return error; },
276    get isLoading() { return loading; },
277    get isSuccess() { return hasSucceeded; },
278    get isError() { return error !== undefined; },
279    reset: () => { data = undefined; error = undefined; loading = false; hasSucceeded = false; },
280  } as MutationResult<K>;
281}"#;
282
283/// Generates the complete Svelte 5 reactive 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_svelte_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: None,
300            query_fn_name: "createQuery",
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: CREATE_QUERY_IMPL,
305            mutation_impl: CREATE_MUTATION_IMPL,
306        },
307    )
308}