From a15e0188768c24649eefdc25085b68819dcc1741 Mon Sep 17 00:00:00 2001 From: Sienna Meridian Satterwhite Date: Sun, 16 Nov 2025 11:50:16 +0000 Subject: [PATCH] initial persistence commit Signed-off-by: Sienna Meridian Satterwhite --- Cargo.lock | 1091 +++++++++++++++++++++- crates/lib/Cargo.toml | 6 + crates/lib/src/lib.rs | 1 + crates/lib/src/persistence/config.rs | 248 +++++ crates/lib/src/persistence/database.rs | 544 +++++++++++ crates/lib/src/persistence/error.rs | 107 +++ crates/lib/src/persistence/health.rs | 212 +++++ crates/lib/src/persistence/lifecycle.rs | 158 ++++ crates/lib/src/persistence/metrics.rs | 211 +++++ crates/lib/src/persistence/mod.rs | 51 + crates/lib/src/persistence/plugin.rs | 259 +++++ crates/lib/src/persistence/reflection.rs | 255 +++++ crates/lib/src/persistence/systems.rs | 459 +++++++++ crates/lib/src/persistence/types.rs | 637 +++++++++++++ crates/lib/src/sync.rs | 9 +- rustfmt.toml | 24 + 16 files changed, 4262 insertions(+), 10 deletions(-) create mode 100644 crates/lib/src/persistence/config.rs create mode 100644 crates/lib/src/persistence/database.rs create mode 100644 crates/lib/src/persistence/error.rs create mode 100644 crates/lib/src/persistence/health.rs create mode 100644 crates/lib/src/persistence/lifecycle.rs create mode 100644 crates/lib/src/persistence/metrics.rs create mode 100644 crates/lib/src/persistence/mod.rs create mode 100644 crates/lib/src/persistence/plugin.rs create mode 100644 crates/lib/src/persistence/reflection.rs create mode 100644 crates/lib/src/persistence/systems.rs create mode 100644 crates/lib/src/persistence/types.rs create mode 100644 rustfmt.toml diff --git a/Cargo.lock b/Cargo.lock index 60e303f..44d7f23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,22 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + [[package]] name = "accesskit" version = "0.21.1" @@ -90,6 +106,19 @@ dependencies = [ "inout", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -105,6 +134,28 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "alsa" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +dependencies = [ + "alsa-sys", + "bitflags 2.10.0", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "android-activity" version = "0.6.0" @@ -119,9 +170,9 @@ dependencies = [ "jni-sys", "libc", "log", - "ndk", + "ndk 0.9.0", "ndk-context", - "ndk-sys", + "ndk-sys 0.6.0+11769913", "num_enum", "thiserror 1.0.69", ] @@ -431,6 +482,72 @@ dependencies = [ "android-activity", ] +[[package]] +name = "bevy_animation" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d2eadb9c20d87ab3a5528a8df483492d5b8102d3f2d61c7b1ed23f40a79166" +dependencies = [ + "bevy_animation_macros", + "bevy_app", + "bevy_asset", + "bevy_color", + "bevy_derive", + "bevy_ecs", + "bevy_math", + "bevy_mesh", + "bevy_platform", + "bevy_reflect", + "bevy_time", + "bevy_transform", + "bevy_utils", + "blake3", + "derive_more 2.0.1", + "downcast-rs 2.0.2", + "either", + "petgraph", + "ron", + "serde", + "smallvec", + "thiserror 2.0.17", + "thread_local", + "tracing", + "uuid", +] + +[[package]] +name = "bevy_animation_macros" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aec80b84926f730f6df81b9bc07255c120f57aaf7ac577f38d12dd8e1a0268ad" +dependencies = [ + "bevy_macro_utils", + "quote", + "syn", +] + +[[package]] +name = "bevy_anti_alias" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c1adb85fe0956d6c3b6f90777b829785bb7e29a48f58febeeefd2bad317713" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_core_pipeline", + "bevy_derive", + "bevy_diagnostic", + "bevy_ecs", + "bevy_image", + "bevy_math", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_utils", + "tracing", +] + [[package]] name = "bevy_app" version = "0.17.2" @@ -446,7 +563,7 @@ dependencies = [ "cfg-if", "console_error_panic_hook", "ctrlc", - "downcast-rs", + "downcast-rs 2.0.2", "log", "thiserror 2.0.17", "variadics_please", @@ -477,7 +594,7 @@ dependencies = [ "crossbeam-channel", "derive_more 2.0.1", "disqualified", - "downcast-rs", + "downcast-rs 2.0.2", "either", "futures-io", "futures-lite", @@ -506,6 +623,24 @@ dependencies = [ "syn", ] +[[package]] +name = "bevy_audio" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83620c82f281848c02ed4b65133a0364512b4eca2b39cd21a171e50e2986d89" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_ecs", + "bevy_math", + "bevy_reflect", + "bevy_transform", + "coreaudio-sys", + "cpal", + "rodio", + "tracing", +] + [[package]] name = "bevy_camera" version = "0.17.2" @@ -525,7 +660,7 @@ dependencies = [ "bevy_utils", "bevy_window", "derive_more 2.0.1", - "downcast-rs", + "downcast-rs 2.0.2", "serde", "smallvec", "thiserror 2.0.17", @@ -603,6 +738,7 @@ dependencies = [ "const-fnv1a-hash", "log", "serde", + "sysinfo", ] [[package]] @@ -655,6 +791,22 @@ dependencies = [ "encase_derive_impl", ] +[[package]] +name = "bevy_gilrs" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28ff35087f25406006338e6d57f31f313a60f3a5e09990ab7c7b5203b0b55077" +dependencies = [ + "bevy_app", + "bevy_ecs", + "bevy_input", + "bevy_platform", + "bevy_time", + "gilrs", + "thiserror 2.0.17", + "tracing", +] + [[package]] name = "bevy_gizmos" version = "0.17.2" @@ -672,9 +824,11 @@ dependencies = [ "bevy_light", "bevy_math", "bevy_mesh", + "bevy_pbr", "bevy_reflect", "bevy_render", "bevy_shader", + "bevy_sprite_render", "bevy_time", "bevy_transform", "bevy_utils", @@ -693,6 +847,41 @@ dependencies = [ "syn", ] +[[package]] +name = "bevy_gltf" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13d67e954b20551818f7cdb33f169ab4db64506ada66eb4d60d3cb8861103411" +dependencies = [ + "base64 0.22.1", + "bevy_animation", + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_ecs", + "bevy_image", + "bevy_light", + "bevy_math", + "bevy_mesh", + "bevy_pbr", + "bevy_platform", + "bevy_reflect", + "bevy_render", + "bevy_scene", + "bevy_tasks", + "bevy_transform", + "fixedbitset", + "gltf", + "itertools 0.14.0", + "percent-encoding", + "serde", + "serde_json", + "smallvec", + "thiserror 2.0.17", + "tracing", +] + [[package]] name = "bevy_image" version = "0.17.2" @@ -713,7 +902,9 @@ dependencies = [ "guillotiere", "half", "image", + "ktx2", "rectangle-pack", + "ruzstd", "serde", "thiserror 2.0.17", "tracing", @@ -762,23 +953,35 @@ checksum = "f43985739584f3a5d43026aa1edd772f064830be46c497518f05f7dfbc886bba" dependencies = [ "bevy_a11y", "bevy_android", + "bevy_animation", + "bevy_anti_alias", "bevy_app", "bevy_asset", + "bevy_audio", "bevy_camera", "bevy_color", "bevy_core_pipeline", "bevy_derive", "bevy_diagnostic", "bevy_ecs", + "bevy_gilrs", "bevy_gizmos", + "bevy_gltf", "bevy_image", "bevy_input", + "bevy_input_focus", + "bevy_light", + "bevy_log", "bevy_math", "bevy_mesh", + "bevy_pbr", + "bevy_picking", "bevy_platform", + "bevy_post_process", "bevy_ptr", "bevy_reflect", "bevy_render", + "bevy_scene", "bevy_shader", "bevy_sprite", "bevy_sprite_render", @@ -788,6 +991,7 @@ dependencies = [ "bevy_time", "bevy_transform", "bevy_ui", + "bevy_ui_render", "bevy_utils", "bevy_window", "bevy_winit", @@ -896,6 +1100,42 @@ version = "0.17.0-dev" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ef8e4b7e61dfe7719bb03c884dc270cd46a82efb40f93e9933b990c5c190c59" +[[package]] +name = "bevy_pbr" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8c76337a6ae9d73d50be168aeee974d05fdeda9129a413eaff719e3b7b5fea" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_core_pipeline", + "bevy_derive", + "bevy_diagnostic", + "bevy_ecs", + "bevy_image", + "bevy_light", + "bevy_math", + "bevy_mesh", + "bevy_platform", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_transform", + "bevy_utils", + "bitflags 2.10.0", + "bytemuck", + "derive_more 2.0.1", + "fixedbitset", + "nonmax", + "offset-allocator", + "smallvec", + "static_assertions", + "thiserror 2.0.17", + "tracing", +] + [[package]] name = "bevy_picking" version = "0.17.2" @@ -909,11 +1149,13 @@ dependencies = [ "bevy_ecs", "bevy_input", "bevy_math", + "bevy_mesh", "bevy_platform", "bevy_reflect", "bevy_time", "bevy_transform", "bevy_window", + "crossbeam-channel", "tracing", "uuid", ] @@ -939,6 +1181,36 @@ dependencies = [ "web-time", ] +[[package]] +name = "bevy_post_process" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26ee8ab6043f8bbe43e9c16bbdde0c5e7289b99e62cd8aad1a2a4166a7f2bce6" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_core_pipeline", + "bevy_derive", + "bevy_ecs", + "bevy_image", + "bevy_math", + "bevy_platform", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_transform", + "bevy_utils", + "bevy_window", + "bitflags 2.10.0", + "nonmax", + "radsort", + "smallvec", + "thiserror 2.0.17", + "tracing", +] + [[package]] name = "bevy_ptr" version = "0.17.2" @@ -958,11 +1230,12 @@ dependencies = [ "bevy_utils", "derive_more 2.0.1", "disqualified", - "downcast-rs", + "downcast-rs 2.0.2", "erased-serde", "foldhash 0.2.0", "glam", "inventory", + "petgraph", "serde", "smallvec", "smol_str 0.2.2", @@ -1016,7 +1289,7 @@ dependencies = [ "bitflags 2.10.0", "bytemuck", "derive_more 2.0.1", - "downcast-rs", + "downcast-rs 2.0.2", "encase", "fixedbitset", "image", @@ -1047,6 +1320,27 @@ dependencies = [ "syn", ] +[[package]] +name = "bevy_scene" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e601ffeebbdaba1193f823dbdc9fc8787a24cf83225a72fee4def5c27a18778a" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_derive", + "bevy_ecs", + "bevy_platform", + "bevy_reflect", + "bevy_transform", + "bevy_utils", + "derive_more 2.0.1", + "serde", + "thiserror 2.0.17", + "uuid", +] + [[package]] name = "bevy_shader" version = "0.17.2" @@ -1079,6 +1373,7 @@ dependencies = [ "bevy_image", "bevy_math", "bevy_mesh", + "bevy_picking", "bevy_reflect", "bevy_text", "bevy_transform", @@ -1158,6 +1453,7 @@ dependencies = [ "async-task", "atomic-waker", "bevy_platform", + "concurrent-queue", "crossbeam-queue", "derive_more 2.0.1", "futures-lite", @@ -1241,6 +1537,7 @@ dependencies = [ "bevy_image", "bevy_input", "bevy_math", + "bevy_picking", "bevy_platform", "bevy_reflect", "bevy_sprite", @@ -1253,6 +1550,38 @@ dependencies = [ "taffy", "thiserror 2.0.17", "tracing", + "uuid", +] + +[[package]] +name = "bevy_ui_render" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adae9770089e04339d003afe7abe7153fe71600d81c828f964c7ac329b04d5b9" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_core_pipeline", + "bevy_derive", + "bevy_ecs", + "bevy_image", + "bevy_math", + "bevy_mesh", + "bevy_platform", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_sprite", + "bevy_sprite_render", + "bevy_text", + "bevy_transform", + "bevy_ui", + "bevy_utils", + "bytemuck", + "derive_more 2.0.1", + "tracing", ] [[package]] @@ -1273,7 +1602,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f582478606d6b6e5c53befbe7612f038fdfb73f8a27f7aae644406637347acd4" dependencies = [ "bevy_app", + "bevy_asset", "bevy_ecs", + "bevy_image", "bevy_input", "bevy_math", "bevy_platform", @@ -1295,8 +1626,10 @@ dependencies = [ "bevy_a11y", "bevy_android", "bevy_app", + "bevy_asset", "bevy_derive", "bevy_ecs", + "bevy_image", "bevy_input", "bevy_input_focus", "bevy_log", @@ -1305,13 +1638,42 @@ dependencies = [ "bevy_reflect", "bevy_tasks", "bevy_window", + "bytemuck", "cfg-if", "tracing", "wasm-bindgen", "web-sys", + "wgpu-types", "winit", ] +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.10.0", + "cexpr", + "clang-sys", + "itertools 0.12.1", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.1", + "shlex", + "syn", +] + [[package]] name = "bit-set" version = "0.5.3" @@ -1354,6 +1716,7 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" dependencies = [ + "bytemuck", "serde_core", ] @@ -1478,6 +1841,18 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + [[package]] name = "candle-core" version = "0.8.4" @@ -1552,6 +1927,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -1602,6 +1986,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "client" version = "0.1.0" @@ -1795,6 +2190,26 @@ dependencies = [ "libc", ] +[[package]] +name = "coreaudio-rs" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" +dependencies = [ + "bitflags 1.3.2", + "core-foundation-sys", + "coreaudio-sys", +] + +[[package]] +name = "coreaudio-sys" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ceec7a6067e62d6f931a2baf6f3a751f4a892595bcec1461a3c94ef9949864b6" +dependencies = [ + "bindgen", +] + [[package]] name = "cosmic-text" version = "0.14.2" @@ -1818,6 +2233,29 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cpal" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" +dependencies = [ + "alsa", + "core-foundation-sys", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk 0.8.0", + "ndk-context", + "oboe", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.54.0", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -2026,6 +2464,12 @@ dependencies = [ "syn", ] +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + [[package]] name = "data-encoding" version = "2.9.0" @@ -2238,6 +2682,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "downcast-rs" version = "2.0.2" @@ -3038,6 +3488,51 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gilrs" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb2c998745a3c1ac90f64f4f7b3a54219fd3612d7705e7798212935641ed18f" +dependencies = [ + "fnv", + "gilrs-core", + "log", + "uuid", + "vec_map", +] + +[[package]] +name = "gilrs-core" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be11a71ac3564f6965839e2ed275bf4fcf5ce16d80d396e1dfdb7b2d80bd587e" +dependencies = [ + "core-foundation 0.10.1", + "inotify", + "io-kit-sys", + "js-sys", + "libc", + "libudev-sys", + "log", + "nix", + "uuid", + "vec_map", + "wasm-bindgen", + "web-sys", + "windows 0.62.2", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + [[package]] name = "glam" version = "0.30.9" @@ -3050,6 +3545,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "gloo-timers" version = "0.3.0" @@ -3062,6 +3563,63 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "glow" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gltf" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ce1918195723ce6ac74e80542c5a96a40c2b26162c1957a5cd70799b8cacf7" +dependencies = [ + "byteorder", + "gltf-json", + "lazy_static", + "serde_json", +] + +[[package]] +name = "gltf-derive" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14070e711538afba5d6c807edb74bcb84e5dbb9211a3bf5dea0dfab5b24f4c51" +dependencies = [ + "inflections", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "gltf-json" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6176f9d60a7eab0a877e8e96548605dedbde9190a7ae1e80bbcc1c9af03ab14" +dependencies = [ + "gltf-derive", + "serde", + "serde_derive", + "serde_json", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + [[package]] name = "gpu-alloc" version = "0.6.0" @@ -3635,6 +4193,8 @@ checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" dependencies = [ "equivalent", "hashbrown 0.16.0", + "serde", + "serde_core", ] [[package]] @@ -3650,6 +4210,32 @@ dependencies = [ "web-time", ] +[[package]] +name = "inflections" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a257582fdcde896fd96463bf2d40eefea0580021c0712a0e2b028b60b47a837a" + +[[package]] +name = "inotify" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" +dependencies = [ + "bitflags 2.10.0", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "inout" version = "0.2.1" @@ -3680,6 +4266,16 @@ dependencies = [ "rustversion", ] +[[package]] +name = "io-kit-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b" +dependencies = [ + "core-foundation-sys", + "mach2", +] + [[package]] name = "ipconfig" version = "0.3.2" @@ -4042,17 +4638,56 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "ktx2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff7f53bdf698e7aa7ec916411bbdc8078135da11b66db5182675b2227f6c0d07" +dependencies = [ + "bitflags 2.10.0", +] + [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lewton" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "777b48df9aaab155475a83a7df3070395ea1ac6902f5cd062b8f2b028075c030" +dependencies = [ + "byteorder", + "ogg", + "tinyvec", +] + [[package]] name = "lib" version = "0.1.0" dependencies = [ "anyhow", + "bevy", + "bincode", "chrono", "crdts", "futures-lite", @@ -4064,6 +4699,9 @@ dependencies = [ "sync-macros", "thiserror 2.0.17", "tokio", + "toml", + "tracing", + "uuid", ] [[package]] @@ -4110,6 +4748,16 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libudev-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -4183,6 +4831,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + [[package]] name = "macro_rules_attribute" version = "0.2.2" @@ -4440,6 +5097,20 @@ dependencies = [ "tempfile", ] +[[package]] +name = "ndk" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" +dependencies = [ + "bitflags 2.10.0", + "jni-sys", + "log", + "ndk-sys 0.5.0+25.2.9519653", + "num_enum", + "thiserror 1.0.69", +] + [[package]] name = "ndk" version = "0.9.0" @@ -4449,7 +5120,7 @@ dependencies = [ "bitflags 2.10.0", "jni-sys", "log", - "ndk-sys", + "ndk-sys 0.6.0+11769913", "num_enum", "raw-window-handle", "thiserror 1.0.69", @@ -4461,6 +5132,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys", +] + [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -4597,6 +5277,15 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51" +[[package]] +name = "ntapi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" +dependencies = [ + "winapi", +] + [[package]] name = "ntimestamp" version = "1.0.0" @@ -4663,6 +5352,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -4829,6 +5529,15 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.10.0", +] + [[package]] name = "objc2-core-image" version = "0.2.2" @@ -4872,6 +5581,16 @@ dependencies = [ "objc2 0.5.2", ] +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "objc2-link-presentation" version = "0.2.2" @@ -4964,6 +5683,29 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "oboe" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" +dependencies = [ + "jni", + "ndk 0.8.0", + "ndk-context", + "num-derive", + "num-traits", + "oboe-sys", +] + +[[package]] +name = "oboe-sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d" +dependencies = [ + "cc", +] + [[package]] name = "offset-allocator" version = "0.2.0" @@ -4974,6 +5716,15 @@ dependencies = [ "nonmax", ] +[[package]] +name = "ogg" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6951b4e8bf21c8193da321bcce9c9dd2e13c858fe078bf9054a288b419ae5d6e" +dependencies = [ + "byteorder", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -5074,6 +5825,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser 0.25.1", +] + [[package]] name = "parking" version = "2.2.1" @@ -5124,6 +5884,19 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", + "serde", + "serde_derive", +] + [[package]] name = "pharos" version = "0.5.3" @@ -5429,6 +6202,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + [[package]] name = "quickcheck" version = "1.0.3" @@ -5799,6 +6581,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rodio" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7ceb6607dd738c99bc8cb28eff249b7cd5c8ec88b9db96c0608c1480d140fb1" +dependencies = [ + "cpal", + "lewton", +] + [[package]] name = "ron" version = "0.10.1" @@ -5977,6 +6769,15 @@ dependencies = [ "unicode-script", ] +[[package]] +name = "ruzstd" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5ff0cc5e135c8870a775d3320910cd9b564ec036b4dc0b8741629020be63f01" +dependencies = [ + "twox-hash", +] + [[package]] name = "ryu" version = "1.0.20" @@ -6033,6 +6834,19 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit", + "tiny-skia", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -6325,6 +7139,31 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.10.0", + "calloop", + "calloop-wayland-source", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + [[package]] name = "smol_str" version = "0.1.24" @@ -6438,6 +7277,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + [[package]] name = "strsim" version = "0.11.1" @@ -6581,6 +7426,20 @@ dependencies = [ "walkdir", ] +[[package]] +name = "sysinfo" +version = "0.37.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows 0.61.3", +] + [[package]] name = "system-configuration" version = "0.6.1" @@ -6720,6 +7579,31 @@ dependencies = [ "crunchy", ] +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -7062,6 +7946,18 @@ version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8" +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "twox-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" + [[package]] name = "typeid" version = "1.0.3" @@ -7264,6 +8160,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "vec_map" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" + [[package]] name = "version_check" version = "0.9.5" @@ -7375,6 +8277,114 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wayland-backend" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35" +dependencies = [ + "cc", + "downcast-rs 1.2.1", + "rustix 1.1.2", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" +dependencies = [ + "bitflags 2.10.0", + "rustix 1.1.2", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.10.0", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "447ccc440a881271b19e9989f75726d60faa09b95b0200a9b7eb5cc47c3eeb29" +dependencies = [ + "rustix 1.1.2", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a07a14257c077ab3279987c4f8bb987851bf57081b93710381daea94f2c2c032" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efd94963ed43cf9938a090ca4f7da58eb55325ec8200c3848963e98dc25b78ec" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142" +dependencies = [ + "dlib", + "log", + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.82" @@ -7443,6 +8453,7 @@ dependencies = [ "cfg_aliases", "document-features", "hashbrown 0.15.5", + "js-sys", "log", "naga", "portable-atomic", @@ -7450,6 +8461,8 @@ dependencies = [ "raw-window-handle", "smallvec", "static_assertions", + "wasm-bindgen", + "web-sys", "wgpu-core", "wgpu-hal", "wgpu-types", @@ -7480,6 +8493,7 @@ dependencies = [ "smallvec", "thiserror 2.0.17", "wgpu-core-deps-apple", + "wgpu-core-deps-wasm", "wgpu-core-deps-windows-linux-android", "wgpu-hal", "wgpu-types", @@ -7494,6 +8508,15 @@ dependencies = [ "wgpu-hal", ] +[[package]] +name = "wgpu-core-deps-wasm" +version = "26.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c03b9f9e1a50686d315fc6debe4980cc45cd37b0e919351917df494e8fdc8885" +dependencies = [ + "wgpu-hal", +] + [[package]] name = "wgpu-core-deps-windows-linux-android" version = "26.0.0" @@ -7519,15 +8542,20 @@ dependencies = [ "cfg-if", "cfg_aliases", "core-graphics-types 0.2.0", + "glow", + "glutin_wgl_sys", "gpu-alloc", "gpu-allocator", "gpu-descriptor", "hashbrown 0.15.5", + "js-sys", + "khronos-egl", "libc", "libloading", "log", "metal", "naga", + "ndk-sys 0.6.0+11769913", "objc", "ordered-float", "parking_lot", @@ -7539,6 +8567,8 @@ dependencies = [ "renderdoc-sys", "smallvec", "thiserror 2.0.17", + "wasm-bindgen", + "web-sys", "wgpu-types", "windows 0.58.0", "windows-core 0.58.0", @@ -7596,6 +8626,16 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core 0.54.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.58.0" @@ -7649,6 +8689,16 @@ dependencies = [ "windows-core 0.62.2", ] +[[package]] +name = "windows-core" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +dependencies = [ + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.58.0" @@ -7786,6 +8836,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.2.0" @@ -8162,6 +9221,7 @@ version = "0.30.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c66d4b9ed69c4009f6321f762d6e61ad8a2389cd431b97cb1e146812e9e6c732" dependencies = [ + "ahash", "android-activity", "atomic-waker", "bitflags 2.10.0", @@ -8176,7 +9236,8 @@ dependencies = [ "dpi", "js-sys", "libc", - "ndk", + "memmap2", + "ndk 0.9.0", "objc2 0.5.2", "objc2-app-kit", "objc2-foundation", @@ -8187,11 +9248,17 @@ dependencies = [ "raw-window-handle", "redox_syscall 0.4.1", "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit", "smol_str 0.2.2", "tracing", "unicode-segmentation", "wasm-bindgen", "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", "web-sys", "web-time", "windows-sys 0.52.0", @@ -8297,6 +9364,12 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "xcursor" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" + [[package]] name = "xkbcommon-dl" version = "0.4.2" diff --git a/crates/lib/Cargo.toml b/crates/lib/Cargo.toml index f2a471b..3e9f892 100644 --- a/crates/lib/Cargo.toml +++ b/crates/lib/Cargo.toml @@ -12,6 +12,12 @@ serde_json.workspace = true crdts.workspace = true anyhow.workspace = true sync-macros = { path = "../sync-macros" } +uuid = { version = "1.0", features = ["v4", "serde"] } +toml.workspace = true +tracing.workspace = true +bevy.workspace = true +bincode = "1.3" +futures-lite = "2.0" [dev-dependencies] tokio.workspace = true diff --git a/crates/lib/src/lib.rs b/crates/lib/src/lib.rs index 3daaab2..15ca16a 100644 --- a/crates/lib/src/lib.rs +++ b/crates/lib/src/lib.rs @@ -24,6 +24,7 @@ mod error; mod models; mod db; pub mod sync; +pub mod persistence; pub use error::{ChatDbError, Result}; pub use models::{Message, Chat}; diff --git a/crates/lib/src/persistence/config.rs b/crates/lib/src/persistence/config.rs new file mode 100644 index 0000000..254da59 --- /dev/null +++ b/crates/lib/src/persistence/config.rs @@ -0,0 +1,248 @@ +//! Configuration for the persistence layer + +use crate::persistence::error::Result; +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +/// Default critical flush delay in milliseconds +const DEFAULT_CRITICAL_FLUSH_DELAY_MS: u64 = 1000; + +/// Default maximum buffer operations before forced flush +const DEFAULT_MAX_BUFFER_OPERATIONS: usize = 1000; + +/// Configuration for the persistence layer +#[derive(Debug, Clone, Serialize, Deserialize, bevy::prelude::Resource)] +pub struct PersistenceConfig { + /// Base flush interval (may be adjusted by battery level) + pub flush_interval_secs: u64, + + /// Max time to defer critical writes (entity creation, etc.) + pub critical_flush_delay_ms: u64, + + /// WAL checkpoint interval + pub checkpoint_interval_secs: u64, + + /// Max WAL size before forced checkpoint (in bytes) + pub max_wal_size_bytes: usize, + + /// Maximum number of operations in write buffer before forcing flush + pub max_buffer_operations: usize, + + /// Enable adaptive flushing based on battery + pub battery_adaptive: bool, + + /// Battery tier configuration + pub battery_tiers: BatteryTiers, + + /// Platform-specific settings + #[serde(default)] + pub platform: PlatformConfig, +} + +impl Default for PersistenceConfig { + fn default() -> Self { + Self { + flush_interval_secs: 10, + critical_flush_delay_ms: DEFAULT_CRITICAL_FLUSH_DELAY_MS, + checkpoint_interval_secs: 30, + max_wal_size_bytes: 5 * 1024 * 1024, // 5MB + max_buffer_operations: DEFAULT_MAX_BUFFER_OPERATIONS, + battery_adaptive: true, + battery_tiers: BatteryTiers::default(), + platform: PlatformConfig::default(), + } + } +} + +impl PersistenceConfig { + /// Get the flush interval based on battery status + pub fn get_flush_interval(&self, battery_level: f32, is_charging: bool) -> Duration { + if !self.battery_adaptive { + return Duration::from_secs(self.flush_interval_secs); + } + + let interval_secs = if is_charging { + self.battery_tiers.charging + } else if battery_level > 0.5 { + self.battery_tiers.high + } else if battery_level > 0.2 { + self.battery_tiers.medium + } else { + self.battery_tiers.low + }; + + Duration::from_secs(interval_secs) + } + + /// Get the critical flush delay + pub fn get_critical_flush_delay(&self) -> Duration { + Duration::from_millis(self.critical_flush_delay_ms) + } + + /// Get the checkpoint interval + pub fn get_checkpoint_interval(&self) -> Duration { + Duration::from_secs(self.checkpoint_interval_secs) + } +} + +/// Battery tier flush intervals (in seconds) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BatteryTiers { + /// Flush interval when charging + pub charging: u64, + + /// Flush interval when battery > 50% + pub high: u64, + + /// Flush interval when battery 20-50% + pub medium: u64, + + /// Flush interval when battery < 20% + pub low: u64, +} + +impl Default for BatteryTiers { + fn default() -> Self { + Self { + charging: 5, + high: 10, + medium: 30, + low: 60, + } + } +} + +/// Platform-specific configuration +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct PlatformConfig { + /// iOS-specific settings + #[serde(default)] + pub ios: IosConfig, +} + +/// iOS-specific configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IosConfig { + /// How long to wait for background flush before giving up (seconds) + pub background_flush_timeout_secs: u64, + + /// Flush interval when in low power mode (seconds) + pub low_power_mode_interval_secs: u64, +} + +impl Default for IosConfig { + fn default() -> Self { + Self { + background_flush_timeout_secs: 5, + low_power_mode_interval_secs: 60, + } + } +} + +/// Load persistence configuration from a TOML string +/// +/// Parses TOML configuration and validates all settings. Use this for +/// loading configuration from embedded strings or dynamic sources. +/// +/// # Parameters +/// - `toml`: TOML-formatted configuration string +/// +/// # Returns +/// - `Ok(PersistenceConfig)`: Parsed and validated configuration +/// - `Err`: If TOML is invalid or contains invalid values +/// +/// # Example TOML +/// ```toml +/// flush_interval_secs = 10 +/// battery_adaptive = true +/// [battery_tiers] +/// charging = 5 +/// high = 10 +/// ``` +pub fn load_config_from_str(toml: &str) -> Result { + Ok(toml::from_str(toml)?) +} + +/// Load persistence configuration from a TOML file +/// +/// Reads and parses a TOML configuration file. This is the recommended way +/// to load configuration for production use, allowing runtime configuration +/// changes without recompilation. +/// +/// # Parameters +/// - `path`: Path to TOML configuration file +/// +/// # Returns +/// - `Ok(PersistenceConfig)`: Loaded configuration +/// - `Err`: If file can't be read or TOML is invalid +/// +/// # Examples +/// ```no_run +/// # use lib::persistence::*; +/// # fn example() -> Result<()> { +/// let config = load_config_from_file("persistence.toml")?; +/// # Ok(()) +/// # } +/// ``` +pub fn load_config_from_file(path: impl AsRef) -> Result { + let content = std::fs::read_to_string(path)?; + Ok(load_config_from_str(&content)?) +} + +/// Serialize persistence configuration to a TOML string +/// +/// Converts configuration to human-readable TOML format. Use this to +/// save configuration to files or display current settings. +/// +/// # Parameters +/// - `config`: Configuration to serialize +/// +/// # Returns +/// - `Ok(String)`: Pretty-printed TOML configuration +/// - `Err`: If serialization fails (rare) +pub fn save_config_to_str(config: &PersistenceConfig) -> Result { + Ok(toml::to_string_pretty(config)?) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_config() { + let config = PersistenceConfig::default(); + assert_eq!(config.flush_interval_secs, 10); + assert_eq!(config.battery_adaptive, true); + } + + #[test] + fn test_battery_adaptive_intervals() { + let config = PersistenceConfig::default(); + + // Charging + let interval = config.get_flush_interval(0.3, true); + assert_eq!(interval, Duration::from_secs(5)); + + // High battery + let interval = config.get_flush_interval(0.8, false); + assert_eq!(interval, Duration::from_secs(10)); + + // Medium battery + let interval = config.get_flush_interval(0.4, false); + assert_eq!(interval, Duration::from_secs(30)); + + // Low battery + let interval = config.get_flush_interval(0.1, false); + assert_eq!(interval, Duration::from_secs(60)); + } + + #[test] + fn test_config_serialization() { + let config = PersistenceConfig::default(); + let toml = save_config_to_str(&config).unwrap(); + let loaded = load_config_from_str(&toml).unwrap(); + + assert_eq!(config.flush_interval_secs, loaded.flush_interval_secs); + assert_eq!(config.battery_adaptive, loaded.battery_adaptive); + } +} diff --git a/crates/lib/src/persistence/database.rs b/crates/lib/src/persistence/database.rs new file mode 100644 index 0000000..8ffadf2 --- /dev/null +++ b/crates/lib/src/persistence/database.rs @@ -0,0 +1,544 @@ +//! Database schema and operations for persistence layer + +use crate::persistence::types::*; +use crate::persistence::error::{PersistenceError, Result}; +use chrono::Utc; +use rusqlite::{Connection, OptionalExtension}; +use std::path::Path; + +/// Default SQLite page size in bytes (4KB) +const DEFAULT_PAGE_SIZE: i64 = 4096; + +/// Cache size for SQLite in KB (negative value = KB instead of pages) +const CACHE_SIZE_KB: i64 = -20000; // 20MB + +/// Get current Unix timestamp in seconds +/// +/// Helper to avoid repeating `Utc::now().timestamp()` throughout the code +#[inline] +fn current_timestamp() -> i64 { + Utc::now().timestamp() +} + +/// Initialize SQLite connection with WAL mode and optimizations +pub fn initialize_persistence_db>(path: P) -> Result { + let conn = Connection::open(path)?; + + configure_sqlite_for_persistence(&conn)?; + create_persistence_schema(&conn)?; + + Ok(conn) +} + +/// Configure SQLite with WAL mode and battery-friendly settings +pub fn configure_sqlite_for_persistence(conn: &Connection) -> Result<()> { + // Enable Write-Ahead Logging for better concurrency and fewer fsyncs + conn.execute_batch("PRAGMA journal_mode = WAL;")?; + + // Don't auto-checkpoint on every transaction - we'll control this manually + conn.execute_batch("PRAGMA wal_autocheckpoint = 0;")?; + + // NORMAL synchronous mode - fsync WAL on commit, but not every write + // This is a good balance between durability and performance + conn.execute_batch("PRAGMA synchronous = NORMAL;")?; + + // Larger page size for better sequential write performance on mobile + // Note: This must be set before the database is created or after VACUUM + // We'll skip setting it if database already exists to avoid issues + let page_size: i64 = conn.query_row("PRAGMA page_size", [], |row| row.get(0))?; + if page_size == DEFAULT_PAGE_SIZE { + // Try to set larger page size, but only if we're at default + // This will only work on a fresh database + let _ = conn.execute_batch("PRAGMA page_size = 8192;"); + } + + // Increase cache size for better performance (in pages, negative = KB) + conn.execute_batch(&format!("PRAGMA cache_size = {};", CACHE_SIZE_KB))?; + + // Use memory for temp tables (faster, we don't need temp table durability) + conn.execute_batch("PRAGMA temp_store = MEMORY;")?; + + Ok(()) +} + +/// Create the database schema for persistence +pub fn create_persistence_schema(conn: &Connection) -> Result<()> { + // Entities table - stores entity metadata + conn.execute( + "CREATE TABLE IF NOT EXISTS entities ( + id BLOB PRIMARY KEY, + entity_type TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + )", + [], + )?; + + // Components table - stores serialized component data + conn.execute( + "CREATE TABLE IF NOT EXISTS components ( + entity_id BLOB NOT NULL, + component_type TEXT NOT NULL, + data BLOB NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (entity_id, component_type), + FOREIGN KEY (entity_id) REFERENCES entities(id) ON DELETE CASCADE + )", + [], + )?; + + // Index for querying components by entity + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_components_entity + ON components(entity_id)", + [], + )?; + + // Operation log - for CRDT sync protocol + conn.execute( + "CREATE TABLE IF NOT EXISTS operation_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + node_id TEXT NOT NULL, + sequence_number INTEGER NOT NULL, + operation BLOB NOT NULL, + timestamp INTEGER NOT NULL, + UNIQUE(node_id, sequence_number) + )", + [], + )?; + + // Index for efficient operation log queries + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_oplog_node_seq + ON operation_log(node_id, sequence_number)", + [], + )?; + + // Vector clock table - for causality tracking + conn.execute( + "CREATE TABLE IF NOT EXISTS vector_clock ( + node_id TEXT PRIMARY KEY, + counter INTEGER NOT NULL, + updated_at INTEGER NOT NULL + )", + [], + )?; + + // Session state table - for crash detection + conn.execute( + "CREATE TABLE IF NOT EXISTS session_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at INTEGER NOT NULL + )", + [], + )?; + + // WAL checkpoint tracking + conn.execute( + "CREATE TABLE IF NOT EXISTS checkpoint_state ( + last_checkpoint INTEGER NOT NULL, + wal_size_bytes INTEGER NOT NULL + )", + [], + )?; + + // Initialize checkpoint state if not exists + conn.execute( + "INSERT OR IGNORE INTO checkpoint_state (rowid, last_checkpoint, wal_size_bytes) + VALUES (1, ?, 0)", + [current_timestamp()], + )?; + + Ok(()) +} + +/// Flush a batch of operations to SQLite in a single transaction +pub fn flush_to_sqlite(ops: &[PersistenceOp], conn: &mut Connection) -> Result { + if ops.is_empty() { + return Ok(0); + } + + let tx = conn.transaction()?; + let mut count = 0; + + for op in ops { + match op { + PersistenceOp::UpsertEntity { id, data } => { + tx.execute( + "INSERT OR REPLACE INTO entities (id, entity_type, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4)", + rusqlite::params![ + id.as_bytes(), + data.entity_type, + data.created_at.timestamp(), + data.updated_at.timestamp(), + ], + )?; + count += 1; + } + + PersistenceOp::UpsertComponent { + entity_id, + component_type, + data, + } => { + tx.execute( + "INSERT OR REPLACE INTO components (entity_id, component_type, data, updated_at) + VALUES (?1, ?2, ?3, ?4)", + rusqlite::params![ + entity_id.as_bytes(), + component_type, + data, + current_timestamp(), + ], + )?; + count += 1; + } + + PersistenceOp::LogOperation { + node_id, + sequence, + operation, + } => { + tx.execute( + "INSERT OR REPLACE INTO operation_log (node_id, sequence_number, operation, timestamp) + VALUES (?1, ?2, ?3, ?4)", + rusqlite::params![ + node_id, + sequence, + operation, + current_timestamp(), + ], + )?; + count += 1; + } + + PersistenceOp::UpdateVectorClock { node_id, counter } => { + tx.execute( + "INSERT OR REPLACE INTO vector_clock (node_id, counter, updated_at) + VALUES (?1, ?2, ?3)", + rusqlite::params![node_id, counter, current_timestamp()], + )?; + count += 1; + } + + PersistenceOp::DeleteEntity { id } => { + tx.execute("DELETE FROM entities WHERE id = ?1", rusqlite::params![id.as_bytes()])?; + count += 1; + } + + PersistenceOp::DeleteComponent { + entity_id, + component_type, + } => { + tx.execute( + "DELETE FROM components WHERE entity_id = ?1 AND component_type = ?2", + rusqlite::params![entity_id.as_bytes(), component_type], + )?; + count += 1; + } + } + } + + tx.commit()?; + Ok(count) +} + +/// Manually checkpoint the WAL file to merge changes into the main database +/// +/// This function performs a SQLite WAL checkpoint, which copies frames from the +/// write-ahead log back into the main database file. This is crucial for: +/// - Reducing WAL file size to save disk space +/// - Ensuring durability of committed transactions +/// - Maintaining database integrity +/// +/// # Parameters +/// - `conn`: Mutable reference to the SQLite connection +/// - `mode`: Checkpoint mode controlling blocking behavior (see [`CheckpointMode`]) +/// +/// # Returns +/// - `Ok(CheckpointInfo)`: Information about the checkpoint operation +/// - `Err`: If the checkpoint fails or database state update fails +/// +/// # Examples +/// ```no_run +/// # use rusqlite::Connection; +/// # use lib::persistence::*; +/// # fn example() -> anyhow::Result<()> { +/// let mut conn = Connection::open("app.db")?; +/// let info = checkpoint_wal(&mut conn, CheckpointMode::Passive)?; +/// if info.busy { +/// // Some pages couldn't be checkpointed due to active readers +/// } +/// # Ok(()) +/// # } +/// ``` +pub fn checkpoint_wal(conn: &mut Connection, mode: CheckpointMode) -> Result { + let mode_str = match mode { + CheckpointMode::Passive => "PASSIVE", + CheckpointMode::Full => "FULL", + CheckpointMode::Restart => "RESTART", + CheckpointMode::Truncate => "TRUNCATE", + }; + + let query = format!("PRAGMA wal_checkpoint({})", mode_str); + + // Returns (busy, log_pages, checkpointed_pages) + let (busy, log_pages, checkpointed_pages): (i32, i32, i32) = + conn.query_row(&query, [], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?; + + // Update checkpoint state + conn.execute( + "UPDATE checkpoint_state SET last_checkpoint = ?1 WHERE rowid = 1", + [current_timestamp()], + )?; + + Ok(CheckpointInfo { + busy: busy != 0, + log_pages, + checkpointed_pages, + }) +} + +/// Get the size of the WAL file in bytes +/// +/// This checks the actual WAL file size on disk without triggering a checkpoint. +/// Large WAL files consume disk space and can slow down recovery, so monitoring +/// size helps maintain optimal performance. +/// +/// # Parameters +/// - `conn`: Reference to the SQLite connection +/// +/// # Returns +/// - `Ok(i64)`: WAL file size in bytes (0 if no WAL exists or in-memory database) +/// - `Err`: If the database path query fails +/// +/// # Note +/// For in-memory databases, always returns 0. +pub fn get_wal_size(conn: &Connection) -> Result { + // Get the database file path + let db_path: Option = conn + .query_row("PRAGMA database_list", [], |row| row.get::<_, String>(2)) + .optional()?; + + // If no path (in-memory database), return 0 + let Some(db_path) = db_path else { + return Ok(0); + }; + + // WAL file has same name as database but with -wal suffix + let wal_path = format!("{}-wal", db_path); + + // Check if WAL file exists and get its size + match std::fs::metadata(&wal_path) { + Ok(metadata) => Ok(metadata.len() as i64), + Err(_) => Ok(0), // WAL doesn't exist yet + } +} + +/// Checkpoint mode for WAL +#[derive(Debug, Clone, Copy)] +pub enum CheckpointMode { + /// Passive checkpoint - doesn't block readers/writers + Passive, + /// Full checkpoint - waits for writers to finish + Full, + /// Restart checkpoint - like Full, but restarts WAL file + Restart, + /// Truncate checkpoint - like Restart, but truncates WAL file to 0 bytes + Truncate, +} + +/// Information about a checkpoint operation +#[derive(Debug)] +pub struct CheckpointInfo { + pub busy: bool, + pub log_pages: i32, + pub checkpointed_pages: i32, +} + +/// Set a session state value in the database +/// +/// Session state is used to track application lifecycle events and detect crashes. +/// Values persist across restarts, enabling crash detection and recovery. +/// +/// # Parameters +/// - `conn`: Mutable reference to the SQLite connection +/// - `key`: State key (e.g., "clean_shutdown", "session_id") +/// - `value`: State value to store +/// +/// # Returns +/// - `Ok(())`: State was successfully saved +/// - `Err`: If the database write fails +pub fn set_session_state(conn: &mut Connection, key: &str, value: &str) -> Result<()> { + conn.execute( + "INSERT OR REPLACE INTO session_state (key, value, updated_at) + VALUES (?1, ?2, ?3)", + rusqlite::params![key, value, current_timestamp()], + )?; + Ok(()) +} + +/// Get a session state value from the database +/// +/// Retrieves persistent state information stored across application sessions. +/// +/// # Parameters +/// - `conn`: Reference to the SQLite connection +/// - `key`: State key to retrieve +/// +/// # Returns +/// - `Ok(Some(value))`: State exists and was retrieved +/// - `Ok(None)`: State key doesn't exist +/// - `Err`: If the database query fails +pub fn get_session_state(conn: &Connection, key: &str) -> Result> { + conn.query_row( + "SELECT value FROM session_state WHERE key = ?1", + rusqlite::params![key], + |row| row.get(0), + ) + .optional() + .map_err(|e| PersistenceError::Database(e)) +} + +/// Check if the previous session had a clean shutdown +/// +/// This is critical for crash detection. When the application starts, this checks +/// if the previous session ended cleanly. If not, it indicates a crash occurred, +/// and recovery procedures may be needed. +/// +/// **Side effect**: Resets the clean_shutdown flag to "false" for the current session. +/// Call [`mark_clean_shutdown`] during normal shutdown to set it back to "true". +/// +/// # Parameters +/// - `conn`: Mutable reference to the SQLite connection (mutates session state) +/// +/// # Returns +/// - `Ok(true)`: Previous session shut down cleanly +/// - `Ok(false)`: Previous session crashed or this is first run +/// - `Err`: If database operations fail +pub fn check_clean_shutdown(conn: &mut Connection) -> Result { + let clean = get_session_state(conn, "clean_shutdown")? + .map(|v| v == "true") + .unwrap_or(false); + + // Reset for this session + set_session_state(conn, "clean_shutdown", "false")?; + + Ok(clean) +} + +/// Mark the current session as cleanly shut down +/// +/// Call this during normal application shutdown to indicate clean termination. +/// The next startup will detect this flag via [`check_clean_shutdown`] and know +/// no crash occurred. +/// +/// # Parameters +/// - `conn`: Mutable reference to the SQLite connection +/// +/// # Returns +/// - `Ok(())`: Clean shutdown flag was set +/// - `Err`: If the database write fails +pub fn mark_clean_shutdown(conn: &mut Connection) -> Result<()> { + set_session_state(conn, "clean_shutdown", "true") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_database_initialization() -> Result<()> { + let conn = Connection::open_in_memory()?; + configure_sqlite_for_persistence(&conn)?; + create_persistence_schema(&conn)?; + + // Verify tables exist + let tables: Vec = conn + .prepare("SELECT name FROM sqlite_master WHERE type='table'")? + .query_map([], |row| row.get(0))? + .collect::, _>>()?; + + assert!(tables.contains(&"entities".to_string())); + assert!(tables.contains(&"components".to_string())); + assert!(tables.contains(&"operation_log".to_string())); + assert!(tables.contains(&"vector_clock".to_string())); + + Ok(()) + } + + #[test] + fn test_flush_operations() -> Result<()> { + let mut conn = Connection::open_in_memory()?; + create_persistence_schema(&conn)?; + + let entity_id = uuid::Uuid::new_v4(); + let ops = vec![ + PersistenceOp::UpsertEntity { + id: entity_id, + data: EntityData { + id: entity_id, + created_at: Utc::now(), + updated_at: Utc::now(), + entity_type: "TestEntity".to_string(), + }, + }, + PersistenceOp::UpsertComponent { + entity_id, + component_type: "Transform".to_string(), + data: vec![1, 2, 3, 4], + }, + ]; + + let count = flush_to_sqlite(&ops, &mut conn)?; + assert_eq!(count, 2); + + // Verify entity exists + let exists: bool = conn.query_row( + "SELECT COUNT(*) > 0 FROM entities WHERE id = ?1", + rusqlite::params![entity_id.as_bytes()], + |row| row.get(0), + )?; + assert!(exists); + + Ok(()) + } + + #[test] + fn test_session_state() -> Result<()> { + let mut conn = Connection::open_in_memory()?; + create_persistence_schema(&conn)?; + + set_session_state(&mut conn, "test_key", "test_value")?; + let value = get_session_state(&conn, "test_key")?; + assert_eq!(value, Some("test_value".to_string())); + + Ok(()) + } + + #[test] + fn test_crash_recovery() -> Result<()> { + let mut conn = Connection::open_in_memory()?; + create_persistence_schema(&conn)?; + + // Simulate first startup - should report as crash (no clean shutdown marker) + let clean = check_clean_shutdown(&mut conn)?; + assert!(!clean, "First startup should be detected as crash"); + + // Mark clean shutdown + mark_clean_shutdown(&mut conn)?; + + // Next startup should report clean shutdown + let clean = check_clean_shutdown(&mut conn)?; + assert!(clean, "Should detect clean shutdown"); + + // After checking clean shutdown, flag should be reset to false + // So if we check again without marking, it should report as crash + let value = get_session_state(&conn, "clean_shutdown")?; + assert_eq!(value, Some("false".to_string()), "Flag should be reset after check"); + + Ok(()) + } +} diff --git a/crates/lib/src/persistence/error.rs b/crates/lib/src/persistence/error.rs new file mode 100644 index 0000000..b6c8f88 --- /dev/null +++ b/crates/lib/src/persistence/error.rs @@ -0,0 +1,107 @@ +//! Error types for the persistence layer + +use std::fmt; + +/// Result type for persistence operations +pub type Result = std::result::Result; + +/// Errors that can occur in the persistence layer +#[derive(Debug)] +pub enum PersistenceError { + /// Database operation failed + Database(rusqlite::Error), + + /// Serialization failed + Serialization(bincode::Error), + + /// Deserialization failed + Deserialization(String), + + /// Configuration error + Config(String), + + /// I/O error (file operations, WAL checks, etc.) + Io(std::io::Error), + + /// Type not found in registry + TypeNotRegistered(String), + + /// Entity or component not found + NotFound(String), + + /// Circuit breaker is open, operation blocked + CircuitBreakerOpen { + consecutive_failures: u32, + retry_after_secs: u64, + }, + + /// Other error + Other(String), +} + +impl fmt::Display for PersistenceError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Database(err) => write!(f, "Database error: {}", err), + Self::Serialization(err) => write!(f, "Serialization error: {}", err), + Self::Deserialization(msg) => write!(f, "Deserialization error: {}", msg), + Self::Config(msg) => write!(f, "Configuration error: {}", msg), + Self::Io(err) => write!(f, "I/O error: {}", err), + Self::TypeNotRegistered(type_name) => { + write!(f, "Type not registered in type registry: {}", type_name) + } + Self::NotFound(msg) => write!(f, "Not found: {}", msg), + Self::CircuitBreakerOpen { + consecutive_failures, + retry_after_secs, + } => write!( + f, + "Circuit breaker open after {} consecutive failures, retry after {} seconds", + consecutive_failures, retry_after_secs + ), + Self::Other(msg) => write!(f, "{}", msg), + } + } +} + +impl std::error::Error for PersistenceError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Database(err) => Some(err), + Self::Serialization(err) => Some(err), + Self::Io(err) => Some(err), + _ => None, + } + } +} + +// Conversions from common error types +impl From for PersistenceError { + fn from(err: rusqlite::Error) -> Self { + Self::Database(err) + } +} + +impl From for PersistenceError { + fn from(err: bincode::Error) -> Self { + Self::Serialization(err) + } +} + +impl From for PersistenceError { + fn from(err: std::io::Error) -> Self { + Self::Io(err) + } +} + +impl From for PersistenceError { + fn from(err: toml::de::Error) -> Self { + Self::Config(err.to_string()) + } +} + +impl From for PersistenceError { + fn from(err: toml::ser::Error) -> Self { + Self::Config(err.to_string()) + } +} diff --git a/crates/lib/src/persistence/health.rs b/crates/lib/src/persistence/health.rs new file mode 100644 index 0000000..c53b589 --- /dev/null +++ b/crates/lib/src/persistence/health.rs @@ -0,0 +1,212 @@ +//! Health monitoring and error recovery for persistence layer + +use bevy::prelude::*; +use std::time::{Duration, Instant}; + +/// Base delay for exponential backoff in milliseconds +const BASE_RETRY_DELAY_MS: u64 = 1000; // 1 second + +/// Maximum retry delay in milliseconds (caps exponential backoff) +const MAX_RETRY_DELAY_MS: u64 = 30000; // 30 seconds + +/// Maximum exponent for exponential backoff calculation +const MAX_BACKOFF_EXPONENT: u32 = 5; + +/// Resource to track persistence health and failures +#[derive(Resource, Debug)] +pub struct PersistenceHealth { + /// Number of consecutive flush failures + pub consecutive_flush_failures: u32, + + /// Number of consecutive checkpoint failures + pub consecutive_checkpoint_failures: u32, + + /// Time of last successful flush + pub last_successful_flush: Option, + + /// Time of last successful checkpoint + pub last_successful_checkpoint: Option, + + /// Whether the persistence layer is in circuit breaker mode + pub circuit_breaker_open: bool, + + /// When the circuit breaker was opened + pub circuit_breaker_opened_at: Option, + + /// Total number of failures across the session + pub total_failures: u64, +} + +impl Default for PersistenceHealth { + fn default() -> Self { + Self { + consecutive_flush_failures: 0, + consecutive_checkpoint_failures: 0, + last_successful_flush: None, + last_successful_checkpoint: None, + circuit_breaker_open: false, + circuit_breaker_opened_at: None, + total_failures: 0, + } + } +} + +impl PersistenceHealth { + /// Circuit breaker threshold - open after this many consecutive failures + pub const CIRCUIT_BREAKER_THRESHOLD: u32 = 5; + + /// How long to keep circuit breaker open before attempting recovery + pub const CIRCUIT_BREAKER_COOLDOWN: Duration = Duration::from_secs(60); + + /// Record a successful flush + pub fn record_flush_success(&mut self) { + self.consecutive_flush_failures = 0; + self.last_successful_flush = Some(Instant::now()); + + // Close circuit breaker if it was open + if self.circuit_breaker_open { + info!("Persistence recovered - closing circuit breaker"); + self.circuit_breaker_open = false; + self.circuit_breaker_opened_at = None; + } + } + + /// Record a flush failure + pub fn record_flush_failure(&mut self) { + self.consecutive_flush_failures += 1; + self.total_failures += 1; + + if self.consecutive_flush_failures >= Self::CIRCUIT_BREAKER_THRESHOLD { + if !self.circuit_breaker_open { + warn!( + "Opening circuit breaker after {} consecutive flush failures", + self.consecutive_flush_failures + ); + self.circuit_breaker_open = true; + self.circuit_breaker_opened_at = Some(Instant::now()); + } + } + } + + /// Record a successful checkpoint + pub fn record_checkpoint_success(&mut self) { + self.consecutive_checkpoint_failures = 0; + self.last_successful_checkpoint = Some(Instant::now()); + } + + /// Record a checkpoint failure + pub fn record_checkpoint_failure(&mut self) { + self.consecutive_checkpoint_failures += 1; + self.total_failures += 1; + } + + /// Check if we should attempt operations (circuit breaker state) + /// + /// **CRITICAL FIX**: Now takes `&mut self` to properly reset the circuit breaker + /// after cooldown expires. This prevents the circuit breaker from remaining + /// permanently open after one post-cooldown failure. + pub fn should_attempt_operation(&mut self) -> bool { + if !self.circuit_breaker_open { + return true; + } + + // Check if cooldown period has elapsed + if let Some(opened_at) = self.circuit_breaker_opened_at { + if opened_at.elapsed() >= Self::CIRCUIT_BREAKER_COOLDOWN { + // Transition to half-open state by resetting the breaker + info!("Circuit breaker cooldown elapsed - entering half-open state (testing recovery)"); + self.circuit_breaker_open = false; + self.circuit_breaker_opened_at = None; + // consecutive_flush_failures is kept to track if this probe succeeds + return true; + } + } + + false + } + + /// Get exponential backoff delay based on consecutive failures + pub fn get_retry_delay(&self) -> Duration { + // Exponential backoff: 1s, 2s, 4s, 8s, 16s, max 30s + let delay_ms = BASE_RETRY_DELAY_MS * 2u64.pow(self.consecutive_flush_failures.min(MAX_BACKOFF_EXPONENT)); + Duration::from_millis(delay_ms.min(MAX_RETRY_DELAY_MS)) + } +} + +/// Message emitted when persistence fails +#[derive(Message, Debug, Clone)] +pub struct PersistenceFailureEvent { + pub error: String, + pub consecutive_failures: u32, + pub circuit_breaker_open: bool, +} + +/// Message emitted when persistence recovers from failures +#[derive(Message, Debug, Clone)] +pub struct PersistenceRecoveryEvent { + pub previous_failures: u32, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_circuit_breaker() { + let mut health = PersistenceHealth::default(); + + // Should allow operations initially + assert!(health.should_attempt_operation()); + assert!(!health.circuit_breaker_open); + + // Record failures + for _ in 0..PersistenceHealth::CIRCUIT_BREAKER_THRESHOLD { + health.record_flush_failure(); + } + + // Circuit breaker should now be open + assert!(health.circuit_breaker_open); + assert!(!health.should_attempt_operation()); + + // Should still block immediately after opening + assert!(!health.should_attempt_operation()); + } + + #[test] + fn test_recovery() { + let mut health = PersistenceHealth::default(); + + // Trigger circuit breaker + for _ in 0..PersistenceHealth::CIRCUIT_BREAKER_THRESHOLD { + health.record_flush_failure(); + } + assert!(health.circuit_breaker_open); + + // Successful flush should close circuit breaker + health.record_flush_success(); + assert!(!health.circuit_breaker_open); + assert_eq!(health.consecutive_flush_failures, 0); + } + + #[test] + fn test_exponential_backoff() { + let mut health = PersistenceHealth::default(); + + // No failures = 1s delay + assert_eq!(health.get_retry_delay(), Duration::from_secs(1)); + + // 1 failure = 2s + health.record_flush_failure(); + assert_eq!(health.get_retry_delay(), Duration::from_secs(2)); + + // 2 failures = 4s + health.record_flush_failure(); + assert_eq!(health.get_retry_delay(), Duration::from_secs(4)); + + // Max out at 30s + for _ in 0..10 { + health.record_flush_failure(); + } + assert_eq!(health.get_retry_delay(), Duration::from_secs(30)); + } +} diff --git a/crates/lib/src/persistence/lifecycle.rs b/crates/lib/src/persistence/lifecycle.rs new file mode 100644 index 0000000..3954bc1 --- /dev/null +++ b/crates/lib/src/persistence/lifecycle.rs @@ -0,0 +1,158 @@ +//! iOS lifecycle event handling for persistence +//! +//! This module provides event types and handlers for iOS application lifecycle +//! events that require immediate persistence (e.g., background suspension). +//! +//! # iOS Integration +//! +//! To integrate with iOS, wire up these handlers in your app delegate: +//! +//! ```swift +//! // In your iOS app delegate: +//! func applicationWillResignActive(_ application: UIApplication) { +//! // Send AppLifecycleEvent::WillResignActive to Bevy +//! } +//! +//! func applicationDidEnterBackground(_ application: UIApplication) { +//! // Send AppLifecycleEvent::DidEnterBackground to Bevy +//! } +//! ``` + +use crate::persistence::*; +use bevy::prelude::*; + +/// Application lifecycle events that require persistence handling +/// +/// These events are critical moments where data must be flushed immediately +/// to avoid data loss. +#[derive(Debug, Clone, Message)] +pub enum AppLifecycleEvent { + /// Application will resign active (iOS: `applicationWillResignActive`) + /// + /// Sent when the app is about to move from active to inactive state. + /// Example: incoming phone call, user switches to another app + WillResignActive, + + /// Application did enter background (iOS: `applicationDidEnterBackground`) + /// + /// Sent when the app has moved to the background. The app has approximately + /// 5 seconds to complete critical tasks before suspension. + DidEnterBackground, + + /// Application will enter foreground (iOS: `applicationWillEnterForeground`) + /// + /// Sent when the app is about to enter the foreground (user returning to app). + WillEnterForeground, + + /// Application did become active (iOS: `applicationDidBecomeActive`) + /// + /// Sent when the app has become active and is ready to receive user input. + DidBecomeActive, + + /// Application will terminate (iOS: `applicationWillTerminate`) + /// + /// Sent when the app is about to terminate. Similar to shutdown but from OS. + WillTerminate, +} + +/// System to handle iOS lifecycle events and trigger immediate persistence +/// +/// This system listens for lifecycle events and performs immediate flushes +/// when the app is backgrounding or terminating. +pub fn lifecycle_event_system( + mut events: MessageReader, + mut write_buffer: ResMut, + db: Res, + mut metrics: ResMut, + mut health: ResMut, + mut pending_tasks: ResMut, +) { + for event in events.read() { + match event { + AppLifecycleEvent::WillResignActive => { + // App is becoming inactive - perform immediate flush + info!("App will resign active - performing immediate flush"); + + if let Err(e) = force_flush(&mut write_buffer, &db, &mut metrics) { + error!("Failed to flush on resign active: {}", e); + health.record_flush_failure(); + } else { + health.record_flush_success(); + } + } + + AppLifecycleEvent::DidEnterBackground => { + // App entered background - perform immediate flush and checkpoint + info!("App entered background - performing immediate flush and checkpoint"); + + // Force immediate flush + if let Err(e) = force_flush(&mut write_buffer, &db, &mut metrics) { + error!("Failed to flush on background: {}", e); + health.record_flush_failure(); + } else { + health.record_flush_success(); + } + + // Also checkpoint the WAL to ensure durability + let start = std::time::Instant::now(); + match db.lock() { + Ok(mut conn) => { + match checkpoint_wal(&mut conn, CheckpointMode::Passive) { + Ok(_) => { + let duration = start.elapsed(); + metrics.record_checkpoint(duration); + health.record_checkpoint_success(); + info!("Background checkpoint completed successfully"); + } + Err(e) => { + error!("Failed to checkpoint on background: {}", e); + health.record_checkpoint_failure(); + } + } + } + Err(e) => { + error!("Failed to acquire database lock for checkpoint: {}", e); + health.record_checkpoint_failure(); + } + } + } + + AppLifecycleEvent::WillTerminate => { + // App will terminate - perform shutdown sequence + warn!("App will terminate - performing shutdown sequence"); + + if let Err(e) = shutdown_system(&mut write_buffer, &db, &mut metrics, Some(&mut pending_tasks)) { + error!("Failed to perform shutdown on terminate: {}", e); + } else { + info!("Clean shutdown completed on terminate"); + } + } + + AppLifecycleEvent::WillEnterForeground => { + // App returning from background - no immediate action needed + info!("App will enter foreground"); + } + + AppLifecycleEvent::DidBecomeActive => { + // App became active - no immediate action needed + info!("App did become active"); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_lifecycle_event_creation() { + let event = AppLifecycleEvent::WillResignActive; + match event { + AppLifecycleEvent::WillResignActive => { + // Success + } + _ => panic!("Event type mismatch"), + } + } +} diff --git a/crates/lib/src/persistence/metrics.rs b/crates/lib/src/persistence/metrics.rs new file mode 100644 index 0000000..624402c --- /dev/null +++ b/crates/lib/src/persistence/metrics.rs @@ -0,0 +1,211 @@ +//! Metrics tracking for persistence layer + +use std::time::Duration; + +/// Metrics for monitoring persistence performance +#[derive(Debug, Clone, Default, bevy::prelude::Resource)] +pub struct PersistenceMetrics { + // Write volume + pub total_writes: u64, + pub bytes_written: u64, + + // Timing + pub flush_count: u64, + pub total_flush_duration: Duration, + pub checkpoint_count: u64, + pub total_checkpoint_duration: Duration, + + // WAL health + pub wal_size_bytes: u64, + pub max_wal_size_bytes: u64, + + // Recovery + pub crash_recovery_count: u64, + pub clean_shutdown_count: u64, + + // Buffer stats + pub max_buffer_size: usize, + pub total_coalesced_ops: u64, +} + +impl PersistenceMetrics { + /// Record a flush operation + pub fn record_flush(&mut self, operations: usize, duration: Duration, bytes_written: u64) { + self.flush_count += 1; + self.total_writes += operations as u64; + self.total_flush_duration += duration; + self.bytes_written += bytes_written; + } + + /// Record a checkpoint operation + pub fn record_checkpoint(&mut self, duration: Duration) { + self.checkpoint_count += 1; + self.total_checkpoint_duration += duration; + } + + /// Update WAL size + pub fn update_wal_size(&mut self, size: u64) { + self.wal_size_bytes = size; + if size > self.max_wal_size_bytes { + self.max_wal_size_bytes = size; + } + } + + /// Record a crash recovery + pub fn record_crash_recovery(&mut self) { + self.crash_recovery_count += 1; + } + + /// Record a clean shutdown + pub fn record_clean_shutdown(&mut self) { + self.clean_shutdown_count += 1; + } + + /// Record buffer stats + pub fn record_buffer_stats(&mut self, buffer_size: usize, coalesced: u64) { + if buffer_size > self.max_buffer_size { + self.max_buffer_size = buffer_size; + } + self.total_coalesced_ops += coalesced; + } + + /// Get average flush duration + pub fn avg_flush_duration(&self) -> Duration { + if self.flush_count == 0 { + Duration::from_secs(0) + } else { + self.total_flush_duration / self.flush_count as u32 + } + } + + /// Get average checkpoint duration + pub fn avg_checkpoint_duration(&self) -> Duration { + if self.checkpoint_count == 0 { + Duration::from_secs(0) + } else { + self.total_checkpoint_duration / self.checkpoint_count as u32 + } + } + + /// Get crash recovery rate + pub fn crash_recovery_rate(&self) -> f64 { + let total = self.crash_recovery_count + self.clean_shutdown_count; + if total == 0 { + 0.0 + } else { + self.crash_recovery_count as f64 / total as f64 + } + } + + /// Check if metrics indicate performance issues + pub fn check_health(&self) -> Vec { + let mut warnings = Vec::new(); + + // Check flush duration + if self.avg_flush_duration() > Duration::from_millis(50) { + warnings.push(HealthWarning::SlowFlush(self.avg_flush_duration())); + } + + // Check WAL size + if self.wal_size_bytes > 5 * 1024 * 1024 { + // 5MB + warnings.push(HealthWarning::LargeWal(self.wal_size_bytes)); + } + + // Check crash rate + if self.crash_recovery_rate() > 0.1 { + warnings.push(HealthWarning::HighCrashRate(self.crash_recovery_rate())); + } + + warnings + } + + /// Reset all metrics + pub fn reset(&mut self) { + *self = Self::default(); + } +} + +/// Health warnings for persistence metrics +#[derive(Debug, Clone)] +pub enum HealthWarning { + /// Flush operations are taking too long + SlowFlush(Duration), + + /// WAL file is too large + LargeWal(u64), + + /// High crash recovery rate + HighCrashRate(f64), +} + +impl std::fmt::Display for HealthWarning { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + HealthWarning::SlowFlush(duration) => { + write!( + f, + "Flush duration ({:?}) exceeds 50ms threshold", + duration + ) + } + HealthWarning::LargeWal(size) => { + write!(f, "WAL size ({} bytes) exceeds 5MB threshold", size) + } + HealthWarning::HighCrashRate(rate) => { + write!(f, "Crash recovery rate ({:.1}%) exceeds 10% threshold", rate * 100.0) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_metrics_recording() { + let mut metrics = PersistenceMetrics::default(); + + metrics.record_flush(10, Duration::from_millis(5), 1024); + assert_eq!(metrics.flush_count, 1); + assert_eq!(metrics.total_writes, 10); + assert_eq!(metrics.bytes_written, 1024); + + metrics.record_checkpoint(Duration::from_millis(10)); + assert_eq!(metrics.checkpoint_count, 1); + } + + #[test] + fn test_average_calculations() { + let mut metrics = PersistenceMetrics::default(); + + metrics.record_flush(10, Duration::from_millis(10), 1024); + metrics.record_flush(20, Duration::from_millis(20), 2048); + + assert_eq!(metrics.avg_flush_duration(), Duration::from_millis(15)); + } + + #[test] + fn test_health_warnings() { + let mut metrics = PersistenceMetrics::default(); + + // Add slow flush + metrics.record_flush(10, Duration::from_millis(100), 1024); + + let warnings = metrics.check_health(); + assert_eq!(warnings.len(), 1); + assert!(matches!(warnings[0], HealthWarning::SlowFlush(_))); + } + + #[test] + fn test_crash_recovery_rate() { + let mut metrics = PersistenceMetrics::default(); + + metrics.record_crash_recovery(); + metrics.record_clean_shutdown(); + metrics.record_clean_shutdown(); + + assert_eq!(metrics.crash_recovery_rate(), 1.0 / 3.0); + } +} diff --git a/crates/lib/src/persistence/mod.rs b/crates/lib/src/persistence/mod.rs new file mode 100644 index 0000000..9964f9f --- /dev/null +++ b/crates/lib/src/persistence/mod.rs @@ -0,0 +1,51 @@ +//! Persistence layer for battery-efficient state management +//! +//! This module implements the persistence strategy defined in RFC 0002. +//! It provides a three-tier system to minimize disk I/O while maintaining data durability: +//! +//! 1. **In-Memory Dirty Tracking** - Track changes without writing immediately +//! 2. **Write Buffer** - Batch and coalesce operations before writing +//! 3. **SQLite with WAL Mode** - Controlled checkpoints to minimize fsync() calls +//! +//! # Example +//! +//! ```no_run +//! use lib::persistence::*; +//! use bevy::prelude::*; +//! +//! fn setup(mut commands: Commands) { +//! // Spawn an entity with the Persisted marker +//! commands.spawn(Persisted::new()); +//! } +//! +//! // The persistence plugin automatically tracks changes to Persisted components +//! fn main() { +//! App::new() +//! .add_plugins(DefaultPlugins) +//! .add_plugins(PersistencePlugin::new("app.db")) +//! .add_systems(Startup, setup) +//! .run(); +//! } +//! ``` + +mod types; +mod database; +mod systems; +mod config; +mod metrics; +mod plugin; +mod reflection; +mod health; +mod error; +mod lifecycle; + +pub use types::*; +pub use database::*; +pub use systems::*; +pub use config::*; +pub use metrics::*; +pub use plugin::*; +pub use reflection::*; +pub use health::*; +pub use error::*; +pub use lifecycle::*; diff --git a/crates/lib/src/persistence/plugin.rs b/crates/lib/src/persistence/plugin.rs new file mode 100644 index 0000000..0671459 --- /dev/null +++ b/crates/lib/src/persistence/plugin.rs @@ -0,0 +1,259 @@ +//! Bevy plugin for the persistence layer +//! +//! This module provides a Bevy plugin that sets up all the necessary resources +//! and systems for the persistence layer. + +use crate::persistence::*; +use bevy::prelude::*; +use std::path::PathBuf; +use std::ops::{Deref, DerefMut}; + +/// Bevy plugin for persistence +/// +/// # Example +/// +/// ```no_run +/// use bevy::prelude::*; +/// use lib::persistence::PersistencePlugin; +/// +/// App::new() +/// .add_plugins(PersistencePlugin::new("app.db")) +/// .run(); +/// ``` +pub struct PersistencePlugin { + /// Path to the SQLite database file + pub db_path: PathBuf, + + /// Persistence configuration + pub config: PersistenceConfig, +} + +impl PersistencePlugin { + /// Create a new persistence plugin with default configuration + pub fn new(db_path: impl Into) -> Self { + Self { + db_path: db_path.into(), + config: PersistenceConfig::default(), + } + } + + /// Create a new persistence plugin with custom configuration + pub fn with_config(db_path: impl Into, config: PersistenceConfig) -> Self { + Self { + db_path: db_path.into(), + config, + } + } + + /// Load configuration from a TOML file + pub fn with_config_file( + db_path: impl Into, + config_path: impl AsRef, + ) -> crate::persistence::error::Result { + let config = load_config_from_file(config_path)?; + Ok(Self { + db_path: db_path.into(), + config, + }) + } +} + +impl Plugin for PersistencePlugin { + fn build(&self, app: &mut App) { + // Initialize database + let db = PersistenceDb::from_path(&self.db_path) + .expect("Failed to initialize persistence database"); + + // Register types for reflection + app.register_type::(); + + // Add messages/events + app.add_message::() + .add_message::() + .add_message::(); + + // Insert resources + app.insert_resource(db) + .insert_resource(DirtyEntitiesResource::default()) + .insert_resource(WriteBufferResource::new(self.config.max_buffer_operations)) + .insert_resource(self.config.clone()) + .insert_resource(BatteryStatus::default()) + .insert_resource(PersistenceMetrics::default()) + .insert_resource(CheckpointTimer::default()) + .insert_resource(PersistenceHealth::default()) + .insert_resource(PendingFlushTasks::default()); + + // Add startup system + app.add_systems(Startup, persistence_startup_system); + + // Add systems in the appropriate schedule + app.add_systems( + Update, + ( + lifecycle_event_system, + collect_dirty_entities_bevy_system, + flush_system, + checkpoint_bevy_system, + ) + .chain(), + ); + } +} + +/// Resource wrapper for DirtyEntities +#[derive(Resource, Default)] +pub struct DirtyEntitiesResource(pub DirtyEntities); + +impl std::ops::Deref for DirtyEntitiesResource { + type Target = DirtyEntities; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl std::ops::DerefMut for DirtyEntitiesResource { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +/// Resource wrapper for WriteBuffer +#[derive(Resource)] +pub struct WriteBufferResource(pub WriteBuffer); + +impl WriteBufferResource { + pub fn new(max_operations: usize) -> Self { + Self(WriteBuffer::new(max_operations)) + } +} + +impl std::ops::Deref for WriteBufferResource { + type Target = WriteBuffer; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl std::ops::DerefMut for WriteBufferResource { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +/// Startup system to initialize persistence +fn persistence_startup_system( + db: Res, + mut metrics: ResMut, +) { + if let Err(e) = startup_system(db.deref(), metrics.deref_mut()) { + error!("Failed to initialize persistence: {}", e); + } else { + info!("Persistence system initialized"); + } +} + +/// System to collect dirty entities using Bevy's change detection +/// +/// This system tracks changes to the `Persisted` component. When `Persisted` is +/// marked as changed (via `mark_dirty()` or direct mutation), ALL components on +/// that entity are serialized and added to the write buffer. +/// +/// For automatic tracking without manual `mark_dirty()` calls, use the +/// `auto_track_component_changes_system` which automatically detects changes +/// to common components like Transform, GlobalTransform, etc. +fn collect_dirty_entities_bevy_system( + mut dirty: ResMut, + mut write_buffer: ResMut, + query: Query<(Entity, &Persisted), Changed>, + world: &World, + type_registry: Res, +) { + let registry = type_registry.read(); + + // Track changed entities and serialize all their components + for (entity, persisted) in query.iter() { + // Serialize all components on this entity (generic tracking) + let components = serialize_all_components_from_entity(entity, world, ®istry); + + // Add operations for each component + for (component_type, data) in components { + dirty.mark_dirty(persisted.network_id, &component_type); + + write_buffer.add(PersistenceOp::UpsertComponent { + entity_id: persisted.network_id, + component_type, + data, + }); + } + } +} + +/// System to automatically track changes to common Bevy components +/// +/// This system detects changes to Transform, automatically triggering persistence +/// by accessing `Persisted` mutably (which marks it as changed via Bevy's change detection). +/// +/// Add this system to your app if you want automatic persistence of Transform changes: +/// +/// ```no_run +/// # use bevy::prelude::*; +/// # use lib::persistence::*; +/// App::new() +/// .add_plugins(PersistencePlugin::new("app.db")) +/// .add_systems(Update, auto_track_transform_changes_system) +/// .run(); +/// ``` +pub fn auto_track_transform_changes_system( + mut query: Query<&mut Persisted, (With, Changed)>, +) { + // Simply accessing &mut Persisted triggers Bevy's change detection + for _persisted in query.iter_mut() { + // No-op - the mutable access itself marks Persisted as changed + } +} + + +/// System to checkpoint the WAL +fn checkpoint_bevy_system( + db: Res, + config: Res, + mut timer: ResMut, + mut metrics: ResMut, + mut health: ResMut, +) { + match checkpoint_system(db.deref(), config.deref(), timer.deref_mut(), metrics.deref_mut()) { + Ok(_) => { + health.record_checkpoint_success(); + } + Err(e) => { + health.record_checkpoint_failure(); + error!( + "Failed to checkpoint WAL (attempt {}): {}", + health.consecutive_checkpoint_failures, + e + ); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_plugin_creation() { + let plugin = PersistencePlugin::new("test.db"); + assert_eq!(plugin.db_path, PathBuf::from("test.db")); + } + + #[test] + fn test_plugin_with_config() { + let mut config = PersistenceConfig::default(); + config.flush_interval_secs = 5; + + let plugin = PersistencePlugin::with_config("test.db", config); + assert_eq!(plugin.config.flush_interval_secs, 5); + } +} diff --git a/crates/lib/src/persistence/reflection.rs b/crates/lib/src/persistence/reflection.rs new file mode 100644 index 0000000..086e7ee --- /dev/null +++ b/crates/lib/src/persistence/reflection.rs @@ -0,0 +1,255 @@ +//! Reflection-based component serialization for persistence +//! +//! This module provides utilities to serialize and deserialize Bevy components +//! using reflection, allowing the persistence layer to work with any component +//! that implements Reflect. + +use bevy::prelude::*; +use bevy::reflect::serde::{ReflectSerializer, ReflectDeserializer}; +use bevy::reflect::TypeRegistry; +use crate::persistence::error::{PersistenceError, Result}; + +/// Marker component to indicate that an entity should be persisted +/// +/// Add this component to any entity that should have its state persisted to disk. +/// The persistence system will automatically serialize all components on entities +/// with this marker when they change. +/// +/// # Triggering Persistence +/// +/// To trigger persistence after modifying components on an entity, access `Persisted` +/// mutably through a query. Bevy's change detection will automatically mark it as changed: +/// +/// ```no_run +/// # use bevy::prelude::*; +/// # use lib::persistence::*; +/// fn update_position(mut query: Query<(&mut Transform, &mut Persisted)>) { +/// for (mut transform, mut persisted) in query.iter_mut() { +/// transform.translation.x += 1.0; +/// // Accessing &mut Persisted triggers change detection automatically +/// } +/// } +/// ``` +/// +/// Alternatively, use `auto_track_transform_changes_system` for automatic persistence +/// of Transform changes without manual queries. +#[derive(Component, Reflect, Default)] +#[reflect(Component)] +pub struct Persisted { + /// Unique network ID for this entity + pub network_id: uuid::Uuid, +} + +impl Persisted { + pub fn new() -> Self { + Self { + network_id: uuid::Uuid::new_v4(), + } + } + + pub fn with_id(network_id: uuid::Uuid) -> Self { + Self { network_id } + } +} + +/// Trait for components that can be persisted +pub trait Persistable: Component + Reflect { + /// Get the type name for this component (used as key in database) + fn type_name() -> &'static str { + std::any::type_name::() + } +} + +/// Serialize a component using Bevy's reflection system +/// +/// This converts any component implementing `Reflect` into bytes for storage. +/// Uses bincode for efficient binary serialization with type information from +/// the registry to handle polymorphic types correctly. +/// +/// # Parameters +/// - `component`: Component to serialize (must implement `Reflect`) +/// - `type_registry`: Bevy's type registry for reflection metadata +/// +/// # Returns +/// - `Ok(Vec)`: Serialized component data +/// - `Err`: If serialization fails (e.g., type not properly registered) +/// +/// # Examples +/// ```no_run +/// # use bevy::prelude::*; +/// # use lib::persistence::*; +/// # fn example(component: &Transform, registry: &AppTypeRegistry) -> anyhow::Result<()> { +/// let registry = registry.read(); +/// let bytes = serialize_component(component.as_reflect(), ®istry)?; +/// # Ok(()) +/// # } +/// ``` +pub fn serialize_component( + component: &dyn Reflect, + type_registry: &TypeRegistry, +) -> Result> { + let serializer = ReflectSerializer::new(component, type_registry); + bincode::serialize(&serializer).map_err(PersistenceError::from) +} + +/// Deserialize a component using Bevy's reflection system +/// +/// Converts serialized bytes back into a reflected component. The returned +/// component is boxed and must be downcast to the concrete type for use. +/// +/// # Parameters +/// - `bytes`: Serialized component data from [`serialize_component`] +/// - `type_registry`: Bevy's type registry for reflection metadata +/// +/// # Returns +/// - `Ok(Box)`: Deserialized component (needs downcasting) +/// - `Err`: If deserialization fails (e.g., type not registered, data corruption) +/// +/// # Examples +/// ```no_run +/// # use bevy::prelude::*; +/// # use lib::persistence::*; +/// # fn example(bytes: &[u8], registry: &AppTypeRegistry) -> anyhow::Result<()> { +/// let registry = registry.read(); +/// let reflected = deserialize_component(bytes, ®istry)?; +/// // Downcast to concrete type as needed +/// # Ok(()) +/// # } +/// ``` +pub fn deserialize_component( + bytes: &[u8], + type_registry: &TypeRegistry, +) -> Result> { + let mut deserializer = bincode::Deserializer::from_slice(bytes, bincode::options()); + let reflect_deserializer = ReflectDeserializer::new(type_registry); + + use serde::de::DeserializeSeed; + reflect_deserializer + .deserialize(&mut deserializer) + .map_err(|e| PersistenceError::Deserialization(e.to_string())) +} + +/// Serialize a component directly from an entity using its type path +/// +/// This is a convenience function that combines type lookup, reflection, and +/// serialization. It's the primary method used by the persistence system to +/// save component state without knowing the concrete type at compile time. +/// +/// # Parameters +/// - `entity`: Bevy entity to read the component from +/// - `component_type`: Type path string (e.g., "bevy_transform::components::Transform") +/// - `world`: Bevy world containing the entity +/// - `type_registry`: Bevy's type registry for reflection metadata +/// +/// # Returns +/// - `Some(Vec)`: Serialized component data +/// - `None`: If entity doesn't have the component or type isn't registered +/// +/// # Examples +/// ```no_run +/// # use bevy::prelude::*; +/// # use lib::persistence::*; +/// # fn example(entity: Entity, world: &World, registry: &AppTypeRegistry) -> Option<()> { +/// let registry = registry.read(); +/// let bytes = serialize_component_from_entity( +/// entity, +/// "bevy_transform::components::Transform", +/// world, +/// ®istry +/// )?; +/// # Some(()) +/// # } +/// ``` +pub fn serialize_component_from_entity( + entity: Entity, + component_type: &str, + world: &World, + type_registry: &TypeRegistry, +) -> Option> { + // Get the type registration + let registration = type_registry.get_with_type_path(component_type)?; + + // Get the ReflectComponent data + let reflect_component = registration.data::()?; + + // Reflect the component from the entity + let reflected = reflect_component.reflect(world.entity(entity))?; + + // Serialize it directly + serialize_component(reflected, type_registry).ok() +} + +/// Serialize all components from an entity that have reflection data +/// +/// This iterates over all components on an entity and serializes those that: +/// - Are registered in the type registry +/// - Have `ReflectComponent` data (meaning they support reflection) +/// - Are not the `Persisted` marker component (to avoid redundant storage) +/// +/// # Parameters +/// - `entity`: Bevy entity to serialize components from +/// - `world`: Bevy world containing the entity +/// - `type_registry`: Bevy's type registry for reflection metadata +/// +/// # Returns +/// Vector of tuples containing (component_type_path, serialized_data) for each component +pub fn serialize_all_components_from_entity( + entity: Entity, + world: &World, + type_registry: &TypeRegistry, +) -> Vec<(String, Vec)> { + let mut components = Vec::new(); + + // Get the entity reference + let entity_ref = world.entity(entity); + + // Iterate over all type registrations + for registration in type_registry.iter() { + // Skip if no ReflectComponent data (not a component) + let Some(reflect_component) = registration.data::() else { + continue; + }; + + // Get the type path for this component + let type_path = registration.type_info().type_path(); + + // Skip the Persisted marker component itself (we don't need to persist it) + if type_path.ends_with("::Persisted") { + continue; + } + + // Try to reflect this component from the entity + if let Some(reflected) = reflect_component.reflect(entity_ref) { + // Serialize the component + if let Ok(data) = serialize_component(reflected, type_registry) { + components.push((type_path.to_string(), data)); + } + } + } + + components +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Component, Reflect, Default)] + #[reflect(Component)] + struct TestComponent { + value: i32, + } + + #[test] + fn test_component_serialization() -> Result<()> { + let mut registry = TypeRegistry::default(); + registry.register::(); + + let component = TestComponent { value: 42 }; + let bytes = serialize_component(&component, ®istry)?; + + assert!(!bytes.is_empty()); + + Ok(()) + } +} diff --git a/crates/lib/src/persistence/systems.rs b/crates/lib/src/persistence/systems.rs new file mode 100644 index 0000000..4c652a4 --- /dev/null +++ b/crates/lib/src/persistence/systems.rs @@ -0,0 +1,459 @@ +//! Bevy systems for the persistence layer +//! +//! This module provides systems that integrate the persistence layer with Bevy's ECS. +//! These systems handle dirty tracking, write buffering, and flushing to SQLite. + +use crate::persistence::*; +use crate::persistence::error::Result; +use bevy::prelude::*; +use bevy::tasks::{IoTaskPool, Task}; +use futures_lite::future; +use rusqlite::Connection; +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +/// Resource wrapping the SQLite connection +#[derive(Clone, bevy::prelude::Resource)] +pub struct PersistenceDb { + pub conn: Arc>, +} + +impl PersistenceDb { + pub fn new(conn: Connection) -> Self { + Self { + conn: Arc::new(Mutex::new(conn)), + } + } + + pub fn from_path(path: impl AsRef) -> Result { + let conn = initialize_persistence_db(path)?; + Ok(Self::new(conn)) + } + + pub fn in_memory() -> Result { + let conn = Connection::open_in_memory()?; + configure_sqlite_for_persistence(&conn)?; + create_persistence_schema(&conn)?; + Ok(Self::new(conn)) + } + + /// Acquire the database connection with proper error handling + /// + /// Handles mutex poisoning gracefully by converting to PersistenceError. + /// If a thread panics while holding the mutex, subsequent lock attempts + /// will fail with a poisoned error, which this method converts to a + /// recoverable error instead of panicking. + /// + /// # Returns + /// - `Ok(MutexGuard)`: Locked connection ready for use + /// - `Err(PersistenceError)`: If mutex is poisoned + pub fn lock(&self) -> Result> { + self.conn.lock() + .map_err(|e| PersistenceError::Other(format!("Database connection mutex poisoned: {}", e))) + } +} + +/// Resource for tracking when the last checkpoint occurred +#[derive(Debug, bevy::prelude::Resource)] +pub struct CheckpointTimer { + pub last_checkpoint: Instant, +} + +impl Default for CheckpointTimer { + fn default() -> Self { + Self { + last_checkpoint: Instant::now(), + } + } +} + +/// Resource for tracking pending async flush tasks +#[derive(Default, bevy::prelude::Resource)] +pub struct PendingFlushTasks { + pub tasks: Vec>>, +} + +/// Result of an async flush operation +#[derive(Debug, Clone)] +pub struct FlushResult { + pub operations_count: usize, + pub duration: std::time::Duration, + pub bytes_written: u64, +} + +/// Helper function to calculate total bytes written from operations +fn calculate_bytes_written(ops: &[PersistenceOp]) -> u64 { + ops.iter() + .map(|op| match op { + PersistenceOp::UpsertComponent { data, .. } => data.len() as u64, + PersistenceOp::LogOperation { operation, .. } => operation.len() as u64, + _ => 0, + }) + .sum() +} + +/// Helper function to perform a flush with metrics tracking (synchronous) +/// +/// Used for critical operations like shutdown where we need to block +fn perform_flush_sync( + ops: &[PersistenceOp], + db: &PersistenceDb, + metrics: &mut PersistenceMetrics, +) -> Result<()> { + if ops.is_empty() { + return Ok(()); + } + + let start = Instant::now(); + let count = { + let mut conn = db.lock()?; + flush_to_sqlite(ops, &mut conn)? + }; + let duration = start.elapsed(); + + let bytes_written = calculate_bytes_written(ops); + metrics.record_flush(count, duration, bytes_written); + + Ok(()) +} + +/// Helper function to perform a flush asynchronously (for normal operations) +/// +/// This runs on the I/O task pool to avoid blocking the main thread +fn perform_flush_async( + ops: Vec, + db: PersistenceDb, +) -> Result { + if ops.is_empty() { + return Ok(FlushResult { + operations_count: 0, + duration: std::time::Duration::ZERO, + bytes_written: 0, + }); + } + + let bytes_written = calculate_bytes_written(&ops); + let start = Instant::now(); + + let count = { + let mut conn = db.lock()?; + flush_to_sqlite(&ops, &mut conn)? + }; + + let duration = start.elapsed(); + + Ok(FlushResult { + operations_count: count, + duration, + bytes_written, + }) +} + +/// System to flush the write buffer to SQLite asynchronously +/// +/// This system runs on a schedule based on the configuration and battery status. +/// It spawns async tasks to avoid blocking the main thread and handles errors gracefully. +/// +/// The system also polls pending flush tasks and updates metrics when they complete. +pub fn flush_system( + mut write_buffer: ResMut, + db: Res, + config: Res, + battery: Res, + mut metrics: ResMut, + mut pending_tasks: ResMut, + mut health: ResMut, + mut failure_events: MessageWriter, + mut recovery_events: MessageWriter, +) { + // First, poll and handle completed async flush tasks + pending_tasks.tasks.retain_mut(|task| { + if let Some(result) = future::block_on(future::poll_once(task)) { + match result { + Ok(flush_result) => { + let previous_failures = health.consecutive_flush_failures; + health.record_flush_success(); + + // Update metrics + metrics.record_flush( + flush_result.operations_count, + flush_result.duration, + flush_result.bytes_written, + ); + + // Emit recovery event if we recovered from failures + if previous_failures > 0 { + recovery_events.write(PersistenceRecoveryEvent { + previous_failures, + }); + } + } + Err(e) => { + health.record_flush_failure(); + + let error_msg = format!("{}", e); + error!( + "Async flush failed (attempt {}/{}): {}", + health.consecutive_flush_failures, + PersistenceHealth::CIRCUIT_BREAKER_THRESHOLD, + error_msg + ); + + // Emit failure event + failure_events.write(PersistenceFailureEvent { + error: error_msg, + consecutive_failures: health.consecutive_flush_failures, + circuit_breaker_open: health.circuit_breaker_open, + }); + } + } + false // Remove completed task + } else { + true // Keep pending task + } + }); + + // Check circuit breaker before spawning new flush + if !health.should_attempt_operation() { + return; + } + + let flush_interval = config.get_flush_interval(battery.level, battery.is_charging); + + // Check if we should flush + if !write_buffer.should_flush(flush_interval) { + return; + } + + // Take operations from buffer + let ops = write_buffer.take_operations(); + if ops.is_empty() { + return; + } + + // Spawn async flush task on I/O thread pool + let task_pool = IoTaskPool::get(); + let db_clone = db.clone(); + + let task = task_pool.spawn(async move { + perform_flush_async(ops, db_clone.clone()) + }); + + pending_tasks.tasks.push(task); + + // Update last flush time + write_buffer.last_flush = Instant::now(); +} + +/// System to checkpoint the WAL file +/// +/// This runs less frequently than flush_system to merge the WAL into the main database. +pub fn checkpoint_system( + db: &PersistenceDb, + config: &PersistenceConfig, + timer: &mut CheckpointTimer, + metrics: &mut PersistenceMetrics, +) -> Result<()> { + let checkpoint_interval = config.get_checkpoint_interval(); + + // Check if it's time to checkpoint + if timer.last_checkpoint.elapsed() < checkpoint_interval { + // Also check WAL size + let wal_size = { + let conn = db.lock()?; + get_wal_size(&conn)? + }; + + metrics.update_wal_size(wal_size as u64); + + // Force checkpoint if WAL is too large + if wal_size < config.max_wal_size_bytes as i64 { + return Ok(()); + } + } + + // Perform checkpoint + let start = Instant::now(); + let info = { + let mut conn = db.lock()?; + checkpoint_wal(&mut conn, CheckpointMode::Passive)? + }; + let duration = start.elapsed(); + + // Update metrics + metrics.record_checkpoint(duration); + timer.last_checkpoint = Instant::now(); + + // Log if checkpoint was busy + if info.busy { + tracing::warn!("WAL checkpoint was busy - some pages may not have been checkpointed"); + } + + Ok(()) +} + +/// System to handle application shutdown +/// +/// This ensures a final flush and checkpoint before the application exits. +/// Uses synchronous flush to ensure all data is written before exit. +/// +/// **CRITICAL**: Waits for all pending async flush tasks to complete before +/// proceeding with shutdown. This prevents data loss from in-flight operations. +pub fn shutdown_system( + write_buffer: &mut WriteBuffer, + db: &PersistenceDb, + metrics: &mut PersistenceMetrics, + pending_tasks: Option<&mut PendingFlushTasks>, +) -> Result<()> { + // CRITICAL: Wait for all pending async flushes to complete + // This prevents data loss from in-flight operations + if let Some(pending) = pending_tasks { + info!("Waiting for {} pending flush tasks to complete before shutdown", pending.tasks.len()); + + for task in pending.tasks.drain(..) { + // Block on each pending task to ensure completion + match future::block_on(task) { + Ok(flush_result) => { + // Update metrics for completed flush + metrics.record_flush( + flush_result.operations_count, + flush_result.duration, + flush_result.bytes_written, + ); + debug!("Pending flush completed: {} operations", flush_result.operations_count); + } + Err(e) => { + error!("Pending flush failed during shutdown: {}", e); + // Continue with shutdown even if a task failed + } + } + } + + info!("All pending flush tasks completed"); + } + + // Force flush any remaining operations (synchronous for shutdown) + let ops = write_buffer.take_operations(); + perform_flush_sync(&ops, db, metrics)?; + + // Checkpoint the WAL + let start = Instant::now(); + { + let mut conn = db.lock()?; + checkpoint_wal(&mut conn, CheckpointMode::Truncate)?; + + // Mark clean shutdown + mark_clean_shutdown(&mut conn)?; + } + let duration = start.elapsed(); + metrics.record_checkpoint(duration); + metrics.record_clean_shutdown(); + + Ok(()) +} + +/// System to initialize persistence on startup +/// +/// This checks for crash recovery and sets up the session. +pub fn startup_system(db: &PersistenceDb, metrics: &mut PersistenceMetrics) -> Result<()> { + let mut conn = db.lock()?; + + // Check if previous session shut down cleanly + let clean_shutdown = check_clean_shutdown(&mut conn)?; + + if !clean_shutdown { + tracing::warn!("Previous session did not shut down cleanly - crash detected"); + metrics.record_crash_recovery(); + + // Perform any necessary recovery operations here + // For now, SQLite's WAL mode handles recovery automatically + } else { + tracing::info!("Previous session shut down cleanly"); + } + + // Set up new session + let session = SessionState::new(); + set_session_state(&mut conn, "session_id", &session.session_id)?; + + Ok(()) +} + +/// Helper function to force an immediate flush (for critical operations) +/// +/// Uses synchronous flush to ensure data is written immediately. +/// Suitable for critical operations like iOS background events. +pub fn force_flush( + write_buffer: &mut WriteBuffer, + db: &PersistenceDb, + metrics: &mut PersistenceMetrics, +) -> Result<()> { + let ops = write_buffer.take_operations(); + perform_flush_sync(&ops, db, metrics)?; + write_buffer.last_flush = Instant::now(); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_persistence_db_in_memory() -> Result<()> { + let db = PersistenceDb::in_memory()?; + + // Verify we can write and read + let entity_id = uuid::Uuid::new_v4(); + let ops = vec![PersistenceOp::UpsertEntity { + id: entity_id, + data: EntityData { + id: entity_id, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + entity_type: "TestEntity".to_string(), + }, + }]; + + let mut conn = db.lock()?; + flush_to_sqlite(&ops, &mut conn)?; + + Ok(()) + } + + #[test] + fn test_flush_system() -> Result<()> { + let db = PersistenceDb::in_memory()?; + let mut write_buffer = WriteBuffer::new(1000); + let mut metrics = PersistenceMetrics::default(); + + // Add some operations + let entity_id = uuid::Uuid::new_v4(); + + // First add the entity + write_buffer.add(PersistenceOp::UpsertEntity { + id: entity_id, + data: EntityData { + id: entity_id, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + entity_type: "TestEntity".to_string(), + }, + }); + + // Then add a component + write_buffer.add(PersistenceOp::UpsertComponent { + entity_id, + component_type: "Transform".to_string(), + data: vec![1, 2, 3], + }); + + // Take operations and flush synchronously (testing the flush logic) + let ops = write_buffer.take_operations(); + perform_flush_sync(&ops, &db, &mut metrics)?; + + assert_eq!(metrics.flush_count, 1); + assert_eq!(write_buffer.len(), 0); + + Ok(()) + } +} diff --git a/crates/lib/src/persistence/types.rs b/crates/lib/src/persistence/types.rs new file mode 100644 index 0000000..dc6022f --- /dev/null +++ b/crates/lib/src/persistence/types.rs @@ -0,0 +1,637 @@ +//! Core types for the persistence layer + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::time::Instant; +use bevy::prelude::Resource; + +/// Maximum size for a single component in bytes (10MB) +/// Components larger than this may indicate serialization issues or unbounded data growth +const MAX_COMPONENT_SIZE_BYTES: usize = 10 * 1024 * 1024; + +/// Critical flush deadline in milliseconds (1 second for tier-1 operations) +const CRITICAL_FLUSH_DEADLINE_MS: u64 = 1000; + +/// Unique identifier for entities that can be synced across nodes +pub type EntityId = uuid::Uuid; + +/// Node identifier for CRDT operations +pub type NodeId = String; + +/// Priority level for persistence operations +/// +/// Determines how quickly an operation should be flushed to disk: +/// - **Normal**: Regular batched flushing (5-60s intervals based on battery) +/// - **Critical**: Flush within 1 second (tier-1 operations like user actions, CRDT ops) +/// - **Immediate**: Flush immediately (shutdown, background suspension) +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum FlushPriority { + /// Normal priority - regular batched flushing + Normal, + /// Critical priority - flush within 1 second + Critical, + /// Immediate priority - flush right now + Immediate, +} + +/// Resource to track entities with uncommitted changes +#[derive(Debug, Default)] +pub struct DirtyEntities { + /// Set of entity IDs with changes not yet in write buffer + pub entities: HashSet, + + /// Map of entity ID to set of dirty component type names + pub components: HashMap>, + + /// Track when each entity was last modified (for prioritization) + pub last_modified: HashMap, +} + +impl DirtyEntities { + pub fn new() -> Self { + Self::default() + } + + /// Mark an entity's component as dirty + pub fn mark_dirty(&mut self, entity_id: EntityId, component_type: impl Into) { + self.entities.insert(entity_id); + self.components + .entry(entity_id) + .or_default() + .insert(component_type.into()); + self.last_modified.insert(entity_id, Instant::now()); + } + + /// Clear all dirty tracking (called after flush to write buffer) + pub fn clear(&mut self) { + self.entities.clear(); + self.components.clear(); + self.last_modified.clear(); + } + + /// Check if an entity is dirty + pub fn is_dirty(&self, entity_id: &EntityId) -> bool { + self.entities.contains(entity_id) + } + + /// Get the number of dirty entities + pub fn count(&self) -> usize { + self.entities.len() + } +} + +/// Operations that can be persisted to the database +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum PersistenceOp { + /// Insert or update an entity's existence + UpsertEntity { + id: EntityId, + data: EntityData, + }, + + /// Insert or update a component on an entity + UpsertComponent { + entity_id: EntityId, + component_type: String, + data: Vec, + }, + + /// Log an operation for CRDT sync + LogOperation { + node_id: NodeId, + sequence: u64, + operation: Vec, + }, + + /// Update vector clock for causality tracking + UpdateVectorClock { + node_id: NodeId, + counter: u64, + }, + + /// Delete an entity + DeleteEntity { + id: EntityId, + }, + + /// Delete a component from an entity + DeleteComponent { + entity_id: EntityId, + component_type: String, + }, +} + +impl PersistenceOp { + /// Get the default priority for this operation type + /// + /// CRDT operations (LogOperation, UpdateVectorClock) are critical tier-1 operations + /// that should be flushed within 1 second to maintain causality across nodes. + /// Other operations use normal priority by default. + pub fn default_priority(&self) -> FlushPriority { + match self { + // CRDT operations are tier-1 (critical) + PersistenceOp::LogOperation { .. } | PersistenceOp::UpdateVectorClock { .. } => { + FlushPriority::Critical + } + // All other operations are normal priority by default + _ => FlushPriority::Normal, + } + } +} + +/// Metadata about an entity +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EntityData { + pub id: EntityId, + pub created_at: DateTime, + pub updated_at: DateTime, + pub entity_type: String, +} + +/// Write buffer for batching persistence operations +#[derive(Debug)] +pub struct WriteBuffer { + /// Pending operations not yet committed to SQLite + pub pending_operations: Vec, + + /// When the buffer was last flushed + pub last_flush: Instant, + + /// Maximum number of operations before forcing a flush + pub max_operations: usize, + + /// Highest priority operation currently in the buffer + pub highest_priority: FlushPriority, + + /// When the first critical operation was added (for deadline tracking) + pub first_critical_time: Option, +} + +impl WriteBuffer { + pub fn new(max_operations: usize) -> Self { + Self { + pending_operations: Vec::new(), + last_flush: Instant::now(), + max_operations, + highest_priority: FlushPriority::Normal, + first_critical_time: None, + } + } + + /// Add an operation to the write buffer with normal priority + /// + /// This is a convenience method that calls `add_with_priority` with `FlushPriority::Normal`. + /// + /// # Panics + /// Panics if component data exceeds MAX_COMPONENT_SIZE_BYTES (10MB) + pub fn add(&mut self, op: PersistenceOp) { + self.add_with_priority(op, FlushPriority::Normal); + } + + /// Add an operation using its default priority + /// + /// Uses `PersistenceOp::default_priority()` to determine priority automatically. + /// CRDT operations will be added as Critical, others as Normal. + /// + /// # Panics + /// Panics if component data exceeds MAX_COMPONENT_SIZE_BYTES (10MB) + pub fn add_with_default_priority(&mut self, op: PersistenceOp) { + let priority = op.default_priority(); + self.add_with_priority(op, priority); + } + + /// Add an operation to the write buffer with the specified priority + /// + /// If an operation for the same entity+component already exists, + /// it will be replaced (keeping only the latest state). The priority + /// is tracked separately to determine flush urgency. + /// + /// # Panics + /// Panics if component data exceeds MAX_COMPONENT_SIZE_BYTES (10MB) + pub fn add_with_priority(&mut self, op: PersistenceOp, priority: FlushPriority) { + // Validate component size to prevent unbounded memory growth + match &op { + PersistenceOp::UpsertComponent { data, component_type, .. } => { + if data.len() > MAX_COMPONENT_SIZE_BYTES { + panic!( + "Component {} size ({} bytes) exceeds maximum ({} bytes). \ + This may indicate unbounded data growth or serialization issues.", + component_type, + data.len(), + MAX_COMPONENT_SIZE_BYTES + ); + } + } + PersistenceOp::LogOperation { operation, .. } => { + if operation.len() > MAX_COMPONENT_SIZE_BYTES { + panic!( + "Operation size ({} bytes) exceeds maximum ({} bytes)", + operation.len(), + MAX_COMPONENT_SIZE_BYTES + ); + } + } + _ => {} + } + + match &op { + PersistenceOp::UpsertComponent { entity_id, component_type, .. } => { + // Remove any existing pending write for this entity+component + self.pending_operations.retain(|existing_op| { + !matches!(existing_op, + PersistenceOp::UpsertComponent { + entity_id: e_id, + component_type: c_type, + .. + } if e_id == entity_id && c_type == component_type + ) + }); + } + PersistenceOp::UpsertEntity { id, .. } => { + // Remove any existing pending write for this entity + self.pending_operations.retain(|existing_op| { + !matches!(existing_op, + PersistenceOp::UpsertEntity { id: e_id, .. } + if e_id == id + ) + }); + } + _ => { + // Other operations don't need coalescing + } + } + + // Track priority for flush urgency + if priority > self.highest_priority { + self.highest_priority = priority; + } + + // Track when first critical operation was added (for deadline enforcement) + if priority >= FlushPriority::Critical && self.first_critical_time.is_none() { + self.first_critical_time = Some(Instant::now()); + } + + self.pending_operations.push(op); + } + + /// Take all pending operations and return them for flushing + /// + /// This resets the priority tracking state. + pub fn take_operations(&mut self) -> Vec { + // Reset priority tracking when operations are taken + self.highest_priority = FlushPriority::Normal; + self.first_critical_time = None; + std::mem::take(&mut self.pending_operations) + } + + /// Check if buffer should be flushed + /// + /// Returns true if any of these conditions are met: + /// - Buffer is at capacity (max_operations reached) + /// - Regular flush interval has elapsed (for normal priority) + /// - Critical operation deadline exceeded (1 second for critical ops) + /// - Immediate priority operation exists + pub fn should_flush(&self, flush_interval: std::time::Duration) -> bool { + // Immediate priority always flushes + if self.highest_priority == FlushPriority::Immediate { + return true; + } + + // Critical priority flushes after 1 second deadline + if self.highest_priority == FlushPriority::Critical { + if let Some(critical_time) = self.first_critical_time { + if critical_time.elapsed().as_millis() >= CRITICAL_FLUSH_DEADLINE_MS as u128 { + return true; + } + } + } + + // Normal flushing conditions + self.pending_operations.len() >= self.max_operations + || self.last_flush.elapsed() >= flush_interval + } + + /// Get the number of pending operations + pub fn len(&self) -> usize { + self.pending_operations.len() + } + + /// Check if the buffer is empty + pub fn is_empty(&self) -> bool { + self.pending_operations.is_empty() + } +} + +/// Battery status for adaptive flushing +#[derive(Debug, Clone, Copy, Resource)] +pub struct BatteryStatus { + /// Battery level from 0.0 to 1.0 + pub level: f32, + + /// Whether the device is currently charging + pub is_charging: bool, + + /// Whether low power mode is enabled (iOS) + pub is_low_power_mode: bool, +} + +impl Default for BatteryStatus { + fn default() -> Self { + Self { + level: 1.0, + is_charging: false, + is_low_power_mode: false, + } + } +} + +impl BatteryStatus { + /// Update battery status from iOS UIDevice.batteryLevel + /// + /// # iOS Integration Example + /// + /// ```swift + /// // In your iOS app code: + /// UIDevice.current.isBatteryMonitoringEnabled = true + /// let batteryLevel = UIDevice.current.batteryLevel // Returns 0.0 to 1.0 + /// let isCharging = UIDevice.current.batteryState == .charging || + /// UIDevice.current.batteryState == .full + /// let isLowPowerMode = ProcessInfo.processInfo.isLowPowerModeEnabled + /// + /// // Update Bevy resource (this is pseudocode - actual implementation depends on your bridge) + /// battery_status.update_from_ios(batteryLevel, isCharging, isLowPowerMode); + /// ``` + pub fn update_from_ios(&mut self, level: f32, is_charging: bool, is_low_power_mode: bool) { + self.level = level.clamp(0.0, 1.0); + self.is_charging = is_charging; + self.is_low_power_mode = is_low_power_mode; + } + + /// Check if the device is in a battery-critical state + /// + /// Returns true if battery is low (<20%) and not charging, or low power mode is enabled. + pub fn is_battery_critical(&self) -> bool { + (self.level < 0.2 && !self.is_charging) || self.is_low_power_mode + } +} + +/// Session state tracking for crash detection +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionState { + pub session_id: String, + pub started_at: DateTime, + pub clean_shutdown: bool, +} + +impl SessionState { + pub fn new() -> Self { + Self { + session_id: uuid::Uuid::new_v4().to_string(), + started_at: Utc::now(), + clean_shutdown: false, + } + } +} + +impl Default for SessionState { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_dirty_entities_tracking() { + let mut dirty = DirtyEntities::new(); + let entity_id = EntityId::new_v4(); + + dirty.mark_dirty(entity_id, "Transform"); + assert!(dirty.is_dirty(&entity_id)); + assert_eq!(dirty.count(), 1); + + dirty.clear(); + assert!(!dirty.is_dirty(&entity_id)); + assert_eq!(dirty.count(), 0); + } + + #[test] + fn test_write_buffer_coalescing() { + let mut buffer = WriteBuffer::new(100); + let entity_id = EntityId::new_v4(); + + // Add first version + buffer.add(PersistenceOp::UpsertComponent { + entity_id, + component_type: "Transform".to_string(), + data: vec![1, 2, 3], + }); + assert_eq!(buffer.len(), 1); + + // Add second version (should replace first) + buffer.add(PersistenceOp::UpsertComponent { + entity_id, + component_type: "Transform".to_string(), + data: vec![4, 5, 6], + }); + assert_eq!(buffer.len(), 1); + + // Verify only latest version exists + let ops = buffer.take_operations(); + assert_eq!(ops.len(), 1); + if let PersistenceOp::UpsertComponent { data, .. } = &ops[0] { + assert_eq!(data, &vec![4, 5, 6]); + } else { + panic!("Expected UpsertComponent"); + } + } + + #[test] + fn test_write_buffer_different_components() { + let mut buffer = WriteBuffer::new(100); + let entity_id = EntityId::new_v4(); + + // Add Transform + buffer.add(PersistenceOp::UpsertComponent { + entity_id, + component_type: "Transform".to_string(), + data: vec![1, 2, 3], + }); + + // Add Velocity (different component, should not coalesce) + buffer.add(PersistenceOp::UpsertComponent { + entity_id, + component_type: "Velocity".to_string(), + data: vec![4, 5, 6], + }); + + assert_eq!(buffer.len(), 2); + } + + #[test] + fn test_flush_priority_immediate() { + let mut buffer = WriteBuffer::new(100); + let entity_id = EntityId::new_v4(); + + // Add operation with immediate priority + buffer.add_with_priority( + PersistenceOp::UpsertEntity { + id: entity_id, + data: EntityData { + id: entity_id, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + entity_type: "TestEntity".to_string(), + }, + }, + FlushPriority::Immediate, + ); + + // Should flush immediately regardless of interval + assert!(buffer.should_flush(std::time::Duration::from_secs(100))); + assert_eq!(buffer.highest_priority, FlushPriority::Immediate); + } + + #[test] + fn test_flush_priority_critical_deadline() { + let mut buffer = WriteBuffer::new(100); + let entity_id = EntityId::new_v4(); + + // Add operation with critical priority + buffer.add_with_priority( + PersistenceOp::UpsertEntity { + id: entity_id, + data: EntityData { + id: entity_id, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + entity_type: "TestEntity".to_string(), + }, + }, + FlushPriority::Critical, + ); + + assert_eq!(buffer.highest_priority, FlushPriority::Critical); + assert!(buffer.first_critical_time.is_some()); + + // Should not flush immediately + assert!(!buffer.should_flush(std::time::Duration::from_secs(100))); + + // Simulate deadline passing by manually setting the time + buffer.first_critical_time = + Some(Instant::now() - std::time::Duration::from_millis(CRITICAL_FLUSH_DEADLINE_MS + 100)); + + // Now should flush due to deadline + assert!(buffer.should_flush(std::time::Duration::from_secs(100))); + } + + #[test] + fn test_flush_priority_normal() { + let mut buffer = WriteBuffer::new(100); + let entity_id = EntityId::new_v4(); + + // Add normal priority operation + buffer.add(PersistenceOp::UpsertEntity { + id: entity_id, + data: EntityData { + id: entity_id, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + entity_type: "TestEntity".to_string(), + }, + }); + + assert_eq!(buffer.highest_priority, FlushPriority::Normal); + assert!(buffer.first_critical_time.is_none()); + + // Should not flush before interval + assert!(!buffer.should_flush(std::time::Duration::from_secs(100))); + + // Set last flush to past + buffer.last_flush = Instant::now() - std::time::Duration::from_secs(200); + + // Now should flush + assert!(buffer.should_flush(std::time::Duration::from_secs(100))); + } + + #[test] + fn test_priority_reset_on_take() { + let mut buffer = WriteBuffer::new(100); + let entity_id = EntityId::new_v4(); + + // Add critical operation + buffer.add_with_priority( + PersistenceOp::UpsertEntity { + id: entity_id, + data: EntityData { + id: entity_id, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + entity_type: "TestEntity".to_string(), + }, + }, + FlushPriority::Critical, + ); + + assert_eq!(buffer.highest_priority, FlushPriority::Critical); + assert!(buffer.first_critical_time.is_some()); + + // Take operations + let ops = buffer.take_operations(); + assert_eq!(ops.len(), 1); + + // Priority should be reset + assert_eq!(buffer.highest_priority, FlushPriority::Normal); + assert!(buffer.first_critical_time.is_none()); + } + + #[test] + fn test_default_priority_for_crdt_ops() { + let log_op = PersistenceOp::LogOperation { + node_id: "node1".to_string(), + sequence: 1, + operation: vec![1, 2, 3], + }; + + let vector_clock_op = PersistenceOp::UpdateVectorClock { + node_id: "node1".to_string(), + counter: 42, + }; + + let entity_op = PersistenceOp::UpsertEntity { + id: EntityId::new_v4(), + data: EntityData { + id: EntityId::new_v4(), + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + entity_type: "TestEntity".to_string(), + }, + }; + + // CRDT operations should have Critical priority + assert_eq!(log_op.default_priority(), FlushPriority::Critical); + assert_eq!(vector_clock_op.default_priority(), FlushPriority::Critical); + + // Other operations should have Normal priority + assert_eq!(entity_op.default_priority(), FlushPriority::Normal); + } + + #[test] + fn test_add_with_default_priority() { + let mut buffer = WriteBuffer::new(100); + + // Add CRDT operation using default priority + buffer.add_with_default_priority(PersistenceOp::LogOperation { + node_id: "node1".to_string(), + sequence: 1, + operation: vec![1, 2, 3], + }); + + // Should be tracked as Critical + assert_eq!(buffer.highest_priority, FlushPriority::Critical); + assert!(buffer.first_critical_time.is_some()); + } +} diff --git a/crates/lib/src/sync.rs b/crates/lib/src/sync.rs index ae76b9a..f2ad43c 100644 --- a/crates/lib/src/sync.rs +++ b/crates/lib/src/sync.rs @@ -54,7 +54,14 @@ impl SyncedValue { } pub fn merge(&mut self, other: &Self) { - self.apply_lww(other.value.clone(), other.timestamp, other.node_id.clone()); + // Only clone if we're actually going to use the values (when other is newer) + if other.timestamp > self.timestamp + || (other.timestamp == self.timestamp && other.node_id > self.node_id) + { + self.value = other.value.clone(); + self.timestamp = other.timestamp; + self.node_id = other.node_id.clone(); + } } } diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..4043bce --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,24 @@ +binop_separator = "Back" +brace_style = "PreferSameLine" +control_brace_style = "AlwaysSameLine" +comment_width = 80 +edition = "2021" +enum_discrim_align_threshold = 40 +fn_params_layout = "Tall" +fn_single_line = false +force_explicit_abi = true +force_multiline_blocks = false +format_code_in_doc_comments = true +format_macro_matchers = true +format_macro_bodies = true +hex_literal_case = "Lower" +imports_indent = "Block" +imports_layout = "Vertical" +match_arm_leading_pipes = "Always" +match_block_trailing_comma = true +imports_granularity = "Crate" +normalize_doc_attributes = true +reorder_impl_items = true +reorder_imports = true +group_imports = "StdExternalCrate" +wrap_comments = true \ No newline at end of file