Skip to main content

tauri_plugin_rpstate/
codegen.rs

1use heck::ToLowerCamelCase;
2use rpstate::tauri_codegen::{FieldExportMeta, FieldKind, SchemaExportEntry};
3use std::collections::HashMap;
4
5pub fn export(out_path: impl AsRef<std::path::Path>) -> std::io::Result<()> {
6    let mut registry = HashMap::new();
7    for entry in inventory::iter::<SchemaExportEntry>() {
8        registry.insert(entry.struct_name, entry);
9    }
10
11    let mut ts = String::new();
12    ts.push_str("/* eslint-disable */\n");
13    ts.push_str("/* tslint:disable */\n");
14    ts.push_str("// @ts-nocheck\n");
15    ts.push_str(
16        r#"// src/bindings/rpstate.ts DO NOT EDIT
17import {invoke} from "@tauri-apps/api/core";
18import {listen} from "@tauri-apps/api/event";
19
20
21export type MapChange<K, V> =
22    | { type: "Insert"; key: K; value: V }
23    | { type: "Update"; key: K; oldValue: V; newValue: V }
24    | { type: "Remove"; key: K; oldValue: V }
25    | { type: "Clear" };
26
27export class Field<T> {
28    private _value: T | null = null;
29    private _unlisten: (() => void) | null = null;
30
31    constructor(public readonly key: string, initialValue?: T) {
32        if (initialValue !== undefined) {
33            this._value = initialValue;
34        } else {
35            this.get()
36                .then((val) => {
37                    this._value = val;
38                })
39                .catch(() => {});
40        }
41
42        this._unlisten = this.subscribe((val) => {
43            this._value = val;
44        });
45    }
46
47    /**
48     * Synchronous in-memory getter.
49     *
50     * @returns The locally cached value.
51     * @tradeoff Resolved in-memory. Might not reflect the actual persistent store
52     * if background sync is pending or failed. Use `get()` for transaction-safe checks.
53     */
54    get value(): T | null {
55        return this._value;
56    }
57
58    /**
59     * Synchronous in-memory setter.
60     *
61     * @param newValue The new value to assign.
62     * @tradeoff Immediately updates the local cache to keep the UI lag-free,
63     * while firing an asynchronous write in the background. Note that writes are debounced/buffered;
64     * call and await the `save()` method on the parent slice class to guarantee immediate disk persistence.
65     */
66    set value(newValue: T) {
67        this._value = newValue;
68        this.set(newValue).catch((err) => {
69            console.error(`Sync write failed for key ${this.key}:`, err);
70        });
71    }
72
73    /**
74     * Absolute asynchronous getter.
75     *
76     * @returns A promise resolving to the most up-to-date value queried directly from the persistent store.
77     * @benefit Transaction-safe. Guarantees that the retrieved value is persisted on disk.
78     */
79    async get(): Promise<T> {
80        return invoke("plugin:rpstate|rpstate_get", { key: this.key });
81    }
82
83    /**
84     * Absolute asynchronous setter.
85     *
86     * @param value The value to persist.
87     * @returns A promise resolving when the value is queued for writing in the persistent store.
88     * @note Writes are debounced/buffered. To guarantee immediate persistence on disk,
89     * call and await the `save()` method on the parent slice class.
90     */
91    async set(value: T): Promise<void> {
92        return invoke("plugin:rpstate|rpstate_set", { key: this.key, value });
93    }
94
95    destroy() {
96        if (this._unlisten) {
97            this._unlisten();
98        }
99    }
100
101    subscribe(cb: (value: T) => void): () => void {
102        invoke("plugin:rpstate|rpstate_subscribe", { key: this.key });
103        let unlisten: (() => void) | null = null;
104        let cancelled = false;
105
106        const channel = `rpstate://${this.key.replace(/\./g, ":")}`;
107
108        listen<T>(channel, (e) => cb(e.payload))
109            .then((fn) => {
110                if (cancelled) {
111                    fn();
112                    invoke("plugin:rpstate|rpstate_unsubscribe", { key: this.key });
113                } else {
114                    unlisten = fn;
115                }
116            });
117
118        return () => {
119            cancelled = true;
120            if (unlisten) {
121                unlisten();
122                invoke("plugin:rpstate|rpstate_unsubscribe", { key: this.key });
123            }
124        };
125    }
126}
127
128export class ReadonlyField<T> {
129    private _value: T | null = null;
130    private _unlisten: (() => void) | null = null;
131
132    constructor(public readonly key: string, initialValue?: T) {
133        if (initialValue !== undefined) {
134            this._value = initialValue;
135        } else {
136            this.get()
137                .then((val) => {
138                    this._value = val;
139                })
140                .catch(() => {});
141        }
142
143        this._unlisten = this.subscribe((val) => {
144            this._value = val;
145        });
146    }
147
148    /**
149     * Synchronous in-memory getter.
150     *
151     * @returns The locally cached value.
152     * @tradeoff Resolved in-memory. Might not reflect the actual persistent store
153     * if background sync is pending or failed. Use `get()` for transaction-safe checks.
154     */
155    get value(): T | null {
156        return this._value;
157    }
158
159    /**
160     * Absolute asynchronous getter.
161     *
162     * @returns A promise resolving to the most up-to-date value queried directly from the persistent store.
163     * @benefit Transaction-safe. Guarantees that the retrieved value is persisted on disk.
164     */
165    async get(): Promise<T> {
166        return invoke("plugin:rpstate|rpstate_get", { key: this.key });
167    }
168
169    destroy() {
170        if (this._unlisten) {
171            this._unlisten();
172        }
173    }
174
175    subscribe(cb: (value: T) => void): () => void {
176        invoke("plugin:rpstate|rpstate_subscribe", { key: this.key });
177        let unlisten: (() => void) | null = null;
178        let cancelled = false;
179
180        const channel = `rpstate://${this.key.replace(/\./g, ":")}`;
181
182        listen<T>(channel, (e) => cb(e.payload))
183            .then((fn) => {
184                if (cancelled) {
185                    fn();
186                    invoke("plugin:rpstate|rpstate_unsubscribe", { key: this.key });
187                } else {
188                    unlisten = fn;
189                }
190            });
191
192        return () => {
193            cancelled = true;
194            if (unlisten) {
195                unlisten();
196                invoke("plugin:rpstate|rpstate_unsubscribe", { key: this.key });
197            }
198        };
199    }
200}
201
202export class ReactiveMapField<K extends string, V> {
203    private _map = new Map<K, V>();
204    private _unlisten: (() => void) | null = null;
205
206    constructor(public readonly prefix: string, initialValues?: Record<string, any>) {
207        if (initialValues) {
208            const dotPrefix = `${this.prefix}.`;
209            for (const [key, value] of Object.entries(initialValues)) {
210                if (key.startsWith(dotPrefix)) {
211                    const subKey = key.slice(dotPrefix.length) as K;
212                    this._map.set(subKey, value);
213                }
214            }
215        }
216
217        this._unlisten = this.subscribeAny((change) => {
218            if (change.type === "Insert") {
219                this._map.set(change.key, change.value);
220            } else if (change.type === "Update") {
221                this._map.set(change.key, change.newValue);
222            } else if (change.type === "Remove") {
223                this._map.delete(change.key);
224            } else if (change.type === "Clear") {
225                this._map.clear();
226            }
227        });
228    }
229
230    destroy() {
231        if (this._unlisten) {
232            this._unlisten();
233        }
234    }
235
236    /**
237     * Absolute asynchronous getter.
238     *
239     * @param key The map key to look up.
240     * @returns A promise resolving to the value queried directly from the backend.
241     * @benefit Transaction-safe. Guarantees data fresh from the persistent store.
242     */
243    async get(key: K): Promise<V | null> {
244        return invoke("plugin:rpstate|rpstate_get", { key: `${this.prefix}.${key}` });
245    }
246
247    /**
248     * Absolute asynchronous setter.
249     *
250     * @param key The map key to assign.
251     * @param value The value to persist.
252     * @returns A promise resolving when the value is queued for writing.
253     * @note Writes are debounced/buffered. To guarantee immediate persistence on disk,
254     * call and await the `save()` method on the parent slice class.
255     */
256    async set(key: K, value: V): Promise<void> {
257        return invoke("plugin:rpstate|rpstate_set", { key: `${this.prefix}.${key}`, value });
258    }
259
260    /**
261     * Synchronous in-memory getter.
262     *
263     * @param key The map key to look up.
264     * @returns The locally cached value.
265     * @tradeoff Resolved in-memory. Might lag behind actual persistent store transactions. Use `get(key)` for absolute checks.
266     */
267    getSync(key: K): V | null {
268        return this._map.get(key) ?? null;
269    }
270
271    /**
272     * Synchronous in-memory setter.
273     *
274     * @param key The map key to assign.
275     * @param value The value to write.
276     * @tradeoff Instantly updates the local memory map while initiating background write.
277     * Writes are debounced/buffered; call and await `save()` on the parent slice class to flush changes to disk.
278     */
279    setSync(key: K, value: V): void {
280        this._map.set(key, value);
281        this.set(key, value).catch((err) => {
282            console.error(`Sync map write failed for ${this.prefix}.${key}:`, err);
283        });
284    }
285
286    /**
287     * Synchronous in-memory key lookup.
288     *
289     * @param key The map key to check.
290     * @returns True if the key is present in the local cache.
291     * @tradeoff Resolved in-memory. Might not reflect pending backend transactions.
292     */
293    hasSync(key: K): boolean {
294        return this._map.has(key);
295    }
296
297    /**
298     * Returns the read-only, native JavaScript Map entries currently synchronized.
299     */
300    get entries(): ReadonlyMap<K, V> {
301        return this._map;
302    }
303
304    subscribeKey(key: K, cb: (value: V) => void): () => void {
305        const fullKey = `${this.prefix}.${key}`;
306        invoke("plugin:rpstate|rpstate_subscribe", { key: fullKey });
307        let unlisten: (() => void) | null = null;
308        let cancelled = false;
309
310        const channel = `rpstate://${fullKey.replace(/\./g, ":")}`;
311
312        listen<V>(channel, (e) => cb(e.payload))
313            .then((fn) => {
314                if (cancelled) {
315                    fn();
316                    invoke("plugin:rpstate|rpstate_unsubscribe", { key: fullKey });
317                } else {
318                    unlisten = fn;
319                }
320            });
321
322        return () => {
323            cancelled = true;
324            if (unlisten) {
325                unlisten();
326                invoke("plugin:rpstate|rpstate_unsubscribe", { key: fullKey });
327            }
328        };
329    }
330
331    subscribeAny(cb: (change: MapChange<K, V>) => void): () => void {
332        const fullKey = this.prefix;
333        invoke("plugin:rpstate|rpstate_subscribe", { key: fullKey });
334        let unlisten: (() => void) | null = null;
335        let cancelled = false;
336
337        const channel = `rpstate://${fullKey.replace(/\./g, ":")}`;
338
339        listen<MapChange<K, V>>(channel, (e) => cb(e.payload))
340            .then((fn) => {
341                if (cancelled) {
342                    fn();
343                    invoke("plugin:rpstate|rpstate_unsubscribe", { key: fullKey });
344                } else {
345                    unlisten = fn;
346                }
347            });
348
349        return () => {
350            cancelled = true;
351            if (unlisten) {
352                unlisten();
353                invoke("plugin:rpstate|rpstate_unsubscribe", { key: fullKey });
354            }
355        };
356    }
357}
358
359"#,
360    );
361
362    let mut schema_lines = Vec::new();
363    for entry in registry.values() {
364        if let Some(prefix) = entry.prefix {
365            let mut resolved = Vec::new();
366            resolve_fields(prefix, entry.fields, &registry, &mut resolved);
367            for (key, ts_type, comment) in resolved {
368                if let Some(cmt) = comment {
369                    schema_lines.push(format!("    /** {} */\n    \"{}\": {};", cmt, key, ts_type));
370                } else {
371                    schema_lines.push(format!("    \"{}\": {};", key, ts_type));
372                }
373            }
374        }
375    }
376
377    ts.push_str("export type StateSchema = {\n");
378    for line in schema_lines {
379        ts.push_str(&line);
380        ts.push('\n');
381    }
382    ts.push_str("};\n\n");
383
384    let mut nested_classes = String::new();
385    let mut root_classes = String::new();
386
387    for entry in registry.values() {
388        match entry.prefix {
389            None => {
390                nested_classes.push_str(&format!("class {}Fields {{\n", entry.struct_name));
391                for field in entry.fields {
392                    let prop_name = field.name.to_lower_camel_case();
393                    let prop_type = match &field.kind {
394                        FieldKind::Plain | FieldKind::Volatile => {
395                            format!("Field<{}>", field.full_ts_type)
396                        }
397                        FieldKind::Nested { struct_name } => format!("{}Fields", struct_name),
398                        FieldKind::Lookup { mutable, .. } => {
399                            if *mutable {
400                                format!("Field<{}>", field.full_ts_type)
401                            } else {
402                                format!("ReadonlyField<{}>", field.full_ts_type)
403                            }
404                        }
405                        FieldKind::LookupNode { struct_name, .. } => {
406                            format!("{}Fields", struct_name)
407                        }
408                        FieldKind::ReactiveMap {
409                            key_type,
410                            value_type,
411                        } => format!("ReactiveMapField<{}, {}>", key_type, value_type),
412                    };
413                    nested_classes
414                        .push_str(&format!("    readonly {}: {};\n", prop_name, prop_type));
415                }
416
417                nested_classes.push_str(
418                    "    constructor(prefix: string, initialValues?: Record<string, any>) {\n",
419                );
420                for field in entry.fields {
421                    let prop_name = field.name.to_lower_camel_case();
422                    match &field.kind {
423                        FieldKind::Plain | FieldKind::Volatile => {
424                            nested_classes.push_str(&format!(
425                                "        this.{} = new Field(`${{prefix}}.{}`, initialValues?.[`${{prefix}}.{}`]);\n",
426                                prop_name, field.name, field.name
427                            ));
428                        }
429                        FieldKind::Nested { struct_name } => {
430                            nested_classes.push_str(&format!(
431                                "        this.{} = new {}Fields(`${{prefix}}.{}`, initialValues);\n",
432                                prop_name, struct_name, field.name
433                            ));
434                        }
435                        FieldKind::Lookup {
436                            target_key,
437                            mutable,
438                        } => {
439                            let class_name = if *mutable { "Field" } else { "ReadonlyField" };
440                            nested_classes.push_str(&format!(
441                                "        this.{} = new {}<{}>(\"{}\", initialValues?.[\"{}\"]);\n",
442                                prop_name, class_name, field.full_ts_type, target_key, target_key
443                            ));
444                        }
445                        FieldKind::LookupNode {
446                            target_prefix,
447                            struct_name,
448                        } => {
449                            nested_classes.push_str(&format!(
450                                "        this.{} = new {}Fields(\"{}\", initialValues);\n",
451                                prop_name, struct_name, target_prefix
452                            ));
453                        }
454                        FieldKind::ReactiveMap {
455                            key_type,
456                            value_type,
457                        } => {
458                            nested_classes.push_str(&format!(
459                                "        this.{} = new ReactiveMapField<{}, {}>(`${{prefix}}.{}`, initialValues);\n",
460                                prop_name, key_type, value_type, field.name
461                            ));
462                        }
463                    }
464                }
465                nested_classes.push_str("    }\n}\n\n");
466            }
467            Some(prefix) => {
468                let mut resolved = Vec::new();
469                resolve_fields(prefix, entry.fields, &registry, &mut resolved);
470
471                let schema_name = format!("{}Schema", entry.struct_name);
472                root_classes.push_str(&format!("export type {} = {{\n", schema_name));
473                for (key, ts_type, comment) in &resolved {
474                    if let Some(cmt) = comment {
475                        root_classes.push_str(&format!(
476                            "    /** {} */\n    \"{}\": {};\n",
477                            cmt, key, ts_type
478                        ));
479                    } else {
480                        root_classes.push_str(&format!("    \"{}\": {};\n", key, ts_type));
481                    }
482                }
483                root_classes.push_str("};\n\n");
484
485                root_classes.push_str(&format!("export class {} {{\n", entry.struct_name));
486
487                for field in entry.fields {
488                    let prop_name = field.name.to_lower_camel_case();
489                    let prop_type = match &field.kind {
490                        FieldKind::Plain | FieldKind::Volatile => {
491                            format!("Field<{}>", field.full_ts_type)
492                        }
493                        FieldKind::Nested { struct_name } => format!("{}Fields", struct_name),
494                        FieldKind::Lookup { mutable, .. } => {
495                            if *mutable {
496                                format!("Field<{}>", field.full_ts_type)
497                            } else {
498                                format!("ReadonlyField<{}>", field.full_ts_type)
499                            }
500                        }
501                        FieldKind::LookupNode { struct_name, .. } => {
502                            format!("{}Fields", struct_name)
503                        }
504                        FieldKind::ReactiveMap {
505                            key_type,
506                            value_type,
507                        } => format!("ReactiveMapField<{}, {}>", key_type, value_type),
508                    };
509                    root_classes.push_str(&format!("    readonly {}: {};\n", prop_name, prop_type));
510                }
511
512                root_classes.push_str(&format!(
513                    "    constructor(initialValues?: Partial<{}>) {{\n",
514                    schema_name
515                ));
516                for field in entry.fields {
517                    let prop_name = field.name.to_lower_camel_case();
518                    let full_key = format!("{}.{}", prefix, field.name);
519                    match &field.kind {
520                        FieldKind::Plain => {
521                            root_classes.push_str(&format!(
522                                "        this.{} = new Field<{}>(\"{}\", initialValues?.[\"{}\"]);\n",
523                                prop_name, field.full_ts_type, full_key, full_key
524                            ));
525                        }
526                        FieldKind::Volatile => {
527                            root_classes.push_str(&format!(
528                                "        this.{} = new Field<{}>(\"{}\", initialValues?.[\"{}\"]);\n",
529                                prop_name, field.full_ts_type, full_key, full_key
530                            ));
531                        }
532                        FieldKind::Nested { struct_name } => {
533                            root_classes.push_str(&format!(
534                                "        this.{} = new {}Fields(\"{}\", initialValues);\n",
535                                prop_name, struct_name, full_key
536                            ));
537                        }
538                        FieldKind::Lookup {
539                            target_key,
540                            mutable,
541                        } => {
542                            let class_name = if *mutable { "Field" } else { "ReadonlyField" };
543                            root_classes.push_str(&format!(
544                                "        this.{} = new {}<{}>(\"{}\", initialValues?.[\"{}\" as any]);\n",
545                                prop_name, class_name, field.full_ts_type, target_key, target_key
546                            ));
547                        }
548                        FieldKind::LookupNode {
549                            target_prefix,
550                            struct_name,
551                        } => {
552                            root_classes.push_str(&format!(
553                                "        this.{} = new {}Fields(\"{}\", initialValues);\n",
554                                prop_name, struct_name, target_prefix
555                            ));
556                        }
557                        FieldKind::ReactiveMap {
558                            key_type,
559                            value_type,
560                        } => {
561                            root_classes.push_str(&format!(
562                                "        this.{} = new ReactiveMapField<{}, {}>(\"{}\", initialValues);\n",
563                                prop_name, key_type, value_type, full_key
564                            ));
565                        }
566                    }
567                }
568                root_classes.push_str("    }\n\n");
569
570                root_classes.push_str(&format!(
571                    "    static async load(): Promise<{}> {{\n",
572                    entry.struct_name
573                ));
574                root_classes.push_str(&format!(
575                    "        const initialValues = await invoke<Partial<{}>>(\"plugin:rpstate|rpstate_get_prefix\", {{ prefix: \"{}\" }});\n",
576                    schema_name, prefix
577                ));
578                root_classes.push_str(&format!(
579                    "        return new {}(initialValues);\n",
580                    entry.struct_name
581                ));
582                root_classes.push_str("    }\n\n");
583
584                root_classes.push_str("    /**\n");
585                root_classes.push_str("     * Flushes all pending changes under this slice's prefix to disk immediately.\n");
586                root_classes.push_str("     * Resolves only when the persistent store has successfully flushed to disk.\n");
587                root_classes.push_str("     */\n");
588                root_classes.push_str("    async save(): Promise<void> {\n");
589                root_classes.push_str(&format!(
590                    "        return invoke(\"plugin:rpstate|rpstate_flush\", {{ prefix: \"{}\" }});\n",
591                    prefix
592                ));
593                root_classes.push_str("    }\n");
594
595                root_classes.push_str("}\n\n");
596            }
597        }
598    }
599
600    ts.push_str(&nested_classes);
601    ts.push_str(&root_classes);
602
603    if let Some(parent) = out_path.as_ref().parent() {
604        std::fs::create_dir_all(parent)?;
605    }
606    std::fs::write(out_path, ts)?;
607    Ok(())
608}
609
610fn resolve_fields(
611    prefix: &str,
612    fields: &[FieldExportMeta],
613    registry: &HashMap<&str, &SchemaExportEntry>,
614    resolved: &mut Vec<(String, String, Option<String>)>,
615) {
616    for field in fields {
617        match &field.kind {
618            FieldKind::Plain => {
619                resolved.push((
620                    format!("{}.{}", prefix, field.name),
621                    field.full_ts_type.to_string(),
622                    None,
623                ));
624            }
625            FieldKind::Volatile => {
626                resolved.push((
627                    format!("{}.{}", prefix, field.name),
628                    field.full_ts_type.to_string(),
629                    Some("volatile".to_string()),
630                ));
631            }
632            FieldKind::Nested { struct_name } => {
633                if let Some(nested) = registry.get(struct_name) {
634                    resolve_fields(
635                        &format!("{}.{}", prefix, field.name),
636                        nested.fields,
637                        registry,
638                        resolved,
639                    );
640                }
641            }
642            FieldKind::Lookup { target_key, .. } => {
643                resolved.push((
644                    format!("{}.{}", prefix, field.name),
645                    field.full_ts_type.to_string(),
646                    Some(format!("@alias {}", target_key)),
647                ));
648            }
649            FieldKind::LookupNode {
650                target_prefix,
651                struct_name,
652            } => {
653                if let Some(nested) = registry.get(struct_name) {
654                    resolve_fields(target_prefix, nested.fields, registry, resolved);
655                }
656            }
657            FieldKind::ReactiveMap {
658                key_type,
659                value_type,
660            } => {
661                resolved.push((
662                    format!("{}.{}.[key]", prefix, field.name),
663                    format!("Record<{}, {}>", key_type, value_type),
664                    Some("reactive map".to_string()),
665                ));
666            }
667        }
668    }
669}