Skip to main content

metaxy_cli/codegen/
solid.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 interface QueryResult<K extends QueryKey> {
34  /** The latest successfully resolved data, or placeholderData. */
35  data: () => QueryOutput<K> | undefined;
36
37  /** The error from the most recent failed fetch, cleared on next attempt. */
38  error: () => RpcError | undefined;
39
40  /** True while a fetch is in-flight (including the initial fetch). */
41  isLoading: () => boolean;
42
43  /** True after the first successful fetch. Stays true even if a later refetch fails. */
44  isSuccess: () => boolean;
45
46  /** True when the most recent fetch failed. */
47  isError: () => 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  data: () => MutationOutput<K> | undefined;
76
77  /** The error from the most recent failed mutation, cleared on next attempt. */
78  error: () => RpcError | undefined;
79
80  /** True while a mutation is in-flight. */
81  isLoading: () => boolean;
82
83  /** True after the most recent mutation succeeded. */
84  isSuccess: () => boolean;
85
86  /** True when the most recent mutation failed. */
87  isError: () => boolean;
88
89  /** Reset state back to idle (clear data, error, status). */
90  reset: () => void;
91}"#;
92
93const CREATE_QUERY_IMPL: &str = r#"export function createQuery<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 initialOpts = resolveOptions();
127  const initialEnabled = typeof initialOpts?.enabled === "function"
128    ? initialOpts.enabled()
129    : (initialOpts?.enabled ?? true);
130
131  const [data, setData] = createSignal<QueryOutput<K> | undefined>(initialOpts?.placeholderData);
132  const [error, setError] = createSignal<RpcError | undefined>();
133  const [isLoading, setIsLoading] = createSignal(initialEnabled);
134  const [hasFetched, setHasFetched] = createSignal(false);
135
136  const resolvedEnabled = createMemo(() => resolveEnabled());
137  const isSuccess = createMemo(() => hasFetched());
138  const isError = createMemo(() => error() !== undefined);
139
140  let generation = 0;
141  let controller: AbortController | undefined;
142  let intervalId: ReturnType<typeof setInterval> | undefined;
143
144  async function fetchData(input: QueryInput<K> | undefined, signal: AbortSignal, gen: number) {
145    const opts = resolveOptions();
146    setIsLoading(true);
147    setError(undefined);
148    try {
149      const callArgs: unknown[] = [key];
150      if (input !== undefined) callArgs.push(input);
151      const mergedCallOptions = { ...opts?.callOptions, signal: opts?.callOptions?.signal
152          ? AbortSignal.any([signal, opts.callOptions.signal])
153          : signal };
154      callArgs.push(mergedCallOptions);
155      const result = await (client.query as (...a: unknown[]) => Promise<unknown>)(
156        ...callArgs
157      ) as QueryOutput<K>;
158      if (gen !== generation) return;
159      setData(result as Exclude<QueryOutput<K> | undefined, Function>);
160      setHasFetched(true);
161      opts?.onSuccess?.(result);
162    } catch (e) {
163      if (gen !== generation) return;
164      const err = e as RpcError;
165      setError(err as Exclude<RpcError | undefined, Function>);
166      opts?.onError?.(err);
167    } finally {
168      if (gen === generation) {
169        setIsLoading(false);
170        opts?.onSettled?.();
171      }
172    }
173  }
174
175  function setupInterval(enabled: boolean, refetchInterval: number | undefined) {
176    if (intervalId) { clearInterval(intervalId); intervalId = undefined; }
177    if (enabled && refetchInterval) {
178      intervalId = setInterval(() => {
179        if (controller && !controller.signal.aborted) {
180          void fetchData(inputFn?.(), controller.signal, generation);
181        }
182      }, refetchInterval);
183    }
184  }
185
186  createEffect(() => {
187    const enabled = resolvedEnabled();
188    const input = inputFn?.();
189
190    if (controller) controller.abort();
191    if (enabled) {
192      generation++;
193      const gen = generation;
194      controller = new AbortController();
195      untrack(() => { void fetchData(input, controller.signal, gen); });
196    } else {
197      setIsLoading(false);
198      controller = undefined;
199    }
200
201    onCleanup(() => {
202      if (controller) { controller.abort(); controller = undefined; }
203    });
204  });
205
206  createEffect(() => {
207    const enabled = resolveEnabled();
208    const refetchInterval = resolveOptions()?.refetchInterval;
209
210    setupInterval(enabled, refetchInterval);
211
212    onCleanup(() => {
213      if (intervalId) { clearInterval(intervalId); intervalId = undefined; }
214    });
215  });
216
217  return {
218    data: data as () => QueryOutput<K> | undefined,
219    error,
220    isLoading,
221    isSuccess,
222    isError,
223    refetch: async () => {
224      const enabled = resolveEnabled();
225      if (!enabled) return;
226      generation++;
227      const gen = generation;
228      const localController = new AbortController();
229      if (controller) controller.abort();
230      controller = localController;
231      setupInterval(enabled, resolveOptions()?.refetchInterval);
232      await fetchData(inputFn?.(), localController.signal, gen);
233    },
234  };
235}"#;
236
237const CREATE_MUTATION_IMPL: &str = r#"export function createMutation<K extends MutationKey>(
238  client: RpcClient,
239  key: K,
240  options?: MutationOptions<K>,
241): MutationResult<K> {
242  const [data, setData] = createSignal<MutationOutput<K> | undefined>();
243  const [error, setError] = createSignal<RpcError | undefined>();
244  const [isLoading, setIsLoading] = createSignal(false);
245  const [hasSucceeded, setHasSucceeded] = createSignal(false);
246
247  const isSuccess = createMemo(() => hasSucceeded());
248  const isError = createMemo(() => error() !== undefined);
249
250  async function execute(...input: MutationArgs<K>): Promise<MutationOutput<K>> {
251    setIsLoading(true);
252    setError(undefined);
253    setHasSucceeded(false);
254    try {
255      const callArgs: unknown[] = [key];
256      if (input.length > 0) callArgs.push(input[0]);
257      if (options?.callOptions) callArgs.push(options.callOptions);
258      const result = await (client.mutate as (...a: unknown[]) => Promise<unknown>)(
259        ...callArgs
260      ) as MutationOutput<K>;
261      setData(result as Exclude<MutationOutput<K> | undefined, Function>);
262      setHasSucceeded(true);
263      options?.onSuccess?.(result);
264      return result;
265    } catch (e) {
266      const err = e as RpcError;
267      setError(err as Exclude<RpcError | undefined, Function>);
268      options?.onError?.(err);
269      throw e;
270    } finally {
271      setIsLoading(false);
272      options?.onSettled?.();
273    }
274  }
275
276  return {
277    mutate: async (...args: MutationArgs<K>) => { await execute(...args); },
278    mutateAsync: (...args: MutationArgs<K>) => execute(...args),
279    data: data as () => MutationOutput<K> | undefined,
280    error,
281    isLoading,
282    isSuccess,
283    isError,
284    reset: () => batch(() => { setData(undefined); setError(undefined); setIsLoading(false); setHasSucceeded(false); }),
285  };
286}"#;
287
288const FRAMEWORK_IMPORT: &str = "import { createSignal, createEffect, createMemo, onCleanup, batch, untrack } from \"solid-js\";";
289
290/// Generates the complete SolidJS reactive primitives file content from a manifest.
291///
292/// Returns an empty string when the manifest contains no procedures (the caller
293/// should skip writing the file in that case).
294pub fn generate_solid_file(
295    manifest: &Manifest,
296    client_import_path: &str,
297    types_import_path: &str,
298    preserve_docs: bool,
299) -> String {
300    common::generate_framework_file(
301        manifest,
302        client_import_path,
303        types_import_path,
304        preserve_docs,
305        &FrameworkConfig {
306            framework_import: Some(FRAMEWORK_IMPORT),
307            query_fn_name: "createQuery",
308            input_as_getter: true,
309            query_interfaces: &[QUERY_OPTIONS_INTERFACE, QUERY_RESULT_INTERFACE],
310            mutation_interfaces: &[MUTATION_OPTIONS_INTERFACE, MUTATION_RESULT_INTERFACE],
311            query_impl: CREATE_QUERY_IMPL,
312            mutation_impl: CREATE_MUTATION_IMPL,
313        },
314    )
315}