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, ®istry, &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, ®istry, &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}