diff --git a/Cargo.lock b/Cargo.lock index 2bc8f33..5aea1c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,21 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "Inflector" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" + +[[package]] +name = "addr" +version = "0.15.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a93b8a41dbe230ad5087cc721f8d41611de654542180586b315d9f4cf6b72bef" +dependencies = [ + "psl-types", +] + [[package]] name = "adler2" version = "2.0.1" @@ -15,7 +30,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ "crypto-common", - "generic-array", + "generic-array 0.14.7", ] [[package]] @@ -44,6 +59,29 @@ dependencies = [ "subtle", ] +[[package]] +name = "affinitypool" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dde2a385b82232b559baeec740c37809051c596f9b56e7da0d0da2c8e8f54f6" +dependencies = [ + "async-channel 2.5.0", + "num_cpus", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + [[package]] name = "ahash" version = "0.8.12" @@ -52,6 +90,7 @@ checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", "const-random", + "getrandom 0.3.4", "once_cell", "version_check", "zerocopy", @@ -72,7 +111,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" dependencies = [ - "as-slice", + "as-slice 0.2.1", ] [[package]] @@ -84,6 +123,34 @@ dependencies = [ "equator", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "ammonia" +version = "4.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6" +dependencies = [ + "cssparser", + "html5ever", + "maplit", + "tendril", + "url", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "1.0.0" @@ -134,12 +201,36 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "any_ascii" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90c6333e01ba7235575b6ab53e5af10f1c327927fd97c36462917e289557ea64" + [[package]] name = "anyhow" version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "approx" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f2a05fd1bd10b2527e20a2cd32d8873d115b8b39fe219ee25f42a8aca6ba278" +dependencies = [ + "num-traits", +] + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "ar_archive_writer" version = "0.5.1" @@ -196,6 +287,18 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" +[[package]] +name = "as-slice" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45403b49e3954a4b8428a0ac21a4b7afadccf92bfd96273f1a58cd4812496ae0" +dependencies = [ + "generic-array 0.12.4", + "generic-array 0.13.3", + "generic-array 0.14.7", + "stable_deref_trait", +] + [[package]] name = "as-slice" version = "0.2.1" @@ -205,6 +308,15 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "ascii-canvas" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8824ecca2e851cec16968d54a01dd372ef8f95b244fb84b84e70128be347c3c6" +dependencies = [ + "term", +] + [[package]] name = "ash" version = "0.38.0+1.3.281" @@ -390,6 +502,77 @@ dependencies = [ "once_cell", ] +[[package]] +name = "async-graphql" +version = "7.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1057a9f7ccf2404d94571dec3451ade1cb524790df6f1ada0d19c2a49f6b0f40" +dependencies = [ + "async-graphql-derive", + "async-graphql-parser", + "async-graphql-value", + "async-io", + "async-trait", + "asynk-strim", + "base64 0.22.1", + "bytes", + "fnv", + "futures-util", + "http", + "indexmap 2.14.0", + "mime", + "multer", + "num-traits", + "pin-project-lite", + "regex", + "serde", + "serde_json", + "serde_urlencoded", + "static_assertions_next", + "thiserror 2.0.18", +] + +[[package]] +name = "async-graphql-derive" +version = "7.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e6cbeadc8515e66450fba0985ce722192e28443697799988265d86304d7cc68" +dependencies = [ + "Inflector", + "async-graphql-parser", + "darling", + "proc-macro-crate", + "proc-macro2", + "quote", + "strum 0.27.2", + "syn 2.0.117", + "thiserror 2.0.18", +] + +[[package]] +name = "async-graphql-parser" +version = "7.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64ef70f77a1c689111e52076da1cd18f91834bcb847de0a9171f83624b07fbf" +dependencies = [ + "async-graphql-value", + "pest", + "serde", + "serde_json", +] + +[[package]] +name = "async-graphql-value" +version = "7.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e3ef112905abea9dea592fc868a6873b10ebd3f983e83308f995d6284e9ba41" +dependencies = [ + "bytes", + "indexmap 2.14.0", + "serde", + "serde_json", +] + [[package]] name = "async-io" version = "2.6.0" @@ -521,6 +704,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "async_io_stream" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" +dependencies = [ + "futures", + "pharos", + "rustc_version", +] + [[package]] name = "async_zip" version = "0.0.17" @@ -547,6 +741,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "asynk-strim" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52697735bdaac441a29391a9e97102c74c6ef0f9b60a40cf109b1b404e29d2f6" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "atomic" version = "0.5.3" @@ -574,7 +778,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" dependencies = [ - "base64", + "base64 0.22.1", "http", "log", "url", @@ -645,6 +849,12 @@ dependencies = [ "match-lookup", ] +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -657,6 +867,19 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bcrypt" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e65938ed058ef47d92cf8b346cc76ef48984572ade631927e9937b5ffc7662c7" +dependencies = [ + "base64 0.22.1", + "blowfish", + "getrandom 0.2.17", + "subtle", + "zeroize", +] + [[package]] name = "bincode" version = "1.3.3" @@ -686,15 +909,30 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec 0.6.3", +] + [[package]] name = "bit-set" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", ] +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bit-vec" version = "0.8.0" @@ -728,6 +966,18 @@ dependencies = [ "no_std_io2", ] +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "blade-graphics" version = "0.7.1" @@ -821,7 +1071,7 @@ version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "generic-array", + "generic-array 0.14.7", ] [[package]] @@ -830,7 +1080,7 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" dependencies = [ - "generic-array", + "generic-array 0.14.7", ] [[package]] @@ -855,6 +1105,40 @@ dependencies = [ "piper", ] +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + +[[package]] +name = "borsh" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "bs58" version = "0.5.1" @@ -886,6 +1170,28 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "bytemuck" version = "1.25.0" @@ -923,6 +1229,9 @@ name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] [[package]] name = "calloop" @@ -950,6 +1259,15 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cbc" version = "0.1.2" @@ -966,7 +1284,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eadd868a2ce9ca38de7eeafdcec9c7065ef89b42b32f0839278d55f35c54d1ff" dependencies = [ "heck 0.4.1", - "indexmap", + "indexmap 2.14.0", "log", "proc-macro2", "quote", @@ -989,6 +1307,63 @@ dependencies = [ "shlex", ] +[[package]] +name = "cedar-policy" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d91e3b10a0f7f2911774d5e49713c4d25753466f9e11d1cd2ec627f8a2dc857" +dependencies = [ + "cedar-policy-core", + "cedar-policy-validator", + "itertools 0.10.5", + "lalrpop-util", + "ref-cast", + "serde", + "serde_json", + "smol_str", + "thiserror 1.0.69", +] + +[[package]] +name = "cedar-policy-core" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd2315591c6b7e18f8038f0a0529f254235fd902b6c217aabc04f2459b0d9995" +dependencies = [ + "either", + "ipnet", + "itertools 0.10.5", + "lalrpop", + "lalrpop-util", + "lazy_static", + "miette", + "regex", + "rustc_lexer", + "serde", + "serde_json", + "serde_with", + "smol_str", + "stacker", + "thiserror 1.0.69", +] + +[[package]] +name = "cedar-policy-validator" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e756e1b2a5da742ed97e65199ad6d0893e9aa4bd6b34be1de9e70bd1e6adc7df" +dependencies = [ + "cedar-policy-core", + "itertools 0.10.5", + "serde", + "serde_json", + "serde_with", + "smol_str", + "stacker", + "thiserror 1.0.69", + "unicode-security", +] + [[package]] name = "cexpr" version = "0.6.0" @@ -1043,6 +1418,47 @@ dependencies = [ "zeroize", ] +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link 0.2.1", +] + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "cipher" version = "0.4.4" @@ -1182,7 +1598,7 @@ checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" dependencies = [ "serde", "termcolor", - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -1524,11 +1940,34 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ - "generic-array", + "generic-array 0.14.7", "rand_core 0.6.4", "typenum", ] +[[package]] +name = "cssparser" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "ctor" version = "0.4.3" @@ -1581,6 +2020,53 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core 0.9.12", +] + [[package]] name = "data-encoding" version = "2.11.0" @@ -1650,6 +2136,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", + "serde_core", ] [[package]] @@ -1665,6 +2152,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + [[package]] name = "digest" version = "0.10.7" @@ -1694,6 +2187,16 @@ dependencies = [ "dirs-sys 0.4.1", ] +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + [[package]] name = "dirs-sys" version = "0.3.7" @@ -1717,6 +2220,17 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -1753,6 +2267,22 @@ dependencies = [ "libloading", ] +[[package]] +name = "dmp" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb2dfc7a18dffd3ef60a442b72a827126f1557d914620f8fc4d1049916da43c1" +dependencies = [ + "trice", + "urlencoding", +] + +[[package]] +name = "double-ended-peekable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0d05e1c0dbad51b52c38bda7adceef61b9efc2baf04acfe8726a8c4630a6f57" + [[package]] name = "downcast-rs" version = "1.2.1" @@ -1765,6 +2295,15 @@ version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + [[package]] name = "dtor" version = "0.0.6" @@ -1804,6 +2343,16 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "earcutr" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79127ed59a85d7687c409e9978547cffb7dc79675355ed22da6b66fd5f6ead01" +dependencies = [ + "itertools 0.11.0", + "num-traits", +] + [[package]] name = "ed25519" version = "2.2.3" @@ -1860,6 +2409,15 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" +[[package]] +name = "ena" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1" +dependencies = [ + "log", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1875,6 +2433,12 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" +[[package]] +name = "endian-type" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" + [[package]] name = "ente-binfmt-compat" version = "0.0.1" @@ -1889,7 +2453,7 @@ name = "ente-brain" version = "0.0.1" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "bincode", "ente-card", "ente-cas", @@ -2333,6 +2897,19 @@ dependencies = [ "zune-inflate", ] +[[package]] +name = "ext-sort" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf5d3b056bcc471d38082b8c453acb6670f7327fd44219b3c411e40834883569" +dependencies = [ + "log", + "rayon", + "rmp-serde", + "serde", + "tempfile", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -2409,6 +2986,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "flate2" version = "1.1.9" @@ -2461,6 +3044,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "font-types" version = "0.11.3" @@ -2573,6 +3162,18 @@ dependencies = [ "libc", ] +[[package]] +name = "fst" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ab85b9b05e3978cc9a9cf8fea7f01b494e1a09ed3037e16ba39edc7a29eb61a" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futf" version = "0.1.5" @@ -2726,6 +3327,15 @@ dependencies = [ "slab", ] +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + [[package]] name = "fxhash" version = "0.2.1" @@ -2735,6 +3345,24 @@ dependencies = [ "byteorder", ] +[[package]] +name = "generic-array" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdf9f34f1447443d37393cc6c2b8313aebddcd96906caf34e54c68d8e57d7bd" +dependencies = [ + "typenum", +] + +[[package]] +name = "generic-array" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f797e67af32588215eaaab8327027ee8e71b9dd0b2b26996aedf20c030fce309" +dependencies = [ + "typenum", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -2745,6 +3373,49 @@ dependencies = [ "version_check", ] +[[package]] +name = "geo" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f811f663912a69249fa620dcd2a005db7254529da2d8a0b23942e81f47084501" +dependencies = [ + "earcutr", + "float_next_after", + "geo-types", + "geographiclib-rs", + "log", + "num-traits", + "robust", + "rstar 0.12.2", + "serde", + "spade", +] + +[[package]] +name = "geo-types" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94776032c45f950d30a13af6113c2ad5625316c9abfbccee4dd5a6695f8fe0f5" +dependencies = [ + "approx 0.5.1", + "num-traits", + "rstar 0.10.0", + "rstar 0.11.0", + "rstar 0.12.2", + "rstar 0.8.4", + "rstar 0.9.3", + "serde", +] + +[[package]] +name = "geographiclib-rs" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5a7f08910fd98737a6eda7568e7c5e645093e073328eeef49758cfe8b0489c7" +dependencies = [ + "libm", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -2953,7 +3624,7 @@ dependencies = [ "rand 0.9.4", "raw-window-handle", "resvg", - "schemars", + "schemars 1.2.1", "seahash", "serde", "serde_json", @@ -3002,7 +3673,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae39dc6d3d201be97e4bc08d96dbef2bc5b5c3d5734e05786e8cc3043342351c" dependencies = [ - "indexmap", + "indexmap 2.14.0", "rustc-hash 2.1.2", ] @@ -3122,7 +3793,7 @@ dependencies = [ "nix 0.29.0", "regex", "rust-embed", - "schemars", + "schemars 1.2.1", "serde", "serde_json", "serde_json_lenient", @@ -3165,7 +3836,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -3184,6 +3855,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hash32" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4041af86e63ac4298ce40e5cca669066e75b6f1aa3390fe2561ffa5e1d9f4cc" +dependencies = [ + "byteorder", +] + [[package]] name = "hash32" version = "0.2.1" @@ -3193,13 +3873,32 @@ dependencies = [ "byteorder", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + [[package]] name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ - "ahash", + "ahash 0.8.12", + "allocator-api2", ] [[package]] @@ -3208,7 +3907,20 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", ] [[package]] @@ -3235,6 +3947,18 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "heapless" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "634bd4d29cbf24424d0a4bfcbf80c6960129dc24424752a7d1d1390607023422" +dependencies = [ + "as-slice 0.1.5", + "generic-array 0.14.7", + "hash32 0.1.1", + "stable_deref_trait", +] + [[package]] name = "heapless" version = "0.7.17" @@ -3242,13 +3966,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" dependencies = [ "atomic-polyfill", - "hash32", + "hash32 0.2.1", "rustc_version", "serde", "spin", "stable_deref_trait", ] +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32 0.3.1", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.4.1" @@ -3364,6 +4098,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "html5ever" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4" +dependencies = [ + "log", + "markup5ever", + "match_token", +] + [[package]] name = "http" version = "1.4.0" @@ -3403,6 +4148,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + [[package]] name = "hyper" version = "1.9.0" @@ -3460,6 +4211,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -3548,6 +4323,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -3669,6 +4450,17 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40fac9d56ed6437b198fddba683305e8e2d651aa42647f00f5ae542e7f5c94a2" +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -3708,7 +4500,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ "block-padding", - "generic-array", + "generic-array 0.14.7", ] [[package]] @@ -3796,6 +4588,24 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -3842,6 +4652,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "khronos-egl" version = "6.0.0" @@ -3892,6 +4717,37 @@ dependencies = [ "log", ] +[[package]] +name = "lalrpop" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cb077ad656299f160924eb2912aa147d7339ea7d69e1b5517326fdcec3c1ca" +dependencies = [ + "ascii-canvas", + "bit-set 0.5.3", + "ena", + "itertools 0.11.0", + "lalrpop-util", + "petgraph", + "pico-args", + "regex", + "regex-syntax", + "string_cache", + "term", + "tiny-keccak", + "unicode-xid", + "walkdir", +] + +[[package]] +name = "lalrpop-util" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507460a910eb7b32ee961886ff48539633b788a36b65692b95f225b844c82553" +dependencies = [ + "regex-automata", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -3928,6 +4784,15 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" +[[package]] +name = "lexicmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7378d131ddf24063b32cbd7e91668d183140c4b3906270635a4d633d1068ea5d" +dependencies = [ + "any_ascii", +] + [[package]] name = "libc" version = "0.2.186" @@ -4337,6 +5202,18 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linfa-linalg" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e7562b41c8876d3367897067013bb2884cc78e6893f092ecd26b305176ac82" +dependencies = [ + "ndarray", + "num-traits", + "rand 0.8.6", + "thiserror 1.0.69", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -4383,6 +5260,15 @@ dependencies = [ "imgref", ] +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -4456,6 +5342,23 @@ dependencies = [ "libc", ] +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + +[[package]] +name = "markup5ever" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + [[package]] name = "match-lookup" version = "0.1.2" @@ -4467,6 +5370,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "match_token" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "matchers" version = "0.2.0" @@ -4476,6 +5390,16 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matrixmultiply" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +dependencies = [ + "autocfg", + "rawpointer", +] + [[package]] name = "maybe-rayon" version = "0.1.1" @@ -4535,6 +5459,29 @@ dependencies = [ "paste", ] +[[package]] +name = "miette" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59bb584eaeeab6bd0226ccf3509a69d7936d148cf3d036ad350abe35e8c6856e" +dependencies = [ + "miette-derive", + "once_cell", + "thiserror 1.0.69", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49e7bc1560b95a3c4a25d03de42fe76ca718ab92d1a22a55b9b4cf67b3ae635c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "mime" version = "0.3.17" @@ -4695,6 +5642,23 @@ dependencies = [ "pxfm", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "multi-stash" version = "0.2.0" @@ -4762,14 +5726,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b977c445f26e49757f9aca3631c3b8b836942cb278d69a92e7b80d3b24da632" dependencies = [ "arrayvec", - "bit-set", + "bit-set 0.8.0", "bitflags 2.11.1", "cfg_aliases", "codespan-reporting", "half", "hashbrown 0.15.5", "hexf-parse", - "indexmap", + "indexmap 2.14.0", "log", "num-traits", "once_cell", @@ -4780,6 +5744,21 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "nakui-core" +version = "0.1.0" +dependencies = [ + "petgraph", + "rhai", + "serde", + "serde_json", + "sha2", + "surrealdb", + "thiserror 1.0.69", + "tokio", + "uuid", +] + [[package]] name = "nanorand" version = "0.7.0" @@ -4789,6 +5768,35 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "ndarray" +version = "0.15.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb12d4e967ec485a5f71c6311fe28158e9d6f4bc4a447b474184d0f91a8fa32" +dependencies = [ + "approx 0.4.0", + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "rawpointer", +] + +[[package]] +name = "ndarray-stats" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af5a8477ac96877b5bd1fd67e0c28736c12943aba24eda92b127e036b0c8f400" +dependencies = [ + "indexmap 1.9.3", + "itertools 0.10.5", + "ndarray", + "noisy_float", + "num-integer", + "num-traits", + "rand 0.8.6", +] + [[package]] name = "netlink-packet-core" version = "0.8.1" @@ -4843,6 +5851,15 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + [[package]] name = "nix" version = "0.29.0" @@ -4895,6 +5912,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "noisy_float" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c16843be85dd410c6a12251c4eca0dd1d3ee8c5725f746c4d5e0fdcec0a864b2" +dependencies = [ + "num-traits", +] + [[package]] name = "nom" version = "7.1.3" @@ -5211,6 +6237,30 @@ dependencies = [ "memchr", ] +[[package]] +name = "object_store" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbfbfff40aeccab00ec8a910b57ca8ecf4319b335c542f2edcd19dd25a1e2a00" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "futures", + "http", + "humantime", + "itertools 0.14.0", + "parking_lot 0.12.5", + "percent-encoding", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "walkdir", + "wasm-bindgen-futures", + "web-time", +] + [[package]] name = "oid-registry" version = "0.8.1" @@ -5387,6 +6437,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" +[[package]] +name = "path-clean" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17359afc20d7ab31fdb42bb844c8b3bb1dabd7dcf7e68428492da7f16966fcef" + [[package]] name = "pathdiff" version = "0.2.3" @@ -5420,15 +6476,23 @@ checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ "digest", "hmac", + "password-hash", + "sha2", ] +[[package]] +name = "pdqselect" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ec91767ecc0a0bbe558ce8c9da33c068066c57ecc8bb8477ef8c1ad3ef77c27" + [[package]] name = "pem" version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", "serde_core", ] @@ -5438,6 +6502,90 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap 2.14.0", +] + +[[package]] +name = "pharos" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414" +dependencies = [ + "futures", + "rustc_version", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.6", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.117", + "unicase", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", + "unicase", +] + [[package]] name = "pico-args" version = "0.5.0" @@ -5610,7 +6758,7 @@ dependencies = [ "cobs", "embedded-io 0.4.0", "embedded-io 0.6.1", - "heapless", + "heapless 0.7.17", "serde", ] @@ -5638,6 +6786,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "prettyplease" version = "0.2.37" @@ -5730,6 +6884,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + [[package]] name = "psm" version = "0.1.31" @@ -5740,6 +6900,26 @@ dependencies = [ "cc", ] +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "pxfm" version = "0.1.29" @@ -5801,6 +6981,30 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick_cache" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb55a1aa7668676bb93926cd4e9cdfe60f03bb866553bcca9112554911b6d3dc" +dependencies = [ + "ahash 0.8.12", + "equivalent", + "hashbrown 0.14.5", + "parking_lot 0.12.5", +] + +[[package]] +name = "quick_cache" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a70b1b8b47e31d0498ecbc3c5470bb931399a8bfed1fd79d1717a61ce7f96e3" +dependencies = [ + "ahash 0.8.12", + "equivalent", + "hashbrown 0.16.1", + "parking_lot 0.12.5", +] + [[package]] name = "quinn" version = "0.11.9" @@ -5878,6 +7082,23 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "radix_trie" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +dependencies = [ + "endian-type", + "nibble_vec", + "serde", +] + [[package]] name = "rand" version = "0.8.6" @@ -6011,6 +7232,12 @@ dependencies = [ "raw-window-handle", ] +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + [[package]] name = "rayon" version = "1.12.0" @@ -6054,6 +7281,12 @@ dependencies = [ "font-types", ] +[[package]] +name = "reblessive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc4a4ea2a66a41a1152c4b3d86e8954dc087bdf33af35446e6e176db4e73c8c" + [[package]] name = "redox_syscall" version = "0.2.16" @@ -6141,6 +7374,15 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + [[package]] name = "resolv-conf" version = "0.7.6" @@ -6161,6 +7403,52 @@ dependencies = [ "usvg", ] +[[package]] +name = "revision" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f53179a035f881adad8c4d58a2c599c6b4a8325b989c68d178d7a34d1b1e4c" +dependencies = [ + "revision-derive 0.10.0", +] + +[[package]] +name = "revision" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b8ee532f15b2f0811eb1a50adf10d036e14a6cdae8d99893e7f3b921cb227d" +dependencies = [ + "chrono", + "geo", + "regex", + "revision-derive 0.11.0", + "roaring", + "rust_decimal", + "uuid", +] + +[[package]] +name = "revision-derive" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0ec466e5d8dca9965eb6871879677bef5590cf7525ad96cae14376efb75073" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "revision-derive" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3415e1bc838c36f9a0a2ac60c0fa0851c72297685e66592c44870d82834dfa2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "rgb" version = "0.8.53" @@ -6170,6 +7458,35 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "rhai" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f9ef5dabe4c0b43d8f1187dc6beb67b53fe607fff7e30c5eb7f71b814b8c2c1" +dependencies = [ + "ahash 0.8.12", + "bitflags 2.11.1", + "num-traits", + "once_cell", + "rhai_codegen", + "serde", + "smallvec", + "smartstring", + "thin-vec", + "web-time", +] + +[[package]] +name = "rhai_codegen" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4322a2a4e8cf30771dd9f27f7f37ca9ac8fe812dddd811096a98483080dabe6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "ring" version = "0.17.14" @@ -6184,6 +7501,80 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rmpv" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a4e1d4b9b938a26d2996af33229f0ca0956c652c1375067f0b45291c1df8417" +dependencies = [ + "rmp", +] + +[[package]] +name = "roaring" +version = "0.10.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19e8d2cfa184d94d0726d650a9f4a1be7f9b76ac9fdb954219878dc00c1c1e7b" +dependencies = [ + "bytemuck", + "byteorder", + "serde", +] + +[[package]] +name = "robust" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e27ee8bb91ca0adcf0ecb116293afa12d393f9c2b9b9cd54d33e8078fe19839" + [[package]] name = "roxmltree" version = "0.20.0" @@ -6201,6 +7592,67 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rstar" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a45c0e8804d37e4d97e55c6f258bc9ad9c5ee7b07437009dd152d764949a27c" +dependencies = [ + "heapless 0.6.1", + "num-traits", + "pdqselect", + "serde", + "smallvec", +] + +[[package]] +name = "rstar" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40f1bfe5acdab44bc63e6699c28b74f75ec43afb59f3eda01e145aff86a25fa" +dependencies = [ + "heapless 0.7.17", + "num-traits", + "serde", + "smallvec", +] + +[[package]] +name = "rstar" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f39465655a1e3d8ae79c6d9e007f4953bfc5d55297602df9dc38f9ae9f1359a" +dependencies = [ + "heapless 0.7.17", + "num-traits", + "serde", + "smallvec", +] + +[[package]] +name = "rstar" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73111312eb7a2287d229f06c00ff35b51ddee180f017ab6dec1f69d62ac098d6" +dependencies = [ + "heapless 0.7.17", + "num-traits", + "serde", + "smallvec", +] + +[[package]] +name = "rstar" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "421400d13ccfd26dfa5858199c30a5d76f9c54e0dba7575273025b43c5175dbb" +dependencies = [ + "heapless 0.8.0", + "num-traits", + "serde", + "smallvec", +] + [[package]] name = "rtnetlink" version = "0.20.0" @@ -6278,6 +7730,33 @@ dependencies = [ "walkdir", ] +[[package]] +name = "rust-stemmers" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e46a2036019fdb888131db7a4c847a1063a7493f971ed94ea82c67eada63ca54" +dependencies = [ + "serde", + "serde_derive", +] + +[[package]] +name = "rust_decimal" +version = "1.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c5108e3d4d903e21aac27f12ba5377b6b34f9f44b325e4894c7924169d06995" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.6", + "rkyv", + "serde", + "serde_json", + "wasm-bindgen", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -6290,6 +7769,15 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustc_lexer" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86aae0c77166108c01305ee1a36a1e77289d7dc6ca0a3cd91ff4992de2d16a5" +dependencies = [ + "unicode-xid", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -6448,6 +7936,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + [[package]] name = "same-file" version = "1.0.6" @@ -6466,6 +7963,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "schemars" version = "1.2.1" @@ -6473,7 +7982,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ "dyn-clone", - "indexmap", + "indexmap 2.14.0", "ref-cast", "schemars_derive", "serde", @@ -6527,6 +8036,18 @@ dependencies = [ "once_cell", ] +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "password-hash", + "pbkdf2", + "salsa20", + "sha2", +] + [[package]] name = "seahash" version = "4.1.0" @@ -6567,6 +8088,16 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" [[package]] name = "serde" @@ -6587,6 +8118,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde-content" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3753ca04f350fa92d00b6146a3555e63c55388c9ef2e11e09bce2ff1c0b509c6" +dependencies = [ + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -6633,7 +8173,7 @@ version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ - "indexmap", + "indexmap 2.14.0", "itoa", "memchr", "serde", @@ -6647,7 +8187,7 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e033097bf0d2b59a62b42c18ebbb797503839b26afdda2c4e1415cb6c813540" dependencies = [ - "indexmap", + "indexmap 2.14.0", "itoa", "memchr", "ryu", @@ -6695,6 +8235,37 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05839ce67618e14a09b286535c0d9c94e85ef25469b0e13cb4f844e5593eb19" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf2ebbe86054f9b45bc3881e865683ccfaccce97b9b4cb53f3039d67f355a334" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "sha1" version = "0.10.6" @@ -6772,6 +8343,24 @@ dependencies = [ "quote", ] +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + [[package]] name = "simplecss" version = "0.2.2" @@ -6833,6 +8422,21 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "smartstring" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" +dependencies = [ + "autocfg", + "serde", + "static_assertions", + "version_check", +] [[package]] name = "smol" @@ -6856,6 +8460,15 @@ name = "smol_str" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "snap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b6b67fb9a61334225b5b790716f609cd58395f895b3fe8b328786812a40bc3b" [[package]] name = "snow" @@ -6894,6 +8507,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spade" +version = "2.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9699399fd9349b00b184f5635b074f9ec93afffef30c853f8c875b32c0f8c7fa" +dependencies = [ + "hashbrown 0.16.1", + "num-traits", + "robust", + "smallvec", +] + [[package]] name = "spin" version = "0.9.8" @@ -6968,6 +8593,24 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "static_assertions_next" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7beae5182595e9a8b683fa98c4317f956c9a2dec3b9716990d20023cc60c766" + +[[package]] +name = "storekey" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c42833834a5d23b344f71d87114e0cc9994766a5c42938f4b50e7b2aef85b2" +dependencies = [ + "byteorder", + "memchr", + "serde", + "thiserror 1.0.69", +] + [[package]] name = "streaming-iterator" version = "0.1.9" @@ -6993,6 +8636,31 @@ dependencies = [ "serde", ] +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot 0.12.5", + "phf_shared", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + [[package]] name = "strsim" version = "0.11.1" @@ -7048,6 +8716,155 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "surrealdb" +version = "2.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3429154a8b5a98ca39100ba45ef49ae046fb1d0869dff78d78a2670b1b278982" +dependencies = [ + "arrayvec", + "async-channel 2.5.0", + "bincode", + "chrono", + "dmp", + "futures", + "geo", + "getrandom 0.3.4", + "indexmap 2.14.0", + "path-clean", + "pharos", + "reblessive", + "revision 0.11.0", + "ring", + "rust_decimal", + "rustls-pki-types", + "semver", + "serde", + "serde-content", + "serde_json", + "surrealdb-core", + "thiserror 1.0.69", + "tokio", + "tokio-util", + "tracing", + "url", + "uuid", + "wasm-bindgen-futures", + "wasmtimer", + "ws_stream_wasm", +] + +[[package]] +name = "surrealdb-core" +version = "2.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba423d9e7e665e4c735a1d4669c3a067135e4a574edf88af215f7f2b815e70ed" +dependencies = [ + "addr", + "affinitypool", + "ahash 0.8.12", + "ammonia", + "any_ascii", + "argon2", + "async-channel 2.5.0", + "async-executor", + "async-graphql", + "base64 0.21.7", + "bcrypt", + "bincode", + "blake3", + "bytes", + "castaway", + "cedar-policy", + "chrono", + "ciborium", + "dashmap", + "deunicode", + "dmp", + "ext-sort", + "fst", + "futures", + "fuzzy-matcher", + "geo", + "geo-types", + "getrandom 0.3.4", + "hashbrown 0.14.5", + "hex", + "http", + "ipnet", + "jsonwebtoken", + "lexicmp", + "linfa-linalg", + "md-5", + "ndarray", + "ndarray-stats", + "num-traits", + "num_cpus", + "object_store", + "parking_lot 0.12.5", + "pbkdf2", + "pharos", + "phf", + "pin-project-lite", + "quick_cache 0.5.2", + "radix_trie", + "rand 0.8.6", + "rayon", + "reblessive", + "regex", + "revision 0.11.0", + "ring", + "rmpv", + "roaring", + "rust-stemmers", + "rust_decimal", + "scrypt", + "semver", + "serde", + "serde-content", + "serde_json", + "sha1", + "sha2", + "snap", + "storekey", + "strsim", + "subtle", + "surrealkv", + "sysinfo 0.33.1", + "tempfile", + "thiserror 1.0.69", + "tokio", + "tracing", + "trice", + "ulid", + "unicase", + "url", + "uuid", + "vart 0.8.1", + "wasm-bindgen-futures", + "wasmtimer", + "ws_stream_wasm", +] + +[[package]] +name = "surrealkv" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08a5041979bdff8599a1d5f6cb7365acb9a79664e2a84e5c4fddac2b3969f7d1" +dependencies = [ + "ahash 0.8.12", + "bytes", + "chrono", + "crc32fast", + "double-ended-peekable", + "getrandom 0.2.17", + "lru", + "parking_lot 0.12.5", + "quick_cache 0.6.21", + "revision 0.10.0", + "vart 0.9.3", +] + [[package]] name = "sval" version = "2.19.0" @@ -7218,6 +9035,20 @@ dependencies = [ "windows 0.57.0", ] +[[package]] +name = "sysinfo" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fc858248ea01b66f19d8e8a6d55f41deaf91e9d495246fd01368d99935c6c01" +dependencies = [ + "core-foundation-sys", + "libc", + "memchr", + "ntapi", + "rayon", + "windows 0.57.0", +] + [[package]] name = "system-configuration" version = "0.6.1" @@ -7286,6 +9117,12 @@ dependencies = [ "objc", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "tempfile" version = "3.27.0" @@ -7310,6 +9147,17 @@ dependencies = [ "utf-8", ] +[[package]] +name = "term" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" +dependencies = [ + "dirs-next", + "rustversion", + "winapi", +] + [[package]] name = "termcolor" version = "1.4.1" @@ -7319,6 +9167,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "thin-vec" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0f7e269b48f0a7dd0146680fa24b50cc67fc0373f086a5b2f99bd084639b482" +dependencies = [ + "serde", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -7556,7 +9413,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ - "indexmap", + "indexmap 2.14.0", "serde_core", "serde_spanned 1.1.1", "toml_datetime 1.1.1+spec-1.1.0", @@ -7589,7 +9446,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap", + "indexmap 2.14.0", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", @@ -7603,7 +9460,7 @@ version = "0.25.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ - "indexmap", + "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "winnow 1.0.2", @@ -7747,6 +9604,17 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "trice" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3aaab10ae9fac0b10f392752bf56f0fd20845f39037fec931e8537b105b515a" +dependencies = [ + "js-sys", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -7786,6 +9654,12 @@ version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "uds_windows" version = "1.2.1" @@ -7868,6 +9742,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-properties" version = "0.1.4" @@ -7880,6 +9763,16 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" +[[package]] +name = "unicode-security" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e4ddba1535dd35ed8b61c52166b7155d7f4e4b8847cec6f48e71dc66d8b5e50" +dependencies = [ + "unicode-normalization", + "unicode-script", +] + [[package]] name = "unicode-segmentation" version = "1.13.2" @@ -7892,6 +9785,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-width" version = "0.2.2" @@ -7945,13 +9844,19 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "usvg" version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" dependencies = [ - "base64", + "base64 0.22.1", "data-url", "flate2", "fontdb 0.23.0", @@ -8056,6 +9961,18 @@ dependencies = [ "sval_serde", ] +[[package]] +name = "vart" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87782b74f898179396e93c0efabb38de0d58d50bbd47eae00c71b3a1144dbbae" + +[[package]] +name = "vart" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1982d899e57d646498709735f16e9224cf1e8680676ad687f930cf8b5b555ae" + [[package]] name = "vcpkg" version = "0.2.15" @@ -8146,6 +10063,7 @@ dependencies = [ "cfg-if", "once_cell", "rustversion", + "serde", "wasm-bindgen-macro", "wasm-bindgen-shared", ] @@ -8219,7 +10137,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.14.0", "wasm-encoder 0.244.0", "wasmparser 0.244.0", ] @@ -8288,7 +10206,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d06bfa36ab3ac2be0dee563380147a5b81ba10dd8885d7fbbc9eb574be67d185" dependencies = [ "bitflags 2.11.1", - "indexmap", + "indexmap 2.14.0", ] [[package]] @@ -8299,7 +10217,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags 2.11.1", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.14.0", "semver", ] @@ -8310,10 +10228,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa4439c5eee9df71ee0c6efb37f63b1fcb1fec38f85f5142c54e7ed05d33091a" dependencies = [ "bitflags 2.11.1", - "indexmap", + "indexmap 2.14.0", "semver", ] +[[package]] +name = "wasmtimer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7ed9d8b15c7fb594d72bfb4b5a276f3d2029333cd93a932f376f5937f6f80ee" +dependencies = [ + "futures", + "js-sys", + "parking_lot 0.12.5", + "pin-utils", + "wasm-bindgen", +] + [[package]] name = "wast" version = "248.0.0" @@ -8323,7 +10254,7 @@ dependencies = [ "bumpalo", "leb128fmt", "memchr", - "unicode-width", + "unicode-width 0.2.2", "wasm-encoder 0.248.0", ] @@ -8453,6 +10384,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web_atoms" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + [[package]] name = "weezl" version = "0.1.12" @@ -9123,7 +11066,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck 0.5.0", - "indexmap", + "indexmap 2.14.0", "prettyplease", "syn 2.0.117", "wasm-metadata", @@ -9154,7 +11097,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags 2.11.1", - "indexmap", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -9173,7 +11116,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.14.0", "log", "semver", "serde", @@ -9189,6 +11132,34 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "ws_stream_wasm" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c173014acad22e83f16403ee360115b38846fe754e735c5d9d3803fe70c6abc" +dependencies = [ + "async_io_stream", + "futures", + "js-sys", + "log", + "pharos", + "rustc_version", + "send_wrapper", + "thiserror 2.0.18", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "x11" version = "2.21.0" @@ -9768,7 +11739,7 @@ version = "0.12.15-zed" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac2d05756ff48539950c3282ad7acf3817ad3f08797c205ad1c34a2ce03b9970" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-core", @@ -9826,7 +11797,7 @@ dependencies = [ "rand 0.8.6", "screencapturekit", "screencapturekit-sys", - "sysinfo", + "sysinfo 0.31.4", "tao-core-video-sys", "windows 0.61.3", "windows-capture", @@ -9840,7 +11811,7 @@ version = "0.4.0-zed" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c0b46ed118eba34d9ba53d94ddc0b665e0e06a2cf874cfa2dd5dec278148642" dependencies = [ - "ahash", + "ahash 0.8.12", "hashbrown 0.14.5", "log", "x11rb", diff --git a/Cargo.toml b/Cargo.toml index a8ca317..196b0d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,6 +53,11 @@ members = [ "crates/modules/ui_engine/widgets/tiled", "crates/modules/ui_engine/widgets/text_input", + # ============================================================ + # modules/nakui/ — ERP matemático (nakui absorbido) + # ============================================================ + "crates/modules/nakui/core", + # ============================================================ # apps/ — apps que consumen el protocolo (yahweh modules+shell) # ============================================================ diff --git a/crates/modules/nakui/core/Cargo.toml b/crates/modules/nakui/core/Cargo.toml new file mode 100644 index 0000000..3d4b869 --- /dev/null +++ b/crates/modules/nakui/core/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "nakui-core" +version = "0.1.0" +edition = "2021" + +[features] +default = [] +# Pulls in surrealdb's pure-Rust SurrealKV backend so SurrealStore can +# persist to disk across process restarts. Lighter compile cost than +# RocksDB (which would otherwise pull in a C++ build); opt-in only. +persistent = ["surrealdb/kv-surrealkv"] + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +rhai = { version = "1.20", features = ["serde"] } +uuid = { version = "1", features = ["v4", "serde"] } +thiserror = "1" +petgraph = "0.6" +sha2 = "0.10" +surrealdb = { version = "2", default-features = false, features = ["kv-mem"] } +tokio = { version = "1", features = ["rt", "macros"] } + +[[bin]] +name = "nakui" +path = "src/bin/nakui.rs" + +[[bin]] +name = "demo" +path = "src/bin/demo.rs" + +[[bin]] +name = "inventory_demo" +path = "src/bin/inventory_demo.rs" + +[[bin]] +name = "sales_demo" +path = "src/bin/sales_demo.rs" diff --git a/crates/modules/nakui/core/src/bin/demo.rs b/crates/modules/nakui/core/src/bin/demo.rs new file mode 100644 index 0000000..93c9254 --- /dev/null +++ b/crates/modules/nakui/core/src/bin/demo.rs @@ -0,0 +1,224 @@ +use nakui_core::event_log::{ + EventLog, ExecuteError, execute_and_log, replay, seed_and_log, verify_log, +}; +use nakui_core::executor::Executor; +use nakui_core::store::{MemoryStore, Store}; +use serde_json::json; +use uuid::Uuid; + +fn main() { + let module_dir = + std::env::var("NAKUI_MODULE").unwrap_or_else(|_| "modules/treasury".into()); + let exec = Executor::load_module(&module_dir).expect("load module"); + + let log_path = std::env::temp_dir().join(format!("nakui_demo_{}.jsonl", Uuid::new_v4())); + let mut log = EventLog::open(&log_path).expect("open log"); + let mut store = MemoryStore::new(); + + let caja_a = Uuid::new_v4(); + let caja_b = Uuid::new_v4(); + let caja_c = Uuid::new_v4(); + seed_and_log( + &exec, + &mut store, + &mut log, + "Caja", + caja_a, + json!({ + "id": caja_a.to_string(), + "name": "Caja Principal", + "saldo": 200_000_i64, + "currency": "USD", + }), + ) + .expect("seed A"); + seed_and_log( + &exec, + &mut store, + &mut log, + "Caja", + caja_b, + json!({ + "id": caja_b.to_string(), + "name": "Caja Chica", + "saldo": 50_000_i64, + "currency": "USD", + }), + ) + .expect("seed B"); + seed_and_log( + &exec, + &mut store, + &mut log, + "Caja", + caja_c, + json!({ + "id": caja_c.to_string(), + "name": "Caja EUR", + "saldo": 30_000_i64, + "currency": "EUR", + }), + ) + .expect("seed C"); + + section("== seed =="); + print_caja(&store, "A", caja_a); + print_caja(&store, "B", caja_b); + print_caja(&store, "C", caja_c); + + section("== A: deposit 50_000 USD =="); + run_and_report( + &exec, + &mut store, + &mut log, + "register_cash_move", + &[("caja", caja_a)], + json!({ + "monto": 50_000_i64, + "tipo": "in", + "timestamp": "2026-05-04T12:00:00Z", + "memo": "deposito A", + "movimiento_id": Uuid::new_v4().to_string(), + }), + ); + print_caja(&store, "A", caja_a); + + section("== transfer A -> B 100_000 USD =="); + run_and_report( + &exec, + &mut store, + &mut log, + "transfer_between_cajas", + &[("source", caja_a), ("dest", caja_b)], + json!({ + "monto": 100_000_i64, + "timestamp": "2026-05-04T12:30:00Z", + "memo": "transferencia operativa", + "transfer_id": Uuid::new_v4().to_string(), + }), + ); + print_caja(&store, "A", caja_a); + print_caja(&store, "B", caja_b); + + section("== transfer A -> B 999_999_999 USD (reject: post-check on source) =="); + run_and_report( + &exec, + &mut store, + &mut log, + "transfer_between_cajas", + &[("source", caja_a), ("dest", caja_b)], + json!({ + "monto": 999_999_999_i64, + "timestamp": "2026-05-04T13:00:00Z", + "memo": "overdraw", + "transfer_id": Uuid::new_v4().to_string(), + }), + ); + + section("== transfer A(USD) -> C(EUR) (reject: rhai throws) =="); + run_and_report( + &exec, + &mut store, + &mut log, + "transfer_between_cajas", + &[("source", caja_a), ("dest", caja_c)], + json!({ + "monto": 10_000_i64, + "timestamp": "2026-05-04T14:00:00Z", + "memo": "USD -> EUR", + "transfer_id": Uuid::new_v4().to_string(), + }), + ); + + section("== self-transfer A -> A (reject: DuplicateInputId) =="); + run_and_report( + &exec, + &mut store, + &mut log, + "transfer_between_cajas", + &[("source", caja_a), ("dest", caja_a)], + json!({ + "monto": 1_000_i64, + "timestamp": "2026-05-04T15:00:00Z", + "memo": "self", + "transfer_id": Uuid::new_v4().to_string(), + }), + ); + + section("== final live state =="); + print_caja(&store, "A", caja_a); + print_caja(&store, "B", caja_b); + print_caja(&store, "C", caja_c); + + let entries = log.entries().expect("read log"); + section(&format!( + "== log: {} entries at {} ==", + entries.len(), + log.path().display() + )); + for e in &entries { + match e { + nakui_core::event_log::LogEntry::Seed { + seq, entity, id, .. + } => println!(" #{:02} seed {} {}", seq, entity, id), + nakui_core::event_log::LogEntry::Morphism { + seq, + morphism, + ops, + .. + } => println!(" #{:02} morph {} ({} ops)", seq, morphism, ops.len()), + } + } + + section("== replay verification (state) =="); + let replayed = replay(&log).expect("replay"); + if store == replayed { + println!(" ok: replayed store byte-equal to live store"); + } else { + println!(" MISMATCH: replay diverges from live"); + } + + section("== determinism verification (ops) =="); + match verify_log(&log, &exec) { + Ok(()) => println!( + " ok: every logged morphism reproduced its ops on re-execution" + ), + Err(e) => println!(" nondeterminism detected: {}", e), + } + + if std::env::var_os("NAKUI_DEMO_KEEP").is_none() { + let _ = std::fs::remove_file(&log_path); + } else { + println!("\n(NAKUI_DEMO_KEEP set — keeping log at {})", log_path.display()); + } +} + +fn run_and_report( + exec: &Executor, + store: &mut MemoryStore, + log: &mut EventLog, + morphism: &str, + inputs: &[(&str, Uuid)], + params: serde_json::Value, +) { + match execute_and_log(exec, store, log, morphism, inputs, params) { + Ok(ops) => println!(" ok ({} ops, logged at #{})", ops.len(), log.next_seq() - 1), + Err(ExecuteError::PreLog(e)) => println!(" rejected: {}", e), + Err(ExecuteError::LogAppend(e)) => println!(" LOG APPEND FAILED: {}", e), + Err(ExecuteError::PostLogStore(e)) => println!( + " POST-LOG STORE FAILED (log is canonical, store stale): {}", + e + ), + } +} + +fn print_caja(store: &MemoryStore, label: &str, id: Uuid) { + let v = store.load("Caja", id).expect("caja exists"); + let saldo = v.get("saldo").and_then(|v| v.as_i64()).unwrap_or(0); + let currency = v.get("currency").and_then(|v| v.as_str()).unwrap_or("?"); + println!(" {} {}: saldo={} {}", label, id, saldo, currency); +} + +fn section(title: &str) { + println!("\n{}", title); +} diff --git a/crates/modules/nakui/core/src/bin/inventory_demo.rs b/crates/modules/nakui/core/src/bin/inventory_demo.rs new file mode 100644 index 0000000..606a556 --- /dev/null +++ b/crates/modules/nakui/core/src/bin/inventory_demo.rs @@ -0,0 +1,170 @@ +use nakui_core::event_log::{ + EventLog, ExecuteError, execute_and_log, replay, seed_and_log, verify_log, +}; +use nakui_core::executor::Executor; +use nakui_core::store::{MemoryStore, Store}; +use serde_json::json; +use uuid::Uuid; + +fn main() { + let module_dir = std::env::var("NAKUI_MODULE") + .unwrap_or_else(|_| "modules/inventory".into()); + let exec = Executor::load_module(&module_dir).expect("load module"); + + let log_path = + std::env::temp_dir().join(format!("nakui_inv_{}.jsonl", Uuid::new_v4())); + let mut log = EventLog::open(&log_path).expect("open log"); + let mut store = MemoryStore::new(); + + // Two stocks of SKU "kg-cafe-honduras-2026" at warehouses A and B, + // plus a third stock of SKU "lt-aceite-girasol" at warehouse C. + let stock_a = Uuid::new_v4(); + let stock_b = Uuid::new_v4(); + let stock_c = Uuid::new_v4(); + seed_and_log( + &exec, + &mut store, &mut log, "Stock", stock_a, + json!({ + "id": stock_a.to_string(), + "sku_id": "kg-cafe-honduras-2026", + "ubicacion": "almacen-norte", + "cantidad": 500_i64, + }), + ).expect("seed A"); + seed_and_log( + &exec, + &mut store, &mut log, "Stock", stock_b, + json!({ + "id": stock_b.to_string(), + "sku_id": "kg-cafe-honduras-2026", + "ubicacion": "almacen-sur", + "cantidad": 100_i64, + }), + ).expect("seed B"); + seed_and_log( + &exec, + &mut store, &mut log, "Stock", stock_c, + json!({ + "id": stock_c.to_string(), + "sku_id": "lt-aceite-girasol", + "ubicacion": "almacen-sur", + "cantidad": 200_i64, + }), + ).expect("seed C"); + + section("== seed =="); + print_stock(&store, "A (cafe norte)", stock_a); + print_stock(&store, "B (cafe sur)", stock_b); + print_stock(&store, "C (aceite sur)", stock_c); + + section("== recibir 250 kg cafe en A =="); + run_and_report(&exec, &mut store, &mut log, "recibir_stock", + &[("stock", stock_a)], + json!({ + "cantidad": 250_i64, + "timestamp": "2026-05-04T08:00:00Z", + "movimiento_id": Uuid::new_v4().to_string(), + }), + ); + print_stock(&store, "A", stock_a); + + section("== transferir 200 kg cafe A -> B (conserva por sku_id) =="); + run_and_report(&exec, &mut store, &mut log, "transferir_stock", + &[("source", stock_a), ("dest", stock_b)], + json!({ + "cantidad": 200_i64, + "timestamp": "2026-05-04T09:00:00Z", + "transfer_id": Uuid::new_v4().to_string(), + }), + ); + print_stock(&store, "A", stock_a); + print_stock(&store, "B", stock_b); + + section("== transferir 999_999 kg cafe A -> B (reject: stock <= 0) =="); + run_and_report(&exec, &mut store, &mut log, "transferir_stock", + &[("source", stock_a), ("dest", stock_b)], + json!({ + "cantidad": 999_999_i64, + "timestamp": "2026-05-04T10:00:00Z", + "transfer_id": Uuid::new_v4().to_string(), + }), + ); + + section("== transferir 50 cafe(A) -> aceite(C) (reject: rhai SKU mismatch) =="); + run_and_report(&exec, &mut store, &mut log, "transferir_stock", + &[("source", stock_a), ("dest", stock_c)], + json!({ + "cantidad": 50_i64, + "timestamp": "2026-05-04T11:00:00Z", + "transfer_id": Uuid::new_v4().to_string(), + }), + ); + + section("== final live state =="); + print_stock(&store, "A", stock_a); + print_stock(&store, "B", stock_b); + print_stock(&store, "C", stock_c); + + let entries = log.entries().expect("read log"); + section(&format!( + "== log: {} entries at {} ==", + entries.len(), + log.path().display() + )); + for e in &entries { + match e { + nakui_core::event_log::LogEntry::Seed { seq, entity, id, .. } => + println!(" #{:02} seed {} {}", seq, entity, id), + nakui_core::event_log::LogEntry::Morphism { seq, morphism, ops, .. } => + println!(" #{:02} morph {} ({} ops)", seq, morphism, ops.len()), + } + } + + section("== replay verification (state) =="); + let replayed = replay(&log).expect("replay"); + if store == replayed { + println!(" ok: replayed store byte-equal to live store"); + } else { + println!(" MISMATCH"); + } + + section("== determinism verification (ops) =="); + match verify_log(&log, &exec) { + Ok(()) => println!( + " ok: every logged morphism reproduced its ops on re-execution" + ), + Err(e) => println!(" nondeterminism detected: {}", e), + } + + let _ = std::fs::remove_file(&log_path); +} + +fn run_and_report( + exec: &Executor, + store: &mut MemoryStore, + log: &mut EventLog, + morphism: &str, + inputs: &[(&str, Uuid)], + params: serde_json::Value, +) { + match execute_and_log(exec, store, log, morphism, inputs, params) { + Ok(ops) => println!(" ok ({} ops, logged at #{})", ops.len(), log.next_seq() - 1), + Err(ExecuteError::PreLog(e)) => println!(" rejected: {}", e), + Err(ExecuteError::LogAppend(e)) => println!(" LOG APPEND FAILED: {}", e), + Err(ExecuteError::PostLogStore(e)) => println!( + " POST-LOG STORE FAILED (log canonical, store stale): {}", e + ), + } +} + +fn print_stock(store: &MemoryStore, label: &str, id: Uuid) { + let v = store.load("Stock", id).expect("stock exists"); + let cantidad = v.get("cantidad").and_then(|v| v.as_i64()).unwrap_or(0); + let sku = v.get("sku_id").and_then(|v| v.as_str()).unwrap_or("?"); + let loc = v.get("ubicacion").and_then(|v| v.as_str()).unwrap_or("?"); + println!(" {}: cantidad={} sku={} ubic={}", label, cantidad, sku, loc); +} + +fn section(title: &str) { + println!("\n{}", title); +} diff --git a/crates/modules/nakui/core/src/bin/nakui.rs b/crates/modules/nakui/core/src/bin/nakui.rs new file mode 100644 index 0000000..6ee5714 --- /dev/null +++ b/crates/modules/nakui/core/src/bin/nakui.rs @@ -0,0 +1,455 @@ +//! `nakui` — operator CLI for inspecting, replaying, and verifying an +//! event log produced by the kernel. The three subcommands map to the +//! three things you need when something goes sideways in production: +//! +//! - `inspect` — what's in the log? (audit trail) +//! - `replay` — what state does the log produce? (recovery dry-run) +//! - `verify-log` — does every morphism still reproduce its ops? +//! (determinism contract — the regression alarm) +//! +//! Exit codes: 0 on success, 1 on operational error, 2 on bad arguments. + +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::process::ExitCode; + +use nakui_core::drift::{DriftDiff, check_against_socket}; +use nakui_core::event_log::{ + EventLog, LogEntry, Snapshot, replay_with_snapshot_into, verify_log, +}; +use nakui_core::executor::Executor; +use nakui_core::run::run_server; +use nakui_core::store::MemoryStore; + +fn main() -> ExitCode { + let args: Vec = std::env::args().collect(); + let prog = args.first().cloned().unwrap_or_else(|| "nakui".into()); + let sub = match args.get(1).map(String::as_str) { + Some(s) => s, + None => { + print_usage(&prog); + return ExitCode::from(2); + } + }; + let rest = &args[2..]; + + let result = match sub { + "inspect" => cmd_inspect(rest), + "replay" => cmd_replay(rest), + "verify-log" => cmd_verify_log(rest), + "run" => cmd_run(rest), + "drift" => cmd_drift(rest), + "snapshot" => cmd_snapshot(rest), + "compact" => cmd_compact(rest), + "-h" | "--help" | "help" => { + print_usage(&prog); + return ExitCode::SUCCESS; + } + other => { + eprintln!("nakui: unknown subcommand `{}`", other); + print_usage(&prog); + return ExitCode::from(2); + } + }; + + match result { + Ok(()) => ExitCode::SUCCESS, + Err(CliError::BadArgs(msg)) => { + eprintln!("nakui: {}", msg); + print_usage(&prog); + ExitCode::from(2) + } + Err(CliError::Op(msg)) => { + eprintln!("nakui: {}", msg); + ExitCode::from(1) + } + // Drift uses its own exit code so callers can distinguish "the + // tool failed" (1) from "the tool worked and detected drift" (3). + Err(CliError::DriftDetected) => ExitCode::from(3), + } +} + +enum CliError { + BadArgs(String), + Op(String), + DriftDetected, +} + +fn print_usage(prog: &str) { + eprintln!( + "usage: + {p} inspect --log + {p} replay --log [--snapshot ] + {p} verify-log --log --module + {p} run --log --module --socket + [--snapshot ] [--store-path ] + {p} drift --log --against + {p} snapshot --log --module --out + {p} compact --log --snapshot + + --store-path activates persistent SurrealStore (kv-surrealkv); + requires the binary to be built with `--features persistent`.", + p = prog + ); +} + +/// Minimal flag parser: `--name value` pairs, no `=` form, no clustering. +/// Returns a map of name -> value. Unknown flags are an error so typos +/// surface immediately instead of silently being ignored. +fn parse_flags(args: &[String], allowed: &[&str]) -> Result, CliError> { + let mut out = BTreeMap::new(); + let mut i = 0; + while i < args.len() { + let flag = &args[i]; + if !flag.starts_with("--") { + return Err(CliError::BadArgs(format!( + "expected --flag, got `{}`", + flag + ))); + } + let name = &flag[2..]; + if !allowed.contains(&name) { + return Err(CliError::BadArgs(format!("unknown flag `--{}`", name))); + } + let val = args.get(i + 1).ok_or_else(|| { + CliError::BadArgs(format!("flag `--{}` requires a value", name)) + })?; + out.insert(name.to_string(), val.clone()); + i += 2; + } + Ok(out) +} + +fn require<'a>( + flags: &'a BTreeMap, + name: &str, +) -> Result<&'a String, CliError> { + flags + .get(name) + .ok_or_else(|| CliError::BadArgs(format!("missing required flag `--{}`", name))) +} + +fn cmd_inspect(args: &[String]) -> Result<(), CliError> { + let flags = parse_flags(args, &["log"])?; + let log_path = PathBuf::from(require(&flags, "log")?); + let log = EventLog::open(&log_path).map_err(|e| CliError::Op(format!("open log: {}", e)))?; + let entries = log + .entries() + .map_err(|e| CliError::Op(format!("read log: {}", e)))?; + println!("log: {}", log.path().display()); + println!("entries: {}", entries.len()); + if entries.is_empty() { + return Ok(()); + } + println!("seq range: {}..={}", entries[0].seq(), entries.last().unwrap().seq()); + println!(); + for e in &entries { + match e { + LogEntry::Seed { + seq, entity, id, .. + } => println!(" #{:04} seed {} {}", seq, entity, id), + LogEntry::Morphism { + seq, + morphism, + ops, + inputs, + .. + } => { + let inputs_s = inputs + .iter() + .map(|(k, v)| format!("{}={}", k, v)) + .collect::>() + .join(", "); + println!( + " #{:04} morph {} ({} ops) [{}]", + seq, + morphism, + ops.len(), + inputs_s + ); + } + } + } + Ok(()) +} + +fn cmd_replay(args: &[String]) -> Result<(), CliError> { + let flags = parse_flags(args, &["log", "snapshot"])?; + let log_path = PathBuf::from(require(&flags, "log")?); + let log = EventLog::open(&log_path).map_err(|e| CliError::Op(format!("open log: {}", e)))?; + + let snapshot = if let Some(p) = flags.get("snapshot") { + let path = PathBuf::from(p); + Snapshot::load(&path) + .map_err(|e| CliError::Op(format!("load snapshot: {}", e)))? + .ok_or_else(|| CliError::Op(format!("snapshot not found: {}", path.display())))? + .into() + } else { + None:: + }; + + let mut store = MemoryStore::new(); + replay_with_snapshot_into(&log, snapshot.as_ref(), &mut store) + .map_err(|e| CliError::Op(format!("replay: {}", e)))?; + + let entries = log + .entries() + .map_err(|e| CliError::Op(format!("read log: {}", e)))?; + let last_seq = entries.last().map(|e| e.seq().to_string()).unwrap_or_else(|| "".into()); + println!("replayed log: {}", log.path().display()); + if let Some(snap) = &snapshot { + println!("snapshot: seq {} (covers seq <= {})", snap.seq, snap.seq); + } + println!("last seq: {}", last_seq); + println!("entities:"); + let mut by_entity: Vec<(&String, usize)> = store + .records() + .iter() + .map(|(k, v)| (k, v.len())) + .collect(); + by_entity.sort_by(|a, b| a.0.cmp(b.0)); + if by_entity.is_empty() { + println!(" (none)"); + } else { + for (entity, count) in by_entity { + println!(" {:<20} {}", entity, count); + } + } + Ok(()) +} + +fn cmd_drift(args: &[String]) -> Result<(), CliError> { + let flags = parse_flags(args, &["log", "against"])?; + let log_path = PathBuf::from(require(&flags, "log")?); + let socket_path = PathBuf::from(require(&flags, "against")?); + + let report = check_against_socket(&log_path, &socket_path) + .map_err(|e| CliError::Op(format!("drift check: {}", e)))?; + + let log_hex = hex_encode(&report.log_hash); + let server_hex = hex_encode(&report.server_hash); + if report.in_sync() { + println!( + "ok: in sync (hash {}, {} records)", + short_hash(&log_hex), + report.log_records + ); + return Ok(()); + } + + println!("DRIFT detected"); + println!( + " log replay: hash {} ({} records)", + log_hex, report.log_records + ); + println!( + " server state: hash {} ({} records)", + server_hex, report.server_records + ); + println!(); + println!("diffs:"); + for d in &report.diffs { + match d { + DriftDiff::OnlyOnServer { entity, id, .. } => { + println!(" + {} {} (only on server)", entity, id); + } + DriftDiff::OnlyInLog { entity, id, .. } => { + println!(" - {} {} (only in log replay)", entity, id); + } + DriftDiff::Tampered { + entity, + id, + log_value, + server_value, + } => { + println!( + " ~ {} {} (tampered)\n log: {}\n server: {}", + entity, id, log_value, server_value + ); + } + } + } + Err(CliError::DriftDetected) +} + +fn hex_encode(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for &b in bytes { + out.push(HEX[(b >> 4) as usize] as char); + out.push(HEX[(b & 0x0f) as usize] as char); + } + out +} + +fn short_hash(hex: &str) -> String { + if hex.len() <= 12 { + hex.to_string() + } else { + format!("{}…{}", &hex[..6], &hex[hex.len() - 4..]) + } +} + +fn cmd_run(args: &[String]) -> Result<(), CliError> { + let flags = parse_flags(args, &["log", "module", "socket", "snapshot", "store-path"])?; + let log_path = PathBuf::from(require(&flags, "log")?); + let module_dir = PathBuf::from(require(&flags, "module")?); + let socket_path = PathBuf::from(require(&flags, "socket")?); + let snapshot_path = flags.get("snapshot").map(PathBuf::from); + let store_path = flags.get("store-path").map(PathBuf::from); + + eprintln!( + "nakui run: module={} log={} socket={} snapshot={} store={}", + module_dir.display(), + log_path.display(), + socket_path.display(), + snapshot_path + .as_ref() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "".into()), + store_path + .as_ref() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "".into()), + ); + + let executor = Executor::load_module(&module_dir) + .map_err(|e| CliError::Op(format!("load module {}: {}", module_dir.display(), e)))?; + let log = EventLog::open(&log_path).map_err(|e| CliError::Op(format!("open log: {}", e)))?; + let snapshot = match &snapshot_path { + Some(p) => Some( + Snapshot::load(p) + .map_err(|e| CliError::Op(format!("load snapshot: {}", e)))? + .ok_or_else(|| { + CliError::Op(format!("snapshot file does not exist: {}", p.display())) + })?, + ), + None => None, + }; + + if let Some(p) = store_path { + run_persistent(executor, log, snapshot, &socket_path, &p) + } else { + let store = MemoryStore::new(); + run_server(executor, log, store, snapshot, &socket_path) + .map_err(|e| CliError::Op(format!("run: {}", e))) + } +} + +#[cfg(feature = "persistent")] +fn run_persistent( + executor: Executor, + log: EventLog, + snapshot: Option, + socket_path: &std::path::Path, + store_path: &std::path::Path, +) -> Result<(), CliError> { + use nakui_core::surreal_store::SurrealStore; + let store = SurrealStore::new_persistent(store_path).map_err(|e| { + CliError::Op(format!( + "open persistent store at {}: {}", + store_path.display(), + e + )) + })?; + run_server(executor, log, store, snapshot, socket_path) + .map_err(|e| CliError::Op(format!("run: {}", e))) +} + +#[cfg(not(feature = "persistent"))] +fn run_persistent( + _executor: Executor, + _log: EventLog, + _snapshot: Option, + _socket_path: &std::path::Path, + _store_path: &std::path::Path, +) -> Result<(), CliError> { + Err(CliError::Op( + "--store-path requires building with `--features persistent`".into(), + )) +} + +fn cmd_snapshot(args: &[String]) -> Result<(), CliError> { + let flags = parse_flags(args, &["log", "module", "out"])?; + let log_path = PathBuf::from(require(&flags, "log")?); + let module_dir = PathBuf::from(require(&flags, "module")?); + let out_path = PathBuf::from(require(&flags, "out")?); + + let exec = Executor::load_module(&module_dir) + .map_err(|e| CliError::Op(format!("load module {}: {}", module_dir.display(), e)))?; + let log = EventLog::open(&log_path).map_err(|e| CliError::Op(format!("open log: {}", e)))?; + let mut store = MemoryStore::new(); + replay_with_snapshot_into(&log, None, &mut store) + .map_err(|e| CliError::Op(format!("replay: {}", e)))?; + let last_seq = log + .entries() + .map_err(|e| CliError::Op(format!("read log: {}", e)))? + .last() + .map(|e| e.seq()) + .ok_or_else(|| CliError::Op("log is empty; nothing to snapshot".into()))?; + let snap = Snapshot::capture(&store, last_seq, &exec); + snap.write(&out_path) + .map_err(|e| CliError::Op(format!("write snapshot: {}", e)))?; + + let entity_count: usize = store.records().values().map(|m| m.len()).sum(); + println!( + "snapshot written to {} (seq {}, {} records, schema {})", + out_path.display(), + last_seq, + entity_count, + short_hash(&hex_encode(&exec.module_schema_hash())), + ); + Ok(()) +} + +fn cmd_compact(args: &[String]) -> Result<(), CliError> { + let flags = parse_flags(args, &["log", "snapshot"])?; + let log_path = PathBuf::from(require(&flags, "log")?); + let snap_path = PathBuf::from(require(&flags, "snapshot")?); + + let snap = Snapshot::load(&snap_path) + .map_err(|e| CliError::Op(format!("load snapshot: {}", e)))? + .ok_or_else(|| CliError::Op(format!("snapshot not found: {}", snap_path.display())))?; + let mut log = EventLog::open(&log_path).map_err(|e| CliError::Op(format!("open log: {}", e)))?; + let before = log + .entries() + .map(|es| es.len()) + .map_err(|e| CliError::Op(format!("read log: {}", e)))?; + log.compact_through(snap.seq) + .map_err(|e| CliError::Op(format!("compact: {}", e)))?; + let after = log + .entries() + .map(|es| es.len()) + .map_err(|e| CliError::Op(format!("read log: {}", e)))?; + println!( + "compacted {} through seq {} ({} → {} entries; {} dropped)", + log_path.display(), + snap.seq, + before, + after, + before.saturating_sub(after), + ); + Ok(()) +} + +fn cmd_verify_log(args: &[String]) -> Result<(), CliError> { + let flags = parse_flags(args, &["log", "module"])?; + let log_path = PathBuf::from(require(&flags, "log")?); + let module_dir = PathBuf::from(require(&flags, "module")?); + + let exec = Executor::load_module(&module_dir) + .map_err(|e| CliError::Op(format!("load module {}: {}", module_dir.display(), e)))?; + let log = EventLog::open(&log_path).map_err(|e| CliError::Op(format!("open log: {}", e)))?; + + match verify_log(&log, &exec) { + Ok(()) => { + let n = log + .entries() + .map(|es| es.len()) + .map_err(|e| CliError::Op(format!("read log: {}", e)))?; + println!("ok: {} entries; every morphism reproduced its ops", n); + Ok(()) + } + Err(e) => Err(CliError::Op(format!("verify failed: {}", e))), + } +} diff --git a/crates/modules/nakui/core/src/bin/sales_demo.rs b/crates/modules/nakui/core/src/bin/sales_demo.rs new file mode 100644 index 0000000..d7a3946 --- /dev/null +++ b/crates/modules/nakui/core/src/bin/sales_demo.rs @@ -0,0 +1,176 @@ +//! Cross-module demo: a `vender` morphism that touches a Stock entity +//! (defined in inventory's schema) and a Caja entity (defined in +//! treasury's schema). The sales module's `nsmc.json` lists three schema +//! files; the executor concatenates them at load time so KCL validates +//! against all three. + +use nakui_core::event_log::{ + EventLog, ExecuteError, execute_and_log, replay, seed_and_log, verify_log, +}; +use nakui_core::executor::Executor; +use nakui_core::store::{MemoryStore, Store}; +use serde_json::json; +use uuid::Uuid; + +fn main() { + let module_dir = std::env::var("NAKUI_MODULE") + .unwrap_or_else(|_| "modules/sales".into()); + let exec = Executor::load_module(&module_dir).expect("load module"); + + let log_path = + std::env::temp_dir().join(format!("nakui_sales_{}.jsonl", Uuid::new_v4())); + let mut log = EventLog::open(&log_path).expect("open log"); + let mut store = MemoryStore::new(); + + let stock_id = Uuid::new_v4(); + let caja_id = Uuid::new_v4(); + seed_and_log( + &exec, + &mut store, &mut log, "Stock", stock_id, + json!({ + "id": stock_id.to_string(), + "sku_id": "kg-cafe-honduras-2026", + "ubicacion": "almacen-norte", + "cantidad": 500_i64, + }), + ).expect("seed stock"); + seed_and_log( + &exec, + &mut store, &mut log, "Caja", caja_id, + json!({ + "id": caja_id.to_string(), + "name": "Caja Principal", + "saldo": 1_000_000_i64, // $10_000.00 in cents + "currency": "USD", + }), + ).expect("seed caja"); + + section("== seed =="); + print_stock(&store, "stock", stock_id); + print_caja(&store, "caja", caja_id); + + // 1. Sell 100 kg cafe at $50.00 / kg = $5000.00 total. + section("== vender 100 kg @ $50.00 c/u =="); + run_and_report(&exec, &mut store, &mut log, "vender", + &[("stock", stock_id), ("caja", caja_id)], + json!({ + "cantidad": 100_i64, + "precio_unitario": 5_000_i64, // $50.00 in cents + "timestamp": "2026-05-04T10:00:00Z", + "venta_id": Uuid::new_v4().to_string(), + }), + ); + print_stock(&store, "stock", stock_id); + print_caja(&store, "caja", caja_id); + + // 2. Try selling more than available stock — should fail Stock post-check. + section("== vender 9999 kg (reject: stock <= 0) =="); + run_and_report(&exec, &mut store, &mut log, "vender", + &[("stock", stock_id), ("caja", caja_id)], + json!({ + "cantidad": 9999_i64, + "precio_unitario": 1_000_i64, + "timestamp": "2026-05-04T11:00:00Z", + "venta_id": Uuid::new_v4().to_string(), + }), + ); + + // 3. Negative price — caught by Rhai. + section("== vender con precio negativo (reject: rhai throw) =="); + run_and_report(&exec, &mut store, &mut log, "vender", + &[("stock", stock_id), ("caja", caja_id)], + json!({ + "cantidad": 10_i64, + "precio_unitario": -100_i64, + "timestamp": "2026-05-04T11:30:00Z", + "venta_id": Uuid::new_v4().to_string(), + }), + ); + + // 4. Another good sale. + section("== vender 50 kg @ $60.00 c/u =="); + run_and_report(&exec, &mut store, &mut log, "vender", + &[("stock", stock_id), ("caja", caja_id)], + json!({ + "cantidad": 50_i64, + "precio_unitario": 6_000_i64, + "timestamp": "2026-05-04T12:00:00Z", + "venta_id": Uuid::new_v4().to_string(), + }), + ); + print_stock(&store, "stock", stock_id); + print_caja(&store, "caja", caja_id); + + section("== final live state =="); + print_stock(&store, "stock", stock_id); + print_caja(&store, "caja", caja_id); + + let entries = log.entries().expect("read log"); + section(&format!( + "== log: {} entries at {} ==", + entries.len(), + log.path().display() + )); + for e in &entries { + match e { + nakui_core::event_log::LogEntry::Seed { seq, entity, id, .. } => + println!(" #{:02} seed {} {}", seq, entity, id), + nakui_core::event_log::LogEntry::Morphism { seq, morphism, ops, .. } => + println!(" #{:02} morph {} ({} ops)", seq, morphism, ops.len()), + } + } + + section("== replay verification (state) =="); + let replayed = replay(&log).expect("replay"); + if store == replayed { + println!(" ok: replayed store byte-equal to live store"); + } else { + println!(" MISMATCH"); + } + + section("== determinism verification (ops) =="); + match verify_log(&log, &exec) { + Ok(()) => println!( + " ok: every logged morphism reproduced its ops on re-execution" + ), + Err(e) => println!(" nondeterminism detected: {}", e), + } + + let _ = std::fs::remove_file(&log_path); +} + +fn run_and_report( + exec: &Executor, + store: &mut MemoryStore, + log: &mut EventLog, + morphism: &str, + inputs: &[(&str, Uuid)], + params: serde_json::Value, +) { + match execute_and_log(exec, store, log, morphism, inputs, params) { + Ok(ops) => println!(" ok ({} ops, logged at #{})", ops.len(), log.next_seq() - 1), + Err(ExecuteError::PreLog(e)) => println!(" rejected: {}", e), + Err(ExecuteError::LogAppend(e)) => println!(" LOG APPEND FAILED: {}", e), + Err(ExecuteError::PostLogStore(e)) => println!( + " POST-LOG STORE FAILED (log canonical, store stale): {}", e + ), + } +} + +fn print_stock(store: &MemoryStore, label: &str, id: Uuid) { + let v = store.load("Stock", id).expect("stock exists"); + let cantidad = v.get("cantidad").and_then(|v| v.as_i64()).unwrap_or(0); + let sku = v.get("sku_id").and_then(|v| v.as_str()).unwrap_or("?"); + println!(" {} cantidad={} sku={}", label, cantidad, sku); +} + +fn print_caja(store: &MemoryStore, label: &str, id: Uuid) { + let v = store.load("Caja", id).expect("caja exists"); + let saldo = v.get("saldo").and_then(|v| v.as_i64()).unwrap_or(0); + let cur = v.get("currency").and_then(|v| v.as_str()).unwrap_or("?"); + println!(" {} saldo={} {} (en centavos)", label, saldo, cur); +} + +fn section(title: &str) { + println!("\n{}", title); +} diff --git a/crates/modules/nakui/core/src/delta.rs b/crates/modules/nakui/core/src/delta.rs new file mode 100644 index 0000000..35f3faf --- /dev/null +++ b/crates/modules/nakui/core/src/delta.rs @@ -0,0 +1,72 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FieldPath { + pub entity: String, + pub id: Uuid, + pub field: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "op", rename_all = "snake_case")] +pub enum FieldOp { + Set { + path: FieldPath, + value: Value, + }, + Create { + entity: String, + id: Uuid, + data: Value, + }, + Delete { + entity: String, + id: Uuid, + }, +} + +impl FieldOp { + /// Token a manifest's `writes` list matches against. + /// "Caja.saldo" for field updates, "Movimiento" for whole-record ops. + pub fn capability_token(&self) -> String { + match self { + FieldOp::Set { path, .. } => format!("{}.{}", path.entity, path.field), + FieldOp::Create { entity, .. } => entity.clone(), + FieldOp::Delete { entity, .. } => entity.clone(), + } + } +} + +/// Apply only the ops that target `(entity, id)` to `state` and return the +/// new value. Returns `None` if a Delete op removes the target — callers +/// should skip post-checks against a deleted entity rather than running +/// them against the stale prior state. +pub fn simulate_on(state: &Value, entity: &str, id: Uuid, ops: &[FieldOp]) -> Option { + let mut s: Option = Some(state.clone()); + for op in ops { + match op { + FieldOp::Set { path, value } if path.entity == entity && path.id == id => { + if let Some(Value::Object(map)) = s.as_mut() { + map.insert(path.field.clone(), value.clone()); + } + } + FieldOp::Create { + entity: e, + id: i, + data, + } if e == entity && *i == id => { + s = Some(data.clone()); + } + FieldOp::Delete { + entity: e, + id: i, + } if e == entity && *i == id => { + s = None; + } + _ => {} + } + } + s +} diff --git a/crates/modules/nakui/core/src/drift.rs b/crates/modules/nakui/core/src/drift.rs new file mode 100644 index 0000000..884ec6f --- /dev/null +++ b/crates/modules/nakui/core/src/drift.rs @@ -0,0 +1,496 @@ +//! Drift detection: compare two snapshots of store state and surface +//! the records that differ. +//! +//! "Drift" here means the live store has departed from what the log can +//! reproduce. The `Store::hash_state` contract makes the binary check +//! cheap (32 bytes); when those disagree, `compare_states` walks both +//! enumerations and produces a diff list the operator can act on. +//! +//! No IO in this module. The wire bits (asking a `nakui run` server for +//! its hash and records) live in the CLI; this is the pure comparison +//! used by both the CLI and any future automated drift-watcher. + +use serde::Serialize; +use serde_json::Value; +use std::collections::HashMap; +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::UnixStream; +use std::path::Path; +use thiserror::Error; +use uuid::Uuid; + +use crate::event_log::{EventLog, replay}; +use crate::store::Store; + +/// A single record-level difference between two snapshots. Variants are +/// labeled from the perspective of the operator running the check: the +/// "log" side is the canonical state (what the log replays to), the +/// "server" side is the live state being audited. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum DriftDiff { + /// Server has a record the log doesn't know about. Phantom data — + /// either an out-of-band write, or a successful op that never + /// reached the WAL (which would itself be a kernel bug). + OnlyOnServer { + entity: String, + id: Uuid, + value: Value, + }, + /// Log expects a record the server lost. Either the server's apply + /// rolled back without a reconcile, or someone deleted a record + /// out-of-band. + OnlyInLog { + entity: String, + id: Uuid, + value: Value, + }, + /// Same (entity, id) on both sides but the values differ — the most + /// dangerous case, because it means a logged event was overwritten + /// or a field was tampered with. + Tampered { + entity: String, + id: Uuid, + log_value: Value, + server_value: Value, + }, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DriftReport { + pub log_hash: [u8; 32], + pub server_hash: [u8; 32], + pub log_records: usize, + pub server_records: usize, + /// Empty iff the two snapshots are byte-identical. Sorted by + /// (entity, id_bytes) so two runs against the same drift produce + /// the same report. + pub diffs: Vec, +} + +impl DriftReport { + pub fn in_sync(&self) -> bool { + self.log_hash == self.server_hash && self.diffs.is_empty() + } +} + +/// Pure comparison: take two canonical-order enumerations (as returned +/// by `Store::iter`) plus their hashes, and return the diff list. +/// +/// Inputs need not be pre-sorted — we re-key by (entity, id) and walk +/// the union — but if the iterators were produced via `Store::iter`, +/// they're already in canonical order and the report's `diffs` will be +/// emitted in that same order. +pub fn compare_states( + log_records: Vec<(String, Uuid, Value)>, + log_hash: [u8; 32], + server_records: Vec<(String, Uuid, Value)>, + server_hash: [u8; 32], +) -> DriftReport { + let log_count = log_records.len(); + let server_count = server_records.len(); + + let mut log_map: HashMap<(String, Uuid), Value> = log_records + .into_iter() + .map(|(e, id, v)| ((e, id), v)) + .collect(); + let server_map: HashMap<(String, Uuid), Value> = server_records + .into_iter() + .map(|(e, id, v)| ((e, id), v)) + .collect(); + + let mut diffs: Vec = Vec::new(); + for ((entity, id), server_value) in &server_map { + match log_map.remove(&(entity.clone(), *id)) { + None => diffs.push(DriftDiff::OnlyOnServer { + entity: entity.clone(), + id: *id, + value: server_value.clone(), + }), + Some(log_value) => { + if log_value != *server_value { + diffs.push(DriftDiff::Tampered { + entity: entity.clone(), + id: *id, + log_value, + server_value: server_value.clone(), + }); + } + } + } + } + // Whatever is left in log_map is missing on the server. + for ((entity, id), value) in log_map { + diffs.push(DriftDiff::OnlyInLog { entity, id, value }); + } + + // Canonical sort: (entity, id_bytes), then by variant kind so + // diff ordering is fully deterministic even when the same key + // appears (which it can't here, but defensively). + diffs.sort_by(|a, b| { + let (ea, ia) = key(a); + let (eb, ib) = key(b); + ea.cmp(eb) + .then_with(|| ia.as_bytes().cmp(ib.as_bytes())) + .then_with(|| variant_order(a).cmp(&variant_order(b))) + }); + + DriftReport { + log_hash, + server_hash, + log_records: log_count, + server_records: server_count, + diffs, + } +} + +fn key(d: &DriftDiff) -> (&str, &Uuid) { + match d { + DriftDiff::OnlyOnServer { entity, id, .. } + | DriftDiff::OnlyInLog { entity, id, .. } + | DriftDiff::Tampered { entity, id, .. } => (entity.as_str(), id), + } +} + +fn variant_order(d: &DriftDiff) -> u8 { + match d { + DriftDiff::OnlyInLog { .. } => 0, + DriftDiff::Tampered { .. } => 1, + DriftDiff::OnlyOnServer { .. } => 2, + } +} + +#[derive(Debug, Error)] +pub enum DriftError { + #[error("open log: {0}")] + Log(#[from] crate::event_log::LogError), + #[error("replay log: {0}")] + Replay(#[from] crate::event_log::ReplayError), + #[error("store: {0}")] + Store(#[from] crate::store::StoreError), + #[error("connect to server socket: {0}")] + Connect(#[source] std::io::Error), + #[error("server io: {0}")] + Io(#[from] std::io::Error), + #[error("server response not json: {0}")] + Parse(#[from] serde_json::Error), + #[error("server returned error for `{op}`: {msg}")] + Server { op: String, msg: String }, + #[error("server response missing field `{field}` for op `{op}`")] + MissingField { op: String, field: String }, + #[error("server hash `{0}` is not 32 hex bytes")] + BadHash(String), +} + +/// Audit a live `nakui run` server against the canonical state derived +/// from a log file. +/// +/// Cheap path: ask the server for `hash_state`, replay the log locally, +/// hash that. If the hashes match, we return immediately with an empty +/// diff list — no large `dump_records` round-trip. +/// +/// Expensive path: hashes differ. Pull the full record dump from the +/// server, run `compare_states`, return the structured report. +pub fn check_against_socket( + log_path: &Path, + socket_path: &Path, +) -> Result { + // Local: replay log → MemoryStore, snapshot. + let log = EventLog::open(log_path)?; + let local_store = replay(&log)?; + let local_records: Vec<(String, Uuid, Value)> = local_store.iter()?.collect(); + let local_hash = local_store.hash_state()?; + + // Wire: open the connection once and reuse it for both requests. + let stream = UnixStream::connect(socket_path).map_err(DriftError::Connect)?; + let mut conn = SocketClient::new(stream)?; + + // Cheap path. + let hash_resp = conn.exchange(serde_json::json!({"op": "hash_state"}))?; + require_ok(&hash_resp, "hash_state")?; + let server_hash = parse_hash(&hash_resp, "hash_state")?; + let server_count = hash_resp + .get("records") + .and_then(Value::as_u64) + .ok_or_else(|| DriftError::MissingField { + op: "hash_state".into(), + field: "records".into(), + })? as usize; + + if server_hash == local_hash { + return Ok(DriftReport { + log_hash: local_hash, + server_hash, + log_records: local_records.len(), + server_records: server_count, + diffs: Vec::new(), + }); + } + + // Expensive path: pull the full server snapshot. + let dump_resp = conn.exchange(serde_json::json!({"op": "dump_records"}))?; + require_ok(&dump_resp, "dump_records")?; + let server_records = parse_records(&dump_resp)?; + + Ok(compare_states( + local_records, + local_hash, + server_records, + server_hash, + )) +} + +struct SocketClient { + writer: UnixStream, + reader: BufReader, +} + +impl SocketClient { + fn new(stream: UnixStream) -> Result { + let reader_stream = stream.try_clone()?; + Ok(Self { + writer: stream, + reader: BufReader::new(reader_stream), + }) + } + + fn exchange(&mut self, req: Value) -> Result { + let mut bytes = serde_json::to_vec(&req).expect("request serializes"); + bytes.push(b'\n'); + self.writer.write_all(&bytes)?; + let mut line = String::new(); + let n = self.reader.read_line(&mut line)?; + if n == 0 { + return Err(DriftError::Io(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "server closed connection without responding", + ))); + } + Ok(serde_json::from_str(line.trim())?) + } +} + +fn require_ok(resp: &Value, op: &str) -> Result<(), DriftError> { + if resp.get("ok").and_then(Value::as_bool) == Some(true) { + Ok(()) + } else { + Err(DriftError::Server { + op: op.into(), + msg: resp + .get("error") + .and_then(Value::as_str) + .unwrap_or("(no error message)") + .to_string(), + }) + } +} + +fn parse_hash(resp: &Value, op: &str) -> Result<[u8; 32], DriftError> { + let s = resp + .get("hash") + .and_then(Value::as_str) + .ok_or_else(|| DriftError::MissingField { + op: op.into(), + field: "hash".into(), + })?; + if s.len() != 64 { + return Err(DriftError::BadHash(s.into())); + } + let mut out = [0u8; 32]; + for (i, byte) in out.iter_mut().enumerate() { + let hi = hex_nibble(s.as_bytes()[i * 2]).ok_or_else(|| DriftError::BadHash(s.into()))?; + let lo = + hex_nibble(s.as_bytes()[i * 2 + 1]).ok_or_else(|| DriftError::BadHash(s.into()))?; + *byte = (hi << 4) | lo; + } + Ok(out) +} + +fn hex_nibble(c: u8) -> Option { + match c { + b'0'..=b'9' => Some(c - b'0'), + b'a'..=b'f' => Some(c - b'a' + 10), + b'A'..=b'F' => Some(c - b'A' + 10), + _ => None, + } +} + +fn parse_records(resp: &Value) -> Result, DriftError> { + let arr = resp + .get("records") + .and_then(Value::as_array) + .ok_or_else(|| DriftError::MissingField { + op: "dump_records".into(), + field: "records".into(), + })?; + let mut out: Vec<(String, Uuid, Value)> = Vec::with_capacity(arr.len()); + for item in arr { + let entity = item + .get("entity") + .and_then(Value::as_str) + .ok_or_else(|| DriftError::MissingField { + op: "dump_records".into(), + field: "records[].entity".into(), + })? + .to_string(); + let id_str = item + .get("id") + .and_then(Value::as_str) + .ok_or_else(|| DriftError::MissingField { + op: "dump_records".into(), + field: "records[].id".into(), + })?; + let id = Uuid::parse_str(id_str).map_err(|_| DriftError::MissingField { + op: "dump_records".into(), + field: format!("records[].id (not uuid: {})", id_str), + })?; + let value = item + .get("value") + .cloned() + .ok_or_else(|| DriftError::MissingField { + op: "dump_records".into(), + field: "records[].value".into(), + })?; + out.push((entity, id, value)); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn h(byte: u8) -> [u8; 32] { + [byte; 32] + } + + #[test] + fn empty_inputs_yield_no_diffs() { + let report = compare_states(Vec::new(), h(0), Vec::new(), h(0)); + assert!(report.in_sync()); + assert!(report.diffs.is_empty()); + } + + #[test] + fn equal_records_yield_no_diffs_even_if_hashes_were_lied_to() { + // The function compares records, not hashes — hash equality is + // the operator's fast-path, but the report's truth is the diffs. + let a = Uuid::new_v4(); + let log = vec![( + "Caja".to_string(), + a, + json!({"saldo": 100}), + )]; + let server = vec![( + "Caja".to_string(), + a, + json!({"saldo": 100}), + )]; + let report = compare_states(log, h(1), server, h(2)); + assert!(report.diffs.is_empty(), "records equal → no diffs"); + } + + #[test] + fn detects_only_on_server() { + let a = Uuid::new_v4(); + let b = Uuid::new_v4(); + let log = vec![( + "Caja".to_string(), + a, + json!({"saldo": 100}), + )]; + let server = vec![ + ("Caja".to_string(), a, json!({"saldo": 100})), + ("Caja".to_string(), b, json!({"saldo": 999})), + ]; + let report = compare_states(log, h(0), server, h(1)); + assert_eq!(report.diffs.len(), 1); + match &report.diffs[0] { + DriftDiff::OnlyOnServer { entity, id, .. } => { + assert_eq!(entity, "Caja"); + assert_eq!(*id, b); + } + other => panic!("expected OnlyOnServer, got {:?}", other), + } + } + + #[test] + fn detects_only_in_log() { + let a = Uuid::new_v4(); + let log = vec![("Caja".to_string(), a, json!({"saldo": 100}))]; + let server = vec![]; + let report = compare_states(log, h(0), server, h(1)); + assert_eq!(report.diffs.len(), 1); + match &report.diffs[0] { + DriftDiff::OnlyInLog { id, .. } => assert_eq!(*id, a), + other => panic!("expected OnlyInLog, got {:?}", other), + } + } + + #[test] + fn detects_tampered() { + let a = Uuid::new_v4(); + let log = vec![("Caja".to_string(), a, json!({"saldo": 100}))]; + let server = vec![("Caja".to_string(), a, json!({"saldo": 999}))]; + let report = compare_states(log, h(0), server, h(1)); + assert_eq!(report.diffs.len(), 1); + match &report.diffs[0] { + DriftDiff::Tampered { + id, + log_value, + server_value, + .. + } => { + assert_eq!(*id, a); + assert_eq!(log_value["saldo"], json!(100)); + assert_eq!(server_value["saldo"], json!(999)); + } + other => panic!("expected Tampered, got {:?}", other), + } + } + + #[test] + fn diffs_emerge_in_canonical_order() { + // Two entities, mixed drift kinds. Result must be sorted by + // (entity, id_bytes) so two runs produce the same report. + let id_caja = Uuid::nil(); // sorts first byte-wise + let id_mov = Uuid::from_u128(u128::MAX); + + let log = vec![ + ("Movimiento".to_string(), id_mov, json!({"x": 1})), + ]; + let server = vec![ + ("Caja".to_string(), id_caja, json!({"saldo": 0})), + ]; + let report = compare_states(log, h(0), server, h(1)); + assert_eq!(report.diffs.len(), 2); + // Caja sorts before Movimiento. + match (&report.diffs[0], &report.diffs[1]) { + (DriftDiff::OnlyOnServer { entity: e1, .. }, DriftDiff::OnlyInLog { entity: e2, .. }) => { + assert_eq!(e1, "Caja"); + assert_eq!(e2, "Movimiento"); + } + other => panic!("unexpected order: {:?}", other), + } + } + + #[test] + fn in_sync_requires_both_hashes_and_no_diffs() { + // Defensive: if hashes match but somehow diffs is non-empty + // (caller mismatch), in_sync says no. + let report = DriftReport { + log_hash: h(0), + server_hash: h(0), + log_records: 1, + server_records: 1, + diffs: vec![DriftDiff::Tampered { + entity: "x".into(), + id: Uuid::nil(), + log_value: json!(1), + server_value: json!(2), + }], + }; + assert!(!report.in_sync()); + } +} diff --git a/crates/modules/nakui/core/src/event_log.rs b/crates/modules/nakui/core/src/event_log.rs new file mode 100644 index 0000000..1a3d2f5 --- /dev/null +++ b/crates/modules/nakui/core/src/event_log.rs @@ -0,0 +1,687 @@ +//! Append-only event log for deterministic replay. +//! +//! Two entry kinds: +//! - `Seed`: an externally-provided initial record (the system boundary). +//! - `Morphism`: a successful kernel-validated morphism call, with the +//! produced ops attached. +//! +//! `replay()` reconstructs a store by reading the log and applying ops +//! directly — fast, no script execution. `verify_log()` re-runs every +//! morphism through the kernel and asserts the recomputed ops match the +//! logged ones, which is the operational definition of determinism. +//! +//! Failures are never logged: a rejected morphism produces no event. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::{BTreeMap, HashMap}; +use std::fs::OpenOptions; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use thiserror::Error; +use uuid::Uuid; + +use crate::delta::FieldOp; +use crate::executor::{ExecError, Executor}; +use crate::store::{MemoryStore, Store, StoreError}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum LogEntry { + Seed { + seq: u64, + entity: String, + id: Uuid, + data: Value, + /// Bundle hash (just the KCL schemas) at the moment this seed + /// was logged. `None` for pre-versioning entries — `verify_log` + /// skips the schema check on those. New writes always populate + /// it via `seed_and_log`. + #[serde(default, skip_serializing_if = "Option::is_none")] + schema_hash: Option<[u8; 32]>, + }, + Morphism { + seq: u64, + morphism: String, + inputs: BTreeMap, + params: Value, + ops: Vec, + /// Hash of (kcl bundle | manifest spec | rhai script bytes) at + /// the moment this event was logged. `None` for pre-versioning + /// entries — `verify_log` skips the schema check on those (they + /// predate the contract). New writes always populate it. + #[serde(default, skip_serializing_if = "Option::is_none")] + schema_hash: Option<[u8; 32]>, + }, +} + +impl LogEntry { + pub fn seq(&self) -> u64 { + match self { + LogEntry::Seed { seq, .. } => *seq, + LogEntry::Morphism { seq, .. } => *seq, + } + } +} + +#[derive(Debug, Error)] +pub enum LogError { + #[error("io: {0}")] + Io(#[from] std::io::Error), + #[error("parse at line {line}: {source}")] + Parse { + line: usize, + #[source] + source: serde_json::Error, + }, + #[error("non-monotonic seq: got {got}, expected {expected}")] + NonMonotonic { got: u64, expected: u64 }, +} + +/// Errors from `execute_and_log`. The variants distinguish *when in the +/// pipeline* the failure occurred — which determines whether the log was +/// updated and whether the live store is still consistent. +#[derive(Debug, Error)] +pub enum ExecuteError { + /// Failure before the log was written. Store untouched, log untouched. + /// Safe to retry with the same inputs. + #[error("pre-log validation failed: {0}")] + PreLog(#[from] ExecError), + + /// Log append failed (typically IO). Store untouched, log untouched. + /// Safe to retry once the log backend recovers. + #[error("log append failed: {0}")] + LogAppend(#[from] LogError), + + /// Apply to the store failed AFTER the event was logged. The log is + /// canonical; the live store is now stale and should be rebuilt by + /// replaying the log. Retrying the same morphism is incorrect — the + /// event is already on disk. + #[error("store apply failed after log was committed (log is canonical, store stale): {0}")] + PostLogStore(crate::store::StoreError), +} + +#[derive(Debug, Error)] +pub enum ReplayError { + #[error("log: {0}")] + Log(#[from] LogError), + #[error("store: {0}")] + Store(#[from] StoreError), +} + +/// A reconcile rebuilds a stale store from the log. Either the wipe step +/// or the replay step can fail. +#[derive(Debug, Error)] +pub enum ReconcileError { + #[error("clearing store before replay failed: {0}")] + Clear(#[source] StoreError), + #[error("replay into cleared store failed: {0}")] + Replay(#[from] ReplayError), +} + +/// Outcome of `execute_and_log_with_recovery`. PreLog/LogAppend mirror the +/// pre-WAL-fence variants of `ExecuteError` — the store is untouched and +/// the caller can retry. `Unrecoverable` means the WAL fence was crossed +/// (event is canonical on disk) but reconcile *also* failed: the operator +/// must intervene before any further writes. +#[derive(Debug, Error)] +pub enum RecoverableExecuteError { + #[error("pre-log validation failed: {0}")] + PreLog(#[from] ExecError), + #[error("log append failed: {0}")] + LogAppend(#[from] LogError), + #[error( + "store apply failed AND reconcile failed — log is canonical, store is in an unknown state. apply: {post_log}; reconcile: {reconcile}" + )] + Unrecoverable { + #[source] + post_log: StoreError, + reconcile: ReconcileError, + }, +} + +#[derive(Debug, Error)] +pub enum VerifyError { + #[error("log: {0}")] + Log(#[from] LogError), + #[error("morphism replay failed at seq {seq}: {source}")] + Exec { + seq: u64, + #[source] + source: ExecError, + }, + #[error( + "non-determinism at seq {seq} morphism `{morphism}`: recomputed ops differ from logged ops" + )] + OpsMismatch { + seq: u64, + morphism: String, + expected: Vec, + actual: Vec, + }, + /// The morphism was logged under a different schema/script bundle + /// than the one currently loaded. Re-executing it would (likely) + /// produce different ops, but the more specific signal is "the + /// rules changed since this was logged" — actionable: migrate the + /// log, or pin the executor to a compatible version. + #[error( + "schema mismatch at seq {seq} morphism `{morphism}`: logged schema_hash differs from current executor" + )] + SchemaMismatch { + seq: u64, + morphism: String, + logged: [u8; 32], + current: [u8; 32], + }, + /// A `Seed` entry was logged under a different KCL bundle than the + /// one currently loaded. The seed's data may no longer fit the + /// entity definition. Coarser than `SchemaMismatch` (any change + /// to any schema file flips it, even one that doesn't affect the + /// seeded entity) but the operator still wants to know. + #[error( + "seed schema mismatch at seq {seq} entity `{entity}` id {id}: logged bundle hash differs from current executor" + )] + SeedSchemaMismatch { + seq: u64, + entity: String, + id: Uuid, + logged: [u8; 32], + current: [u8; 32], + }, +} + +pub struct EventLog { + path: PathBuf, + next_seq: u64, +} + +impl EventLog { + /// Open or create a log at `path`. Reads existing entries to compute + /// `next_seq` and validate monotonicity. The first entry can start at + /// any seq (compacted logs are rooted at seq > 0); subsequent entries + /// must be strictly contiguous. + pub fn open(path: impl Into) -> Result { + let path = path.into(); + let mut next_seq: u64 = 0; + if path.exists() { + let entries = read_entries(&path)?; + let mut iter = entries.iter(); + if let Some(first) = iter.next() { + next_seq = first.seq() + 1; + for e in iter { + if e.seq() != next_seq { + return Err(LogError::NonMonotonic { + got: e.seq(), + expected: next_seq, + }); + } + next_seq = e.seq() + 1; + } + } + } + Ok(Self { path, next_seq }) + } + + pub fn next_seq(&self) -> u64 { + self.next_seq + } + + pub fn path(&self) -> &Path { + &self.path + } + + /// Append an entry. Calls `sync_all()` so the entry is durable on disk + /// before returning Ok — this is the WAL fence: by the time the caller + /// proceeds to mutate the store, the event is recoverable from a power + /// loss. + pub fn append(&mut self, entry: LogEntry) -> Result<(), LogError> { + if entry.seq() != self.next_seq { + return Err(LogError::NonMonotonic { + got: entry.seq(), + expected: self.next_seq, + }); + } + let mut f = OpenOptions::new() + .create(true) + .append(true) + .open(&self.path)?; + let s = serde_json::to_string(&entry).expect("LogEntry serializes"); + f.write_all(s.as_bytes())?; + f.write_all(b"\n")?; + f.sync_all()?; + self.next_seq += 1; + Ok(()) + } + + pub fn entries(&self) -> Result, LogError> { + if !self.path.exists() { + return Ok(Vec::new()); + } + read_entries(&self.path) + } + + /// Truncate the log to drop entries with `seq <= through_seq`. + /// IRREVERSIBLE: caller must verify a Snapshot covering `through_seq` + /// exists on durable storage before calling this — once the entries + /// are gone, replay can only start from the snapshot. + /// + /// Atomic at the filesystem level: writes survivors to a sibling + /// tempfile then renames over the original. + pub fn compact_through(&mut self, through_seq: u64) -> Result<(), LogError> { + let survivors: Vec = self + .entries()? + .into_iter() + .filter(|e| e.seq() > through_seq) + .collect(); + + let tmp = self.path.with_extension("compacting"); + { + let mut f = std::fs::File::create(&tmp)?; + for e in &survivors { + let s = serde_json::to_string(e).expect("LogEntry serializes"); + f.write_all(s.as_bytes())?; + f.write_all(b"\n")?; + } + f.sync_all()?; + } + std::fs::rename(&tmp, &self.path)?; + sync_parent_dir(&self.path)?; + Ok(()) + } +} + +/// Open and fsync the parent directory of `target`. After an atomic +/// rename, the directory entry change isn't durable until the directory +/// itself is fsynced — without this, a kernel/power crash between the +/// rename and the next disk flush could leave the directory in a state +/// where the rename never happened (depending on filesystem journal +/// mode). With it, the rename survives. +/// +/// Best-effort on platforms where opening a directory for sync isn't +/// permitted: the syscalls are POSIX-portable across Linux, macOS, and +/// the BSDs (the OSes Nakui targets), so this generally succeeds. A +/// failure here is propagated as an IO error so the caller can choose +/// to surface it; we prefer "loud" over "silent" for durability code. +fn sync_parent_dir(target: &Path) -> std::io::Result<()> { + let parent = target.parent().unwrap_or_else(|| Path::new(".")); + let dir = std::fs::File::open(parent)?; + dir.sync_all() +} + +/// A snapshot of a `Store`'s state at a particular log seq. Lets us short- +/// circuit replay: load the snapshot, then apply only the events with +/// `seq > snapshot.seq`. MemoryStore-specific for V1 — backends that +/// already persist (SurrealStore + RocksDB) don't need this layer. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Snapshot { + /// The last log seq this snapshot subsumes. `replay` resumes at seq+1. + pub seq: u64, + /// Full state at that seq, in MemoryStore's native shape. + pub records: HashMap>, + /// Module schema hash at capture time. `Some` for snapshots taken + /// via `capture(_, _, executor)`; `None` for those taken via the + /// hash-unaware `from_memory_store`. Loaders use this to refuse a + /// snapshot produced under a different bundle. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub schema_hash: Option<[u8; 32]>, +} + +#[derive(Debug, Error)] +pub enum SnapshotMismatchError { + #[error( + "snapshot schema_hash differs from current executor; refusing to load (snapshot was taken under a different module bundle)" + )] + SchemaMismatch { + snapshot: [u8; 32], + current: [u8; 32], + }, +} + +impl Snapshot { + /// Capture the in-memory store's current state without binding to a + /// schema bundle. Test fixtures and ad-hoc tooling call this; the + /// production path uses `capture` so the snapshot can be validated + /// against the executor on load. + pub fn from_memory_store(store: &MemoryStore, seq: u64) -> Self { + Self { + seq, + records: store.records().clone(), + schema_hash: None, + } + } + + /// Production capture: stamp the snapshot with the executor's + /// `module_schema_hash` so future loads can refuse a mismatch. + pub fn capture(store: &MemoryStore, seq: u64, executor: &Executor) -> Self { + Self { + seq, + records: store.records().clone(), + schema_hash: Some(executor.module_schema_hash()), + } + } + + /// Verify the snapshot was produced under a bundle compatible with + /// `executor`. Snapshots without a hash (legacy / `from_memory_store`) + /// pass — the operator opted out of this check at capture time. + pub fn ensure_compatible_with( + &self, + executor: &Executor, + ) -> Result<(), SnapshotMismatchError> { + let Some(snap_hash) = self.schema_hash else { + return Ok(()); + }; + let current = executor.module_schema_hash(); + if snap_hash != current { + return Err(SnapshotMismatchError::SchemaMismatch { + snapshot: snap_hash, + current, + }); + } + Ok(()) + } + + /// Atomically write the snapshot to `path`. Writes the bytes to a + /// sibling tempfile (`.writing`), fsyncs, renames over the + /// target, then fsyncs the parent directory so the rename survives + /// a crash. A crash mid-write leaves either the previous snapshot + /// at `path` (rename never happened) or the new one (rename + /// completed and was durable) — never a truncated file. A stale + /// tempfile from a prior crash gets overwritten by `File::create` + /// on the next attempt, so writes are also self-healing. + pub fn write(&self, path: &Path) -> Result<(), LogError> { + let data = serde_json::to_vec_pretty(self).expect("snapshot serializes"); + let tmp = path.with_extension("writing"); + { + let mut f = std::fs::File::create(&tmp)?; + f.write_all(&data)?; + f.sync_all()?; + } + std::fs::rename(&tmp, path)?; + sync_parent_dir(path).map_err(LogError::Io) + } + + pub fn load(path: &Path) -> Result, LogError> { + if !path.exists() { + return Ok(None); + } + let text = std::fs::read_to_string(path).map_err(LogError::Io)?; + let snap: Snapshot = serde_json::from_str(&text).map_err(|e| LogError::Parse { + line: 0, + source: e, + })?; + Ok(Some(snap)) + } +} + +fn read_entries(path: &Path) -> Result, LogError> { + let f = std::fs::File::open(path)?; + let r = BufReader::new(f); + let mut out = Vec::new(); + for (i, line) in r.lines().enumerate() { + let line = line?; + if line.trim().is_empty() { + continue; + } + let entry: LogEntry = serde_json::from_str(&line).map_err(|e| LogError::Parse { + line: i + 1, + source: e, + })?; + out.push(entry); + } + Ok(out) +} + +/// Seed an entity into the store and persist the event. +/// +/// WAL order: append to log *first*, then mutate the store. If the log +/// append fails, the store is untouched and the caller can safely retry. +/// `Store::seed` is infallible by trait contract — once the log entry is +/// durable the store update is guaranteed to land for in-memory backends. +/// For backends with fallible writes (network/disk), failures surface as +/// a panic during `seed()`; callers that need a fallible seed path should +/// wrap their own retry/reconcile loop. +pub fn seed_and_log( + executor: &Executor, + store: &mut S, + log: &mut EventLog, + entity: &str, + id: Uuid, + data: Value, +) -> Result<(), LogError> { + let seq = log.next_seq(); + log.append(LogEntry::Seed { + seq, + entity: entity.to_string(), + id, + data: data.clone(), + schema_hash: Some(executor.schema_bundle_hash), + })?; + store.seed(entity, id, data); + // Best-effort: a failure here means next startup does an extra full + // replay, never a correctness issue. + let _ = store.set_last_applied_seq(seq); + Ok(()) +} + +/// Run a morphism and persist the event in WAL order: +/// 1. compute() — pure, no mutation; full kernel validation incl. dry-run. +/// 2. log.append() — event hits disk *before* the store changes. +/// 3. store.apply() — materialize the change. By WAL semantics the log +/// is now the source of truth: if (3) fails, the stale store can be +/// rebuilt by replaying the log. +/// +/// The error variants tell the caller exactly which stage failed so they +/// know whether to retry, recover, or rebuild. +pub fn execute_and_log( + executor: &Executor, + store: &mut S, + log: &mut EventLog, + morphism: &str, + inputs: &[(&str, Uuid)], + params: Value, +) -> Result, ExecuteError> { + let ops = executor.compute(store, morphism, inputs, params.clone())?; + let seq = log.next_seq(); + let entry = LogEntry::Morphism { + seq, + morphism: morphism.to_string(), + inputs: inputs + .iter() + .map(|(r, id)| (r.to_string(), *id)) + .collect(), + params, + ops: ops.clone(), + schema_hash: executor.schema_hash(morphism), + }; + log.append(entry)?; + store.apply(&ops).map_err(ExecuteError::PostLogStore)?; + let _ = store.set_last_applied_seq(seq); + Ok(ops) +} + +/// Rebuild a (possibly stale) store from the log. Wipes the store, then +/// replays every event. Use this after a `PostLogStore` failure: the WAL +/// fence guarantees the log is the source of truth, so a clean replay +/// brings the store back into agreement with it. +/// +/// `execute_and_log_with_recovery` automates this for the common case; +/// reach for `reconcile` directly when an operator/CLI is doing the +/// recovery, or when a backend reports drift detected out-of-band. +pub fn reconcile(store: &mut S, log: &EventLog) -> Result<(), ReconcileError> { + store.clear().map_err(ReconcileError::Clear)?; + replay_into(log, store)?; + Ok(()) +} + +/// Like `execute_and_log`, but on `PostLogStore` automatically rebuilds +/// the store from the log and returns the ops as if the apply had +/// succeeded. The caller sees a consistent post-state — either the event +/// landed cleanly, or it landed via reconcile, or `Unrecoverable` (which +/// means even the rebuild failed and the store must not be trusted). +/// +/// PreLog and LogAppend are forwarded verbatim: the WAL fence wasn't +/// crossed, so there's nothing to reconcile. +pub fn execute_and_log_with_recovery( + executor: &Executor, + store: &mut S, + log: &mut EventLog, + morphism: &str, + inputs: &[(&str, Uuid)], + params: Value, +) -> Result, RecoverableExecuteError> { + let ops = executor.compute(store, morphism, inputs, params.clone())?; + let seq = log.next_seq(); + let entry = LogEntry::Morphism { + seq, + morphism: morphism.to_string(), + inputs: inputs + .iter() + .map(|(r, id)| (r.to_string(), *id)) + .collect(), + params, + ops: ops.clone(), + schema_hash: executor.schema_hash(morphism), + }; + log.append(entry)?; + if let Err(post_log) = store.apply(&ops) { + if let Err(reconcile) = reconcile(store, log) { + return Err(RecoverableExecuteError::Unrecoverable { + post_log, + reconcile, + }); + } + // After reconcile the store reflects log state up to log.next_seq()-1 + // (which equals our seq). The reconcile path itself updated the + // marker; nothing more to do here. + } else { + let _ = store.set_last_applied_seq(seq); + } + Ok(ops) +} + +/// Replay the log into a caller-provided `Store`. The store should be +/// empty on entry; existing records are not erased. Use this with any +/// `Store` impl (MemoryStore, SurrealStore, future backends). +pub fn replay_into(log: &EventLog, store: &mut S) -> Result<(), ReplayError> { + replay_with_snapshot_into(log, None, store) +} + +/// Replay starting from a snapshot. If `snapshot` is `Some`, every record +/// in it is seeded into `store` first, then events with `seq > snapshot.seq` +/// are applied. The point: replay cost shrinks from O(events) to +/// O(events_after_snapshot), useful when the log grows large. +pub fn replay_with_snapshot_into( + log: &EventLog, + snapshot: Option<&Snapshot>, + store: &mut S, +) -> Result<(), ReplayError> { + let start_seq = if let Some(snap) = snapshot { + for (entity, recs) in &snap.records { + for (id, data) in recs { + store.seed(entity, *id, data.clone()); + } + } + snap.seq + 1 + } else { + 0 + }; + + let mut last_applied: Option = snapshot.map(|s| s.seq); + for entry in log.entries()? { + if entry.seq() < start_seq { + continue; + } + let seq = entry.seq(); + match entry { + LogEntry::Seed { + entity, id, data, .. + } => store.seed(&entity, id, data), + LogEntry::Morphism { ops, .. } => store.apply(&ops)?, + } + last_applied = Some(seq); + } + if let Some(seq) = last_applied { + let _ = store.set_last_applied_seq(seq); + } + Ok(()) +} + +/// Convenience: replay into a fresh `MemoryStore`. The fast path: O(events) +/// with no Rhai execution. +pub fn replay(log: &EventLog) -> Result { + let mut store = MemoryStore::new(); + replay_into(log, &mut store)?; + Ok(store) +} + +/// Re-execute every logged morphism through the kernel and assert the +/// recomputed ops match the logged ops byte-for-byte. This is the +/// determinism contract: if it ever fails, a morphism became impure. +pub fn verify_log(log: &EventLog, executor: &Executor) -> Result<(), VerifyError> { + let mut store = MemoryStore::new(); + for entry in log.entries()? { + match entry { + LogEntry::Seed { + seq, + entity, + id, + data, + schema_hash, + } => { + if let Some(logged_hash) = schema_hash { + let current_hash = executor.schema_bundle_hash; + if logged_hash != current_hash { + return Err(VerifyError::SeedSchemaMismatch { + seq, + entity, + id, + logged: logged_hash, + current: current_hash, + }); + } + } + store.seed(&entity, id, data); + } + LogEntry::Morphism { + seq, + morphism, + inputs, + params, + ops: logged, + schema_hash, + } => { + // Schema check first: if the rules changed, re-execution + // is meaningless — it'd just surface as OpsMismatch with + // a less actionable message. Legacy entries with no + // hash predate the contract; we let those through. + if let Some(logged_hash) = schema_hash { + if let Some(current_hash) = executor.schema_hash(&morphism) { + if logged_hash != current_hash { + return Err(VerifyError::SchemaMismatch { + seq, + morphism, + logged: logged_hash, + current: current_hash, + }); + } + } + } + let owned: Vec<(String, Uuid)> = inputs.into_iter().collect(); + let refs: Vec<(&str, Uuid)> = + owned.iter().map(|(r, id)| (r.as_str(), *id)).collect(); + let recomputed = executor + .run(&mut store, &morphism, &refs, params) + .map_err(|e| VerifyError::Exec { seq, source: e })?; + if recomputed != logged { + return Err(VerifyError::OpsMismatch { + seq, + morphism, + expected: logged, + actual: recomputed, + }); + } + } + } + } + Ok(()) +} diff --git a/crates/modules/nakui/core/src/executor.rs b/crates/modules/nakui/core/src/executor.rs new file mode 100644 index 0000000..07627b7 --- /dev/null +++ b/crates/modules/nakui/core/src/executor.rs @@ -0,0 +1,667 @@ +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use thiserror::Error; +use uuid::Uuid; + +use crate::delta::{FieldOp, simulate_on}; +use crate::graph::{GraphError, ManifestGraph}; +use crate::kcl_wrapper::{self, KclError}; +use crate::manifest::{ConserveRule, Manifest, ManifestError, MorphismSpec, ValidationError}; +use crate::rhai_executor::{RhaiError, RhaiExecutor}; +use crate::store::{Store, StoreError}; + +#[derive(Debug, Error)] +pub enum ExecError { + #[error("morphism `{0}` not in manifest")] + UnknownMorphism(String), + #[error("missing input role `{role}` for morphism `{morphism}`")] + MissingInput { morphism: String, role: String }, + #[error("duplicate input id {id} bound to roles `{role_a}` and `{role_b}`")] + DuplicateInputId { + id: Uuid, + role_a: String, + role_b: String, + }, + #[error("entity `{0}` id `{1}` not found in store")] + EntityMissing(String, Uuid), + #[error( + "capability violation: morphism `{morphism}` produced op on `{token}` not in writes={declared:?}" + )] + CapabilityViolation { + morphism: String, + token: String, + declared: Vec, + }, + #[error( + "conservation violated: Σ Δ {entity}.{field} where {group_by} = {group:?} = {total} (expected 0)" + )] + ConservationViolation { + entity: String, + field: String, + group_by: String, + group: String, + total: i128, + }, + #[error("conservation rule {entity}.{field}: {message}")] + ConservationMalformed { + entity: String, + field: String, + message: String, + }, + #[error("kcl pre-check failed on `{role}` ({entity}): {source}")] + KclPre { + role: String, + entity: String, + #[source] + source: KclError, + }, + #[error("kcl post-check failed on `{role}` ({entity}): {source}")] + KclPost { + role: String, + entity: String, + #[source] + source: KclError, + }, + #[error("kcl post-check failed on created {entity} {id}: {source}")] + KclPostCreate { + entity: String, + id: Uuid, + #[source] + source: KclError, + }, + #[error("rhai: {0}")] + Rhai(#[from] RhaiError), + #[error("store: {0}")] + Store(#[from] StoreError), + #[error("manifest: {0}")] + Manifest(#[from] ManifestError), + #[error("manifest validation: {0}")] + ManifestValidation(#[from] ValidationError), + #[error("manifest graph: {0}")] + Graph(#[from] GraphError), + #[error("io: {0}")] + Io(#[from] std::io::Error), +} + +pub struct Executor { + pub manifest: Manifest, + pub graph: ManifestGraph, + pub module_dir: PathBuf, + pub schema_path: PathBuf, + pub rhai: RhaiExecutor, + /// `true` when `schema_path` is a tempfile bundle created by + /// `load_module`; Drop removes it. `false` for inline-built executors + /// that point at a real schema file owned by the caller (tests). + pub owned_bundle: bool, + /// Per-morphism `schema_hash`: SHA-256 of (kcl bundle + manifest spec + /// + rhai script bytes), computed once at load. The hash is the + /// determinism contract for KCL evolution — `verify_log` uses it to + /// reject logs whose entries were produced under different rules. + pub schema_hashes: HashMap, + /// Module-wide bundle hash: SHA-256 of just the KCL bundle bytes. + /// Stamped onto every `LogEntry::Seed` via `seed_and_log` so + /// `verify_log` can flag seeds whose entity schemas have evolved + /// since they were logged. Coarser than `schema_hashes` (any + /// schema.k edit moves it, even one that doesn't affect the seeded + /// entity) but cheap and conservative — false positives over false + /// negatives, like the morphism hash. + pub schema_bundle_hash: [u8; 32], +} + +impl Drop for Executor { + fn drop(&mut self) { + if self.owned_bundle { + let _ = std::fs::remove_file(&self.schema_path); + } + } +} + +/// One row of the bound-inputs map. Holds both `role` and `entity` so the +/// capability check can verify a Set's `path.entity` matches the role's +/// declared entity (catches uuid-collision and lazy scripts). +#[derive(Debug, Clone)] +struct InputBinding { + role: String, + entity: String, +} + +impl Executor { + pub fn load_module(module_dir: impl Into) -> Result { + let module_dir = module_dir.into(); + let manifest = Manifest::load(&module_dir.join("nsmc.json"))?; + manifest.validate(&module_dir)?; + let graph = ManifestGraph::build(&manifest)?; + let schema_path = build_schema_bundle(&module_dir, &manifest.effective_schemas())?; + + let schema_bundle_bytes = std::fs::read(&schema_path)?; + let schema_bundle_hash = compute_schema_bundle_hash(&schema_bundle_bytes); + let mut schema_hashes = HashMap::with_capacity(manifest.morphisms.len()); + for spec in &manifest.morphisms { + let script_path = module_dir.join(&spec.script); + let hash = compute_morphism_schema_hash(&schema_bundle_bytes, spec, &script_path)?; + schema_hashes.insert(spec.name.clone(), hash); + } + + Ok(Self { + manifest, + graph, + module_dir, + schema_path, + rhai: RhaiExecutor::new_sandboxed(), + owned_bundle: true, + schema_hashes, + schema_bundle_hash, + }) + } + + /// Hash for the named morphism in the currently loaded module. `None` + /// if no such morphism is declared. Used by `verify_log` to enforce + /// the schema-version contract. + pub fn schema_hash(&self, morphism: &str) -> Option<[u8; 32]> { + self.schema_hashes.get(morphism).copied() + } + + /// Single 32-byte hash representing the entire module's schema: + /// every morphism's hash, in canonical name order, framed and + /// chained. Snapshots pin this so a snapshot taken under bundle A + /// can be detected when later loaded against bundle B. + pub fn module_schema_hash(&self) -> [u8; 32] { + let mut entries: Vec<(&String, &[u8; 32])> = self.schema_hashes.iter().collect(); + entries.sort_by_key(|(name, _)| name.as_str().to_owned()); + let mut hasher = Sha256::new(); + hasher.update(b"nakui-module-v1\0"); + for (name, hash) in entries { + hasher.update((name.len() as u64).to_le_bytes()); + hasher.update(name.as_bytes()); + hasher.update(hash); + } + hasher.finalize().into() + } + + /// Compute the ops for a morphism without mutating the store. + /// + /// Pipeline: + /// 1. Resolve manifest spec; bind caller's role->id to spec inputs. + /// 2. Reject duplicate ids across roles. + /// 3. Load every input entity; KCL pre-check each. + /// 4. Run the Rhai script with `{ states, ids, params }`. + /// 5. Capability check: every Set targets a tracked id whose entity + /// matches the role's declared entity, and produces a `.` + /// token in `writes`; Create/Delete produce `` tokens. + /// 6. Delta-level invariants (conservation rules). + /// 7. Per-input KCL post-check (skipped for inputs that the ops Delete). + /// 8. KCL-validate every Created record against its entity schema. + /// 9. Pre-apply check: store.apply_dry_run guarantees apply will land. + /// + /// On `Ok`, the returned ops are *contractually applicable* — caller can + /// log first and then apply, knowing apply will succeed barring transient + /// backend faults. + pub fn compute( + &self, + store: &S, + morphism_name: &str, + inputs: &[(&str, Uuid)], + params: Value, + ) -> Result, ExecError> { + let spec: &MorphismSpec = self + .manifest + .morphism(morphism_name) + .ok_or_else(|| ExecError::UnknownMorphism(morphism_name.to_string()))?; + + // 1. Bind inputs. + let inputs_map: BTreeMap = inputs + .iter() + .map(|(role, id)| (role.to_string(), *id)) + .collect(); + for spec_in in &spec.inputs { + if !inputs_map.contains_key(&spec_in.role) { + return Err(ExecError::MissingInput { + morphism: morphism_name.to_string(), + role: spec_in.role.clone(), + }); + } + } + + // 2. Build id -> binding (role + entity), rejecting duplicates. + let mut id_to_input: HashMap = HashMap::new(); + for spec_in in &spec.inputs { + let id = inputs_map[&spec_in.role]; + if let Some(other) = id_to_input.get(&id) { + return Err(ExecError::DuplicateInputId { + id, + role_a: other.role.clone(), + role_b: spec_in.role.clone(), + }); + } + id_to_input.insert( + id, + InputBinding { + role: spec_in.role.clone(), + entity: spec_in.entity.clone(), + }, + ); + } + + // 3. Load + pre-check every input. + let mut loaded: BTreeMap = BTreeMap::new(); + let mut id_strings: BTreeMap = BTreeMap::new(); + for spec_in in &spec.inputs { + let id = inputs_map[&spec_in.role]; + let state = store + .load(&spec_in.entity, id) + .ok_or_else(|| ExecError::EntityMissing(spec_in.entity.clone(), id))?; + self.kcl_check(&spec_in.entity, &state) + .map_err(|e| ExecError::KclPre { + role: spec_in.role.clone(), + entity: spec_in.entity.clone(), + source: e, + })?; + loaded.insert(spec_in.role.clone(), state); + id_strings.insert(spec_in.role.clone(), id.to_string()); + } + + // 4. Rhai. + let script_path = self.module_dir.join(&spec.script); + let input = json!({ + "states": loaded, + "ids": id_strings, + "params": params, + }); + let ops = self.rhai.run(&script_path, input)?; + + // 5. Capability check. + let declared: HashSet<&str> = spec.writes.iter().map(String::as_str).collect(); + for op in &ops { + let token = match op { + FieldOp::Set { path, .. } => match id_to_input.get(&path.id) { + Some(binding) if binding.entity == path.entity => { + format!("{}.{}", binding.role, path.field) + } + Some(_) => { + return Err(ExecError::CapabilityViolation { + morphism: morphism_name.to_string(), + token: format!( + ".{}.{}", + path.entity, path.field + ), + declared: spec.writes.clone(), + }); + } + None => { + return Err(ExecError::CapabilityViolation { + morphism: morphism_name.to_string(), + token: format!(".{}.{}", path.entity, path.field), + declared: spec.writes.clone(), + }); + } + }, + FieldOp::Create { entity, .. } => entity.clone(), + FieldOp::Delete { entity, .. } => entity.clone(), + }; + if !declared.contains(token.as_str()) { + return Err(ExecError::CapabilityViolation { + morphism: morphism_name.to_string(), + token, + declared: spec.writes.clone(), + }); + } + } + + // 6. Conservation invariants. + for rule in &spec.invariants.conserve { + check_conservation(rule, &loaded, &id_to_input, &ops)?; + } + + // 7. Per-input KCL post-check; skip Deleted inputs. + for spec_in in &spec.inputs { + let id = inputs_map[&spec_in.role]; + if let Some(new_state) = + simulate_on(&loaded[&spec_in.role], &spec_in.entity, id, &ops) + { + self.kcl_check(&spec_in.entity, &new_state) + .map_err(|e| ExecError::KclPost { + role: spec_in.role.clone(), + entity: spec_in.entity.clone(), + source: e, + })?; + } + } + + // 8. Validate every Created record against its entity schema. + for op in &ops { + if let FieldOp::Create { entity, id, data } = op { + self.kcl_check(entity, data) + .map_err(|e| ExecError::KclPostCreate { + entity: entity.clone(), + id: *id, + source: e, + })?; + } + } + + // 9. Pre-apply check: structural compatibility with current store state. + store.apply_dry_run(&ops)?; + + Ok(ops) + } + + /// compute + apply, for callers that don't need event logging. + pub fn run( + &self, + store: &mut S, + morphism_name: &str, + inputs: &[(&str, Uuid)], + params: Value, + ) -> Result, ExecError> { + let ops = self.compute(store, morphism_name, inputs, params)?; + store.apply(&ops)?; + Ok(ops) + } + + fn kcl_check(&self, entity: &str, state: &Value) -> Result<(), KclError> { + let tmp = std::env::temp_dir().join(format!("nakui_{}_{}.json", entity, Uuid::new_v4())); + std::fs::write(&tmp, serde_json::to_vec(state).expect("state serializes")) + .map_err(KclError::Io)?; + let result = kcl_wrapper::vet(&self.schema_path, &tmp, entity); + let _ = std::fs::remove_file(&tmp); + result + } +} + +/// Concatenate every declared `.k` file into a single bundle on disk. +/// `kcl vet` only takes one schema arg, so cross-module modules (e.g. sales +/// referencing both treasury and inventory entities) bundle their imports +/// at load time. The bundle lives in `temp_dir` for the lifetime of the +/// executor; one file per Executor instance. +/// Module-wide hash of just the KCL bundle bytes. Stamped on +/// `LogEntry::Seed` entries (which don't run through any morphism, so +/// `compute_morphism_schema_hash` doesn't apply). Bumped by any byte +/// change in any schema file the manifest exposes — coarser than a +/// per-entity hash would be, but doesn't require KCL parsing. +fn compute_schema_bundle_hash(schema_bundle_bytes: &[u8]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(b"nakui-bundle-v1\0"); + hasher.update((schema_bundle_bytes.len() as u64).to_le_bytes()); + hasher.update(schema_bundle_bytes); + hasher.finalize().into() +} + +/// Per-morphism schema hash. SHA-256 with length-prefixed framing over +/// three inputs that together determine the morphism's deterministic +/// behaviour: the KCL schema bundle (entity shapes + invariants), the +/// manifest spec (writes, conserve, depends_on, etc.), and a +/// **normalized** form of the Rhai script — comments stripped and +/// whitespace runs collapsed, with string literals preserved exactly. +/// +/// The normalization makes the hash invariant to cosmetic edits (a +/// developer adding a `// TODO` doesn't invalidate the log) without +/// missing real behavioural changes. The framing tag is bumped to +/// `nakui-schema-v2` so logs hashed under v1 (raw bytes) cleanly fail +/// SchemaMismatch on upgrade rather than silently divergence. +fn compute_morphism_schema_hash( + schema_bundle_bytes: &[u8], + spec: &MorphismSpec, + script_path: &Path, +) -> std::io::Result<[u8; 32]> { + let script_bytes = std::fs::read(script_path)?; + let script_source = std::str::from_utf8(&script_bytes).map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("script {} is not valid UTF-8: {}", script_path.display(), e), + ) + })?; + let normalized_script = normalize_rhai_source(script_source); + let spec_json = serde_json::to_vec(spec).expect("MorphismSpec serializes"); + + let mut hasher = Sha256::new(); + hasher.update(b"nakui-schema-v2\0"); + hasher.update(b"schema:"); + hasher.update((schema_bundle_bytes.len() as u64).to_le_bytes()); + hasher.update(schema_bundle_bytes); + hasher.update(b"spec:"); + hasher.update((spec_json.len() as u64).to_le_bytes()); + hasher.update(&spec_json); + hasher.update(b"script:"); + hasher.update((normalized_script.len() as u64).to_le_bytes()); + hasher.update(normalized_script.as_bytes()); + Ok(hasher.finalize().into()) +} + +/// Strip line/block comments and collapse whitespace runs in a Rhai +/// source string. Preserves string literals exactly. Used to make the +/// schema hash invariant to cosmetic edits. +/// +/// Limitations: +/// - Doesn't handle backtick template literals (Rhai 1.x interp +/// strings). If the modules ever start using them, the normalizer +/// must be extended; until then it's not a concern for the +/// production scripts in `modules/`. +/// - Doesn't handle nested block comments — Rhai itself doesn't +/// either. +pub fn normalize_rhai_source(src: &str) -> String { + let mut out = String::with_capacity(src.len()); + let mut chars = src.chars().peekable(); + let mut prev_was_space = true; // strip leading whitespace + + while let Some(c) = chars.next() { + // Line comment: //...\n + if c == '/' && chars.peek() == Some(&'/') { + chars.next(); + while let Some(&n) = chars.peek() { + if n == '\n' { + break; + } + chars.next(); + } + continue; + } + // Block comment: /* ... */ + if c == '/' && chars.peek() == Some(&'*') { + chars.next(); + let mut prev = '\0'; + while let Some(n) = chars.next() { + if prev == '*' && n == '/' { + break; + } + prev = n; + } + continue; + } + // String literal: copy verbatim including escape sequences. + if c == '"' { + out.push('"'); + while let Some(n) = chars.next() { + if n == '\\' { + out.push('\\'); + if let Some(esc) = chars.next() { + out.push(esc); + } + } else if n == '"' { + out.push('"'); + break; + } else { + out.push(n); + } + } + prev_was_space = false; + continue; + } + // Whitespace run → single space (or nothing if at edge). + if c.is_whitespace() { + if !prev_was_space { + out.push(' '); + prev_was_space = true; + } + continue; + } + // Regular character. + out.push(c); + prev_was_space = false; + } + + if out.ends_with(' ') { + out.pop(); + } + out +} + +fn build_schema_bundle(module_dir: &std::path::Path, schemas: &[String]) -> std::io::Result { + let mut combined = String::new(); + for s in schemas { + let p = module_dir.join(s); + let content = std::fs::read_to_string(&p)?; + combined.push_str("# --- "); + combined.push_str(p.to_string_lossy().as_ref()); + combined.push_str(" ---\n"); + combined.push_str(&content); + combined.push_str("\n\n"); + } + let bundle = std::env::temp_dir().join(format!("nakui_schema_{}.k", Uuid::new_v4())); + std::fs::write(&bundle, combined)?; + Ok(bundle) +} + +fn check_conservation( + rule: &ConserveRule, + loaded: &BTreeMap, + id_to_input: &HashMap, + ops: &[FieldOp], +) -> Result<(), ExecError> { + let mut delta_by_group: HashMap = HashMap::new(); + + for op in ops { + if let FieldOp::Set { path, value } = op { + if path.entity != rule.entity || path.field != rule.field { + continue; + } + let binding = id_to_input + .get(&path.id) + .filter(|b| b.entity == path.entity) + .ok_or_else(|| ExecError::ConservationMalformed { + entity: rule.entity.clone(), + field: rule.field.clone(), + message: format!( + "Set on id {} with entity {} cannot be reconciled to a tracked input", + path.id, path.entity + ), + })?; + let old_state = &loaded[&binding.role]; + let old_val = + old_state + .get(&rule.field) + .and_then(Value::as_i64) + .ok_or_else(|| ExecError::ConservationMalformed { + entity: rule.entity.clone(), + field: rule.field.clone(), + message: format!("old value at role `{}` is not i64", binding.role), + })?; + let new_val = + value + .as_i64() + .ok_or_else(|| ExecError::ConservationMalformed { + entity: rule.entity.clone(), + field: rule.field.clone(), + message: format!("Set value at role `{}` is not i64", binding.role), + })?; + let group_key = match &rule.group_by { + Some(g) => old_state + .get(g) + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + None => String::new(), + }; + *delta_by_group.entry(group_key).or_insert(0) += + (new_val as i128) - (old_val as i128); + } + } + + for (group, total) in &delta_by_group { + if *total != 0 { + return Err(ExecError::ConservationViolation { + entity: rule.entity.clone(), + field: rule.field.clone(), + group_by: rule.group_by.clone().unwrap_or_else(|| "(global)".into()), + group: group.clone(), + total: *total, + }); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_strips_line_and_block_comments() { + let src = r#" +// header comment +let x = 1; // trailing +/* block + spans lines */ +let y = 2; +"#; + let normalized = normalize_rhai_source(src); + assert_eq!(normalized, "let x = 1; let y = 2;"); + } + + #[test] + fn normalize_collapses_whitespace_runs() { + let src = "let a =\t\t1;\n\n\n\nlet b = 2;"; + let normalized = normalize_rhai_source(src); + assert_eq!(normalized, "let a = 1; let b = 2;"); + } + + #[test] + fn normalize_preserves_strings_verbatim_including_double_spaces() { + // The double space, the // inside, and the escape are preserved + // exactly because they're inside a string literal — semantic + // content, not cosmetic. + let src = r#"let s = "hello // not a comment \"world\"";"#; + let normalized = normalize_rhai_source(src); + assert_eq!(normalized, r#"let s = "hello // not a comment \"world\"";"#); + } + + #[test] + fn normalize_is_idempotent() { + let src = "// a\nlet x = 1;\n"; + let n1 = normalize_rhai_source(src); + let n2 = normalize_rhai_source(&n1); + assert_eq!(n1, n2); + } + + #[test] + fn normalize_distinguishes_real_changes() { + // Adding a new statement is a non-cosmetic change — the + // normalized output must reflect it. + let a = "let x = 1;"; + let b = "let x = 1; let y = 2;"; + assert_ne!(normalize_rhai_source(a), normalize_rhai_source(b)); + + // Same for changing a literal value. + let c = "let x = 1;"; + let d = "let x = 2;"; + assert_ne!(normalize_rhai_source(c), normalize_rhai_source(d)); + } + + #[test] + fn normalize_handles_comment_at_end_without_newline() { + let src = "let x = 1; // no trailing newline"; + let normalized = normalize_rhai_source(src); + assert_eq!(normalized, "let x = 1;"); + } + + #[test] + fn normalize_handles_unterminated_block_comment() { + // Defensive: if someone writes `/* ...` and forgets to close, + // we don't infinite-loop or panic. The trailing content is + // discarded, which is fine — Rhai won't parse this either. + let src = "let x = 1; /* never ends"; + let normalized = normalize_rhai_source(src); + assert_eq!(normalized, "let x = 1;"); + } +} diff --git a/crates/modules/nakui/core/src/graph.rs b/crates/modules/nakui/core/src/graph.rs new file mode 100644 index 0000000..21dece5 --- /dev/null +++ b/crates/modules/nakui/core/src/graph.rs @@ -0,0 +1,277 @@ +//! Static dependency graph derived from a `Manifest`. +//! +//! Two graphs in one structure: +//! - **Explicit graph** (`depends_on`): morphism-to-morphism edges declared +//! by the manifest author. Cycles here are an error — the graph is built +//! with cycle detection. +//! - **Data-flow indexes** (`reads`/`writes`): inverted indexes from +//! canonical entity tokens (`"Caja.saldo"` or `"Movimiento"`) to the +//! morphisms that read or write them. Self-loops in data flow are +//! legal (a morphism that reads a field and updates it is normal). +//! +//! Tokens are normalized at build time: a manifest's role-prefixed tokens +//! (`"caja.saldo"`) become entity-prefixed (`"Caja.saldo"`) so cross-module +//! queries work uniformly. + +use petgraph::algo::tarjan_scc; +use petgraph::graph::{DiGraph, NodeIndex}; +use petgraph::visit::Topo; +use std::collections::{HashMap, HashSet}; +use thiserror::Error; + +use crate::manifest::Manifest; + +#[derive(Debug, Error)] +pub enum GraphError { + #[error("dependency cycle in `depends_on` involving morphisms {0:?}")] + Cycle(Vec), + #[error("morphism `{0}` referenced in depends_on but not declared in this manifest")] + UnknownMorphism(String), +} + +#[derive(Debug)] +pub struct ManifestGraph { + /// Explicit `depends_on` graph. Edge `a -> b` means: morphism `b` + /// depends on `a`, so `a` must be available before `b`. + explicit: DiGraph, + + /// Data-flow indexes. Token form: "Entity.field" or "Entity". + readers_of_token: HashMap>, + writers_of_token: HashMap>, + + /// Per-morphism canonicalized token sets. + morphism_reads: HashMap>, + morphism_writes: HashMap>, +} + +impl ManifestGraph { + pub fn build(manifest: &Manifest) -> Result { + let explicit = build_explicit(manifest)?; + if let Some(cycle) = find_cycle(&explicit) { + return Err(GraphError::Cycle(cycle)); + } + let (readers_of_token, writers_of_token, morphism_reads, morphism_writes) = + build_data_flow(manifest); + Ok(Self { + explicit, + readers_of_token, + writers_of_token, + morphism_reads, + morphism_writes, + }) + } + + /// Morphisms that read `token`. Token form: "Entity.field" or "Entity". + pub fn readers_of(&self, token: &str) -> &[String] { + self.readers_of_token + .get(token) + .map(|v| v.as_slice()) + .unwrap_or(&[]) + } + + /// Morphisms that write `token`. + pub fn writers_of(&self, token: &str) -> &[String] { + self.writers_of_token + .get(token) + .map(|v| v.as_slice()) + .unwrap_or(&[]) + } + + pub fn morphism_reads(&self, name: &str) -> &[String] { + self.morphism_reads + .get(name) + .map(|v| v.as_slice()) + .unwrap_or(&[]) + } + + pub fn morphism_writes(&self, name: &str) -> &[String] { + self.morphism_writes + .get(name) + .map(|v| v.as_slice()) + .unwrap_or(&[]) + } + + /// Morphisms whose `reads` overlap any of `name`'s `writes`. The + /// dirty-marking primitive: after `name` runs successfully, these are + /// the candidates whose derived state would be invalidated. The result + /// excludes `name` itself even if it reads what it writes. + pub fn affected_by(&self, name: &str) -> Vec { + let writes = match self.morphism_writes.get(name) { + Some(w) => w, + None => return Vec::new(), + }; + let mut affected: HashSet = HashSet::new(); + for token in writes { + if let Some(readers) = self.readers_of_token.get(token) { + for r in readers { + if r != name { + affected.insert(r.clone()); + } + } + } + } + let mut out: Vec<_> = affected.into_iter().collect(); + out.sort(); + out + } + + /// Topological order of the explicit dependency graph. If `a` is in + /// `b.depends_on`, `a` precedes `b` in the result. + pub fn topological_order(&self) -> Vec { + let mut topo = Topo::new(&self.explicit); + let mut out = Vec::new(); + while let Some(idx) = topo.next(&self.explicit) { + out.push(self.explicit[idx].clone()); + } + out + } +} + +fn build_explicit(manifest: &Manifest) -> Result, GraphError> { + let mut graph = DiGraph::new(); + let mut nodes: HashMap = HashMap::new(); + for m in &manifest.morphisms { + let idx = graph.add_node(m.name.clone()); + nodes.insert(m.name.clone(), idx); + } + for m in &manifest.morphisms { + let to = nodes[&m.name]; + for dep in &m.depends_on { + let from = *nodes + .get(dep) + .ok_or_else(|| GraphError::UnknownMorphism(dep.clone()))?; + graph.add_edge(from, to, ()); + } + } + Ok(graph) +} + +/// Returns one cycle's nodes (sorted) if the graph has any. Self-loops +/// are returned as `[name]`; multi-node SCCs as the SCC's nodes. +fn find_cycle(graph: &DiGraph) -> Option> { + for scc in tarjan_scc(graph) { + if scc.len() > 1 { + let mut names: Vec = scc.iter().map(|i| graph[*i].clone()).collect(); + names.sort(); + return Some(names); + } + if scc.len() == 1 && graph.find_edge(scc[0], scc[0]).is_some() { + return Some(vec![graph[scc[0]].clone()]); + } + } + None +} + +fn build_data_flow( + manifest: &Manifest, +) -> ( + HashMap>, + HashMap>, + HashMap>, + HashMap>, +) { + let mut readers: HashMap> = HashMap::new(); + let mut writers: HashMap> = HashMap::new(); + let mut m_reads: HashMap> = HashMap::new(); + let mut m_writes: HashMap> = HashMap::new(); + + for m in &manifest.morphisms { + let role_to_entity: HashMap<&str, &str> = m + .inputs + .iter() + .map(|i| (i.role.as_str(), i.entity.as_str())) + .collect(); + + // Dedupe per-morphism: `source.saldo` and `dest.saldo` both + // canonicalize to `Caja.saldo` — the morphism is one writer, not + // two. + let mut seen_reads: HashSet = HashSet::new(); + for r in &m.reads { + if let Some(token) = canonicalize_token(r, &role_to_entity) { + if seen_reads.insert(token.clone()) { + readers.entry(token.clone()).or_default().push(m.name.clone()); + m_reads.entry(m.name.clone()).or_default().push(token); + } + } + } + let mut seen_writes: HashSet = HashSet::new(); + for w in &m.writes { + if let Some(token) = canonicalize_token(w, &role_to_entity) { + if seen_writes.insert(token.clone()) { + writers.entry(token.clone()).or_default().push(m.name.clone()); + m_writes.entry(m.name.clone()).or_default().push(token); + } + } + } + } + + (readers, writers, m_reads, m_writes) +} + +/// "role.field" -> "Entity.field" via the inputs map; "Entity" -> "Entity". +fn canonicalize_token(t: &str, roles: &HashMap<&str, &str>) -> Option { + if let Some((role, field)) = t.split_once('.') { + roles + .get(role) + .map(|entity| format!("{}.{}", entity, field)) + } else { + Some(t.to_string()) + } +} + +/// Tracks which morphisms have stale derived state because some morphism +/// they read from was applied. Wire it next to your `execute_and_log` +/// loop: after a successful run, call `mark_dirty_after(morphism, &graph)`; +/// then any consumer (cached view, derived report, downstream pipeline) +/// queries `is_dirty(name)` before using its cached output. +/// +/// The tracker holds names only — it doesn't know what "recompute" means +/// for any particular morphism. That's deliberate: the kernel exposes the +/// invalidation primitive; what to do with the dirty set is the caller's. +#[derive(Debug, Default, Clone)] +pub struct DirtyTracker { + dirty: HashSet, +} + +impl DirtyTracker { + pub fn new() -> Self { + Self::default() + } + + /// After `morphism_name` runs successfully, mark every morphism in + /// `graph.affected_by(morphism_name)` as dirty. + pub fn mark_dirty_after(&mut self, morphism_name: &str, graph: &ManifestGraph) { + for affected in graph.affected_by(morphism_name) { + self.dirty.insert(affected); + } + } + + pub fn is_dirty(&self, morphism: &str) -> bool { + self.dirty.contains(morphism) + } + + /// Sorted list of dirty morphisms. Stable order for UI/telemetry. + pub fn dirty(&self) -> Vec { + let mut out: Vec = self.dirty.iter().cloned().collect(); + out.sort(); + out + } + + pub fn len(&self) -> usize { + self.dirty.len() + } + + pub fn is_empty(&self) -> bool { + self.dirty.is_empty() + } + + /// Clear the dirty flag for a specific morphism (call after the + /// caller has recomputed it). + pub fn clear(&mut self, morphism: &str) { + self.dirty.remove(morphism); + } + + pub fn clear_all(&mut self) { + self.dirty.clear(); + } +} diff --git a/crates/modules/nakui/core/src/kcl_wrapper.rs b/crates/modules/nakui/core/src/kcl_wrapper.rs new file mode 100644 index 0000000..94e890a --- /dev/null +++ b/crates/modules/nakui/core/src/kcl_wrapper.rs @@ -0,0 +1,43 @@ +use std::path::Path; +use std::process::Command; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum KclError { + #[error("kcl binary not found on PATH (install: https://kcl-lang.io)")] + BinaryMissing, + #[error("kcl validation failed:\n{0}")] + ValidationFailed(String), + #[error("io invoking kcl: {0}")] + Io(#[from] std::io::Error), +} + +/// Validate `state_path` (json) against a schema defined in `schema_path` (.k), +/// targeting the named schema. +pub fn vet(schema_path: &Path, state_path: &Path, schema_name: &str) -> Result<(), KclError> { + let out = match Command::new("kcl") + .arg("vet") + .arg(state_path) + .arg(schema_path) + .arg("-s") + .arg(schema_name) + .output() + { + Ok(o) => o, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(KclError::BinaryMissing), + Err(e) => return Err(e.into()), + }; + + if out.status.success() { + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + let msg = if stderr.trim().is_empty() { + stdout + } else { + stderr + }; + Err(KclError::ValidationFailed(msg)) + } +} diff --git a/crates/modules/nakui/core/src/lib.rs b/crates/modules/nakui/core/src/lib.rs new file mode 100644 index 0000000..e2353de --- /dev/null +++ b/crates/modules/nakui/core/src/lib.rs @@ -0,0 +1,11 @@ +pub mod delta; +pub mod drift; +pub mod event_log; +pub mod executor; +pub mod graph; +pub mod kcl_wrapper; +pub mod manifest; +pub mod rhai_executor; +pub mod run; +pub mod store; +pub mod surreal_store; diff --git a/crates/modules/nakui/core/src/manifest.rs b/crates/modules/nakui/core/src/manifest.rs new file mode 100644 index 0000000..dda56e6 --- /dev/null +++ b/crates/modules/nakui/core/src/manifest.rs @@ -0,0 +1,306 @@ +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::path::Path; +use thiserror::Error; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Manifest { + pub module: String, + /// Schema files that compose this module's KCL surface. Paths are + /// resolved relative to the module directory; cross-module references + /// use `"../other_module/schema.k"`. Defaults to `["schema.k"]` when + /// the field is absent — the single-file case. + #[serde(default)] + pub schemas: Vec, + pub morphisms: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MorphismSpec { + pub name: String, + pub inputs: Vec, + pub reads: Vec, + pub writes: Vec, + #[serde(default)] + pub invariants: Invariants, + #[serde(default)] + pub depends_on: Vec, + pub script: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MorphismInput { + pub role: String, + pub entity: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Invariants { + /// Sum-conservation rules. The total Δ of (entity, field) across the ops + /// produced by the morphism must be zero — optionally bucketed by another + /// field on the entity (e.g. group_by="currency" so USD and EUR are + /// independent ledgers). + #[serde(default)] + pub conserve: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConserveRule { + pub entity: String, + pub field: String, + #[serde(default)] + pub group_by: Option, +} + +#[derive(Debug, Error)] +pub enum ManifestError { + #[error("io reading manifest: {0}")] + Io(#[from] std::io::Error), + #[error("parsing manifest json: {0}")] + Parse(#[from] serde_json::Error), +} + +/// Errors raised by `Manifest::validate`. Each variant flags a specific +/// semantic issue caught before the kernel ever runs the module — these +/// are the contract between manifest authors (humans or AI) and Nakui. +#[derive(Debug, Error)] +pub enum ValidationError { + #[error("morphism name `{0}` declared more than once")] + DuplicateMorphism(String), + #[error("morphism `{morphism}`: input role `{role}` declared more than once")] + DuplicateRole { morphism: String, role: String }, + #[error( + "morphism `{morphism}`: input entity `{entity}` is not declared in any schema file (known: {known:?})" + )] + InputUnknownEntity { + morphism: String, + entity: String, + known: Vec, + }, + #[error( + "morphism `{morphism}`: writes token `{token}` references unknown role `{role}` (declared roles: {roles:?})" + )] + WritesUnknownRole { + morphism: String, + token: String, + role: String, + roles: Vec, + }, + #[error( + "morphism `{morphism}`: writes token `{token}` is not a declared role.field nor a known entity name" + )] + WritesUnknownEntity { morphism: String, token: String }, + #[error("morphism `{morphism}`: conserve rule references unknown entity `{entity}`")] + ConserveUnknownEntity { morphism: String, entity: String }, + #[error("morphism `{morphism}`: depends_on `{dep}` does not name a morphism in this manifest")] + DependsOnUnknown { morphism: String, dep: String }, + #[error("morphism `{morphism}`: script file `{script}` not found at {resolved}")] + ScriptMissing { + morphism: String, + script: String, + resolved: String, + }, + #[error("schema file `{path}` declared in manifest does not exist at {resolved}")] + SchemaFileMissing { path: String, resolved: String }, + #[error("schema name `{name}` is declared in multiple files: {files:?}")] + DuplicateSchema { name: String, files: Vec }, + #[error("io reading schema `{path}`: {source}")] + Io { + path: String, + #[source] + source: std::io::Error, + }, +} + +impl Manifest { + pub fn load(path: &Path) -> Result { + let text = std::fs::read_to_string(path)?; + let m: Self = serde_json::from_str(&text)?; + Ok(m) + } + + pub fn morphism(&self, name: &str) -> Option<&MorphismSpec> { + self.morphisms.iter().find(|m| m.name == name) + } + + /// Schema files this module exposes. Defaults to `["schema.k"]` when + /// the manifest doesn't declare any explicitly. + pub fn effective_schemas(&self) -> Vec { + if self.schemas.is_empty() { + vec!["schema.k".to_string()] + } else { + self.schemas.clone() + } + } + + /// Run all semantic checks. Catches author errors that would otherwise + /// surface as opaque runtime failures — misspelled entity names that + /// silently make conservation a no-op, role typos in writes that allow + /// any op through, unresolvable script paths, etc. + pub fn validate(&self, module_dir: &Path) -> Result<(), ValidationError> { + // 1. Resolve schemas: read each file, parse schema names, detect + // cross-file duplicates. Build the set of known entity names. + let mut entity_to_files: HashMap> = HashMap::new(); + for s in self.effective_schemas() { + let resolved = module_dir.join(&s); + if !resolved.exists() { + return Err(ValidationError::SchemaFileMissing { + path: s.clone(), + resolved: resolved.display().to_string(), + }); + } + let content = std::fs::read_to_string(&resolved).map_err(|e| { + ValidationError::Io { + path: s.clone(), + source: e, + } + })?; + for name in extract_schema_names(&content) { + entity_to_files.entry(name).or_default().push(s.clone()); + } + } + for (name, files) in &entity_to_files { + if files.len() > 1 { + return Err(ValidationError::DuplicateSchema { + name: name.clone(), + files: files.clone(), + }); + } + } + let known_entities: HashSet<&str> = + entity_to_files.keys().map(String::as_str).collect(); + + // 2. Manifest-level: morphism names must be unique. + let mut seen: HashSet<&str> = HashSet::new(); + for m in &self.morphisms { + if !seen.insert(m.name.as_str()) { + return Err(ValidationError::DuplicateMorphism(m.name.clone())); + } + } + let known_morphisms: HashSet<&str> = + self.morphisms.iter().map(|m| m.name.as_str()).collect(); + + // 3. Per-morphism checks. + for m in &self.morphisms { + let mut roles: HashSet<&str> = HashSet::new(); + for inp in &m.inputs { + if !roles.insert(inp.role.as_str()) { + return Err(ValidationError::DuplicateRole { + morphism: m.name.clone(), + role: inp.role.clone(), + }); + } + if !known_entities.contains(inp.entity.as_str()) { + return Err(ValidationError::InputUnknownEntity { + morphism: m.name.clone(), + entity: inp.entity.clone(), + known: sorted(&known_entities), + }); + } + } + + for token in &m.writes { + if let Some((role, _field)) = token.split_once('.') { + if !roles.contains(role) { + return Err(ValidationError::WritesUnknownRole { + morphism: m.name.clone(), + token: token.clone(), + role: role.to_string(), + roles: m.inputs.iter().map(|i| i.role.clone()).collect(), + }); + } + } else if !known_entities.contains(token.as_str()) { + return Err(ValidationError::WritesUnknownEntity { + morphism: m.name.clone(), + token: token.clone(), + }); + } + } + + for rule in &m.invariants.conserve { + if !known_entities.contains(rule.entity.as_str()) { + return Err(ValidationError::ConserveUnknownEntity { + morphism: m.name.clone(), + entity: rule.entity.clone(), + }); + } + } + + for dep in &m.depends_on { + if !known_morphisms.contains(dep.as_str()) { + return Err(ValidationError::DependsOnUnknown { + morphism: m.name.clone(), + dep: dep.clone(), + }); + } + } + + let script_resolved = module_dir.join(&m.script); + if !script_resolved.exists() { + return Err(ValidationError::ScriptMissing { + morphism: m.name.clone(), + script: m.script.clone(), + resolved: script_resolved.display().to_string(), + }); + } + } + + Ok(()) + } +} + +/// Cheap line-scan over a `.k` file to extract every `schema NAME` declared +/// at column 0 (top-level). Tolerates inheritance (`schema X(Y):`) and +/// generic params (`schema X[T]:`); ignores comments and string literals +/// because top-level KCL syntax doesn't admit them ambiguously. +fn extract_schema_names(content: &str) -> Vec { + let mut out = Vec::new(); + for line in content.lines() { + // Top-level declarations are not indented in idiomatic KCL. + if line.starts_with("schema ") { + let after = &line["schema ".len()..]; + let name: String = after + .chars() + .take_while(|c| c.is_alphanumeric() || *c == '_') + .collect(); + if !name.is_empty() { + out.push(name); + } + } + } + out +} + +fn sorted(set: &HashSet<&str>) -> Vec { + let mut v: Vec = set.iter().map(|s| s.to_string()).collect(); + v.sort(); + v +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extract_schema_names_handles_basic_forms() { + let content = r#" +schema Caja: + saldo: int + +schema Movimiento(Base): + monto: int + +# schema Comentario: +schema Generic[T]: + inner: T + +schema _Underscore: + x: int +"#; + let names = extract_schema_names(content); + assert_eq!( + names, + vec!["Caja", "Movimiento", "Generic", "_Underscore"] + ); + } +} diff --git a/crates/modules/nakui/core/src/rhai_executor.rs b/crates/modules/nakui/core/src/rhai_executor.rs new file mode 100644 index 0000000..be41439 --- /dev/null +++ b/crates/modules/nakui/core/src/rhai_executor.rs @@ -0,0 +1,103 @@ +use rhai::packages::{ + ArithmeticPackage, BasicArrayPackage, BasicIteratorPackage, BasicMapPackage, + BasicStringPackage, CorePackage, LogicPackage, Package, +}; +use rhai::{AST, Dynamic, Engine, Scope}; +use serde_json::Value; +use std::cell::RefCell; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use thiserror::Error; + +use crate::delta::FieldOp; + +#[derive(Debug, Error)] +pub enum RhaiError { + #[error("compile error: {0}")] + Compile(String), + #[error("runtime error: {0}")] + Runtime(String), + #[error("morphism returned non-array")] + BadDelta, + #[error("delta op malformed: {0}")] + BadOp(String), + #[error("io reading script: {0}")] + Io(#[from] std::io::Error), +} + +pub struct RhaiExecutor { + engine: Engine, + /// Compiled-AST cache keyed by absolute script path. Avoids reading + + /// reparsing on every call (verify_log re-runs every morphism in the + /// log; without the cache that becomes an O(events × parse) blowup). + asts: RefCell>>, +} + +impl RhaiExecutor { + /// Build a deterministic engine. Time, random, IO, debug/print are all + /// excluded by construction (we register packages by name, not the + /// StandardPackage bundle which would pull in BasicTimePackage). + pub fn new_sandboxed() -> Self { + let mut engine = Engine::new_raw(); + // Deliberately omitted: BasicTimePackage, EvalPackage, DebugPackage. + CorePackage::new().register_into_engine(&mut engine); + LogicPackage::new().register_into_engine(&mut engine); + ArithmeticPackage::new().register_into_engine(&mut engine); + BasicArrayPackage::new().register_into_engine(&mut engine); + BasicMapPackage::new().register_into_engine(&mut engine); + BasicStringPackage::new().register_into_engine(&mut engine); + BasicIteratorPackage::new().register_into_engine(&mut engine); + + engine.set_max_call_levels(64); + engine.set_max_expr_depths(64, 32); + Self { + engine, + asts: RefCell::new(HashMap::new()), + } + } + + pub fn run(&self, script_path: &Path, input: Value) -> Result, RhaiError> { + let ast = self.ast_for(script_path)?; + + let dyn_input: Dynamic = rhai::serde::to_dynamic(input) + .map_err(|e| RhaiError::Runtime(format!("input -> dynamic: {}", e)))?; + let mut scope = Scope::new(); + scope.push_dynamic("input", dyn_input); + + let result: Dynamic = self + .engine + .eval_ast_with_scope(&mut scope, &ast) + .map_err(|e| RhaiError::Runtime(e.to_string()))?; + + let arr = result.into_array().map_err(|_| RhaiError::BadDelta)?; + + let mut ops = Vec::with_capacity(arr.len()); + for item in arr { + let json: Value = rhai::serde::from_dynamic(&item) + .map_err(|e| RhaiError::BadOp(format!("dynamic -> json: {}", e)))?; + let op: FieldOp = serde_json::from_value(json) + .map_err(|e| RhaiError::BadOp(e.to_string()))?; + ops.push(op); + } + Ok(ops) + } + + /// Returns a cached compiled AST for `script_path`, compiling it on the + /// first call. Cache hits avoid filesystem IO and parse cost entirely. + fn ast_for(&self, script_path: &Path) -> Result, RhaiError> { + if let Some(ast) = self.asts.borrow().get(script_path) { + return Ok(Arc::clone(ast)); + } + let source = std::fs::read_to_string(script_path)?; + let compiled = self + .engine + .compile(&source) + .map_err(|e| RhaiError::Compile(e.to_string()))?; + let arc = Arc::new(compiled); + self.asts + .borrow_mut() + .insert(script_path.to_path_buf(), Arc::clone(&arc)); + Ok(arc) + } +} diff --git a/crates/modules/nakui/core/src/run.rs b/crates/modules/nakui/core/src/run.rs new file mode 100644 index 0000000..672b309 --- /dev/null +++ b/crates/modules/nakui/core/src/run.rs @@ -0,0 +1,352 @@ +//! `nakui run` server: a long-lived process that holds an in-memory store +//! reconstructed from the log, exposes a Unix Domain Socket, and serves +//! line-delimited JSON requests to drive the kernel. +//! +//! Why UDS + line-JSON for V1: +//! - Multi-client without committing to a transport (HTTP/NATS later). +//! - Filesystem permissions gate access; no port exposure. +//! - Self-describing: `describe` returns the manifest's morphism specs +//! so an agent (human or LLM) can drive the server without external +//! docs. +//! +//! Concurrency: one connection at a time. Backed by `&mut Store`, the +//! kernel is single-writer by design. Multiple clients queue in +//! `accept()`. If/when we want concurrency, the right unit to parallelize +//! is reads, not writes — that's a future refactor with locks at the +//! right granularity. +//! +//! Recovery: every `execute` goes through `execute_and_log_with_recovery` +//! so a transient apply failure auto-rebuilds the in-memory store from +//! the log without taking the server down. + +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::Path; + +use serde::Deserialize; +use serde_json::{Value, json}; +use thiserror::Error; +use uuid::Uuid; + +use crate::event_log::{ + EventLog, RecoverableExecuteError, ReplayError, Snapshot, SnapshotMismatchError, + execute_and_log_with_recovery, replay_with_snapshot_into, verify_log, +}; +use crate::executor::Executor; +use crate::store::Store; + +#[derive(Debug, Error)] +pub enum RunError { + #[error("io: {0}")] + Io(#[from] std::io::Error), + #[error("clear store on startup: {0}")] + Clear(#[source] crate::store::StoreError), + #[error("replay on startup: {0}")] + Replay(#[from] ReplayError), + #[error("log: {0}")] + Log(#[from] crate::event_log::LogError), + #[error("snapshot incompatible: {0}")] + SnapshotMismatch(#[from] SnapshotMismatchError), + #[error( + "snapshot/log gap: snapshot covers up to seq {snap_seq}, log's first remaining entry is seq {log_first_seq} (expected ≤ {expected})" + )] + SnapshotGap { + snap_seq: u64, + log_first_seq: u64, + expected: u64, + }, +} + +/// Run the server until a `shutdown` request is received or `accept` +/// returns an unrecoverable error. On exit, removes the socket file. +/// +/// Startup reconstruction: +/// - With `Some(snapshot)`: validate its `schema_hash` against the +/// executor, seed the store from the snapshot, replay only the log +/// tail (entries with `seq > snapshot.seq`). +/// - With `None`: full replay from seq 0. Slower for long logs. +/// +/// In both cases the store is wiped first, so the server never serves +/// requests against a state the log can't reproduce. This is true for +/// `MemoryStore` and for persistent backends like `SurrealStore` — +/// persistence is a durability property of the runtime cache, not a +/// way to skip replay. (A future "skip replay if last_applied_seq +/// matches" optimization would change that.) +pub fn run_server( + executor: Executor, + mut log: EventLog, + mut store: S, + snapshot: Option, + socket_path: &Path, +) -> Result<(), RunError> { + startup_replay(&executor, &log, &mut store, snapshot.as_ref())?; + + // Best-effort cleanup of stale sockets from a prior crashed run. + // Bind itself will fail if a live process is already listening. + let _ = std::fs::remove_file(socket_path); + let listener = UnixListener::bind(socket_path)?; + + let result = accept_loop(&listener, &executor, &mut store, &mut log); + let _ = std::fs::remove_file(socket_path); + result +} + +fn startup_replay( + executor: &Executor, + log: &EventLog, + store: &mut S, + snapshot: Option<&Snapshot>, +) -> Result<(), RunError> { + // Snapshot validation runs first (cheap) so a bad snapshot is caught + // even when we'd otherwise take the skip-replay fast path. + if let Some(snap) = snapshot { + snap.ensure_compatible_with(executor)?; + let entries = log.entries()?; + if let Some(first) = entries.first() { + let expected = snap.seq.saturating_add(1); + if first.seq() > expected { + return Err(RunError::SnapshotGap { + snap_seq: snap.seq, + log_first_seq: first.seq(), + expected, + }); + } + } + } + + // Fast path: persistent stores carry a `last_applied_seq` marker; + // when it matches the log's last seq, the store is verifiably in + // sync and we can skip the clear+replay entirely. Failures here + // (e.g. backend can't read meta) just fall through to full replay + // — never a correctness issue. + let log_last_seq = log.entries()?.last().map(|e| e.seq()); + if let Ok(applied) = store.last_applied_seq() { + if applied == log_last_seq && applied.is_some() { + return Ok(()); + } + } + + store.clear().map_err(RunError::Clear)?; + replay_with_snapshot_into(log, snapshot, store)?; + Ok(()) +} + +fn accept_loop( + listener: &UnixListener, + executor: &Executor, + store: &mut S, + log: &mut EventLog, +) -> Result<(), RunError> { + loop { + let (stream, _addr) = listener.accept()?; + let shutdown = handle_connection(stream, executor, store, log); + if shutdown { + return Ok(()); + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "op", rename_all = "snake_case")] +enum Request { + Execute { + morphism: String, + #[serde(default)] + inputs: std::collections::BTreeMap, + #[serde(default)] + params: Value, + }, + Load { + entity: String, + id: Uuid, + }, + Describe, + Verify, + /// Return the SHA-256 of the live store's full state plus a record + /// count. Used by the drift detector as the cheap fast-path check + /// before asking for the full record dump. + HashState, + /// Return every record on the server in canonical order. Used after + /// a hash mismatch to compute the per-record diff. Response can be + /// large — the operator opts into it. + DumpRecords, + Shutdown, +} + +/// Process one connection. Returns `true` if the client requested +/// shutdown — the caller should stop the accept loop after the response +/// has been flushed. +/// +/// IO errors on a single connection don't kill the server: we log to +/// stderr and move on. Only a request-level shutdown ends the loop. +fn handle_connection( + stream: UnixStream, + executor: &Executor, + store: &mut S, + log: &mut EventLog, +) -> bool { + let mut writer = match stream.try_clone() { + Ok(s) => s, + Err(e) => { + eprintln!("nakui run: clone stream: {}", e); + return false; + } + }; + let reader = BufReader::new(stream); + for line in reader.lines() { + let line = match line { + Ok(l) => l, + Err(e) => { + eprintln!("nakui run: read: {}", e); + return false; + } + }; + if line.trim().is_empty() { + continue; + } + let (response, shutdown) = dispatch(&line, executor, store, log); + let bytes = serde_json::to_vec(&response).expect("response serializes"); + if let Err(e) = writer.write_all(&bytes).and_then(|_| writer.write_all(b"\n")) { + eprintln!("nakui run: write: {}", e); + return false; + } + if shutdown { + let _ = writer.flush(); + return true; + } + } + false +} + +fn dispatch( + line: &str, + executor: &Executor, + store: &mut S, + log: &mut EventLog, +) -> (Value, bool) { + let req: Request = match serde_json::from_str(line) { + Ok(r) => r, + Err(e) => return (error_response(&format!("bad request: {}", e)), false), + }; + match req { + Request::Execute { + morphism, + inputs, + params, + } => { + let inputs_vec: Vec<(&str, Uuid)> = + inputs.iter().map(|(k, v)| (k.as_str(), *v)).collect(); + match execute_and_log_with_recovery( + executor, + store, + log, + &morphism, + &inputs_vec, + params, + ) { + Ok(ops) => ( + json!({ + "ok": true, + "seq": log.next_seq().saturating_sub(1), + "ops": ops, + "schema_hash": executor.schema_hash(&morphism).map(|h| hex_encode(&h)), + }), + false, + ), + Err(RecoverableExecuteError::PreLog(e)) => ( + json!({"ok": false, "stage": "pre_log", "error": e.to_string()}), + false, + ), + Err(RecoverableExecuteError::LogAppend(e)) => ( + json!({"ok": false, "stage": "log_append", "error": e.to_string()}), + false, + ), + Err(e @ RecoverableExecuteError::Unrecoverable { .. }) => ( + json!({"ok": false, "stage": "unrecoverable", "error": e.to_string()}), + false, + ), + } + } + Request::Load { entity, id } => { + let value = store.load(&entity, id); + (json!({"ok": true, "value": value}), false) + } + Request::Describe => { + let hashes: std::collections::BTreeMap = executor + .schema_hashes + .iter() + .map(|(k, v)| (k.clone(), hex_encode(v))) + .collect(); + ( + json!({ + "ok": true, + "protocol": 1, + "module": executor.manifest.module, + "schemas": executor.manifest.effective_schemas(), + "morphisms": executor.manifest.morphisms, + "schema_hashes": hashes, + }), + false, + ) + } + Request::Verify => match verify_log(log, executor) { + Ok(()) => { + let entries = log + .entries() + .map(|es| es.len()) + .unwrap_or(0); + (json!({"ok": true, "entries": entries}), false) + } + Err(e) => ( + json!({"ok": false, "error": e.to_string()}), + false, + ), + }, + Request::HashState => { + let records: Vec<_> = match store.iter() { + Ok(it) => it.collect(), + Err(e) => return (json!({"ok": false, "error": e.to_string()}), false), + }; + let count = records.len(); + let hash = match store.hash_state() { + Ok(h) => h, + Err(e) => return (json!({"ok": false, "error": e.to_string()}), false), + }; + ( + json!({ + "ok": true, + "hash": hex_encode(&hash), + "records": count, + }), + false, + ) + } + Request::DumpRecords => match store.iter() { + Ok(it) => { + let records: Vec = it + .map(|(entity, id, value)| { + json!({"entity": entity, "id": id, "value": value}) + }) + .collect(); + (json!({"ok": true, "records": records}), false) + } + Err(e) => (json!({"ok": false, "error": e.to_string()}), false), + }, + Request::Shutdown => (json!({"ok": true, "shutdown": true}), true), + } +} + +fn error_response(msg: &str) -> Value { + json!({"ok": false, "error": msg}) +} + +fn hex_encode(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for &b in bytes { + out.push(HEX[(b >> 4) as usize] as char); + out.push(HEX[(b & 0x0f) as usize] as char); + } + out +} + diff --git a/crates/modules/nakui/core/src/store.rs b/crates/modules/nakui/core/src/store.rs new file mode 100644 index 0000000..ef10b68 --- /dev/null +++ b/crates/modules/nakui/core/src/store.rs @@ -0,0 +1,593 @@ +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use thiserror::Error; +use uuid::Uuid; + +use crate::delta::FieldOp; + +#[derive(Debug, Clone, Error)] +pub enum StoreError { + #[error("entity {0} id {1} not found")] + NotFound(String, Uuid), + #[error("entity {0} id {1} already exists")] + Conflict(String, Uuid), + #[error("set on non-object record at {0} {1}")] + NotAnObject(String, Uuid), + /// Backend-specific transient or systemic failure (network, disk, + /// driver). Distinct from the data-shape errors above. + #[error("backend error: {0}")] + Backend(String), +} + +pub trait Store { + fn load(&self, entity: &str, id: Uuid) -> Option; + + /// Insert or replace a record without going through the morphism + /// pipeline. Represents external/boundary input — the source of + /// records that didn't originate from a kernel-validated event. + fn seed(&mut self, entity: &str, id: Uuid, data: Value); + + /// Read-only check: would `apply(ops)` succeed against current state? + /// Does NOT mutate. The kernel runs this as the last step of `compute` + /// so that, by the time we log an event, the apply is contractually + /// guaranteed to land. + fn apply_dry_run(&self, ops: &[FieldOp]) -> Result<(), StoreError>; + + fn apply(&mut self, ops: &[FieldOp]) -> Result<(), StoreError>; + + /// Drop every record. Used by `reconcile` to wipe a stale store before + /// replaying the log. Must leave the store in the same state it would + /// be in immediately after construction. Implementors that override + /// `last_applied_seq` must reset that marker here too — a cleared + /// store has applied nothing. + fn clear(&mut self) -> Result<(), StoreError>; + + /// The last log seq whose effects are reflected in this store, if + /// the store can persist that fact. Default `Ok(None)` covers + /// transient backends. The startup path uses this to skip the full + /// replay when the store is verifiably already in sync with the log. + fn last_applied_seq(&self) -> Result, StoreError> { + Ok(None) + } + + /// Persist the marker after a successful apply / seed / replay. + /// Best-effort: callers ignore failures here because a stale marker + /// only costs an extra full replay on next startup, never + /// correctness — full replay starts with `clear()`, so it tolerates + /// any prior state. Default impl is a no-op for transient backends. + fn set_last_applied_seq(&mut self, _seq: u64) -> Result<(), StoreError> { + Ok(()) + } + + /// Enumerate every record in canonical order: sorted first by entity + /// name, then by id bytes. The canonical order is what makes + /// `hash_state` reproducible — without it two stores with the same + /// records would hash differently depending on insertion order. + /// + /// Returns owned `Value`s. For an in-memory backend this clones; for + /// a remote backend it materializes a snapshot. V1 chooses simplicity + /// over streaming — the hash and drift-comparison use cases need to + /// see all records anyway, and an iterator over a Vec keeps the + /// trait method object-safe and free of async lifetime concerns. + fn iter(&self) -> Result + '_>, StoreError>; + + /// Deterministic SHA-256 of the store's full state. Two stores with + /// the same records (regardless of how they got there or which + /// backend they live in) produce the same hash; any drift produces + /// a different one. The default impl is the contract — backends + /// should only override it for backend-native acceleration (e.g. + /// server-side table digests), and an override must produce the + /// same bytes as the default. + /// + /// Framing per record: + /// entity_bytes | 0x00 | id_bytes | 0x00 | canonical_value_hash + /// The length prefix on entity/id prevents (entity="ab", id="c") + /// from colliding with (entity="a", id="bc"). The value bytes are + /// produced by `hash_value`, which walks the JSON tree with + /// type-tagged framing — that decouples the hash from + /// `serde_json::to_vec`'s representation choices (especially + /// integer-valued floats vs ints) so cross-backend comparison + /// works. + fn hash_state(&self) -> Result<[u8; 32], StoreError> { + let mut hasher = Sha256::new(); + for (entity, id, value) in self.iter()? { + hasher.update(entity.as_bytes()); + hasher.update([0u8]); + hasher.update(id.as_bytes()); + hasher.update([0u8]); + hash_value(&mut hasher, &value); + } + Ok(hasher.finalize().into()) + } +} + +/// Canonical hash of a `serde_json::Value`. Type-tagged so a string +/// "true" can't collide with the boolean `true`; length-prefixed so +/// concatenation can't shift bytes between fields. Numbers normalize: +/// any integer-valued number (i64, u64, or a finite f64 with no +/// fractional part) is hashed as an i128 — that's what makes +/// cross-backend equality work, since SurrealDB may round-trip +/// what the caller wrote as `100_i64` back as the same numeric value +/// without us needing to commit to a wire-format-specific +/// representation. +pub fn hash_value(hasher: &mut Sha256, v: &Value) { + match v { + Value::Null => hasher.update([TAG_NULL]), + Value::Bool(b) => { + hasher.update([TAG_BOOL]); + hasher.update([*b as u8]); + } + Value::Number(n) => { + if let Some(i) = n.as_i64() { + hash_int(hasher, i as i128); + } else if let Some(u) = n.as_u64() { + hash_int(hasher, u as i128); + } else if let Some(f) = n.as_f64() { + // Integer-valued floats canonicalize to int. Anything + // else (fractions, NaN, infinities) hashes as the raw + // f64 bit pattern — that's still deterministic, just + // not normalized. + if f.is_finite() + && f.fract() == 0.0 + && f >= I128_MIN_AS_F64 + && f <= I128_MAX_AS_F64 + { + hash_int(hasher, f as i128); + } else { + hasher.update([TAG_FLOAT]); + hasher.update(f.to_bits().to_le_bytes()); + } + } else { + // serde_json::Number guarantees one of the above; this + // branch only fires if a future variant appears. + hasher.update([TAG_FLOAT]); + hasher.update(f64::NAN.to_bits().to_le_bytes()); + } + } + Value::String(s) => { + hasher.update([TAG_STRING]); + hasher.update((s.len() as u64).to_le_bytes()); + hasher.update(s.as_bytes()); + } + Value::Array(arr) => { + hasher.update([TAG_ARRAY]); + hasher.update((arr.len() as u64).to_le_bytes()); + for item in arr { + hash_value(hasher, item); + } + } + Value::Object(map) => { + hasher.update([TAG_OBJECT]); + hasher.update((map.len() as u64).to_le_bytes()); + // serde_json::Map without `preserve_order` is BTreeMap + // (alphabetical). We sort defensively in case the build + // pulls in `preserve_order` transitively from a future dep. + let mut keys: Vec<&String> = map.keys().collect(); + keys.sort(); + for k in keys { + hasher.update((k.len() as u64).to_le_bytes()); + hasher.update(k.as_bytes()); + hash_value(hasher, &map[k]); + } + } + } +} + +fn hash_int(hasher: &mut Sha256, n: i128) { + hasher.update([TAG_INT]); + hasher.update(n.to_le_bytes()); +} + +const TAG_NULL: u8 = 0; +const TAG_BOOL: u8 = 1; +const TAG_INT: u8 = 2; +const TAG_FLOAT: u8 = 3; +const TAG_STRING: u8 = 4; +const TAG_ARRAY: u8 = 5; +const TAG_OBJECT: u8 = 6; + +// f64 can't represent i128::MAX exactly; the cast truncates upward to +// the next representable f64. Use those as the comparison bounds so +// `f as i128` stays well-defined. +const I128_MIN_AS_F64: f64 = -1.7014118346046923e38; +const I128_MAX_AS_F64: f64 = 1.7014118346046923e38; + +#[derive(Debug, Default, Clone, PartialEq)] +pub struct MemoryStore { + records: HashMap>, + /// Last log seq whose effects are reflected here. In-process only — + /// resets to `None` on construction or `clear`. The skip-replay + /// optimization in `nakui run` benefits the persistent backends; + /// for `MemoryStore` it's harmless bookkeeping (process restart = + /// new store = `None`, which forces full replay). + last_applied: Option, +} + +impl MemoryStore { + pub fn new() -> Self { + Self::default() + } + + /// Borrow the internal records map. Used by `Snapshot::from_memory_store` + /// to capture state for snapshot persistence. + pub fn records(&self) -> &HashMap> { + &self.records + } +} + +impl Store for MemoryStore { + fn load(&self, entity: &str, id: Uuid) -> Option { + self.records.get(entity)?.get(&id).cloned() + } + + fn seed(&mut self, entity: &str, id: Uuid, data: Value) { + self.records + .entry(entity.to_string()) + .or_default() + .insert(id, data); + } + + fn apply_dry_run(&self, ops: &[FieldOp]) -> Result<(), StoreError> { + for op in ops { + match op { + FieldOp::Set { path, .. } => { + match self.records.get(&path.entity).and_then(|m| m.get(&path.id)) { + None => { + return Err(StoreError::NotFound(path.entity.clone(), path.id)); + } + Some(Value::Object(_)) => {} + Some(_) => { + return Err(StoreError::NotAnObject(path.entity.clone(), path.id)); + } + } + } + FieldOp::Create { entity, id, .. } => { + if self + .records + .get(entity) + .and_then(|m| m.get(id)) + .is_some() + { + return Err(StoreError::Conflict(entity.clone(), *id)); + } + } + FieldOp::Delete { entity, id } => { + if self + .records + .get(entity) + .and_then(|m| m.get(id)) + .is_none() + { + return Err(StoreError::NotFound(entity.clone(), *id)); + } + } + } + } + Ok(()) + } + + fn apply(&mut self, ops: &[FieldOp]) -> Result<(), StoreError> { + self.apply_dry_run(ops)?; + for op in ops { + match op { + FieldOp::Set { path, value } => { + let rec = self + .records + .get_mut(&path.entity) + .and_then(|m| m.get_mut(&path.id)) + .expect("validated by dry_run"); + let map = match rec { + Value::Object(m) => m, + _ => unreachable!("dry_run guards against non-object"), + }; + map.insert(path.field.clone(), value.clone()); + } + FieldOp::Create { entity, id, data } => { + self.records + .entry(entity.clone()) + .or_default() + .insert(*id, data.clone()); + } + FieldOp::Delete { entity, id } => { + self.records + .get_mut(entity) + .expect("validated by dry_run") + .remove(id); + } + } + } + Ok(()) + } + + fn clear(&mut self) -> Result<(), StoreError> { + self.records.clear(); + self.last_applied = None; + Ok(()) + } + + fn last_applied_seq(&self) -> Result, StoreError> { + Ok(self.last_applied) + } + + fn set_last_applied_seq(&mut self, seq: u64) -> Result<(), StoreError> { + self.last_applied = Some(seq); + Ok(()) + } + + fn iter(&self) -> Result + '_>, StoreError> { + let mut out: Vec<(String, Uuid, Value)> = self + .records + .iter() + .flat_map(|(entity, m)| { + m.iter() + .map(move |(id, v)| (entity.clone(), *id, v.clone())) + }) + .collect(); + out.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.as_bytes().cmp(b.1.as_bytes()))); + Ok(Box::new(out.into_iter())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::delta::{FieldOp, FieldPath}; + use serde_json::json; + + #[test] + fn dry_run_rejects_set_on_non_object() { + let mut store = MemoryStore::new(); + let id = Uuid::new_v4(); + store.seed("Caja", id, json!(42)); // not an object + let op = FieldOp::Set { + path: FieldPath { + entity: "Caja".into(), + id, + field: "saldo".into(), + }, + value: json!(100), + }; + match store.apply_dry_run(&[op.clone()]) { + Err(StoreError::NotAnObject(e, i)) => { + assert_eq!(e, "Caja"); + assert_eq!(i, id); + } + other => panic!("expected NotAnObject, got {:?}", other), + } + // apply must reject too without panicking. + assert!(matches!( + store.apply(&[op]), + Err(StoreError::NotAnObject(_, _)) + )); + } + + #[test] + fn dry_run_rejects_create_conflict() { + let mut store = MemoryStore::new(); + let id = Uuid::new_v4(); + store.seed("Caja", id, json!({"id": id.to_string()})); + let op = FieldOp::Create { + entity: "Caja".into(), + id, + data: json!({"id": id.to_string()}), + }; + assert!(matches!( + store.apply_dry_run(&[op]), + Err(StoreError::Conflict(_, _)) + )); + } + + #[test] + fn dry_run_passes_for_valid_set() { + let mut store = MemoryStore::new(); + let id = Uuid::new_v4(); + store.seed("Caja", id, json!({"saldo": 100, "currency": "USD"})); + let op = FieldOp::Set { + path: FieldPath { + entity: "Caja".into(), + id, + field: "saldo".into(), + }, + value: json!(150), + }; + assert!(store.apply_dry_run(&[op]).is_ok()); + } + + #[test] + fn iter_returns_canonical_order_regardless_of_insertion() { + let a = Uuid::new_v4(); + let b = Uuid::new_v4(); + let c = Uuid::new_v4(); + + let mut s1 = MemoryStore::new(); + s1.seed("Caja", a, json!({"id": a.to_string(), "x": 1})); + s1.seed("Movimiento", c, json!({"id": c.to_string(), "y": 3})); + s1.seed("Caja", b, json!({"id": b.to_string(), "x": 2})); + + let mut s2 = MemoryStore::new(); + s2.seed("Movimiento", c, json!({"id": c.to_string(), "y": 3})); + s2.seed("Caja", b, json!({"id": b.to_string(), "x": 2})); + s2.seed("Caja", a, json!({"id": a.to_string(), "x": 1})); + + let r1: Vec<_> = s1.iter().unwrap().collect(); + let r2: Vec<_> = s2.iter().unwrap().collect(); + assert_eq!(r1, r2, "iter order must be insertion-independent"); + + // Entities lexicographically sorted (Caja before Movimiento). + let entities: Vec<&str> = r1.iter().map(|(e, _, _)| e.as_str()).collect(); + assert_eq!(entities, vec!["Caja", "Caja", "Movimiento"]); + + // Within Caja, ids in byte order. + let caja_ids: Vec = r1 + .iter() + .filter(|(e, _, _)| e == "Caja") + .map(|(_, id, _)| *id) + .collect(); + let mut expected = vec![a, b]; + expected.sort_by(|x, y| x.as_bytes().cmp(y.as_bytes())); + assert_eq!(caja_ids, expected); + } + + #[test] + fn hash_state_is_deterministic_and_independent_of_insertion_order() { + let a = Uuid::new_v4(); + let b = Uuid::new_v4(); + + let mut s1 = MemoryStore::new(); + s1.seed("Caja", a, json!({"id": a.to_string(), "saldo": 100})); + s1.seed("Caja", b, json!({"id": b.to_string(), "saldo": 200})); + + let mut s2 = MemoryStore::new(); + s2.seed("Caja", b, json!({"id": b.to_string(), "saldo": 200})); + s2.seed("Caja", a, json!({"id": a.to_string(), "saldo": 100})); + + assert_eq!( + s1.hash_state().unwrap(), + s2.hash_state().unwrap(), + "equal state must hash identically regardless of how it was built" + ); + } + + #[test] + fn hash_state_changes_when_state_changes() { + let a = Uuid::new_v4(); + + let mut s1 = MemoryStore::new(); + s1.seed("Caja", a, json!({"id": a.to_string(), "saldo": 100})); + + let mut s2 = MemoryStore::new(); + s2.seed("Caja", a, json!({"id": a.to_string(), "saldo": 101})); + + assert_ne!( + s1.hash_state().unwrap(), + s2.hash_state().unwrap(), + "off-by-one in a single field must produce a different hash" + ); + + // Adding a record changes the hash too. + let mut s3 = MemoryStore::new(); + s3.seed("Caja", a, json!({"id": a.to_string(), "saldo": 100})); + s3.seed("Caja", Uuid::new_v4(), json!({"id": "extra", "saldo": 0})); + assert_ne!(s1.hash_state().unwrap(), s3.hash_state().unwrap()); + } + + #[test] + fn last_applied_seq_round_trips_and_resets_on_clear() { + let mut store = MemoryStore::new(); + assert_eq!( + store.last_applied_seq().unwrap(), + None, + "fresh MemoryStore has no marker" + ); + store.set_last_applied_seq(5).unwrap(); + assert_eq!(store.last_applied_seq().unwrap(), Some(5)); + store.set_last_applied_seq(12).unwrap(); + assert_eq!(store.last_applied_seq().unwrap(), Some(12)); + store.clear().unwrap(); + assert_eq!( + store.last_applied_seq().unwrap(), + None, + "clear() resets the marker — a cleared store has applied nothing" + ); + } + + #[test] + fn integer_and_integer_valued_float_hash_identically() { + // The cross-backend property: the same numeric value, written + // by a backend as i64 vs read back as integer-valued f64, + // must hash the same. + let int_value = json!({"saldo": 100_i64}); + let float_value = json!({"saldo": 100.0_f64}); + + let mut h_int = sha2::Sha256::new(); + super::hash_value(&mut h_int, &int_value); + let mut h_float = sha2::Sha256::new(); + super::hash_value(&mut h_float, &float_value); + assert_eq!( + h_int.finalize(), + h_float.finalize(), + "integer-valued numbers must canonicalize regardless of source representation" + ); + } + + #[test] + fn fractional_floats_do_not_canonicalize_to_int() { + // Floats with fractional parts must remain floats — collapsing + // 100.5 into 100 would hide real differences. + let int_value = json!({"x": 100_i64}); + let frac_value = json!({"x": 100.5_f64}); + + let mut h_int = sha2::Sha256::new(); + super::hash_value(&mut h_int, &int_value); + let mut h_frac = sha2::Sha256::new(); + super::hash_value(&mut h_frac, &frac_value); + assert_ne!( + h_int.finalize(), + h_frac.finalize(), + "100 and 100.5 must hash differently" + ); + } + + #[test] + fn same_object_with_different_insertion_order_hashes_same() { + // serde_json::Map is BTreeMap by default but we sort defensively + // in case `preserve_order` is enabled by some transitive dep. + let mut m1 = serde_json::Map::new(); + m1.insert("a".into(), json!(1)); + m1.insert("b".into(), json!(2)); + m1.insert("c".into(), json!(3)); + let mut m2 = serde_json::Map::new(); + m2.insert("c".into(), json!(3)); + m2.insert("a".into(), json!(1)); + m2.insert("b".into(), json!(2)); + + let mut h1 = sha2::Sha256::new(); + super::hash_value(&mut h1, &Value::Object(m1)); + let mut h2 = sha2::Sha256::new(); + super::hash_value(&mut h2, &Value::Object(m2)); + assert_eq!(h1.finalize(), h2.finalize()); + } + + #[test] + fn type_tagged_framing_distinguishes_string_from_number() { + // The string "42" must not collide with the number 42. + let str_v = json!("42"); + let num_v = json!(42); + let mut h_str = sha2::Sha256::new(); + super::hash_value(&mut h_str, &str_v); + let mut h_num = sha2::Sha256::new(); + super::hash_value(&mut h_num, &num_v); + assert_ne!(h_str.finalize(), h_num.finalize()); + + // Bool true must not collide with the number 1. + let bool_v = json!(true); + let one_v = json!(1); + let mut h_bool = sha2::Sha256::new(); + super::hash_value(&mut h_bool, &bool_v); + let mut h_one = sha2::Sha256::new(); + super::hash_value(&mut h_one, &one_v); + assert_ne!(h_bool.finalize(), h_one.finalize()); + } + + #[test] + fn empty_store_has_a_well_defined_hash() { + let s1 = MemoryStore::new(); + let s2 = MemoryStore::new(); + assert_eq!(s1.hash_state().unwrap(), s2.hash_state().unwrap()); + // The empty hash is the SHA-256 of an empty input — fix the + // expected bytes so an accidental framing change in `hash_state` + // can't silently sail through. + let expected = hex_decode( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + ); + assert_eq!(s1.hash_state().unwrap().to_vec(), expected); + } + + fn hex_decode(s: &str) -> Vec { + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("hex")) + .collect() + } +} diff --git a/crates/modules/nakui/core/src/surreal_store.rs b/crates/modules/nakui/core/src/surreal_store.rs new file mode 100644 index 0000000..c233e6e --- /dev/null +++ b/crates/modules/nakui/core/src/surreal_store.rs @@ -0,0 +1,403 @@ +//! SurrealDB-backed `Store` implementation. +//! +//! Wraps an embedded `kv-mem` SurrealDB instance behind the same sync +//! `Store` trait the kernel uses. Each instance owns a private `tokio` +//! current-thread runtime and `block_on`s every async call. +//! +//! Why everything goes through `db.query()`: +//! SurrealDB 2.x's typed-response API (`db.upsert(thing).content(data)`) +//! deserializes responses through a serializer that is hostile to +//! `serde_json::Value` and to dynamic record shapes. Using raw SurrealQL +//! with parameter binding sidesteps that — `Response::check()` validates +//! success without forcing us to materialize the response into a typed +//! shape. +//! +//! Identity handling: SurrealDB owns record identity via a `RecordId` +//! (table:id). We strip the application-level `id` field before sending +//! and restore it on read so KCL schemas (which require `id: str`) see +//! a stable shape. + +use serde_json::Value; +use surrealdb::Surreal; +use surrealdb::engine::local::{Db, Mem}; +#[cfg(feature = "persistent")] +use surrealdb::engine::local::SurrealKv; +use thiserror::Error; +use tokio::runtime::Runtime; +use uuid::Uuid; + +use crate::delta::FieldOp; +use crate::store::{Store, StoreError}; + +/// Reserved table prefix for runtime metadata that lives alongside user +/// records. Anything starting with this prefix is hidden from `iter` +/// (and therefore from `hash_state`, `dump_records`, drift detection) +/// so user-facing views never see internal bookkeeping. +const META_TABLE_PREFIX: &str = "nakui_"; + +/// Single-record table where `last_applied_seq` lives. Singleton id = +/// `singleton`. Wiped by `clear()` because the table prefix is part of +/// the enumeration there — a cleared store has applied nothing. +const META_TABLE: &str = "nakui_runtime_meta"; +const META_SINGLETON_ID: &str = "singleton"; + +/// Field alias used by `iter` to surface the application-level record +/// id alongside the rest of the row, in a single per-table query. The +/// alias is stripped before the row is handed back to the caller, so +/// it never shows up in user views. Reserved — a user record with a +/// field of this name would collide and `iter` would error on UUID +/// parse failure. +const ITER_ID_ALIAS: &str = "__nakui_app_id"; + +#[derive(Debug, Error)] +pub enum SurrealStoreError { + #[error("io creating tokio runtime: {0}")] + Runtime(#[from] std::io::Error), + #[error("surrealdb: {0}")] + Backend(#[from] surrealdb::Error), +} + +pub struct SurrealStore { + runtime: Runtime, + db: Surreal, +} + +impl SurrealStore { + /// Build an in-memory SurrealDB instance (`kv-mem`). Volatile — + /// nothing persists when the process exits. + pub fn new_in_memory() -> Result { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + let db = runtime.block_on(async { + let db = Surreal::new::(()).await?; + db.use_ns("nakui").use_db("default").await?; + Ok::<_, surrealdb::Error>(db) + })?; + Ok(Self { runtime, db }) + } + + /// Build a SurrealKV-backed SurrealDB instance at `path`. Records + /// survive process restarts. Requires the `persistent` Cargo feature. + /// + /// Reopening an existing path resumes from the persisted state — the + /// canonical use is `let store = SurrealStore::new_persistent(path)?` + /// at process startup, with the path stable across runs. + #[cfg(feature = "persistent")] + pub fn new_persistent( + path: impl AsRef, + ) -> Result { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + let path = path.as_ref().to_path_buf(); + let db = runtime.block_on(async { + let db = Surreal::new::(path).await?; + db.use_ns("nakui").use_db("default").await?; + Ok::<_, surrealdb::Error>(db) + })?; + Ok(Self { runtime, db }) + } +} + +fn strip_app_id(mut data: Value) -> Value { + if let Value::Object(map) = &mut data { + map.remove("id"); + } + data +} + +fn restore_app_id(mut data: Value, id: Uuid) -> Value { + if let Value::Object(map) = &mut data { + map.insert("id".into(), Value::String(id.to_string())); + } + data +} + +fn json_to_map(v: Value) -> Result, StoreError> { + match v { + Value::Object(map) => Ok(map), + _ => Err(StoreError::Backend( + "SurrealStore expects object-shaped records".into(), + )), + } +} + +fn map_err(e: surrealdb::Error) -> StoreError { + StoreError::Backend(e.to_string()) +} + +impl Store for SurrealStore { + fn load(&self, entity: &str, id: Uuid) -> Option { + let entity = entity.to_string(); + let id_str = id.to_string(); + self.runtime.block_on(async { + // `OMIT id` skips SurrealDB's Thing-typed id which serde_json::Value + // can't represent; we restore the application id ourselves. + let mut response = self + .db + .query("SELECT * OMIT id FROM type::thing($table, $id)") + .bind(("table", entity)) + .bind(("id", id_str)) + .await + .ok()?; + let rows: Vec = response.take(0).ok()?; + let row = rows.into_iter().next()?; + Some(restore_app_id(row, id)) + }) + } + + fn seed(&mut self, entity: &str, id: Uuid, data: Value) { + let stripped = strip_app_id(data); + let map = json_to_map(stripped).expect("seed data is object-shaped"); + let entity = entity.to_string(); + let id_str = id.to_string(); + self.runtime.block_on(async { + self.db + .query("UPSERT type::thing($table, $id) CONTENT $data") + .bind(("table", entity)) + .bind(("id", id_str)) + .bind(("data", map)) + .await + .and_then(|r| r.check()) + .expect("seed upsert"); + }); + } + + fn apply_dry_run(&self, ops: &[FieldOp]) -> Result<(), StoreError> { + self.runtime.block_on(async { + for op in ops { + match op { + FieldOp::Set { path, .. } => { + let exists = self.exists(&path.entity, path.id).await?; + if !exists { + return Err(StoreError::NotFound( + path.entity.clone(), + path.id, + )); + } + // We don't model NotAnObject for SurrealStore: every + // record stored via this trait is map-shaped by + // construction (json_to_map enforces it on write). + } + FieldOp::Create { entity, id, .. } => { + if self.exists(entity, *id).await? { + return Err(StoreError::Conflict(entity.clone(), *id)); + } + } + FieldOp::Delete { entity, id } => { + if !self.exists(entity, *id).await? { + return Err(StoreError::NotFound(entity.clone(), *id)); + } + } + } + } + Ok(()) + }) + } + + fn iter(&self) -> Result + '_>, StoreError> { + // One query per table: pull the application id alongside every + // other field via an alias, strip the SurrealDB-typed `id` via + // OMIT, then restore the application `id` field in code so the + // output is byte-identical to what `load()` produces (cross- + // backend hash equality and the `iter ↔ load` parity contract + // both depend on this). + // + // Filters runtime metadata tables (META_TABLE_PREFIX) so client + // views never leak internal bookkeeping. + self.runtime.block_on(async { + let mut info = self + .db + .query("INFO FOR DB") + .await + .and_then(|r| r.check()) + .map_err(map_err)?; + let row: Option = info.take(0).map_err(map_err)?; + let tables: Vec = row + .as_ref() + .and_then(|v| v.get("tables")) + .and_then(|v| v.as_object()) + .map(|m| { + m.keys() + .filter(|k| !k.starts_with(META_TABLE_PREFIX)) + .cloned() + .collect() + }) + .unwrap_or_default(); + + let mut out: Vec<(String, Uuid, Value)> = Vec::new(); + for table in &tables { + // The alias is parameterised in the SELECT clause so the + // SurrealQL parser sees a literal field name; we can't + // bind it as a parameter (only values bind, not + // identifiers), but it's a compile-time constant so + // there's no injection surface. + let select = format!( + "SELECT meta::id(id) AS {alias}, * OMIT id FROM type::table($t)", + alias = ITER_ID_ALIAS, + ); + let mut resp = self + .db + .query(&select) + .bind(("t", table.clone())) + .await + .and_then(|r| r.check()) + .map_err(map_err)?; + let rows: Vec = resp.take(0).map_err(map_err)?; + for row in rows { + let Value::Object(mut map) = row else { + return Err(StoreError::Backend(format!( + "row in table {} is not an object", + table + ))); + }; + let app_id_str = match map.remove(ITER_ID_ALIAS) { + Some(Value::String(s)) => s, + _ => { + return Err(StoreError::Backend(format!( + "row in table {} missing alias `{}`", + table, ITER_ID_ALIAS + ))); + } + }; + let id = Uuid::parse_str(&app_id_str).map_err(|e| { + StoreError::Backend(format!( + "non-uuid id in table {}: {} ({})", + table, app_id_str, e + )) + })?; + // Match `restore_app_id`: insert the application id + // back as a regular `id: ` field. Callers + // reading the row see exactly what `load()` returns. + map.insert("id".into(), Value::String(app_id_str)); + out.push((table.clone(), id, Value::Object(map))); + } + } + out.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.as_bytes().cmp(b.1.as_bytes()))); + Ok(Box::new(out.into_iter()) + as Box>) + }) + } + + fn clear(&mut self) -> Result<(), StoreError> { + // Wipes EVERY table including the runtime meta table — a + // cleared store must report `last_applied_seq() == None`. + self.runtime.block_on(async { + let mut info = self + .db + .query("INFO FOR DB") + .await + .and_then(|r| r.check()) + .map_err(map_err)?; + let row: Option = info.take(0).map_err(map_err)?; + let tables = row + .as_ref() + .and_then(|v| v.get("tables")) + .and_then(|v| v.as_object()); + let names: Vec = match tables { + Some(map) => map.keys().cloned().collect(), + None => Vec::new(), + }; + for name in names { + self.db + .query("DELETE FROM type::table($t)") + .bind(("t", name)) + .await + .and_then(|r| r.check()) + .map_err(map_err)?; + } + Ok(()) + }) + } + + fn last_applied_seq(&self) -> Result, StoreError> { + self.runtime.block_on(async { + let mut resp = self + .db + .query("SELECT VALUE last_applied_seq FROM type::thing($t, $id)") + .bind(("t", META_TABLE)) + .bind(("id", META_SINGLETON_ID)) + .await + .and_then(|r| r.check()) + .map_err(map_err)?; + // The query yields either zero rows (no meta record yet) or + // one row containing the i64 value. + let rows: Vec = resp.take(0).map_err(map_err)?; + Ok(rows.into_iter().next().map(|v| v as u64)) + }) + } + + fn set_last_applied_seq(&mut self, seq: u64) -> Result<(), StoreError> { + let seq_signed = seq as i64; + self.runtime.block_on(async { + self.db + .query("UPSERT type::thing($t, $id) CONTENT { last_applied_seq: $seq }") + .bind(("t", META_TABLE)) + .bind(("id", META_SINGLETON_ID)) + .bind(("seq", seq_signed)) + .await + .and_then(|r| r.check()) + .map_err(map_err)?; + Ok(()) + }) + } + + fn apply(&mut self, ops: &[FieldOp]) -> Result<(), StoreError> { + self.apply_dry_run(ops)?; + self.runtime.block_on(async { + for op in ops { + match op { + FieldOp::Set { path, value } => { + let mut patch = serde_json::Map::new(); + patch.insert(path.field.clone(), value.clone()); + self.db + .query("UPDATE type::thing($table, $id) MERGE $patch") + .bind(("table", path.entity.clone())) + .bind(("id", path.id.to_string())) + .bind(("patch", patch)) + .await + .and_then(|r| r.check()) + .map_err(map_err)?; + } + FieldOp::Create { entity, id, data } => { + let stripped = strip_app_id(data.clone()); + let map = json_to_map(stripped)?; + self.db + .query("CREATE type::thing($table, $id) CONTENT $data") + .bind(("table", entity.clone())) + .bind(("id", id.to_string())) + .bind(("data", map)) + .await + .and_then(|r| r.check()) + .map_err(map_err)?; + } + FieldOp::Delete { entity, id } => { + self.db + .query("DELETE type::thing($table, $id)") + .bind(("table", entity.clone())) + .bind(("id", id.to_string())) + .await + .and_then(|r| r.check()) + .map_err(map_err)?; + } + } + } + Ok(()) + }) + } +} + +impl SurrealStore { + async fn exists(&self, entity: &str, id: Uuid) -> Result { + let mut response = self + .db + .query("SELECT * OMIT id FROM type::thing($table, $id)") + .bind(("table", entity.to_string())) + .bind(("id", id.to_string())) + .await + .map_err(map_err)?; + let rows: Vec = response.take(0).map_err(map_err)?; + Ok(!rows.is_empty()) + } +} diff --git a/crates/modules/nakui/core/tests/drift.rs b/crates/modules/nakui/core/tests/drift.rs new file mode 100644 index 0000000..5c0c10a --- /dev/null +++ b/crates/modules/nakui/core/tests/drift.rs @@ -0,0 +1,258 @@ +//! End-to-end drift detector: spin up `run_server` against log A, run +//! `check_against_socket` first against the same log (in-sync) and then +//! against a divergent log B (drift detected, with the expected diff +//! list). +//! +//! Same threading inversion as `tests/run.rs`: server on main thread +//! (Executor is `!Send`), client on a worker thread. + +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::UnixStream; +use std::path::{Path, PathBuf}; +use std::thread; + +use nakui_core::drift::{DriftDiff, check_against_socket}; +use nakui_core::event_log::{EventLog, execute_and_log, seed_and_log}; +use nakui_core::executor::Executor; +use nakui_core::run::run_server; +use nakui_core::store::MemoryStore; +use serde_json::json; +use uuid::Uuid; + +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root above core/") + .to_path_buf() +} + +fn treasury_module() -> PathBuf { + workspace_root().join("modules/treasury") +} + +fn fresh_log_path() -> PathBuf { + std::env::temp_dir().join(format!("nakui_drift_log_{}.jsonl", Uuid::new_v4())) +} + +fn fresh_socket_path() -> PathBuf { + std::env::temp_dir().join(format!("nakui_drift_{}.sock", Uuid::new_v4())) +} + +/// Build a real WAL-formed log: two cajas seeded + one deposit. +fn build_log_a(path: &Path, caja_a: Uuid, caja_b: Uuid) { + let executor = Executor::load_module(treasury_module()).expect("load"); + let mut log = EventLog::open(path).expect("open log"); + let mut store = MemoryStore::new(); + seed_and_log( + &executor, + &mut store, + &mut log, + "Caja", + caja_a, + json!({"id": caja_a.to_string(), "name": "A", "saldo": 100_000_i64, "currency": "USD"}), + ) + .unwrap(); + seed_and_log( + &executor, + &mut store, + &mut log, + "Caja", + caja_b, + json!({"id": caja_b.to_string(), "name": "B", "saldo": 50_000_i64, "currency": "USD"}), + ) + .unwrap(); + execute_and_log( + &executor, + &mut store, + &mut log, + "register_cash_move", + &[("caja", caja_a)], + json!({ + "monto": 5_000_i64, + "tipo": "in", + "timestamp": "2026-05-04T10:00:00Z", + "memo": "x", + "movimiento_id": Uuid::new_v4().to_string(), + }), + ) + .unwrap(); +} + +/// Build a divergent log: only caja_a seeded, no deposit, no caja_b. +/// Replaying B produces a different state than the server (which used A). +fn build_log_b(path: &Path, caja_a: Uuid) { + let executor = Executor::load_module(treasury_module()).expect("load"); + let mut log = EventLog::open(path).expect("open log b"); + let mut store = MemoryStore::new(); + seed_and_log( + &executor, + &mut store, + &mut log, + "Caja", + caja_a, + json!({"id": caja_a.to_string(), "name": "A", "saldo": 100_000_i64, "currency": "USD"}), + ) + .unwrap(); +} + +/// Wait for the socket to exist and be connectable, then return a +/// connected stream. Used by helpers that send raw requests bypassing +/// `check_against_socket` (e.g. shutdown). +fn connect_with_retry(path: &Path) -> UnixStream { + for _ in 0..100 { + if let Ok(s) = UnixStream::connect(path) { + return s; + } + thread::sleep(std::time::Duration::from_millis(20)); + } + panic!("server never started accepting on {}", path.display()); +} + +fn send_shutdown(socket_path: &Path) { + let mut stream = connect_with_retry(socket_path); + stream.write_all(b"{\"op\":\"shutdown\"}\n").unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); +} + +#[test] +fn drift_check_reports_in_sync_when_log_matches_server() { + let log_path = fresh_log_path(); + let socket_path = fresh_socket_path(); + + let caja_a = Uuid::new_v4(); + let caja_b = Uuid::new_v4(); + build_log_a(&log_path, caja_a, caja_b); + + let executor = Executor::load_module(treasury_module()).expect("load"); + let log = EventLog::open(&log_path).expect("reopen"); + let store = MemoryStore::new(); + + let socket_for_client = socket_path.clone(); + let log_for_client = log_path.clone(); + let client = thread::spawn(move || -> Result<(), String> { + let report = check_against_socket(&log_for_client, &socket_for_client) + .map_err(|e| format!("check failed: {}", e))?; + if !report.in_sync() { + return Err(format!( + "expected in_sync, got {} diffs: {:?}", + report.diffs.len(), + report.diffs + )); + } + if report.log_hash != report.server_hash { + return Err("hashes diverged with empty diff — invariant broken".into()); + } + if report.log_records != report.server_records { + return Err(format!( + "record count diverged: log={} server={}", + report.log_records, report.server_records + )); + } + send_shutdown(&socket_for_client); + Ok(()) + }); + + run_server(executor, log, store, None, &socket_path).expect("server clean exit"); + client.join().unwrap().expect("client assertions"); + + let _ = std::fs::remove_file(&log_path); +} + +#[test] +fn drift_check_surfaces_expected_per_record_diffs() { + let log_a_path = fresh_log_path(); + let log_b_path = fresh_log_path(); + let socket_path = fresh_socket_path(); + + let caja_a = Uuid::new_v4(); + let caja_b = Uuid::new_v4(); + build_log_a(&log_a_path, caja_a, caja_b); + build_log_b(&log_b_path, caja_a); + + let executor = Executor::load_module(treasury_module()).expect("load"); + let log = EventLog::open(&log_a_path).expect("reopen"); + let store = MemoryStore::new(); + + let socket_for_client = socket_path.clone(); + let log_b_for_client = log_b_path.clone(); + let client = thread::spawn(move || -> Result<(), String> { + // Server is running log A's state; we audit using log B's + // canonical view. Expected diffs: + // - Caja caja_a: tampered (B says saldo=100_000, server has 105_000 from deposit) + // - Caja caja_b: only_on_server (B never seeded it) + // - Movimiento : only_on_server (B never executed the deposit) + let report = check_against_socket(&log_b_for_client, &socket_for_client) + .map_err(|e| format!("check failed: {}", e))?; + if report.in_sync() { + return Err("expected drift, got in_sync".into()); + } + + let mut tampered = 0; + let mut only_on_server = 0; + let mut only_in_log = 0; + let mut tampered_caja_a = false; + let mut server_extra_caja_b = false; + let mut server_extra_movimiento = false; + + for d in &report.diffs { + match d { + DriftDiff::Tampered { + entity, + id, + log_value, + server_value, + } => { + tampered += 1; + if entity == "Caja" && *id == caja_a { + tampered_caja_a = true; + if log_value["saldo"] != json!(100_000_i64) { + return Err(format!("log saldo wrong: {}", log_value)); + } + if server_value["saldo"] != json!(105_000_i64) { + return Err(format!("server saldo wrong: {}", server_value)); + } + } + } + DriftDiff::OnlyOnServer { entity, id, .. } => { + only_on_server += 1; + if entity == "Caja" && *id == caja_b { + server_extra_caja_b = true; + } + if entity == "Movimiento" { + server_extra_movimiento = true; + } + } + DriftDiff::OnlyInLog { .. } => only_in_log += 1, + } + } + if tampered != 1 { + return Err(format!("expected 1 tampered, got {}", tampered)); + } + if only_on_server != 2 { + return Err(format!("expected 2 only_on_server, got {}", only_on_server)); + } + if only_in_log != 0 { + return Err(format!("expected 0 only_in_log, got {}", only_in_log)); + } + if !tampered_caja_a { + return Err("expected tampered diff for caja_a".into()); + } + if !server_extra_caja_b { + return Err("expected only_on_server diff for caja_b".into()); + } + if !server_extra_movimiento { + return Err("expected only_on_server diff for some Movimiento".into()); + } + + send_shutdown(&socket_for_client); + Ok(()) + }); + + run_server(executor, log, store, None, &socket_path).expect("server clean exit"); + client.join().unwrap().expect("client assertions"); + + let _ = std::fs::remove_file(&log_a_path); + let _ = std::fs::remove_file(&log_b_path); +} diff --git a/crates/modules/nakui/core/tests/event_log.rs b/crates/modules/nakui/core/tests/event_log.rs new file mode 100644 index 0000000..d943c74 --- /dev/null +++ b/crates/modules/nakui/core/tests/event_log.rs @@ -0,0 +1,620 @@ +//! Integration tests for the event log: round-trip persistence, +//! replay-equivalence with the live store, and determinism verification. + +use std::path::{Path, PathBuf}; + +use nakui_core::delta::FieldOp; +use nakui_core::event_log::{ + EventLog, ExecuteError, LogEntry, RecoverableExecuteError, Snapshot, execute_and_log, + execute_and_log_with_recovery, reconcile, replay, replay_with_snapshot_into, seed_and_log, + verify_log, +}; +use nakui_core::executor::Executor; +use nakui_core::store::{MemoryStore, Store, StoreError}; +use serde_json::{Value, json}; +use uuid::Uuid; + +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root above core/") + .to_path_buf() +} + +fn treasury_module() -> PathBuf { + workspace_root().join("modules/treasury") +} + +fn fresh_log_path() -> PathBuf { + std::env::temp_dir().join(format!("nakui_test_{}.jsonl", Uuid::new_v4())) +} + +#[test] +fn replay_reconstructs_live_store() { + let exec = Executor::load_module(treasury_module()).expect("load module"); + let log_path = fresh_log_path(); + let mut log = EventLog::open(&log_path).expect("open log"); + let mut live = MemoryStore::new(); + + let a = Uuid::new_v4(); + let b = Uuid::new_v4(); + seed_and_log( + &exec, + &mut live, + &mut log, + "Caja", + a, + json!({ + "id": a.to_string(), "name": "A", "saldo": 200_000_i64, "currency": "USD", + }), + ) + .unwrap(); + seed_and_log( + &exec, + &mut live, + &mut log, + "Caja", + b, + json!({ + "id": b.to_string(), "name": "B", "saldo": 50_000_i64, "currency": "USD", + }), + ) + .unwrap(); + + let mov_id = Uuid::new_v4(); + execute_and_log( + &exec, + &mut live, + &mut log, + "register_cash_move", + &[("caja", a)], + json!({ + "monto": 25_000_i64, "tipo": "in", + "timestamp": "2026-05-04T10:00:00Z", "memo": "x", + "movimiento_id": mov_id.to_string(), + }), + ) + .unwrap(); + + let xfer_id = Uuid::new_v4(); + execute_and_log( + &exec, + &mut live, + &mut log, + "transfer_between_cajas", + &[("source", a), ("dest", b)], + json!({ + "monto": 75_000_i64, + "timestamp": "2026-05-04T10:30:00Z", "memo": "xf", + "transfer_id": xfer_id.to_string(), + }), + ) + .unwrap(); + + // Failed morphism — should NOT be logged. + let attempt = execute_and_log( + &exec, + &mut live, + &mut log, + "transfer_between_cajas", + &[("source", a), ("dest", b)], + json!({ + "monto": 999_999_999_i64, + "timestamp": "2026-05-04T10:45:00Z", "memo": "overdraw", + "transfer_id": Uuid::new_v4().to_string(), + }), + ); + assert!(matches!(attempt, Err(ExecuteError::PreLog(_)))); + + let replayed = replay(&log).expect("replay"); + assert_eq!(live, replayed, "replayed store must equal live store"); + + // Failed attempt left no trace in the log. + let entries = log.entries().unwrap(); + assert_eq!( + entries.len(), + 4, + "2 seeds + 2 successful morphisms = 4 entries; got {}", + entries.len() + ); + + let _ = std::fs::remove_file(&log_path); +} + +#[test] +fn verify_log_passes_for_deterministic_morphisms() { + let exec = Executor::load_module(treasury_module()).expect("load module"); + let log_path = fresh_log_path(); + let mut log = EventLog::open(&log_path).expect("open log"); + let mut live = MemoryStore::new(); + + let a = Uuid::new_v4(); + let b = Uuid::new_v4(); + seed_and_log( + &exec, + &mut live, + &mut log, + "Caja", + a, + json!({"id": a.to_string(), "name": "A", "saldo": 200_000_i64, "currency": "USD"}), + ) + .unwrap(); + seed_and_log( + &exec, + &mut live, + &mut log, + "Caja", + b, + json!({"id": b.to_string(), "name": "B", "saldo": 50_000_i64, "currency": "USD"}), + ) + .unwrap(); + execute_and_log( + &exec, + &mut live, + &mut log, + "transfer_between_cajas", + &[("source", a), ("dest", b)], + json!({ + "monto": 25_000_i64, + "timestamp": "2026-05-04T11:00:00Z", "memo": "v", + "transfer_id": Uuid::new_v4().to_string(), + }), + ) + .unwrap(); + + verify_log(&log, &exec).expect("re-execution must produce identical ops"); + + let _ = std::fs::remove_file(&log_path); +} + +/// Store wrapper that passes dry_run through to MemoryStore but always +/// fails on apply. Used to simulate a transient backend failure landing +/// AFTER the kernel has validated and the log has been written. +struct FailOnApplyStore { + inner: MemoryStore, +} + +impl Store for FailOnApplyStore { + fn load(&self, entity: &str, id: Uuid) -> Option { + self.inner.load(entity, id) + } + fn seed(&mut self, entity: &str, id: Uuid, data: Value) { + self.inner.seed(entity, id, data); + } + fn apply_dry_run(&self, ops: &[FieldOp]) -> Result<(), StoreError> { + self.inner.apply_dry_run(ops) + } + fn apply(&mut self, _ops: &[FieldOp]) -> Result<(), StoreError> { + Err(StoreError::NotFound( + "synthetic_apply_failure".into(), + Uuid::nil(), + )) + } + fn clear(&mut self) -> Result<(), StoreError> { + self.inner.clear() + } + fn iter(&self) -> Result + '_>, StoreError> { + self.inner.iter() + } +} + +#[test] +fn post_log_store_failure_leaves_log_canonical() { + let exec = Executor::load_module(treasury_module()).expect("load module"); + let log_path = fresh_log_path(); + let mut log = EventLog::open(&log_path).expect("open log"); + + // Seed the inner store directly (no logging — we're simulating the + // backend independently of the log). + let mut inner = MemoryStore::new(); + let a = Uuid::new_v4(); + let b = Uuid::new_v4(); + inner.seed( + "Caja", + a, + json!({"id": a.to_string(), "name": "A", "saldo": 200_000_i64, "currency": "USD"}), + ); + inner.seed( + "Caja", + b, + json!({"id": b.to_string(), "name": "B", "saldo": 50_000_i64, "currency": "USD"}), + ); + let mut store = FailOnApplyStore { inner }; + + let result = execute_and_log( + &exec, + &mut store, + &mut log, + "transfer_between_cajas", + &[("source", a), ("dest", b)], + json!({ + "monto": 25_000_i64, + "timestamp": "2026-05-04T11:00:00Z", + "memo": "wal-test", + "transfer_id": Uuid::new_v4().to_string(), + }), + ); + + match result { + Err(ExecuteError::PostLogStore(_)) => {} + other => panic!("expected PostLogStore, got {:?}", other), + } + + // Log is canonical: the morphism event is durable. + let entries = log.entries().expect("read"); + assert_eq!(entries.len(), 1, "log must contain the morphism event"); + assert!(matches!(&entries[0], LogEntry::Morphism { .. })); + + // Live store is stale: apply was rejected, so saldos are unchanged. + assert_eq!( + store.load("Caja", a).unwrap().get("saldo").unwrap().as_i64(), + Some(200_000) + ); + assert_eq!( + store.load("Caja", b).unwrap().get("saldo").unwrap().as_i64(), + Some(50_000) + ); + + let _ = std::fs::remove_file(&log_path); +} + +#[test] +fn reopen_log_resumes_from_correct_seq() { + let exec = Executor::load_module(treasury_module()).expect("load module"); + let log_path = fresh_log_path(); + let a = Uuid::new_v4(); + + { + let mut log = EventLog::open(&log_path).unwrap(); + let mut store = MemoryStore::new(); + seed_and_log( + &exec, + &mut store, + &mut log, + "Caja", + a, + json!({"id": a.to_string(), "name": "A", "saldo": 100_i64, "currency": "USD"}), + ) + .unwrap(); + assert_eq!(log.next_seq(), 1); + } + + { + let log = EventLog::open(&log_path).unwrap(); + assert_eq!(log.next_seq(), 1, "next_seq must persist across reopens"); + let entries = log.entries().unwrap(); + assert_eq!(entries.len(), 1); + assert!(matches!(&entries[0], LogEntry::Seed { seq: 0, .. })); + } + + let _ = std::fs::remove_file(&log_path); +} + +#[test] +fn snapshot_plus_log_tail_replays_to_same_state() { + let exec = Executor::load_module(treasury_module()).expect("load"); + let log_path = fresh_log_path(); + let snap_path = log_path.with_extension("snap"); + let mut log = EventLog::open(&log_path).expect("open"); + let mut live = MemoryStore::new(); + + let a = Uuid::new_v4(); + let b = Uuid::new_v4(); + seed_and_log( + &exec, + &mut live, + &mut log, + "Caja", + a, + json!({"id": a.to_string(), "name": "A", "saldo": 200_000_i64, "currency": "USD"}), + ) + .unwrap(); + seed_and_log( + &exec, + &mut live, + &mut log, + "Caja", + b, + json!({"id": b.to_string(), "name": "B", "saldo": 50_000_i64, "currency": "USD"}), + ) + .unwrap(); + execute_and_log( + &exec, + &mut live, + &mut log, + "register_cash_move", + &[("caja", a)], + json!({ + "monto": 25_000_i64, "tipo": "in", + "timestamp": "2026-05-04T10:00:00Z", "memo": "before-snap", + "movimiento_id": Uuid::new_v4().to_string(), + }), + ) + .unwrap(); + + // Take snapshot at this point: seq 0 (seed A), 1 (seed B), 2 (deposit) + // are reflected in `live`. Next event will be seq 3. + let snap = Snapshot::from_memory_store(&live, log.next_seq() - 1); + snap.write(&snap_path).expect("write snapshot"); + assert_eq!(snap.seq, 2); + + // More events after the snapshot. + execute_and_log( + &exec, + &mut live, + &mut log, + "transfer_between_cajas", + &[("source", a), ("dest", b)], + json!({ + "monto": 75_000_i64, + "timestamp": "2026-05-04T10:30:00Z", "memo": "after-snap", + "transfer_id": Uuid::new_v4().to_string(), + }), + ) + .unwrap(); + + // Replay from snapshot + log tail; must equal live store. + let loaded_snap = Snapshot::load(&snap_path).expect("load").expect("present"); + let mut replayed = MemoryStore::new(); + replay_with_snapshot_into(&log, Some(&loaded_snap), &mut replayed).expect("replay"); + + assert_eq!(live, replayed, "snapshot + tail must equal full replay"); + + let _ = std::fs::remove_file(&log_path); + let _ = std::fs::remove_file(&snap_path); +} + +#[test] +fn compact_through_drops_old_entries_keeps_seq() { + let exec = Executor::load_module(treasury_module()).expect("load module"); + let log_path = fresh_log_path(); + let mut log = EventLog::open(&log_path).expect("open"); + + let mut live = MemoryStore::new(); + for i in 0..5 { + let id = Uuid::new_v4(); + seed_and_log( + &exec, + &mut live, + &mut log, + "Caja", + id, + json!({"id": id.to_string(), "name": format!("c{}", i), "saldo": 100_i64, "currency": "USD"}), + ) + .unwrap(); + } + + assert_eq!(log.next_seq(), 5); + assert_eq!(log.entries().unwrap().len(), 5); + + // Compact through seq 2: entries 0,1,2 are dropped; 3,4 remain. + log.compact_through(2).expect("compact"); + + let surviving = log.entries().unwrap(); + assert_eq!(surviving.len(), 2); + assert_eq!(surviving[0].seq(), 3); + assert_eq!(surviving[1].seq(), 4); + + // next_seq stays at 5 — we kept the surviving entries' counter intact. + // (Reopen to confirm the persisted log roundtrips this.) + drop(log); + let reopened = EventLog::open(&log_path).expect("reopen after compact"); + assert_eq!(reopened.next_seq(), 5); + + let _ = std::fs::remove_file(&log_path); +} + +#[test] +fn snapshot_then_compact_then_replay_equals_pre_compaction() { + let exec = Executor::load_module(treasury_module()).expect("load"); + let log_path = fresh_log_path(); + let snap_path = log_path.with_extension("snap"); + let mut log = EventLog::open(&log_path).expect("open"); + let mut live = MemoryStore::new(); + + let a = Uuid::new_v4(); + seed_and_log( + &exec, + &mut live, + &mut log, + "Caja", + a, + json!({"id": a.to_string(), "name": "A", "saldo": 1_000_i64, "currency": "USD"}), + ) + .unwrap(); + for i in 0..3 { + execute_and_log( + &exec, + &mut live, + &mut log, + "register_cash_move", + &[("caja", a)], + json!({ + "monto": 100_i64, "tipo": "in", + "timestamp": format!("2026-05-04T10:0{}:00Z", i), "memo": "x", + "movimiento_id": Uuid::new_v4().to_string(), + }), + ) + .unwrap(); + } + // Snapshot at seq 3 (1 seed + 3 morphisms = seqs 0..=3). + let snap = Snapshot::from_memory_store(&live, log.next_seq() - 1); + snap.write(&snap_path).expect("write snap"); + log.compact_through(snap.seq).expect("compact"); + + // After compaction: log has 0 entries (all subsumed). next_seq = 4. + assert_eq!(log.entries().unwrap().len(), 0); + + // More events after compaction. + execute_and_log( + &exec, + &mut live, + &mut log, + "register_cash_move", + &[("caja", a)], + json!({ + "monto": 500_i64, "tipo": "in", + "timestamp": "2026-05-04T11:00:00Z", "memo": "post-compact", + "movimiento_id": Uuid::new_v4().to_string(), + }), + ) + .unwrap(); + + // Reconstruct from snapshot + remaining log. + let loaded_snap = Snapshot::load(&snap_path).unwrap().unwrap(); + let mut replayed = MemoryStore::new(); + replay_with_snapshot_into(&log, Some(&loaded_snap), &mut replayed).expect("replay"); + assert_eq!(live, replayed, "snapshot + post-compact log must equal live"); + + let _ = std::fs::remove_file(&log_path); + let _ = std::fs::remove_file(&snap_path); +} + +#[test] +fn reconcile_rebuilds_drifted_store_from_log() { + let exec = Executor::load_module(treasury_module()).expect("load module"); + let log_path = fresh_log_path(); + let mut log = EventLog::open(&log_path).expect("open log"); + let mut live = MemoryStore::new(); + + let a = Uuid::new_v4(); + seed_and_log( + &exec, + &mut live, + &mut log, + "Caja", + a, + json!({"id": a.to_string(), "name": "A", "saldo": 100_000_i64, "currency": "USD"}), + ) + .unwrap(); + execute_and_log( + &exec, + &mut live, + &mut log, + "register_cash_move", + &[("caja", a)], + json!({ + "monto": 5_000_i64, "tipo": "in", + "timestamp": "2026-05-04T10:00:00Z", "memo": "x", + "movimiento_id": Uuid::new_v4().to_string(), + }), + ) + .unwrap(); + + // Drift the store out-of-band: a poison record nobody logged, plus a + // tampered saldo on the legitimate one. + let ghost = Uuid::new_v4(); + live.seed( + "Caja", + ghost, + json!({"id": ghost.to_string(), "name": "GHOST", "saldo": 0, "currency": "USD"}), + ); + live.seed( + "Caja", + a, + json!({"id": a.to_string(), "name": "A", "saldo": 999_999_i64, "currency": "USD"}), + ); + + // Canonical state: replay from log into a clean store. + let canonical = replay(&log).expect("replay"); + assert_ne!(live, canonical, "drift was set up to differ from log"); + + reconcile(&mut live, &log).expect("reconcile"); + assert_eq!(live, canonical, "reconcile must restore log-canonical state"); + assert!(live.load("Caja", ghost).is_none(), "poison record must be wiped"); + + let _ = std::fs::remove_file(&log_path); +} + +#[test] +fn execute_and_log_with_recovery_succeeds_on_clean_path() { + // The clean path: no apply failure means the wrapper returns the same + // ops as `execute_and_log` and leaves the store consistent. + let exec = Executor::load_module(treasury_module()).expect("load module"); + let log_path = fresh_log_path(); + let mut log = EventLog::open(&log_path).expect("open log"); + let mut store = MemoryStore::new(); + + let a = Uuid::new_v4(); + seed_and_log( + &exec, + &mut store, + &mut log, + "Caja", + a, + json!({"id": a.to_string(), "name": "A", "saldo": 10_000_i64, "currency": "USD"}), + ) + .unwrap(); + + let ops = execute_and_log_with_recovery( + &exec, + &mut store, + &mut log, + "register_cash_move", + &[("caja", a)], + json!({ + "monto": 1_000_i64, "tipo": "in", + "timestamp": "2026-05-04T10:00:00Z", "memo": "x", + "movimiento_id": Uuid::new_v4().to_string(), + }), + ) + .expect("recovery wrapper"); + assert!(!ops.is_empty(), "morphism produced ops"); + + let replayed = replay(&log).expect("replay"); + assert_eq!(store, replayed, "store and log agree on clean path"); + + let _ = std::fs::remove_file(&log_path); +} + +#[test] +fn execute_and_log_with_recovery_reports_unrecoverable_when_replay_also_fails() { + // Apply is permanently broken — reconcile (which replays through apply) + // will also fail. The wrapper must surface `Unrecoverable` so the + // caller knows the store is no longer in sync with the log. + let exec = Executor::load_module(treasury_module()).expect("load module"); + let log_path = fresh_log_path(); + let mut log = EventLog::open(&log_path).expect("open log"); + + let mut inner = MemoryStore::new(); + let a = Uuid::new_v4(); + let b = Uuid::new_v4(); + inner.seed( + "Caja", + a, + json!({"id": a.to_string(), "name": "A", "saldo": 200_000_i64, "currency": "USD"}), + ); + inner.seed( + "Caja", + b, + json!({"id": b.to_string(), "name": "B", "saldo": 50_000_i64, "currency": "USD"}), + ); + let mut store = FailOnApplyStore { inner }; + + let result = execute_and_log_with_recovery( + &exec, + &mut store, + &mut log, + "transfer_between_cajas", + &[("source", a), ("dest", b)], + json!({ + "monto": 25_000_i64, + "timestamp": "2026-05-04T11:00:00Z", + "memo": "recover-fail", + "transfer_id": Uuid::new_v4().to_string(), + }), + ); + assert!( + matches!(result, Err(RecoverableExecuteError::Unrecoverable { .. })), + "expected Unrecoverable, got {:?}", + result + ); + + // The log entry is still canonical: an operator who fixes the backend + // can recover via `nakui replay` later. + let entries = log.entries().expect("read log"); + assert_eq!(entries.len(), 1); + assert!(matches!(&entries[0], LogEntry::Morphism { .. })); + + let _ = std::fs::remove_file(&log_path); +} diff --git a/crates/modules/nakui/core/tests/fixtures/bad_created_record.rhai b/crates/modules/nakui/core/tests/fixtures/bad_created_record.rhai new file mode 100644 index 0000000..4fec923 --- /dev/null +++ b/crates/modules/nakui/core/tests/fixtures/bad_created_record.rhai @@ -0,0 +1,19 @@ +// EVIL: creates a Movimiento with monto = -1, violating schema.k: +// check: monto > 0 +// Schema check on the Created record (KclPostCreate) must reject this. +let mov_id = input.params.mov_id; +[ + #{ + op: "create", + entity: "Movimiento", + id: mov_id, + data: #{ + id: mov_id, + caja_id: input.ids.caja, + monto: -1, + tipo: "in", + timestamp: "2026-05-04T00:00:00Z", + memo: "evil", + }, + }, +] diff --git a/crates/modules/nakui/core/tests/fixtures/capability_violation.rhai b/crates/modules/nakui/core/tests/fixtures/capability_violation.rhai new file mode 100644 index 0000000..696a44f --- /dev/null +++ b/crates/modules/nakui/core/tests/fixtures/capability_violation.rhai @@ -0,0 +1,9 @@ +// EVIL: writes to a Caja id that wasn't declared in inputs. +// The phantom id is passed via params to keep the script syntactically valid. +[ + #{ + op: "set", + path: #{ entity: "Caja", id: input.params.phantom_id, field: "saldo" }, + value: 0, + }, +] diff --git a/crates/modules/nakui/core/tests/fixtures/conservation_violation.rhai b/crates/modules/nakui/core/tests/fixtures/conservation_violation.rhai new file mode 100644 index 0000000..19084b5 --- /dev/null +++ b/crates/modules/nakui/core/tests/fixtures/conservation_violation.rhai @@ -0,0 +1,14 @@ +// EVIL: subtracts from BOTH cajas. Same currency, so the conservation rule +// (Σ Δ Caja.saldo group_by currency = 0) catches it. +[ + #{ + op: "set", + path: #{ entity: "Caja", id: input.ids.source, field: "saldo" }, + value: input.states.source.saldo - 100, + }, + #{ + op: "set", + path: #{ entity: "Caja", id: input.ids.dest, field: "saldo" }, + value: input.states.dest.saldo - 1, + }, +] diff --git a/crates/modules/nakui/core/tests/fixtures/delete_primary.rhai b/crates/modules/nakui/core/tests/fixtures/delete_primary.rhai new file mode 100644 index 0000000..87ab2a2 --- /dev/null +++ b/crates/modules/nakui/core/tests/fixtures/delete_primary.rhai @@ -0,0 +1,7 @@ +// Deletes its primary input. The kernel must: +// - accept the Delete op (token = "Caja", in writes) +// - skip the per-input KCL post-check (entity no longer exists) +// - allow apply to remove the record cleanly +[ + #{ op: "delete", entity: "Caja", id: input.ids.caja }, +] diff --git a/crates/modules/nakui/core/tests/fixtures/entity_mismatch.rhai b/crates/modules/nakui/core/tests/fixtures/entity_mismatch.rhai new file mode 100644 index 0000000..331f7d2 --- /dev/null +++ b/crates/modules/nakui/core/tests/fixtures/entity_mismatch.rhai @@ -0,0 +1,10 @@ +// EVIL: tries to write `Stock.cantidad` using a Caja's UUID. The id matches +// a tracked role but the entity does not — the capability check must reject +// with a `` token rather than letting it through. +[ + #{ + op: "set", + path: #{ entity: "Stock", id: input.ids.caja, field: "cantidad" }, + value: 0, + }, +] diff --git a/crates/modules/nakui/core/tests/graph.rs b/crates/modules/nakui/core/tests/graph.rs new file mode 100644 index 0000000..da676a0 --- /dev/null +++ b/crates/modules/nakui/core/tests/graph.rs @@ -0,0 +1,355 @@ +//! ManifestGraph: cycle detection on `depends_on`, data-flow indexes for +//! `reads`/`writes`, and the `affected_by` query that powers dirty-marking. + +use std::path::{Path, PathBuf}; + +use nakui_core::executor::Executor; +use nakui_core::graph::{DirtyTracker, GraphError, ManifestGraph}; +use nakui_core::manifest::{ + ConserveRule, Invariants, Manifest, MorphismInput, MorphismSpec, +}; + +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root above core/") + .to_path_buf() +} + +fn module(name: &str) -> PathBuf { + workspace_root().join("modules").join(name) +} + +fn morphism(name: &str, depends_on: Vec) -> MorphismSpec { + MorphismSpec { + name: name.into(), + inputs: vec![MorphismInput { + role: "caja".into(), + entity: "Caja".into(), + }], + reads: vec!["caja.saldo".into()], + writes: vec!["caja.saldo".into()], + invariants: Invariants::default(), + depends_on, + script: "morphisms/register_cash_move.rhai".into(), + } +} + +fn manifest_with(morphisms: Vec) -> Manifest { + Manifest { + module: "graph_test".into(), + schemas: vec![], + morphisms, + } +} + +#[test] +fn detects_two_node_cycle() { + let m = manifest_with(vec![ + morphism("a", vec!["b".into()]), + morphism("b", vec!["a".into()]), + ]); + match ManifestGraph::build(&m) { + Err(GraphError::Cycle(names)) => { + assert!(names.contains(&"a".to_string())); + assert!(names.contains(&"b".to_string())); + } + other => panic!("expected Cycle, got {:?}", other), + } +} + +#[test] +fn detects_self_loop() { + let m = manifest_with(vec![morphism("loop", vec!["loop".into()])]); + match ManifestGraph::build(&m) { + Err(GraphError::Cycle(names)) => { + assert_eq!(names, vec!["loop".to_string()]); + } + other => panic!("expected Cycle, got {:?}", other), + } +} + +#[test] +fn detects_three_node_cycle() { + let m = manifest_with(vec![ + morphism("a", vec!["b".into()]), + morphism("b", vec!["c".into()]), + morphism("c", vec!["a".into()]), + ]); + match ManifestGraph::build(&m) { + Err(GraphError::Cycle(names)) => { + assert_eq!(names.len(), 3); + } + other => panic!("expected Cycle, got {:?}", other), + } +} + +#[test] +fn topological_order_respects_explicit_dependencies() { + // a <- b <- c (c depends on b depends on a) + let m = manifest_with(vec![ + morphism("a", vec![]), + morphism("b", vec!["a".into()]), + morphism("c", vec!["b".into()]), + ]); + let g = ManifestGraph::build(&m).expect("acyclic"); + let order = g.topological_order(); + let pos = |n: &str| order.iter().position(|x| x == n).unwrap(); + assert!(pos("a") < pos("b")); + assert!(pos("b") < pos("c")); +} + +#[test] +fn unknown_depends_on_target_errors() { + let m = manifest_with(vec![morphism("a", vec!["ghost".into()])]); + match ManifestGraph::build(&m) { + Err(GraphError::UnknownMorphism(name)) => assert_eq!(name, "ghost"), + other => panic!("expected UnknownMorphism, got {:?}", other), + } +} + +#[test] +fn treasury_data_flow_indexes_match_manifest() { + let exec = Executor::load_module(module("treasury")).expect("load"); + let g = &exec.graph; + + // Both register_cash_move and transfer_between_cajas write Caja.saldo. + let mut writers: Vec<&str> = g.writers_of("Caja.saldo").iter().map(|s| s.as_str()).collect(); + writers.sort(); + assert_eq!(writers, vec!["register_cash_move", "transfer_between_cajas"]); + + // Both read Caja.saldo too. + let mut readers: Vec<&str> = g.readers_of("Caja.saldo").iter().map(|s| s.as_str()).collect(); + readers.sort(); + assert_eq!(readers, vec!["register_cash_move", "transfer_between_cajas"]); + + // Movimiento is written only by register_cash_move. + assert_eq!( + g.writers_of("Movimiento"), + &["register_cash_move".to_string()] + ); + + // Transferencia is written only by transfer_between_cajas. + assert_eq!( + g.writers_of("Transferencia"), + &["transfer_between_cajas".to_string()] + ); + + // Nothing in treasury reads Movimiento or Transferencia. + assert!(g.readers_of("Movimiento").is_empty()); + assert!(g.readers_of("Transferencia").is_empty()); +} + +#[test] +fn affected_by_excludes_self_and_finds_overlap() { + // A simple two-morphism manifest where one writes what the other reads. + let m = manifest_with(vec![ + MorphismSpec { + name: "writer".into(), + inputs: vec![MorphismInput { + role: "caja".into(), + entity: "Caja".into(), + }], + reads: vec![], + writes: vec!["caja.saldo".into()], + invariants: Invariants::default(), + depends_on: vec![], + script: "morphisms/register_cash_move.rhai".into(), + }, + MorphismSpec { + name: "reader".into(), + inputs: vec![MorphismInput { + role: "caja".into(), + entity: "Caja".into(), + }], + reads: vec!["caja.saldo".into()], + writes: vec![], + invariants: Invariants::default(), + depends_on: vec![], + script: "morphisms/register_cash_move.rhai".into(), + }, + MorphismSpec { + name: "self_loop".into(), + inputs: vec![MorphismInput { + role: "caja".into(), + entity: "Caja".into(), + }], + reads: vec!["caja.saldo".into()], + writes: vec!["caja.saldo".into()], + invariants: Invariants::default(), + depends_on: vec![], + script: "morphisms/register_cash_move.rhai".into(), + }, + ]); + let g = ManifestGraph::build(&m).expect("acyclic"); + + let mut affected = g.affected_by("writer"); + affected.sort(); + // writer writes Caja.saldo; readers are reader + self_loop, but + // self_loop is "writer"? no, self_loop is a separate morphism here, + // and it does read Caja.saldo so it's affected by writer. + assert_eq!(affected, vec!["reader", "self_loop"]); + + // self_loop writes its own field but should not list itself. + let affected_self = g.affected_by("self_loop"); + assert_eq!(affected_self, vec!["reader"]); +} + +#[test] +fn cross_module_graph_canonicalizes_to_entity_tokens() { + // sales/vender uses role "stock" (entity Stock) and role "caja" (entity Caja). + // Reads and writes should canonicalize to "Stock.cantidad" and "Caja.saldo". + let exec = Executor::load_module(module("sales")).expect("load sales"); + let g = &exec.graph; + + assert_eq!(g.writers_of("Stock.cantidad"), &["vender".to_string()]); + assert_eq!(g.writers_of("Caja.saldo"), &["vender".to_string()]); + assert_eq!(g.writers_of("Venta"), &["vender".to_string()]); + + let reads = g.morphism_reads("vender"); + assert!(reads.contains(&"Stock.cantidad".to_string())); + assert!(reads.contains(&"Caja.saldo".to_string())); + assert!(reads.contains(&"Caja.currency".to_string())); +} + +#[test] +fn executor_load_module_rejects_cyclic_manifest() { + // Synthesize a tempdir with a cyclic manifest and confirm Executor + // surfaces ExecError::Graph rather than running. + let tmp = std::env::temp_dir().join(format!("nakui_cycle_{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(tmp.join("morphisms")).unwrap(); + std::fs::write( + tmp.join("schema.k"), + "schema Caja:\n saldo: int\n check:\n saldo >= 0\n", + ) + .unwrap(); + std::fs::write(tmp.join("morphisms/op.rhai"), "[]").unwrap(); + std::fs::write( + tmp.join("nsmc.json"), + r#"{ + "module": "cycle", + "morphisms": [ + {"name": "a", "inputs": [{"role":"caja","entity":"Caja"}], + "reads": [], "writes": ["caja.saldo"], "depends_on": ["b"], + "script": "morphisms/op.rhai"}, + {"name": "b", "inputs": [{"role":"caja","entity":"Caja"}], + "reads": [], "writes": ["caja.saldo"], "depends_on": ["a"], + "script": "morphisms/op.rhai"} + ] + }"#, + ) + .unwrap(); + + let err = match Executor::load_module(&tmp) { + Ok(_) => panic!("must fail with cycle"), + Err(e) => e, + }; + let msg = err.to_string(); + assert!(msg.contains("graph") || msg.contains("cycle"), + "expected graph diagnostic, got `{}`", msg); + + let _ = std::fs::remove_dir_all(&tmp); +} + +#[test] +fn dirty_tracker_marks_after_treasury_morphism() { + let exec = Executor::load_module(module("treasury")).expect("load"); + let mut tracker = DirtyTracker::new(); + + // register_cash_move writes Caja.saldo + Movimiento. Both are read by + // transfer_between_cajas (Caja.saldo) but Movimiento is read by no one. + tracker.mark_dirty_after("register_cash_move", &exec.graph); + + let dirty = tracker.dirty(); + assert!( + dirty.contains(&"transfer_between_cajas".to_string()), + "transfer_between_cajas reads Caja.saldo, must be dirty after deposit; got {:?}", + dirty + ); + assert!( + !tracker.is_dirty("register_cash_move"), + "self should not be marked dirty by its own write" + ); +} + +#[test] +fn dirty_tracker_clear_works() { + let exec = Executor::load_module(module("treasury")).expect("load"); + let mut tracker = DirtyTracker::new(); + tracker.mark_dirty_after("transfer_between_cajas", &exec.graph); + let count_before = tracker.len(); + assert!(count_before > 0); + + let first = tracker.dirty().into_iter().next().unwrap(); + tracker.clear(&first); + assert!(!tracker.is_dirty(&first)); + assert_eq!(tracker.len(), count_before - 1); +} + +#[test] +fn dirty_tracker_accumulates_across_morphisms() { + // Manifest with three morphisms where each writes what the next reads. + // After running A then B, both readers should be marked. + let m = manifest_with(vec![ + MorphismSpec { + name: "writer_a".into(), + inputs: vec![MorphismInput { + role: "caja".into(), + entity: "Caja".into(), + }], + reads: vec![], + writes: vec!["caja.saldo".into()], + invariants: Invariants::default(), + depends_on: vec![], + script: "morphisms/register_cash_move.rhai".into(), + }, + MorphismSpec { + name: "writer_b".into(), + inputs: vec![MorphismInput { + role: "caja".into(), + entity: "Caja".into(), + }], + reads: vec![], + writes: vec!["Movimiento".into()], + invariants: Invariants::default(), + depends_on: vec![], + script: "morphisms/register_cash_move.rhai".into(), + }, + MorphismSpec { + name: "reader_caja".into(), + inputs: vec![MorphismInput { + role: "caja".into(), + entity: "Caja".into(), + }], + reads: vec!["caja.saldo".into()], + writes: vec![], + invariants: Invariants::default(), + depends_on: vec![], + script: "morphisms/register_cash_move.rhai".into(), + }, + MorphismSpec { + name: "reader_mov".into(), + inputs: vec![MorphismInput { + role: "caja".into(), + entity: "Caja".into(), + }], + reads: vec!["Movimiento".into()], + writes: vec![], + invariants: Invariants::default(), + depends_on: vec![], + script: "morphisms/register_cash_move.rhai".into(), + }, + ]); + let g = ManifestGraph::build(&m).unwrap(); + let mut tracker = DirtyTracker::new(); + + tracker.mark_dirty_after("writer_a", &g); + assert!(tracker.is_dirty("reader_caja")); + assert!(!tracker.is_dirty("reader_mov")); + + tracker.mark_dirty_after("writer_b", &g); + assert!(tracker.is_dirty("reader_caja")); + assert!(tracker.is_dirty("reader_mov")); + + assert_eq!(tracker.len(), 2); +} diff --git a/crates/modules/nakui/core/tests/inventory.rs b/crates/modules/nakui/core/tests/inventory.rs new file mode 100644 index 0000000..96d4d26 --- /dev/null +++ b/crates/modules/nakui/core/tests/inventory.rs @@ -0,0 +1,173 @@ +//! Inventory module integration tests. The point: prove the kernel is +//! module-agnostic — these tests use the SAME executor code path as +//! treasury, just pointed at a different module dir, and the conservation +//! rule is just declarative (Stock.cantidad group_by sku_id). + +use std::path::{Path, PathBuf}; + +use nakui_core::executor::{ExecError, Executor}; +use nakui_core::store::{MemoryStore, Store}; +use serde_json::{Value, json}; +use uuid::Uuid; + +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root above core/") + .to_path_buf() +} + +fn inventory_module() -> PathBuf { + workspace_root().join("modules/inventory") +} + +fn cantidad(store: &MemoryStore, id: Uuid) -> i64 { + store + .load("Stock", id) + .and_then(|v| v.get("cantidad").and_then(Value::as_i64)) + .expect("stock with cantidad") +} + +fn seed_stock(store: &mut MemoryStore, id: Uuid, sku: &str, cantidad: i64) { + store.seed( + "Stock", + id, + json!({ + "id": id.to_string(), + "sku_id": sku, + "ubicacion": "test-loc", + "cantidad": cantidad, + }), + ); +} + +#[test] +fn transfer_conserves_units_across_same_sku() { + let exec = Executor::load_module(inventory_module()).expect("load module"); + let mut store = MemoryStore::new(); + + let a = Uuid::new_v4(); + let b = Uuid::new_v4(); + seed_stock(&mut store, a, "sku-X", 500); + seed_stock(&mut store, b, "sku-X", 100); + + let ops = exec + .run( + &mut store, + "transferir_stock", + &[("source", a), ("dest", b)], + json!({ + "cantidad": 150_i64, + "timestamp": "2026-05-04T00:00:00Z", + "transfer_id": Uuid::new_v4().to_string(), + }), + ) + .expect("transfer must pass"); + + assert_eq!(ops.len(), 3, "2 sets + 1 create = 3 ops"); + assert_eq!(cantidad(&store, a), 350); + assert_eq!(cantidad(&store, b), 250); + // Total preserved. + assert_eq!(cantidad(&store, a) + cantidad(&store, b), 600); +} + +#[test] +fn transfer_across_different_skus_is_rejected_by_conservation() { + // Construct a buggy synthetic morphism that mimics transfer but skips + // the in-script same-sku check. We do this by pointing at a fixture + // script that lacks the `throw if source.sku_id != dest.sku_id`. + // + // Without that fixture we can rely on the production script's `throw` + // to fire first — which is itself fine but proves the SCRIPT, not the + // KERNEL. To prove the kernel-level conservation works on inventory, + // see kernel_guards.rs (treasury) — that test exercises the same + // executor logic with Caja.saldo grouped by currency. Here we just + // assert the production script rejects cross-SKU. + let exec = Executor::load_module(inventory_module()).expect("load module"); + let mut store = MemoryStore::new(); + + let a = Uuid::new_v4(); + let c = Uuid::new_v4(); + seed_stock(&mut store, a, "sku-X", 500); + seed_stock(&mut store, c, "sku-Y", 200); + + let result = exec.run( + &mut store, + "transferir_stock", + &[("source", a), ("dest", c)], + json!({ + "cantidad": 50_i64, + "timestamp": "2026-05-04T00:00:00Z", + "transfer_id": Uuid::new_v4().to_string(), + }), + ); + + match result { + Err(ExecError::Rhai(_)) => {} + other => panic!("expected Rhai (script throw on sku mismatch), got {:?}", other), + } + assert_eq!(cantidad(&store, a), 500); + assert_eq!(cantidad(&store, c), 200); +} + +#[test] +fn overdraw_transfer_blocked_by_kcl_post_check() { + let exec = Executor::load_module(inventory_module()).expect("load module"); + let mut store = MemoryStore::new(); + + let a = Uuid::new_v4(); + let b = Uuid::new_v4(); + seed_stock(&mut store, a, "sku-X", 100); + seed_stock(&mut store, b, "sku-X", 0); + + let result = exec.run( + &mut store, + "transferir_stock", + &[("source", a), ("dest", b)], + json!({ + "cantidad": 999_i64, + "timestamp": "2026-05-04T00:00:00Z", + "transfer_id": Uuid::new_v4().to_string(), + }), + ); + + match result { + Err(ExecError::KclPost { role, entity, .. }) => { + assert_eq!(role, "source"); + assert_eq!(entity, "Stock"); + } + other => panic!("expected KclPost on source, got {:?}", other), + } + assert_eq!(cantidad(&store, a), 100); + assert_eq!(cantidad(&store, b), 0); +} + +#[test] +fn recibir_increases_stock_and_creates_movimiento() { + let exec = Executor::load_module(inventory_module()).expect("load module"); + let mut store = MemoryStore::new(); + + let a = Uuid::new_v4(); + seed_stock(&mut store, a, "sku-X", 100); + + let mov_id = Uuid::new_v4(); + let ops = exec + .run( + &mut store, + "recibir_stock", + &[("stock", a)], + json!({ + "cantidad": 50_i64, + "timestamp": "2026-05-04T00:00:00Z", + "movimiento_id": mov_id.to_string(), + }), + ) + .expect("recibir must pass"); + + assert_eq!(ops.len(), 2, "1 set + 1 create"); + assert_eq!(cantidad(&store, a), 150); + assert!( + store.load("MovimientoStock", mov_id).is_some(), + "movimiento must be persisted" + ); +} diff --git a/crates/modules/nakui/core/tests/kernel_guards.rs b/crates/modules/nakui/core/tests/kernel_guards.rs new file mode 100644 index 0000000..7f4f876 --- /dev/null +++ b/crates/modules/nakui/core/tests/kernel_guards.rs @@ -0,0 +1,297 @@ +//! Regression tests for the kernel's enforcement layers. +//! +//! Each test runs a deliberately-broken morphism that should be rejected by +//! a *specific* layer of the executor pipeline. After every rejection we also +//! assert the store is untouched — the kernel must never half-apply a delta. +//! +//! Layers exercised (in pipeline order): +//! 1. CapabilityViolation (untracked write) +//! 2. ConservationViolation (delta sum != 0) +//! 3. KclPostCreate (created record fails its schema) + +use std::path::{Path, PathBuf}; + +use nakui_core::executor::{ExecError, Executor}; +use nakui_core::graph::ManifestGraph; +use nakui_core::manifest::{ + ConserveRule, Invariants, Manifest, MorphismInput, MorphismSpec, +}; +use nakui_core::rhai_executor::RhaiExecutor; +use nakui_core::store::{MemoryStore, Store}; +use serde_json::{Value, json}; +use uuid::Uuid; + +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root above core/") + .to_path_buf() +} + +fn fixtures_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures") +} + +fn build_executor(spec: MorphismSpec) -> Executor { + let manifest = Manifest { + module: "kernel_guards_test".into(), + schemas: vec![], + morphisms: vec![spec], + }; + let graph = ManifestGraph::build(&manifest).expect("graph builds"); + Executor { + manifest, + graph, + // module_dir is where script paths resolve; we point it at fixtures. + module_dir: fixtures_dir(), + // schema_path stays on the real treasury schema so we exercise the + // production check blocks. `owned_bundle: false` so Drop leaves it + // alone — it belongs to the source tree. + schema_path: workspace_root().join("modules/treasury/schema.k"), + rhai: RhaiExecutor::new_sandboxed(), + owned_bundle: false, + // Inline-built executors don't go through `load_module`, so they + // have no schema-hash cache. These guard tests don't write to a + // log, so verify_log never runs against this executor. + schema_hashes: std::collections::HashMap::new(), + schema_bundle_hash: [0u8; 32], + } +} + +fn seed_caja(store: &mut MemoryStore, id: Uuid, name: &str, saldo: i64, currency: &str) { + store.seed( + "Caja", + id, + json!({ + "id": id.to_string(), + "name": name, + "saldo": saldo, + "currency": currency, + }), + ); +} + +fn caja_saldo(store: &MemoryStore, id: Uuid) -> i64 { + store + .load("Caja", id) + .and_then(|v| v.get("saldo").and_then(Value::as_i64)) + .expect("caja with saldo") +} + +#[test] +fn capability_violation_blocks_write_to_untracked_caja() { + let spec = MorphismSpec { + name: "evil_capability".into(), + inputs: vec![MorphismInput { + role: "caja".into(), + entity: "Caja".into(), + }], + reads: vec!["caja.saldo".into()], + writes: vec!["caja.saldo".into()], + invariants: Invariants::default(), + depends_on: vec![], + script: "capability_violation.rhai".into(), + }; + let exec = build_executor(spec); + + let mut store = MemoryStore::new(); + let caja_id = Uuid::new_v4(); + let phantom_id = Uuid::new_v4(); + seed_caja(&mut store, caja_id, "tracked", 100_000, "USD"); + seed_caja(&mut store, phantom_id, "phantom", 100_000, "USD"); + + let params = json!({ "phantom_id": phantom_id.to_string() }); + + let result = exec.run(&mut store, "evil_capability", &[("caja", caja_id)], params); + + match result { + Err(ExecError::CapabilityViolation { token, .. }) => { + assert!( + token.contains("untracked"), + "expected token to flag untracked id, got `{}`", + token + ); + } + other => panic!("expected CapabilityViolation, got {:?}", other), + } + + // Neither caja moved. + assert_eq!(caja_saldo(&store, caja_id), 100_000); + assert_eq!(caja_saldo(&store, phantom_id), 100_000); +} + +#[test] +fn conservation_violation_blocks_unbalanced_transfer() { + let spec = MorphismSpec { + name: "evil_conservation".into(), + inputs: vec![ + MorphismInput { + role: "source".into(), + entity: "Caja".into(), + }, + MorphismInput { + role: "dest".into(), + entity: "Caja".into(), + }, + ], + reads: vec![ + "source.saldo".into(), + "source.currency".into(), + "dest.saldo".into(), + "dest.currency".into(), + ], + writes: vec!["source.saldo".into(), "dest.saldo".into()], + invariants: Invariants { + conserve: vec![ConserveRule { + entity: "Caja".into(), + field: "saldo".into(), + group_by: Some("currency".into()), + }], + }, + depends_on: vec![], + script: "conservation_violation.rhai".into(), + }; + let exec = build_executor(spec); + + let mut store = MemoryStore::new(); + let source = Uuid::new_v4(); + let dest = Uuid::new_v4(); + seed_caja(&mut store, source, "A", 200_000, "USD"); + seed_caja(&mut store, dest, "B", 50_000, "USD"); + + let result = exec.run( + &mut store, + "evil_conservation", + &[("source", source), ("dest", dest)], + json!({}), + ); + + match result { + Err(ExecError::ConservationViolation { + entity, + field, + total, + .. + }) => { + assert_eq!(entity, "Caja"); + assert_eq!(field, "saldo"); + assert_eq!(total, -101, "expected Δ = -100 + -1 = -101"); + } + other => panic!("expected ConservationViolation, got {:?}", other), + } + + assert_eq!(caja_saldo(&store, source), 200_000); + assert_eq!(caja_saldo(&store, dest), 50_000); +} + +#[test] +fn capability_rejects_entity_mismatch_on_tracked_id() { + // The script writes `Stock.cantidad` using the Caja's UUID. The id is + // tracked (it's the caja role's id) but the entity differs — the + // capability layer must catch this regardless of UUID coincidence. + let spec = MorphismSpec { + name: "evil_entity_mismatch".into(), + inputs: vec![MorphismInput { + role: "caja".into(), + entity: "Caja".into(), + }], + reads: vec!["caja.saldo".into()], + writes: vec!["caja.saldo".into()], + invariants: Invariants::default(), + depends_on: vec![], + script: "entity_mismatch.rhai".into(), + }; + let exec = build_executor(spec); + + let mut store = MemoryStore::new(); + let caja_id = Uuid::new_v4(); + seed_caja(&mut store, caja_id, "tracked", 100_000, "USD"); + + let result = exec.run(&mut store, "evil_entity_mismatch", &[("caja", caja_id)], json!({})); + + match result { + Err(ExecError::CapabilityViolation { token, .. }) => { + assert!( + token.contains("entity-mismatch"), + "expected entity-mismatch token, got `{}`", + token + ); + } + other => panic!("expected CapabilityViolation, got {:?}", other), + } + assert_eq!(caja_saldo(&store, caja_id), 100_000); +} + +#[test] +fn delete_primary_skips_post_check_and_removes_record() { + // A morphism that deletes its primary input must succeed without the + // post-check running against a stale-then-stripped state. + let spec = MorphismSpec { + name: "delete_caja".into(), + inputs: vec![MorphismInput { + role: "caja".into(), + entity: "Caja".into(), + }], + reads: vec![], + writes: vec!["Caja".into()], + invariants: Invariants::default(), + depends_on: vec![], + script: "delete_primary.rhai".into(), + }; + let exec = build_executor(spec); + + let mut store = MemoryStore::new(); + let caja_id = Uuid::new_v4(); + seed_caja(&mut store, caja_id, "doomed", 100_000, "USD"); + + let ops = exec + .run(&mut store, "delete_caja", &[("caja", caja_id)], json!({})) + .expect("delete must succeed"); + + assert_eq!(ops.len(), 1); + assert!(matches!(&ops[0], nakui_core::delta::FieldOp::Delete { .. })); + assert!( + store.load("Caja", caja_id).is_none(), + "Caja must be gone after Delete" + ); +} + +#[test] +fn bad_created_record_blocks_negative_movimiento() { + let spec = MorphismSpec { + name: "evil_create".into(), + inputs: vec![MorphismInput { + role: "caja".into(), + entity: "Caja".into(), + }], + reads: vec!["caja.saldo".into()], + writes: vec!["Movimiento".into()], + invariants: Invariants::default(), + depends_on: vec![], + script: "bad_created_record.rhai".into(), + }; + let exec = build_executor(spec); + + let mut store = MemoryStore::new(); + let caja_id = Uuid::new_v4(); + seed_caja(&mut store, caja_id, "A", 100_000, "USD"); + let mov_id = Uuid::new_v4(); + + let params = json!({ "mov_id": mov_id.to_string() }); + + let result = exec.run(&mut store, "evil_create", &[("caja", caja_id)], params); + + match result { + Err(ExecError::KclPostCreate { entity, .. }) => { + assert_eq!(entity, "Movimiento"); + } + other => panic!("expected KclPostCreate, got {:?}", other), + } + + // Caja unchanged, Movimiento never landed. + assert_eq!(caja_saldo(&store, caja_id), 100_000); + assert!( + store.load("Movimiento", mov_id).is_none(), + "Movimiento must not be persisted" + ); +} diff --git a/crates/modules/nakui/core/tests/manifest_validation.rs b/crates/modules/nakui/core/tests/manifest_validation.rs new file mode 100644 index 0000000..2b4b1bd --- /dev/null +++ b/crates/modules/nakui/core/tests/manifest_validation.rs @@ -0,0 +1,282 @@ +//! Manifest::validate covers the contract between authors (humans or AI) +//! and Nakui. Each test inline-builds a manifest with one specific defect +//! and asserts the right diagnostic fires. +//! +//! Most tests point at `modules/treasury/` so the schema/script paths +//! resolve. Two tests need a synthetic tempdir to express their defect +//! (missing schema file, duplicate schema across files). + +use std::fs; +use std::path::{Path, PathBuf}; + +use nakui_core::executor::Executor; +use nakui_core::manifest::{ + ConserveRule, Invariants, Manifest, MorphismInput, MorphismSpec, ValidationError, +}; +use uuid::Uuid; + +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root above core/") + .to_path_buf() +} + +fn treasury_dir() -> PathBuf { + workspace_root().join("modules/treasury") +} + +fn caja_input() -> MorphismInput { + MorphismInput { + role: "caja".into(), + entity: "Caja".into(), + } +} + +fn baseline_morphism() -> MorphismSpec { + MorphismSpec { + name: "test_op".into(), + inputs: vec![caja_input()], + reads: vec!["caja.saldo".into()], + writes: vec!["caja.saldo".into()], + invariants: Invariants::default(), + depends_on: vec![], + script: "morphisms/register_cash_move.rhai".into(), + } +} + +fn baseline_manifest() -> Manifest { + Manifest { + module: "test".into(), + schemas: vec![], + morphisms: vec![baseline_morphism()], + } +} + +#[test] +fn production_modules_validate_clean() { + for name in ["treasury", "inventory", "sales"] { + let dir = workspace_root().join("modules").join(name); + let manifest = Manifest::load(&dir.join("nsmc.json")) + .unwrap_or_else(|e| panic!("load {}: {}", name, e)); + manifest + .validate(&dir) + .unwrap_or_else(|e| panic!("validate {}: {}", name, e)); + } +} + +#[test] +fn rejects_duplicate_morphism_name() { + let mut m = baseline_manifest(); + m.morphisms.push(baseline_morphism()); // same name as the first + match m.validate(&treasury_dir()) { + Err(ValidationError::DuplicateMorphism(name)) => assert_eq!(name, "test_op"), + other => panic!("expected DuplicateMorphism, got {:?}", other), + } +} + +#[test] +fn rejects_duplicate_role_within_morphism() { + let mut m = baseline_manifest(); + m.morphisms[0].inputs.push(caja_input()); // same role twice + match m.validate(&treasury_dir()) { + Err(ValidationError::DuplicateRole { morphism, role }) => { + assert_eq!(morphism, "test_op"); + assert_eq!(role, "caja"); + } + other => panic!("expected DuplicateRole, got {:?}", other), + } +} + +#[test] +fn rejects_input_unknown_entity() { + let mut m = baseline_manifest(); + m.morphisms[0].inputs[0].entity = "Banana".into(); + match m.validate(&treasury_dir()) { + Err(ValidationError::InputUnknownEntity { + morphism, + entity, + known, + }) => { + assert_eq!(morphism, "test_op"); + assert_eq!(entity, "Banana"); + assert!(known.contains(&"Caja".to_string())); + } + other => panic!("expected InputUnknownEntity, got {:?}", other), + } +} + +#[test] +fn rejects_writes_unknown_role() { + let mut m = baseline_manifest(); + m.morphisms[0].writes = vec!["ghost.saldo".into()]; + match m.validate(&treasury_dir()) { + Err(ValidationError::WritesUnknownRole { + morphism, + token, + role, + .. + }) => { + assert_eq!(morphism, "test_op"); + assert_eq!(token, "ghost.saldo"); + assert_eq!(role, "ghost"); + } + other => panic!("expected WritesUnknownRole, got {:?}", other), + } +} + +#[test] +fn rejects_writes_unknown_entity() { + let mut m = baseline_manifest(); + m.morphisms[0].writes = vec!["BananaSplit".into()]; + match m.validate(&treasury_dir()) { + Err(ValidationError::WritesUnknownEntity { morphism, token }) => { + assert_eq!(morphism, "test_op"); + assert_eq!(token, "BananaSplit"); + } + other => panic!("expected WritesUnknownEntity, got {:?}", other), + } +} + +#[test] +fn rejects_conserve_unknown_entity() { + let mut m = baseline_manifest(); + m.morphisms[0].invariants.conserve = vec![ConserveRule { + entity: "Banana".into(), + field: "x".into(), + group_by: None, + }]; + match m.validate(&treasury_dir()) { + Err(ValidationError::ConserveUnknownEntity { morphism, entity }) => { + assert_eq!(morphism, "test_op"); + assert_eq!(entity, "Banana"); + } + other => panic!("expected ConserveUnknownEntity, got {:?}", other), + } +} + +#[test] +fn rejects_depends_on_unknown_morphism() { + let mut m = baseline_manifest(); + m.morphisms[0].depends_on = vec!["ghost_morphism".into()]; + match m.validate(&treasury_dir()) { + Err(ValidationError::DependsOnUnknown { morphism, dep }) => { + assert_eq!(morphism, "test_op"); + assert_eq!(dep, "ghost_morphism"); + } + other => panic!("expected DependsOnUnknown, got {:?}", other), + } +} + +#[test] +fn rejects_missing_script() { + let mut m = baseline_manifest(); + m.morphisms[0].script = "morphisms/ghost.rhai".into(); + match m.validate(&treasury_dir()) { + Err(ValidationError::ScriptMissing { morphism, script, .. }) => { + assert_eq!(morphism, "test_op"); + assert_eq!(script, "morphisms/ghost.rhai"); + } + other => panic!("expected ScriptMissing, got {:?}", other), + } +} + +#[test] +fn rejects_missing_schema_file() { + let mut m = baseline_manifest(); + m.schemas = vec!["nonexistent.k".into()]; + match m.validate(&treasury_dir()) { + Err(ValidationError::SchemaFileMissing { path, .. }) => { + assert_eq!(path, "nonexistent.k"); + } + other => panic!("expected SchemaFileMissing, got {:?}", other), + } +} + +#[test] +fn rejects_duplicate_schema_across_files() { + // Synthesize a tempdir with two .k files that both declare `schema X`. + let tmp = std::env::temp_dir().join(format!("nakui_dup_{}", Uuid::new_v4())); + fs::create_dir_all(&tmp).unwrap(); + fs::create_dir_all(tmp.join("morphisms")).unwrap(); + fs::write( + tmp.join("a.k"), + "schema Caja:\n saldo: int\n check:\n saldo >= 0\n", + ) + .unwrap(); + fs::write( + tmp.join("b.k"), + "schema Caja:\n monto: int\n check:\n monto >= 0\n", + ) + .unwrap(); + fs::write(tmp.join("morphisms/op.rhai"), "[]").unwrap(); + + let m = Manifest { + module: "dup".into(), + schemas: vec!["a.k".into(), "b.k".into()], + morphisms: vec![MorphismSpec { + name: "op".into(), + inputs: vec![MorphismInput { + role: "caja".into(), + entity: "Caja".into(), + }], + reads: vec![], + writes: vec![], + invariants: Invariants::default(), + depends_on: vec![], + script: "morphisms/op.rhai".into(), + }], + }; + + match m.validate(&tmp) { + Err(ValidationError::DuplicateSchema { name, files }) => { + assert_eq!(name, "Caja"); + assert!(files.contains(&"a.k".to_string())); + assert!(files.contains(&"b.k".to_string())); + } + other => panic!("expected DuplicateSchema, got {:?}", other), + } + + let _ = fs::remove_dir_all(&tmp); +} + +#[test] +fn executor_load_module_runs_validation() { + // Synthesize a module dir whose manifest references a missing script — + // load_module must surface ManifestValidation, not a runtime kernel error. + let tmp = std::env::temp_dir().join(format!("nakui_bad_{}", Uuid::new_v4())); + fs::create_dir_all(&tmp).unwrap(); + fs::write( + tmp.join("schema.k"), + "schema Caja:\n saldo: int\n check:\n saldo >= 0\n", + ) + .unwrap(); + fs::write( + tmp.join("nsmc.json"), + r#"{ + "module": "bad", + "morphisms": [{ + "name": "op", + "inputs": [{"role": "caja", "entity": "Caja"}], + "reads": [], + "writes": ["caja.saldo"], + "depends_on": [], + "script": "morphisms/missing.rhai" + }] + }"#, + ) + .unwrap(); + + let err = match Executor::load_module(&tmp) { + Ok(_) => panic!("must fail validation"), + Err(e) => e, + }; + let msg = err.to_string(); + assert!( + msg.contains("validation") && msg.contains("missing.rhai"), + "expected validation diagnostic naming the missing script, got `{}`", + msg + ); + + let _ = fs::remove_dir_all(&tmp); +} diff --git a/crates/modules/nakui/core/tests/run.rs b/crates/modules/nakui/core/tests/run.rs new file mode 100644 index 0000000..94f7e8b --- /dev/null +++ b/crates/modules/nakui/core/tests/run.rs @@ -0,0 +1,286 @@ +//! End-to-end tests for `nakui run` — bind a socket from the main test +//! thread, drive it from a client thread with line-JSON requests, and +//! assert behaviour through the wire. +//! +//! Why server-on-main / client-on-thread: `Executor` is `!Send` (Rhai +//! caches AST in a `RefCell`). Moving it across thread boundaries is a +//! compile-time error, so the test thread runs the server and a worker +//! thread plays the client. The worker calls `shutdown` last, which lets +//! the main thread return from `run_server` and join. + +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::UnixStream; +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::Duration; + +use nakui_core::event_log::{EventLog, execute_and_log, seed_and_log}; +use nakui_core::executor::Executor; +use nakui_core::run::run_server; +use nakui_core::store::MemoryStore; +use serde_json::{Value, json}; +use uuid::Uuid; + +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root above core/") + .to_path_buf() +} + +fn treasury_module() -> PathBuf { + workspace_root().join("modules/treasury") +} + +fn fresh_log_path() -> PathBuf { + std::env::temp_dir().join(format!("nakui_run_log_{}.jsonl", Uuid::new_v4())) +} + +fn fresh_socket_path() -> PathBuf { + std::env::temp_dir().join(format!("nakui_run_{}.sock", Uuid::new_v4())) +} + +/// One client connection: keeps a single BufReader alive across +/// exchanges so buffered bytes from one response don't get dropped +/// before the next read. +struct Conn { + writer: UnixStream, + reader: BufReader, +} + +impl Conn { + fn connect_with_retry(path: &Path) -> Self { + for _ in 0..100 { + if let Ok(stream) = UnixStream::connect(path) { + let reader_stream = stream.try_clone().expect("clone for reader"); + return Self { + writer: stream, + reader: BufReader::new(reader_stream), + }; + } + thread::sleep(Duration::from_millis(20)); + } + panic!("server never started accepting on {}", path.display()); + } + + fn exchange(&mut self, req: Value) -> Value { + let mut s = serde_json::to_vec(&req).expect("serialize request"); + s.push(b'\n'); + self.writer.write_all(&s).expect("write request"); + let mut line = String::new(); + let n = self.reader.read_line(&mut line).expect("read response"); + assert!(n > 0, "server closed connection without responding"); + serde_json::from_str(line.trim()).expect("parse response") + } +} + +#[test] +fn run_server_full_protocol_round_trip() { + let log_path = fresh_log_path(); + let socket_path = fresh_socket_path(); + + let caja_id = Uuid::new_v4(); + { + let executor = Executor::load_module(treasury_module()).expect("load module"); + let mut log = EventLog::open(&log_path).expect("open log"); + let mut store = MemoryStore::new(); + seed_and_log( + &executor, + &mut store, + &mut log, + "Caja", + caja_id, + json!({ + "id": caja_id.to_string(), + "name": "A", + "saldo": 100_000_i64, + "currency": "USD", + }), + ) + .expect("seed"); + } + + let executor = Executor::load_module(treasury_module()).expect("load module"); + let log = EventLog::open(&log_path).expect("reopen log"); + let store = MemoryStore::new(); + + let socket_for_client = socket_path.clone(); + let client = thread::spawn(move || -> Result<(), String> { + let mut conn = Conn::connect_with_retry(&socket_for_client); + + let resp = conn.exchange(json!({"op": "describe"})); + if resp["ok"] != json!(true) { + return Err(format!("describe not ok: {}", resp)); + } + if resp["module"] != json!("treasury") { + return Err(format!("module mismatch: {}", resp)); + } + if resp["protocol"] != json!(1) { + return Err(format!("protocol mismatch: {}", resp)); + } + let morphisms = resp["morphisms"] + .as_array() + .ok_or("morphisms not array")?; + if !morphisms.iter().any(|m| m["name"] == "register_cash_move") { + return Err("register_cash_move missing from describe".into()); + } + + let resp = conn.exchange(json!({ + "op": "load", + "entity": "Caja", + "id": caja_id.to_string(), + })); + if resp["value"]["saldo"].as_i64() != Some(100_000) { + return Err(format!("initial saldo wrong: {}", resp)); + } + + let resp = conn.exchange(json!({ + "op": "execute", + "morphism": "register_cash_move", + "inputs": {"caja": caja_id.to_string()}, + "params": { + "monto": 5_000_i64, + "tipo": "in", + "timestamp": "2026-05-04T10:00:00Z", + "memo": "via run", + "movimiento_id": Uuid::new_v4().to_string(), + }, + })); + if resp["ok"] != json!(true) { + return Err(format!("execute failed: {}", resp)); + } + if resp["seq"].as_u64().is_none() { + return Err(format!("execute missing seq: {}", resp)); + } + if resp["ops"].as_array().map(|a| a.is_empty()).unwrap_or(true) { + return Err(format!("execute missing ops: {}", resp)); + } + + let resp = conn.exchange(json!({ + "op": "load", + "entity": "Caja", + "id": caja_id.to_string(), + })); + if resp["value"]["saldo"].as_i64() != Some(105_000) { + return Err(format!("post-execute saldo wrong: {}", resp)); + } + + // Kernel rejection: returns ok=false with stage=pre_log. + let other = Uuid::new_v4(); + let resp = conn.exchange(json!({ + "op": "execute", + "morphism": "transfer_between_cajas", + "inputs": {"source": caja_id.to_string(), "dest": other.to_string()}, + "params": { + "monto": 999_999_999_i64, + "timestamp": "2026-05-04T10:30:00Z", + "memo": "overdraw", + "transfer_id": Uuid::new_v4().to_string(), + }, + })); + if resp["ok"] != json!(false) || resp["stage"] != json!("pre_log") { + return Err(format!("expected pre_log rejection: {}", resp)); + } + + // Bad JSON — connection survives, server keeps serving. + conn.writer.write_all(b"not json\n").map_err(|e| e.to_string())?; + let mut line = String::new(); + conn.reader.read_line(&mut line).map_err(|e| e.to_string())?; + let parsed: Value = serde_json::from_str(line.trim()).map_err(|e| e.to_string())?; + if parsed["ok"] != json!(false) { + return Err(format!("bad request didn't get error: {}", parsed)); + } + + let resp = conn.exchange(json!({"op": "verify"})); + if resp["ok"] != json!(true) { + return Err(format!("verify failed: {}", resp)); + } + if resp["entries"].as_u64() != Some(2) { + return Err(format!("verify entries wrong: {}", resp)); + } + + let resp = conn.exchange(json!({"op": "shutdown"})); + if resp["ok"] != json!(true) || resp["shutdown"] != json!(true) { + return Err(format!("shutdown response wrong: {}", resp)); + } + Ok(()) + }); + + run_server(executor, log, store, None, &socket_path).expect("server clean exit"); + client.join().expect("client thread joined").expect("client assertions"); + + assert!( + !socket_path.exists(), + "shutdown must remove the socket file" + ); + let _ = std::fs::remove_file(&log_path); +} + +#[test] +fn run_server_reconciles_drifted_store_on_startup() { + let log_path = fresh_log_path(); + let socket_path = fresh_socket_path(); + + let caja_id = Uuid::new_v4(); + { + let executor = Executor::load_module(treasury_module()).expect("load"); + let mut log = EventLog::open(&log_path).expect("open log"); + let mut store = MemoryStore::new(); + seed_and_log( + &executor, + &mut store, + &mut log, + "Caja", + caja_id, + json!({ + "id": caja_id.to_string(), + "name": "A", + "saldo": 200_000_i64, + "currency": "USD", + }), + ) + .expect("seed"); + execute_and_log( + &executor, + &mut store, + &mut log, + "register_cash_move", + &[("caja", caja_id)], + json!({ + "monto": 1_500_i64, + "tipo": "in", + "timestamp": "2026-05-04T09:00:00Z", + "memo": "pre-run", + "movimiento_id": Uuid::new_v4().to_string(), + }), + ) + .expect("deposit"); + } + + let executor = Executor::load_module(treasury_module()).expect("load"); + let log = EventLog::open(&log_path).expect("reopen"); + let empty_store = MemoryStore::new(); + + let socket_for_client = socket_path.clone(); + let client = thread::spawn(move || -> Result<(), String> { + let mut conn = Conn::connect_with_retry(&socket_for_client); + let resp = conn.exchange(json!({ + "op": "load", + "entity": "Caja", + "id": caja_id.to_string(), + })); + if resp["value"]["saldo"].as_i64() != Some(201_500) { + return Err(format!( + "expected saldo 201_500 (200k seed + 1.5k replayed deposit), got {}", + resp + )); + } + conn.exchange(json!({"op": "shutdown"})); + Ok(()) + }); + + run_server(executor, log, empty_store, None, &socket_path).expect("clean exit"); + client.join().unwrap().expect("client assertions"); + + let _ = std::fs::remove_file(&log_path); +} diff --git a/crates/modules/nakui/core/tests/run_persistent.rs b/crates/modules/nakui/core/tests/run_persistent.rs new file mode 100644 index 0000000..63bcfa5 --- /dev/null +++ b/crates/modules/nakui/core/tests/run_persistent.rs @@ -0,0 +1,328 @@ +//! Smoke test for the persistent backend wired into `nakui run`. +//! +//! Gated behind `--features persistent` because SurrealKV pulls in a +//! ~5 min cold native build. Run with: +//! cargo test --features persistent --test run_persistent +//! +//! What this proves: +//! 1. `run_server` accepts a `SurrealStore` and serves the standard +//! protocol (execute/load/shutdown round-trip). +//! 2. After shutdown, reopening the same backing store path reveals +//! the records were actually written through to disk — i.e., the +//! runtime wasn't just hitting an in-memory façade. +//! +//! What this does NOT prove (covered elsewhere or deferred): +//! - That startup skips replay when the persistent state is current. +//! V1 always replays from log, even with a persistent store; the +//! persistent layer is durability for the runtime cache, not a +//! replay shortcut. A future `last_applied_seq` tracker would +//! change that. +//! - Cross-backend hash equality (Memory vs Surreal). Different +//! concern — round-trip parity of serde_json::Value through the +//! SurrealDB driver. + +#![cfg(feature = "persistent")] + +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::UnixStream; +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::Duration; + +use nakui_core::event_log::{EventLog, seed_and_log}; +use nakui_core::executor::Executor; +use nakui_core::run::run_server; +use nakui_core::store::Store; +use nakui_core::surreal_store::SurrealStore; +use serde_json::{Value, json}; +use uuid::Uuid; + +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root above core/") + .to_path_buf() +} + +fn treasury_module() -> PathBuf { + workspace_root().join("modules/treasury") +} + +fn fresh_log_path() -> PathBuf { + std::env::temp_dir().join(format!("nakui_runp_log_{}.jsonl", Uuid::new_v4())) +} + +fn fresh_store_path() -> PathBuf { + std::env::temp_dir().join(format!("nakui_runp_store_{}", Uuid::new_v4())) +} + +fn fresh_socket_path() -> PathBuf { + std::env::temp_dir().join(format!("nakui_runp_{}.sock", Uuid::new_v4())) +} + +struct Conn { + writer: UnixStream, + reader: BufReader, +} + +fn connect_with_retry(path: &Path) -> Conn { + for _ in 0..200 { + if let Ok(stream) = UnixStream::connect(path) { + let reader_stream = stream.try_clone().expect("clone"); + return Conn { + writer: stream, + reader: BufReader::new(reader_stream), + }; + } + thread::sleep(Duration::from_millis(20)); + } + panic!("server never started accepting on {}", path.display()); +} + +fn exchange(conn: &mut Conn, req: Value) -> Value { + let mut bytes = serde_json::to_vec(&req).unwrap(); + bytes.push(b'\n'); + conn.writer.write_all(&bytes).unwrap(); + let mut line = String::new(); + conn.reader.read_line(&mut line).unwrap(); + serde_json::from_str(line.trim()).unwrap() +} + +#[test] +fn run_server_with_persistent_surreal_serves_protocol_and_writes_to_disk() { + let log_path = fresh_log_path(); + let store_path = fresh_store_path(); + let socket_path = fresh_socket_path(); + + // Pre-seed via the WAL so the log has a record the server can + // replay into the persistent store on startup. + let caja = Uuid::new_v4(); + { + let mut store = SurrealStore::new_persistent(&store_path).expect("open persistent"); + let mut log = EventLog::open(&log_path).expect("open log"); + seed_and_log( + &executor, + &mut store, + &mut log, + "Caja", + caja, + json!({ + "id": caja.to_string(), + "name": "A", + "saldo": 100_000_i64, + "currency": "USD", + }), + ) + .expect("seed"); + } + + // Start the server with the same persistent store path. + let executor = Executor::load_module(treasury_module()).expect("load module"); + let log = EventLog::open(&log_path).expect("reopen log"); + let store = SurrealStore::new_persistent(&store_path).expect("reopen persistent"); + + let socket_for_client = socket_path.clone(); + let client = thread::spawn(move || -> Result<(), String> { + let mut conn = connect_with_retry(&socket_for_client); + + // Initial load picks up the seed (replayed at startup into the + // persistent store). + let resp = exchange( + &mut conn, + json!({"op": "load", "entity": "Caja", "id": caja.to_string()}), + ); + if resp["value"]["saldo"].as_i64() != Some(100_000) { + return Err(format!("startup replay didn't land seed: {}", resp)); + } + + // Drive a deposit through the server — this writes through the + // log AND the persistent store. + let resp = exchange( + &mut conn, + json!({ + "op": "execute", + "morphism": "register_cash_move", + "inputs": {"caja": caja.to_string()}, + "params": { + "monto": 7_500_i64, + "tipo": "in", + "timestamp": "2026-05-04T10:00:00Z", + "memo": "persisted", + "movimiento_id": Uuid::new_v4().to_string(), + } + }), + ); + if resp["ok"] != json!(true) { + return Err(format!("execute failed: {}", resp)); + } + + let resp = exchange( + &mut conn, + json!({"op": "load", "entity": "Caja", "id": caja.to_string()}), + ); + if resp["value"]["saldo"].as_i64() != Some(107_500) { + return Err(format!("post-execute saldo wrong: {}", resp)); + } + + let _ = exchange(&mut conn, json!({"op": "shutdown"})); + Ok(()) + }); + + run_server(executor, log, store, None, &socket_path).expect("server clean exit"); + client.join().unwrap().expect("client assertions"); + + // Now the server is gone. Open a fresh handle to the SAME persistent + // store path — the records must be there without any replay. This + // is what proves "persistent backend" beyond the unit-level tests + // in surreal_persist.rs: the runtime actually wrote through. + let store_again = SurrealStore::new_persistent(&store_path).expect("reopen final"); + let v = store_again + .load("Caja", caja) + .expect("Caja persisted across server shutdown"); + assert_eq!( + v.get("saldo").and_then(Value::as_i64), + Some(107_500), + "deposit landed in persistent store" + ); + + let _ = std::fs::remove_file(&log_path); + let _ = std::fs::remove_dir_all(&store_path); +} + +#[test] +fn run_server_skips_replay_when_persistent_store_is_in_sync() { + // The optimization: when the persistent store's `last_applied_seq` + // matches the log's last seq, startup_replay must skip the + // clear+replay entirely. We prove that by mutating the store + // out-of-band between cycles — if skip happens, the mutation + // survives; if full replay runs (clear+replay), it'd be wiped. + let log_path = fresh_log_path(); + let store_path = fresh_store_path(); + let socket_path1 = fresh_socket_path(); + let socket_path2 = fresh_socket_path(); + let caja = Uuid::new_v4(); + + // Cycle 1: drive a deposit through the server. After shutdown the + // persistent store's marker should equal the log's last seq. + { + let executor = Executor::load_module(treasury_module()).expect("load"); + let mut log = EventLog::open(&log_path).expect("open log"); + let mut store = SurrealStore::new_persistent(&store_path).expect("open persistent"); + seed_and_log( + &executor, + &mut store, + &mut log, + "Caja", + caja, + json!({ + "id": caja.to_string(), + "name": "A", + "saldo": 100_000_i64, + "currency": "USD", + }), + ) + .expect("seed"); + // We end the WAL flow without running run_server in this cycle — + // the next cycle is the one that exercises the skip path. + drop(store); + drop(log); + drop(executor); + } + + // Out-of-band mutation: open the persistent store directly and + // change the saldo. Marker stays at the same seq. + { + let mut store = + SurrealStore::new_persistent(&store_path).expect("reopen for poison"); + store.seed( + "Caja", + caja, + json!({ + "id": caja.to_string(), + "name": "A", + "saldo": 999_999_i64, // poison + "currency": "USD", + }), + ); + // The marker we set during the WAL flow stays intact — seed() + // alone does not bump it. + } + + // Cycle 2: run_server with the poisoned store. Marker == log_last + // (still 0 from the seed) → skip path → poison saldo survives. + let executor = Executor::load_module(treasury_module()).expect("load"); + let log = EventLog::open(&log_path).expect("reopen log"); + let store = SurrealStore::new_persistent(&store_path).expect("reopen final"); + + let socket_for_client = socket_path1.clone(); + let client = thread::spawn(move || -> Result<(), String> { + let mut conn = connect_with_retry(&socket_for_client); + let resp = exchange( + &mut conn, + json!({"op": "load", "entity": "Caja", "id": caja.to_string()}), + ); + let saldo = resp["value"]["saldo"].as_i64(); + let _ = exchange(&mut conn, json!({"op": "shutdown"})); + if saldo != Some(999_999) { + return Err(format!( + "skip-replay should preserve out-of-band saldo (999_999), got {:?}", + saldo + )); + } + Ok(()) + }); + + run_server(executor, log, store, None, &socket_path1).expect("server clean exit"); + client.join().unwrap().expect("client assertions"); + + // Cycle 3: explicitly invalidate the marker (simulating a backend + // that lost track) and confirm full replay restores log-canonical + // state — wiping the poison. + { + let mut store = SurrealStore::new_persistent(&store_path).expect("reopen for marker reset"); + // Force the marker into the "uninitialized" state by clearing + // and reseeding the legitimate record without bumping it. The + // simplest way is to clear() then re-seed; clear nukes + // last_applied_seq. + store.clear().expect("clear"); + store.seed( + "Caja", + caja, + json!({ + "id": caja.to_string(), + "name": "A", + "saldo": 999_999_i64, // poison still present + "currency": "USD", + }), + ); + // last_applied_seq is now None → mismatch with log_last → full replay path. + } + + let executor = Executor::load_module(treasury_module()).expect("load"); + let log = EventLog::open(&log_path).expect("reopen"); + let store = SurrealStore::new_persistent(&store_path).expect("reopen"); + + let socket_for_client = socket_path2.clone(); + let client = thread::spawn(move || -> Result<(), String> { + let mut conn = connect_with_retry(&socket_for_client); + let resp = exchange( + &mut conn, + json!({"op": "load", "entity": "Caja", "id": caja.to_string()}), + ); + let saldo = resp["value"]["saldo"].as_i64(); + let _ = exchange(&mut conn, json!({"op": "shutdown"})); + if saldo != Some(100_000) { + return Err(format!( + "full replay should restore canonical saldo (100_000), got {:?}", + saldo + )); + } + Ok(()) + }); + + run_server(executor, log, store, None, &socket_path2).expect("server clean exit"); + client.join().unwrap().expect("client assertions"); + + let _ = std::fs::remove_file(&log_path); + let _ = std::fs::remove_dir_all(&store_path); +} diff --git a/crates/modules/nakui/core/tests/sales.rs b/crates/modules/nakui/core/tests/sales.rs new file mode 100644 index 0000000..a9e5e49 --- /dev/null +++ b/crates/modules/nakui/core/tests/sales.rs @@ -0,0 +1,164 @@ +//! Cross-module integration tests. The `sales` module references entities +//! defined in `treasury` and `inventory` via its manifest's `schemas` list. +//! These tests assert: +//! - The kernel correctly bundles multiple .k files at module load. +//! - Per-entity KCL post-checks fire against the right schema even when +//! three are concatenated. +//! - A non-conserving morphism (sale = stock−1, caja+price) passes the +//! kernel cleanly because no `invariants.conserve` was declared. + +use std::path::{Path, PathBuf}; + +use nakui_core::executor::{ExecError, Executor}; +use nakui_core::store::{MemoryStore, Store}; +use serde_json::{Value, json}; +use uuid::Uuid; + +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root above core/") + .to_path_buf() +} + +fn sales_module() -> PathBuf { + workspace_root().join("modules/sales") +} + +fn caja_saldo(store: &MemoryStore, id: Uuid) -> i64 { + store + .load("Caja", id) + .and_then(|v| v.get("saldo").and_then(Value::as_i64)) + .expect("caja with saldo") +} + +fn stock_cantidad(store: &MemoryStore, id: Uuid) -> i64 { + store + .load("Stock", id) + .and_then(|v| v.get("cantidad").and_then(Value::as_i64)) + .expect("stock with cantidad") +} + +fn seed(store: &mut MemoryStore) -> (Uuid, Uuid) { + let stock = Uuid::new_v4(); + let caja = Uuid::new_v4(); + store.seed( + "Stock", + stock, + json!({ + "id": stock.to_string(), + "sku_id": "sku-test", + "ubicacion": "test-loc", + "cantidad": 500_i64, + }), + ); + store.seed( + "Caja", + caja, + json!({ + "id": caja.to_string(), + "name": "Caja Test", + "saldo": 1_000_000_i64, + "currency": "USD", + }), + ); + (stock, caja) +} + +#[test] +fn sale_decreases_stock_and_increases_caja() { + let exec = Executor::load_module(sales_module()).expect("load module"); + let mut store = MemoryStore::new(); + let (stock, caja) = seed(&mut store); + + let venta_id = Uuid::new_v4(); + let ops = exec + .run( + &mut store, + "vender", + &[("stock", stock), ("caja", caja)], + json!({ + "cantidad": 100_i64, + "precio_unitario": 5_000_i64, + "timestamp": "2026-05-04T10:00:00Z", + "venta_id": venta_id.to_string(), + }), + ) + .expect("sale must succeed"); + + assert_eq!(ops.len(), 3, "2 sets + 1 create"); + assert_eq!(stock_cantidad(&store, stock), 400); + assert_eq!(caja_saldo(&store, caja), 1_500_000); + + let venta = store + .load("Venta", venta_id) + .expect("venta must be persisted"); + assert_eq!(venta.get("total").and_then(Value::as_i64), Some(500_000)); + assert_eq!(venta.get("cantidad").and_then(Value::as_i64), Some(100)); + assert_eq!( + venta.get("currency").and_then(Value::as_str), + Some("USD") + ); +} + +#[test] +fn overdraw_stock_rejected_by_inventory_post_check() { + let exec = Executor::load_module(sales_module()).expect("load module"); + let mut store = MemoryStore::new(); + let (stock, caja) = seed(&mut store); + + let result = exec.run( + &mut store, + "vender", + &[("stock", stock), ("caja", caja)], + json!({ + "cantidad": 9999_i64, + "precio_unitario": 100_i64, + "timestamp": "2026-05-04T10:00:00Z", + "venta_id": Uuid::new_v4().to_string(), + }), + ); + + match result { + Err(ExecError::KclPost { role, entity, .. }) => { + assert_eq!(role, "stock"); + assert_eq!(entity, "Stock"); + } + other => panic!("expected KclPost on stock, got {:?}", other), + } + assert_eq!(stock_cantidad(&store, stock), 500); + assert_eq!(caja_saldo(&store, caja), 1_000_000); +} + +#[test] +fn venta_total_invariant_caught_when_corrupted() { + // The Venta schema's check block enforces `total == cantidad * precio`. + // The production script always produces a consistent total. To prove + // the schema check fires, this test would need a buggy script — that's + // covered indirectly: if anyone breaks the script, this fails. For now + // we just confirm a clean sale's Venta passes its own invariant. + let exec = Executor::load_module(sales_module()).expect("load module"); + let mut store = MemoryStore::new(); + let (stock, caja) = seed(&mut store); + let venta_id = Uuid::new_v4(); + + exec.run( + &mut store, + "vender", + &[("stock", stock), ("caja", caja)], + json!({ + "cantidad": 7_i64, + "precio_unitario": 13_i64, + "timestamp": "2026-05-04T10:00:00Z", + "venta_id": venta_id.to_string(), + }), + ) + .expect("sale must pass"); + + let venta = store.load("Venta", venta_id).expect("venta"); + assert_eq!( + venta.get("total").and_then(Value::as_i64), + Some(7 * 13), + "Venta.total must equal cantidad * precio" + ); +} diff --git a/crates/modules/nakui/core/tests/schema_versioning.rs b/crates/modules/nakui/core/tests/schema_versioning.rs new file mode 100644 index 0000000..b8a5542 --- /dev/null +++ b/crates/modules/nakui/core/tests/schema_versioning.rs @@ -0,0 +1,444 @@ +//! Schema versioning: every logged morphism carries a `schema_hash` that +//! pins it to the (kcl + manifest spec + rhai) bundle active at write +//! time. `verify_log` rejects logs whose entries were produced under +//! rules that no longer match the loaded executor. +//! +//! The tests here build a *temp copy* of the treasury module so we can +//! mutate its files without polluting the source tree. Each test cleans +//! its temp dir even if it panics (the helper drops via `TempModule`). + +use std::path::{Path, PathBuf}; + +use nakui_core::event_log::{ + EventLog, LogEntry, VerifyError, execute_and_log, replay, seed_and_log, verify_log, +}; +use nakui_core::executor::Executor; +use nakui_core::store::MemoryStore; +use serde_json::{Value, json}; +use uuid::Uuid; + +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root above core/") + .to_path_buf() +} + +fn treasury_module() -> PathBuf { + workspace_root().join("modules/treasury") +} + +fn fresh_log_path() -> PathBuf { + std::env::temp_dir().join(format!("nakui_schema_{}.jsonl", Uuid::new_v4())) +} + +/// Owned temp copy of a module directory. Drops the entire tree. +struct TempModule { + pub path: PathBuf, +} + +impl TempModule { + fn from(src: &Path) -> Self { + let dst = std::env::temp_dir().join(format!("nakui_module_{}", Uuid::new_v4())); + copy_dir_recursive(src, &dst).expect("copy module"); + Self { path: dst } + } +} + +impl Drop for TempModule { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } +} + +fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> { + std::fs::create_dir_all(dst)?; + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let ty = entry.file_type()?; + let dst_path = dst.join(entry.file_name()); + if ty.is_dir() { + copy_dir_recursive(&entry.path(), &dst_path)?; + } else { + std::fs::copy(entry.path(), &dst_path)?; + } + } + Ok(()) +} + +fn deposit_5k( + exec: &Executor, + store: &mut MemoryStore, + log: &mut EventLog, + caja: Uuid, +) { + execute_and_log( + exec, + store, + log, + "register_cash_move", + &[("caja", caja)], + json!({ + "monto": 5_000_i64, + "tipo": "in", + "timestamp": "2026-05-04T10:00:00Z", + "memo": "x", + "movimiento_id": Uuid::new_v4().to_string(), + }), + ) + .expect("deposit"); +} + +fn seed_caja(exec: &Executor, store: &mut MemoryStore, log: &mut EventLog, id: Uuid) { + seed_and_log( + exec, + store, + log, + "Caja", + id, + json!({"id": id.to_string(), "name": "A", "saldo": 100_000_i64, "currency": "USD"}), + ) + .unwrap(); +} + +#[test] +fn executor_exposes_per_morphism_schema_hash() { + let exec = Executor::load_module(treasury_module()).expect("load"); + let h_deposit = exec + .schema_hash("register_cash_move") + .expect("register_cash_move has a hash"); + let h_transfer = exec + .schema_hash("transfer_between_cajas") + .expect("transfer_between_cajas has a hash"); + assert_ne!( + h_deposit, h_transfer, + "different morphisms must have different hashes" + ); + assert!( + exec.schema_hash("not_a_real_morphism").is_none(), + "unknown morphisms have no hash" + ); + + // Re-loading the same module yields the same hashes — the contract + // depends only on the bytes on disk, not load-time state. + let exec2 = Executor::load_module(treasury_module()).expect("reload"); + assert_eq!(exec.schema_hash("register_cash_move"), exec2.schema_hash("register_cash_move")); +} + +#[test] +fn execute_and_log_writes_schema_hash_into_entries() { + let temp = TempModule::from(&treasury_module()); + let exec = Executor::load_module(&temp.path).expect("load"); + let log_path = fresh_log_path(); + let mut log = EventLog::open(&log_path).unwrap(); + let mut store = MemoryStore::new(); + + let a = Uuid::new_v4(); + seed_caja(&exec, &mut store, &mut log, a); + deposit_5k(&exec, &mut store, &mut log, a); + + let entries = log.entries().unwrap(); + let morphism_entry = entries + .iter() + .find_map(|e| match e { + LogEntry::Morphism { schema_hash, .. } => Some(*schema_hash), + _ => None, + }) + .expect("morphism entry present"); + assert_eq!( + morphism_entry, + Some(exec.schema_hash("register_cash_move").unwrap()), + "logged hash must equal the executor's hash for that morphism" + ); + + let _ = std::fs::remove_file(&log_path); +} + +#[test] +fn verify_log_passes_when_module_is_unchanged() { + let temp = TempModule::from(&treasury_module()); + let exec = Executor::load_module(&temp.path).expect("load"); + let log_path = fresh_log_path(); + let mut log = EventLog::open(&log_path).unwrap(); + let mut store = MemoryStore::new(); + + let a = Uuid::new_v4(); + seed_caja(&exec, &mut store, &mut log, a); + deposit_5k(&exec, &mut store, &mut log, a); + + verify_log(&log, &exec).expect("clean module → verify ok"); + + let _ = std::fs::remove_file(&log_path); +} + +#[test] +fn verify_log_rejects_log_after_morphism_script_changes() { + let temp = TempModule::from(&treasury_module()); + + // Write a log under the original script. + let log_path = fresh_log_path(); + let a = Uuid::new_v4(); + let original_hash; + { + let exec = Executor::load_module(&temp.path).expect("load v1"); + original_hash = exec.schema_hash("register_cash_move").unwrap(); + let mut log = EventLog::open(&log_path).unwrap(); + let mut store = MemoryStore::new(); + seed_caja(&exec, &mut store, &mut log, a); + deposit_5k(&exec, &mut store, &mut log, a); + } + + // Mutate the script with a real (non-cosmetic) change — prepend a + // new statement. The normalizer preserves this since it changes + // tokens, not just whitespace/comments. + let script_path = temp.path.join("morphisms/register_cash_move.rhai"); + let original = std::fs::read_to_string(&script_path).expect("read script"); + std::fs::write( + &script_path, + format!("let _audit_marker = 42;\n{}", original), + ) + .expect("write script"); + + // Reload — the hash for register_cash_move must change. + let exec2 = Executor::load_module(&temp.path).expect("reload v2"); + let new_hash = exec2.schema_hash("register_cash_move").unwrap(); + assert_ne!(original_hash, new_hash, "real source edit must move the hash"); + + // verify_log must surface SchemaMismatch, not OpsMismatch — the + // schema check runs first because "rules changed" is more + // actionable than "ops differ for some reason." + let log = EventLog::open(&log_path).unwrap(); + match verify_log(&log, &exec2) { + Err(VerifyError::SchemaMismatch { + morphism, + logged, + current, + .. + }) => { + assert_eq!(morphism, "register_cash_move"); + assert_eq!(logged, original_hash); + assert_eq!(current, new_hash); + } + other => panic!("expected SchemaMismatch, got {:?}", other), + } + + // Replay still works — it doesn't validate against the executor. + let replayed = replay(&log).expect("replay is schema-agnostic"); + assert!(replayed.records().contains_key("Caja")); + + let _ = std::fs::remove_file(&log_path); +} + +#[test] +fn legacy_log_without_schema_hash_still_replays_and_verifies() { + // Hand-craft a log entry that omits schema_hash entirely — what an + // older nakui-core would have written. The Option default lets it + // deserialize, replay walks ops the normal way, and verify_log + // skips the schema check because the entry predates the contract. + let log_path = fresh_log_path(); + let a = Uuid::new_v4(); + { + let exec = Executor::load_module(treasury_module()).expect("load"); + let mut log = EventLog::open(&log_path).unwrap(); + let mut store = MemoryStore::new(); + seed_caja(&exec, &mut store, &mut log, a); + // Now write a Morphism entry by hand, bypassing execute_and_log, + // simulating a log produced by an older binary. + let entry: Value = json!({ + "kind": "morphism", + "seq": log.next_seq(), + "morphism": "register_cash_move", + "inputs": {"caja": a.to_string()}, + "params": { + "monto": 5_000, + "tipo": "in", + "timestamp": "2026-05-04T10:00:00Z", + "memo": "legacy", + "movimiento_id": Uuid::new_v4().to_string(), + }, + "ops": [] + // NOTE: no schema_hash field — that's the legacy shape. + }); + // Append via raw IO to skip log.append's monotonic check (which + // we trivially satisfy anyway since seq is correct). + let line = serde_json::to_string(&entry).unwrap(); + let mut f = std::fs::OpenOptions::new() + .append(true) + .open(&log_path) + .unwrap(); + use std::io::Write; + f.write_all(line.as_bytes()).unwrap(); + f.write_all(b"\n").unwrap(); + f.sync_all().unwrap(); + } + + // Replay must succeed (no schema check). + let log = EventLog::open(&log_path).unwrap(); + let entries = log.entries().expect("entries parse"); + assert_eq!(entries.len(), 2, "seed + legacy morphism"); + let legacy = entries + .iter() + .find_map(|e| match e { + LogEntry::Morphism { schema_hash, .. } => Some(*schema_hash), + _ => None, + }) + .expect("morphism present"); + assert!( + legacy.is_none(), + "legacy entry must deserialize with schema_hash=None" + ); + + let _ = std::fs::remove_file(&log_path); +} + +#[test] +fn executor_exposes_schema_bundle_hash() { + let exec1 = Executor::load_module(treasury_module()).expect("load 1"); + let exec2 = Executor::load_module(treasury_module()).expect("load 2"); + assert_eq!( + exec1.schema_bundle_hash, exec2.schema_bundle_hash, + "bundle hash must be stable across re-loads of the same module" + ); + + // The bundle hash and the per-morphism hash live in different + // tag namespaces (`nakui-bundle-v1` vs `nakui-schema-v1`), so they + // can't accidentally collide even when the script bytes are + // empty/identical. + let morph_hash = exec1.schema_hash("register_cash_move").unwrap(); + assert_ne!(exec1.schema_bundle_hash, morph_hash); +} + +#[test] +fn seed_and_log_writes_bundle_hash_into_seed_entries() { + let exec = Executor::load_module(treasury_module()).expect("load"); + let log_path = fresh_log_path(); + let mut log = EventLog::open(&log_path).unwrap(); + let mut store = MemoryStore::new(); + let id = Uuid::new_v4(); + seed_caja(&exec, &mut store, &mut log, id); + + let entries = log.entries().unwrap(); + let seed_hash = entries + .iter() + .find_map(|e| match e { + LogEntry::Seed { schema_hash, .. } => Some(*schema_hash), + _ => None, + }) + .expect("seed entry present"); + assert_eq!( + seed_hash, + Some(exec.schema_bundle_hash), + "logged seed hash must equal the executor's bundle hash" + ); + + let _ = std::fs::remove_file(&log_path); +} + +#[test] +fn verify_log_rejects_seed_after_schema_kcl_changes() { + let temp = TempModule::from(&treasury_module()); + let log_path = fresh_log_path(); + let id = Uuid::new_v4(); + let original_hash; + { + let exec = Executor::load_module(&temp.path).expect("load v1"); + original_hash = exec.schema_bundle_hash; + let mut log = EventLog::open(&log_path).unwrap(); + let mut store = MemoryStore::new(); + seed_caja(&exec, &mut store, &mut log, id); + } + + // Mutate schema.k. Even a comment is enough — bundle hash is byte- + // level for the same false-positive-over-false-negative reason as + // morphism hashes. + let schema_path = temp.path.join("schema.k"); + let original = std::fs::read_to_string(&schema_path).expect("read schema"); + std::fs::write( + &schema_path, + format!("{}\n# seed-versioning-test mutation\n", original), + ) + .expect("write schema"); + + let exec2 = Executor::load_module(&temp.path).expect("reload v2"); + let new_hash = exec2.schema_bundle_hash; + assert_ne!(original_hash, new_hash, "schema.k byte change must move the bundle hash"); + + let log = EventLog::open(&log_path).unwrap(); + match verify_log(&log, &exec2) { + Err(VerifyError::SeedSchemaMismatch { + entity, + id: mismatched_id, + logged, + current, + .. + }) => { + assert_eq!(entity, "Caja"); + assert_eq!(mismatched_id, id); + assert_eq!(logged, original_hash); + assert_eq!(current, new_hash); + } + other => panic!("expected SeedSchemaMismatch, got {:?}", other), + } + + let _ = std::fs::remove_file(&log_path); +} + +#[test] +fn comment_only_edits_do_not_invalidate_the_hash() { + // The improvement that motivated the AST-aware normalization: + // operators leaving TODOs or whitespace edits in scripts no longer + // re-stamps every log entry. Same script behaviour ⇒ same hash. + let temp = TempModule::from(&treasury_module()); + let exec1 = Executor::load_module(&temp.path).expect("load v1"); + let h1 = exec1.schema_hash("register_cash_move").unwrap(); + + let script_path = temp.path.join("morphisms/register_cash_move.rhai"); + let original = std::fs::read_to_string(&script_path).expect("read"); + std::fs::write( + &script_path, + format!( + "// new top-level comment\n\n\n{}\n\n// trailing TODO\n/*\n block\n comment\n*/\n", + original.replace("// states.caja:", "// states.caja: EDITED COMMENT"), + ), + ) + .expect("write"); + + let exec2 = Executor::load_module(&temp.path).expect("reload v2"); + let h2 = exec2.schema_hash("register_cash_move").unwrap(); + assert_eq!( + h1, h2, + "comment-only and whitespace-only edits must not move the hash" + ); + + // Sanity: the bundle hash also stays intact (we didn't touch schema.k). + assert_eq!(exec1.schema_bundle_hash, exec2.schema_bundle_hash); +} + +#[test] +fn morphism_script_change_does_not_flag_unrelated_seeds() { + // Bundle hash covers schema.k only — a .rhai edit moves the + // morphism hash but leaves the bundle hash alone. So existing + // seeds verify cleanly even when a morphism's behaviour changed. + let temp = TempModule::from(&treasury_module()); + let log_path = fresh_log_path(); + let id = Uuid::new_v4(); + { + let exec = Executor::load_module(&temp.path).expect("load v1"); + let mut log = EventLog::open(&log_path).unwrap(); + let mut store = MemoryStore::new(); + seed_caja(&exec, &mut store, &mut log, id); + // No morphism executed — only the seed is in the log. + } + + // Modify a Rhai script. Bundle stays the same. + let script_path = temp.path.join("morphisms/register_cash_move.rhai"); + let original = std::fs::read_to_string(&script_path).expect("read"); + std::fs::write(&script_path, format!("{}\n// rhai-only mutation\n", original)).unwrap(); + + let exec2 = Executor::load_module(&temp.path).expect("reload"); + let log = EventLog::open(&log_path).unwrap(); + verify_log(&log, &exec2) + .expect("seed-only log should pass verify after a morphism-only change"); + + let _ = std::fs::remove_file(&log_path); +} diff --git a/crates/modules/nakui/core/tests/snapshot_chain.rs b/crates/modules/nakui/core/tests/snapshot_chain.rs new file mode 100644 index 0000000..afecc30 --- /dev/null +++ b/crates/modules/nakui/core/tests/snapshot_chain.rs @@ -0,0 +1,400 @@ +//! End-to-end tests for the snapshot lifecycle: capture, compact, and +//! boot from snapshot. Plus the schema-hash binding that ties a snapshot +//! to the bundle that produced it. + +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::UnixStream; +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::Duration; + +use nakui_core::event_log::{ + EventLog, Snapshot, SnapshotMismatchError, execute_and_log, replay, seed_and_log, +}; +use nakui_core::executor::Executor; +use nakui_core::run::run_server; +use nakui_core::store::{MemoryStore, Store}; +use serde_json::{Value, json}; +use uuid::Uuid; + +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root above core/") + .to_path_buf() +} + +fn treasury_module() -> PathBuf { + workspace_root().join("modules/treasury") +} + +fn fresh_log_path() -> PathBuf { + std::env::temp_dir().join(format!("nakui_snap_log_{}.jsonl", Uuid::new_v4())) +} + +fn fresh_snap_path() -> PathBuf { + std::env::temp_dir().join(format!("nakui_snap_{}.json", Uuid::new_v4())) +} + +fn fresh_socket_path() -> PathBuf { + std::env::temp_dir().join(format!("nakui_snap_run_{}.sock", Uuid::new_v4())) +} + +fn seed_caja(exec: &Executor, store: &mut MemoryStore, log: &mut EventLog, id: Uuid, saldo: i64) { + seed_and_log( + exec, + store, + log, + "Caja", + id, + json!({"id": id.to_string(), "name": "A", "saldo": saldo, "currency": "USD"}), + ) + .unwrap(); +} + +fn deposit(exec: &Executor, store: &mut MemoryStore, log: &mut EventLog, caja: Uuid, monto: i64) { + execute_and_log( + exec, + store, + log, + "register_cash_move", + &[("caja", caja)], + json!({ + "monto": monto, + "tipo": "in", + "timestamp": "2026-05-04T10:00:00Z", + "memo": "x", + "movimiento_id": Uuid::new_v4().to_string(), + }), + ) + .unwrap(); +} + +#[test] +fn module_schema_hash_is_stable_and_independent_of_load_order() { + let exec1 = Executor::load_module(treasury_module()).expect("load 1"); + let exec2 = Executor::load_module(treasury_module()).expect("load 2"); + assert_eq!( + exec1.module_schema_hash(), + exec2.module_schema_hash(), + "two clean loads of the same module → identical module hash" + ); +} + +#[test] +fn capture_records_executor_hash_legacy_does_not() { + let exec = Executor::load_module(treasury_module()).expect("load"); + let mut store = MemoryStore::new(); + store.seed("Caja", Uuid::new_v4(), json!({"x": 1})); + + let captured = Snapshot::capture(&store, 0, &exec); + assert_eq!(captured.schema_hash, Some(exec.module_schema_hash())); + + let legacy = Snapshot::from_memory_store(&store, 0); + assert_eq!(legacy.schema_hash, None, "legacy constructor opts out"); + + captured + .ensure_compatible_with(&exec) + .expect("captured snapshot is compatible with the executor that built it"); + legacy + .ensure_compatible_with(&exec) + .expect("legacy snapshot has no hash → no check, passes"); +} + +#[test] +fn ensure_compatible_with_rejects_mismatched_hash() { + let exec = Executor::load_module(treasury_module()).expect("load"); + let mut snap = Snapshot::capture(&MemoryStore::new(), 0, &exec); + // Tamper with the hash to simulate a snapshot from a different bundle. + snap.schema_hash = Some([0xAB; 32]); + match snap.ensure_compatible_with(&exec) { + Err(SnapshotMismatchError::SchemaMismatch { .. }) => {} + other => panic!("expected SchemaMismatch, got {:?}", other), + } +} + +#[test] +fn snapshot_then_compact_then_run_server_resumes_correctly() { + // The full operator workflow: + // 1. Run a series of WAL-validated ops. + // 2. Capture a snapshot covering the last seq. + // 3. Compact the log so it only retains entries past snap.seq. + // 4. Start a server pointing at the (compacted) log + snapshot. + // 5. Confirm the server's state is correct via the load op. + // + // After step 3 the log alone can't reconstruct the state — the + // snapshot is the only thing that proves the server isn't lying. + let log_path = fresh_log_path(); + let snap_path = fresh_snap_path(); + let socket_path = fresh_socket_path(); + + let caja = Uuid::new_v4(); + let snap_seq; + let captured_module_hash; + { + let exec = Executor::load_module(treasury_module()).expect("load"); + captured_module_hash = exec.module_schema_hash(); + let mut log = EventLog::open(&log_path).unwrap(); + let mut store = MemoryStore::new(); + seed_caja(&exec, &mut store, &mut log, caja, 100_000); + deposit(&exec, &mut store, &mut log, caja, 5_000); + deposit(&exec, &mut store, &mut log, caja, 7_500); + + snap_seq = log.next_seq() - 1; + let snap = Snapshot::capture(&store, snap_seq, &exec); + snap.write(&snap_path).unwrap(); + log.compact_through(snap_seq).unwrap(); + + // Sanity: after compaction the log has no surviving entries. + let surviving = log.entries().unwrap(); + assert_eq!(surviving.len(), 0); + // But next_seq is preserved, so future appends keep monotonicity. + assert_eq!(log.next_seq(), snap_seq + 1); + } + + // Verify the snapshot file carries the captured hash (resilient + // through write+read). + let reloaded = Snapshot::load(&snap_path).unwrap().unwrap(); + assert_eq!(reloaded.schema_hash, Some(captured_module_hash)); + assert_eq!(reloaded.seq, snap_seq); + + // Boot the server with snapshot + compacted log. + let executor = Executor::load_module(treasury_module()).expect("reload"); + let log = EventLog::open(&log_path).unwrap(); + let store = MemoryStore::new(); + + let socket_for_client = socket_path.clone(); + let client = thread::spawn(move || -> Result<(), String> { + let mut conn = connect_with_retry(&socket_for_client); + let resp = exchange(&mut conn, json!({"op": "load", "entity": "Caja", "id": caja.to_string()})); + if resp["value"]["saldo"].as_i64() != Some(112_500) { + return Err(format!( + "expected saldo 112_500 (100k seed + 5k + 7.5k from snapshot), got {}", + resp + )); + } + // Append a new op via the live server and load it back — + // confirms the WAL still works on top of a snapshot-loaded state. + let resp = exchange( + &mut conn, + json!({ + "op": "execute", + "morphism": "register_cash_move", + "inputs": {"caja": caja.to_string()}, + "params": { + "monto": 1_000_i64, + "tipo": "in", + "timestamp": "2026-05-04T11:00:00Z", + "memo": "post-snap", + "movimiento_id": Uuid::new_v4().to_string(), + } + }), + ); + if resp["ok"] != json!(true) { + return Err(format!("execute on snapshot-booted server failed: {}", resp)); + } + let resp = exchange(&mut conn, json!({"op": "load", "entity": "Caja", "id": caja.to_string()})); + if resp["value"]["saldo"].as_i64() != Some(113_500) { + return Err(format!("post-execute saldo wrong: {}", resp)); + } + send_shutdown(&mut conn); + Ok(()) + }); + + run_server(executor, log, store, Some(reloaded), &socket_path).expect("server clean exit"); + client.join().unwrap().expect("client assertions"); + + let _ = std::fs::remove_file(&log_path); + let _ = std::fs::remove_file(&snap_path); +} + +#[test] +fn run_server_refuses_snapshot_with_wrong_schema_hash() { + let log_path = fresh_log_path(); + let socket_path = fresh_socket_path(); + + let caja = Uuid::new_v4(); + { + let exec = Executor::load_module(treasury_module()).expect("load"); + let mut log = EventLog::open(&log_path).unwrap(); + let mut store = MemoryStore::new(); + seed_caja(&exec, &mut store, &mut log, caja, 100_000); + deposit(&exec, &mut store, &mut log, caja, 5_000); + } + + // Build a snapshot with a fabricated hash — simulates "snapshot + // taken under module A, loaded against module B." + let exec = Executor::load_module(treasury_module()).expect("reload"); + let log = EventLog::open(&log_path).unwrap(); + let snap_state = replay(&log).unwrap(); + let last_seq = log.entries().unwrap().last().unwrap().seq(); + let mut bad_snap = Snapshot::capture(&snap_state, last_seq, &exec); + bad_snap.schema_hash = Some([0xAB; 32]); + + let store = MemoryStore::new(); + let result = run_server(exec, log, store, Some(bad_snap), &socket_path); + assert!( + matches!( + result, + Err(nakui_core::run::RunError::SnapshotMismatch(_)) + ), + "expected SnapshotMismatch, got {:?}", + result + ); + // Socket must not have been bound. + assert!(!socket_path.exists()); + + let _ = std::fs::remove_file(&log_path); +} + +#[test] +fn run_server_detects_gap_between_snapshot_and_compacted_log() { + // Snapshot says it covers up to seq K. Log was compacted further, + // so its first remaining entry is K+5 — entries K+1..=K+4 are + // gone. run_server must refuse rather than silently fabricate a + // state that drops events. + let log_path = fresh_log_path(); + let socket_path = fresh_socket_path(); + + let caja = Uuid::new_v4(); + let exec = Executor::load_module(treasury_module()).expect("load"); + { + let mut log = EventLog::open(&log_path).unwrap(); + let mut store = MemoryStore::new(); + seed_caja(&exec, &mut store, &mut log, caja, 100_000); + deposit(&exec, &mut store, &mut log, caja, 1_000); + deposit(&exec, &mut store, &mut log, caja, 1_000); + deposit(&exec, &mut store, &mut log, caja, 1_000); + deposit(&exec, &mut store, &mut log, caja, 1_000); + deposit(&exec, &mut store, &mut log, caja, 1_000); + } + + // Snapshot at seq 0 (only the seed). + let mut log = EventLog::open(&log_path).unwrap(); + let mut state = MemoryStore::new(); + nakui_core::event_log::replay_with_snapshot_into(&log, None, &mut state).unwrap(); + let snap = Snapshot::capture(&state, 0, &exec); + + // Compact the log past the snapshot — drop seqs 0..=3, leaving + // entries from seq 4 onward. The snapshot can't reconstruct the + // missing tail. + log.compact_through(3).unwrap(); + drop(log); + + let exec = Executor::load_module(treasury_module()).expect("reload"); + let log = EventLog::open(&log_path).unwrap(); + let store = MemoryStore::new(); + let result = run_server(exec, log, store, Some(snap), &socket_path); + match result { + Err(nakui_core::run::RunError::SnapshotGap { + snap_seq, + log_first_seq, + expected, + }) => { + assert_eq!(snap_seq, 0); + assert_eq!(expected, 1); + assert!( + log_first_seq >= 4, + "log's first surviving entry should be ≥ 4, got {}", + log_first_seq + ); + } + other => panic!("expected SnapshotGap, got {:?}", other), + } + + let _ = std::fs::remove_file(&log_path); +} + +#[test] +fn snapshot_write_overwrites_existing_atomically() { + // Two snapshots at different seqs written to the same path. The + // second must completely replace the first; load() returns the + // newer one. + let snap_path = fresh_snap_path(); + let exec = Executor::load_module(treasury_module()).expect("load"); + + let s1 = Snapshot::capture(&MemoryStore::new(), 0, &exec); + s1.write(&snap_path).expect("write first"); + let loaded = Snapshot::load(&snap_path).unwrap().unwrap(); + assert_eq!(loaded.seq, 0); + + // Now write a different snapshot to the same path. + let mut store = MemoryStore::new(); + let id = Uuid::new_v4(); + store.seed("Caja", id, json!({"id": id.to_string(), "saldo": 7})); + let s2 = Snapshot::capture(&store, 42, &exec); + s2.write(&snap_path).expect("overwrite"); + let loaded = Snapshot::load(&snap_path).unwrap().unwrap(); + assert_eq!(loaded.seq, 42, "second write must replace the first"); + assert!(loaded.records.contains_key("Caja")); + + // No leftover tempfile. + let writing_path = snap_path.with_extension("writing"); + assert!( + !writing_path.exists(), + "tempfile must be renamed, not left behind" + ); + + let _ = std::fs::remove_file(&snap_path); +} + +#[test] +fn snapshot_write_recovers_from_stale_tempfile() { + // A prior write crashed after creating .writing but before rename. + // The next write must succeed regardless — File::create truncates + // the stale tempfile. + let snap_path = fresh_snap_path(); + let writing_path = snap_path.with_extension("writing"); + + // Plant a stale tempfile with garbage content. + std::fs::write(&writing_path, b"junk from a prior crashed write").unwrap(); + assert!(writing_path.exists()); + + let exec = Executor::load_module(treasury_module()).expect("load"); + let snap = Snapshot::capture(&MemoryStore::new(), 0, &exec); + snap.write(&snap_path).expect("write despite stale tempfile"); + + // Tempfile should be renamed (not orphaned), so it's gone. + assert!( + !writing_path.exists(), + "stale tempfile must be consumed by the rename" + ); + let loaded = Snapshot::load(&snap_path).unwrap().unwrap(); + assert_eq!(loaded.seq, 0); + + let _ = std::fs::remove_file(&snap_path); +} + +// === helpers shared with the run-server protocol tests === + +struct Conn { + writer: UnixStream, + reader: BufReader, +} + +fn connect_with_retry(path: &Path) -> Conn { + for _ in 0..200 { + if let Ok(stream) = UnixStream::connect(path) { + let reader_stream = stream.try_clone().expect("clone"); + return Conn { + writer: stream, + reader: BufReader::new(reader_stream), + }; + } + thread::sleep(Duration::from_millis(20)); + } + panic!("server never started accepting on {}", path.display()); +} + +fn exchange(conn: &mut Conn, req: Value) -> Value { + let mut bytes = serde_json::to_vec(&req).unwrap(); + bytes.push(b'\n'); + conn.writer.write_all(&bytes).unwrap(); + let mut line = String::new(); + conn.reader.read_line(&mut line).unwrap(); + serde_json::from_str(line.trim()).unwrap() +} + +fn send_shutdown(conn: &mut Conn) { + let _ = exchange(conn, json!({"op": "shutdown"})); +} diff --git a/crates/modules/nakui/core/tests/state_hash.rs b/crates/modules/nakui/core/tests/state_hash.rs new file mode 100644 index 0000000..19a3566 --- /dev/null +++ b/crates/modules/nakui/core/tests/state_hash.rs @@ -0,0 +1,159 @@ +//! Tests for the `Store::iter` / `Store::hash_state` contract under +//! realistic WAL flows: a live store and a log-replayed store must hash +//! identically, drift must be detectable as a hash mismatch, and the +//! property must hold across backends (within a backend — cross-backend +//! parity is a separate concern, see notes below). + +use std::path::{Path, PathBuf}; + +use nakui_core::event_log::{EventLog, execute_and_log, replay, seed_and_log}; +use nakui_core::executor::Executor; +use nakui_core::store::{MemoryStore, Store}; +use serde_json::json; +use uuid::Uuid; + +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root above core/") + .to_path_buf() +} + +fn treasury_module() -> PathBuf { + workspace_root().join("modules/treasury") +} + +fn fresh_log_path() -> PathBuf { + std::env::temp_dir().join(format!("nakui_hash_{}.jsonl", Uuid::new_v4())) +} + +fn seed_two_cajas( + exec: &Executor, + store: &mut MemoryStore, + log: &mut EventLog, + a: Uuid, + b: Uuid, +) { + seed_and_log( + exec, + store, + log, + "Caja", + a, + json!({"id": a.to_string(), "name": "A", "saldo": 200_000_i64, "currency": "USD"}), + ) + .unwrap(); + seed_and_log( + exec, + store, + log, + "Caja", + b, + json!({"id": b.to_string(), "name": "B", "saldo": 50_000_i64, "currency": "USD"}), + ) + .unwrap(); +} + +#[test] +fn live_store_hash_matches_replayed_store_hash() { + let exec = Executor::load_module(treasury_module()).expect("load"); + let log_path = fresh_log_path(); + let mut log = EventLog::open(&log_path).unwrap(); + let mut live = MemoryStore::new(); + + let a = Uuid::new_v4(); + let b = Uuid::new_v4(); + seed_two_cajas(&exec, &mut live, &mut log, a, b); + + execute_and_log( + &exec, + &mut live, + &mut log, + "register_cash_move", + &[("caja", a)], + json!({ + "monto": 25_000_i64, + "tipo": "in", + "timestamp": "2026-05-04T10:00:00Z", + "memo": "x", + "movimiento_id": Uuid::new_v4().to_string(), + }), + ) + .unwrap(); + execute_and_log( + &exec, + &mut live, + &mut log, + "transfer_between_cajas", + &[("source", a), ("dest", b)], + json!({ + "monto": 75_000_i64, + "timestamp": "2026-05-04T10:30:00Z", + "memo": "xf", + "transfer_id": Uuid::new_v4().to_string(), + }), + ) + .unwrap(); + + let replayed = replay(&log).expect("replay"); + + assert_eq!( + live.hash_state().unwrap(), + replayed.hash_state().unwrap(), + "live and replayed stores must hash identically" + ); + + let _ = std::fs::remove_file(&log_path); +} + +#[test] +fn drift_is_detectable_via_hash_diff() { + let exec = Executor::load_module(treasury_module()).expect("load"); + let log_path = fresh_log_path(); + let mut log = EventLog::open(&log_path).unwrap(); + let mut live = MemoryStore::new(); + + let a = Uuid::new_v4(); + let b = Uuid::new_v4(); + seed_two_cajas(&exec, &mut live, &mut log, a, b); + + let baseline = live.hash_state().unwrap(); + let replayed_baseline = replay(&log).unwrap().hash_state().unwrap(); + assert_eq!(baseline, replayed_baseline); + + // Drift the live store out-of-band — exactly what the drift detector + // is meant to catch. + live.seed( + "Caja", + a, + json!({"id": a.to_string(), "name": "A", "saldo": 999_999_i64, "currency": "USD"}), + ); + + let drifted = live.hash_state().unwrap(); + let log_canonical = replay(&log).unwrap().hash_state().unwrap(); + assert_ne!( + drifted, log_canonical, + "the whole point of hash_state: this comparison must surface the drift" + ); + + let _ = std::fs::remove_file(&log_path); +} + +#[test] +fn hash_state_is_stable_across_repeated_calls() { + // The hash must not drift just because we asked for it twice. + // Sounds obvious; protects against an iteration order that depends + // on a HashMap's per-process random seed sneaking past the sort. + let mut store = MemoryStore::new(); + for _ in 0..10 { + let id = Uuid::new_v4(); + store.seed( + "Caja", + id, + json!({"id": id.to_string(), "saldo": 100_i64, "currency": "USD"}), + ); + } + let h1 = store.hash_state().unwrap(); + let h2 = store.hash_state().unwrap(); + assert_eq!(h1, h2, "hash must be a function of state, not call order"); +} diff --git a/crates/modules/nakui/core/tests/surreal_persist.rs b/crates/modules/nakui/core/tests/surreal_persist.rs new file mode 100644 index 0000000..3963b1a --- /dev/null +++ b/crates/modules/nakui/core/tests/surreal_persist.rs @@ -0,0 +1,97 @@ +//! Persistence test for SurrealStore against the RocksDB backend. +//! +//! Gated behind the `persistent` Cargo feature because RocksDB is a heavy +//! native dep (~5 min to compile cold). Run with: +//! cargo test --features persistent --test surreal_persist + +#![cfg(feature = "persistent")] + +use std::path::PathBuf; + +use nakui_core::store::Store; +use nakui_core::surreal_store::SurrealStore; +use serde_json::{Value, json}; +use uuid::Uuid; + +fn fresh_db_path() -> PathBuf { + std::env::temp_dir().join(format!("nakui_persist_{}", Uuid::new_v4())) +} + +#[test] +fn data_survives_close_and_reopen() { + let path = fresh_db_path(); + let id = Uuid::new_v4(); + + { + let mut store = SurrealStore::new_persistent(&path).expect("open persistent"); + store.seed( + "Caja", + id, + json!({ + "id": id.to_string(), + "name": "persisted", + "saldo": 12_345_i64, + "currency": "USD", + }), + ); + // Drop store; runtime + db released. + } + + { + let store = SurrealStore::new_persistent(&path).expect("reopen persistent"); + let loaded = store + .load("Caja", id) + .expect("record must survive reopen"); + assert_eq!( + loaded.get("saldo").and_then(Value::as_i64), + Some(12_345), + "saldo persisted" + ); + assert_eq!( + loaded.get("currency").and_then(Value::as_str), + Some("USD"), + "currency persisted" + ); + } + + let _ = std::fs::remove_dir_all(&path); +} + +#[test] +fn applied_ops_persist_across_reopens() { + use nakui_core::delta::{FieldOp, FieldPath}; + + let path = fresh_db_path(); + let id = Uuid::new_v4(); + + { + let mut store = SurrealStore::new_persistent(&path).expect("open"); + store.seed( + "Caja", + id, + json!({"id": id.to_string(), "saldo": 100_i64, "currency": "USD"}), + ); + store + .apply(&[FieldOp::Set { + path: FieldPath { + entity: "Caja".into(), + id, + field: "saldo".into(), + }, + value: json!(999_i64), + }]) + .expect("apply Set"); + } + + { + let store = SurrealStore::new_persistent(&path).expect("reopen"); + let v = store.load("Caja", id).expect("present"); + assert_eq!( + v.get("saldo").and_then(Value::as_i64), + Some(999), + "Set op persisted across restart" + ); + } + + let _ = std::fs::remove_dir_all(&path); +} diff --git a/crates/modules/nakui/core/tests/surreal_store.rs b/crates/modules/nakui/core/tests/surreal_store.rs new file mode 100644 index 0000000..da12b88 --- /dev/null +++ b/crates/modules/nakui/core/tests/surreal_store.rs @@ -0,0 +1,537 @@ +//! SurrealStore: kv-mem SurrealDB behind the same `Store` trait. +//! +//! Tests confirm: round-trip persistence preserving the application-level +//! `id` field, the dry-run contract, and the full WAL flow against the +//! real DB driver — execute_and_log → replay_into → live equals replayed. + +use std::path::{Path, PathBuf}; + +use nakui_core::delta::{FieldOp, FieldPath}; +use nakui_core::event_log::{ + EventLog, execute_and_log, reconcile, replay_into, seed_and_log, verify_log, +}; +use nakui_core::executor::Executor; +use nakui_core::store::{MemoryStore, Store, StoreError}; +use nakui_core::surreal_store::SurrealStore; +use serde_json::{Value, json}; +use uuid::Uuid; + +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root above core/") + .to_path_buf() +} + +fn treasury_module() -> PathBuf { + workspace_root().join("modules/treasury") +} + +fn fresh_log_path() -> PathBuf { + std::env::temp_dir().join(format!("nakui_surreal_{}.jsonl", Uuid::new_v4())) +} + +fn caja_data(id: Uuid, saldo: i64, currency: &str) -> Value { + json!({ + "id": id.to_string(), + "name": "Caja", + "saldo": saldo, + "currency": currency, + }) +} + +#[test] +fn seed_then_load_preserves_application_id() { + let mut store = SurrealStore::new_in_memory().expect("surreal"); + let id = Uuid::new_v4(); + store.seed("Caja", id, caja_data(id, 100_000, "USD")); + + let loaded = store.load("Caja", id).expect("loaded"); + assert_eq!( + loaded.get("id").and_then(Value::as_str), + Some(id.to_string().as_str()), + "load must restore the application-level id field" + ); + assert_eq!(loaded.get("saldo").and_then(Value::as_i64), Some(100_000)); + assert_eq!(loaded.get("currency").and_then(Value::as_str), Some("USD")); +} + +#[test] +fn apply_set_updates_field() { + let mut store = SurrealStore::new_in_memory().expect("surreal"); + let id = Uuid::new_v4(); + store.seed("Caja", id, caja_data(id, 100_000, "USD")); + + store + .apply(&[FieldOp::Set { + path: FieldPath { + entity: "Caja".into(), + id, + field: "saldo".into(), + }, + value: json!(250_000_i64), + }]) + .expect("apply Set"); + + let loaded = store.load("Caja", id).expect("loaded"); + assert_eq!(loaded.get("saldo").and_then(Value::as_i64), Some(250_000)); + // Other fields preserved. + assert_eq!(loaded.get("currency").and_then(Value::as_str), Some("USD")); +} + +#[test] +fn apply_create_persists_record() { + let mut store = SurrealStore::new_in_memory().expect("surreal"); + let id = Uuid::new_v4(); + store + .apply(&[FieldOp::Create { + entity: "Movimiento".into(), + id, + data: json!({ + "id": id.to_string(), + "caja_id": Uuid::new_v4().to_string(), + "monto": 1000, + "tipo": "in", + "timestamp": "2026-05-04T00:00:00Z", + }), + }]) + .expect("apply Create"); + + let loaded = store.load("Movimiento", id).expect("loaded"); + assert_eq!(loaded.get("monto").and_then(Value::as_i64), Some(1000)); + assert_eq!(loaded.get("tipo").and_then(Value::as_str), Some("in")); +} + +#[test] +fn apply_delete_removes_record() { + let mut store = SurrealStore::new_in_memory().expect("surreal"); + let id = Uuid::new_v4(); + store.seed("Caja", id, caja_data(id, 100_000, "USD")); + + store + .apply(&[FieldOp::Delete { + entity: "Caja".into(), + id, + }]) + .expect("apply Delete"); + + assert!(store.load("Caja", id).is_none()); +} + +#[test] +fn dry_run_rejects_create_conflict() { + let mut store = SurrealStore::new_in_memory().expect("surreal"); + let id = Uuid::new_v4(); + store.seed("Caja", id, caja_data(id, 100, "USD")); + + let result = store.apply_dry_run(&[FieldOp::Create { + entity: "Caja".into(), + id, + data: json!({"id": id.to_string()}), + }]); + assert!(matches!(result, Err(StoreError::Conflict(_, _)))); +} + +#[test] +fn dry_run_rejects_set_not_found() { + let store = SurrealStore::new_in_memory().expect("surreal"); + let id = Uuid::new_v4(); + let result = store.apply_dry_run(&[FieldOp::Set { + path: FieldPath { + entity: "Caja".into(), + id, + field: "saldo".into(), + }, + value: json!(0), + }]); + assert!(matches!(result, Err(StoreError::NotFound(_, _)))); +} + +#[test] +fn full_wal_flow_against_surreal() { + let exec = Executor::load_module(treasury_module()).expect("load module"); + let log_path = fresh_log_path(); + let mut log = EventLog::open(&log_path).expect("open log"); + let mut live = SurrealStore::new_in_memory().expect("live store"); + + let a = Uuid::new_v4(); + let b = Uuid::new_v4(); + seed_and_log( + &exec, + &mut live, + &mut log, + "Caja", + a, + caja_data(a, 200_000, "USD"), + ) + .expect("seed A"); + seed_and_log( + &exec, + &mut live, + &mut log, + "Caja", + b, + caja_data(b, 50_000, "USD"), + ) + .expect("seed B"); + + execute_and_log( + &exec, + &mut live, + &mut log, + "register_cash_move", + &[("caja", a)], + json!({ + "monto": 25_000_i64, + "tipo": "in", + "timestamp": "2026-05-04T10:00:00Z", + "memo": "test", + "movimiento_id": Uuid::new_v4().to_string(), + }), + ) + .expect("deposit ok"); + + execute_and_log( + &exec, + &mut live, + &mut log, + "transfer_between_cajas", + &[("source", a), ("dest", b)], + json!({ + "monto": 75_000_i64, + "timestamp": "2026-05-04T10:30:00Z", + "memo": "xfer", + "transfer_id": Uuid::new_v4().to_string(), + }), + ) + .expect("transfer ok"); + + // Replay into a fresh SurrealStore and confirm field-by-field that + // saldos and entity counts match the live one. + let mut replayed = SurrealStore::new_in_memory().expect("replay store"); + replay_into(&log, &mut replayed).expect("replay"); + + let live_a = live.load("Caja", a).expect("live A"); + let replayed_a = replayed.load("Caja", a).expect("replayed A"); + assert_eq!( + live_a.get("saldo").and_then(Value::as_i64), + replayed_a.get("saldo").and_then(Value::as_i64) + ); + + let live_b = live.load("Caja", b).expect("live B"); + let replayed_b = replayed.load("Caja", b).expect("replayed B"); + assert_eq!( + live_b.get("saldo").and_then(Value::as_i64), + replayed_b.get("saldo").and_then(Value::as_i64) + ); + + assert_eq!(live_a.get("saldo").and_then(Value::as_i64), Some(150_000)); + assert_eq!(live_b.get("saldo").and_then(Value::as_i64), Some(125_000)); + + let _ = std::fs::remove_file(&log_path); +} + +#[test] +fn verify_log_against_surreal_passes() { + let exec = Executor::load_module(treasury_module()).expect("load module"); + let log_path = fresh_log_path(); + let mut log = EventLog::open(&log_path).expect("open log"); + let mut live = SurrealStore::new_in_memory().expect("live"); + + let a = Uuid::new_v4(); + let b = Uuid::new_v4(); + seed_and_log(&exec, &mut live, &mut log, "Caja", a, caja_data(a, 200_000, "USD")).unwrap(); + seed_and_log(&exec, &mut live, &mut log, "Caja", b, caja_data(b, 50_000, "USD")).unwrap(); + execute_and_log( + &exec, + &mut live, + &mut log, + "transfer_between_cajas", + &[("source", a), ("dest", b)], + json!({ + "monto": 25_000_i64, + "timestamp": "2026-05-04T11:00:00Z", + "memo": "v", + "transfer_id": Uuid::new_v4().to_string(), + }), + ) + .unwrap(); + + // verify_log internally creates its own MemoryStore for re-execution; + // even though `live` is SurrealStore, the determinism check is + // re-running each morphism through the kernel and comparing ops, so + // the verification store backend doesn't need to match the live one. + verify_log(&log, &exec).expect("re-execution must produce identical ops"); + + let _ = std::fs::remove_file(&log_path); +} + +#[test] +fn replay_into_memorystore_from_surreal_run_log() { + // Ensure logs produced by SurrealStore-backed runs replay correctly + // into a *different* backend (MemoryStore). The log is the source of + // truth — backend choice shouldn't change the replay result. + let exec = Executor::load_module(treasury_module()).expect("load"); + let log_path = fresh_log_path(); + let mut log = EventLog::open(&log_path).expect("open"); + + let mut surreal_live = SurrealStore::new_in_memory().expect("surreal"); + let a = Uuid::new_v4(); + seed_and_log( + &exec, + &mut surreal_live, + &mut log, + "Caja", + a, + caja_data(a, 100_000, "USD"), + ) + .unwrap(); + execute_and_log( + &exec, + &mut surreal_live, + &mut log, + "register_cash_move", + &[("caja", a)], + json!({ + "monto": 50_000_i64, + "tipo": "in", + "timestamp": "2026-05-04T08:00:00Z", + "memo": "x", + "movimiento_id": Uuid::new_v4().to_string(), + }), + ) + .unwrap(); + + let mut mem_replay = MemoryStore::new(); + replay_into(&log, &mut mem_replay).expect("replay"); + + let live_saldo = surreal_live + .load("Caja", a) + .and_then(|v| v.get("saldo").and_then(Value::as_i64)) + .unwrap(); + let replay_saldo = mem_replay + .load("Caja", a) + .and_then(|v| v.get("saldo").and_then(Value::as_i64)) + .unwrap(); + assert_eq!(live_saldo, replay_saldo); + assert_eq!(live_saldo, 150_000); + + let _ = std::fs::remove_file(&log_path); +} + +#[test] +fn clear_drops_all_records_across_tables() { + let mut store = SurrealStore::new_in_memory().expect("surreal"); + let caja_id = Uuid::new_v4(); + let mov_id = Uuid::new_v4(); + store.seed("Caja", caja_id, caja_data(caja_id, 100_000, "USD")); + store.seed( + "Movimiento", + mov_id, + json!({ + "id": mov_id.to_string(), + "caja_id": caja_id.to_string(), + "monto": 1_000, + "tipo": "in", + "timestamp": "2026-05-04T00:00:00Z", + }), + ); + assert!(store.load("Caja", caja_id).is_some()); + assert!(store.load("Movimiento", mov_id).is_some()); + + store.clear().expect("clear"); + + assert!( + store.load("Caja", caja_id).is_none(), + "clear must drop records from every table" + ); + assert!(store.load("Movimiento", mov_id).is_none()); + + // Store is reusable after clear — seed a new record and load it back. + let fresh = Uuid::new_v4(); + store.seed("Caja", fresh, caja_data(fresh, 1, "USD")); + assert!(store.load("Caja", fresh).is_some()); +} + +#[test] +fn cross_backend_hash_equals_for_equivalent_data() { + // The whole point of the canonical Value hasher: a SurrealStore + // and a MemoryStore that hold the same logical records must hash + // identically. Same WAL log replayed into each backend ⇒ + // hash_state produces byte-equal output. + let exec = Executor::load_module(treasury_module()).expect("load module"); + let log_path = fresh_log_path(); + let mut log = EventLog::open(&log_path).expect("open log"); + + let mut surreal = SurrealStore::new_in_memory().expect("surreal"); + let mut memory = MemoryStore::new(); + + let a = Uuid::new_v4(); + let b = Uuid::new_v4(); + // Seed both backends through the WAL so they go through identical + // op sequences. We seed each backend separately because seed_and_log + // takes one store at a time. + seed_and_log( + &exec, + &mut surreal, + &mut log, + "Caja", + a, + caja_data(a, 200_000, "USD"), + ) + .unwrap(); + seed_and_log( + &exec, + &mut surreal, + &mut log, + "Caja", + b, + caja_data(b, 50_000, "USD"), + ) + .unwrap(); + execute_and_log( + &exec, + &mut surreal, + &mut log, + "register_cash_move", + &[("caja", a)], + json!({ + "monto": 1_000_i64, + "tipo": "in", + "timestamp": "2026-05-04T08:00:00Z", + "memo": "x", + "movimiento_id": Uuid::new_v4().to_string(), + }), + ) + .unwrap(); + + // Replay that same log into a fresh MemoryStore. + nakui_core::event_log::replay_into(&log, &mut memory).expect("replay"); + + let h_surreal = surreal.hash_state().expect("surreal hash"); + let h_memory = memory.hash_state().expect("memory hash"); + assert_eq!( + h_surreal, h_memory, + "MemoryStore and SurrealStore must hash identically for the same WAL state" + ); + + let _ = std::fs::remove_file(&log_path); +} + +#[test] +fn iter_and_hash_state_round_trip_against_surreal() { + // Build the same WAL flow against two independent SurrealStores. + // Each store reaches the same logical state via a different path + // (one via execute_and_log, the other via replay_into) and they + // must hash identically — that's the contract drift detection + // sits on top of. + let exec = Executor::load_module(treasury_module()).expect("load module"); + let log_path = fresh_log_path(); + let mut log = EventLog::open(&log_path).expect("open log"); + + let mut live = SurrealStore::new_in_memory().expect("live"); + let a = Uuid::new_v4(); + let b = Uuid::new_v4(); + seed_and_log(&exec, &mut live, &mut log, "Caja", a, caja_data(a, 200_000, "USD")).unwrap(); + seed_and_log(&exec, &mut live, &mut log, "Caja", b, caja_data(b, 50_000, "USD")).unwrap(); + execute_and_log( + &exec, + &mut live, + &mut log, + "register_cash_move", + &[("caja", a)], + json!({ + "monto": 1_000_i64, + "tipo": "in", + "timestamp": "2026-05-04T08:00:00Z", + "memo": "x", + "movimiento_id": Uuid::new_v4().to_string(), + }), + ) + .unwrap(); + + // iter must enumerate every record. + let recs: Vec<_> = live.iter().expect("iter").collect(); + let by_entity: std::collections::HashMap<&str, usize> = + recs.iter().fold(std::collections::HashMap::new(), |mut m, (e, _, _)| { + *m.entry(e.as_str()).or_insert(0) += 1; + m + }); + assert_eq!(by_entity.get("Caja").copied(), Some(2), "two Cajas"); + assert_eq!(by_entity.get("Movimiento").copied(), Some(1), "one Movimiento"); + + // canonical order: entities sorted, ids byte-sorted within entity. + let entities: Vec<&str> = recs.iter().map(|(e, _, _)| e.as_str()).collect(); + assert!( + entities.windows(2).all(|w| w[0] <= w[1]), + "entities must be sorted: {:?}", + entities + ); + + // Replay the log into a fresh SurrealStore — same hash. + let mut replayed = SurrealStore::new_in_memory().expect("replay store"); + replay_into(&log, &mut replayed).expect("replay"); + assert_eq!( + live.hash_state().unwrap(), + replayed.hash_state().unwrap(), + "live and replayed SurrealStores must hash identically" + ); + + // Drift detection: tamper one saldo and confirm the hash diverges. + live.seed("Caja", a, caja_data(a, 999_999, "USD")); + assert_ne!( + live.hash_state().unwrap(), + replayed.hash_state().unwrap(), + "out-of-band saldo change must show up as a hash mismatch" + ); + + let _ = std::fs::remove_file(&log_path); +} + +#[test] +fn reconcile_rebuilds_drifted_surreal_store_from_log() { + let exec = Executor::load_module(treasury_module()).expect("load module"); + let log_path = fresh_log_path(); + let mut log = EventLog::open(&log_path).expect("open log"); + let mut store = SurrealStore::new_in_memory().expect("surreal"); + + let a = Uuid::new_v4(); + seed_and_log(&exec, &mut store, &mut log, "Caja", a, caja_data(a, 100_000, "USD")).unwrap(); + execute_and_log( + &exec, + &mut store, + &mut log, + "register_cash_move", + &[("caja", a)], + json!({ + "monto": 5_000_i64, + "tipo": "in", + "timestamp": "2026-05-04T10:00:00Z", + "memo": "x", + "movimiento_id": Uuid::new_v4().to_string(), + }), + ) + .unwrap(); + + // Drift: a poison record nobody logged + an out-of-band saldo bump. + let ghost = Uuid::new_v4(); + store.seed("Caja", ghost, caja_data(ghost, 0, "USD")); + store.seed("Caja", a, caja_data(a, 999_999, "USD")); + assert_eq!( + store.load("Caja", a).and_then(|v| v.get("saldo").and_then(Value::as_i64)), + Some(999_999), + "drift was applied" + ); + + reconcile(&mut store, &log).expect("reconcile"); + + // After reconcile: ghost gone, saldo = 100_000 (seed) + 5_000 (deposit). + assert!(store.load("Caja", ghost).is_none(), "poison record wiped"); + assert_eq!( + store.load("Caja", a).and_then(|v| v.get("saldo").and_then(Value::as_i64)), + Some(105_000), + "reconcile must restore log-canonical saldo" + ); + + let _ = std::fs::remove_file(&log_path); +} diff --git a/crates/modules/nakui/modules/inventory/morphisms/recibir_stock.rhai b/crates/modules/nakui/modules/inventory/morphisms/recibir_stock.rhai new file mode 100644 index 0000000..d51e96b --- /dev/null +++ b/crates/modules/nakui/modules/inventory/morphisms/recibir_stock.rhai @@ -0,0 +1,36 @@ +// recibir_stock +// Inflow: external supplier delivers `cantidad` units into a Stock record. +// NOT conservation-bound: the units enter the system from outside. +// +// states.stock: the current Stock record. +// ids.stock: its UUID (string). +// params: { cantidad:i64, timestamp:str, movimiento_id:str } + +let cantidad = input.params.cantidad; +if cantidad <= 0 { + throw "cantidad debe ser positiva (la dirección la fija el morfismo)" +} +let mov_id = input.params.movimiento_id; +if type_of(mov_id) == "()" { + throw "params.movimiento_id es obligatorio (idempotencia)" +} + +[ + #{ + op: "set", + path: #{ entity: "Stock", id: input.ids.stock, field: "cantidad" }, + value: input.states.stock.cantidad + cantidad, + }, + #{ + op: "create", + entity: "MovimientoStock", + id: mov_id, + data: #{ + id: mov_id, + stock_id: input.ids.stock, + delta: cantidad, + razon: "recepcion", + timestamp: input.params.timestamp, + }, + }, +] diff --git a/crates/modules/nakui/modules/inventory/morphisms/transferir_stock.rhai b/crates/modules/nakui/modules/inventory/morphisms/transferir_stock.rhai new file mode 100644 index 0000000..6d13266 --- /dev/null +++ b/crates/modules/nakui/modules/inventory/morphisms/transferir_stock.rhai @@ -0,0 +1,50 @@ +// transferir_stock +// Two-input morphism. Conservation rule (Stock.cantidad grouped by sku_id) +// is enforced by the kernel against the produced ops. Same-SKU is also +// asserted in-script for an explicit error message; without that check +// the kernel would still catch the violation via per-sku grouping. +// +// states.source / states.dest: the two Stock records. +// ids.source / ids.dest: their UUIDs. +// params: { cantidad:i64, timestamp:str, transfer_id:str } + +let cantidad = input.params.cantidad; +let source = input.states.source; +let dest = input.states.dest; + +if cantidad <= 0 { + throw "cantidad debe ser positiva" +} +if source.sku_id != dest.sku_id { + throw "transferencia exige mismo SKU; source=" + source.sku_id + " dest=" + dest.sku_id +} +let xfr_id = input.params.transfer_id; +if type_of(xfr_id) == "()" { + throw "params.transfer_id es obligatorio (idempotencia)" +} + +[ + #{ + op: "set", + path: #{ entity: "Stock", id: input.ids.source, field: "cantidad" }, + value: source.cantidad - cantidad, + }, + #{ + op: "set", + path: #{ entity: "Stock", id: input.ids.dest, field: "cantidad" }, + value: dest.cantidad + cantidad, + }, + #{ + op: "create", + entity: "TransferenciaStock", + id: xfr_id, + data: #{ + id: xfr_id, + source_stock_id: input.ids.source, + dest_stock_id: input.ids.dest, + sku_id: source.sku_id, + cantidad: cantidad, + timestamp: input.params.timestamp, + }, + }, +] diff --git a/crates/modules/nakui/modules/inventory/nsmc.json b/crates/modules/nakui/modules/inventory/nsmc.json new file mode 100644 index 0000000..6971f19 --- /dev/null +++ b/crates/modules/nakui/modules/inventory/nsmc.json @@ -0,0 +1,31 @@ +{ + "module": "inventory", + "morphisms": [ + { + "name": "recibir_stock", + "inputs": [ + { "role": "stock", "entity": "Stock" } + ], + "reads": ["stock.cantidad", "stock.sku_id"], + "writes": ["stock.cantidad", "MovimientoStock"], + "depends_on": [], + "script": "morphisms/recibir_stock.rhai" + }, + { + "name": "transferir_stock", + "inputs": [ + { "role": "source", "entity": "Stock" }, + { "role": "dest", "entity": "Stock" } + ], + "reads": ["source.cantidad", "source.sku_id", "dest.cantidad", "dest.sku_id"], + "writes": ["source.cantidad", "dest.cantidad", "TransferenciaStock"], + "invariants": { + "conserve": [ + { "entity": "Stock", "field": "cantidad", "group_by": "sku_id" } + ] + }, + "depends_on": [], + "script": "morphisms/transferir_stock.rhai" + } + ] +} diff --git a/crates/modules/nakui/modules/inventory/schema.k b/crates/modules/nakui/modules/inventory/schema.k new file mode 100644 index 0000000..0dbf2c7 --- /dev/null +++ b/crates/modules/nakui/modules/inventory/schema.k @@ -0,0 +1,34 @@ +schema Stock: + id: str + sku_id: str + ubicacion: str + cantidad: int + + check: + cantidad >= 0, "stock no puede ser negativo" + len(ubicacion) > 0, "ubicacion requerida" + len(sku_id) > 0, "sku_id requerido" + +schema MovimientoStock: + id: str + stock_id: str + delta: int + razon: str + timestamp: str + + check: + razon in ["recepcion", "despacho", "ajuste"], "razon invalida" + delta != 0, "delta no puede ser cero" + +schema TransferenciaStock: + id: str + source_stock_id: str + dest_stock_id: str + sku_id: str + cantidad: int + timestamp: str + + check: + cantidad > 0, "cantidad debe ser positiva" + source_stock_id != dest_stock_id, "source y dest no pueden ser el mismo stock" + len(sku_id) > 0, "sku_id requerido" diff --git a/crates/modules/nakui/modules/sales/morphisms/vender.rhai b/crates/modules/nakui/modules/sales/morphisms/vender.rhai new file mode 100644 index 0000000..eb09819 --- /dev/null +++ b/crates/modules/nakui/modules/sales/morphisms/vender.rhai @@ -0,0 +1,52 @@ +// vender +// Cross-module morphism: decreases Stock (inventory) and increases Caja +// (treasury). NOT conservation-bound — a sale is asymmetric: units leave +// the system, money enters. The kernel validates each entity against its +// own schema (Stock from inventory, Caja from treasury, Venta from sales). +// +// states.stock: the Stock record (inventory module). +// states.caja: the Caja record (treasury module). +// params: { cantidad:i64, precio_unitario:i64 (en centavos), +// timestamp:str, venta_id:str } + +let cantidad = input.params.cantidad; +let precio = input.params.precio_unitario; +let venta_id = input.params.venta_id; + +if cantidad <= 0 { throw "cantidad debe ser positiva" } +if precio <= 0 { throw "precio_unitario debe ser positivo" } +if type_of(venta_id) == "()" { throw "params.venta_id es obligatorio (idempotencia)" } + +let stock = input.states.stock; +let caja = input.states.caja; + +let total = cantidad * precio; + +[ + #{ + op: "set", + path: #{ entity: "Stock", id: input.ids.stock, field: "cantidad" }, + value: stock.cantidad - cantidad, + }, + #{ + op: "set", + path: #{ entity: "Caja", id: input.ids.caja, field: "saldo" }, + value: caja.saldo + total, + }, + #{ + op: "create", + entity: "Venta", + id: venta_id, + data: #{ + id: venta_id, + stock_id: input.ids.stock, + caja_id: input.ids.caja, + sku_id: stock.sku_id, + cantidad: cantidad, + precio_unitario: precio, + currency: caja.currency, + total: total, + timestamp: input.params.timestamp, + }, + }, +] diff --git a/crates/modules/nakui/modules/sales/nsmc.json b/crates/modules/nakui/modules/sales/nsmc.json new file mode 100644 index 0000000..438c476 --- /dev/null +++ b/crates/modules/nakui/modules/sales/nsmc.json @@ -0,0 +1,21 @@ +{ + "module": "sales", + "schemas": [ + "schema.k", + "../treasury/schema.k", + "../inventory/schema.k" + ], + "morphisms": [ + { + "name": "vender", + "inputs": [ + { "role": "stock", "entity": "Stock" }, + { "role": "caja", "entity": "Caja" } + ], + "reads": ["stock.cantidad", "stock.sku_id", "caja.saldo", "caja.currency"], + "writes": ["stock.cantidad", "caja.saldo", "Venta"], + "depends_on": [], + "script": "morphisms/vender.rhai" + } + ] +} diff --git a/crates/modules/nakui/modules/sales/schema.k b/crates/modules/nakui/modules/sales/schema.k new file mode 100644 index 0000000..a55ccef --- /dev/null +++ b/crates/modules/nakui/modules/sales/schema.k @@ -0,0 +1,16 @@ +schema Venta: + id: str + stock_id: str + caja_id: str + sku_id: str + cantidad: int + precio_unitario: int + currency: str + total: int + timestamp: str + + check: + cantidad > 0, "cantidad positiva" + precio_unitario > 0, "precio_unitario positivo" + len(currency) == 3, "currency ISO 4217" + total == cantidad * precio_unitario, "total debe ser cantidad * precio_unitario" diff --git a/crates/modules/nakui/modules/treasury/morphisms/register_cash_move.rhai b/crates/modules/nakui/modules/treasury/morphisms/register_cash_move.rhai new file mode 100644 index 0000000..8ac42c0 --- /dev/null +++ b/crates/modules/nakui/modules/treasury/morphisms/register_cash_move.rhai @@ -0,0 +1,46 @@ +// register_cash_move +// Pure transition. Receives `input` as { states, ids, params }; returns +// an array of FieldOp objects. No clock, no random, no IO. +// +// states.caja: the current Caja record loaded from the store. +// ids.caja: the Caja's UUID (string form). +// params: { monto:i64, tipo:"in"|"out", timestamp:str, memo:str, movimiento_id:str } + +let saldo = input.states.caja.saldo; +let monto = input.params.monto; +let tipo = input.params.tipo; +let caja_id = input.ids.caja; + +let nuevo_saldo = if tipo == "in" { + saldo + monto +} else if tipo == "out" { + saldo - monto +} else { + throw "tipo desconocido: " + tipo +}; + +let mov_id = input.params.movimiento_id; +if type_of(mov_id) == "()" { + throw "params.movimiento_id es obligatorio (idempotencia)" +} + +[ + #{ + op: "set", + path: #{ entity: "Caja", id: caja_id, field: "saldo" }, + value: nuevo_saldo, + }, + #{ + op: "create", + entity: "Movimiento", + id: mov_id, + data: #{ + id: mov_id, + caja_id: caja_id, + monto: monto, + tipo: tipo, + timestamp: input.params.timestamp, + memo: input.params.memo, + }, + }, +] diff --git a/crates/modules/nakui/modules/treasury/morphisms/transfer_between_cajas.rhai b/crates/modules/nakui/modules/treasury/morphisms/transfer_between_cajas.rhai new file mode 100644 index 0000000..3c71266 --- /dev/null +++ b/crates/modules/nakui/modules/treasury/morphisms/transfer_between_cajas.rhai @@ -0,0 +1,53 @@ +// transfer_between_cajas +// Two-input morphism. Conservation rule (Caja.saldo grouped by currency) +// is checked by the kernel against the produced ops; the script just emits +// the deltas. +// +// states.source / states.dest: the two Caja records. +// ids.source / ids.dest: their UUIDs (string form). +// params: { monto:i64, timestamp:str, memo:str, transfer_id:str } + +let source = input.states.source; +let dest = input.states.dest; +let monto = input.params.monto; + +if monto <= 0 { + throw "monto debe ser positivo" +} +if source.currency != dest.currency { + throw "transferencia exige misma moneda; source=" + source.currency + " dest=" + dest.currency +} +let transfer_id = input.params.transfer_id; +if type_of(transfer_id) == "()" { + throw "params.transfer_id es obligatorio (idempotencia)" +} + +let new_source_saldo = source.saldo - monto; +let new_dest_saldo = dest.saldo + monto; + +[ + #{ + op: "set", + path: #{ entity: "Caja", id: input.ids.source, field: "saldo" }, + value: new_source_saldo, + }, + #{ + op: "set", + path: #{ entity: "Caja", id: input.ids.dest, field: "saldo" }, + value: new_dest_saldo, + }, + #{ + op: "create", + entity: "Transferencia", + id: transfer_id, + data: #{ + id: transfer_id, + source_caja_id: input.ids.source, + dest_caja_id: input.ids.dest, + monto: monto, + currency: source.currency, + timestamp: input.params.timestamp, + memo: input.params.memo, + }, + }, +] diff --git a/crates/modules/nakui/modules/treasury/nsmc.json b/crates/modules/nakui/modules/treasury/nsmc.json new file mode 100644 index 0000000..7fd67a4 --- /dev/null +++ b/crates/modules/nakui/modules/treasury/nsmc.json @@ -0,0 +1,31 @@ +{ + "module": "treasury", + "morphisms": [ + { + "name": "register_cash_move", + "inputs": [ + { "role": "caja", "entity": "Caja" } + ], + "reads": ["caja.saldo", "caja.currency"], + "writes": ["caja.saldo", "Movimiento"], + "depends_on": [], + "script": "morphisms/register_cash_move.rhai" + }, + { + "name": "transfer_between_cajas", + "inputs": [ + { "role": "source", "entity": "Caja" }, + { "role": "dest", "entity": "Caja" } + ], + "reads": ["source.saldo", "source.currency", "dest.saldo", "dest.currency"], + "writes": ["source.saldo", "dest.saldo", "Transferencia"], + "invariants": { + "conserve": [ + { "entity": "Caja", "field": "saldo", "group_by": "currency" } + ] + }, + "depends_on": [], + "script": "morphisms/transfer_between_cajas.rhai" + } + ] +} diff --git a/crates/modules/nakui/modules/treasury/schema.k b/crates/modules/nakui/modules/treasury/schema.k new file mode 100644 index 0000000..9f65ec6 --- /dev/null +++ b/crates/modules/nakui/modules/treasury/schema.k @@ -0,0 +1,35 @@ +schema Caja: + id: str + name: str + saldo: int + currency: str + + check: + saldo >= 0, "saldo de caja no puede ser negativo" + len(currency) == 3, "currency debe ser ISO 4217 (3 letras)" + +schema Movimiento: + id: str + caja_id: str + monto: int + tipo: str + timestamp: str + memo?: str + + check: + monto > 0, "monto debe ser positivo (la direccion la fija el tipo)" + tipo in ["in", "out"], "tipo debe ser 'in' u 'out'" + +schema Transferencia: + id: str + source_caja_id: str + dest_caja_id: str + monto: int + currency: str + timestamp: str + memo?: str + + check: + monto > 0, "monto debe ser positivo" + len(currency) == 3, "currency ISO 4217" + source_caja_id != dest_caja_id, "source y dest no pueden ser la misma caja"