metaxy_cli/codegen/
svelte.rs1use 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. Works even 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 generation++;
226 const gen = generation;
227 const localController = new AbortController();
228 if (controller) controller.abort();
229 controller = localController;
230 const enabled = resolveEnabled();
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 let data = $state<MutationOutput<K> | undefined>();
243 let error = $state<RpcError | undefined>();
244 let loading = $state(false);
245 let hasSucceeded = $state(false);
246
247 async function execute(...input: MutationArgs<K>): Promise<MutationOutput<K>> {
248 loading = true;
249 error = undefined;
250 hasSucceeded = false;
251 try {
252 const callArgs: unknown[] = [key];
253 if (input.length > 0) callArgs.push(input[0]);
254 if (options?.callOptions) callArgs.push(options.callOptions);
255 const result = await (client.mutate as (...a: unknown[]) => Promise<unknown>)(...callArgs) as MutationOutput<K>;
256 data = result;
257 hasSucceeded = true;
258 options?.onSuccess?.(result);
259 return result;
260 } catch (e) {
261 error = e as RpcError;
262 options?.onError?.(error);
263 throw e;
264 } finally {
265 loading = false;
266 options?.onSettled?.();
267 }
268 }
269
270 return {
271 mutate: async (...args: MutationArgs<K>) => { await execute(...args); },
272 mutateAsync: (...args: MutationArgs<K>) => execute(...args),
273 get data() { return data; },
274 get error() { return error; },
275 get isLoading() { return loading; },
276 get isSuccess() { return hasSucceeded; },
277 get isError() { return error !== undefined; },
278 reset: () => { data = undefined; error = undefined; loading = false; hasSucceeded = false; },
279 } as MutationResult<K>;
280}"#;
281
282const STREAM_OPTIONS_INTERFACE: &str = r#"export interface StreamOptions<K extends StreamKey> {
283 /** Per-call options forwarded to client.stream(). */
284 callOptions?: CallOptions;
285
286 /** Called for each chunk received from the stream. */
287 onChunk?: (chunk: StreamOutput<K>) => void;
288
289 /** Called when the stream completes successfully. */
290 onDone?: () => void;
291
292 /** Called when the stream encounters an error. */
293 onError?: (error: RpcError) => void;
294}"#;
295
296const STREAM_RESULT_INTERFACE: &str = r#"export interface StreamResult<K extends StreamKey> {
297 /** All chunks received so far. */
298 readonly chunks: StreamOutput<K>[];
299
300 /** The error from the stream, if any. */
301 readonly error: RpcError | undefined;
302
303 /** True while the stream is active. */
304 readonly isStreaming: boolean;
305
306 /** True when the stream has completed without error. */
307 readonly isDone: boolean;
308
309 /** Start (or restart) the stream. */
310 start: () => void;
311
312 /** Abort the active stream. */
313 stop: () => void;
314}"#;
315
316const CREATE_STREAM_IMPL: &str = r#"export function createStream<K extends StreamKey>(
317 client: RpcClient,
318 ...args: unknown[]
319): StreamResult<K> {
320 const key = args[0] as K;
321
322 let inputFn: (() => StreamInput<K>) | undefined;
323 let options: StreamOptions<K> | undefined;
324
325 if (typeof args[1] === "function") {
326 inputFn = args[1] as () => StreamInput<K>;
327 options = args[2] as StreamOptions<K> | undefined;
328 } else if (typeof args[1] === "object" && args[1] !== null && !VOID_STREAM_KEYS.has(key)) {
329 inputFn = () => args[1] as StreamInput<K>;
330 options = args[2] as StreamOptions<K> | undefined;
331 } else {
332 options = args[1] as StreamOptions<K> | undefined;
333 }
334
335 let chunks = $state<StreamOutput<K>[]>([]);
336 let error = $state<RpcError | undefined>();
337 let streaming = $state(false);
338 let done = $state(false);
339 let controller: AbortController | undefined;
340
341 async function run() {
342 if (controller) controller.abort();
343 controller = new AbortController();
344 chunks = [];
345 error = undefined;
346 streaming = true;
347 done = false;
348
349 try {
350 const callArgs: unknown[] = [key];
351 const input = inputFn?.();
352 if (input !== undefined) callArgs.push(input);
353 const mergedCallOptions = { ...options?.callOptions, signal: controller.signal };
354 callArgs.push(mergedCallOptions);
355 const gen = (client.stream as (...a: unknown[]) => AsyncGenerator<unknown>)(...callArgs);
356 for await (const chunk of gen) {
357 if (controller.signal.aborted) break;
358 chunks = [...chunks, chunk as StreamOutput<K>];
359 options?.onChunk?.(chunk as StreamOutput<K>);
360 }
361 if (!controller.signal.aborted) {
362 done = true;
363 options?.onDone?.();
364 }
365 } catch (e) {
366 if (!controller.signal.aborted) {
367 error = e as RpcError;
368 options?.onError?.(error);
369 }
370 } finally {
371 streaming = false;
372 }
373 }
374
375 return {
376 get chunks() { return chunks; },
377 get error() { return error; },
378 get isStreaming() { return streaming; },
379 get isDone() { return done; },
380 start: () => { void run(); },
381 stop: () => { if (controller) { controller.abort(); controller = undefined; } },
382 } as StreamResult<K>;
383}"#;
384
385pub fn generate_svelte_file(
390 manifest: &Manifest,
391 client_import_path: &str,
392 types_import_path: &str,
393 preserve_docs: bool,
394) -> String {
395 common::generate_framework_file(
396 manifest,
397 client_import_path,
398 types_import_path,
399 preserve_docs,
400 &FrameworkConfig {
401 framework_import: None,
402 query_fn_name: "createQuery",
403 stream_fn_name: "createStream",
404 input_as_getter: true,
405 query_interfaces: &[QUERY_OPTIONS_INTERFACE, QUERY_RESULT_INTERFACE],
406 mutation_interfaces: &[MUTATION_OPTIONS_INTERFACE, MUTATION_RESULT_INTERFACE],
407 stream_interfaces: &[STREAM_OPTIONS_INTERFACE, STREAM_RESULT_INTERFACE],
408 query_impl: CREATE_QUERY_IMPL,
409 mutation_impl: CREATE_MUTATION_IMPL,
410 stream_impl: CREATE_STREAM_IMPL,
411 },
412 )
413}