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. Works even 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      generation++;
225      const gen = generation;
226      const localController = new AbortController();
227      if (controller) controller.abort();
228      controller = localController;
229      const enabled = resolveEnabled();
230      setupInterval(enabled, resolveOptions()?.refetchInterval);
231      await fetchData(inputFn?.(), localController.signal, gen);
232    },
233  };
234}"#;
235
236const CREATE_MUTATION_IMPL: &str = r#"export function createMutation<K extends MutationKey>(
237  client: RpcClient,
238  key: K,
239  options?: MutationOptions<K>,
240): MutationResult<K> {
241  const [data, setData] = createSignal<MutationOutput<K> | undefined>();
242  const [error, setError] = createSignal<RpcError | undefined>();
243  const [isLoading, setIsLoading] = createSignal(false);
244  const [hasSucceeded, setHasSucceeded] = createSignal(false);
245
246  const isSuccess = createMemo(() => hasSucceeded());
247  const isError = createMemo(() => error() !== undefined);
248
249  async function execute(...input: MutationArgs<K>): Promise<MutationOutput<K>> {
250    setIsLoading(true);
251    setError(undefined);
252    setHasSucceeded(false);
253    try {
254      const callArgs: unknown[] = [key];
255      if (input.length > 0) callArgs.push(input[0]);
256      if (options?.callOptions) callArgs.push(options.callOptions);
257      const result = await (client.mutate as (...a: unknown[]) => Promise<unknown>)(
258        ...callArgs
259      ) as MutationOutput<K>;
260      setData(result as Exclude<MutationOutput<K> | undefined, Function>);
261      setHasSucceeded(true);
262      options?.onSuccess?.(result);
263      return result;
264    } catch (e) {
265      const err = e as RpcError;
266      setError(err as Exclude<RpcError | undefined, Function>);
267      options?.onError?.(err);
268      throw e;
269    } finally {
270      setIsLoading(false);
271      options?.onSettled?.();
272    }
273  }
274
275  return {
276    mutate: async (...args: MutationArgs<K>) => { await execute(...args); },
277    mutateAsync: (...args: MutationArgs<K>) => execute(...args),
278    data: data as () => MutationOutput<K> | undefined,
279    error,
280    isLoading,
281    isSuccess,
282    isError,
283    reset: () => batch(() => { setData(undefined); setError(undefined); setIsLoading(false); setHasSucceeded(false); }),
284  };
285}"#;
286
287const STREAM_OPTIONS_INTERFACE: &str = r#"export interface StreamOptions<K extends StreamKey> {
288  callOptions?: CallOptions;
289  onChunk?: (chunk: StreamOutput<K>) => void;
290  onDone?: () => void;
291  onError?: (error: RpcError) => void;
292}"#;
293
294const STREAM_RESULT_INTERFACE: &str = r#"export interface StreamResult<K extends StreamKey> {
295  readonly chunks: () => StreamOutput<K>[];
296  readonly error: () => RpcError | undefined;
297  readonly isStreaming: () => boolean;
298  readonly isDone: () => boolean;
299  start: () => void;
300  stop: () => void;
301}"#;
302
303const CREATE_STREAM_IMPL: &str = r#"export function createStream<K extends StreamKey>(
304  client: RpcClient,
305  ...args: unknown[]
306): StreamResult<K> {
307  const key = args[0] as K;
308
309  let inputFn: (() => StreamInput<K>) | undefined;
310  let options: StreamOptions<K> | undefined;
311
312  if (typeof args[1] === "function") {
313    inputFn = args[1] as () => StreamInput<K>;
314    options = args[2] as StreamOptions<K> | undefined;
315  } else if (typeof args[1] === "object" && args[1] !== null && !VOID_STREAM_KEYS.has(key)) {
316    inputFn = () => args[1] as StreamInput<K>;
317    options = args[2] as StreamOptions<K> | undefined;
318  } else {
319    options = args[1] as StreamOptions<K> | undefined;
320  }
321
322  const [chunks, setChunks] = createSignal<StreamOutput<K>[]>([]);
323  const [error, setError] = createSignal<RpcError | undefined>();
324  const [isStreaming, setIsStreaming] = createSignal(false);
325  const [isDone, setIsDone] = createSignal(false);
326  let controller: AbortController | undefined;
327
328  function stop() {
329    if (controller) {
330      controller.abort();
331      controller = undefined;
332    }
333  }
334
335  async function run() {
336    stop();
337    controller = new AbortController();
338    batch(() => {
339      setChunks([]);
340      setError(undefined);
341      setIsStreaming(true);
342      setIsDone(false);
343    });
344
345    try {
346      const callArgs: unknown[] = [key];
347      const input = inputFn?.();
348      if (input !== undefined) callArgs.push(input);
349      const mergedCallOptions = { ...options?.callOptions, signal: controller.signal };
350      callArgs.push(mergedCallOptions);
351      const gen = (client.stream as (...a: unknown[]) => AsyncGenerator<unknown>)(...callArgs);
352      for await (const chunk of gen) {
353        if (controller.signal.aborted) break;
354        setChunks(prev => [...prev, chunk as StreamOutput<K>]);
355        options?.onChunk?.(chunk as StreamOutput<K>);
356      }
357      if (!controller.signal.aborted) {
358        setIsDone(true);
359        options?.onDone?.();
360      }
361    } catch (e) {
362      if (!controller.signal.aborted) {
363        setError(e as RpcError);
364        options?.onError?.(e as RpcError);
365      }
366    } finally {
367      setIsStreaming(false);
368    }
369  }
370
371  onCleanup(stop);
372
373  return { chunks, error, isStreaming, isDone, start: () => { void run(); }, stop };
374}"#;
375
376const FRAMEWORK_IMPORT: &str = "import { createSignal, createEffect, createMemo, onCleanup, batch, untrack } from \"solid-js\";";
377
378/// Generates the complete SolidJS reactive primitives file content from a manifest.
379///
380/// Returns an empty string when the manifest contains no procedures (the caller
381/// should skip writing the file in that case).
382pub fn generate_solid_file(
383    manifest: &Manifest,
384    client_import_path: &str,
385    types_import_path: &str,
386    preserve_docs: bool,
387) -> String {
388    common::generate_framework_file(
389        manifest,
390        client_import_path,
391        types_import_path,
392        preserve_docs,
393        &FrameworkConfig {
394            framework_import: Some(FRAMEWORK_IMPORT),
395            query_fn_name: "createQuery",
396            input_as_getter: true,
397            query_interfaces: &[QUERY_OPTIONS_INTERFACE, QUERY_RESULT_INTERFACE],
398            mutation_interfaces: &[MUTATION_OPTIONS_INTERFACE, MUTATION_RESULT_INTERFACE],
399            query_impl: CREATE_QUERY_IMPL,
400            mutation_impl: CREATE_MUTATION_IMPL,
401            stream_fn_name: "createStream",
402            stream_interfaces: &[STREAM_OPTIONS_INTERFACE, STREAM_RESULT_INTERFACE],
403            stream_impl: CREATE_STREAM_IMPL,
404        },
405    )
406}