{"messages":[{"content":"You are a constitutional council ranking individual git commits for ownership allocation.\n\nCompare these two commits. Decide which contributed more lasting value to the project.\n\nJudge substance, not spectacle:\n- Prefer correct, lasting design and real bugfixes over churn, formatting, renames, or generated noise.\n- Prefer clarity and necessity over sheer line count. A small precise change can beat a large diffuse one.\n- Do not favor a side merely because its patch is longer or noisier.\n- Weight what the change does for the project, not the contributor's name.\n\nReturn ONLY a JSON object: {\"winner\": \"A\" or \"B\", \"ratio\": \"N:M\", \"explanation\": \"...\"}\nThe explanation must cite concrete differences in the patches (1-3 sentences).\n\nSide A — contributor: tommy-mor\nSide A — commit message:\n[285b64d4] Offload Reddit payloads to RocksDB and stream event log replay.\n\nVendor durable as a workspace crate, store entity JSON in entity_db instead\nof GlobalTree, and replay events.jsonl one line at a time to cut startup RAM.\n\nCo-authored-by: Cursor \n\nSide A — unified diff (full patch):\ndiff --git a/Cargo.lock b/Cargo.lock\nindex e55d87f32ab32064686431c7082ef8c9ca872d63..8fc09f9ac978bd7ccf57f177989057b606677db8 100644\n--- a/Cargo.lock\n+++ b/Cargo.lock\n@@ -11,6 +11,18 @@ dependencies = [\n \"memchr\",\n ]\n \n+[[package]]\n+name = \"anes\"\n+version = \"0.1.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299\"\n+\n+[[package]]\n+name = \"anstyle\"\n+version = \"1.0.14\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000\"\n+\n [[package]]\n name = \"anyhow\"\n version = \"1.0.102\"\n@@ -56,6 +68,12 @@ version = \"1.1.2\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0\"\n \n+[[package]]\n+name = \"autocfg\"\n+version = \"1.5.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53\"\n+\n [[package]]\n name = \"axum\"\n version = \"0.7.9\"\n@@ -153,6 +171,75 @@ version = \"0.22.1\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6\"\n \n+[[package]]\n+name = \"bincode\"\n+version = \"1.3.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad\"\n+dependencies = [\n+ \"serde\",\n+]\n+\n+[[package]]\n+name = \"bindgen\"\n+version = \"0.65.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"cfdf7b466f9a4903edc73f95d6d2bcd5baf8ae620638762244d3f60143643cc5\"\n+dependencies = [\n+ \"bitflags 1.3.2\",\n+ \"cexpr\",\n+ \"clang-sys\",\n+ \"lazy_static\",\n+ \"lazycell\",\n+ \"peeking_take_while\",\n+ \"prettyplease\",\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"regex\",\n+ \"rustc-hash 1.1.0\",\n+ \"shlex\",\n+ \"syn\",\n+]\n+\n+[[package]]\n+name = \"bindgen\"\n+version = \"0.72.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895\"\n+dependencies = [\n+ \"bitflags 2.11.1\",\n+ \"cexpr\",\n+ \"clang-sys\",\n+ \"itertools 0.13.0\",\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"regex\",\n+ \"rustc-hash 2.1.2\",\n+ \"shlex\",\n+ \"syn\",\n+]\n+\n+[[package]]\n+name = \"bit-set\"\n+version = \"0.8.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3\"\n+dependencies = [\n+ \"bit-vec\",\n+]\n+\n+[[package]]\n+name = \"bit-vec\"\n+version = \"0.8.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7\"\n+\n+[[package]]\n+name = \"bitflags\"\n+version = \"1.3.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a\"\n+\n [[package]]\n name = \"bitflags\"\n version = \"2.11.1\"\n@@ -171,6 +258,22 @@ version = \"1.11.1\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33\"\n \n+[[package]]\n+name = \"bzip2-sys\"\n+version = \"0.1.13+1.0.8\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14\"\n+dependencies = [\n+ \"cc\",\n+ \"pkg-config\",\n+]\n+\n+[[package]]\n+name = \"cast\"\n+version = \"0.3.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5\"\n+\n [[package]]\n name = \"cc\"\n version = \"1.2.62\"\n@@ -178,15 +281,89 @@ source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98\"\n dependencies = [\n \"find-msvc-tools\",\n+ \"jobserver\",\n+ \"libc\",\n \"shlex\",\n ]\n \n+[[package]]\n+name = \"cexpr\"\n+version = \"0.6.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766\"\n+dependencies = [\n+ \"nom\",\n+]\n+\n [[package]]\n name = \"cfg-if\"\n version = \"1.0.4\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801\"\n \n+[[package]]\n+name = \"ciborium\"\n+version = \"0.2.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e\"\n+dependencies = [\n+ \"ciborium-io\",\n+ \"ciborium-ll\",\n+ \"serde\",\n+]\n+\n+[[package]]\n+name = \"ciborium-io\"\n+version = \"0.2.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757\"\n+\n+[[package]]\n+name = \"ciborium-ll\"\n+version = \"0.2.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9\"\n+dependencies = [\n+ \"ciborium-io\",\n+ \"half\",\n+]\n+\n+[[package]]\n+name = \"clang-sys\"\n+version = \"1.8.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4\"\n+dependencies = [\n+ \"glob\",\n+ \"libc\",\n+ \"libloading\",\n+]\n+\n+[[package]]\n+name = \"clap\"\n+version = \"4.6.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51\"\n+dependencies = [\n+ \"clap_builder\",\n+]\n+\n+[[package]]\n+name = \"clap_builder\"\n+version = \"4.6.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f\"\n+dependencies = [\n+ \"anstyle\",\n+ \"clap_lex\",\n+]\n+\n+[[package]]\n+name = \"clap_lex\"\n+version = \"1.1.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9\"\n+\n [[package]]\n name = \"cookie\"\n version = \"0.18.1\"\n@@ -224,6 +401,73 @@ version = \"0.8.7\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b\"\n \n+[[package]]\n+name = \"criterion\"\n+version = \"0.5.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f\"\n+dependencies = [\n+ \"anes\",\n+ \"cast\",\n+ \"ciborium\",\n+ \"clap\",\n+ \"criterion-plot\",\n+ \"is-terminal\",\n+ \"itertools 0.10.5\",\n+ \"num-traits\",\n+ \"once_cell\",\n+ \"oorandom\",\n+ \"plotters\",\n+ \"rayon\",\n+ \"regex\",\n+ \"serde\",\n+ \"serde_derive\",\n+ \"serde_json\",\n+ \"tinytemplate\",\n+ \"walkdir\",\n+]\n+\n+[[package]]\n+name = \"criterion-plot\"\n+version = \"0.5.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1\"\n+dependencies = [\n+ \"cast\",\n+ \"itertools 0.10.5\",\n+]\n+\n+[[package]]\n+name = \"crossbeam-deque\"\n+version = \"0.8.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51\"\n+dependencies = [\n+ \"crossbeam-epoch\",\n+ \"crossbeam-utils\",\n+]\n+\n+[[package]]\n+name = \"crossbeam-epoch\"\n+version = \"0.9.18\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e\"\n+dependencies = [\n+ \"crossbeam-utils\",\n+]\n+\n+[[package]]\n+name = \"crossbeam-utils\"\n+version = \"0.8.21\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28\"\n+\n+[[package]]\n+name = \"crunchy\"\n+version = \"0.2.4\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5\"\n+\n [[package]]\n name = \"deranged\"\n version = \"0.5.8\"\n@@ -250,6 +494,25 @@ version = \"0.15.7\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b\"\n \n+[[package]]\n+name = \"durable\"\n+version = \"0.1.0\"\n+dependencies = [\n+ \"bincode\",\n+ \"criterion\",\n+ \"proptest\",\n+ \"rocksdb\",\n+ \"serde\",\n+ \"tempfile\",\n+ \"thiserror\",\n+]\n+\n+[[package]]\n+name = \"either\"\n+version = \"1.16.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e\"\n+\n [[package]]\n name = \"encoding_rs\"\n version = \"0.8.35\"\n@@ -373,6 +636,18 @@ dependencies = [\n \"wasi\",\n ]\n \n+[[package]]\n+name = \"getrandom\"\n+version = \"0.3.4\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd\"\n+dependencies = [\n+ \"cfg-if\",\n+ \"libc\",\n+ \"r-efi 5.3.0\",\n+ \"wasip2\",\n+]\n+\n [[package]]\n name = \"getrandom\"\n version = \"0.4.2\"\n@@ -381,11 +656,17 @@ checksum = \"0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555\"\n dependencies = [\n \"cfg-if\",\n \"libc\",\n- \"r-efi\",\n+ \"r-efi 6.0.0\",\n \"wasip2\",\n \"wasip3\",\n ]\n \n+[[package]]\n+name = \"glob\"\n+version = \"0.3.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280\"\n+\n [[package]]\n name = \"h2\"\n version = \"0.4.14\"\n@@ -405,6 +686,17 @@ dependencies = [\n \"tracing\",\n ]\n \n+[[package]]\n+name = \"half\"\n+version = \"2.7.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b\"\n+dependencies = [\n+ \"cfg-if\",\n+ \"crunchy\",\n+ \"zerocopy\",\n+]\n+\n [[package]]\n name = \"hashbrown\"\n version = \"0.15.5\"\n@@ -426,6 +718,12 @@ version = \"0.5.0\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea\"\n \n+[[package]]\n+name = \"hermit-abi\"\n+version = \"0.5.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c\"\n+\n [[package]]\n name = \"http\"\n version = \"1.4.1\"\n@@ -676,12 +974,51 @@ version = \"2.12.0\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2\"\n \n+[[package]]\n+name = \"is-terminal\"\n+version = \"0.4.17\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46\"\n+dependencies = [\n+ \"hermit-abi\",\n+ \"libc\",\n+ \"windows-sys 0.61.2\",\n+]\n+\n+[[package]]\n+name = \"itertools\"\n+version = \"0.10.5\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473\"\n+dependencies = [\n+ \"either\",\n+]\n+\n+[[package]]\n+name = \"itertools\"\n+version = \"0.13.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186\"\n+dependencies = [\n+ \"either\",\n+]\n+\n [[package]]\n name = \"itoa\"\n version = \"1.0.18\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682\"\n \n+[[package]]\n+name = \"jobserver\"\n+version = \"0.1.34\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33\"\n+dependencies = [\n+ \"getrandom 0.3.4\",\n+ \"libc\",\n+]\n+\n [[package]]\n name = \"js-sys\"\n version = \"0.3.99\"\n@@ -700,6 +1037,12 @@ version = \"1.5.0\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe\"\n \n+[[package]]\n+name = \"lazycell\"\n+version = \"1.3.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55\"\n+\n [[package]]\n name = \"leb128fmt\"\n version = \"0.1.0\"\n@@ -712,6 +1055,43 @@ version = \"0.2.186\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66\"\n \n+[[package]]\n+name = \"libloading\"\n+version = \"0.8.9\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55\"\n+dependencies = [\n+ \"cfg-if\",\n+ \"windows-link\",\n+]\n+\n+[[package]]\n+name = \"librocksdb-sys\"\n+version = \"0.11.0+8.1.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"d3386f101bcb4bd252d8e9d2fb41ec3b0862a15a62b478c355b2982efa469e3e\"\n+dependencies = [\n+ \"bindgen 0.65.1\",\n+ \"bzip2-sys\",\n+ \"cc\",\n+ \"glob\",\n+ \"libc\",\n+ \"libz-sys\",\n+ \"lz4-sys\",\n+ \"zstd-sys\",\n+]\n+\n+[[package]]\n+name = \"libz-sys\"\n+version = \"1.1.28\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22\"\n+dependencies = [\n+ \"cc\",\n+ \"pkg-config\",\n+ \"vcpkg\",\n+]\n+\n [[package]]\n name = \"linux-raw-sys\"\n version = \"0.12.1\"\n@@ -730,6 +1110,16 @@ version = \"0.4.30\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5\"\n \n+[[package]]\n+name = \"lz4-sys\"\n+version = \"1.11.1+lz4-1.10.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"6bd8c0d6c6ed0cd30b3652886bb8711dc4bb01d637a68105a3d5158039b418e6\"\n+dependencies = [\n+ \"cc\",\n+ \"libc\",\n+]\n+\n [[package]]\n name = \"matchers\"\n version = \"0.2.0\"\n@@ -781,6 +1171,12 @@ version = \"0.3.17\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a\"\n \n+[[package]]\n+name = \"minimal-lexical\"\n+version = \"0.2.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a\"\n+\n [[package]]\n name = \"mio\"\n version = \"1.2.0\"\n@@ -826,6 +1222,16 @@ dependencies = [\n \"tempfile\",\n ]\n \n+[[package]]\n+name = \"nom\"\n+version = \"7.1.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a\"\n+dependencies = [\n+ \"memchr\",\n+ \"minimal-lexical\",\n+]\n+\n [[package]]\n name = \"nu-ansi-term\"\n version = \"0.50.3\"\n@@ -841,19 +1247,34 @@ version = \"0.2.2\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441\"\n \n+[[package]]\n+name = \"num-traits\"\n+version = \"0.2.19\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841\"\n+dependencies = [\n+ \"autocfg\",\n+]\n+\n [[package]]\n name = \"once_cell\"\n version = \"1.21.4\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50\"\n \n+[[package]]\n+name = \"oorandom\"\n+version = \"11.1.5\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e\"\n+\n [[package]]\n name = \"openssl\"\n version = \"0.10.80\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967\"\n dependencies = [\n- \"bitflags\",\n+ \"bitflags 2.11.1\",\n \"cfg-if\",\n \"foreign-types\",\n \"libc\",\n@@ -890,6 +1311,12 @@ dependencies = [\n \"vcpkg\",\n ]\n \n+[[package]]\n+name = \"peeking_take_while\"\n+version = \"0.1.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099\"\n+\n [[package]]\n name = \"percent-encoding\"\n version = \"2.3.2\"\n@@ -908,6 +1335,34 @@ version = \"0.3.33\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e\"\n \n+[[package]]\n+name = \"plotters\"\n+version = \"0.3.7\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747\"\n+dependencies = [\n+ \"num-traits\",\n+ \"plotters-backend\",\n+ \"plotters-svg\",\n+ \"wasm-bindgen\",\n+ \"web-sys\",\n+]\n+\n+[[package]]\n+name = \"plotters-backend\"\n+version = \"0.3.7\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a\"\n+\n+[[package]]\n+name = \"plotters-svg\"\n+version = \"0.3.7\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670\"\n+dependencies = [\n+ \"plotters-backend\",\n+]\n+\n [[package]]\n name = \"potential_utf\"\n version = \"0.1.5\"\n@@ -974,6 +1429,31 @@ dependencies = [\n \"unicode-ident\",\n ]\n \n+[[package]]\n+name = \"proptest\"\n+version = \"1.11.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744\"\n+dependencies = [\n+ \"bit-set\",\n+ \"bit-vec\",\n+ \"bitflags 2.11.1\",\n+ \"num-traits\",\n+ \"rand 0.9.4\",\n+ \"rand_chacha 0.9.0\",\n+ \"rand_xorshift\",\n+ \"regex-syntax\",\n+ \"rusty-fork\",\n+ \"tempfile\",\n+ \"unarray\",\n+]\n+\n+[[package]]\n+name = \"quick-error\"\n+version = \"1.2.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0\"\n+\n [[package]]\n name = \"quote\"\n version = \"1.0.45\"\n@@ -983,6 +1463,12 @@ dependencies = [\n \"proc-macro2\",\n ]\n \n+[[package]]\n+name = \"r-efi\"\n+version = \"5.3.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f\"\n+\n [[package]]\n name = \"r-efi\"\n version = \"6.0.0\"\n@@ -996,8 +1482,18 @@ source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a\"\n dependencies = [\n \"libc\",\n- \"rand_chacha\",\n- \"rand_core\",\n+ \"rand_chacha 0.3.1\",\n+ \"rand_core 0.6.4\",\n+]\n+\n+[[package]]\n+name = \"rand\"\n+version = \"0.9.4\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea\"\n+dependencies = [\n+ \"rand_chacha 0.9.0\",\n+ \"rand_core 0.9.5\",\n ]\n \n [[package]]\n@@ -1007,7 +1503,17 @@ source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88\"\n dependencies = [\n \"ppv-lite86\",\n- \"rand_core\",\n+ \"rand_core 0.6.4\",\n+]\n+\n+[[package]]\n+name = \"rand_chacha\"\n+version = \"0.9.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb\"\n+dependencies = [\n+ \"ppv-lite86\",\n+ \"rand_core 0.9.5\",\n ]\n \n [[package]]\n@@ -1019,6 +1525,56 @@ dependencies = [\n \"getrandom 0.2.17\",\n ]\n \n+[[package]]\n+name = \"rand_core\"\n+version = \"0.9.5\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c\"\n+dependencies = [\n+ \"getrandom 0.3.4\",\n+]\n+\n+[[package]]\n+name = \"rand_xorshift\"\n+version = \"0.4.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a\"\n+dependencies = [\n+ \"rand_core 0.9.5\",\n+]\n+\n+[[package]]\n+name = \"rayon\"\n+version = \"1.12.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d\"\n+dependencies = [\n+ \"either\",\n+ \"rayon-core\",\n+]\n+\n+[[package]]\n+name = \"rayon-core\"\n+version = \"1.13.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91\"\n+dependencies = [\n+ \"crossbeam-deque\",\n+ \"crossbeam-utils\",\n+]\n+\n+[[package]]\n+name = \"regex\"\n+version = \"1.12.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276\"\n+dependencies = [\n+ \"aho-corasick\",\n+ \"memchr\",\n+ \"regex-automata\",\n+ \"regex-syntax\",\n+]\n+\n [[package]]\n name = \"regex-automata\"\n version = \"0.4.14\"\n@@ -1090,13 +1646,35 @@ dependencies = [\n \"windows-sys 0.52.0\",\n ]\n \n+[[package]]\n+name = \"rocksdb\"\n+version = \"0.21.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"bb6f170a4041d50a0ce04b0d2e14916d6ca863ea2e422689a5b694395d299ffe\"\n+dependencies = [\n+ \"libc\",\n+ \"librocksdb-sys\",\n+]\n+\n+[[package]]\n+name = \"rustc-hash\"\n+version = \"1.1.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2\"\n+\n+[[package]]\n+name = \"rustc-hash\"\n+version = \"2.1.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe\"\n+\n [[package]]\n name = \"rustix\"\n version = \"1.1.4\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190\"\n dependencies = [\n- \"bitflags\",\n+ \"bitflags 2.11.1\",\n \"errno\",\n \"libc\",\n \"linux-raw-sys\",\n@@ -1142,12 +1720,33 @@ version = \"1.0.22\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d\"\n \n+[[package]]\n+name = \"rusty-fork\"\n+version = \"0.3.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2\"\n+dependencies = [\n+ \"fnv\",\n+ \"quick-error\",\n+ \"tempfile\",\n+ \"wait-timeout\",\n+]\n+\n [[package]]\n name = \"ryu\"\n version = \"1.0.23\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f\"\n \n+[[package]]\n+name = \"same-file\"\n+version = \"1.0.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502\"\n+dependencies = [\n+ \"winapi-util\",\n+]\n+\n [[package]]\n name = \"schannel\"\n version = \"0.1.29\"\n@@ -1163,7 +1762,7 @@ version = \"3.7.0\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d\"\n dependencies = [\n- \"bitflags\",\n+ \"bitflags 2.11.1\",\n \"core-foundation 0.10.1\",\n \"core-foundation-sys\",\n \"libc\",\n@@ -1307,9 +1906,10 @@ dependencies = [\n \"axum\",\n \"axum-extra\",\n \"dotenvy\",\n+ \"durable\",\n \"futures-util\",\n \"maud\",\n- \"rand\",\n+ \"rand 0.8.6\",\n \"reqwest\",\n \"serde\",\n \"serde_json\",\n@@ -1378,7 +1978,7 @@ version = \"0.7.0\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b\"\n dependencies = [\n- \"bitflags\",\n+ \"bitflags 2.11.1\",\n \"core-foundation 0.9.4\",\n \"system-configuration-sys\",\n ]\n@@ -1476,6 +2076,16 @@ dependencies = [\n \"zerovec\",\n ]\n \n+[[package]]\n+name = \"tinytemplate\"\n+version = \"1.2.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc\"\n+dependencies = [\n+ \"serde\",\n+ \"serde_json\",\n+]\n+\n [[package]]\n name = \"tokio\"\n version = \"1.52.3\"\n@@ -1558,7 +2168,7 @@ version = \"0.5.2\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5\"\n dependencies = [\n- \"bitflags\",\n+ \"bitflags 2.11.1\",\n \"bytes\",\n \"http\",\n \"http-body\",\n@@ -1575,7 +2185,7 @@ version = \"0.6.11\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840\"\n dependencies = [\n- \"bitflags\",\n+ \"bitflags 2.11.1\",\n \"bytes\",\n \"futures-util\",\n \"http\",\n@@ -1667,6 +2277,12 @@ version = \"0.2.5\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b\"\n \n+[[package]]\n+name = \"unarray\"\n+version = \"0.1.4\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94\"\n+\n [[package]]\n name = \"unicode-ident\"\n version = \"1.0.24\"\n@@ -1727,6 +2343,25 @@ version = \"0.9.5\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a\"\n \n+[[package]]\n+name = \"wait-timeout\"\n+version = \"0.2.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11\"\n+dependencies = [\n+ \"libc\",\n+]\n+\n+[[package]]\n+name = \"walkdir\"\n+version = \"2.5.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b\"\n+dependencies = [\n+ \"same-file\",\n+ \"winapi-util\",\n+]\n+\n [[package]]\n name = \"want\"\n version = \"0.3.1\"\n@@ -1843,7 +2478,7 @@ version = \"0.244.0\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe\"\n dependencies = [\n- \"bitflags\",\n+ \"bitflags 2.11.1\",\n \"hashbrown 0.15.5\",\n \"indexmap\",\n \"semver\",\n@@ -1859,6 +2494,15 @@ dependencies = [\n \"wasm-bindgen\",\n ]\n \n+[[package]]\n+name = \"winapi-util\"\n+version = \"0.1.11\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22\"\n+dependencies = [\n+ \"windows-sys 0.61.2\",\n+]\n+\n [[package]]\n name = \"windows-link\"\n version = \"0.2.1\"\n@@ -2040,7 +2684,7 @@ source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2\"\n dependencies = [\n \"anyhow\",\n- \"bitflags\",\n+ \"bitflags 2.11.1\",\n \"indexmap\",\n \"log\",\n \"serde\",\n@@ -2184,3 +2828,14 @@ name = \"zmij\"\n version = \"1.0.21\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa\"\n+\n+[[package]]\n+name = \"zstd-sys\"\n+version = \"2.0.16+zstd.1.5.7\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748\"\n+dependencies = [\n+ \"bindgen 0.72.1\",\n+ \"cc\",\n+ \"pkg-config\",\n+]\ndiff --git a/Cargo.toml b/Cargo.toml\nindex 156820ec5151b709a254dafc177da10488fee566..89f57d8943de978ef6c2dc038a5dfb89420c4283 100644\n--- a/Cargo.toml\n+++ b/Cargo.toml\n@@ -1,3 +1,3 @@\n [workspace]\n-members = [\"server\"]\n+members = [\"server\", \"durable\"]\n resolver = \"2\"\ndiff --git a/Dockerfile b/Dockerfile\nindex 38e4d595f2366b6a6e9405f1e12373b70fac79a1..517aa49ea2c01ba0d06a4647259ed917a0f6665c 100644\n--- a/Dockerfile\n+++ b/Dockerfile\n@@ -3,7 +3,7 @@ FROM rust:1.88-slim AS builder\n WORKDIR /build\n \n RUN apt-get update && \\\n- apt-get install -y pkg-config libssl-dev && \\\n+ apt-get install -y pkg-config libssl-dev clang && \\\n rm -rf /var/lib/apt/lists/*\n \n COPY . .\ndiff --git a/durable/.gitignore b/durable/.gitignore\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..90c273ef12d6313594dde541c3465f2a47739729\n--- /dev/null\n+++ b/durable/.gitignore\n@@ -0,0 +1,35 @@\n+all.txt\n+\n+# Generated by Cargo\n+# will have compiled files and executables\n+debug/\n+target/\n+\n+# These are backup files generated by rustfmt\n+**/*.rs.bk\n+\n+# MSVC Windows builds of rustc generate these, which store debugging information\n+*.pdb\n+\n+# Generated by cargo mutants\n+# Contains mutation testing data\n+**/mutants.out*/\n+\n+# RustRover\n+# JetBrains specific template is maintained in a separate JetBrains.gitignore that can\n+# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore\n+# and can be added to the global gitignore or merged into this file. For a more nuclear\n+# option (not recommended) you can uncomment the following to ignore the entire idea folder.\n+#.idea/\n+\n+\n+# Added by cargo\n+\n+/target\n+\n+\n+# Added by cargo\n+#\n+# already existing elements were commented out\n+\n+#/target\ndiff --git a/durable/Cargo.lock b/durable/Cargo.lock\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..99d59ec2166a1e88dc4867d316b3d5fe9bb0a1b6\n--- /dev/null\n+++ b/durable/Cargo.lock\n@@ -0,0 +1,1198 @@\n+# This file is automatically @generated by Cargo.\n+# It is not intended for manual editing.\n+version = 4\n+\n+[[package]]\n+name = \"aho-corasick\"\n+version = \"1.1.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916\"\n+dependencies = [\n+ \"memchr\",\n+]\n+\n+[[package]]\n+name = \"anes\"\n+version = \"0.1.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299\"\n+\n+[[package]]\n+name = \"anstyle\"\n+version = \"1.0.11\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd\"\n+\n+[[package]]\n+name = \"autocfg\"\n+version = \"1.5.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8\"\n+\n+[[package]]\n+name = \"bincode\"\n+version = \"1.3.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad\"\n+dependencies = [\n+ \"serde\",\n+]\n+\n+[[package]]\n+name = \"bindgen\"\n+version = \"0.65.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"cfdf7b466f9a4903edc73f95d6d2bcd5baf8ae620638762244d3f60143643cc5\"\n+dependencies = [\n+ \"bitflags 1.3.2\",\n+ \"cexpr\",\n+ \"clang-sys\",\n+ \"lazy_static\",\n+ \"lazycell\",\n+ \"peeking_take_while\",\n+ \"prettyplease\",\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"regex\",\n+ \"rustc-hash 1.1.0\",\n+ \"shlex\",\n+ \"syn\",\n+]\n+\n+[[package]]\n+name = \"bindgen\"\n+version = \"0.71.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3\"\n+dependencies = [\n+ \"bitflags 2.9.1\",\n+ \"cexpr\",\n+ \"clang-sys\",\n+ \"itertools 0.13.0\",\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"regex\",\n+ \"rustc-hash 2.1.1\",\n+ \"shlex\",\n+ \"syn\",\n+]\n+\n+[[package]]\n+name = \"bit-set\"\n+version = \"0.8.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3\"\n+dependencies = [\n+ \"bit-vec\",\n+]\n+\n+[[package]]\n+name = \"bit-vec\"\n+version = \"0.8.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7\"\n+\n+[[package]]\n+name = \"bitflags\"\n+version = \"1.3.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a\"\n+\n+[[package]]\n+name = \"bitflags\"\n+version = \"2.9.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967\"\n+\n+[[package]]\n+name = \"bumpalo\"\n+version = \"3.18.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"793db76d6187cd04dff33004d8e6c9cc4e05cd330500379d2394209271b4aeee\"\n+\n+[[package]]\n+name = \"bzip2-sys\"\n+version = \"0.1.13+1.0.8\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14\"\n+dependencies = [\n+ \"cc\",\n+ \"pkg-config\",\n+]\n+\n+[[package]]\n+name = \"cast\"\n+version = \"0.3.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5\"\n+\n+[[package]]\n+name = \"cc\"\n+version = \"1.2.27\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"d487aa071b5f64da6f19a3e848e3578944b726ee5a4854b82172f02aa876bfdc\"\n+dependencies = [\n+ \"jobserver\",\n+ \"libc\",\n+ \"shlex\",\n+]\n+\n+[[package]]\n+name = \"cexpr\"\n+version = \"0.6.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766\"\n+dependencies = [\n+ \"nom\",\n+]\n+\n+[[package]]\n+name = \"cfg-if\"\n+version = \"1.0.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268\"\n+\n+[[package]]\n+name = \"ciborium\"\n+version = \"0.2.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e\"\n+dependencies = [\n+ \"ciborium-io\",\n+ \"ciborium-ll\",\n+ \"serde\",\n+]\n+\n+[[package]]\n+name = \"ciborium-io\"\n+version = \"0.2.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757\"\n+\n+[[package]]\n+name = \"ciborium-ll\"\n+version = \"0.2.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9\"\n+dependencies = [\n+ \"ciborium-io\",\n+ \"half\",\n+]\n+\n+[[package]]\n+name = \"clang-sys\"\n+version = \"1.8.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4\"\n+dependencies = [\n+ \"glob\",\n+ \"libc\",\n+ \"libloading\",\n+]\n+\n+[[package]]\n+name = \"clap\"\n+version = \"4.5.40\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f\"\n+dependencies = [\n+ \"clap_builder\",\n+]\n+\n+[[package]]\n+name = \"clap_builder\"\n+version = \"4.5.40\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e\"\n+dependencies = [\n+ \"anstyle\",\n+ \"clap_lex\",\n+]\n+\n+[[package]]\n+name = \"clap_lex\"\n+version = \"0.7.5\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675\"\n+\n+[[package]]\n+name = \"criterion\"\n+version = \"0.5.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f\"\n+dependencies = [\n+ \"anes\",\n+ \"cast\",\n+ \"ciborium\",\n+ \"clap\",\n+ \"criterion-plot\",\n+ \"is-terminal\",\n+ \"itertools 0.10.5\",\n+ \"num-traits\",\n+ \"once_cell\",\n+ \"oorandom\",\n+ \"plotters\",\n+ \"rayon\",\n+ \"regex\",\n+ \"serde\",\n+ \"serde_derive\",\n+ \"serde_json\",\n+ \"tinytemplate\",\n+ \"walkdir\",\n+]\n+\n+[[package]]\n+name = \"criterion-plot\"\n+version = \"0.5.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1\"\n+dependencies = [\n+ \"cast\",\n+ \"itertools 0.10.5\",\n+]\n+\n+[[package]]\n+name = \"crossbeam-deque\"\n+version = \"0.8.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51\"\n+dependencies = [\n+ \"crossbeam-epoch\",\n+ \"crossbeam-utils\",\n+]\n+\n+[[package]]\n+name = \"crossbeam-epoch\"\n+version = \"0.9.18\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e\"\n+dependencies = [\n+ \"crossbeam-utils\",\n+]\n+\n+[[package]]\n+name = \"crossbeam-utils\"\n+version = \"0.8.21\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28\"\n+\n+[[package]]\n+name = \"crunchy\"\n+version = \"0.2.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929\"\n+\n+[[package]]\n+name = \"durable\"\n+version = \"0.1.0\"\n+dependencies = [\n+ \"bincode\",\n+ \"criterion\",\n+ \"proptest\",\n+ \"rocksdb\",\n+ \"serde\",\n+ \"tempfile\",\n+ \"thiserror\",\n+]\n+\n+[[package]]\n+name = \"either\"\n+version = \"1.15.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719\"\n+\n+[[package]]\n+name = \"errno\"\n+version = \"0.3.13\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad\"\n+dependencies = [\n+ \"libc\",\n+ \"windows-sys 0.60.2\",\n+]\n+\n+[[package]]\n+name = \"fastrand\"\n+version = \"2.3.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be\"\n+\n+[[package]]\n+name = \"fnv\"\n+version = \"1.0.7\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1\"\n+\n+[[package]]\n+name = \"getrandom\"\n+version = \"0.3.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4\"\n+dependencies = [\n+ \"cfg-if\",\n+ \"libc\",\n+ \"r-efi\",\n+ \"wasi\",\n+]\n+\n+[[package]]\n+name = \"glob\"\n+version = \"0.3.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2\"\n+\n+[[package]]\n+name = \"half\"\n+version = \"2.6.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9\"\n+dependencies = [\n+ \"cfg-if\",\n+ \"crunchy\",\n+]\n+\n+[[package]]\n+name = \"hermit-abi\"\n+version = \"0.5.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c\"\n+\n+[[package]]\n+name = \"is-terminal\"\n+version = \"0.4.16\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9\"\n+dependencies = [\n+ \"hermit-abi\",\n+ \"libc\",\n+ \"windows-sys 0.59.0\",\n+]\n+\n+[[package]]\n+name = \"itertools\"\n+version = \"0.10.5\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473\"\n+dependencies = [\n+ \"either\",\n+]\n+\n+[[package]]\n+name = \"itertools\"\n+version = \"0.13.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186\"\n+dependencies = [\n+ \"either\",\n+]\n+\n+[[package]]\n+name = \"itoa\"\n+version = \"1.0.15\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c\"\n+\n+[[package]]\n+name = \"jobserver\"\n+version = \"0.1.33\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a\"\n+dependencies = [\n+ \"getrandom\",\n+ \"libc\",\n+]\n+\n+[[package]]\n+name = \"js-sys\"\n+version = \"0.3.77\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f\"\n+dependencies = [\n+ \"once_cell\",\n+ \"wasm-bindgen\",\n+]\n+\n+[[package]]\n+name = \"lazy_static\"\n+version = \"1.5.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe\"\n+\n+[[package]]\n+name = \"lazycell\"\n+version = \"1.3.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55\"\n+\n+[[package]]\n+name = \"libc\"\n+version = \"0.2.174\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776\"\n+\n+[[package]]\n+name = \"libloading\"\n+version = \"0.8.8\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667\"\n+dependencies = [\n+ \"cfg-if\",\n+ \"windows-targets 0.53.2\",\n+]\n+\n+[[package]]\n+name = \"librocksdb-sys\"\n+version = \"0.11.0+8.1.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"d3386f101bcb4bd252d8e9d2fb41ec3b0862a15a62b478c355b2982efa469e3e\"\n+dependencies = [\n+ \"bindgen 0.65.1\",\n+ \"bzip2-sys\",\n+ \"cc\",\n+ \"glob\",\n+ \"libc\",\n+ \"libz-sys\",\n+ \"lz4-sys\",\n+ \"zstd-sys\",\n+]\n+\n+[[package]]\n+name = \"libz-sys\"\n+version = \"1.1.22\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"8b70e7a7df205e92a1a4cd9aaae7898dac0aa555503cc0a649494d0d60e7651d\"\n+dependencies = [\n+ \"cc\",\n+ \"pkg-config\",\n+ \"vcpkg\",\n+]\n+\n+[[package]]\n+name = \"linux-raw-sys\"\n+version = \"0.9.4\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12\"\n+\n+[[package]]\n+name = \"log\"\n+version = \"0.4.27\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94\"\n+\n+[[package]]\n+name = \"lz4-sys\"\n+version = \"1.11.1+lz4-1.10.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"6bd8c0d6c6ed0cd30b3652886bb8711dc4bb01d637a68105a3d5158039b418e6\"\n+dependencies = [\n+ \"cc\",\n+ \"libc\",\n+]\n+\n+[[package]]\n+name = \"memchr\"\n+version = \"2.7.5\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0\"\n+\n+[[package]]\n+name = \"minimal-lexical\"\n+version = \"0.2.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a\"\n+\n+[[package]]\n+name = \"nom\"\n+version = \"7.1.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a\"\n+dependencies = [\n+ \"memchr\",\n+ \"minimal-lexical\",\n+]\n+\n+[[package]]\n+name = \"num-traits\"\n+version = \"0.2.19\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841\"\n+dependencies = [\n+ \"autocfg\",\n+]\n+\n+[[package]]\n+name = \"once_cell\"\n+version = \"1.21.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d\"\n+\n+[[package]]\n+name = \"oorandom\"\n+version = \"11.1.5\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e\"\n+\n+[[package]]\n+name = \"peeking_take_while\"\n+version = \"0.1.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099\"\n+\n+[[package]]\n+name = \"pkg-config\"\n+version = \"0.3.32\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c\"\n+\n+[[package]]\n+name = \"plotters\"\n+version = \"0.3.7\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747\"\n+dependencies = [\n+ \"num-traits\",\n+ \"plotters-backend\",\n+ \"plotters-svg\",\n+ \"wasm-bindgen\",\n+ \"web-sys\",\n+]\n+\n+[[package]]\n+name = \"plotters-backend\"\n+version = \"0.3.7\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a\"\n+\n+[[package]]\n+name = \"plotters-svg\"\n+version = \"0.3.7\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670\"\n+dependencies = [\n+ \"plotters-backend\",\n+]\n+\n+[[package]]\n+name = \"ppv-lite86\"\n+version = \"0.2.21\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9\"\n+dependencies = [\n+ \"zerocopy\",\n+]\n+\n+[[package]]\n+name = \"prettyplease\"\n+version = \"0.2.35\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"061c1221631e079b26479d25bbf2275bfe5917ae8419cd7e34f13bfc2aa7539a\"\n+dependencies = [\n+ \"proc-macro2\",\n+ \"syn\",\n+]\n+\n+[[package]]\n+name = \"proc-macro2\"\n+version = \"1.0.95\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778\"\n+dependencies = [\n+ \"unicode-ident\",\n+]\n+\n+[[package]]\n+name = \"proptest\"\n+version = \"1.7.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"6fcdab19deb5195a31cf7726a210015ff1496ba1464fd42cb4f537b8b01b471f\"\n+dependencies = [\n+ \"bit-set\",\n+ \"bit-vec\",\n+ \"bitflags 2.9.1\",\n+ \"lazy_static\",\n+ \"num-traits\",\n+ \"rand\",\n+ \"rand_chacha\",\n+ \"rand_xorshift\",\n+ \"regex-syntax\",\n+ \"rusty-fork\",\n+ \"tempfile\",\n+ \"unarray\",\n+]\n+\n+[[package]]\n+name = \"quick-error\"\n+version = \"1.2.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0\"\n+\n+[[package]]\n+name = \"quote\"\n+version = \"1.0.40\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d\"\n+dependencies = [\n+ \"proc-macro2\",\n+]\n+\n+[[package]]\n+name = \"r-efi\"\n+version = \"5.3.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f\"\n+\n+[[package]]\n+name = \"rand\"\n+version = \"0.9.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97\"\n+dependencies = [\n+ \"rand_chacha\",\n+ \"rand_core\",\n+]\n+\n+[[package]]\n+name = \"rand_chacha\"\n+version = \"0.9.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb\"\n+dependencies = [\n+ \"ppv-lite86\",\n+ \"rand_core\",\n+]\n+\n+[[package]]\n+name = \"rand_core\"\n+version = \"0.9.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38\"\n+dependencies = [\n+ \"getrandom\",\n+]\n+\n+[[package]]\n+name = \"rand_xorshift\"\n+version = \"0.4.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a\"\n+dependencies = [\n+ \"rand_core\",\n+]\n+\n+[[package]]\n+name = \"rayon\"\n+version = \"1.10.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa\"\n+dependencies = [\n+ \"either\",\n+ \"rayon-core\",\n+]\n+\n+[[package]]\n+name = \"rayon-core\"\n+version = \"1.12.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2\"\n+dependencies = [\n+ \"crossbeam-deque\",\n+ \"crossbeam-utils\",\n+]\n+\n+[[package]]\n+name = \"regex\"\n+version = \"1.11.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191\"\n+dependencies = [\n+ \"aho-corasick\",\n+ \"memchr\",\n+ \"regex-automata\",\n+ \"regex-syntax\",\n+]\n+\n+[[package]]\n+name = \"regex-automata\"\n+version = \"0.4.9\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908\"\n+dependencies = [\n+ \"aho-corasick\",\n+ \"memchr\",\n+ \"regex-syntax\",\n+]\n+\n+[[package]]\n+name = \"regex-syntax\"\n+version = \"0.8.5\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c\"\n+\n+[[package]]\n+name = \"rocksdb\"\n+version = \"0.21.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"bb6f170a4041d50a0ce04b0d2e14916d6ca863ea2e422689a5b694395d299ffe\"\n+dependencies = [\n+ \"libc\",\n+ \"librocksdb-sys\",\n+]\n+\n+[[package]]\n+name = \"rustc-hash\"\n+version = \"1.1.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2\"\n+\n+[[package]]\n+name = \"rustc-hash\"\n+version = \"2.1.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d\"\n+\n+[[package]]\n+name = \"rustix\"\n+version = \"1.0.7\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266\"\n+dependencies = [\n+ \"bitflags 2.9.1\",\n+ \"errno\",\n+ \"libc\",\n+ \"linux-raw-sys\",\n+ \"windows-sys 0.59.0\",\n+]\n+\n+[[package]]\n+name = \"rustversion\"\n+version = \"1.0.21\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d\"\n+\n+[[package]]\n+name = \"rusty-fork\"\n+version = \"0.3.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"cb3dcc6e454c328bb824492db107ab7c0ae8fcffe4ad210136ef014458c1bc4f\"\n+dependencies = [\n+ \"fnv\",\n+ \"quick-error\",\n+ \"tempfile\",\n+ \"wait-timeout\",\n+]\n+\n+[[package]]\n+name = \"ryu\"\n+version = \"1.0.20\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f\"\n+\n+[[package]]\n+name = \"same-file\"\n+version = \"1.0.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502\"\n+dependencies = [\n+ \"winapi-util\",\n+]\n+\n+[[package]]\n+name = \"serde\"\n+version = \"1.0.219\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6\"\n+dependencies = [\n+ \"serde_derive\",\n+]\n+\n+[[package]]\n+name = \"serde_derive\"\n+version = \"1.0.219\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00\"\n+dependencies = [\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"syn\",\n+]\n+\n+[[package]]\n+name = \"serde_json\"\n+version = \"1.0.140\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373\"\n+dependencies = [\n+ \"itoa\",\n+ \"memchr\",\n+ \"ryu\",\n+ \"serde\",\n+]\n+\n+[[package]]\n+name = \"shlex\"\n+version = \"1.3.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64\"\n+\n+[[package]]\n+name = \"syn\"\n+version = \"2.0.104\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40\"\n+dependencies = [\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"unicode-ident\",\n+]\n+\n+[[package]]\n+name = \"tempfile\"\n+version = \"3.20.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1\"\n+dependencies = [\n+ \"fastrand\",\n+ \"getrandom\",\n+ \"once_cell\",\n+ \"rustix\",\n+ \"windows-sys 0.59.0\",\n+]\n+\n+[[package]]\n+name = \"thiserror\"\n+version = \"1.0.69\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52\"\n+dependencies = [\n+ \"thiserror-impl\",\n+]\n+\n+[[package]]\n+name = \"thiserror-impl\"\n+version = \"1.0.69\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1\"\n+dependencies = [\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"syn\",\n+]\n+\n+[[package]]\n+name = \"tinytemplate\"\n+version = \"1.2.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc\"\n+dependencies = [\n+ \"serde\",\n+ \"serde_json\",\n+]\n+\n+[[package]]\n+name = \"unarray\"\n+version = \"0.1.4\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94\"\n+\n+[[package]]\n+name = \"unicode-ident\"\n+version = \"1.0.18\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512\"\n+\n+[[package]]\n+name = \"vcpkg\"\n+version = \"0.2.15\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426\"\n+\n+[[package]]\n+name = \"wait-timeout\"\n+version = \"0.2.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11\"\n+dependencies = [\n+ \"libc\",\n+]\n+\n+[[package]]\n+name = \"walkdir\"\n+version = \"2.5.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b\"\n+dependencies = [\n+ \"same-file\",\n+ \"winapi-util\",\n+]\n+\n+[[package]]\n+name = \"wasi\"\n+version = \"0.14.2+wasi-0.2.4\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3\"\n+dependencies = [\n+ \"wit-bindgen-rt\",\n+]\n+\n+[[package]]\n+name = \"wasm-bindgen\"\n+version = \"0.2.100\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5\"\n+dependencies = [\n+ \"cfg-if\",\n+ \"once_cell\",\n+ \"rustversion\",\n+ \"wasm-bindgen-macro\",\n+]\n+\n+[[package]]\n+name = \"wasm-bindgen-backend\"\n+version = \"0.2.100\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6\"\n+dependencies = [\n+ \"bumpalo\",\n+ \"log\",\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"syn\",\n+ \"wasm-bindgen-shared\",\n+]\n+\n+[[package]]\n+name = \"wasm-bindgen-macro\"\n+version = \"0.2.100\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407\"\n+dependencies = [\n+ \"quote\",\n+ \"wasm-bindgen-macro-support\",\n+]\n+\n+[[package]]\n+name = \"wasm-bindgen-macro-support\"\n+version = \"0.2.100\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de\"\n+dependencies = [\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"syn\",\n+ \"wasm-bindgen-backend\",\n+ \"wasm-bindgen-shared\",\n+]\n+\n+[[package]]\n+name = \"wasm-bindgen-shared\"\n+version = \"0.2.100\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d\"\n+dependencies = [\n+ \"unicode-ident\",\n+]\n+\n+[[package]]\n+name = \"web-sys\"\n+version = \"0.3.77\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2\"\n+dependencies = [\n+ \"js-sys\",\n+ \"wasm-bindgen\",\n+]\n+\n+[[package]]\n+name = \"winapi-util\"\n+version = \"0.1.9\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb\"\n+dependencies = [\n+ \"windows-sys 0.59.0\",\n+]\n+\n+[[package]]\n+name = \"windows-sys\"\n+version = \"0.59.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b\"\n+dependencies = [\n+ \"windows-targets 0.52.6\",\n+]\n+\n+[[package]]\n+name = \"windows-sys\"\n+version = \"0.60.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb\"\n+dependencies = [\n+ \"windows-targets 0.53.2\",\n+]\n+\n+[[package]]\n+name = \"windows-targets\"\n+version = \"0.52.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973\"\n+dependencies = [\n+ \"windows_aarch64_gnullvm 0.52.6\",\n+ \"windows_aarch64_msvc 0.52.6\",\n+ \"windows_i686_gnu 0.52.6\",\n+ \"windows_i686_gnullvm 0.52.6\",\n+ \"windows_i686_msvc 0.52.6\",\n+ \"windows_x86_64_gnu 0.52.6\",\n+ \"windows_x86_64_gnullvm 0.52.6\",\n+ \"windows_x86_64_msvc 0.52.6\",\n+]\n+\n+[[package]]\n+name = \"windows-targets\"\n+version = \"0.53.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef\"\n+dependencies = [\n+ \"windows_aarch64_gnullvm 0.53.0\",\n+ \"windows_aarch64_msvc 0.53.0\",\n+ \"windows_i686_gnu 0.53.0\",\n+ \"windows_i686_gnullvm 0.53.0\",\n+ \"windows_i686_msvc 0.53.0\",\n+ \"windows_x86_64_gnu 0.53.0\",\n+ \"windows_x86_64_gnullvm 0.53.0\",\n+ \"windows_x86_64_msvc 0.53.0\",\n+]\n+\n+[[package]]\n+name = \"windows_aarch64_gnullvm\"\n+version = \"0.52.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3\"\n+\n+[[package]]\n+name = \"windows_aarch64_gnullvm\"\n+version = \"0.53.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764\"\n+\n+[[package]]\n+name = \"windows_aarch64_msvc\"\n+version = \"0.52.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469\"\n+\n+[[package]]\n+name = \"windows_aarch64_msvc\"\n+version = \"0.53.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c\"\n+\n+[[package]]\n+name = \"windows_i686_gnu\"\n+version = \"0.52.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b\"\n+\n+[[package]]\n+name = \"windows_i686_gnu\"\n+version = \"0.53.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3\"\n+\n+[[package]]\n+name = \"windows_i686_gnullvm\"\n+version = \"0.52.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66\"\n+\n+[[package]]\n+name = \"windows_i686_gnullvm\"\n+version = \"0.53.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11\"\n+\n+[[package]]\n+name = \"windows_i686_msvc\"\n+version = \"0.52.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66\"\n+\n+[[package]]\n+name = \"windows_i686_msvc\"\n+version = \"0.53.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d\"\n+\n+[[package]]\n+name = \"windows_x86_64_gnu\"\n+version = \"0.52.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78\"\n+\n+[[package]]\n+name = \"windows_x86_64_gnu\"\n+version = \"0.53.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba\"\n+\n+[[package]]\n+name = \"windows_x86_64_gnullvm\"\n+version = \"0.52.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d\"\n+\n+[[package]]\n+name = \"windows_x86_64_gnullvm\"\n+version = \"0.53.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57\"\n+\n+[[package]]\n+name = \"windows_x86_64_msvc\"\n+version = \"0.52.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec\"\n+\n+[[package]]\n+name = \"windows_x86_64_msvc\"\n+version = \"0.53.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486\"\n+\n+[[package]]\n+name = \"wit-bindgen-rt\"\n+version = \"0.39.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1\"\n+dependencies = [\n+ \"bitflags 2.9.1\",\n+]\n+\n+[[package]]\n+name = \"zerocopy\"\n+version = \"0.8.26\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f\"\n+dependencies = [\n+ \"zerocopy-derive\",\n+]\n+\n+[[package]]\n+name = \"zerocopy-derive\"\n+version = \"0.8.26\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181\"\n+dependencies = [\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"syn\",\n+]\n+\n+[[package]]\n+name = \"zstd-sys\"\n+version = \"2.0.15+zstd.1.5.7\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"eb81183ddd97d0c74cedf1d50d85c8d08c1b8b68ee863bdee9e706eedba1a237\"\n+dependencies = [\n+ \"bindgen 0.71.1\",\n+ \"cc\",\n+ \"pkg-config\",\n+]\ndiff --git a/durable/Cargo.toml b/durable/Cargo.toml\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..907e79cd894446e2ac76c8240b86c2927103727b\n--- /dev/null\n+++ b/durable/Cargo.toml\n@@ -0,0 +1,18 @@\n+[package]\n+name = \"durable\"\n+version = \"0.1.0\"\n+edition = \"2021\"\n+authors = [\"Durable Contributors\"]\n+description = \"RocksDB-backed persistent data structures for Rust\"\n+license = \"MIT OR Apache-2.0\"\n+\n+[dependencies]\n+rocksdb = \"0.21\"\n+serde = { version = \"1.0\", features = [\"derive\"] }\n+bincode = \"1.3\"\n+thiserror = \"1.0\"\n+\n+[dev-dependencies]\n+tempfile = \"3.8\"\n+criterion = \"0.5\"\n+proptest = \"1.4\"\ndiff --git a/durable/LICENSE b/durable/LICENSE\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..261eeb9e9f8b2b4b0d119366dda99c6fd7d35c64\n--- /dev/null\n+++ b/durable/LICENSE\n@@ -0,0 +1,201 @@\n+ Apache License\n+ Version 2.0, January 2004\n+ http://www.apache.org/licenses/\n+\n+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n+\n+ 1. Definitions.\n+\n+ \"License\" shall mean the terms and conditions for use, reproduction,\n+ and distribution as defined by Sections 1 through 9 of this document.\n+\n+ \"Licensor\" shall mean the copyright owner or entity authorized by\n+ the copyright owner that is granting the License.\n+\n+ \"Legal Entity\" shall mean the union of the acting entity and all\n+ other entities that control, are controlled by, or are under common\n+ control with that entity. For the purposes of this definition,\n+ \"control\" means (i) the power, direct or indirect, to cause the\n+ direction or management of such entity, whether by contract or\n+ otherwise, or (ii) ownership of fifty percent (50%) or more of the\n+ outstanding shares, or (iii) beneficial ownership of such entity.\n+\n+ \"You\" (or \"Your\") shall mean an individual or Legal Entity\n+ exercising permissions granted by this License.\n+\n+ \"Source\" form shall mean the preferred form for making modifications,\n+ including but not limited to software source code, documentation\n+ source, and configuration files.\n+\n+ \"Object\" form shall mean any form resulting from mechanical\n+ transformation or translation of a Source form, including but\n+ not limited to compiled object code, generated documentation,\n+ and conversions to other media types.\n+\n+ \"Work\" shall mean the work of authorship, whether in Source or\n+ Object form, made available under the License, as indicated by a\n+ copyright notice that is included in or attached to the work\n+ (an example is provided in the Appendix below).\n+\n+ \"Derivative Works\" shall mean any work, whether in Source or Object\n+ form, that is based on (or derived from) the Work and for which the\n+ editorial revisions, annotations, elaborations, or other modifications\n+ represent, as a whole, an original work of authorship. For the purposes\n+ of this License, Derivative Works shall not include works that remain\n+ separable from, or merely link (or bind by name) to the interfaces of,\n+ the Work and Derivative Works thereof.\n+\n+ \"Contribution\" shall mean any work of authorship, including\n+ the original version of the Work and any modifications or additions\n+ to that Work or Derivative Works thereof, that is intentionally\n+ submitted to Licensor for inclusion in the Work by the copyright owner\n+ or by an individual or Legal Entity authorized to submit on behalf of\n+ the copyright owner. For the purposes of this definition, \"submitted\"\n+ means any form of electronic, verbal, or written communication sent\n+ to the Licensor or its representatives, including but not limited to\n+ communication on electronic mailing lists, source code control systems,\n+ and issue tracking systems that are managed by, or on behalf of, the\n+ Licensor for the purpose of discussing and improving the Work, but\n+ excluding communication that is conspicuously marked or otherwise\n+ designated in writing by the copyright owner as \"Not a Contribution.\"\n+\n+ \"Contributor\" shall mean Licensor and any individual or Legal Entity\n+ on behalf of whom a Contribution has been received by Licensor and\n+ subsequently incorporated within the Work.\n+\n+ 2. Grant of Copyright License. Subject to the terms and conditions of\n+ this License, each Contributor hereby grants to You a perpetual,\n+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n+ copyright license to reproduce, prepare Derivative Works of,\n+ publicly display, publicly perform, sublicense, and distribute the\n+ Work and such Derivative Works in Source or Object form.\n+\n+ 3. Grant of Patent License. Subject to the terms and conditions of\n+ this License, each Contributor hereby grants to You a perpetual,\n+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n+ (except as stated in this section) patent license to make, have made,\n+ use, offer to sell, sell, import, and otherwise transfer the Work,\n+ where such license applies only to those patent claims licensable\n+ by such Contributor that are necessarily infringed by their\n+ Contribution(s) alone or by combination of their Contribution(s)\n+ with the Work to which such Contribution(s) was submitted. If You\n+ institute patent litigation against any entity (including a\n+ cross-claim or counterclaim in a lawsuit) alleging that the Work\n+ or a Contribution incorporated within the Work constitutes direct\n+ or contributory patent infringement, then any patent licenses\n+ granted to You under this License for that Work shall terminate\n+ as of the date such litigation is filed.\n+\n+ 4. Redistribution. You may reproduce and distribute copies of the\n+ Work or Derivative Works thereof in any medium, with or without\n+ modifications, and in Source or Object form, provided that You\n+ meet the following conditions:\n+\n+ (a) You must give any other recipients of the Work or\n+ Derivative Works a copy of this License; and\n+\n+ (b) You must cause any modified files to carry prominent notices\n+ stating that You changed the files; and\n+\n+ (c) You must retain, in the Source form of any Derivative Works\n+ that You distribute, all copyright, patent, trademark, and\n+ attribution notices from the Source form of the Work,\n+ excluding those notices that do not pertain to any part of\n+ the Derivative Works; and\n+\n+ (d) If the Work includes a \"NOTICE\" text file as part of its\n+ distribution, then any Derivative Works that You distribute must\n+ include a readable copy of the attribution notices contained\n+ within such NOTICE file, excluding those notices that do not\n+ pertain to any part of the Derivative Works, in at least one\n+ of the following places: within a NOTICE text file distributed\n+ as part of the Derivative Works; within the Source form or\n+ documentation, if provided along with the Derivative Works; or,\n+ within a display generated by the Derivative Works, if and\n+ wherever such third-party notices normally appear. The contents\n+ of the NOTICE file are for informational purposes only and\n+ do not modify the License. You may add Your own attribution\n+ notices within Derivative Works that You distribute, alongside\n+ or as an addendum to the NOTICE text from the Work, provided\n+ that such additional attribution notices cannot be construed\n+ as modifying the License.\n+\n+ You may add Your own copyright statement to Your modifications and\n+ may provide additional or different license terms and conditions\n+ for use, reproduction, or distribution of Your modifications, or\n+ for any such Derivative Works as a whole, provided Your use,\n+ reproduction, and distribution of the Work otherwise complies with\n+ the conditions stated in this License.\n+\n+ 5. Submission of Contributions. Unless You explicitly state otherwise,\n+ any Contribution intentionally submitted for inclusion in the Work\n+ by You to the Licensor shall be under the terms and conditions of\n+ this License, without any additional terms or conditions.\n+ Notwithstanding the above, nothing herein shall supersede or modify\n+ the terms of any separate license agreement you may have executed\n+ with Licensor regarding such Contributions.\n+\n+ 6. Trademarks. This License does not grant permission to use the trade\n+ names, trademarks, service marks, or product names of the Licensor,\n+ except as required for reasonable and customary use in describing the\n+ origin of the Work and reproducing the content of the NOTICE file.\n+\n+ 7. Disclaimer of Warranty. Unless required by applicable law or\n+ agreed to in writing, Licensor provides the Work (and each\n+ Contributor provides its Contributions) on an \"AS IS\" BASIS,\n+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n+ implied, including, without limitation, any warranties or conditions\n+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n+ PARTICULAR PURPOSE. You are solely responsible for determining the\n+ appropriateness of using or redistributing the Work and assume any\n+ risks associated with Your exercise of permissions under this License.\n+\n+ 8. Limitation of Liability. In no event and under no legal theory,\n+ whether in tort (including negligence), contract, or otherwise,\n+ unless required by applicable law (such as deliberate and grossly\n+ negligent acts) or agreed to in writing, shall any Contributor be\n+ liable to You for damages, including any direct, indirect, special,\n+ incidental, or consequential damages of any character arising as a\n+ result of this License or out of the use or inability to use the\n+ Work (including but not limited to damages for loss of goodwill,\n+ work stoppage, computer failure or malfunction, or any and all\n+ other commercial damages or losses), even if such Contributor\n+ has been advised of the possibility of such damages.\n+\n+ 9. Accepting Warranty or Additional Liability. While redistributing\n+ the Work or Derivative Works thereof, You may choose to offer,\n+ and charge a fee for, acceptance of support, warranty, indemnity,\n+ or other liability obligations and/or rights consistent with this\n+ License. However, in accepting such obligations, You may act only\n+ on Your own behalf and on Your sole responsibility, not on behalf\n+ of any other Contributor, and only if You agree to indemnify,\n+ defend, and hold each Contributor harmless for any liability\n+ incurred by, or claims asserted against, such Contributor by reason\n+ of your accepting any such warranty or additional liability.\n+\n+ END OF TERMS AND CONDITIONS\n+\n+ APPENDIX: How to apply the Apache License to your work.\n+\n+ To apply the Apache License to your work, attach the following\n+ boilerplate notice, with the fields enclosed by brackets \"[]\"\n+ replaced with your own identifying information. (Don't include\n+ the brackets!) The text should be enclosed in the appropriate\n+ comment syntax for the file format. We also recommend that a\n+ file or class name and description of purpose be included on the\n+ same \"printed page\" as the copyright notice for easier\n+ identification within third-party archives.\n+\n+ Copyright [yyyy] [name of copyright owner]\n+\n+ Licensed under the Apache License, Version 2.0 (the \"License\");\n+ you may not use this file except in compliance with the License.\n+ You may obtain a copy of the License at\n+\n+ http://www.apache.org/licenses/LICENSE-2.0\n+\n+ Unless required by applicable law or agreed to in writing, software\n+ distributed under the License is distributed on an \"AS IS\" BASIS,\n+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n+ See the License for the specific language governing permissions and\n+ limitations under the License.\ndiff --git a/durable/README.md b/durable/README.md\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..46f3b163a41d1094334ea5dc4e8add7b627f16e2\n--- /dev/null\n+++ b/durable/README.md\n@@ -0,0 +1,207 @@\n+# Durable\n+\n+RocksDB-backed persistent data structures for Rust. Think `std::collections` but on disk!\n+\n+## Features\n+\n+- **Persistent Collections**: `DurableVec`, `DurableMap`, `DurableSet` (coming soon)\n+- **Type-Safe**: Full Rust type safety with serde serialization\n+- **ACID Guarantees**: All operations are atomic and crash-safe\n+- **Zero-Copy Capable**: Efficient iteration without loading entire collections\n+- **Embedded**: No external services required - just a directory on disk\n+\n+## Quick Start\n+\n+Add to your `Cargo.toml`:\n+\n+```toml\n+[dependencies]\n+durable = \"0.1.0\"\n+```\n+\n+## Example\n+\n+### DurableVec\n+```rust\n+use durable::{Db, DurableVec};\n+use serde::{Serialize, Deserialize};\n+\n+#[derive(Debug, Serialize, Deserialize)]\n+struct Task {\n+ id: u64,\n+ title: String,\n+ completed: bool,\n+}\n+\n+fn main() -> Result<(), Box> {\n+ // Open or create a database\n+ let db = Db::open(\"my_db\")?;\n+ \n+ // Create a persistent vector\n+ let mut tasks = DurableVec::::new(&db, \"tasks\")?;\n+ \n+ // Use it like a normal Vec!\n+ tasks.push(Task {\n+ id: 1,\n+ title: \"Build something amazing\".to_string(),\n+ completed: false,\n+ })?;\n+ \n+ // Data persists across program restarts\n+ println!(\"Total tasks: {}\", tasks.len()?);\n+ \n+ Ok(())\n+}\n+```\n+\n+### DurableMap\n+```rust\n+use durable::{Db, DurableMap};\n+\n+fn main() -> Result<(), Box> {\n+ let db = Db::open(\"my_db\")?;\n+ \n+ // Create a persistent map\n+ let mut scores = DurableMap::::new(&db, \"scores\")?;\n+ \n+ // Use it like a HashMap!\n+ // Use put() when you don't need the old value (more efficient)\n+ scores.put(\"Alice\".to_string(), 100)?;\n+ scores.put(\"Bob\".to_string(), 85)?;\n+ \n+ // Use insert() when you need to know the old value\n+ if let Some(old_score) = scores.insert(\"Alice\".to_string(), 120)? {\n+ println!(\"Alice's previous score was: {}\", old_score);\n+ }\n+ \n+ // Get values\n+ if let Some(score) = scores.get(&\"Alice\".to_string())? {\n+ println!(\"Alice's score: {}\", score);\n+ }\n+ \n+ // Iterate over entries\n+ for (name, score) in scores.iter()? {\n+ println!(\"{}: {}\", name, score);\n+ }\n+ \n+ Ok(())\n+}\n+```\n+\n+### Nested Collections\n+\n+Durable supports nesting collections within each other for complex data structures:\n+\n+```rust\n+use durable::{Db, DurableMap, DurableVec};\n+\n+fn main() -> Result<(), Box> {\n+ let db = Db::open(\"my_db\")?;\n+ \n+ // Create a map where each user has a list of posts\n+ let user_posts: DurableMap> = \n+ DurableMap::new_nested(&db, \"user_posts\");\n+ \n+ // Add posts for a user\n+ let mut alice_posts = user_posts.entry(\"alice\".to_string())?.or_default()?;\n+ alice_posts.push(\"Hello, world!\".to_string())?;\n+ alice_posts.push(\"Rust is awesome!\".to_string())?;\n+ \n+ // Or use chained calls for convenience\n+ user_posts.entry(\"bob\".to_string())?.or_default()?.push(\"First post!\".to_string())?;\n+ \n+ // Access nested data\n+ let alice_posts = user_posts.entry(\"alice\".to_string())?.or_default()?;\n+ println!(\"Alice has {} posts\", alice_posts.len()?);\n+ \n+ Ok(())\n+}\n+```\n+\n+The entry API automatically creates nested collections when they don't exist, providing ergonomic access patterns similar to `std::collections::HashMap::entry().or_default()`.\n+\n+## Current Status\n+\n+### Implemented\n+\n+- ✅ `DurableVec` with full test coverage including:\n+ - Basic operations: `push`, `pop`, `get`, `len`, `clear`\n+ - Batch operations: `extend`\n+ - Iteration: `iter()` returns a streaming iterator, `to_vec()` loads into memory\n+ - Property-based testing with proptest\n+ - Unicode string support\n+ - Complex type support\n+\n+- ✅ `DurableMap` with full test coverage including:\n+ - Basic operations: `insert`, `put`, `get`, `remove`, `contains_key`, `len`, `clear`\n+ - Batch operations: `extend`\n+ - Iteration: `iter()`, `keys()`, `values()` return streaming iterators\n+ - Memory loading: `to_vec()`, `keys_vec()`, `values_vec()` for convenience\n+ - Complex key and value types\n+ - Property-based testing with proptest\n+\n+- ✅ **Nested Collections** with entry API:\n+ - `DurableMap>` - Maps to vectors\n+ - `entry()` method with `or_default()` for ergonomic access\n+ - Automatic collection creation and management\n+ - Full persistence and isolation between nested collections\n+ - Type-safe compile-time enforcement\n+\n+### Coming Soon\n+\n+- 🚧 `DurableSet` - Persistent HashSet \n+- 🚧 Deep nesting (e.g., `DurableMap>>`)\n+- 🚧 Schema migration support\n+- 🚧 Batch operations across multiple collections\n+\n+## Performance\n+\n+All operations are designed to be efficient:\n+\n+- **DurableVec**:\n+ - `push`: Single atomic write with WAL flush\n+ - `get`: Direct key lookup, O(1) \n+ - `len`: Metadata lookup, O(1)\n+ - `extend`: Batched writes for efficiency\n+ - `clear`: Atomic batch deletion\n+\n+- **DurableMap**:\n+ - `insert`: Returns old value (2 ops: get + put), O(1) average\n+ - `put`: No return value (1 op: existence check + put), O(1) average\n+ - `get`: Direct key lookup, O(1) average\n+ - `remove`: Single delete with WAL flush\n+ - `len`: Metadata lookup, O(1)\n+ - `extend`: Batched writes for efficiency\n+\n+## Testing\n+\n+Run the test suite:\n+\n+```bash\n+cargo test\n+```\n+\n+Run the examples:\n+\n+```bash\n+cargo run --example vec_example\n+cargo run --example map_example\n+cargo run --example combined_example # Shows both collections working together\n+cargo run --example streaming_demo # Demonstrates efficient streaming iteration\n+cargo run --example nested_example # Shows nested collections (Map -> Vec)\n+cargo run --example simple_ranking # Gaming leaderboard from docs/motivation.md\n+cargo run --example ranking_history # Complex ranking system with persistence\n+```\n+\n+## License\n+\n+Licensed under either of:\n+\n+- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0)\n+- MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT)\n+\n+at your option.\n+\n+## Contributing\n+\n+Contributions are welcome! Please feel free to submit a Pull Request.\ndiff --git a/durable/docs/001.md b/durable/docs/001.md\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..3ad7cf9d93572de9ded367f9ce6dcc28608bba39\n--- /dev/null\n+++ b/durable/docs/001.md\n@@ -0,0 +1,272 @@\n+## Durable — RocksDB-backed Persistent Data Structures for Rust\n+\n+### *draft RFC v0.1*\n+\n+---\n+\n+### 1  Purpose\n+\n+Provide ergonomic, std-like collections (`DurableMap`, `DurableVec`, `DurableSet`, …) whose contents are **durably stored in RocksDB** yet feel in-memory:\n+\n+```rust\n+let db = durable::open(\"db\"); // single call opens RocksDB\n+let mut posts = DurableMap::::new(&db, \"posts\")?;\n+\n+posts.insert(id, post)?; // ACID write\n+let p = posts.get(&id)?; // typed read\n+```\n+\n+Target use cases:\n+\n+| Domain | Why durable? |\n+| ------------------------------ | -------------------------------- |\n+| Local-first apps / CRDT caches | crash-safe, embedded |\n+| High-write time-series blobs | append-only keys, fast iteration |\n+| ML / ranking histories | vector-within-vector patterns |\n+| Game state / async board games | snapshot + rollback |\n+| Sorter (tag → bucket → item) | exactly the motivating structure |\n+\n+---\n+\n+### 2  Design Goals\n+\n+1. **Ergonomic** – Feel like `std::collections`; no manual key mangling.\n+2. **Typed** – Keys & values are generic; (de)serialization pluggable (default = `bincode`).\n+3. **Atomic** – Multi-op `batch.commit()` gives RocksDB write-batch semantics.\n+4. **Crash-safe** – Every public write path `fsync`s RocksDB WAL first.\n+5. **Composable** – Any collection may nest another via *prefix subspaces*.\n+6. **Zero external services** – Single embedded `.sst` directory.\n+7. **Opt-in features** – Reactive diffs, LRU cache, metrics behind feature flags.\n+\n+Non-goals (v0 line):\n+\n+* Distributed replication\n+* Multi-process concurrency (one writer process is assumed; readers may open secondary Rocks instances)\n+* SQL-style ad-hoc queries – you iterate, not query-plan.\n+\n+---\n+\n+### 3  Key–Value Layout\n+\n+*Each collection owns a **prefix** inside a single Column Family (`default` by default).*\n+\n+```\n+ 0x00 0x00 [ 0x00 …]\n+```\n+\n+* `user-prefix` = crate-level namespace (allows multi-tenant apps)\n+* `col_id` = 8-byte, little-endian numeric ID assigned on `DurableMap::new(&db,\"posts\")`.\n+* `logical-key` = `serde`-encoded key (or ordinal for Vec).\n+* `subindex` = extra path segments used by nested collections (e.g., `Vec` elements in a map value).\n+\n+Because RocksDB stores keys lexicographically, all elements of a collection (and its descendants) live contiguously → range scans & prefix deletes are cheap.\n+\n+---\n+\n+### 4  Public API (surface)\n+\n+```rust\n+/// Opens (or creates) a durable database at `path`.\n+pub fn open>(path: P) -> Result;\n+\n+/// A transactional write batch\n+pub struct Batch<'db> { /* .. */ }\n+\n+impl<'db> Batch<'db> {\n+ pub fn put(&mut self, col: &impl WriteCollection, key: &K, val: &V) -> Result<()>;\n+ pub fn delete(&mut self, col: &impl WriteCollection, key: &K) -> Result<()>;\n+ pub fn commit(self) -> Result<()>;\n+}\n+\n+/// Collections ------------------------------------------------------------\n+\n+pub struct DurableMap<'db, K, V> { /* .. */ }\n+pub struct DurableVec<'db, T> { /* .. */ }\n+pub struct DurableSet<'db, T> { /* .. */ }\n+pub struct DurableIndex<'db, K, V> { /* sorted-map, range queries */ }\n+\n+/// Common read API\n+pub trait ReadCollection {\n+ fn get(&self, k: &K) -> Result>;\n+ fn contains_key(&self, k: &K) -> Result;\n+ fn len(&self) -> Result;\n+ fn iter(&self) -> Iter<'_, (K,V)>; // owns snapshot\n+}\n+\n+/// Common write API (auto batch or explicit)\n+pub trait WriteCollection: ReadCollection {\n+ fn insert(&mut self, k: K, v: V) -> Result>;\n+ fn remove(&mut self, k: &K) -> Result>;\n+ fn clear(&mut self) -> Result<()>;\n+}\n+```\n+\n+*All writes go through an internal `rocksdb::WriteBatch`* so a single logical op remains atomic even if it touches subkeys (e.g., pushing into a `DurableVec` updates `len` meta key + appends element).\n+\n+---\n+\n+### 5  Collection Semantics\n+\n+#### 5.1 `DurableVec`\n+\n+* Meta-key `__len` stores current length (`u64`).\n+* Element key = `|`\n+* `push(elem)` = `batch.put(key(len), elem); batch.put(__len, len+1)`\n+ – O(1) write, O(log N) read via prefix seek.\n+* `iter()` performs a prefix range; snapshot guarantees repeat-read.\n+\n+#### 5.2 `DurableMap`\n+\n+* Key = `|`\n+* `len` optional (feature `\"size_tracking\"`); otherwise O(prefix-scan).\n+\n+#### 5.3 Nested Collections\n+\n+```rust\n+let users = DurableMap::>::new(&db,\"users\")?;\n+users.entry(\"alice\")?.or_default()?.push(order)?;\n+```\n+\n+Internally `Entry::or_default` creates a *child prefix* off the parent’s key:\n+`|\"alice\"|0x00||…`\n+\n+Child collections store their own metadata keys beneath that path.\n+\n+---\n+\n+### 6  Transactions & Consistency\n+\n+* **Auto-batch**: default mutator methods create a WriteBatch, commit, and flush WAL.\n+* **Explicit batch**: user opens `let mut wb = db.batch();`, issues puts/deletes via collection adapters, then `wb.commit()` for cross-collection atomicity.\n+* **Crash guarantee**: after `commit` returns, updates survive power loss (`rocksdb::DB::flush_wal(true)`).\n+\n+Read operations take a **consistent snapshot** by default; advanced users can opt-out for max throughput.\n+\n+---\n+\n+### 7  Migrations (v0.2 roadmap)\n+\n+* Each collection stores a `u32 schema_version` meta key.\n+* `durable::open` accepts an optional `Schema` describing:\n+\n+ ```rust\n+ struct Schema { collections: Vec, version: u32 }\n+ ```\n+\n+ If version mismatch ⇒ run user-supplied `migrate(old, new, &db)` which gets a mutable view and may batch-rewrite keys.\n+\n+---\n+\n+### 8  Reactive Diffs (feature `\"watch\"`)\n+\n+* Behind a Tokio-aware feature; uses RocksDB’s `get_updates_since(seq)` API.\n+* `watch_prefix(prefix) -> impl Stream`\n+ – `Diff` = key, old val (Option), new val (Option).\n+* Back-pressure handled with an in-process ring buffer; user chooses lag policy.\n+\n+---\n+\n+### 9  Caching Layer (feature `\"cache\"`)\n+\n+* Probabilistic LRU over deserialized values.\n+* Configurable per-collection: `with_cache(cap_entries, ttl_ms)`.\n+* Coherent: write path invalidates cache keys on commit.\n+\n+---\n+\n+### 10  Error Model\n+\n+```rust\n+#[non_exhaustive]\n+pub enum Error {\n+ Rocks(rocksdb::Error),\n+ Serde(bincode::Error),\n+ Corruption(String),\n+ TransactionAborted,\n+ // feature-gated variants e.g. WatchLagged\n+}\n+```\n+\n+`Result = std::result::Result` everywhere.\n+\n+---\n+\n+### 11  Performance Budget (baseline targets)\n+\n+| Operation | Goal |\n+| -------------------- | -------------------------------------- |\n+| `map.insert` | < 30 µs (including WAL fsync) |\n+| `vec.push` | < 25 µs |\n+| Prefix iteration 1 M | > 120 MB/s read BW on NVMe |\n+| Concurrent readers | Linear scaling up to RocksDB read-IOPS |\n+| Watch latency | < 5 ms p50 on local SSD |\n+\n+(bench harness lives in `benches/` via Criterion.)\n+\n+---\n+\n+### 12  Dependency Footprint\n+\n+* `rocksdb` (–> FB fork) ⟹ builds C++11 static lib (\\~10 MB)\n+* `bincode` (default), with `serde` feature.\n+* `tokio-stream` only if `watch` feature enabled.\n+\n+MSRV = 1.76.\n+\n+---\n+\n+### 13  Minimum Deliverable for **v0.1-alpha**\n+\n+* [ ] `Db::open`, `Db::batch`\n+* [ ] `DurableMap`, `DurableVec`\n+* [ ] automatic serialization via `serde`\n+* [ ] atomic commit + WAL flush\n+* [ ] snapshot reads\n+* [ ] unit tests (insert/get/iter/crash-recovery using `tempdir`)\n+* [ ] criterion bench\n+\n+---\n+\n+### 14  Future Work\n+\n+* **Replicated mode** (Raft or FoundationDB layer)\n+* **CRDT merge semantics** for offline edits\n+* **DurableGraph** (adjacency lists + index)\n+* WebAssembly key-value adapters (edge workers)\n+* `tracing` instrumentation & Prometheus metrics\n+\n+---\n+\n+### 15  Licensing & Governance\n+\n+* License: **Apache-2.0 OR MIT** (standard Rust dual license)\n+* Code-of-conduct: Rust CoC template\n+* Contribution model: PR + mandatory CI (fmt, clippy, test, bench)\n+* Early roadmap guided by original author(s); transfer to an org when ≥3 maintainers.\n+\n+---\n+\n+## Appendix A — Sorter Use-Case Sketch\n+\n+```rust\n+type TagId = String;\n+type Bucket = u64; // logical Unix day or version #\n+type ItemId = String;\n+type Elo = i32;\n+\n+let hist = DurableMap::>>::new(&db,\"hist\")?;\n+\n+// update elo\n+hist.entry(\"tf2\")?\n+ .or_default()?\n+ .entry(today_bucket)?\n+ .or_default()?\n+ .push((item_id, new_elo))?;\n+\n+// stream bucket\n+let items = hist.get(\"tf2\")?.unwrap()\n+ .get(&today_bucket)?.unwrap()\n+ .iter().collect::>();\n+```\n+\n+All three layers share one RocksDB instance; you pay one WAL flush per ranking update, but reads are prefix-scans with zero allocations.\ndiff --git a/durable/docs/motivation.md b/durable/docs/motivation.md\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..50d8ee5ef9f384d908e3a86f6945e3f4ced58292\n--- /dev/null\n+++ b/durable/docs/motivation.md\n@@ -0,0 +1,248 @@\n+# Why Durable? The Missing Abstraction Layer for Persistent Storage\n+\n+## The Problem: The Abstraction Gap\n+\n+Every database forces developers to translate between how they **think** about data and how they **store** it. This translation layer is where bugs hide, performance suffers, and development slows down.\n+\n+### Example: Building a Multiplayer Game Leaderboard\n+\n+Let's say you need to store player rankings by game mode, with history by day. Here's the data model in your head:\n+\n+```\n+Game Mode → Day → List of (Player, Score)\n+```\n+\n+#### With Raw Key-Value Stores (sled, RocksDB)\n+\n+```rust\n+// Storing a score requires manual key construction\n+let key = format!(\"leaderboard:{}:{}:player:{}\", game_mode, day, player_id);\n+db.insert(key.as_bytes(), score.to_le_bytes())?;\n+\n+// Getting today's leaderboard? Manual prefix scan and deserialization\n+let prefix = format!(\"leaderboard:{}:{}:\", game_mode, day);\n+let mut scores = Vec::new();\n+for item in db.scan_prefix(prefix.as_bytes()) {\n+ let (key, value) = item?;\n+ // Parse player_id from key string... hope the format is right\n+ // Deserialize score... hope it's the right type\n+ scores.push((player_id, score));\n+}\n+scores.sort_by_key(|(_, s)| *s);\n+\n+// Want to know how many players played today? Another scan!\n+// Want to atomically update multiple scores? Write a transaction wrapper!\n+// Want to clean up old days? Manual prefix iteration and deletion!\n+```\n+\n+**Problems:**\n+- String manipulation for every operation\n+- No type safety (everything is bytes)\n+- Manual implementation of collection semantics\n+- No atomicity across related keys\n+- Performance overhead from string parsing\n+\n+#### With SQL Databases (SQLite, PostgreSQL)\n+\n+```sql\n+CREATE TABLE leaderboards (\n+ game_mode VARCHAR(50),\n+ day DATE,\n+ player_id UUID,\n+ score INTEGER,\n+ PRIMARY KEY (game_mode, day, player_id)\n+);\n+\n+-- Getting a leaderboard requires SQL\n+SELECT player_id, score \n+FROM leaderboards \n+WHERE game_mode = ? AND day = ?\n+ORDER BY score DESC;\n+```\n+\n+```rust\n+// In Rust, you need an ORM or manual query building\n+let scores: Vec<(Uuid, i32)> = sqlx::query_as(\n+ \"SELECT player_id, score FROM leaderboards WHERE game_mode = $1 AND day = $2 ORDER BY score DESC\"\n+)\n+.bind(&game_mode)\n+.bind(&day)\n+.fetch_all(&pool)\n+.await?;\n+```\n+\n+**Problems:**\n+- Impedance mismatch (relational model vs nested structures)\n+- SQL complexity for simple operations\n+- ORMs add abstraction layers and performance overhead\n+- Async runtime required even for local storage\n+- Schema migrations for every structural change\n+\n+#### With Document Stores (MongoDB)\n+\n+```javascript\n+// Document structure\n+{\n+ game_mode: \"ranked\",\n+ day: \"2024-01-15\",\n+ scores: [\n+ { player_id: \"abc\", score: 1500 },\n+ { player_id: \"def\", score: 1400 }\n+ ]\n+}\n+\n+// But now you have a different problem: updating a single score\n+// requires loading and saving the entire document!\n+```\n+\n+**Problems:**\n+- Not embedded (requires separate process)\n+- Document size limits\n+- Inefficient for partial updates\n+- Complex setup for local-first apps\n+\n+## The Solution: Native Data Structures\n+\n+With Durable, you express your data model directly:\n+\n+```rust\n+let leaderboard = DurableMap::>>::new(&db, \"leaderboard\")?;\n+\n+// Store a score - reads like natural Rust code\n+leaderboard\n+ .entry(game_mode)?\n+ .or_default()?\n+ .entry(day)?\n+ .or_default()?\n+ .push((player_id, score))?;\n+\n+// Get today's leaderboard - it's just a Vec\n+let mut today_scores = leaderboard\n+ .get(&game_mode)?\n+ .and_then(|mode| mode.get(&day).ok())\n+ .unwrap_or_default();\n+today_scores.sort_by_key(|(_, s)| *s);\n+\n+// All operations are atomic, typed, and efficient\n+```\n+\n+## Why This Matters\n+\n+### 1. **Zero Translation Overhead**\n+\n+Your mental model **is** the storage model. No more:\n+- String concatenation for keys\n+- Manual serialization/deserialization \n+- SQL query construction\n+- Document structure mapping\n+\n+### 2. **Composition Without Complexity**\n+\n+Nested data structures \"just work\":\n+\n+```rust\n+// A real-world example: user notifications by app by priority\n+let notifications = DurableMap::>>>::new(&db, \"notifs\")?;\n+\n+// Natural access patterns\n+notifications\n+ .get(&user_id)?\n+ .get(&app_id)?\n+ .get(&Priority::High)?\n+ .iter()\n+ .take(10) // Latest 10 high-priority notifications\n+```\n+\n+Try implementing this with SQL joins or KV prefixes!\n+\n+### 3. **Type Safety Throughout**\n+\n+```rust\n+// This won't compile - type safety at every level\n+let score: String = leaderboard.get(&\"chess\")?.get(&20240115)?.get(0)?;\n+// ^^^^^^ expected Score, found String\n+\n+// With raw KV stores, this is a runtime error after deserialization\n+```\n+\n+### 4. **Atomicity By Design**\n+\n+```rust\n+// Multiple operations in one atomic batch\n+let mut batch = db.batch();\n+batch.vec_push(&game.players, new_player)?;\n+batch.map_insert(&game.scores, player_id, 0)?;\n+batch.map_increment(&game.stats, \"player_count\", 1)?;\n+batch.commit()?; // All or nothing\n+```\n+\n+### 5. **Performance Without Compromise**\n+\n+- **Locality**: Related data stored contiguously (prefix design)\n+- **Zero-copy possible**: Direct memory mapping for read-heavy workloads\n+- **Streaming iteration**: No need to load entire collections\n+- **Bulk operations**: Native batch support\n+\n+## Comparison Matrix\n+\n+| Feature | Durable | sled/RocksDB | SQLite | MongoDB |\n+|---------|---------|--------------|---------|----------|\n+| **Native collections** | ✅ Built-in | ❌ DIY | ❌ Tables only | ⚠️ Documents |\n+| **Type safety** | ✅ Full | ❌ Bytes | ⚠️ ORM-dependent | ⚠️ Schema validation |\n+| **Nested structures** | ✅ Natural | ❌ Manual prefixes | ❌ Joins/JSON | ✅ Embedded docs |\n+| **Atomic operations** | ✅ Automatic | ⚠️ Manual batching | ✅ Transactions | ⚠️ Document-level |\n+| **Local/embedded** | ✅ Yes | ✅ Yes | ✅ Yes | ❌ Separate process |\n+| **Schema evolution** | ✅ Per-collection | ❌ DIY | ⚠️ Migrations | ✅ Flexible |\n+| **Memory efficiency** | ✅ Scan & stream | ✅ Manual | ⚠️ Query-dependent | ❌ Doc loading |\n+\n+## Real-World Use Cases Where Durable Shines\n+\n+### Local-First Sync Engine\n+\n+```rust\n+// Sync state with conflict tracking\n+let sync_state = DurableMap::>::new(&db, \"sync\")?;\n+\n+// Natural conflict detection\n+let versions = sync_state.get(&record_id)?;\n+if versions.values().unique().count() > 1 {\n+ // Conflict detected - handle naturally\n+}\n+```\n+\n+### Time-Series Analytics Cache\n+\n+```rust\n+// Metrics by source by minute\n+let metrics = DurableMap::>>::new(&db, \"metrics\")?;\n+\n+// Natural windowing\n+let last_hour: Vec = metrics\n+ .get(&source)?\n+ .range(now - 3600..=now)?\n+ .flat_map(|(_, minute_metrics)| minute_metrics.iter())\n+ .collect();\n+```\n+\n+### Feature Flag System with History\n+\n+```rust\n+// Flags by environment with change history\n+let flags = DurableMap::>>::new(&db, \"flags\")?;\n+\n+// Natural audit trail\n+let history = flags.get(&Env::Prod)?.get(\"new-feature\")?.iter().collect();\n+```\n+\n+## The Bottom Line\n+\n+**Durable isn't a better database - it's the missing abstraction layer that lets you use persistent storage like in-memory collections.**\n+\n+Stop translating. Start building.\n+\n+---\n+\n+*Next: Read the [RFC](001.md) for implementation details, or jump to the [Quick Start Guide](quickstart.md).* \n\\ No newline at end of file\ndiff --git a/durable/examples/combined_example.rs b/durable/examples/combined_example.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..626a6e1cf7e3c9c26f9f2edc58950d9bc31ec67e\n--- /dev/null\n+++ b/durable/examples/combined_example.rs\n@@ -0,0 +1,160 @@\n+use durable::{Db, DurableMap, DurableVec};\n+use serde::{Serialize, Deserialize};\n+use std::time::{SystemTime, UNIX_EPOCH};\n+\n+#[derive(Debug, Clone, Serialize, Deserialize)]\n+struct Message {\n+ id: u64,\n+ from: String,\n+ to: String,\n+ content: String,\n+ timestamp: u64,\n+}\n+\n+#[derive(Debug, Clone, Serialize, Deserialize)]\n+struct User {\n+ username: String,\n+ display_name: String,\n+ message_count: u32,\n+}\n+\n+fn get_timestamp() -> u64 {\n+ SystemTime::now()\n+ .duration_since(UNIX_EPOCH)\n+ .unwrap()\n+ .as_secs()\n+}\n+\n+fn main() -> Result<(), Box> {\n+ // Open or create a database\n+ let db = Db::open(\"chat_db\")?;\n+ \n+ // Create our collections\n+ let mut users = DurableMap::::new(&db, \"users\")?;\n+ let mut messages = DurableVec::::new(&db, \"messages\")?;\n+ let mut user_message_indices = DurableMap::>::new(&db, \"user_messages\")?;\n+ \n+ // Create some users\n+ users.insert(\"alice\".to_string(), User {\n+ username: \"alice\".to_string(),\n+ display_name: \"Alice Smith\".to_string(),\n+ message_count: 0,\n+ })?;\n+ \n+ users.insert(\"bob\".to_string(), User {\n+ username: \"bob\".to_string(),\n+ display_name: \"Bob Johnson\".to_string(),\n+ message_count: 0,\n+ })?;\n+ \n+ users.insert(\"charlie\".to_string(), User {\n+ username: \"charlie\".to_string(),\n+ display_name: \"Charlie Brown\".to_string(),\n+ message_count: 0,\n+ })?;\n+ \n+ // Helper to send a message\n+ let send_message = |from: &str, to: &str, content: &str, \n+ messages: &mut DurableVec,\n+ users: &mut DurableMap,\n+ indices: &mut DurableMap>| -> Result<(), Box> {\n+ // Create message\n+ let msg_id = messages.len()? as u64;\n+ let message = Message {\n+ id: msg_id,\n+ from: from.to_string(),\n+ to: to.to_string(),\n+ content: content.to_string(),\n+ timestamp: get_timestamp(),\n+ };\n+ \n+ // Store message\n+ messages.push(message)?;\n+ let msg_index = messages.len()? - 1;\n+ \n+ // Update sender's message count\n+ if let Some(mut sender) = users.get(&from.to_string())? {\n+ sender.message_count += 1;\n+ users.insert(from.to_string(), sender)?;\n+ }\n+ \n+ // Track message indices for recipient\n+ let mut recipient_indices = indices.get(&to.to_string())?.unwrap_or_default();\n+ recipient_indices.push(msg_index);\n+ indices.insert(to.to_string(), recipient_indices)?;\n+ \n+ Ok(())\n+ };\n+ \n+ // Send some messages\n+ println!(\"💬 Chat Application Demo\\n\");\n+ println!(\"Sending messages...\");\n+ \n+ send_message(\"alice\", \"bob\", \"Hey Bob, how's the Durable library coming along?\", \n+ &mut messages, &mut users, &mut user_message_indices)?;\n+ \n+ send_message(\"bob\", \"alice\", \"It's going great! We have DurableVec and DurableMap working!\", \n+ &mut messages, &mut users, &mut user_message_indices)?;\n+ \n+ send_message(\"charlie\", \"alice\", \"That sounds awesome! Can I help with testing?\", \n+ &mut messages, &mut users, &mut user_message_indices)?;\n+ \n+ send_message(\"alice\", \"charlie\", \"Absolutely! The more testing the better!\", \n+ &mut messages, &mut users, &mut user_message_indices)?;\n+ \n+ send_message(\"bob\", \"charlie\", \"Check out the examples directory for usage patterns\", \n+ &mut messages, &mut users, &mut user_message_indices)?;\n+ \n+ // Display all users and their message counts\n+ println!(\"\\n👥 Users:\");\n+ let mut all_users = users.to_vec()?;\n+ all_users.sort_by_key(|(username, _)| username.clone());\n+ \n+ for (username, user) in all_users {\n+ println!(\" {} ({}) - {} messages sent\", \n+ user.display_name, username, user.message_count);\n+ }\n+ \n+ // Display all messages\n+ println!(\"\\n📨 All messages:\");\n+ for (i, msg) in messages.iter()?.enumerate() {\n+ let msg = msg?;\n+ println!(\" [{}] {} → {}: {}\", i, msg.from, msg.to, msg.content);\n+ }\n+ \n+ // Show inbox for each user\n+ println!(\"\\n📥 User inboxes:\");\n+ for item in users.iter() {\n+ let (username, _) = item?;\n+ if let Some(indices) = user_message_indices.get(&username)? {\n+ println!(\"\\n {}'s inbox ({} messages):\", username, indices.len());\n+ for &idx in &indices {\n+ if let Some(msg) = messages.get(idx)? {\n+ println!(\" From {}: {}\", msg.from, msg.content);\n+ }\n+ }\n+ }\n+ }\n+ \n+ // Statistics\n+ println!(\"\\n📊 Statistics:\");\n+ println!(\" Total users: {}\", users.len()?);\n+ println!(\" Total messages: {}\", messages.len()?);\n+ \n+ // Demonstrate persistence\n+ println!(\"\\n💾 Data has been persisted to disk!\");\n+ println!(\" Database location: ./chat_db\");\n+ \n+ // Clean up\n+ drop(messages);\n+ drop(users);\n+ drop(user_message_indices);\n+ drop(db);\n+ \n+ // Remove the database for this example\n+ std::fs::remove_dir_all(\"chat_db\").ok();\n+ \n+ println!(\"\\n✅ Example completed!\");\n+ \n+ Ok(())\n+} \n\\ No newline at end of file\ndiff --git a/durable/examples/map_example.rs b/durable/examples/map_example.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..08b8f2c8826caf53c4c20b422a540c92f6624029\n--- /dev/null\n+++ b/durable/examples/map_example.rs\n@@ -0,0 +1,99 @@\n+use durable::{Db, DurableMap};\n+use serde::{Serialize, Deserialize};\n+\n+#[derive(Debug, Clone, Serialize, Deserialize)]\n+struct UserProfile {\n+ name: String,\n+ email: String,\n+ score: u32,\n+}\n+\n+fn main() -> Result<(), Box> {\n+ // Open or create a database\n+ let db = Db::open(\"example_db\")?;\n+ \n+ // Create a persistent map of user profiles\n+ let mut users = DurableMap::::new(&db, \"users\")?;\n+ \n+ // Insert some users\n+ // Using put() when we don't need the old value - more efficient!\n+ users.put(\n+ \"alice\".to_string(),\n+ UserProfile {\n+ name: \"Alice Smith\".to_string(),\n+ email: \"alice@example.com\".to_string(),\n+ score: 1500,\n+ },\n+ )?;\n+ \n+ users.put(\n+ \"bob\".to_string(),\n+ UserProfile {\n+ name: \"Bob Johnson\".to_string(),\n+ email: \"bob@example.com\".to_string(),\n+ score: 1200,\n+ },\n+ )?;\n+ \n+ // Using insert() when we might need the old value\n+ let old_charlie = users.insert(\n+ \"charlie\".to_string(),\n+ UserProfile {\n+ name: \"Charlie Brown\".to_string(),\n+ email: \"charlie@example.com\".to_string(),\n+ score: 1800,\n+ },\n+ )?;\n+ \n+ if old_charlie.is_some() {\n+ println!(\"Replaced existing charlie entry\");\n+ }\n+ \n+ println!(\"Total users: {}\", users.len()?);\n+ \n+ // Look up a specific user\n+ if let Some(alice) = users.get(&\"alice\".to_string())? {\n+ println!(\"\\nAlice's profile: {:?}\", alice);\n+ }\n+ \n+ // Check if a user exists\n+ println!(\"\\nDoes 'david' exist? {}\", users.contains_key(&\"david\".to_string())?);\n+ \n+ // Update a user's score\n+ if let Some(mut bob) = users.get(&\"bob\".to_string())? {\n+ bob.score += 100;\n+ // Use put() here since we don't need the old value back\n+ users.put(\"bob\".to_string(), bob)?;\n+ println!(\"Updated Bob's score!\");\n+ }\n+ \n+ // Iterate over all users\n+ println!(\"\\nAll users (sorted by username):\");\n+ let mut all_users = users.to_vec()?;\n+ all_users.sort_by_key(|(username, _)| username.clone());\n+ \n+ for (username, profile) in all_users {\n+ println!(\" {} ({}) - Score: {}\", username, profile.email, profile.score);\n+ }\n+ \n+ // Get just the usernames\n+ let mut usernames = users.keys_vec()?;\n+ usernames.sort();\n+ println!(\"\\nAll usernames: {:?}\", usernames);\n+ \n+ // Find the highest scoring user\n+ let profiles = users.values_vec()?;\n+ if let Some(top_user) = profiles.iter().max_by_key(|p| p.score) {\n+ println!(\"\\nTop scorer: {} with {} points\", top_user.name, top_user.score);\n+ }\n+ \n+ // Remove a user\n+ if let Some(removed) = users.remove(&\"charlie\".to_string())? {\n+ println!(\"\\nRemoved user: {}\", removed.name);\n+ println!(\"Users remaining: {}\", users.len()?);\n+ }\n+ \n+ println!(\"\\nData has been persisted to disk.\");\n+ \n+ Ok(())\n+} \n\\ No newline at end of file\ndiff --git a/durable/examples/nested_example.rs b/durable/examples/nested_example.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..3b880f2c8b8ad2633d4b5fcf2016652216c9de8e\n--- /dev/null\n+++ b/durable/examples/nested_example.rs\n@@ -0,0 +1,64 @@\n+use durable::{Db, DurableMap, DurableVec};\n+\n+fn main() -> Result<(), Box> {\n+ // Open a database\n+ let db = Db::open(\"nested_example_db\")?;\n+ \n+ // Create a map where each user has a list of posts\n+ let user_posts: DurableMap> = DurableMap::new_nested(&db, \"user_posts\");\n+ \n+ // Add posts for Alice\n+ println!(\"Adding posts for Alice...\");\n+ let mut alice_posts = user_posts.entry(\"alice\".to_string())?.or_default()?;\n+ alice_posts.push(\"Hello, world!\".to_string())?;\n+ alice_posts.push(\"Rust is awesome!\".to_string())?;\n+ alice_posts.push(\"Loving persistent data structures!\".to_string())?;\n+ \n+ // Add posts for Bob\n+ println!(\"Adding posts for Bob...\");\n+ let mut bob_posts = user_posts.entry(\"bob\".to_string())?.or_default()?;\n+ bob_posts.push(\"First post\".to_string())?;\n+ bob_posts.push(\"Learning Rust\".to_string())?;\n+ \n+ // Add a post for Charlie in a chained call\n+ println!(\"Adding post for Charlie...\");\n+ user_posts.entry(\"charlie\".to_string())?.or_default()?.push(\"One-liner post!\".to_string())?;\n+ \n+ // Read back Alice's posts\n+ println!(\"\\nAlice's posts:\");\n+ let alice_posts_read = user_posts.entry(\"alice\".to_string())?.or_default()?;\n+ for i in 0..alice_posts_read.len()? {\n+ if let Some(post) = alice_posts_read.get(i)? {\n+ println!(\" {}: {}\", i + 1, post);\n+ }\n+ }\n+ \n+ // Read back Bob's posts\n+ println!(\"\\nBob's posts:\");\n+ let bob_posts_read = user_posts.entry(\"bob\".to_string())?.or_default()?;\n+ for i in 0..bob_posts_read.len()? {\n+ if let Some(post) = bob_posts_read.get(i)? {\n+ println!(\" {}: {}\", i + 1, post);\n+ }\n+ }\n+ \n+ // Read back Charlie's posts\n+ println!(\"\\nCharlie's posts:\");\n+ let charlie_posts_read = user_posts.entry(\"charlie\".to_string())?.or_default()?;\n+ for i in 0..charlie_posts_read.len()? {\n+ if let Some(post) = charlie_posts_read.get(i)? {\n+ println!(\" {}: {}\", i + 1, post);\n+ }\n+ }\n+ \n+ println!(\"\\nDemonstration of persistence...\");\n+ println!(\"Data is now persisted to disk. You can stop and restart this program,\");\n+ println!(\"and all the posts will still be there!\");\n+ \n+ println!(\"\\nTotal users with posts: 3\");\n+ println!(\"Alice has {} posts\", alice_posts_read.len()?);\n+ println!(\"Bob has {} posts\", bob_posts_read.len()?);\n+ println!(\"Charlie has {} posts\", charlie_posts_read.len()?);\n+ \n+ Ok(())\n+}\n\\ No newline at end of file\ndiff --git a/durable/examples/ranking_history.rs b/durable/examples/ranking_history.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..4da8592d240318318ae20c834d616210edb16c8d\n--- /dev/null\n+++ b/durable/examples/ranking_history.rs\n@@ -0,0 +1,179 @@\n+use durable::{Db, DurableMap, DurableVec};\n+use serde::{Serialize, Deserialize};\n+\n+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, PartialOrd)]\n+struct RankingEntry {\n+ player_id: String,\n+ score: i32,\n+ timestamp: u64,\n+}\n+\n+impl RankingEntry {\n+ fn new(player_id: &str, score: i32) -> Self {\n+ Self {\n+ player_id: player_id.to_string(),\n+ score,\n+ timestamp: std::time::SystemTime::now()\n+ .duration_since(std::time::UNIX_EPOCH)\n+ .unwrap()\n+ .as_secs(),\n+ }\n+ }\n+}\n+\n+fn main() -> Result<(), Box> {\n+ println!(\"🎮 Gaming Ranking History System (Rewritten with Nested Entry API)\");\n+ println!(\"================================================================\");\n+ \n+ let db = Db::open(\"ranking_history_db\")?;\n+ \n+ // THE CORE CHANGE: Define the truly nested data structure.\n+ // Instead of a composite key, we nest a Map within a Map.\n+ // This represents the ideal, ergonomic API.\n+ type DailyRankings = DurableVec;\n+ type GameHistory = DurableMap;\n+ type Rankings = DurableMap;\n+\n+ let rankings: Rankings = DurableMap::new_nested(&db, \"game_rankings_v2\");\n+ \n+ // Simulate some game days\n+ let today = 20241215u32;\n+ let yesterday = 20241214u32;\n+ let last_week = 20241208u32;\n+ \n+ // No more `make_key` helper function!\n+ \n+ println!(\"\\n📊 Adding ranking data using chained entry().or_default()...\");\n+ \n+ // Add rankings for CS2 today. This demonstrates the new, clean access pattern.\n+ println!(\"Adding CS2 rankings for today ({})\", today);\n+ let mut cs2_today = rankings\n+ .entry(\"cs2\".to_string())?\n+ .or_default()? // Returns GameHistory (DurableMap) for \"cs2\"\n+ .entry(today)?\n+ .or_default()?; // Returns DailyRankings (DurableVec<...>) for `today`\n+\n+ cs2_today.push(RankingEntry::new(\"player1\", 2450))?;\n+ cs2_today.push(RankingEntry::new(\"player2\", 2380))?;\n+ cs2_today.push(RankingEntry::new(\"player3\", 2320))?;\n+ cs2_today.push(RankingEntry::new(\"player4\", 2280))?;\n+ \n+ // Add rankings for CS2 yesterday\n+ println!(\"Adding CS2 rankings for yesterday ({})\", yesterday);\n+ rankings\n+ .entry(\"cs2\".to_string())?\n+ .or_default()?\n+ .entry(yesterday)?\n+ .or_default()?\n+ .push(RankingEntry::new(\"player1\", 2420))?;\n+ rankings\n+ .entry(\"cs2\".to_string())?\n+ .or_default()?\n+ .entry(yesterday)?\n+ .or_default()?\n+ .push(RankingEntry::new(\"player2\", 2350))?;\n+ rankings\n+ .entry(\"cs2\".to_string())?\n+ .or_default()?\n+ .entry(yesterday)?\n+ .or_default()?\n+ .push(RankingEntry::new(\"player5\", 2300))?;\n+\n+ // Add rankings for Valorant today\n+ println!(\"Adding Valorant rankings for today ({})\", today);\n+ let mut valorant_today = rankings\n+ .entry(\"valorant\".to_string())?\n+ .or_default()?\n+ .entry(today)?\n+ .or_default()?;\n+\n+ valorant_today.push(RankingEntry::new(\"player6\", 1850))?;\n+ valorant_today.push(RankingEntry::new(\"player7\", 1820))?;\n+ valorant_today.push(RankingEntry::new(\"player1\", 1800))?; // Same player, different game\n+ \n+ // Add TF2 rankings (matching the docs example)\n+ println!(\"Adding TF2 rankings for last week ({})\", last_week);\n+ rankings\n+ .entry(\"tf2\".to_string())?\n+ .or_default()?\n+ .entry(last_week)?\n+ .or_default()?\n+ .push(RankingEntry::new(\"veteran_player\", 3200))?;\n+ rankings\n+ .entry(\"tf2\".to_string())?\n+ .or_default()?\n+ .entry(last_week)?\n+ .or_default()?\n+ .push(RankingEntry::new(\"old_school_gamer\", 3150))?;\n+ \n+ println!(\"\\n🏆 Reading back ranking data with the same natural API...\");\n+ \n+ // Get today's CS2 leaderboard\n+ println!(\"\\n🎯 CS2 Leaderboard for {} (today):\", today);\n+ let mut today_rankings = rankings\n+ .entry(\"cs2\".to_string())?\n+ .or_default()?\n+ .entry(today)?\n+ .or_default()?\n+ .to_vec()?;\n+\n+ // Sort by score descending\n+ today_rankings.sort_by(|a, b| b.score.cmp(&a.score));\n+ \n+ for (rank, entry) in today_rankings.iter().enumerate() {\n+ println!(\" {}. {} - {} points\", rank + 1, entry.player_id, entry.score);\n+ }\n+ \n+ // Show cross-game analysis is still easy\n+ println!(\"\\n🎮 Multi-game player analysis for player1 on {}:\", today);\n+ let cs2_player1_score = today_rankings.iter()\n+ .find(|e| e.player_id == \"player1\")\n+ .map(|e| e.score);\n+\n+ let valorant_player1_score = valorant_today.to_vec()?.iter()\n+ .find(|e| e.player_id == \"player1\")\n+ .map(|e| e.score);\n+\n+ if let Some(score) = cs2_player1_score { println!(\" CS2 Score: {}\", score); }\n+ if let Some(score) = valorant_player1_score { println!(\" Valorant Score: {}\", score); }\n+ \n+ // Showcase the power of the nested structure for stats\n+ // For nested collections, we use the keys API instead of iter()\n+ println!(\"\\n📊 Dynamic Database Statistics (discovered games):\");\n+ \n+ // Note: For nested collections, we iterate over known keys or use a different approach\n+ // since the values (nested DurableMaps) cannot be directly deserialized\n+ let games = vec![\"cs2\", \"valorant\", \"tf2\"]; // In a real app, you might track these separately\n+ \n+ for game in games {\n+ let game_history = rankings.entry(game.to_string())?.or_default()?;\n+ let active_days = game_history.len()?;\n+ \n+ if active_days > 0 {\n+ // For demonstration, let's count entries from known days\n+ let mut total_entries = 0;\n+ let days = [today, yesterday, last_week];\n+ \n+ for day in days {\n+ if let Ok(daily_rankings) = game_history.entry(day) {\n+ if let Ok(rankings_vec) = daily_rankings.or_default() {\n+ total_entries += rankings_vec.len()?;\n+ }\n+ }\n+ }\n+ \n+ if total_entries > 0 {\n+ println!(\" • {}: {} total entries across {} active day(s)\", \n+ game.to_uppercase(), total_entries, active_days);\n+ }\n+ }\n+ }\n+ \n+ println!(\"\\n✨ Key Benefits of This Rewritten Approach:\");\n+ println!(\" • No more manual key construction (`format!`) - the core goal is met!\");\n+ println!(\" • The code's structure now mirrors the mental model: `rankings[game][day]`\");\n+ println!(\" • Truly compositional API, unlocking more powerful dynamic queries (like the stats section)\");\n+ println!(\" • Demonstrates the full power of the `DurableCollection` and `entry()` design.\");\n+\n+ Ok(())\n+}\n\\ No newline at end of file\ndiff --git a/durable/examples/simple_ranking.rs b/durable/examples/simple_ranking.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..3cc873c41f3d93c7fa9e6e2dfc80e815f456b1f9\n--- /dev/null\n+++ b/durable/examples/simple_ranking.rs\n@@ -0,0 +1,68 @@\n+use durable::{Db, DurableMap, DurableVec};\n+\n+fn main() -> Result<(), Box> {\n+ println!(\"🏆 Simple Game Ranking Example\");\n+ println!(\"Demonstrating the pattern from docs/motivation.md\");\n+ println!(\"===============================================\");\n+ \n+ let db = Db::open(\"simple_ranking_db\")?;\n+ \n+ // This is the exact pattern from the docs: Game Mode → List of (Player, Score)\n+ // For simplicity, we're showing one day's data per game mode\n+ let rankings: DurableMap> = DurableMap::new_nested(&db, \"rankings\");\n+ \n+ println!(\"\\n📊 Adding TF2 rankings (from the docs example)...\");\n+ \n+ // This is the exact code pattern shown in docs/motivation.md\n+ let mut tf2_rankings = rankings.entry(\"tf2\".to_string())?.or_default()?;\n+ tf2_rankings.push((\"player1\".to_string(), 1500))?;\n+ tf2_rankings.push((\"player2\".to_string(), 1400))?;\n+ tf2_rankings.push((\"player3\".to_string(), 1300))?;\n+ \n+ println!(\"✅ Added TF2 rankings using the docs pattern!\");\n+ \n+ // Add some other games for comparison\n+ println!(\"\\n📊 Adding CS2 rankings...\");\n+ let mut cs2_rankings = rankings.entry(\"cs2\".to_string())?.or_default()?;\n+ cs2_rankings.push((\"pro_player\".to_string(), 2500))?;\n+ cs2_rankings.push((\"skilled_gamer\".to_string(), 2200))?;\n+ \n+ println!(\"✅ Added CS2 rankings!\");\n+ \n+ // Now read back the data\n+ println!(\"\\n🏆 Current TF2 Leaderboard:\");\n+ let tf2_data = rankings.entry(\"tf2\".to_string())?.or_default()?;\n+ \n+ // Convert to vec and sort for display\n+ let mut tf2_leaderboard = tf2_data.to_vec()?;\n+ tf2_leaderboard.sort_by(|a, b| b.1.cmp(&a.1)); // Sort by score descending\n+ \n+ for (rank, (player, score)) in tf2_leaderboard.iter().enumerate() {\n+ println!(\" {}. {} - {} points\", rank + 1, player, score);\n+ }\n+ \n+ println!(\"\\n🏆 Current CS2 Leaderboard:\");\n+ let cs2_data = rankings.entry(\"cs2\".to_string())?.or_default()?;\n+ \n+ let mut cs2_leaderboard = cs2_data.to_vec()?;\n+ cs2_leaderboard.sort_by(|a, b| b.1.cmp(&a.1));\n+ \n+ for (rank, (player, score)) in cs2_leaderboard.iter().enumerate() {\n+ println!(\" {}. {} - {} points\", rank + 1, player, score);\n+ }\n+ \n+ println!(\"\\n📈 Database Statistics:\");\n+ println!(\" TF2 has {} players\", tf2_data.len()?);\n+ println!(\" CS2 has {} players\", cs2_data.len()?);\n+ \n+ println!(\"\\n✨ This demonstrates the exact pattern from docs/motivation.md:\");\n+ println!(\" rankings.entry(game_mode)?.or_default()?.push((player, score))?;\");\n+ println!(\" \");\n+ println!(\" Compare this to the manual key construction required with raw KV stores:\");\n+ println!(\" let key = format!(\\\"leaderboard:{{}}:{{}}:player:{{}}\\\", game_mode, day, player_id);\");\n+ println!(\" db.insert(key.as_bytes(), score.to_le_bytes())?;\");\n+ println!(\" \");\n+ println!(\" Durable provides the ergonomic, type-safe abstraction over RocksDB!\");\n+ \n+ Ok(())\n+}\n\\ No newline at end of file\ndiff --git a/durable/examples/streaming_demo.rs b/durable/examples/streaming_demo.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..3a74a5318675f94f89aaf01562a5b28f8a1b664a\n--- /dev/null\n+++ b/durable/examples/streaming_demo.rs\n@@ -0,0 +1,81 @@\n+use durable::{Db, DurableMap, DurableVec};\n+\n+fn main() -> Result<(), Box> {\n+ let db = Db::open(\"streaming_demo_db\")?;\n+ \n+ // Create collections with a moderate amount of data\n+ let mut map = DurableMap::::new(&db, \"large_map\")?;\n+ let mut vec = DurableVec::::new(&db, \"large_vec\")?;\n+ \n+ println!(\"🚀 Streaming Iterator Demo\\n\");\n+ \n+ // Add 1000 entries to demonstrate streaming\n+ println!(\"Adding 1000 entries to map and vec...\");\n+ for i in 0..1000 {\n+ map.insert(i, format!(\"Value {}\", i))?;\n+ vec.push(format!(\"Item {}\", i))?;\n+ }\n+ \n+ println!(\"\\n📊 Collection sizes:\");\n+ println!(\" Map entries: {}\", map.len()?);\n+ println!(\" Vec elements: {}\", vec.len()?);\n+ \n+ // Demonstrate streaming iteration - memory efficient\n+ println!(\"\\n✨ Streaming iteration (memory efficient):\");\n+ \n+ // Count items without loading into memory\n+ let map_count = map.iter().count();\n+ println!(\" Counted {} map entries without loading into memory\", map_count);\n+ \n+ // Find specific items efficiently\n+ let target = 500;\n+ let found = map.iter()\n+ .find(|item| {\n+ item.as_ref()\n+ .map(|(k, _)| *k == target)\n+ .unwrap_or(false)\n+ });\n+ \n+ if let Some(Ok((k, v))) = found {\n+ println!(\" Found key {} with value '{}' via streaming\", k, v);\n+ }\n+ \n+ // Process only what we need\n+ println!(\"\\n🎯 Processing first 10 items only:\");\n+ for (i, item) in vec.iter()?.take(10).enumerate() {\n+ match item {\n+ Ok(value) => println!(\" [{}] {}\", i, value),\n+ Err(e) => println!(\" [{}] Error: {:?}\", i, e),\n+ }\n+ }\n+ \n+ // Filter and process without loading all data\n+ println!(\"\\n🔍 Filtering even keys without loading all data:\");\n+ let even_count = map.keys()\n+ .filter(|item| {\n+ item.as_ref()\n+ .map(|k| k % 2 == 0)\n+ .unwrap_or(false)\n+ })\n+ .count();\n+ println!(\" Found {} even keys\", even_count);\n+ \n+ // Compare with loading everything into memory\n+ println!(\"\\n⚠️ Loading all data into memory (less efficient for large collections):\");\n+ let all_values = map.values_vec()?;\n+ println!(\" Loaded {} values into a Vec\", all_values.len());\n+ \n+ println!(\"\\n✅ Streaming iterators provide:\");\n+ println!(\" • Constant memory usage regardless of collection size\");\n+ println!(\" • Ability to process data larger than RAM\");\n+ println!(\" • Early termination when finding specific items\");\n+ println!(\" • Efficient filtering and transformation\");\n+ \n+ // Clean up\n+ drop(map);\n+ drop(vec);\n+ drop(db);\n+ std::fs::remove_dir_all(\"streaming_demo_db\").ok();\n+ \n+ Ok(())\n+} \n\\ No newline at end of file\ndiff --git a/durable/examples/vec_example.rs b/durable/examples/vec_example.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..07d394b5f3964ea1942a645c2f008e9e3c37d6d8\n--- /dev/null\n+++ b/durable/examples/vec_example.rs\n@@ -0,0 +1,66 @@\n+use durable::{Db, DurableVec};\n+use serde::{Serialize, Deserialize};\n+\n+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]\n+struct Task {\n+ id: u64,\n+ title: String,\n+ completed: bool,\n+}\n+\n+fn main() -> Result<(), Box> {\n+ // Open or create a database\n+ let db = Db::open(\"example_db\")?;\n+ \n+ // Create a persistent vector of tasks\n+ let mut tasks = DurableVec::::new(&db, \"tasks\")?;\n+ \n+ // Add some tasks\n+ tasks.push(Task {\n+ id: 1,\n+ title: \"Build Durable library\".to_string(),\n+ completed: true,\n+ })?;\n+ \n+ tasks.push(Task {\n+ id: 2,\n+ title: \"Write comprehensive tests\".to_string(),\n+ completed: true,\n+ })?;\n+ \n+ tasks.push(Task {\n+ id: 3,\n+ title: \"Create documentation\".to_string(),\n+ completed: false,\n+ })?;\n+ \n+ println!(\"Total tasks: {}\", tasks.len()?);\n+ \n+ // Iterate through all tasks\n+ println!(\"\\nAll tasks:\");\n+ for (i, task) in tasks.iter()?.enumerate() {\n+ let task = task?;\n+ println!(\" [{}] {} - {}\", \n+ i, \n+ task.title, \n+ if task.completed { \"✓\" } else { \"○\" }\n+ );\n+ }\n+ \n+ // Get a specific task\n+ if let Some(task) = tasks.get(1)? {\n+ println!(\"\\nTask at index 1: {:?}\", task);\n+ }\n+ \n+ // Mark the last task as completed\n+ if let Some(mut last_task) = tasks.pop()? {\n+ println!(\"\\nCompleting task: {}\", last_task.title);\n+ last_task.completed = true;\n+ tasks.push(last_task)?;\n+ }\n+ \n+ // The data persists even after the program exits!\n+ println!(\"\\nData has been persisted to disk.\");\n+ \n+ Ok(())\n+} \n\\ No newline at end of file\ndiff --git a/durable/repomix.config.json b/durable/repomix.config.json\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..bb52e4fcd383e8937245bd62566db8b7652402c2\n--- /dev/null\n+++ b/durable/repomix.config.json\n@@ -0,0 +1,27 @@\n+{\n+ \"output\": {\n+ \"filePath\": \"all.txt\",\n+ \"style\": \"plain\",\n+ \"parsableStyle\": false,\n+ \"fileSummary\": true,\n+ \"directoryStructure\": true,\n+ \"removeComments\": false,\n+ \"removeEmptyLines\": false,\n+ \"compress\": false,\n+ \"topFilesLength\": 100,\n+ \"showLineNumbers\": false,\n+ \"copyToClipboard\": false\n+ },\n+ \"include\": [],\n+ \"ignore\": {\n+ \"useGitignore\": true,\n+ \"useDefaultPatterns\": true,\n+ \"customPatterns\": []\n+ },\n+ \"security\": {\n+ \"enableSecurityCheck\": true\n+ },\n+ \"tokenCount\": {\n+ \"encoding\": \"o200k_base\"\n+ }\n+}\ndiff --git a/durable/src/lib.rs b/durable/src/lib.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..94e8823be93be1176b0a4cdc9797181b9f8caf60\n--- /dev/null\n+++ b/durable/src/lib.rs\n@@ -0,0 +1,123 @@\n+//! Durable - RocksDB-backed persistent data structures for Rust\n+\n+use std::path::Path;\n+use std::sync::Arc;\n+use rocksdb::{DB as RocksDB, Options, WriteBatch};\n+use thiserror::Error;\n+\n+pub mod vec;\n+pub mod map;\n+pub use vec::DurableVec;\n+pub use map::DurableMap;\n+\n+/// Error types for Durable operations\n+#[derive(Error, Debug)]\n+pub enum DurableError {\n+ #[error(\"RocksDB error: {0}\")]\n+ RocksDB(#[from] rocksdb::Error),\n+ \n+ #[error(\"Serialization error: {0}\")]\n+ Serialization(#[from] bincode::Error),\n+ \n+ #[error(\"Key not found\")]\n+ KeyNotFound,\n+ \n+ #[error(\"Collection not found: {0}\")]\n+ CollectionNotFound(String),\n+ \n+ #[error(\"Data corruption: {0}\")]\n+ Corruption(String),\n+}\n+\n+pub type Result = std::result::Result;\n+\n+/// A trait for types that can be used as nested collections.\n+pub trait DurableCollection {\n+ /// Creates a new instance of the collection from a database handle\n+ /// and a pre-determined, unique key prefix.\n+ /// \n+ /// This is the key method that allows `DurableMap` to instantiate\n+ /// a nested collection handle.\n+ fn from_prefix(db: Db, prefix: Vec) -> Self;\n+}\n+\n+/// The main database handle\n+#[derive(Clone)]\n+pub struct Db {\n+ inner: Arc,\n+}\n+\n+impl Db {\n+ /// Opens or creates a durable database at the given path\n+ pub fn open>(path: P) -> Result {\n+ let mut opts = Options::default();\n+ opts.create_if_missing(true);\n+ opts.create_missing_column_families(true);\n+ \n+ let db = RocksDB::open(&opts, path)?;\n+ Ok(Db { \n+ inner: Arc::new(db),\n+ })\n+ }\n+ \n+ /// Create a new write batch for atomic operations\n+ pub fn batch(&self) -> Batch {\n+ Batch {\n+ db: self.clone(),\n+ inner: WriteBatch::default(),\n+ }\n+ }\n+ \n+ /// Get the underlying RocksDB handle (for advanced usage)\n+ pub(crate) fn rocks(&self) -> &RocksDB {\n+ &self.inner\n+ }\n+ \n+ /// Get a new unique collection ID for nested collections\n+ pub fn new_collection_id(&self) -> Result {\n+ let key = b\"__global_meta:next_collection_id\";\n+ \n+ // Get current value\n+ let current_bytes = self.rocks().get(key)?;\n+ let current_id = match current_bytes {\n+ Some(bytes) => {\n+ if bytes.len() != 8 {\n+ return Err(DurableError::Corruption(\"Invalid collection ID bytes size\".into()));\n+ }\n+ let id_bytes: [u8; 8] = bytes[..8].try_into()\n+ .map_err(|_| DurableError::Corruption(\"Invalid collection ID bytes\".into()))?;\n+ u64::from_le_bytes(id_bytes)\n+ }\n+ None => 0,\n+ };\n+ \n+ let next_id = current_id + 1;\n+ \n+ // Try to atomically update - use compare-and-swap semantics\n+ let mut batch = WriteBatch::default();\n+ batch.put(key, &next_id.to_le_bytes());\n+ \n+ // For now, just write it directly. In a real implementation,\n+ // we'd want proper compare-and-swap to handle concurrent access\n+ self.rocks().write(batch)?;\n+ self.rocks().flush_wal(true)?;\n+ \n+ Ok(current_id)\n+ }\n+}\n+\n+/// A write batch for atomic operations\n+pub struct Batch {\n+ db: Db,\n+ inner: WriteBatch,\n+}\n+\n+impl Batch {\n+ /// Commit all operations in this batch atomically\n+ pub fn commit(self) -> Result<()> {\n+ self.db.rocks().write(self.inner)?;\n+ self.db.rocks().flush_wal(true)?;\n+ Ok(())\n+ }\n+ \n+}\ndiff --git a/durable/src/map.rs b/durable/src/map.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..2629857270b1be346a20fc67dec1cfc57c829403\n--- /dev/null\n+++ b/durable/src/map.rs\n@@ -0,0 +1,1073 @@\n+use crate::{Db, Result, DurableError, DurableCollection};\n+use rocksdb::{IteratorMode, WriteBatch, Direction};\n+use serde::{Serialize, Deserialize};\n+use std::marker::PhantomData;\n+\n+/// A persistent map backed by RocksDB\n+pub struct DurableMap {\n+ db: Db,\n+ prefix: Vec,\n+ _phantom: PhantomData<(K, V)>,\n+}\n+\n+impl DurableMap \n+where \n+ K: Serialize + for<'de> Deserialize<'de>,\n+ V: Serialize + for<'de> Deserialize<'de>,\n+{\n+ /// Create a new DurableMap with the given name\n+ pub fn new(db: &Db, name: &str) -> Result {\n+ let prefix = format!(\"map:{}\", name).into_bytes();\n+ \n+ Ok(DurableMap {\n+ db: db.clone(),\n+ prefix,\n+ _phantom: PhantomData,\n+ })\n+ }\n+ \n+ /// Insert a key-value pair into the map\n+ pub fn insert(&mut self, key: K, value: V) -> Result> {\n+ let key_bytes = bincode::serialize(&key)?;\n+ let value_bytes = bincode::serialize(&value)?;\n+ \n+ // Get the old value if it exists\n+ let old_value = self.get(&key)?;\n+ \n+ let mut batch = WriteBatch::default();\n+ \n+ // Write the new value\n+ let db_key = self.entry_key(&key_bytes);\n+ batch.put(&db_key, &value_bytes);\n+ \n+ // Update length if this is a new key\n+ if old_value.is_none() {\n+ let new_len = self.len()? + 1;\n+ let len_key = self.meta_key(\"len\");\n+ batch.put(&len_key, &(new_len as u64).to_le_bytes());\n+ }\n+ \n+ // Commit atomically\n+ self.db.rocks().write(batch)?;\n+ self.db.rocks().flush_wal(true)?;\n+ \n+ Ok(old_value)\n+ }\n+ \n+ /// Put a key-value pair into the map without returning the old value\n+ /// \n+ /// This is more efficient than `insert` when you don't need the old value,\n+ /// as it only checks for key existence without deserializing the value.\n+ pub fn put(&mut self, key: K, value: V) -> Result<()> {\n+ let key_bytes = bincode::serialize(&key)?;\n+ let value_bytes = bincode::serialize(&value)?;\n+ let db_key = self.entry_key(&key_bytes);\n+ \n+ let mut batch = WriteBatch::default();\n+ \n+ // Check if this is a new key (without deserializing the value)\n+ let is_new = self.db.rocks().get_pinned(&db_key)?.is_none();\n+ \n+ // Write the new value\n+ batch.put(&db_key, &value_bytes);\n+ \n+ // Update length if this is a new key\n+ if is_new {\n+ let new_len = self.len()? + 1;\n+ let len_key = self.meta_key(\"len\");\n+ batch.put(&len_key, &(new_len as u64).to_le_bytes());\n+ }\n+ \n+ // Commit atomically\n+ self.db.rocks().write(batch)?;\n+ self.db.rocks().flush_wal(true)?;\n+ \n+ Ok(())\n+ }\n+ \n+ /// Get a value by key\n+ pub fn get(&self, key: &K) -> Result> {\n+ let key_bytes = bincode::serialize(key)?;\n+ let db_key = self.entry_key(&key_bytes);\n+ \n+ match self.db.rocks().get(&db_key)? {\n+ Some(bytes) => {\n+ let value = bincode::deserialize(&bytes)?;\n+ Ok(Some(value))\n+ }\n+ None => Ok(None),\n+ }\n+ }\n+ \n+ /// Check if a key exists in the map\n+ pub fn contains_key(&self, key: &K) -> Result {\n+ let key_bytes = bincode::serialize(key)?;\n+ let db_key = self.entry_key(&key_bytes);\n+ \n+ Ok(self.db.rocks().get(&db_key)?.is_some())\n+ }\n+ \n+ /// Remove a key-value pair from the map\n+ pub fn remove(&mut self, key: &K) -> Result> {\n+ let key_bytes = bincode::serialize(key)?;\n+ let db_key = self.entry_key(&key_bytes);\n+ \n+ // Get the old value\n+ let old_value = match self.db.rocks().get(&db_key)? {\n+ Some(bytes) => {\n+ let value = bincode::deserialize(&bytes)?;\n+ Some(value)\n+ }\n+ None => None,\n+ };\n+ \n+ // Delete the key if it existed and update length\n+ if old_value.is_some() {\n+ let mut batch = WriteBatch::default();\n+ \n+ // Delete the entry\n+ batch.delete(&db_key);\n+ \n+ // Update length\n+ let new_len = self.len()? - 1;\n+ let len_key = self.meta_key(\"len\");\n+ batch.put(&len_key, &(new_len as u64).to_le_bytes());\n+ \n+ // Commit atomically\n+ self.db.rocks().write(batch)?;\n+ self.db.rocks().flush_wal(true)?;\n+ }\n+ \n+ Ok(old_value)\n+ }\n+ \n+\n+ \n+ /// Clear all entries from the map\n+ pub fn clear(&mut self) -> Result<()> {\n+ let prefix = self.entry_prefix();\n+ let mut batch = WriteBatch::default();\n+ \n+ // Collect all keys to delete\n+ let iter = self.db.rocks().iterator(IteratorMode::From(&prefix, Direction::Forward));\n+ for item in iter {\n+ let (key, _) = item?;\n+ if !key.starts_with(&prefix) {\n+ break;\n+ }\n+ batch.delete(&key);\n+ }\n+ \n+ // Reset length to 0\n+ let len_key = self.meta_key(\"len\");\n+ batch.delete(&len_key);\n+ \n+ // Commit atomically\n+ self.db.rocks().write(batch)?;\n+ self.db.rocks().flush_wal(true)?;\n+ \n+ Ok(())\n+ }\n+ \n+ /// Iterate over all key-value pairs using a streaming iterator\n+ pub fn iter(&self) -> MapIterator<'_, K, V> {\n+ let prefix = self.entry_prefix();\n+ let iter = self.db.rocks().iterator(IteratorMode::From(&prefix, Direction::Forward));\n+ \n+ MapIterator {\n+ inner: iter,\n+ prefix,\n+ _phantom: PhantomData,\n+ }\n+ }\n+ \n+ /// Load all key-value pairs into a Vec\n+ /// \n+ /// Note: This loads the entire collection into memory. For large collections,\n+ /// prefer using `iter()` which streams elements.\n+ pub fn to_vec(&self) -> Result> {\n+ let mut result = Vec::new();\n+ for item in self.iter() {\n+ result.push(item?);\n+ }\n+ Ok(result)\n+ }\n+ \n+ /// Iterate over all keys using a streaming iterator\n+ pub fn keys(&self) -> KeyIterator<'_, K, V> {\n+ let prefix = self.entry_prefix();\n+ let iter = self.db.rocks().iterator(IteratorMode::From(&prefix, Direction::Forward));\n+ \n+ KeyIterator {\n+ inner: iter,\n+ prefix,\n+ _phantom: PhantomData,\n+ }\n+ }\n+ \n+ /// Load all keys into a Vec\n+ /// \n+ /// Note: This loads all keys into memory. For large collections,\n+ /// prefer using `keys()` which streams elements.\n+ pub fn keys_vec(&self) -> Result> {\n+ let mut result = Vec::new();\n+ for item in self.keys() {\n+ result.push(item?);\n+ }\n+ Ok(result)\n+ }\n+ \n+ /// Iterate over all values using a streaming iterator\n+ pub fn values(&self) -> ValueIterator<'_, K, V> {\n+ let prefix = self.entry_prefix();\n+ let iter = self.db.rocks().iterator(IteratorMode::From(&prefix, Direction::Forward));\n+ \n+ ValueIterator {\n+ inner: iter,\n+ prefix,\n+ _phantom: PhantomData,\n+ }\n+ }\n+ \n+ /// Load all values into a Vec\n+ /// \n+ /// Note: This loads all values into memory. For large collections,\n+ /// prefer using `values()` which streams elements.\n+ pub fn values_vec(&self) -> Result> {\n+ let mut result = Vec::new();\n+ for item in self.values() {\n+ result.push(item?);\n+ }\n+ Ok(result)\n+ }\n+ \n+ /// Insert multiple key-value pairs in a single batch\n+ pub fn extend(&mut self, iter: I) -> Result<()>\n+ where\n+ I: IntoIterator\n+ {\n+ let mut batch = WriteBatch::default();\n+ let current_len = self.len()?;\n+ let mut new_entries = 0;\n+ \n+ for (key, value) in iter {\n+ let key_bytes = bincode::serialize(&key)?;\n+ let value_bytes = bincode::serialize(&value)?;\n+ let db_key = self.entry_key(&key_bytes);\n+ \n+ // Check if this is a new key\n+ if !self.contains_key(&key)? {\n+ new_entries += 1;\n+ }\n+ \n+ batch.put(&db_key, &value_bytes);\n+ }\n+ \n+ // Update length if we added new entries\n+ if new_entries > 0 {\n+ let new_len = current_len + new_entries;\n+ let len_key = self.meta_key(\"len\");\n+ batch.put(&len_key, &(new_len as u64).to_le_bytes());\n+ }\n+ \n+ // Commit atomically\n+ self.db.rocks().write(batch)?;\n+ self.db.rocks().flush_wal(true)?;\n+ \n+ Ok(())\n+ }\n+ \n+\n+ \n+ // Helper methods\n+ \n+ fn entry_key(&self, key_bytes: &[u8]) -> Vec {\n+ let mut db_key = self.prefix.clone();\n+ db_key.extend_from_slice(b\":entry:\");\n+ db_key.extend_from_slice(key_bytes);\n+ db_key\n+ }\n+ \n+ fn entry_prefix(&self) -> Vec {\n+ let mut prefix = self.prefix.clone();\n+ prefix.extend_from_slice(b\":entry:\");\n+ prefix\n+ }\n+}\n+\n+// Additional implementation block for methods that don't require serialization constraints\n+impl DurableMap {\n+ /// Create a new DurableMap for nested collections (no serialization constraints)\n+ pub fn new_nested(db: &Db, name: &str) -> Self {\n+ let prefix = format!(\"map:{}\", name).into_bytes();\n+ \n+ DurableMap {\n+ db: db.clone(),\n+ prefix,\n+ _phantom: PhantomData,\n+ }\n+ }\n+ \n+ /// Create a new DurableMap from a prefix (used for nested collections)\n+ pub fn from_prefix(db: Db, prefix: Vec) -> Self {\n+ Self {\n+ db,\n+ prefix,\n+ _phantom: PhantomData,\n+ }\n+ }\n+ \n+ /// Get the number of entries in the map (unconstrained version for nested collections)\n+ pub fn len(&self) -> Result {\n+ let key = self.meta_key(\"len\");\n+ match self.db.rocks().get(&key)? {\n+ Some(bytes) => {\n+ if bytes.len() != 8 {\n+ return Err(DurableError::Corruption(\"Invalid length bytes size\".into()));\n+ }\n+ let len_bytes: [u8; 8] = bytes[..8].try_into()\n+ .map_err(|_| DurableError::Corruption(\"Invalid length bytes\".into()))?;\n+ Ok(u64::from_le_bytes(len_bytes) as usize)\n+ }\n+ None => Ok(0),\n+ }\n+ }\n+ \n+ /// Check if the map is empty (unconstrained version for nested collections)\n+ pub fn is_empty(&self) -> Result {\n+ Ok(self.len()? == 0)\n+ }\n+\n+ /// Get the entry key for nested collections (needed by entry API)\n+ pub(crate) fn make_entry_key(&self, key_bytes: &[u8]) -> Vec {\n+ let mut db_key = self.prefix.clone();\n+ db_key.extend_from_slice(b\":entry:\");\n+ db_key.extend_from_slice(key_bytes);\n+ db_key\n+ }\n+ \n+ fn meta_key(&self, meta_type: &str) -> Vec {\n+ let mut key = self.prefix.clone();\n+ key.extend_from_slice(b\":__meta:\");\n+ key.extend_from_slice(meta_type.as_bytes());\n+ key\n+ }\n+}\n+\n+// Implement the DurableCollection trait for DurableMap\n+// This implementation is used for nested collections and doesn't require\n+// serialization bounds since nested maps use collection markers, not direct serialization\n+impl DurableCollection for DurableMap {\n+ fn from_prefix(db: Db, prefix: Vec) -> Self {\n+ DurableMap::from_prefix(db, prefix)\n+ }\n+}\n+\n+/// Entry API for DurableMap with nested collections\n+pub enum DurableEntry<'a, K, V> {\n+ Occupied(OccupiedEntry<'a, K, V>),\n+ Vacant(VacantEntry<'a, K, V>),\n+}\n+\n+impl<'a, K, V> DurableEntry<'a, K, V>\n+where\n+ K: Serialize,\n+ V: DurableCollection,\n+{\n+ /// Gets the collection, creating it if it doesn't exist\n+ pub fn or_default(self) -> Result {\n+ match self {\n+ DurableEntry::Occupied(entry) => entry.or_default(),\n+ DurableEntry::Vacant(entry) => entry.or_default(),\n+ }\n+ }\n+}\n+\n+/// Represents an entry that already exists\n+pub struct OccupiedEntry<'a, K, V> {\n+ map: &'a DurableMap,\n+ key_bytes: Vec,\n+ value_marker: Vec, // The bytes read from RocksDB, e.g., [0x02, ...]\n+}\n+\n+impl<'a, K, V> OccupiedEntry<'a, K, V> \n+where\n+ V: DurableCollection,\n+{\n+ /// Gets a handle to the existing nested collection\n+ pub fn get(self) -> Result {\n+ // 1. Parse the collection_id from self.value_marker\n+ if self.value_marker.len() != 9 || self.value_marker[0] != 0x02 {\n+ return Err(DurableError::Corruption(\"Invalid collection marker\".into()));\n+ }\n+ \n+ let col_id_bytes: [u8; 8] = self.value_marker[1..9].try_into()\n+ .map_err(|_| DurableError::Corruption(\"Invalid collection ID\".into()))?;\n+ let col_id = u64::from_le_bytes(col_id_bytes);\n+\n+ // 2. Re-construct the unique prefix for the child collection\n+ let parent_key_prefix = self.map.make_entry_key(&self.key_bytes);\n+ let child_prefix = [parent_key_prefix.as_slice(), &[0x00], &col_id.to_le_bytes()].concat();\n+\n+ // 3. Create the collection handle using the trait method\n+ Ok(V::from_prefix(self.map.db.clone(), child_prefix))\n+ }\n+ \n+ /// Gets a handle to the existing nested collection (same as get but consumes self)\n+ pub fn or_default(self) -> Result {\n+ self.get()\n+ }\n+}\n+\n+/// Represents a slot that is empty\n+pub struct VacantEntry<'a, K, V> {\n+ map: &'a DurableMap,\n+ key: K, // The original key from the user\n+}\n+\n+impl<'a, K, V> VacantEntry<'a, K, V> \n+where\n+ K: Serialize,\n+ V: DurableCollection,\n+{\n+ /// Inserts a new default collection and returns a handle to it\n+ pub fn or_default(self) -> Result {\n+ // 1. Atomically get a new unique ID for the collection\n+ let new_col_id = self.map.db.new_collection_id()?;\n+\n+ // 2. Create the marker value that points to our new collection\n+ let mut value_marker = vec![0x02_u8];\n+ value_marker.extend_from_slice(&new_col_id.to_le_bytes());\n+\n+ // 3. Get the key bytes and construct the full parent entry key\n+ let key_bytes = bincode::serialize(&self.key)?;\n+ let parent_db_key = self.map.make_entry_key(&key_bytes);\n+\n+ // 4. ATOMICALLY write the marker to the parent map\n+ self.map.db.rocks().put(&parent_db_key, &value_marker)?;\n+ self.map.db.rocks().flush_wal(true)?;\n+\n+ // 5. Construct the unique prefix for our new child collection\n+ let child_prefix = [parent_db_key.as_slice(), &[0x00], &new_col_id.to_le_bytes()].concat();\n+\n+ // 6. Create and return the new collection handle\n+ Ok(V::from_prefix(self.map.db.clone(), child_prefix))\n+ }\n+}\n+\n+// API for nested collections\n+impl DurableMap {\n+ /// The entry point for creating or accessing a nested collection.\n+ /// This method is only available when `V` is a `DurableCollection`.\n+ pub fn entry(&self, key: K) -> Result>\n+ where\n+ V: DurableCollection,\n+ K: Serialize,\n+ {\n+ let key_bytes = bincode::serialize(&key)?;\n+ let db_key = self.make_entry_key(&key_bytes);\n+\n+ match self.db.rocks().get(&db_key)? {\n+ Some(value_marker) => {\n+ // Key exists. The value should be a collection marker.\n+ Ok(DurableEntry::Occupied(OccupiedEntry {\n+ map: self,\n+ key_bytes,\n+ value_marker,\n+ }))\n+ }\n+ None => {\n+ // Key doesn't exist.\n+ Ok(DurableEntry::Vacant(VacantEntry { map: self, key }))\n+ }\n+ }\n+ }\n+}\n+\n+/// Iterator over key-value pairs in a DurableMap\n+pub struct MapIterator<'a, K, V> {\n+ inner: rocksdb::DBIterator<'a>,\n+ prefix: Vec,\n+ _phantom: PhantomData<(K, V)>,\n+}\n+\n+impl<'a, K, V> Iterator for MapIterator<'a, K, V>\n+where\n+ K: for<'de> Deserialize<'de>,\n+ V: for<'de> Deserialize<'de>,\n+{\n+ type Item = Result<(K, V)>;\n+ \n+ fn next(&mut self) -> Option {\n+ match self.inner.next() {\n+ Some(Ok((db_key, value_bytes))) => {\n+ // Check if we're still within our prefix\n+ if !db_key.starts_with(&self.prefix) {\n+ return None;\n+ }\n+ \n+ // Extract the key part (skip prefix)\n+ let key_start = self.prefix.len();\n+ let key_bytes = &db_key[key_start..];\n+ \n+ // Deserialize key and value\n+ match (bincode::deserialize(key_bytes), bincode::deserialize(&value_bytes)) {\n+ (Ok(key), Ok(value)) => Some(Ok((key, value))),\n+ (Err(e), _) | (_, Err(e)) => Some(Err(e.into())),\n+ }\n+ }\n+ Some(Err(e)) => Some(Err(e.into())),\n+ None => None,\n+ }\n+ }\n+}\n+\n+/// Iterator over keys in a DurableMap\n+pub struct KeyIterator<'a, K, V> {\n+ inner: rocksdb::DBIterator<'a>,\n+ prefix: Vec,\n+ _phantom: PhantomData<(K, V)>,\n+}\n+\n+impl<'a, K, V> Iterator for KeyIterator<'a, K, V>\n+where\n+ K: for<'de> Deserialize<'de>,\n+{\n+ type Item = Result;\n+ \n+ fn next(&mut self) -> Option {\n+ match self.inner.next() {\n+ Some(Ok((db_key, _))) => {\n+ // Check if we're still within our prefix\n+ if !db_key.starts_with(&self.prefix) {\n+ return None;\n+ }\n+ \n+ // Extract the key part (skip prefix)\n+ let key_start = self.prefix.len();\n+ let key_bytes = &db_key[key_start..];\n+ \n+ // Deserialize key\n+ match bincode::deserialize(key_bytes) {\n+ Ok(key) => Some(Ok(key)),\n+ Err(e) => Some(Err(e.into())),\n+ }\n+ }\n+ Some(Err(e)) => Some(Err(e.into())),\n+ None => None,\n+ }\n+ }\n+}\n+\n+/// Iterator over values in a DurableMap\n+pub struct ValueIterator<'a, K, V> {\n+ inner: rocksdb::DBIterator<'a>,\n+ prefix: Vec,\n+ _phantom: PhantomData<(K, V)>,\n+}\n+\n+impl<'a, K, V> Iterator for ValueIterator<'a, K, V>\n+where\n+ V: for<'de> Deserialize<'de>,\n+{\n+ type Item = Result;\n+ \n+ fn next(&mut self) -> Option {\n+ match self.inner.next() {\n+ Some(Ok((db_key, value_bytes))) => {\n+ // Check if we're still within our prefix\n+ if !db_key.starts_with(&self.prefix) {\n+ return None;\n+ }\n+ \n+ // Deserialize value\n+ match bincode::deserialize(&value_bytes) {\n+ Ok(value) => Some(Ok(value)),\n+ Err(e) => Some(Err(e.into())),\n+ }\n+ }\n+ Some(Err(e)) => Some(Err(e.into())),\n+ None => None,\n+ }\n+ }\n+}\n+\n+#[cfg(test)]\n+mod tests {\n+ use super::*;\n+ use tempfile::TempDir;\n+ use std::collections::HashMap;\n+ \n+ fn setup_test_db() -> (TempDir, Db) {\n+ let temp_dir = TempDir::new().unwrap();\n+ let db = Db::open(temp_dir.path()).unwrap();\n+ (temp_dir, db)\n+ }\n+ \n+ #[test]\n+ fn test_insert_and_get() {\n+ let (_temp, db) = setup_test_db();\n+ let mut map = DurableMap::::new(&db, \"test_map\").unwrap();\n+ \n+ // Insert some values\n+ assert_eq!(map.insert(\"one\".to_string(), 1).unwrap(), None);\n+ assert_eq!(map.insert(\"two\".to_string(), 2).unwrap(), None);\n+ assert_eq!(map.insert(\"three\".to_string(), 3).unwrap(), None);\n+ \n+ // Get values\n+ assert_eq!(map.get(&\"one\".to_string()).unwrap(), Some(1));\n+ assert_eq!(map.get(&\"two\".to_string()).unwrap(), Some(2));\n+ assert_eq!(map.get(&\"three\".to_string()).unwrap(), Some(3));\n+ assert_eq!(map.get(&\"four\".to_string()).unwrap(), None);\n+ \n+ // Update existing value\n+ assert_eq!(map.insert(\"two\".to_string(), 22).unwrap(), Some(2));\n+ assert_eq!(map.get(&\"two\".to_string()).unwrap(), Some(22));\n+ }\n+ \n+ #[test]\n+ fn test_remove() {\n+ let (_temp, db) = setup_test_db();\n+ let mut map = DurableMap::::new(&db, \"remove_map\").unwrap();\n+ \n+ // Insert and remove\n+ map.insert(\"key\".to_string(), \"value\".to_string()).unwrap();\n+ assert_eq!(map.remove(&\"key\".to_string()).unwrap(), Some(\"value\".to_string()));\n+ assert_eq!(map.remove(&\"key\".to_string()).unwrap(), None);\n+ assert_eq!(map.get(&\"key\".to_string()).unwrap(), None);\n+ }\n+ \n+ #[test]\n+ fn test_contains_key() {\n+ let (_temp, db) = setup_test_db();\n+ let mut map = DurableMap::::new(&db, \"contains_map\").unwrap();\n+ \n+ map.insert(42, \"answer\".to_string()).unwrap();\n+ \n+ assert!(map.contains_key(&42).unwrap());\n+ assert!(!map.contains_key(&43).unwrap());\n+ }\n+ \n+ #[test]\n+ fn test_len_and_clear() {\n+ let (_temp, db) = setup_test_db();\n+ let mut map = DurableMap::::new(&db, \"len_map\").unwrap();\n+ \n+ // Empty map\n+ assert_eq!(map.len().unwrap(), 0);\n+ assert!(map.is_empty().unwrap());\n+ \n+ // Add items\n+ for i in 0..10 {\n+ map.insert(i, i * 2).unwrap();\n+ }\n+ assert_eq!(map.len().unwrap(), 10);\n+ assert!(!map.is_empty().unwrap());\n+ \n+ // Clear\n+ map.clear().unwrap();\n+ assert_eq!(map.len().unwrap(), 0);\n+ assert!(map.is_empty().unwrap());\n+ }\n+ \n+ #[test]\n+ fn test_persistence() {\n+ let (temp_dir, db) = setup_test_db();\n+ \n+ // Create and populate map\n+ {\n+ let mut map = DurableMap::>::new(&db, \"persist_map\").unwrap();\n+ map.insert(\"binary\".to_string(), vec![1, 2, 3, 4, 5]).unwrap();\n+ map.insert(\"data\".to_string(), vec![10, 20, 30]).unwrap();\n+ }\n+ \n+ // Drop the database\n+ drop(db);\n+ \n+ // Reopen and verify data persists\n+ {\n+ let db = Db::open(temp_dir.path()).unwrap();\n+ let map = DurableMap::>::new(&db, \"persist_map\").unwrap();\n+ \n+ assert_eq!(map.get(&\"binary\".to_string()).unwrap(), Some(vec![1, 2, 3, 4, 5]));\n+ assert_eq!(map.get(&\"data\".to_string()).unwrap(), Some(vec![10, 20, 30]));\n+ assert_eq!(map.len().unwrap(), 2);\n+ }\n+ }\n+ \n+ #[test]\n+ fn test_iteration() {\n+ let (_temp, db) = setup_test_db();\n+ let mut map = DurableMap::::new(&db, \"iter_map\").unwrap();\n+ \n+ // Insert data\n+ let data = vec![\n+ (\"apple\".to_string(), 1),\n+ (\"banana\".to_string(), 2),\n+ (\"cherry\".to_string(), 3),\n+ ];\n+ \n+ for (k, v) in &data {\n+ map.insert(k.clone(), *v).unwrap();\n+ }\n+ \n+ // Test iter()\n+ let mut items = map.to_vec().unwrap();\n+ items.sort_by_key(|(k, _)| k.clone());\n+ assert_eq!(items, data);\n+ \n+ // Test keys()\n+ let mut keys = map.keys_vec().unwrap();\n+ keys.sort();\n+ assert_eq!(keys, vec![\"apple\", \"banana\", \"cherry\"]);\n+ \n+ // Test values()\n+ let mut values = map.values_vec().unwrap();\n+ values.sort();\n+ assert_eq!(values, vec![1, 2, 3]);\n+ }\n+ \n+ #[test]\n+ fn test_extend() {\n+ let (_temp, db) = setup_test_db();\n+ let mut map = DurableMap::::new(&db, \"extend_map\").unwrap();\n+ \n+ // Extend from iterator\n+ let data: HashMap = vec![\n+ (1, \"one\".to_string()),\n+ (2, \"two\".to_string()),\n+ (3, \"three\".to_string()),\n+ ].into_iter().collect();\n+ \n+ map.extend(data.clone()).unwrap();\n+ \n+ // Verify all items were inserted\n+ for (k, v) in data {\n+ assert_eq!(map.get(&k).unwrap(), Some(v));\n+ }\n+ assert_eq!(map.len().unwrap(), 3);\n+ }\n+ \n+ #[test]\n+ fn test_complex_keys() {\n+ use serde::{Serialize, Deserialize};\n+ \n+ #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]\n+ struct ComplexKey {\n+ id: u64,\n+ name: String,\n+ }\n+ \n+ let (_temp, db) = setup_test_db();\n+ let mut map = DurableMap::::new(&db, \"complex_map\").unwrap();\n+ \n+ let key1 = ComplexKey { id: 1, name: \"first\".to_string() };\n+ let key2 = ComplexKey { id: 2, name: \"second\".to_string() };\n+ \n+ map.insert(key1.clone(), \"value1\".to_string()).unwrap();\n+ map.insert(key2.clone(), \"value2\".to_string()).unwrap();\n+ \n+ assert_eq!(map.get(&key1).unwrap(), Some(\"value1\".to_string()));\n+ assert_eq!(map.get(&key2).unwrap(), Some(\"value2\".to_string()));\n+ }\n+ \n+ #[test]\n+ fn test_multiple_maps_same_db() {\n+ let (_temp, db) = setup_test_db();\n+ \n+ let mut map1 = DurableMap::::new(&db, \"map1\").unwrap();\n+ let mut map2 = DurableMap::::new(&db, \"map2\").unwrap();\n+ \n+ // Insert different data\n+ map1.insert(\"shared_key\".to_string(), 100).unwrap();\n+ map2.insert(\"shared_key\".to_string(), 200).unwrap();\n+ \n+ // Verify isolation\n+ assert_eq!(map1.get(&\"shared_key\".to_string()).unwrap(), Some(100));\n+ assert_eq!(map2.get(&\"shared_key\".to_string()).unwrap(), Some(200));\n+ }\n+ \n+ #[test]\n+ fn test_streaming_iterators() {\n+ let (_temp, db) = setup_test_db();\n+ let mut map = DurableMap::::new(&db, \"stream_map\").unwrap();\n+ \n+ // Insert test data\n+ let data = vec![\n+ (\"alice\".to_string(), 100),\n+ (\"bob\".to_string(), 200),\n+ (\"charlie\".to_string(), 300),\n+ ];\n+ \n+ for (k, v) in &data {\n+ map.insert(k.clone(), *v).unwrap();\n+ }\n+ \n+ // Test streaming iteration\n+ let mut collected = Vec::new();\n+ for item in map.iter() {\n+ let (k, v) = item.unwrap();\n+ collected.push((k, v));\n+ }\n+ collected.sort_by_key(|(k, _)| k.clone());\n+ assert_eq!(collected, data);\n+ \n+ // Test keys iterator\n+ let mut keys = Vec::new();\n+ for key in map.keys() {\n+ keys.push(key.unwrap());\n+ }\n+ keys.sort();\n+ assert_eq!(keys, vec![\"alice\", \"bob\", \"charlie\"]);\n+ \n+ // Test values iterator\n+ let mut values = Vec::new();\n+ for value in map.values() {\n+ values.push(value.unwrap());\n+ }\n+ values.sort();\n+ assert_eq!(values, vec![100, 200, 300]);\n+ \n+ // Test that iterators properly handle prefix boundaries\n+ let mut map2 = DurableMap::::new(&db, \"stream_map2\").unwrap();\n+ map2.insert(\"dave\".to_string(), 400).unwrap();\n+ \n+ // Each iterator should only see its own data\n+ let collected1: Vec<_> = map.iter().map(Result::unwrap).collect();\n+ let collected2: Vec<_> = map2.iter().map(Result::unwrap).collect();\n+ \n+ assert_eq!(collected1.len(), 3);\n+ assert_eq!(collected2.len(), 1);\n+ assert_eq!(collected2[0], (\"dave\".to_string(), 400));\n+ }\n+ \n+ #[test]\n+ fn test_metadata_length_tracking() {\n+ let (_temp, db) = setup_test_db();\n+ let mut map = DurableMap::::new(&db, \"length_map\").unwrap();\n+ \n+ // Empty map\n+ assert_eq!(map.len().unwrap(), 0);\n+ assert!(map.is_empty().unwrap());\n+ \n+ // Insert operations should update length\n+ map.insert(\"key1\".to_string(), \"value1\".to_string()).unwrap();\n+ assert_eq!(map.len().unwrap(), 1);\n+ \n+ map.insert(\"key2\".to_string(), \"value2\".to_string()).unwrap();\n+ assert_eq!(map.len().unwrap(), 2);\n+ \n+ // Updating existing key should not change length\n+ map.insert(\"key1\".to_string(), \"new_value1\".to_string()).unwrap();\n+ assert_eq!(map.len().unwrap(), 2);\n+ \n+ // Remove operations should update length\n+ map.remove(&\"key1\".to_string()).unwrap();\n+ assert_eq!(map.len().unwrap(), 1);\n+ \n+ // Removing non-existent key should not change length\n+ map.remove(&\"non_existent\".to_string()).unwrap();\n+ assert_eq!(map.len().unwrap(), 1);\n+ \n+ // Extend should update length correctly\n+ let data = vec![\n+ (\"key3\".to_string(), \"value3\".to_string()),\n+ (\"key4\".to_string(), \"value4\".to_string()),\n+ (\"key5\".to_string(), \"value5\".to_string()),\n+ ];\n+ map.extend(data).unwrap();\n+ assert_eq!(map.len().unwrap(), 4); // key2 + 3 new keys\n+ \n+ // Extend with existing keys should only count new ones\n+ let mixed_data = vec![\n+ (\"key2\".to_string(), \"updated_value2\".to_string()), // existing\n+ (\"key6\".to_string(), \"value6\".to_string()), // new\n+ ];\n+ map.extend(mixed_data).unwrap();\n+ assert_eq!(map.len().unwrap(), 5); // only key6 was new\n+ \n+ // Clear should reset length to 0\n+ map.clear().unwrap();\n+ assert_eq!(map.len().unwrap(), 0);\n+ assert!(map.is_empty().unwrap());\n+ }\n+ \n+ #[test]\n+ fn test_put_method() {\n+ let (_temp, db) = setup_test_db();\n+ let mut map = DurableMap::::new(&db, \"put_map\").unwrap();\n+ \n+ // Put new entries\n+ map.put(\"a\".to_string(), 1).unwrap();\n+ map.put(\"b\".to_string(), 2).unwrap();\n+ map.put(\"c\".to_string(), 3).unwrap();\n+ \n+ // Verify entries exist and length is correct\n+ assert_eq!(map.get(&\"a\".to_string()).unwrap(), Some(1));\n+ assert_eq!(map.get(&\"b\".to_string()).unwrap(), Some(2));\n+ assert_eq!(map.get(&\"c\".to_string()).unwrap(), Some(3));\n+ assert_eq!(map.len().unwrap(), 3);\n+ \n+ // Update existing entry with put\n+ map.put(\"b\".to_string(), 20).unwrap();\n+ assert_eq!(map.get(&\"b\".to_string()).unwrap(), Some(20));\n+ assert_eq!(map.len().unwrap(), 3); // Length should not change\n+ \n+ // Compare put vs insert performance characteristics\n+ // put() doesn't return old value but is more efficient\n+ map.put(\"d\".to_string(), 4).unwrap();\n+ assert_eq!(map.len().unwrap(), 4);\n+ \n+ // insert() returns old value\n+ let old = map.insert(\"d\".to_string(), 40).unwrap();\n+ assert_eq!(old, Some(4));\n+ assert_eq!(map.len().unwrap(), 4);\n+ }\n+}\n+\n+#[cfg(all(test, not(miri)))]\n+mod proptests {\n+ use super::*;\n+ use proptest::prelude::*;\n+ use tempfile::TempDir;\n+ use std::collections::HashMap;\n+ \n+ fn setup_test_db() -> (TempDir, Db) {\n+ let temp_dir = TempDir::new().unwrap();\n+ let db = Db::open(temp_dir.path()).unwrap();\n+ (temp_dir, db)\n+ }\n+ \n+ proptest! {\n+ #[test]\n+ fn prop_insert_get_consistency(data: HashMap) {\n+ let (_temp, db) = setup_test_db();\n+ let mut map = DurableMap::::new(&db, \"prop_map\").unwrap();\n+ \n+ // Insert all pairs\n+ for (k, v) in &data {\n+ map.insert(k.clone(), *v).unwrap();\n+ }\n+ \n+ // Verify all can be retrieved\n+ for (k, v) in &data {\n+ prop_assert_eq!(map.get(k).unwrap(), Some(*v));\n+ }\n+ \n+ // Verify length\n+ prop_assert_eq!(map.len().unwrap(), data.len());\n+ }\n+ \n+ #[test]\n+ fn prop_remove_consistency(data: HashMap) {\n+ let (_temp, db) = setup_test_db();\n+ let mut map = DurableMap::::new(&db, \"remove_map\").unwrap();\n+ \n+ // Insert all\n+ map.extend(data.clone()).unwrap();\n+ \n+ // Remove all and verify\n+ for (k, v) in data {\n+ prop_assert_eq!(map.remove(&k).unwrap(), Some(v));\n+ prop_assert_eq!(map.remove(&k).unwrap(), None);\n+ prop_assert!(!map.contains_key(&k).unwrap());\n+ }\n+ \n+ prop_assert!(map.is_empty().unwrap());\n+ }\n+ \n+ #[test]\n+ fn prop_clear_makes_empty(data: HashMap) {\n+ let (_temp, db) = setup_test_db();\n+ let mut map = DurableMap::::new(&db, \"clear_map\").unwrap();\n+ \n+ map.extend(data).unwrap();\n+ map.clear().unwrap();\n+ \n+ prop_assert_eq!(map.len().unwrap(), 0);\n+ prop_assert!(map.is_empty().unwrap());\n+ prop_assert_eq!(map.to_vec().unwrap(), vec![]);\n+ }\n+ }\n+ \n+ #[test]\n+ fn test_nested_collections() {\n+ use crate::DurableVec;\n+ \n+ let (_temp, db) = setup_test_db();\n+ \n+ // Create a map where values are DurableVec\n+ let users_posts: DurableMap> = DurableMap::new_nested(&db, \"user_posts\");\n+ \n+ // Test creating nested collections through the entry API\n+ let mut alice_posts = users_posts.entry(\"alice\".to_string()).unwrap().or_default().unwrap();\n+ alice_posts.push(101).unwrap();\n+ alice_posts.push(102).unwrap();\n+ alice_posts.push(103).unwrap();\n+ \n+ // Test accessing the same collection again\n+ let alice_posts_again = users_posts.entry(\"alice\".to_string()).unwrap().or_default().unwrap();\n+ assert_eq!(alice_posts_again.len().unwrap(), 3);\n+ assert_eq!(alice_posts_again.get(0).unwrap(), Some(101));\n+ assert_eq!(alice_posts_again.get(1).unwrap(), Some(102));\n+ assert_eq!(alice_posts_again.get(2).unwrap(), Some(103));\n+ \n+ // Test creating a different nested collection\n+ let mut bob_posts = users_posts.entry(\"bob\".to_string()).unwrap().or_default().unwrap();\n+ bob_posts.push(201).unwrap();\n+ bob_posts.push(202).unwrap();\n+ \n+ // Verify isolation between nested collections\n+ assert_eq!(alice_posts_again.len().unwrap(), 3);\n+ assert_eq!(bob_posts.len().unwrap(), 2);\n+ \n+ // Test chained calls\n+ users_posts.entry(\"charlie\".to_string()).unwrap().or_default().unwrap().push(301).unwrap();\n+ let charlie_posts = users_posts.entry(\"charlie\".to_string()).unwrap().or_default().unwrap();\n+ assert_eq!(charlie_posts.len().unwrap(), 1);\n+ assert_eq!(charlie_posts.get(0).unwrap(), Some(301));\n+ }\n+ \n+ // Note: Nested DurableMap-in-DurableMap requires implementing Serialize/Deserialize \n+ // for DurableMap, which is not straightforward since it contains database handles.\n+ // For now, let's focus on the more common case of Map-to-Vec nesting.\n+ \n+ // Deep nesting with Map -> Map -> Vec also requires DurableMap serialization\n+ // Let's skip this for now and focus on the fundamental Map -> Vec case\n+ \n+ #[test]\n+ fn test_nested_collection_persistence() {\n+ use crate::DurableVec;\n+ \n+ let (temp_dir, db) = setup_test_db();\n+ \n+ // Create nested structure and populate it\n+ {\n+ let users_data: DurableMap> = DurableMap::new_nested(&db, \"users\");\n+ let mut user1_data = users_data.entry(\"user1\".to_string()).unwrap().or_default().unwrap();\n+ user1_data.push(\"data1\".to_string()).unwrap();\n+ user1_data.push(\"data2\".to_string()).unwrap();\n+ \n+ let mut user2_data = users_data.entry(\"user2\".to_string()).unwrap().or_default().unwrap();\n+ user2_data.push(\"other_data\".to_string()).unwrap();\n+ }\n+ \n+ // Drop the database\n+ drop(db);\n+ \n+ // Reopen and verify persistence\n+ {\n+ let db = Db::open(temp_dir.path()).unwrap();\n+ let users_data: DurableMap> = DurableMap::new_nested(&db, \"users\");\n+ \n+ let user1_data = users_data.entry(\"user1\".to_string()).unwrap().or_default().unwrap();\n+ assert_eq!(user1_data.len().unwrap(), 2);\n+ assert_eq!(user1_data.get(0).unwrap(), Some(\"data1\".to_string()));\n+ assert_eq!(user1_data.get(1).unwrap(), Some(\"data2\".to_string()));\n+ \n+ let user2_data = users_data.entry(\"user2\".to_string()).unwrap().or_default().unwrap();\n+ assert_eq!(user2_data.len().unwrap(), 1);\n+ assert_eq!(user2_data.get(0).unwrap(), Some(\"other_data\".to_string()));\n+ }\n+ }\n+} \n\\ No newline at end of file\ndiff --git a/durable/src/vec.rs b/durable/src/vec.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..28d08fd0786df241aaf9c01b279708e547e4c034\n--- /dev/null\n+++ b/durable/src/vec.rs\n@@ -0,0 +1,658 @@\n+use crate::{Db, Result, DurableError, DurableCollection};\n+use rocksdb::WriteBatch;\n+use serde::{Serialize, Deserialize};\n+use std::marker::PhantomData;\n+\n+/// A persistent vector backed by RocksDB\n+pub struct DurableVec {\n+ db: Db,\n+ prefix: Vec,\n+ _phantom: PhantomData,\n+}\n+\n+impl DurableVec \n+where \n+ T: Serialize + for<'de> Deserialize<'de>\n+{\n+ /// Create a new DurableVec with the given name\n+ pub fn new(db: &Db, name: &str) -> Result {\n+ let prefix = format!(\"vec:{}\", name).into_bytes();\n+ \n+ Ok(DurableVec {\n+ db: db.clone(),\n+ prefix,\n+ _phantom: PhantomData,\n+ })\n+ }\n+ \n+ /// Create a new DurableVec from a prefix (used for nested collections)\n+ pub fn from_prefix(db: Db, prefix: Vec) -> Self {\n+ Self {\n+ db,\n+ prefix,\n+ _phantom: PhantomData,\n+ }\n+ }\n+ \n+ /// Get the length of the vector\n+ pub fn len(&self) -> Result {\n+ let key = self.meta_key(\"len\");\n+ match self.db.rocks().get(&key)? {\n+ Some(bytes) => {\n+ if bytes.len() != 8 {\n+ return Err(DurableError::Corruption(\"Invalid length bytes size\".into()));\n+ }\n+ let len_bytes: [u8; 8] = bytes[..8].try_into()\n+ .map_err(|_| DurableError::Corruption(\"Invalid length bytes\".into()))?;\n+ Ok(u64::from_le_bytes(len_bytes) as usize)\n+ }\n+ None => Ok(0),\n+ }\n+ }\n+ \n+ /// Check if the vector is empty\n+ pub fn is_empty(&self) -> Result {\n+ Ok(self.len()? == 0)\n+ }\n+ \n+ /// Push an element to the end of the vector\n+ pub fn push(&mut self, value: T) -> Result<()> {\n+ let len = self.len()?;\n+ let mut batch = WriteBatch::default();\n+ \n+ // Serialize the value\n+ let value_bytes = bincode::serialize(&value)?;\n+ \n+ // Write the element\n+ let elem_key = self.element_key(len);\n+ batch.put(&elem_key, &value_bytes);\n+ \n+ // Update the length\n+ let new_len = (len + 1) as u64;\n+ let len_key = self.meta_key(\"len\");\n+ batch.put(&len_key, &new_len.to_le_bytes());\n+ \n+ // Commit atomically\n+ self.db.rocks().write(batch)?;\n+ self.db.rocks().flush_wal(true)?;\n+ \n+ Ok(())\n+ }\n+ \n+ /// Get an element at the given index\n+ pub fn get(&self, index: usize) -> Result> {\n+ let len = self.len()?;\n+ if index >= len {\n+ return Ok(None);\n+ }\n+ \n+ let key = self.element_key(index);\n+ match self.db.rocks().get(&key)? {\n+ Some(bytes) => {\n+ let value = bincode::deserialize(&bytes)?;\n+ Ok(Some(value))\n+ }\n+ None => Err(DurableError::Corruption(\n+ format!(\"Element at index {} not found but index < len\", index)\n+ )),\n+ }\n+ }\n+ \n+ /// Clear all elements from the vector\n+ pub fn clear(&mut self) -> Result<()> {\n+ let len = self.len()?;\n+ let mut batch = WriteBatch::default();\n+ \n+ // Delete all elements\n+ for i in 0..len {\n+ let key = self.element_key(i);\n+ batch.delete(&key);\n+ }\n+ \n+ // Delete the length meta key\n+ let len_key = self.meta_key(\"len\");\n+ batch.delete(&len_key);\n+ \n+ // Commit atomically\n+ self.db.rocks().write(batch)?;\n+ self.db.rocks().flush_wal(true)?;\n+ \n+ Ok(())\n+ }\n+ \n+ /// Create a streaming iterator over the vector\n+ pub fn iter(&self) -> Result> + '_> {\n+ let prefix = self.element_prefix();\n+ let iter = self.db.rocks().iterator(rocksdb::IteratorMode::From(&prefix, rocksdb::Direction::Forward));\n+ \n+ Ok(VecIterator {\n+ inner: iter,\n+ prefix,\n+ _phantom: PhantomData,\n+ })\n+ }\n+ \n+ /// Convert the entire vector to a Vec in memory\n+ /// \n+ /// Note: This loads the entire collection into memory. For large collections,\n+ /// prefer using `iter()` which streams elements.\n+ pub fn to_vec(&self) -> Result> {\n+ let len = self.len()?;\n+ let mut result = Vec::with_capacity(len);\n+ \n+ for item in self.iter()? {\n+ result.push(item?);\n+ }\n+ \n+ Ok(result)\n+ }\n+ \n+ /// Push multiple elements in a single batch\n+ pub fn extend(&mut self, iter: I) -> Result<()>\n+ where\n+ I: IntoIterator\n+ {\n+ let mut batch = WriteBatch::default();\n+ let mut len = self.len()?;\n+ \n+ for value in iter {\n+ let value_bytes = bincode::serialize(&value)?;\n+ let elem_key = self.element_key(len);\n+ batch.put(&elem_key, &value_bytes);\n+ len += 1;\n+ }\n+ \n+ // Update length\n+ let len_key = self.meta_key(\"len\");\n+ batch.put(&len_key, &(len as u64).to_le_bytes());\n+ \n+ // Commit atomically\n+ self.db.rocks().write(batch)?;\n+ self.db.rocks().flush_wal(true)?;\n+ \n+ Ok(())\n+ }\n+ \n+ /// Remove and return the last element\n+ pub fn pop(&mut self) -> Result> {\n+ let len = self.len()?;\n+ if len == 0 {\n+ return Ok(None);\n+ }\n+ \n+ let last_idx = len - 1;\n+ let value = self.get(last_idx)?;\n+ \n+ let mut batch = WriteBatch::default();\n+ \n+ // Delete the last element\n+ let elem_key = self.element_key(last_idx);\n+ batch.delete(&elem_key);\n+ \n+ // Update length\n+ let len_key = self.meta_key(\"len\");\n+ batch.put(&len_key, &(last_idx as u64).to_le_bytes());\n+ \n+ // Commit atomically\n+ self.db.rocks().write(batch)?;\n+ self.db.rocks().flush_wal(true)?;\n+ \n+ Ok(value)\n+ }\n+ \n+ // Helper methods\n+ \n+ fn element_key(&self, index: usize) -> Vec {\n+ let mut key = self.prefix.clone();\n+ key.push(b':');\n+ key.extend_from_slice(&(index as u64).to_be_bytes());\n+ key\n+ }\n+ \n+ fn meta_key(&self, meta_type: &str) -> Vec {\n+ let mut key = self.prefix.clone();\n+ key.extend_from_slice(b\":__meta:\");\n+ key.extend_from_slice(meta_type.as_bytes());\n+ key\n+ }\n+ \n+ fn element_prefix(&self) -> Vec {\n+ let mut prefix = self.prefix.clone();\n+ prefix.push(b':');\n+ prefix\n+ }\n+}\n+\n+// Implement the DurableCollection trait for DurableVec\n+impl DurableCollection for DurableVec \n+where\n+ T: Serialize + for<'de> Deserialize<'de>\n+{\n+ fn from_prefix(db: Db, prefix: Vec) -> Self {\n+ DurableVec::from_prefix(db, prefix)\n+ }\n+}\n+\n+/// Iterator over a DurableVec\n+pub struct VecIterator<'a, T> {\n+ inner: rocksdb::DBIterator<'a>,\n+ prefix: Vec,\n+ _phantom: PhantomData,\n+}\n+\n+impl<'a, T> Iterator for VecIterator<'a, T>\n+where\n+ T: for<'de> Deserialize<'de>\n+{\n+ type Item = Result;\n+ \n+ fn next(&mut self) -> Option {\n+ loop {\n+ match self.inner.next() {\n+ Some(Ok((key, value))) => {\n+ // Check if we're still within our prefix\n+ if !key.starts_with(&self.prefix) {\n+ return None;\n+ }\n+ \n+ // Check if this is a meta key (skip it)\n+ // The key pattern is: prefix:element_index or prefix:__meta:type\n+ // We want to skip any key that contains \"__meta:\"\n+ if key.windows(7).any(|w| w == b\"__meta:\") {\n+ continue; // Skip this key and try the next one\n+ }\n+ \n+ // Deserialize the value\n+ match bincode::deserialize(&value) {\n+ Ok(item) => return Some(Ok(item)),\n+ Err(e) => return Some(Err(e.into())),\n+ }\n+ }\n+ Some(Err(e)) => return Some(Err(e.into())),\n+ None => return None,\n+ }\n+ }\n+ }\n+}\n+\n+#[cfg(test)]\n+mod tests {\n+ use super::*;\n+ use tempfile::TempDir;\n+ \n+ fn setup_test_db() -> (TempDir, Db) {\n+ let temp_dir = TempDir::new().unwrap();\n+ let db = Db::open(temp_dir.path()).unwrap();\n+ (temp_dir, db)\n+ }\n+ \n+ #[test]\n+ fn test_push_and_get() {\n+ let (_temp, db) = setup_test_db();\n+ let mut vec = DurableVec::::new(&db, \"test_vec\").unwrap();\n+ \n+ // Push some values\n+ vec.push(\"first\".to_string()).unwrap();\n+ vec.push(\"second\".to_string()).unwrap();\n+ vec.push(\"third\".to_string()).unwrap();\n+ \n+ // Check length\n+ assert_eq!(vec.len().unwrap(), 3);\n+ \n+ // Get values\n+ assert_eq!(vec.get(0).unwrap(), Some(\"first\".to_string()));\n+ assert_eq!(vec.get(1).unwrap(), Some(\"second\".to_string()));\n+ assert_eq!(vec.get(2).unwrap(), Some(\"third\".to_string()));\n+ assert_eq!(vec.get(3).unwrap(), None);\n+ }\n+ \n+ #[test]\n+ fn test_persistence() {\n+ let (temp_dir, db) = setup_test_db();\n+ \n+ // Create and populate vector\n+ {\n+ let mut vec = DurableVec::::new(&db, \"persist_vec\").unwrap();\n+ vec.push(42).unwrap();\n+ vec.push(100).unwrap();\n+ vec.push(-7).unwrap();\n+ }\n+ \n+ // Drop the database\n+ drop(db);\n+ \n+ // Reopen and verify data persists\n+ {\n+ let db = Db::open(temp_dir.path()).unwrap();\n+ let vec = DurableVec::::new(&db, \"persist_vec\").unwrap();\n+ \n+ assert_eq!(vec.len().unwrap(), 3);\n+ assert_eq!(vec.get(0).unwrap(), Some(42));\n+ assert_eq!(vec.get(1).unwrap(), Some(100));\n+ assert_eq!(vec.get(2).unwrap(), Some(-7));\n+ }\n+ }\n+ \n+ #[test]\n+ fn test_clear() {\n+ let (_temp, db) = setup_test_db();\n+ let mut vec = DurableVec::::new(&db, \"clear_vec\").unwrap();\n+ \n+ // Add some elements\n+ vec.extend(vec![1, 2, 3, 4, 5]).unwrap();\n+ assert_eq!(vec.len().unwrap(), 5);\n+ \n+ // Clear\n+ vec.clear().unwrap();\n+ assert_eq!(vec.len().unwrap(), 0);\n+ assert!(vec.is_empty().unwrap());\n+ \n+ // Should be able to push again\n+ vec.push(42).unwrap();\n+ assert_eq!(vec.len().unwrap(), 1);\n+ assert_eq!(vec.get(0).unwrap(), Some(42));\n+ }\n+ \n+ #[test]\n+ fn test_pop() {\n+ let (_temp, db) = setup_test_db();\n+ let mut vec = DurableVec::::new(&db, \"pop_vec\").unwrap();\n+ \n+ // Empty pop\n+ assert_eq!(vec.pop().unwrap(), None);\n+ \n+ // Push and pop\n+ vec.push(\"a\".to_string()).unwrap();\n+ vec.push(\"b\".to_string()).unwrap();\n+ vec.push(\"c\".to_string()).unwrap();\n+ \n+ assert_eq!(vec.pop().unwrap(), Some(\"c\".to_string()));\n+ assert_eq!(vec.len().unwrap(), 2);\n+ assert_eq!(vec.pop().unwrap(), Some(\"b\".to_string()));\n+ assert_eq!(vec.len().unwrap(), 1);\n+ assert_eq!(vec.pop().unwrap(), Some(\"a\".to_string()));\n+ assert_eq!(vec.len().unwrap(), 0);\n+ assert_eq!(vec.pop().unwrap(), None);\n+ }\n+ \n+ #[test]\n+ fn test_iteration() {\n+ let (_temp, db) = setup_test_db();\n+ let mut vec = DurableVec::::new(&db, \"iter_vec\").unwrap();\n+ \n+ // Add elements\n+ let values = vec![10, 20, 30, 40, 50];\n+ vec.extend(values.clone()).unwrap();\n+ \n+ // Iterate and collect\n+ let collected = vec.to_vec().unwrap();\n+ \n+ assert_eq!(collected, values);\n+ }\n+ \n+ #[test]\n+ fn test_extend() {\n+ let (_temp, db) = setup_test_db();\n+ let mut vec = DurableVec::::new(&db, \"extend_vec\").unwrap();\n+ \n+ // Extend with iterator\n+ vec.extend(vec![\"a\", \"b\", \"c\"].into_iter().map(String::from)).unwrap();\n+ assert_eq!(vec.len().unwrap(), 3);\n+ \n+ // Extend again\n+ vec.extend(vec![\"d\", \"e\"].into_iter().map(String::from)).unwrap();\n+ assert_eq!(vec.len().unwrap(), 5);\n+ \n+ // Verify all elements\n+ let all = vec.to_vec().unwrap();\n+ assert_eq!(all, vec![\"a\", \"b\", \"c\", \"d\", \"e\"]);\n+ }\n+ \n+ #[test]\n+ fn test_large_dataset() {\n+ let (_temp, db) = setup_test_db();\n+ let mut vec = DurableVec::::new(&db, \"large_vec\").unwrap();\n+ \n+ // Push many elements\n+ let count = 1000;\n+ for i in 0..count {\n+ vec.push(i).unwrap();\n+ }\n+ \n+ assert_eq!(vec.len().unwrap(), count as usize);\n+ \n+ // Verify some random accesses\n+ assert_eq!(vec.get(0).unwrap(), Some(0));\n+ assert_eq!(vec.get(500).unwrap(), Some(500));\n+ assert_eq!(vec.get(999).unwrap(), Some(999));\n+ assert_eq!(vec.get(1000).unwrap(), None);\n+ \n+ // Verify iteration count\n+ let all_values = vec.to_vec().unwrap();\n+ assert_eq!(all_values.len(), count as usize);\n+ }\n+ \n+ #[test] \n+ fn test_complex_types() {\n+ use serde::{Serialize, Deserialize};\n+ \n+ #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n+ struct User {\n+ id: u64,\n+ name: String,\n+ email: String,\n+ active: bool,\n+ }\n+ \n+ let (_temp, db) = setup_test_db();\n+ let mut vec = DurableVec::::new(&db, \"users\").unwrap();\n+ \n+ let user1 = User {\n+ id: 1,\n+ name: \"Alice\".to_string(),\n+ email: \"alice@example.com\".to_string(),\n+ active: true,\n+ };\n+ \n+ let user2 = User {\n+ id: 2,\n+ name: \"Bob\".to_string(),\n+ email: \"bob@example.com\".to_string(),\n+ active: false,\n+ };\n+ \n+ vec.push(user1.clone()).unwrap();\n+ vec.push(user2.clone()).unwrap();\n+ \n+ assert_eq!(vec.get(0).unwrap(), Some(user1));\n+ assert_eq!(vec.get(1).unwrap(), Some(user2));\n+ }\n+ \n+ #[test]\n+ fn test_empty_vec_operations() {\n+ let (_temp, db) = setup_test_db();\n+ let vec = DurableVec::::new(&db, \"empty_vec\").unwrap();\n+ \n+ // Test operations on empty vec\n+ assert_eq!(vec.len().unwrap(), 0);\n+ assert!(vec.is_empty().unwrap());\n+ assert_eq!(vec.get(0).unwrap(), None);\n+ assert_eq!(vec.get(100).unwrap(), None);\n+ assert_eq!(vec.to_vec().unwrap(), Vec::::new());\n+ }\n+ \n+ #[test]\n+ fn test_multiple_vecs_same_db() {\n+ let (_temp, db) = setup_test_db();\n+ \n+ // Create multiple vectors with different names\n+ let mut vec1 = DurableVec::::new(&db, \"vec1\").unwrap();\n+ let mut vec2 = DurableVec::::new(&db, \"vec2\").unwrap();\n+ \n+ // Push different data to each\n+ vec1.push(\"vec1_data\".to_string()).unwrap();\n+ vec2.push(\"vec2_data\".to_string()).unwrap();\n+ \n+ // Verify they don't interfere\n+ assert_eq!(vec1.get(0).unwrap(), Some(\"vec1_data\".to_string()));\n+ assert_eq!(vec2.get(0).unwrap(), Some(\"vec2_data\".to_string()));\n+ assert_eq!(vec1.len().unwrap(), 1);\n+ assert_eq!(vec2.len().unwrap(), 1);\n+ }\n+ \n+ #[test]\n+ fn test_batch_atomicity() {\n+ let (_temp, db) = setup_test_db();\n+ let mut vec = DurableVec::::new(&db, \"batch_vec\").unwrap();\n+ \n+ // Add initial data\n+ vec.push(1).unwrap();\n+ vec.push(2).unwrap();\n+ vec.push(3).unwrap();\n+ \n+ // Verify initial state\n+ assert_eq!(vec.len().unwrap(), 3);\n+ \n+ // Clear should be atomic - either all elements deleted or none\n+ vec.clear().unwrap();\n+ assert_eq!(vec.len().unwrap(), 0);\n+ \n+ // Extend should be atomic - either all elements added or none\n+ vec.extend(vec![10, 20, 30, 40, 50]).unwrap();\n+ assert_eq!(vec.len().unwrap(), 5);\n+ let all = vec.to_vec().unwrap();\n+ assert_eq!(all, vec![10, 20, 30, 40, 50]);\n+ }\n+ \n+ #[test]\n+ fn test_unicode_strings() {\n+ let (_temp, db) = setup_test_db();\n+ let mut vec = DurableVec::::new(&db, \"unicode_vec\").unwrap();\n+ \n+ let test_strings = vec![\n+ \"Hello, 世界!\".to_string(),\n+ \"🦀 Rust 🚀\".to_string(),\n+ \"Ñoño\".to_string(),\n+ \"🏴‍☠️ Pirates\".to_string(),\n+ ];\n+ \n+ vec.extend(test_strings.clone()).unwrap();\n+ \n+ let retrieved = vec.to_vec().unwrap();\n+ assert_eq!(retrieved, test_strings);\n+ }\n+ \n+ #[test]\n+ fn test_streaming_iterator() {\n+ let (_temp, db) = setup_test_db();\n+ let mut vec = DurableVec::::new(&db, \"stream_vec\").unwrap();\n+ \n+ // Add test data\n+ let values = vec![1, 2, 3, 4, 5];\n+ vec.extend(values.clone()).unwrap();\n+ \n+ // Test streaming iteration\n+ let mut collected = Vec::new();\n+ for item in vec.iter().unwrap() {\n+ collected.push(item.unwrap());\n+ }\n+ \n+ assert_eq!(collected, values);\n+ \n+ // Test that iterator properly handles prefix boundaries\n+ let mut vec2 = DurableVec::::new(&db, \"stream_vec2\").unwrap();\n+ vec2.extend(vec![10, 20, 30]).unwrap();\n+ \n+ // Each iterator should only see its own data\n+ let collected1: Vec<_> = vec.iter().unwrap().collect::>>().unwrap();\n+ let collected2: Vec<_> = vec2.iter().unwrap().collect::>>().unwrap();\n+ \n+ assert_eq!(collected1, values);\n+ assert_eq!(collected2, vec![10, 20, 30]);\n+ }\n+}\n+\n+#[cfg(all(test, not(miri)))] // Skip proptest under miri\n+mod proptests {\n+ use super::*;\n+ use proptest::prelude::*;\n+ use tempfile::TempDir;\n+ \n+ fn setup_test_db() -> (TempDir, Db) {\n+ let temp_dir = TempDir::new().unwrap();\n+ let db = Db::open(temp_dir.path()).unwrap();\n+ (temp_dir, db)\n+ }\n+ \n+ proptest! {\n+ #[test]\n+ fn prop_push_get_consistency(values: Vec) {\n+ let (_temp, db) = setup_test_db();\n+ let mut vec = DurableVec::::new(&db, \"prop_vec\").unwrap();\n+ \n+ // Push all values\n+ for value in &values {\n+ vec.push(*value).unwrap();\n+ }\n+ \n+ // Verify length\n+ prop_assert_eq!(vec.len().unwrap(), values.len());\n+ \n+ // Verify all values can be retrieved correctly\n+ for (i, expected) in values.iter().enumerate() {\n+ prop_assert_eq!(vec.get(i).unwrap(), Some(*expected));\n+ }\n+ }\n+ \n+ #[test]\n+ fn prop_extend_iter_roundtrip(values: Vec) {\n+ let (_temp, db) = setup_test_db();\n+ let mut vec = DurableVec::::new(&db, \"extend_vec\").unwrap();\n+ \n+ // Extend with all values\n+ vec.extend(values.clone()).unwrap();\n+ \n+ // Get back via iteration\n+ let retrieved = vec.to_vec().unwrap();\n+ \n+ prop_assert_eq!(retrieved, values);\n+ }\n+ \n+ #[test]\n+ fn prop_pop_removes_last(mut values: Vec) {\n+ let (_temp, db) = setup_test_db();\n+ let mut vec = DurableVec::::new(&db, \"pop_vec\").unwrap();\n+ \n+ // Add all values\n+ vec.extend(values.clone()).unwrap();\n+ \n+ // Pop values and verify\n+ while let Some(expected) = values.pop() {\n+ let popped = vec.pop().unwrap();\n+ prop_assert_eq!(popped, Some(expected));\n+ prop_assert_eq!(vec.len().unwrap(), values.len());\n+ }\n+ \n+ // Vector should be empty\n+ prop_assert!(vec.is_empty().unwrap());\n+ prop_assert_eq!(vec.pop().unwrap(), None);\n+ }\n+ \n+ #[test]\n+ fn prop_clear_makes_empty(values: Vec) {\n+ let (_temp, db) = setup_test_db();\n+ let mut vec = DurableVec::::new(&db, \"clear_vec\").unwrap();\n+ \n+ // Add values\n+ vec.extend(values).unwrap();\n+ \n+ // Clear\n+ vec.clear().unwrap();\n+ \n+ // Should be empty\n+ prop_assert_eq!(vec.len().unwrap(), 0);\n+ prop_assert!(vec.is_empty().unwrap());\n+ prop_assert_eq!(vec.get(0).unwrap(), None);\n+ }\n+ }\n+} \n\\ No newline at end of file\ndiff --git a/durable/todo.tdsl b/durable/todo.tdsl\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..6c9df9cbb25bb72d17aac07296a0c935ccbe89e7\n--- /dev/null\n+++ b/durable/todo.tdsl\n@@ -0,0 +1,4 @@\n+Add streaming iterators to avoid loading entire collections\n+Implement collection nesting (e.g., DurableMap>)\n+Add benchmarks to measure performance\n+Implement schema versioning and migrations\ndiff --git a/server/Cargo.toml b/server/Cargo.toml\nindex 6fb7bf52fa58f46f5e8fb0f7fd395247b57506d7..34672bd7acb9d165eea290e779c548fa4d2b8e3d 100644\n--- a/server/Cargo.toml\n+++ b/server/Cargo.toml\n@@ -22,6 +22,7 @@ async-stream = \"0.3\"\n futures-util = { version = \"0.3\", default-features = false, features = [\"std\"] }\n rand = \"0.8\"\n urlencoding = \"2\"\n+durable = { path = \"../durable\" }\n \n [dev-dependencies]\n reqwest = { version = \"0.12\", features = [\"json\"] }\ndiff --git a/server/src/entity_store.rs b/server/src/entity_store.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..be3300529e97801a4d42f06f3f669b78ddd6856d\n--- /dev/null\n+++ b/server/src/entity_store.rs\n@@ -0,0 +1,90 @@\n+//! Off-heap storage for full entity payloads (Reddit API JSON).\n+//!\n+//! Derived [`crate::reducer::EntityData`] stays in the in-memory tree; raw JSON\n+//! lives in RocksDB via the workspace `durable` crate.\n+\n+use std::path::Path;\n+use std::sync::{Arc, Mutex};\n+\n+use durable::{Db, DurableMap};\n+use serde_json::Value;\n+\n+use crate::path_types::ItemId;\n+\n+#[derive(Debug, thiserror::Error)]\n+pub enum EntityStoreError {\n+ #[error(\"durable error: {0}\")]\n+ Durable(#[from] durable::DurableError),\n+ #[error(\"json error: {0}\")]\n+ Json(#[from] serde_json::Error),\n+ #[error(\"io error: {0}\")]\n+ Io(#[from] std::io::Error),\n+ #[error(\"store lock poisoned\")]\n+ Poisoned,\n+}\n+\n+struct EntityStoreInner {\n+ _db: Db,\n+ payloads: DurableMap,\n+}\n+\n+/// Disk-backed map of entity id → raw JSON payload.\n+#[derive(Clone)]\n+pub struct EntityStore {\n+ inner: Arc>,\n+}\n+\n+impl EntityStore {\n+ /// Open (or create) the entity database under `dir`.\n+ pub fn open(dir: &Path) -> Result {\n+ std::fs::create_dir_all(dir)?;\n+ let db = Db::open(dir)?;\n+ let payloads = DurableMap::new(&db, \"entity_payloads\")?;\n+ Ok(Self {\n+ inner: Arc::new(Mutex::new(EntityStoreInner { _db: db, payloads })),\n+ })\n+ }\n+\n+ /// Persist a payload for `id` (overwrites any existing entry).\n+ pub fn put(&self, id: &ItemId, payload: &Value) -> Result<(), EntityStoreError> {\n+ let json = serde_json::to_string(payload)?;\n+ let mut inner = self\n+ .inner\n+ .lock()\n+ .map_err(|_| EntityStoreError::Poisoned)?;\n+ inner\n+ .payloads\n+ .put(id.as_str().to_string(), json)\n+ .map_err(EntityStoreError::from)\n+ }\n+\n+ /// Load a stored payload, if present.\n+ pub fn get(&self, id: &ItemId) -> Result, EntityStoreError> {\n+ let inner = self\n+ .inner\n+ .lock()\n+ .map_err(|_| EntityStoreError::Poisoned)?;\n+ match inner.payloads.get(&id.as_str().to_string())? {\n+ Some(json) => Ok(Some(serde_json::from_str(&json)?)),\n+ None => Ok(None),\n+ }\n+ }\n+}\n+\n+#[cfg(test)]\n+mod tests {\n+ use super::*;\n+ use serde_json::json;\n+\n+ #[test]\n+ fn round_trip_payload() {\n+ let tmp = tempfile::tempdir().unwrap();\n+ let store = EntityStore::open(tmp.path()).unwrap();\n+ let id = ItemId::parse(\"reddit.com/r/rust\").unwrap();\n+ let payload = json!({\"kind\": \"t5\", \"data\": {\"display_name\": \"rust\"}});\n+\n+ store.put(&id, &payload).unwrap();\n+ let loaded = store.get(&id).unwrap().unwrap();\n+ assert_eq!(loaded, payload);\n+ }\n+}\ndiff --git a/server/src/event_log.rs b/server/src/event_log.rs\nindex 03c86f7f028cdf167b4943951d724da486b1b82c..838c8830b265f28a03442681575271eef539508c 100644\n--- a/server/src/event_log.rs\n+++ b/server/src/event_log.rs\n@@ -7,6 +7,12 @@ use tokio::{\n \n use crate::events::Event;\n \n+#[derive(Debug, Default)]\n+pub struct ReplayStats {\n+ pub applied: usize,\n+ pub bad_lines: usize,\n+}\n+\n #[derive(Debug, thiserror::Error)]\n pub enum EventLogError {\n #[error(\"io error: {0}\")]\n@@ -51,30 +57,87 @@ impl EventLog {\n Ok(())\n }\n \n- pub async fn load_all(&self) -> Result<(Vec, Vec<(usize, String)>), EventLogError> {\n+ /// Stream the log one line at a time — parse each [`Event`], apply, drop before the next line.\n+ pub async fn replay(&self, mut apply: F) -> Result\n+ where\n+ F: FnMut(Event) -> Result<(), EventLogError>,\n+ {\n+ let mut stats = ReplayStats::default();\n if !fs::try_exists(&self.path).await? {\n- return Ok((vec![], vec![]));\n+ return Ok(stats);\n }\n \n let f = fs::File::open(&self.path).await?;\n let mut reader = BufReader::new(f).lines();\n \n- let mut events = Vec::new();\n- let mut bad_lines = Vec::new();\n-\n- let mut line_no: usize = 0;\n while let Some(line) = reader.next_line().await? {\n- line_no += 1;\n let trimmed = line.trim();\n if trimmed.is_empty() {\n continue;\n }\n match serde_json::from_str::(trimmed) {\n- Ok(ev) => events.push(ev),\n- Err(_) => bad_lines.push((line_no, line)),\n+ Ok(ev) => match apply(ev) {\n+ Ok(()) => stats.applied += 1,\n+ Err(e) => return Err(e),\n+ },\n+ Err(_) => stats.bad_lines += 1,\n }\n }\n \n- Ok((events, bad_lines))\n+ Ok(stats)\n+ }\n+\n+ /// Load every event into memory. Prefer [`Self::replay`] for startup.\n+ pub async fn load_all(&self) -> Result<(Vec, Vec<(usize, String)>), EventLogError> {\n+ let mut events = Vec::new();\n+ let stats = self\n+ .replay(|ev| {\n+ events.push(ev);\n+ Ok(())\n+ })\n+ .await?;\n+ let _ = stats;\n+ Ok((events, vec![]))\n+ }\n+}\n+\n+#[cfg(test)]\n+mod tests {\n+ use super::*;\n+ use crate::events::Event;\n+\n+ #[tokio::test]\n+ async fn replay_applies_one_line_at_a_time() {\n+ let tmp = tempfile::tempdir().unwrap();\n+ let path = tmp.path().join(\"events.jsonl\");\n+ let log = EventLog::new(&path);\n+ log.append(&Event::NodeEnsured {\n+ id: \"reddit.com/r/rust\".into(),\n+ })\n+ .await\n+ .unwrap();\n+ log.append(&Event::VoteRecorded {\n+ ts: 1,\n+ a: \"a\".into(),\n+ b: \"b\".into(),\n+ ratio_left: 2,\n+ ratio_right: 1,\n+ scope: String::new(),\n+ })\n+ .await\n+ .unwrap();\n+\n+ let mut seen = Vec::new();\n+ let stats = log\n+ .replay(|ev| {\n+ seen.push(ev);\n+ Ok(())\n+ })\n+ .await\n+ .unwrap();\n+\n+ assert_eq!(stats.applied, 2);\n+ assert_eq!(stats.bad_lines, 0);\n+ assert_eq!(seen.len(), 2);\n }\n }\ndiff --git a/server/src/lib.rs b/server/src/lib.rs\nindex 7f7e28c8ac3758de87f1f8e073b24be4132d38da..c9440a1019cb05c410eeb07b0b3ba09e4c6a6c6a 100644\n--- a/server/src/lib.rs\n+++ b/server/src/lib.rs\n@@ -1,4 +1,5 @@\n pub mod api;\n+pub mod entity_store;\n pub mod event_log;\n pub mod events;\n pub mod fetch;\ndiff --git a/server/src/reddit.rs b/server/src/reddit.rs\nindex 626454e5a2f638734193b5190a2beea286af85b6..28fcb83a2dfef01c76b802cec850624197b4b686 100644\n--- a/server/src/reddit.rs\n+++ b/server/src/reddit.rs\n@@ -10,6 +10,7 @@ use serde_json::Value;\n use tokio::sync::{mpsc, oneshot, RwLock};\n \n use crate::{\n+ entity_store::EntityStore,\n event_log::EventLog,\n events::Event,\n fetch::now_ms,\n@@ -80,6 +81,7 @@ impl RedditBroker {\n pub fn spawn(\n tree: Arc>,\n event_log: Arc,\n+ entity_store: EntityStore,\n config: RedditApiConfig,\n ) -> Self {\n let (tx, rx) = mpsc::channel(100);\n@@ -104,7 +106,7 @@ impl RedditBroker {\n \"reddit worker started\"\n );\n \n- tokio::spawn(reddit_worker(rx, tree, event_log, client, config));\n+ tokio::spawn(reddit_worker(rx, tree, event_log, entity_store, client, config));\n \n Self { tx }\n }\n@@ -193,9 +195,16 @@ pub fn entity_view_from_payload(id: &ItemId, payload: &Value) -> Option Result<(), String> {\n let view = entity_view_from_payload(id, &payload);\n- tree.apply_entity_raw(id, payload, view);\n+ store.put(id, &payload).map_err(|e| e.to_string())?;\n+ tree.apply_entity(id, view);\n+ Ok(())\n }\n \n fn notify(done: Option>, result: FetchJobResult) {\n@@ -208,6 +217,7 @@ async fn reddit_worker(\n mut rx: mpsc::Receiver,\n tree: Arc>,\n event_log: Arc,\n+ entity_store: EntityStore,\n client: Client,\n config: RedditApiConfig,\n ) {\n@@ -302,14 +312,16 @@ async fn reddit_worker(\n let mut tree = tree.write().await;\n if kind == FetchKind::Children {\n let view = entity_view_from_payload(&child_id, &child_payload);\n- tree.apply_entity_under_parent(\n- &fetch_id,\n- &child_id,\n- child_payload,\n- view,\n- );\n- } else {\n- apply_entity_import(&mut tree, &child_id, child_payload);\n+ if let Err(e) = entity_store.put(&child_id, &child_payload) {\n+ write_err = Some(e.to_string());\n+ break;\n+ }\n+ tree.apply_entity_under_parent(&fetch_id, &child_id, view);\n+ } else if let Err(e) =\n+ apply_entity_import(&mut tree, &entity_store, &child_id, child_payload)\n+ {\n+ write_err = Some(e);\n+ break;\n }\n }\n written += 1;\ndiff --git a/server/src/reducer.rs b/server/src/reducer.rs\nindex fc23f41137d7df33997afe19c533505c250cc305..cf00f235d02e487439f8767d532929fe329a0084 100644\n--- a/server/src/reducer.rs\n+++ b/server/src/reducer.rs\n@@ -1,7 +1,6 @@\n use std::collections::{HashMap, HashSet, VecDeque};\n \n use serde::{Deserialize, Serialize};\n-use serde_json::Value;\n \n use crate::path_types::ItemId;\n \n@@ -134,9 +133,8 @@ pub struct EntityData {\n #[derive(Debug, Clone, Default)]\n pub struct NodeState {\n pub id: ItemId,\n- /// Full imported API JSON (persisted in the event log).\n- pub entity_raw: Option,\n- /// Domain-specific view derived from `entity_raw` (e.g. Reddit title/author).\n+ /// Domain-specific view derived from imported payload (e.g. Reddit title/author).\n+ /// Raw JSON lives in [`crate::entity_store::EntityStore`].\n pub data: Option,\n pub children: HashSet,\n pub local_ranking: GroupState,\n@@ -206,28 +204,25 @@ impl GlobalTree {\n }\n }\n \n- pub fn apply_entity_raw(&mut self, id: &ItemId, payload: Value, view: Option) {\n+ pub fn apply_entity(&mut self, id: &ItemId, view: Option) {\n self.ensure_path(id);\n if let Some(node) = self.nodes.get_mut(id) {\n- node.entity_raw = Some(payload);\n node.data = view;\n }\n }\n \n- /// Import entity data for `id` and attach it as a direct child of `parent`\n+ /// Import entity view for `id` and attach it as a direct child of `parent`\n /// without running [`Self::ensure_path`] on `id` (avoids Reddit `/comments/`\n /// parent rules pulling intermediate path segments into the subreddit).\n pub fn apply_entity_under_parent(\n &mut self,\n parent: &ItemId,\n id: &ItemId,\n- payload: Value,\n view: Option,\n ) {\n self.ensure_path(parent);\n self.ensure_node(id);\n if let Some(node) = self.nodes.get_mut(id) {\n- node.entity_raw = Some(payload);\n node.data = view;\n }\n if let Some(p) = self.nodes.get_mut(parent) {\ndiff --git a/server/src/state.rs b/server/src/state.rs\nindex 4c3008e73cc74d8483a2064a0a280e73d82a01ec..dce04022de7ad9b0ffe0bac05ea79182b616945a 100644\n--- a/server/src/state.rs\n+++ b/server/src/state.rs\n@@ -3,6 +3,7 @@ use std::sync::Arc;\n use tokio::sync::RwLock;\n \n use crate::{\n+ entity_store::EntityStore,\n event_log::EventLog,\n events::Event,\n journal::JournalClient,\n@@ -43,6 +44,42 @@ fn parent_from_event_scope(scope: &str) -> ItemId {\n }\n }\n \n+fn apply_event(\n+ ev: Event,\n+ tree: &mut GlobalTree,\n+ entity_store: &EntityStore,\n+) -> Result<(), crate::event_log::EventLogError> {\n+ match ev {\n+ Event::VoteRecorded {\n+ ts,\n+ a,\n+ b,\n+ ratio_left,\n+ ratio_right,\n+ scope,\n+ } => {\n+ if let Some(vote) = VoteData::from_recorded(ts, &a, &b, ratio_left, ratio_right) {\n+ let parent = parent_from_event_scope(&scope);\n+ tree.apply_vote(&parent, vote);\n+ }\n+ }\n+ Event::ViewRecorded { .. } => {}\n+ Event::NodeEnsured { id } => {\n+ if let Some(parsed) = ItemId::parse(&id).or_else(|| ItemId::from_url(&id)) {\n+ tree.ensure_path(&parsed);\n+ }\n+ }\n+ Event::EntityImported { id, payload, .. } => {\n+ if let Some(parsed) = ItemId::parse(&id).or_else(|| ItemId::from_url(&id)) {\n+ if let Err(e) = apply_entity_import(tree, entity_store, &parsed, payload) {\n+ tracing::warn!(item = %id, err = %e, \"entity replay failed\");\n+ }\n+ }\n+ }\n+ }\n+ Ok(())\n+}\n+\n #[derive(Clone)]\n pub struct AppConfig {\n pub data_dir: String,\n@@ -71,6 +108,7 @@ impl AppConfig {\n pub struct AppState {\n pub cfg: Arc,\n pub event_log: Arc,\n+ pub entity_store: EntityStore,\n pub views: ViewStore,\n pub tree: Arc>,\n journal: JournalClient,\n@@ -82,48 +120,31 @@ impl AppState {\n let event_log = Arc::new(EventLog::new(cfg.event_log_path.clone()));\n let views_path = format!(\"{}/views.json\", cfg.data_dir);\n let views = ViewStore::new(&views_path);\n+ let entity_db_path = format!(\"{}/entity_db\", cfg.data_dir);\n+ let entity_store =\n+ EntityStore::open(std::path::Path::new(&entity_db_path)).expect(\"entity store\");\n \n let mut tree = GlobalTree::new();\n- if let Ok((events, _)) = event_log.load_all().await {\n- for ev in events {\n- match ev {\n- Event::VoteRecorded {\n- ts,\n- a,\n- b,\n- ratio_left,\n- ratio_right,\n- scope,\n- } => {\n- if let Some(vote) =\n- VoteData::from_recorded(ts, &a, &b, ratio_left, ratio_right)\n- {\n- let parent = parent_from_event_scope(&scope);\n- tree.apply_vote(&parent, vote);\n- }\n- }\n- Event::ViewRecorded { .. } => {}\n- Event::NodeEnsured { id } => {\n- if let Some(parsed) = ItemId::parse(&id).or_else(|| ItemId::from_url(&id)) {\n- tree.ensure_path(&parsed);\n- }\n- }\n- Event::EntityImported { id, payload, .. } => {\n- if let Some(parsed) = ItemId::parse(&id).or_else(|| ItemId::from_url(&id)) {\n- apply_entity_import(&mut tree, &parsed, payload);\n- }\n- }\n- }\n- }\n+ if let Err(e) = event_log\n+ .replay(|ev| apply_event(ev, &mut tree, &entity_store))\n+ .await\n+ {\n+ tracing::warn!(err = %e, \"event log replay failed\");\n }\n \n let tree = Arc::new(RwLock::new(tree));\n let journal = JournalClient::spawn(tree.clone(), event_log.clone());\n- let reddit = RedditBroker::spawn(tree.clone(), event_log.clone(), RedditApiConfig::from_env());\n+ let reddit = RedditBroker::spawn(\n+ tree.clone(),\n+ event_log.clone(),\n+ entity_store.clone(),\n+ RedditApiConfig::from_env(),\n+ );\n \n Self {\n cfg: Arc::new(cfg),\n event_log,\n+ entity_store,\n views,\n tree,\n journal,\n@@ -184,10 +205,10 @@ impl AppState {\n mod tests {\n use super::{normalize_scope, parse_item_param};\n use crate::{\n+ entity_store::EntityStore,\n event_log::EventLog,\n events::Event,\n path_types::ItemId,\n- reddit::apply_entity_import,\n reducer::GlobalTree,\n };\n use serde_json::json;\n@@ -197,6 +218,7 @@ mod tests {\n let tmp = tempfile::tempdir().unwrap();\n let log_path = tmp.path().join(\"events.jsonl\");\n let log = EventLog::new(log_path.to_string_lossy().into_owned());\n+ let entity_store = EntityStore::open(&tmp.path().join(\"entity_db\")).unwrap();\n let payload = json!({\"kind\":\"t5\",\"data\":{\"title\":\"Rust\",\"display_name\":\"rust\"}});\n log.append(&Event::EntityImported {\n id: \"reddit.com/r/rust\".into(),\n@@ -207,19 +229,16 @@ mod tests {\n .unwrap();\n \n let mut tree = GlobalTree::new();\n- let (events, _) = log.load_all().await.unwrap();\n- for ev in events {\n- if let Event::EntityImported { id, payload, .. } = ev {\n- let parsed = ItemId::parse(&id).unwrap();\n- apply_entity_import(&mut tree, &parsed, payload);\n- }\n- }\n+ log.replay(|ev| super::apply_event(ev, &mut tree, &entity_store))\n+ .await\n+ .unwrap();\n let node = tree.get(&ItemId::parse(\"reddit.com/r/rust\").unwrap()).unwrap();\n assert_eq!(node.data.as_ref().unwrap().title, \"Rust\");\n- assert_eq!(\n- node.entity_raw.as_ref().unwrap()[\"data\"][\"display_name\"],\n- \"rust\"\n- );\n+ let stored = entity_store\n+ .get(&ItemId::parse(\"reddit.com/r/rust\").unwrap())\n+ .unwrap()\n+ .unwrap();\n+ assert_eq!(stored[\"data\"][\"display_name\"], \"rust\");\n }\n \n #[test]\ndiff --git a/server/tests/integration_ui.rs b/server/tests/integration_ui.rs\nindex 56d2db313b313ffce07eff38a8ccdaa2fbb9498f..32da054b58b97a4751a038c677d213d6c3fa7cdc 100644\n--- a/server/tests/integration_ui.rs\n+++ b/server/tests/integration_ui.rs\n@@ -104,9 +104,17 @@ async fn post_ui_record_vote_morphs_ranking_and_persists() {\n let log = std::fs::read_to_string(tmp.path().join(\"events.jsonl\")).unwrap();\n assert!(log.contains(\"vote_recorded\"));\n \n+ // Replay in a fresh data dir (RocksDB locks entity_db while the server runs).\n+ let replay_tmp = TempDir::new().unwrap();\n+ std::fs::copy(\n+ tmp.path().join(\"events.jsonl\"),\n+ replay_tmp.path().join(\"events.jsonl\"),\n+ )\n+ .unwrap();\n+ let replay_data = replay_tmp.path().to_string_lossy().into_owned();\n let cfg = AppConfig {\n- data_dir: tmp.path().to_string_lossy().into_owned(),\n- event_log_path: tmp.path().join(\"events.jsonl\").to_string_lossy().into_owned(),\n+ data_dir: replay_data.clone(),\n+ event_log_path: format!(\"{replay_data}/events.jsonl\"),\n port: 0,\n };\n let state = create_app_state(cfg).await;\n\n\nSide B — contributor: tommy-mor\nSide B — commit message:\n[3b3d5873] item refactor\n\nSide B — unified diff (full patch):\ndiff --git a/plan.md b/plan.md\ndeleted file mode 100644\nindex 00d6867a1e0ed144a16a020ea037f685ce646c73..0000000000000000000000000000000000000000\n--- a/plan.md\n+++ /dev/null\n@@ -1,155 +0,0 @@\n-# Plan: `ItemId` + `RouteContext` (identity vs hrefs)\n-\n-This document is for **the next agent** to continue the refactor without re-deriving context from chat. It supersedes ad-hoc notes: treat it as the checklist of record until the work lands and this file is deleted or trimmed.\n-\n-## Goal\n-\n-- **Identity** (what lives in the reducer graph, votes, indexes) becomes a **structural `ItemId` enum** in `slug-types`, not a canonical `String` / `CanonicalItemUrl` newtype.\n-- **Presentation** (tilde / dash display, breadcrumbs) derives from `ItemId` via explicit methods, not string stripping.\n-- **Routing** (browser `href`s for public vs room) goes through **`RouteContext`** (started in `server/src/html/routing.rs`) so Maud/handlers do not stitch `/r/…` vs `/~` ad hoc.\n-\n-**Non-goals for v1 of the migration:** backward-compatible JSONL or dual-read of old canonical strings in the event log (project has accepted breaking changes). If you reintroduce compat, document it here.\n-\n-## Current state (as of this plan)\n-\n-- **`CanonicalItemUrl`** (`types/src/paths.rs`): newtype around `String`; `parse` / `parent` / `display_path` / `tilde_tail` / etc. Reducer `ContentState`, `VoteData`, ranking, RPC, search, garden, breadcrumbs all use it or `String` keys derived from it.\n-- **`ThreadNav`** (`server/src/html/forum/nav.rs`): encodes scope prefixes for threads and garden URLs; **`RouteContext`** now wraps `ThreadNav` (`server/src/html/routing.rs`, re-exported from `server/src/html/mod.rs`) but **most HTML still takes `&ThreadNav` directly** — migration incomplete.\n-- **URL normalization** lives in `types/src/url_normalize.rs` + `canonicalize_item` / `finalize_external_identity_url` in `paths.rs` (YouTube, sorted query params, room path `room_route_segment` in `paths.rs`).\n-- **Room HTTP paths** are `/r/{short}{slug}` (fused segment); wire **`room_id`** remains `short/slug` for RPC/events.\n-\n-## Target architecture\n-\n-### `ItemId` (types)\n-\n-Suggested shape (adjust after profiling `Ord` / `Hash` / serde size):\n-\n-```text\n-ItemId::Root — tilde ontology root (today `SLUG_TILDE_ONTOLOGY_ROOT`)\n-ItemId::Local { segments } — slug.social ~/… path as Vec (lowercase segments, non-empty for non-root)\n-ItemId::External { url: Url } — normalized `url::Url` (crate `url` already in `slug-types`)\n-```\n-\n-**API surface (minimum):**\n-\n-- `ItemId::parse(&str) -> Option` — single entry from DSL / user input / legacy wire (internally may call `canonicalize_item` + structured split).\n-- `ItemId::to_wire_url(&self) -> String` — only for **external** boundaries if needed (HTTP fetch, rare assertions); avoid using as the primary key once maps use `ItemId`.\n-- `parent`, `display_path`, `tilde_tail` / `tilde_http_tail`, `tilde_segments`, `last_segment`, `normalized_storage` — port from `CanonicalItemUrl`.\n-- **`Ord` + `Hash` + `Eq`** stable for `BTreeSet` / `HashMap` (see `write_actor` scope-rank snapshots).\n-- **`Serialize` / `Deserialize`** — decide **tagged JSON** for any persisted or API-carried structs (e.g. `VoteData` in tests). If RPC must stay stringy for clients, use a **DTO layer** that converts `ItemId` ↔ wire at the boundary only.\n-\n-**Remove:** `CanonicalItemUrl` type and all `path_types::CanonicalItemUrl` / `slug_types::paths::CanonicalItemUrl` exports once call sites are migrated. **`Borrow`** on the old newtype goes away; update `nav!` / any code that assumed map keys borrowed as `str`.\n-\n-### `RouteContext` (server HTML)\n-\n-- **File:** `server/src/html/routing.rs` — **`RouteContext(ThreadNav)`** with `item_href`, `item_href_raw`, `thread_url`, `garden_root_url`, `room_url`, `From`/`Into` `ThreadNav`.\n-- **Direction:** new code and refactored Maud should take **`&RouteContext`** (or owned where appropriate) instead of `&ThreadNav` when building links. Long term, **`item_href(&ItemId)`** should not parse strings — it should pattern-match `ItemId` and append tilde tail or `/-/…` external tail using the same rules as today’s `ThreadNav::garden_item_url`.\n-\n-### Axum / garden routes\n-\n-- **No** single catch-all route (explicit decision): keep the existing router layout in `server/src/lib.rs`.\n-- Room routes stay **`/r/:room_key/...`** with `room_key` fused; parsing via `slug_types::room_id_from_route_segment` / `room_route_segment` in `paths.rs`.\n-\n-## Phased execution (recommended order)\n-\n-### Phase 0 — Preconditions (quick)\n-\n-1. Read **`AGENTS.md`** (UI contract, durability matrix, `RpcCommand` vs `HtmlUiAction`).\n-2. Run **`cargo test --workspace`** and **`./scripts/clj-test.sh`** on clean `main` before large diffs; repeat after each phase.\n-\n-### Phase 1 — `ItemId` in `slug-types` (no server yet)\n-\n-1. Add **`ItemId`** (new file e.g. `types/src/item_id.rs` **or** inline at bottom of `paths.rs` — see **Module cycle** below).\n-2. Implement **`ItemId::parse`** using existing **`canonicalize_item`** + normalization; port **`CanonicalItemUrl`** methods to **`ItemId`** with tests ported from `paths.rs` `#[cfg(test)] mod tests`.\n-3. **`GardenItemUrl::from_stored(&ItemId, room_wire)`** (and thread helpers) — build absolute hrefs from structure, not from re-parsing a canonical string.\n-4. **`TildeHttpPathTail::to_item_id`** (rename from `to_canonical`) / **`tilde_http_path_to_item_id`**.\n-5. **`TildeOntologyPath::from_stored(&ItemId)`**.\n-6. Export **`ItemId`** from **`types/src/lib.rs`**; update **`server/src/path_types.rs`** re-exports.\n-7. **Delete `CanonicalItemUrl`** and fix all **in-crate** references in `types` only until `cargo test` passes for `slug-types`.\n-\n-**Module cycle trap:** `item_id.rs` must not `use crate::paths::{...}` if `paths.rs` also imports `ItemId` for `GardenItemUrl` in the same module. **Fix one of:**\n-\n-- **A)** Put `ItemId` **inside `paths.rs`** below `canonicalize_item` / helpers (simplest, large file), or \n-- **B)** Split **`canonicalize_item`** (+ dash host helpers + `finalize_external_identity_url`) into **`types/src/item_wire.rs`**, then `paths.rs` + `item_id.rs` both depend on `item_wire` only (cleaner, more files).\n-\n-### Phase 2 — Reducer + ranking (server core)\n-\n-1. **`server/src/reducer.rs`**: `ContentState` / `GroupState` / **`VoteData`** — replace **`CanonicalItemUrl`** with **`ItemId`** on all maps, sets, deques, vectors.\n-2. **`apply_vote`**: normalize `a`/`b` via **`ItemId::parse`** or **`ItemId`**-aware logic (remove string round-trip).\n-3. **`apply_ingest_to_content`**: **`dsl`** still yields strings for item titles in statements; normalize to **`ItemId`** at ingest boundary via **`ItemId::parse`** once per item.\n-4. **`server/src/ranking.rs`**, **`server/src/scope_rank.rs`**, **`server/src/api/write_actor.rs`** (including **`BTreeSet`** ordering), **`server/src/api/validate.rs`**, **`server/src/api/helpers.rs`** — propagate **`ItemId`**.\n-5. **`server/tests/basic.rs`** and any reducer tests constructing **`VoteData`** — use **`ItemId::parse(...).unwrap()`** or helpers.\n-\n-### Phase 3 — RPC + search + external resolver\n-\n-1. **`server/src/api/rpc.rs`**: rank/pair/matchup/search payloads; today many paths use **`GardenItemUrl::from_storage_str(item.as_str(), …)`** — switch to **`ItemId`** + **`GardenItemUrl::from_stored(&item_id, …)`** (or equivalent).\n-2. **`server/src/html/search.rs`**: scoring uses item path strings — derive from **`ItemId::display_path`** / **`to_wire_url`** only at the scoring boundary if needed.\n-3. **`server/src/external_resolver.rs`**: take **`&ItemId`** or **`ItemId::external_url()`** instead of **`&CanonicalItemUrl`**.\n-\n-### Phase 4 — HTML / Maud\n-\n-1. **`ThreadNav::garden_item_url`**: overload or replace with **`garden_item_href(&self, item: &ItemId)`** (no `CanonicalItemUrl::parse` inside).\n-2. **`RouteContext`**: extend **`item_href(&ItemId)`**; migrate call sites from **`ThreadNav`** to **`RouteContext`** where only link-building is needed (keep **`ThreadNav`** where scope / auth helpers need the full struct).\n-3. **`server/src/html/garden.rs`**, **`breadcrumb_path.rs`**, **`forum/*`**, **`editor.rs`**: replace **`CanonicalItemUrl`** with **`ItemId`**; breadcrumbs should walk **`ItemId::parent`** without string `rsplit`.\n-4. **`types` JSON types** (`RankRow`, etc.): decide whether **`GardenItemUrl`** stays string for JSON or becomes a structured field; keep **one** wire format for the public API.\n-\n-### Phase 5 — Cleanup + docs\n-\n-1. Remove dead **`canonical_path`** / **`breadcrumb_path`** string logic if fully superseded.\n-2. Update **`AGENTS.md`** if durability, `POST /ui`, or command surfaces change.\n-3. Delete or shrink **`plan.md`** when done.\n-\n-## File / symbol checklist (non-exhaustive — grep-driven)\n-\n-Run periodically:\n-\n-```bash\n-rg \"CanonicalItemUrl\" -g'*.rs'\n-rg \"path_types::CanonicalItemUrl\" -g'*.rs'\n-rg \"tilde_http_path_to_canonical\" -g'*.rs'\n-```\n-\n-**High-touch files (from prior exploration):**\n-\n-| Area | Files |\n-|------|--------|\n-| Types | `types/src/paths.rs`, `types/src/lib.rs`, `types/src/url_normalize.rs`, (optional) `types/src/item_id.rs`, `types/src/item_wire.rs` |\n-| Server re-exports | `server/src/path_types.rs`, `server/src/canonical_path.rs` |\n-| Reducer / ingest | `server/src/reducer.rs`, `server/src/dsl.rs` (parse output types if changed) |\n-| Ranking | `server/src/ranking.rs`, `server/src/scope_rank.rs` |\n-| Writer / RPC | `server/src/api/write_actor.rs`, `server/src/api/rpc.rs`, `server/src/api/helpers.rs`, `server/src/api/validate.rs` |\n-| HTML | `server/src/html/garden.rs`, `server/src/html/breadcrumb_path.rs`, `server/src/html/forum/nav.rs`, `server/src/html/routing.rs`, `server/src/html/search.rs`, `server/src/html/editor.rs`, `server/src/html/forum/ingest.rs`, … |\n-| Tests | `server/tests/basic.rs`, `server/tests/integration.rs`, `types/src/paths.rs` tests, Clojure under `test/` if URLs/assertions mention canonical shapes |\n-\n-## Events / JSONL\n-\n-- **`Ingest`** events store **`raw` DSL** only — no change required for item identity inside the event.\n-- If any future event type stores item ids as strings, migrate to **structured `ItemId` serde** or accept string only at the event boundary with immediate parse into **`ItemId`** on `apply_event`.\n-\n-## `nav!` macro (`server/src/paths.rs`)\n-\n-- Macros use **`keypath($key)`** with **`.clone()`** — **`ItemId`** must be **`Clone`** (already for enums). Remove any reliance on **`Borrow`** for map keys.\n-\n-## Testing gate\n-\n-After each phase:\n-\n-```bash\n-cargo test --workspace\n-./scripts/clj-test.sh\n-```\n-\n-## Risks / gotchas\n-\n-1. **`Ord` on `ItemId`**: must match prior **`CanonicalItemUrl`** / `String` ordering wherever **`BTreeSet`** is used (e.g. deterministic scope-rank snapshots in **`write_actor`**).\n-2. **External `ItemId`**: **`Url`** equality / hashing — normalization is already centralized in **`url_normalize`**; ensure **`ItemId::parse`** always inserts normalized **`Url`** into **`External`**.\n-3. **Fake parent URLs** in garden (e.g. **`https://.`** for external root ranking): find all **`parse(\"https://.\")`** style hacks and express as **`ItemId`** or a dedicated sentinel.\n-4. **Serde**: tests and any RPC clients that snapshot JSON may need expectation updates if **`VoteData`** shape changes.\n-\n-## Optional follow-ups (not blocking `ItemId`)\n-\n-- More **domain normalizers** in **`url_normalize.rs`** (e.g. `music.youtube.com`, Spotify, etc.).\n-- **Room wire** vs **HTTP segment** helpers already in **`paths.rs`** (`ROOM_SHORT_ID_LEN`, `room_route_segment`, `room_id_from_route_segment`).\n-\n----\n-\n-**End state criteria:** `rg CanonicalItemUrl` returns nothing; reducer maps use **`ItemId`**; HTML link generation for items goes through **`RouteContext` + `ItemId`**; tests and Kaocha green.\ndiff --git a/server/src/api/helpers.rs b/server/src/api/helpers.rs\nindex 1b291db83df7364a026f2e147e0a29a70a399371..8cd23a02fa5219d6aa375e766e6b2bd2c7bb7dfb 100644\n--- a/server/src/api/helpers.rs\n+++ b/server/src/api/helpers.rs\n@@ -4,7 +4,8 @@ use axum::{\n Json,\n };\n use sha2::{Digest, Sha256};\n-use slug_types::paths::{CanonicalItemUrl, GardenItemUrl};\n+use slug_types::paths::GardenItemUrl;\n+use slug_types::ItemId;\n use slug_types::*;\n use std::collections::HashMap;\n \n@@ -31,12 +32,12 @@ pub fn now_ms() -> i64 {\n }\n \n /// Resolve DSL/user input to a stored canonical item id.\n-pub fn resolve_item(item: &str) -> Result {\n+pub fn resolve_item(item: &str) -> Result {\n let canonical = canonicalize_item(item);\n if canonical.is_empty() {\n return Err(format!(\"empty item path: `{}`\", item));\n }\n- Ok(CanonicalItemUrl(canonical))\n+ ItemId::parse(&canonical).ok_or_else(|| format!(\"invalid item path: `{}`\", item))\n }\n \n pub fn parse_parent_specs(parent: Option<&String>) -> Vec {\n@@ -94,7 +95,7 @@ pub fn paginate_rankings(\n (out_components, out_unranked)\n }\n \n-pub fn pick_random_distinct_canonical(items: &[CanonicalItemUrl]) -> Option<(CanonicalItemUrl, CanonicalItemUrl)> {\n+pub fn pick_random_distinct_canonical(items: &[ItemId]) -> Option<(ItemId, ItemId)> {\n use rand::seq::SliceRandom;\n if items.len() < 2 {\n return None;\n@@ -115,15 +116,15 @@ pub fn pick_random_distinct_canonical(items: &[CanonicalItemUrl]) -> Option<(Can\n }\n \n pub fn is_pair_voted(group: &crate::reducer::GroupState, a: &str, b: &str) -> bool {\n- let a_key = CanonicalItemUrl(a.to_string());\n- let b_key = CanonicalItemUrl(b.to_string());\n+ let a_key = ItemId::parse(a).unwrap_or_else(|| ItemId::opaque(a.to_string()));\n+ let b_key = ItemId::parse(b).unwrap_or_else(|| ItemId::opaque(b.to_string()));\n let Some(&a_idx) = group.item_to_idx.get(&a_key) else { return false; };\n let Some(&b_idx) = group.item_to_idx.get(&b_key) else { return false; };\n let (i, j) = if a_idx < b_idx { (a_idx, b_idx) } else { (b_idx, a_idx) };\n group.voted_pairs.contains(&(i, j))\n }\n \n-pub fn compute_connectivity_stats(group: &crate::reducer::GroupState, pool: &[CanonicalItemUrl]) -> ConnectivityStats {\n+pub fn compute_connectivity_stats(group: &crate::reducer::GroupState, pool: &[ItemId]) -> ConnectivityStats {\n let n = pool.len();\n \n let global_idxs: Vec> = pool\ndiff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs\nindex 0ff2d701dd1e8661f58abd55672cd91280e9491e..079d96eb6f1d717c203b1d4aec09f3916384b919 100644\n--- a/server/src/api/rpc.rs\n+++ b/server/src/api/rpc.rs\n@@ -17,7 +17,7 @@ use crate::{\n dsl,\n events::{Event, Ingest, ThreadCapability},\n identity::{parse_agent, parse_username},\n- path_types::CanonicalItemUrl,\n+ path_types::ItemId,\n ranking::{connected_components_from_voted_pairs, ranked_items_subset},\n reducer::{scope_from_room_wire, ReducerState, ScopeId},\n state::{AppState, InviteState},\n@@ -150,7 +150,7 @@ fn build_rank_response_for_content(\n \n if !is_global && !specs.is_empty() {\n let none_exist = specs.iter().all(|spec| {\n- let Some(canon) = CanonicalItemUrl::parse(spec) else { return true };\n+ let Some(canon) = ItemId::parse(spec) else { return true };\n !content.items.contains(&canon) && !content.item_children.contains_key(&canon)\n });\n if none_exist {\n@@ -163,10 +163,10 @@ fn build_rank_response_for_content(\n \n let depth = depth.max(1);\n let rankings = if is_global {\n- let all_items: Vec = content.items.iter().cloned().collect();\n+ let all_items: Vec = content.items.iter().cloned().collect();\n crate::scope_rank::build_rankings_for_item_set(content, &all_items)\n } else if specs.is_empty() {\n- crate::scope_rank::build_children_rankings(content, &CanonicalItemUrl::ontology_root())\n+ crate::scope_rank::build_children_rankings(content, &ItemId::ontology_root())\n } else if depth > 1 {\n let items = crate::scope_rank::resolve_scope_recursive(content, &specs, depth);\n crate::scope_rank::build_rankings_for_item_set(content, &items)\n@@ -359,8 +359,8 @@ async fn rpc_check(\n let mut simulated = { reduced_arc.read().await.clone() };\n simulated.apply_event(event);\n \n- let voted_parents: Vec = {\n- let mut parents: HashSet = HashSet::new();\n+ let voted_parents: Vec = {\n+ let mut parents: HashSet = HashSet::new();\n for s in &v.doc.statements {\n if let dsl::Stmt::Vote { item1, item2, .. } = s {\n if let (Ok(a), Ok(b)) = (resolve_item(item1), resolve_item(item2)) {\n@@ -369,7 +369,7 @@ async fn rpc_check(\n }\n }\n }\n- let mut out: Vec = parents.into_iter().collect();\n+ let mut out: Vec = parents.into_iter().collect();\n out.sort();\n out\n };\n@@ -695,7 +695,7 @@ fn rpc_search(reduced: &ReducerState, q: &str, limit: usize, principal: Option<&\n async fn rpc_get_pair(state: &AppState, room: String, parent_path: String) -> Result {\n let scope = scope_from_room_wire(&room);\n let reduced_arc = state.reduced.clone();\n- let pool: Vec = {\n+ let pool: Vec = {\n let reduced = reduced_arc.read().await;\n let content = content_for_room(&reduced, &room);\n let tmp = if parent_path.trim().is_empty() {\n@@ -716,7 +716,7 @@ async fn rpc_get_pair(state: &AppState, room: String, parent_path: String) -> Re\n Some(\"add items via ingest\".into()),\n ));\n }\n- let selected: Option<(CanonicalItemUrl, CanonicalItemUrl)> = {\n+ let selected: Option<(ItemId, ItemId)> = {\n let mut reduced = reduced_arc.write().await;\n let content = reduced.content.entry(scope.clone()).or_default();\n let group = &mut content.ranking_group;\n@@ -729,16 +729,16 @@ async fn rpc_get_pair(state: &AppState, room: String, parent_path: String) -> Re\n .filter_map(|it| group.item_to_idx.get(it).copied())\n .collect();\n let ranked = ranked_items_subset(group, &idxs, 10000, 1e-8);\n- let ranked_set: HashSet = ranked.iter().map(|r| r.item.clone()).collect();\n- let unsorted: Vec = pool\n+ let ranked_set: HashSet = ranked.iter().map(|r| r.item.clone()).collect();\n+ let unsorted: Vec = pool\n .iter()\n .filter(|it| !ranked_set.contains(*it))\n .cloned()\n .collect();\n- let mut pick: Option<(CanonicalItemUrl, CanonicalItemUrl)> = None;\n+ let mut pick: Option<(ItemId, ItemId)> = None;\n if !unsorted.is_empty() {\n if let Some(left) = unsorted.choose(&mut rng).cloned() {\n- let mut candidates: Vec = if !ranked.is_empty() {\n+ let mut candidates: Vec = if !ranked.is_empty() {\n ranked.iter().map(|r| r.item.clone()).collect()\n } else {\n pool.clone()\n@@ -865,7 +865,7 @@ pub async fn handle_rpc_batch(\n } else {\n let content = content_for_room(&reduced, &room);\n let item_str = canonicalize_item(&item_path);\n- let item = CanonicalItemUrl(item_str.clone());\n+ let item = ItemId::parse(&item_str).unwrap_or_else(|| ItemId::opaque(item_str.clone()));\n if !content.items.contains(&item) {\n line_err(\n \"item not found\",\n@@ -1212,7 +1212,7 @@ pub async fn handle_rpc_batch(\n }\n \n let ranked_total = ranked.len();\n- let mut unranked: Vec = content\n+ let mut unranked: Vec = content\n .items\n .iter()\n .filter(|it| !group.item_to_idx.contains_key(*it))\n@@ -1263,7 +1263,7 @@ pub async fn handle_rpc_batch(\n } else {\n let content = content_for_room(&reduced, &room);\n let item_str = canonicalize_item(&item_path);\n- let item = CanonicalItemUrl(item_str.clone());\n+ let item = ItemId::parse(&item_str).unwrap_or_else(|| ItemId::opaque(item_str.clone()));\n let limit = limit.unwrap_or(50).clamp(1, 200);\n if !content.items.contains(&item) {\n line_err(\n@@ -1304,7 +1304,7 @@ pub async fn handle_rpc_batch(\n let content = content_for_room(&reduced, &room);\n let scope = scope_from_room_wire(&room);\n let item_str = canonicalize_item(&item_path);\n- let item = CanonicalItemUrl(item_str.clone());\n+ let item = ItemId::parse(&item_str).unwrap_or_else(|| ItemId::opaque(item_str.clone()));\n let entries = content.rank_history.get(&item).cloned().unwrap_or_default();\n let history: Vec = entries.iter().map(|e| {\n let caused_by: Vec = reduced.ingests_by_id.get(&e.post_id)\n@@ -1361,7 +1361,11 @@ pub async fn handle_rpc_batch(\n line_err(e, h)\n } else {\n let content = content_for_room(&reduced, &room);\n- let parents: HashSet<&str> = content.item_children.keys().map(|s| s.as_str()).collect();\n+ let parents: HashSet = content\n+ .item_children\n+ .keys()\n+ .map(|k| k.to_storage_string())\n+ .collect();\n let mut paths: Vec = content\n .items\n .iter()\n@@ -1380,11 +1384,11 @@ pub async fn handle_rpc_batch(\n let content = content_for_room(&reduced, &room);\n let out: Vec = content\n .item_children\n- .get(&CanonicalItemUrl::ontology_root())\n+ .get(&ItemId::ontology_root())\n .map(|roots| {\n let mut v: Vec = roots.iter()\n .map(|path| {\n- let children = content.item_children.get(path.as_str()).map(|s| s.len()).unwrap_or(0);\n+ let children = content.item_children.get(path).map(|s| s.len()).unwrap_or(0);\n PathSummary {\n path: TildeOntologyPath::from_stored(path),\n children,\ndiff --git a/server/src/api/validate.rs b/server/src/api/validate.rs\nindex 3c7aa80fd485cf247231fe5c5c7e6fea62f22534..3f657a88ef9328a31e3e29c3bc3bd32bec6fd7a1 100644\n--- a/server/src/api/validate.rs\n+++ b/server/src/api/validate.rs\n@@ -4,7 +4,7 @@ use std::collections::HashSet;\n use crate::{\n canonical_path::canonicalize_tag,\n dsl,\n- path_types::CanonicalItemUrl,\n+ path_types::ItemId,\n reducer::{ReducerState, ScopeId},\n };\n use slug_types::paths::GardenItemUrl;\n@@ -32,11 +32,11 @@ pub fn validate_ingest_document(\n ScopeId::Public => None,\n _ => reduced.content_for_scope(scope),\n };\n- let item_exists = |key: &CanonicalItemUrl| {\n+ let item_exists = |key: &ItemId| {\n scoped_content.map(|c| c.items.contains(key)).unwrap_or(false)\n || public_content.items.contains(key)\n };\n- let body_exists = |key: &CanonicalItemUrl| {\n+ let body_exists = |key: &ItemId| {\n scoped_content.map(|c| c.item_bodies.contains_key(key)).unwrap_or(false)\n || public_content.item_bodies.contains_key(key)\n };\n@@ -52,7 +52,7 @@ pub fn validate_ingest_document(\n };\n \n let ts = super::helpers::now_ms();\n- let mut defined_in_doc: HashSet = HashSet::new();\n+ let mut defined_in_doc: HashSet = HashSet::new();\n \n for s in &doc.statements {\n match s {\ndiff --git a/server/src/api/write_actor.rs b/server/src/api/write_actor.rs\nindex f9c3b8bd3fbf8fcb9c035e1a1572fef0b08fa8a9..3e96b015def54921962f74b4cd884808ff6c3bcc 100644\n--- a/server/src/api/write_actor.rs\n+++ b/server/src/api/write_actor.rs\n@@ -10,7 +10,7 @@ use crate::{\n events::{AgentBound, Event, GrantAdded, Ingest, PostRedacted, RoomDeleted, UserRegistered},\n html::JsBuilder,\n identity::parse_agent,\n- path_types::CanonicalItemUrl,\n+ path_types::ItemId,\n reducer::{scope_from_room_wire, ReducerState, ScopeId},\n state::AppState,\n write_cmd::WriteCmd,\n@@ -90,7 +90,7 @@ async fn broadcast_web_refresh(state: &AppState, room_key: &str, thread_id: &str\n }\n \n fn compute_scope_rank_changes(\n- parent: &CanonicalItemUrl,\n+ parent: &ItemId,\n before: &crate::scope_rank::ChildrenRankings,\n after: &crate::scope_rank::ChildrenRankings,\n room_wire: &str,\n@@ -99,7 +99,7 @@ fn compute_scope_rank_changes(\n use std::collections::BTreeSet;\n fn build_positions(\n rankings: &crate::scope_rank::ChildrenRankings,\n- ) -> HashMap> {\n+ ) -> HashMap> {\n let mut map = HashMap::new();\n for comp in &rankings.component_rankings {\n let total = comp.ranked.len();\n@@ -116,7 +116,7 @@ fn compute_scope_rank_changes(\n let before_pos = build_positions(before);\n let after_pos = build_positions(after);\n \n- let all_items: BTreeSet = before_pos\n+ let all_items: BTreeSet = before_pos\n .keys()\n .cloned()\n .chain(after_pos.keys().cloned())\n@@ -287,8 +287,8 @@ pub async fn writer_actor(mut rx: mpsc::Receiver, state: AppState) {\n .map(|d| reduced.agent_bindings.get(d).is_none())\n .unwrap_or(false);\n \n- let voted_parent_scopes: Vec = {\n- let mut parents: HashSet = HashSet::new();\n+ let voted_parent_scopes: Vec = {\n+ let mut parents: HashSet = HashSet::new();\n for s in &v.doc.statements {\n if let dsl::Stmt::Vote { item1, item2, .. } = s {\n if let (Ok(a), Ok(b)) = (resolve_item(item1), resolve_item(item2)) {\n@@ -301,12 +301,12 @@ pub async fn writer_actor(mut rx: mpsc::Receiver, state: AppState) {\n }\n }\n }\n- let mut out: Vec = parents.into_iter().collect();\n+ let mut out: Vec = parents.into_iter().collect();\n out.sort();\n out\n };\n \n- let pre_rankings: HashMap =\n+ let pre_rankings: HashMap =\n if !voted_parent_scopes.is_empty() {\n let content = content_for_room(&reduced, &room_key);\n voted_parent_scopes\ndiff --git a/server/src/external_resolver.rs b/server/src/external_resolver.rs\nindex 87409c4cab5f1930d2cd075f3417fa824fd791c7..6f5c982a627f9f620f0e3be0ba0cb92a3d7d4bb7 100644\n--- a/server/src/external_resolver.rs\n+++ b/server/src/external_resolver.rs\n@@ -1,6 +1,6 @@\n use async_trait::async_trait;\n \n-use crate::path_types::CanonicalItemUrl;\n+use crate::path_types::ItemId;\n \n #[async_trait]\n pub trait ExternalResolver: Send + Sync {\n@@ -11,7 +11,7 @@ pub trait ExternalResolver: Send + Sync {\n fn normalize(&self, path: &str) -> String;\n \n /// Fetches body when missing; GitHub hook lands here in a follow-up.\n- async fn fetch_body(&self, canonical_url: &CanonicalItemUrl) -> Result;\n+ async fn fetch_body(&self, canonical_url: &ItemId) -> Result;\n }\n \n /// Placeholder until domain-specific resolvers exist.\n@@ -27,7 +27,7 @@ impl ExternalResolver for DefaultExternalResolver {\n path.to_string()\n }\n \n- async fn fetch_body(&self, _canonical_url: &CanonicalItemUrl) -> Result {\n+ async fn fetch_body(&self, _canonical_url: &ItemId) -> Result {\n Err(\"external fetch not implemented\".to_string())\n }\n }\ndiff --git a/server/src/html/breadcrumb_path.rs b/server/src/html/breadcrumb_path.rs\nindex c8a3937923161a6ff248bd77e87eff0dc6fe9ab0..5743a98753f579c70e10961278469afd7cb9ddcf 100644\n--- a/server/src/html/breadcrumb_path.rs\n+++ b/server/src/html/breadcrumb_path.rs\n@@ -1,8 +1,8 @@\n-use crate::path_types::{tilde_http_path_to_canonical, CanonicalItemUrl};\n+use crate::path_types::{tilde_http_path_to_item_id, ItemId};\n \n /// Semantic view of an ontology path for rendering and routing decisions.\n pub(super) struct OntologyPath {\n- canonical: CanonicalItemUrl,\n+ canonical: ItemId,\n /// Breadcrumb segments: for `~/a/b` this is `[\"a\", \"b\"]` (leading `~` rendered separately).\n segments: Vec,\n }\n@@ -11,11 +11,11 @@ impl OntologyPath {\n /// Path is the `*path` segment from `/~/*path` (e.g. `topic/a`). Always treat it as under `~/`\n /// so it canonicalizes to `https://slug.social/~/…`, not the non-tilde site path.\n pub(super) fn from_input(path: &str) -> Self {\n- let canonical = tilde_http_path_to_canonical(path);\n+ let canonical = tilde_http_path_to_item_id(path);\n Self::from_canonical(canonical)\n }\n \n- pub(super) fn from_canonical(canonical: CanonicalItemUrl) -> Self {\n+ pub(super) fn from_canonical(canonical: ItemId) -> Self {\n // tilde_segments() returns [\"~\", \"a\", \"b\"] but bc_path() renders \"~\" itself,\n // so we skip the leading \"~\" segment here.\n let segments = canonical\n@@ -28,7 +28,7 @@ impl OntologyPath {\n }\n \n pub(super) fn root() -> Self {\n- Self::from_canonical(CanonicalItemUrl::ontology_root())\n+ Self::from_canonical(ItemId::ontology_root())\n }\n \n pub(super) fn is_root(&self) -> bool {\n@@ -56,7 +56,7 @@ impl OntologyPath {\n \n /// External `https://host/…` items addressed as `/-/host/…` in the URL bar.\n pub(super) struct ExternalOntologyPath {\n- canonical: CanonicalItemUrl,\n+ canonical: ItemId,\n /// e.g. `[\"github.com\", \"org\", \"repo\", \"issues\"]`\n segments: Vec,\n }\n@@ -71,13 +71,13 @@ impl ExternalOntologyPath {\n } else {\n format!(\"-/{}\", p.trim_start_matches('/'))\n };\n- let Some(canonical) = CanonicalItemUrl::parse(&raw) else {\n- return Self::from_canonical(CanonicalItemUrl(\"https://.\".to_string()));\n+ let Some(canonical) = ItemId::parse(&raw) else {\n+ return Self::from_canonical(ItemId::opaque(\"https://.\".to_string()));\n };\n Self::from_canonical(canonical)\n }\n \n- pub(super) fn from_canonical(canonical: CanonicalItemUrl) -> Self {\n+ pub(super) fn from_canonical(canonical: ItemId) -> Self {\n let s = canonical.as_str();\n let rest = s\n .strip_prefix(\"https://\")\ndiff --git a/server/src/html/editor.rs b/server/src/html/editor.rs\nindex 80657ac0df9b4bb67e4cdacca296f70267840bd1..c76417acd7a164fda23537cd4e7fb9f1f1dcd895 100644\n--- a/server/src/html/editor.rs\n+++ b/server/src/html/editor.rs\n@@ -97,7 +97,7 @@ pub async fn editor_check(\n simulated.apply_event(event);\n \n // Collect voted parent scopes.\n- let voted_parents: Vec = {\n+ let voted_parents: Vec = {\n let mut parents = std::collections::HashSet::new();\n for s in &v.doc.statements {\n if let crate::dsl::Stmt::Vote { item1, item2, .. } = s {\n@@ -107,7 +107,7 @@ pub async fn editor_check(\n }\n }\n }\n- let mut out: Vec = parents.into_iter().collect();\n+ let mut out: Vec = parents.into_iter().collect();\n out.sort();\n out\n };\ndiff --git a/server/src/html/forum/nav.rs b/server/src/html/forum/nav.rs\nindex 48fe11e46731670874ff8b6b05baa6f09ae0b7e4..7ca5b491f38104ec8c81db43a95c59da00abd8c4 100644\n--- a/server/src/html/forum/nav.rs\n+++ b/server/src/html/forum/nav.rs\n@@ -52,9 +52,14 @@ impl ThreadNav {\n }\n \n pub(crate) fn garden_item_url(&self, item: &str) -> String {\n- let Some(c) = crate::path_types::CanonicalItemUrl::parse(item) else {\n+ let Some(c) = crate::path_types::ItemId::parse(item) else {\n return format!(\"{}/{}\", self.garden_path_prefix, canonicalize_item(item));\n };\n+ self.garden_item_href(&c)\n+ }\n+\n+ /// Relative href for a structured [`crate::path_types::ItemId`] in this scope’s garden.\n+ pub(crate) fn garden_item_href(&self, c: &crate::path_types::ItemId) -> String {\n if let Some(tail) = c.tilde_tail().map(str::to_owned) {\n format!(\"{}/{}\", self.garden_path_prefix, tail)\n } else if c.as_str().starts_with(\"http://\") || c.as_str().starts_with(\"https://\") {\n@@ -63,7 +68,7 @@ impl ThreadNav {\n let ext_prefix = format!(\"{}-\", self.garden_path_prefix.trim_end_matches('~'));\n format!(\"{}/{}\", ext_prefix, rest)\n } else {\n- format!(\"{}/{}\", self.garden_path_prefix, canonicalize_item(item))\n+ format!(\"{}/{}\", self.garden_path_prefix, canonicalize_item(c.as_str()))\n }\n }\n \ndiff --git a/server/src/html/garden.rs b/server/src/html/garden.rs\nindex e615dd356bcf634232d85610c0a26235ead125fd..2af9ac5bfdee1630883e6f8257883baeafcb5a44 100644\n--- a/server/src/html/garden.rs\n+++ b/server/src/html/garden.rs\n@@ -10,7 +10,7 @@ use crate::{\n api::optional_principal,\n canonical_path::canonicalize_item,\n events::ThreadCapability,\n- path_types::CanonicalItemUrl,\n+ path_types::ItemId,\n reducer::{ContentState, ReducerState, ScopeId},\n ranking::{connected_components_from_voted_pairs, ranked_items_subset},\n scope_rank::{build_children_rankings, ChildrenRankings},\n@@ -27,7 +27,7 @@ use super::{\n \n /// Display path for an item: `~/…` or `-/…` form.\n fn item_display_path(item: &str) -> String {\n- CanonicalItemUrl::parse(item)\n+ ItemId::parse(item)\n .map(|c| c.display_path())\n .unwrap_or_else(|| canonicalize_item(item))\n }\n@@ -159,7 +159,7 @@ pub async fn garden_index(\n let nav = ThreadNav::public();\n let child_rankings = {\n let reduced = state.reduced.read().await;\n- build_children_rankings(reduced.public(), &CanonicalItemUrl::ontology_root())\n+ build_children_rankings(reduced.public(), &ItemId::ontology_root())\n };\n \n let page = layout(\n@@ -236,7 +236,7 @@ pub async fn external_garden_index(\n ) -> impl IntoResponse {\n let nav = ThreadNav::public();\n let ext_path = ExternalOntologyPath::from_input(\"\");\n- let parent = CanonicalItemUrl::parse(\"https://.\").unwrap();\n+ let parent = ItemId::parse(\"https://.\").unwrap();\n let child_rankings = {\n let reduced = state.reduced.read().await;\n build_children_rankings(reduced.public(), &parent)\n@@ -365,7 +365,7 @@ pub async fn room_external_garden_index(\n return room_not_found_page(&jar, &uri).into_response();\n }\n let ext_path = ExternalOntologyPath::from_input(\"\");\n- let parent = CanonicalItemUrl::parse(\"https://.\").unwrap();\n+ let parent = ItemId::parse(\"https://.\").unwrap();\n let child_rankings = build_children_rankings(\n content_for_garden_view(&reduced, &nav.scope()),\n &parent,\n@@ -509,13 +509,13 @@ struct ItemPageViewModel {\n fn build_sibling_rank(\n reduced: &crate::reducer::ReducerState,\n scope: &ScopeId,\n- item: &CanonicalItemUrl,\n+ item: &ItemId,\n ) -> Option {\n let item = item.clone().normalized_storage();\n let content = content_for_garden_view(reduced, scope);\n let group = &content.ranking_group;\n let parent = item.parent()?.normalized_storage();\n- let siblings: Vec = content\n+ let siblings: Vec = content\n .item_children\n .get(&parent)\n .map(|s| s.iter().cloned().collect())\n@@ -574,7 +574,7 @@ fn build_rank_history(\n item: &str,\n ) -> Vec {\n let content = content_for_garden_view(reduced, scope);\n- let item_key = CanonicalItemUrl(item.to_string());\n+ let item_key = ItemId::parse(item).unwrap_or_else(|| ItemId::opaque(item.to_string()));\n let entries = match content.rank_history.get(&item_key) {\n None => return vec![],\n Some(e) => e,\n@@ -592,8 +592,8 @@ fn build_rank_history(\n if a_str == item || b_str == item {\n Some(crate::reducer::VoteData {\n ts: e.ts,\n- a: CanonicalItemUrl(a_str),\n- b: CanonicalItemUrl(b_str),\n+ a: ItemId::parse(&a_str).unwrap_or_else(|| ItemId::opaque(a_str)),\n+ b: ItemId::parse(&b_str).unwrap_or_else(|| ItemId::opaque(b_str)),\n ratio_left, ratio_right,\n body: explanation,\n principal: reduced.ingests_by_id.get(&e.post_id)\n@@ -629,8 +629,8 @@ fn build_item_page_view_model(\n item: &str,\n ) -> ItemPageViewModel {\n let content = content_for_garden_view(reduced, scope);\n- let item_key = CanonicalItemUrl::parse(item)\n- .unwrap_or_else(|| CanonicalItemUrl::parse(\"~/\").unwrap())\n+ let item_key = ItemId::parse(item)\n+ .unwrap_or_else(|| ItemId::parse(\"~/\").unwrap())\n .normalized_storage();\n let item_has_parent = item_key.parent().is_some();\n let child_rankings = build_children_rankings(content, &item_key);\n@@ -925,10 +925,10 @@ mod tests {\n .map(|r| r.item.as_str())\n .collect();\n assert_eq!(names, vec![\"https://slug.social/~/topic/a\", \"https://slug.social/~/topic/b\"]);\n- use crate::path_types::CanonicalItemUrl;\n+ use crate::path_types::ItemId;\n assert!(\n- model.child_rankings.unranked_items.contains(&CanonicalItemUrl(\"https://slug.social/~/topic/kid1\".to_string()))\n- || model.child_rankings.unranked_items.contains(&CanonicalItemUrl(\"https://slug.social/~/topic/kid2\".to_string()))\n+ model.child_rankings.unranked_items.contains(&ItemId::parse(\"https://slug.social/~/topic/kid1\").unwrap())\n+ || model.child_rankings.unranked_items.contains(&ItemId::parse(\"https://slug.social/~/topic/kid2\").unwrap())\n );\n }\n \n@@ -941,8 +941,8 @@ mod tests {\n \"9ab12cd/my-room\",\n \"@00000000-0000-0000-0000-000000000000:test:local/test\\n~/t1 {a}\\n~/t2 {b}\\n\",\n );\n- use crate::path_types::CanonicalItemUrl;\n- let root = CanonicalItemUrl::ontology_root();\n+ use crate::path_types::ItemId;\n+ let root = ItemId::ontology_root();\n let model = build_item_page_view_model(\n &reduced,\n &ScopeId::Room(\"9ab12cd/my-room\".to_string()),\n@@ -971,8 +971,8 @@ mod tests {\n \"@00000000-0000-0000-0000-000000000000:test:local/test\\n\\\n ~/a {a}\\n~/b {b}\\n~/a 2:1 ~/b {because}\\n\",\n );\n- use crate::path_types::CanonicalItemUrl;\n- let root = CanonicalItemUrl::ontology_root();\n+ use crate::path_types::ItemId;\n+ let root = ItemId::ontology_root();\n let model = build_item_page_view_model(\n &reduced,\n &ScopeId::Room(\"9ab12cd/my-room\".to_string()),\ndiff --git a/server/src/html/routing.rs b/server/src/html/routing.rs\nindex 13e9ae0cbe32d454e34a7737edf8234d2a558502..df545a8195aa97484ef0012cff31d802ac67d9df 100644\n--- a/server/src/html/routing.rs\n+++ b/server/src/html/routing.rs\n@@ -4,7 +4,7 @@\n //! Prefer `RouteContext::item_href` / [`RouteContext::thread_url`] in new Maud over stitching\n //! `/r/…` vs `/~` manually. Call sites can migrate incrementally from passing `&ThreadNav`.\n \n-use crate::path_types::CanonicalItemUrl;\n+use crate::path_types::ItemId;\n \n use super::forum::ThreadNav;\n \n@@ -32,9 +32,9 @@ impl RouteContext {\n self.0\n }\n \n- /// Relative path for a stored canonical item in this scope’s garden.\n- pub fn item_href(&self, item: &CanonicalItemUrl) -> String {\n- self.0.garden_item_url(item.as_str())\n+ /// Relative path for a stored item in this scope’s garden.\n+ pub fn item_href(&self, item: &ItemId) -> String {\n+ self.0.garden_item_href(item)\n }\n \n /// Same as [`Self::item_href`] but parses `item` first (raw DSL / user paste).\ndiff --git a/server/src/path_types.rs b/server/src/path_types.rs\nindex 361a9d446c9043cbdea5f060db2e8633c2fd9bf9..a6e0b90a1aaead0bc1f7e0f4c5296506edf3f7ac 100644\n--- a/server/src/path_types.rs\n+++ b/server/src/path_types.rs\n@@ -1,5 +1,6 @@\n-//! Re-exports — implementations live in `slug-types` (`paths` module).\n+//! Re-exports — implementations live in `slug-types` (`paths` / `item_id` modules).\n \n+pub use slug_types::ItemId;\n pub use slug_types::paths::{\n- tilde_http_path_to_canonical, CanonicalItemUrl, RelativePath, TildeHttpPathTail, TildePath,\n+ tilde_http_path_to_item_id, RelativePath, TildeHttpPathTail, TildePath,\n };\ndiff --git a/server/src/ranking.rs b/server/src/ranking.rs\nindex b24e80ddd5f34b4e454bb81b541e8f54bd97a541..3710c9f64437f5bef3b2121905b6f3bcb7611047 100644\n--- a/server/src/ranking.rs\n+++ b/server/src/ranking.rs\n@@ -1,11 +1,11 @@\n use std::collections::HashMap;\n \n-use crate::path_types::CanonicalItemUrl;\n+use crate::path_types::ItemId;\n use crate::reducer::GroupState;\n \n #[derive(Debug, Clone)]\n pub struct RankedItem {\n- pub item: CanonicalItemUrl,\n+ pub item: ItemId,\n pub score: f64,\n }\n \n@@ -239,7 +239,7 @@ pub fn group_summary_scores(\n group: &mut GroupState,\n max_iters: usize,\n tol: f64,\n-) -> HashMap {\n+) -> HashMap {\n ranked_items(group, max_iters, tol)\n .into_iter()\n .map(|r| (r.item, r.score))\n@@ -256,11 +256,11 @@ mod tests {\n }\n \n fn vote(ts: i64, a: &str, b: &str, l: i32, r: i32) -> VoteData {\n- use crate::path_types::CanonicalItemUrl;\n+ use crate::path_types::ItemId;\n VoteData {\n ts,\n- a: CanonicalItemUrl(a.to_string()),\n- b: CanonicalItemUrl(b.to_string()),\n+ a: ItemId::parse(a).unwrap(),\n+ b: ItemId::parse(b).unwrap(),\n ratio_left: l,\n ratio_right: r,\n body: \"because\".to_string(),\ndiff --git a/server/src/reducer.rs b/server/src/reducer.rs\nindex a8651994f2c062af25b6ad792a014dcc083296f9..cd11a5391de8e36b1c3bb6c9b8067ceba0878e31 100644\n--- a/server/src/reducer.rs\n+++ b/server/src/reducer.rs\n@@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};\n use crate::canonical_path::canonicalize_tag;\n use crate::dsl;\n use crate::events::{Event, Ingest, ThreadCapability};\n-use crate::path_types::CanonicalItemUrl;\n+use crate::path_types::ItemId;\n \n #[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]\n pub enum ScopeId {\n@@ -27,8 +27,8 @@ pub fn scope_from_room_wire(room: &str) -> ScopeId {\n #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]\n pub struct VoteData {\n pub ts: i64,\n- pub a: CanonicalItemUrl,\n- pub b: CanonicalItemUrl,\n+ pub a: ItemId,\n+ pub b: ItemId,\n pub ratio_left: i32,\n pub ratio_right: i32,\n pub body: String,\n@@ -40,8 +40,8 @@ pub struct VoteData {\n \n #[derive(Debug, Clone)]\n pub struct GroupState {\n- pub item_to_idx: HashMap,\n- pub idx_to_item: Vec,\n+ pub item_to_idx: HashMap,\n+ pub idx_to_item: Vec,\n \n /// Aggregated directed edge weights: (src_idx, dst_idx) -> weight.\n pub edges: HashMap<(usize, usize), f64>,\n@@ -72,7 +72,7 @@ impl GroupState {\n }\n }\n \n- fn ensure_item(&mut self, item: &CanonicalItemUrl) -> usize {\n+ fn ensure_item(&mut self, item: &ItemId) -> usize {\n if let Some(&idx) = self.item_to_idx.get(item) {\n return idx;\n }\n@@ -85,11 +85,11 @@ impl GroupState {\n \n /// Public test helper: insert an item into the group without a vote (for unit tests).\n pub fn ensure_item_pub(&mut self, item: &str) -> usize {\n- if let Some(canon) = CanonicalItemUrl::parse(item) {\n+ if let Some(canon) = ItemId::parse(item) {\n self.ensure_item(&canon)\n } else {\n // Fallback: treat as raw canonical string\n- let canon = CanonicalItemUrl(item.to_string());\n+ let canon = ItemId::opaque(item.to_string());\n self.ensure_item(&canon)\n }\n }\n@@ -103,8 +103,8 @@ impl GroupState {\n }\n \n pub fn apply_vote(&mut self, mut vote: VoteData) {\n- vote.a = CanonicalItemUrl::parse(vote.a.as_str()).unwrap_or(vote.a);\n- vote.b = CanonicalItemUrl::parse(vote.b.as_str()).unwrap_or(vote.b);\n+ vote.a = ItemId::parse(vote.a.as_str()).unwrap_or_else(|| vote.a.clone());\n+ vote.b = ItemId::parse(vote.b.as_str()).unwrap_or_else(|| vote.b.clone());\n vote.thread_tag = canonicalize_tag(&vote.thread_tag);\n if vote.ratio_left < 0 {\n vote.ratio_left = 0;\n@@ -210,18 +210,18 @@ impl Default for ForumThreadState {\n #[derive(Debug, Clone, Default)]\n pub struct ContentState {\n pub ranking_group: GroupState,\n- pub items: HashSet,\n- pub item_bodies: HashMap,\n+ pub items: HashSet,\n+ pub item_bodies: HashMap,\n /// Parent canonical URL -> direct children.\n- pub item_children: HashMap>,\n+ pub item_children: HashMap>,\n /// Per-item vote history (most recent first).\n- pub item_votes: HashMap>,\n+ pub item_votes: HashMap>,\n /// Per-item ingest references (most recent first).\n- pub item_snippets: HashMap>,\n+ pub item_snippets: HashMap>,\n /// Item path -> threads that mention or vote on this item.\n- pub item_threads: HashMap>,\n+ pub item_threads: HashMap>,\n /// Per-item rank history, oldest first.\n- pub rank_history: HashMap>,\n+ pub rank_history: HashMap>,\n }\n \n #[derive(Debug, Clone)]\n@@ -351,7 +351,7 @@ impl ReducerState {\n /// For `a/b/c/d` this creates: `a/b/c→d`, `a/b→a/b/c`, `a→a/b`, `\"\"→a`.\n /// Stops early when an intermediate is already registered (its ancestors must be too).\n /// @e2bdefa9-a6fa-4725-b0a2-c0b09d95bb20:claudecode:anthropic/claude-opus-4\n- fn add_child_edge(content: &mut ContentState, item: &CanonicalItemUrl) {\n+ fn add_child_edge(content: &mut ContentState, item: &ItemId) {\n let mut child = item.clone();\n loop {\n let Some(parent) = child.parent() else { break };\n@@ -366,16 +366,16 @@ impl ReducerState {\n }\n \n /// Resolve an item path as a first-class canonical path.\n- fn normalize_item(item: &str) -> Option {\n- CanonicalItemUrl::parse(item)\n+ fn normalize_item(item: &str) -> Option {\n+ ItemId::parse(item)\n }\n \n /// 1-indexed rank of `item` within its connected component in the parent scope.\n /// 0 if the item has no votes connecting it to siblings (unranked).\n fn scope_rank_of(\n group: &GroupState,\n- item: &CanonicalItemUrl,\n- item_children: &HashMap>,\n+ item: &ItemId,\n+ item_children: &HashMap>,\n ) -> usize {\n let scope = match item.parent() {\n Some(p) => p,\n@@ -421,7 +421,7 @@ impl ReducerState {\n /// 1-indexed position of `item` in the component-aware global flat list.\n /// Components sorted largest-first; items ranked within each component.\n /// 0 if the item is not in the ranking group.\n- fn global_rank_of(group: &GroupState, item: &CanonicalItemUrl) -> usize {\n+ fn global_rank_of(group: &GroupState, item: &ItemId) -> usize {\n if !group.item_to_idx.contains_key(item) {\n return 0;\n }\n@@ -448,7 +448,7 @@ impl ReducerState {\n let doc = dsl::parse_full(&ing.raw).map_err(|_| ())?;\n let canonical_thread = canonicalize_tag(&ing.thread_tag);\n \n- let voted_items: Vec = doc\n+ let voted_items: Vec = doc\n .statements\n .iter()\n .filter_map(|s| {\n@@ -467,7 +467,7 @@ impl ReducerState {\n let principal = ing.principal.clone();\n let delegate = ing.delegate.clone();\n \n- let before: HashMap = if !voted_items.is_empty() {\n+ let before: HashMap = if !voted_items.is_empty() {\n crate::ranking::compute_group_ranking(&mut content.ranking_group, 10000, 1e-8);\n voted_items\n .iter()\n@@ -485,7 +485,7 @@ impl ReducerState {\n HashMap::new()\n };\n \n- let mut ingest_items: HashSet = HashSet::new();\n+ let mut ingest_items: HashSet = HashSet::new();\n \n for stmt in doc.statements {\n match stmt {\ndiff --git a/server/src/scope_rank.rs b/server/src/scope_rank.rs\nindex d656b2f0a0a3623ca6b234b746beaaca8ae6017d..4fdaae150c74ec5162c2accc06d4cc778ad79914 100644\n--- a/server/src/scope_rank.rs\n+++ b/server/src/scope_rank.rs\n@@ -3,7 +3,7 @@\n \n use std::collections::{HashMap, HashSet};\n \n-use crate::path_types::CanonicalItemUrl;\n+use crate::path_types::ItemId;\n use crate::ranking::{connected_components_from_voted_pairs, ranked_items_subset, RankedItem};\n use crate::reducer::ContentState;\n \n@@ -17,12 +17,12 @@ pub struct ScopedComponent {\n pub struct ChildrenRankings {\n pub component_rankings: Vec,\n /// Items in scope with no rank (no votes connecting them to others in this scope).\n- pub unranked_items: Vec,\n+ pub unranked_items: Vec,\n }\n \n /// Resolve one scope spec (literal path) to direct children of that parent. No wildcards.\n-fn resolve_one_scope(content: &ContentState, spec: &str) -> HashSet {\n- let Some(parent) = CanonicalItemUrl::parse(spec.trim()) else {\n+fn resolve_one_scope(content: &ContentState, spec: &str) -> HashSet {\n+ let Some(parent) = ItemId::parse(spec.trim()) else {\n return HashSet::new();\n };\n content\n@@ -36,12 +36,12 @@ fn resolve_one_scope(content: &ContentState, spec: &str) -> HashSet Vec {\n+pub fn resolve_scope(content: &ContentState, specs: &[String]) -> Vec {\n let mut set = HashSet::new();\n for spec in specs {\n set.extend(resolve_one_scope(content, spec));\n }\n- let mut out: Vec = set.into_iter().collect();\n+ let mut out: Vec = set.into_iter().collect();\n out.sort();\n out\n }\n@@ -49,18 +49,18 @@ pub fn resolve_scope(content: &ContentState, specs: &[String]) -> Vec Vec {\n+pub fn resolve_scope_recursive(content: &ContentState, specs: &[String], depth: usize) -> Vec {\n if depth == 0 {\n return vec![];\n }\n- let mut visited: HashSet = HashSet::new();\n- let mut frontier: Vec = specs\n+ let mut visited: HashSet = HashSet::new();\n+ let mut frontier: Vec = specs\n .iter()\n- .filter_map(|s| CanonicalItemUrl::parse(s))\n+ .filter_map(|s| ItemId::parse(s))\n .collect();\n \n for _level in 0..depth {\n- let mut next_frontier: Vec = Vec::new();\n+ let mut next_frontier: Vec = Vec::new();\n for parent in &frontier {\n if let Some(children) = content.item_children.get(parent) {\n for child in children {\n@@ -76,16 +76,16 @@ pub fn resolve_scope_recursive(content: &ContentState, specs: &[String], depth:\n frontier = next_frontier;\n }\n \n- let mut out: Vec = visited.into_iter().collect();\n+ let mut out: Vec = visited.into_iter().collect();\n out.sort();\n out\n }\n \n /// Build connected-component rankings for an explicit set of item paths.\n /// Use this when scope comes from multiple parents (resolve_scope).\n-pub fn build_rankings_for_item_set(content: &ContentState, items_in_scope: &[CanonicalItemUrl]) -> ChildrenRankings {\n+pub fn build_rankings_for_item_set(content: &ContentState, items_in_scope: &[ItemId]) -> ChildrenRankings {\n let group = &content.ranking_group;\n- let mut items_in_scope: Vec = items_in_scope.to_vec();\n+ let mut items_in_scope: Vec = items_in_scope.to_vec();\n items_in_scope.sort();\n \n let scoped_idxs: Vec = items_in_scope\n@@ -127,7 +127,7 @@ pub fn build_rankings_for_item_set(content: &ContentState, items_in_scope: &[Can\n })\n .collect();\n \n- let mut unranked_items: Vec = isolate_local_idxs\n+ let mut unranked_items: Vec = isolate_local_idxs\n .into_iter()\n .filter_map(|li| local_to_global.get(li).copied())\n .filter_map(|idx| group.idx_to_item.get(idx).cloned())\n@@ -148,9 +148,9 @@ pub fn build_rankings_for_item_set(content: &ContentState, items_in_scope: &[Can\n \n /// Build connected-component rankings for direct children of parent_scope.\n /// Matches the HTML garden view: multiple components, isolates, no-vote items.\n-pub fn build_children_rankings(content: &ContentState, parent: &CanonicalItemUrl) -> ChildrenRankings {\n+pub fn build_children_rankings(content: &ContentState, parent: &ItemId) -> ChildrenRankings {\n let parent = parent.clone().normalized_storage();\n- let items: Vec = content\n+ let items: Vec = content\n .item_children\n .get(&parent)\n .map(|s| s.iter().cloned().collect())\n@@ -164,10 +164,13 @@ mod tests {\n use std::collections::{HashMap, HashSet};\n \n fn content_with_children(edges: &[(&str, &[&str])]) -> ContentState {\n- let mut item_children: HashMap> = HashMap::new();\n+ let mut item_children: HashMap> = HashMap::new();\n for (parent, children) in edges {\n- let parent = CanonicalItemUrl((*parent).to_string());\n- let set: HashSet = children.iter().map(|s| CanonicalItemUrl((*s).to_string())).collect();\n+ let parent = ItemId::parse(parent).unwrap();\n+ let set: HashSet = children\n+ .iter()\n+ .map(|s| ItemId::parse(s).unwrap())\n+ .collect();\n item_children.insert(parent, set);\n }\n ContentState {\n@@ -189,8 +192,8 @@ mod tests {\n ]);\n let out = resolve_one_scope(&content, \"models\");\n assert_eq!(out.len(), 2);\n- assert!(out.contains(&CanonicalItemUrl(\"https://slug.social/models/x\".to_string())));\n- assert!(out.contains(&CanonicalItemUrl(\"https://slug.social/models/y\".to_string())));\n+ assert!(out.contains(&ItemId::parse(\"https://slug.social/models/x\").unwrap()));\n+ assert!(out.contains(&ItemId::parse(\"https://slug.social/models/y\").unwrap()));\n }\n \n #[test]\n@@ -201,9 +204,9 @@ mod tests {\n ]);\n let out = resolve_scope(&content, &[\"a\".into(), \"b\".into()]);\n assert_eq!(out.len(), 4);\n- assert!(out.contains(&CanonicalItemUrl(\"https://slug.social/a/1\".to_string())));\n- assert!(out.contains(&CanonicalItemUrl(\"https://slug.social/a/2\".to_string())));\n- assert!(out.contains(&CanonicalItemUrl(\"https://slug.social/b/1\".to_string())));\n- assert!(out.contains(&CanonicalItemUrl(\"https://slug.social/b/2\".to_string())));\n+ assert!(out.contains(&ItemId::parse(\"https://slug.social/a/1\").unwrap()));\n+ assert!(out.contains(&ItemId::parse(\"https://slug.social/a/2\").unwrap()));\n+ assert!(out.contains(&ItemId::parse(\"https://slug.social/b/1\").unwrap()));\n+ assert!(out.contains(&ItemId::parse(\"https://slug.social/b/2\").unwrap()));\n }\n }\ndiff --git a/server/tests/basic.rs b/server/tests/basic.rs\nindex 17db86894f18d6b542d2943f4cedb0572b247171..c8efea2976e20b443d78a5d4c3b244bc4f2fd6da 100644\n--- a/server/tests/basic.rs\n+++ b/server/tests/basic.rs\n@@ -7,8 +7,15 @@ use slugsocial_server::{\n };\n \n \n+use slugsocial_server::path_types::ItemId;\n+\n use tempfile::TempDir;\n \n+#[inline]\n+fn item_id(s: &str) -> ItemId {\n+ ItemId::parse(s).unwrap()\n+}\n+\n fn ingest_event(ts: i64, raw: &str) -> Event {\n Event::Ingest(Ingest {\n ts,\n@@ -47,7 +54,7 @@ fn reducer_external_namespace_ranking() {\n let g = &content.ranking_group;\n assert_eq!(g.idx_to_item.len(), 2);\n \n- let parent = slugsocial_server::path_types::CanonicalItemUrl::parse(\"https://github.com/iss\").unwrap();\n+ let parent = slugsocial_server::path_types::ItemId::parse(\"https://github.com/iss\").unwrap();\n let children = slugsocial_server::scope_rank::build_children_rankings(content, &parent);\n assert_eq!(children.component_rankings.len(), 1);\n let names: Vec<&str> = children.component_rankings[0]\n@@ -77,9 +84,9 @@ fn reducer_and_ranking_linear_chain() {\n let mut group = state.public().ranking_group.clone();\n let ranked = ranked_items(&mut group, 20000, 1e-9);\n assert_eq!(ranked.len(), 3);\n- assert_eq!(ranked[0].item, \"https://slug.social/~/t/a\");\n- assert_eq!(ranked[1].item, \"https://slug.social/~/t/b\");\n- assert_eq!(ranked[2].item, \"https://slug.social/~/t/c\");\n+ assert_eq!(ranked[0].item.as_str(), \"https://slug.social/~/t/a\");\n+ assert_eq!(ranked[1].item.as_str(), \"https://slug.social/~/t/b\");\n+ assert_eq!(ranked[2].item.as_str(), \"https://slug.social/~/t/c\");\n }\n \n #[test]\n@@ -112,15 +119,15 @@ fn reducer_handles_item_and_body_from_ingest() {\n ));\n \n let content = state.public();\n- assert!(content.items.contains(\"https://slug.social/~/t/test-item\"));\n+ assert!(content.items.contains(&item_id(\"https://slug.social/~/t/test-item\")));\n assert_eq!(\n- content.item_bodies.get(\"https://slug.social/~/t/test-item\"),\n+ content.item_bodies.get(&item_id(\"https://slug.social/~/t/test-item\")),\n Some(&\"Description here\".to_string())\n );\n assert!(content\n .item_children\n- .get(\"https://slug.social/~/t\")\n- .map(|c| c.contains(\"https://slug.social/~/t/test-item\"))\n+ .get(&item_id(\"https://slug.social/~/t\"))\n+ .map(|c| c.contains(&item_id(\"https://slug.social/~/t/test-item\")))\n .unwrap_or(false));\n }\n \n@@ -136,12 +143,12 @@ fn reducer_indexes_item_threads_and_vote_thread() {\n state.apply_event(Event::Ingest(ev));\n \n let content = state.public();\n- let threads_for_insertion = content.item_threads.get(\"https://slug.social/~/sorts/insertion\").unwrap();\n+ let threads_for_insertion = content.item_threads.get(&item_id(\"https://slug.social/~/sorts/insertion\")).unwrap();\n assert!(threads_for_insertion.contains(\"sorting-hat\"));\n- let threads_for_mergesort = content.item_threads.get(\"https://slug.social/~/sorts/mergesort\").unwrap();\n+ let threads_for_mergesort = content.item_threads.get(&item_id(\"https://slug.social/~/sorts/mergesort\")).unwrap();\n assert!(threads_for_mergesort.contains(\"sorting-hat\"));\n \n- let vote = content.item_votes.get(\"https://slug.social/~/sorts/insertion\").unwrap().front().unwrap();\n+ let vote = content.item_votes.get(&item_id(\"https://slug.social/~/sorts/insertion\")).unwrap().front().unwrap();\n assert_eq!(vote.thread_tag, \"sorting-hat\");\n }\n \n@@ -155,8 +162,8 @@ fn reducer_aggregates_multiple_votes() {\n }\n \n let group = &state.public().ranking_group;\n- let a_idx = group.item_to_idx[\"https://slug.social/~/t/a\"];\n- let b_idx = group.item_to_idx[\"https://slug.social/~/t/b\"];\n+ let a_idx = group.item_to_idx[&item_id(\"https://slug.social/~/t/a\")];\n+ let b_idx = group.item_to_idx[&item_id(\"https://slug.social/~/t/b\")];\n \n // Should have accumulated edge weights in both directions.\n assert!(group.edges.contains_key(&(a_idx, b_idx)));\n@@ -232,7 +239,7 @@ fn ranking_dominant_item_wins() {\n let mut group = state.public().ranking_group.clone();\n let ranked = ranked_items(&mut group, 20000, 1e-9);\n \n- assert_eq!(ranked[0].item, \"https://slug.social/~/t/champion\");\n+ assert_eq!(ranked[0].item.as_str(), \"https://slug.social/~/t/champion\");\n assert!(ranked[0].score > ranked[1].score);\n }\n \n@@ -379,7 +386,7 @@ async fn full_workflow_reducer_and_ranking() {\n let ranked = ranked_items(&mut group, 20000, 1e-9);\n \n assert_eq!(ranked.len(), 2);\n- assert_eq!(ranked[0].item, \"https://slug.social/~/langs/rust\"); // Should win\n+ assert_eq!(ranked[0].item.as_str(), \"https://slug.social/~/langs/rust\"); // Should win\n assert!(ranked[0].score > ranked[1].score);\n }\n \n@@ -395,27 +402,27 @@ fn reducer_materializes_ancestor_path_segments() {\n \n // The intermediate path \"https://slug.social/~/ai-models/anthropic\" should appear as a child of \"https://slug.social/~/ai-models\".\n let content = state.public();\n- let ai_models_children = content.item_children.get(\"https://slug.social/~/ai-models\").expect(\"ai-models should have children\");\n+ let ai_models_children = content.item_children.get(&item_id(\"https://slug.social/~/ai-models\")).expect(\"ai-models should have children\");\n assert!(\n- ai_models_children.contains(\"https://slug.social/~/ai-models/anthropic\"),\n+ ai_models_children.contains(&item_id(\"https://slug.social/~/ai-models/anthropic\")),\n \"ai-models/anthropic should be a child of ai-models\"\n );\n \n // The leaf items should still be children of \"https://slug.social/~/ai-models/anthropic\".\n- let anthropic_children = content.item_children.get(\"https://slug.social/~/ai-models/anthropic\").expect(\"ai-models/anthropic should have children\");\n- assert!(anthropic_children.contains(\"https://slug.social/~/ai-models/anthropic/claude-opus\"));\n- assert!(anthropic_children.contains(\"https://slug.social/~/ai-models/anthropic/claude-sonnet\"));\n+ let anthropic_children = content.item_children.get(&item_id(\"https://slug.social/~/ai-models/anthropic\")).expect(\"ai-models/anthropic should have children\");\n+ assert!(anthropic_children.contains(&item_id(\"https://slug.social/~/ai-models/anthropic/claude-opus\")));\n+ assert!(anthropic_children.contains(&item_id(\"https://slug.social/~/ai-models/anthropic/claude-sonnet\")));\n \n // Root should contain \"https://slug.social/~/ai-models\".\n- let root_children = content.item_children.get(\"https://slug.social/~\").expect(\"root should have children\");\n- assert!(root_children.contains(\"https://slug.social/~/ai-models\"));\n+ let root_children = content.item_children.get(&item_id(\"https://slug.social/~\")).expect(\"root should have children\");\n+ assert!(root_children.contains(&item_id(\"https://slug.social/~/ai-models\")));\n \n // The phantom intermediates should NOT be in the items set (they weren't explicitly created).\n- assert!(!content.items.contains(\"https://slug.social/~/ai-models\"));\n- assert!(!content.items.contains(\"https://slug.social/~/ai-models/anthropic\"));\n+ assert!(!content.items.contains(&item_id(\"https://slug.social/~/ai-models\")));\n+ assert!(!content.items.contains(&item_id(\"https://slug.social/~/ai-models/anthropic\")));\n // But the leaf items should be.\n- assert!(content.items.contains(\"https://slug.social/~/ai-models/anthropic/claude-opus\"));\n- assert!(content.items.contains(\"https://slug.social/~/ai-models/anthropic/claude-sonnet\"));\n+ assert!(content.items.contains(&item_id(\"https://slug.social/~/ai-models/anthropic/claude-opus\")));\n+ assert!(content.items.contains(&item_id(\"https://slug.social/~/ai-models/anthropic/claude-sonnet\")));\n }\n \n #[test]\n@@ -469,9 +476,9 @@ fn ranking_repeated_votes_normalized() {\n \n // Same winner regardless of how many times voted.\n assert_eq!(ranked_once[0].item, ranked_many[0].item);\n- assert_eq!(ranked_once[0].item, \"https://slug.social/~/norm/a\");\n+ assert_eq!(ranked_once[0].item.as_str(), \"https://slug.social/~/norm/a\");\n assert_eq!(ranked_once[1].item, ranked_many[1].item);\n- assert_eq!(ranked_once[1].item, \"https://slug.social/~/norm/b\");\n+ assert_eq!(ranked_once[1].item.as_str(), \"https://slug.social/~/norm/b\");\n \n // Scores should be identical (normalization makes repeated votes idempotent).\n let eps = 1e-6;\n@@ -506,8 +513,8 @@ fn reducer_zero_zero_vote_ratio_normalizes_to_one_one() {\n \"~/t/a {a}\\n~/t/b {b}\\n~/t/a 0:0 ~/t/b {zero}\\n\",\n ));\n let group = &state.public().ranking_group;\n- let a_idx = group.item_to_idx[\"https://slug.social/~/t/a\"];\n- let b_idx = group.item_to_idx[\"https://slug.social/~/t/b\"];\n+ let a_idx = group.item_to_idx[&item_id(\"https://slug.social/~/t/a\")];\n+ let b_idx = group.item_to_idx[&item_id(\"https://slug.social/~/t/b\")];\n // 0:0 should normalize to 1:1 — both directions should have weight\n assert!(group.edges.contains_key(&(a_idx, b_idx)));\n assert!(group.edges.contains_key(&(b_idx, a_idx)));\n@@ -523,8 +530,8 @@ fn reducer_negative_ratio_clamped_to_zero() {\n let mut group = GroupState::new();\n group.apply_vote(slugsocial_server::reducer::VoteData {\n ts: 1,\n- a: slugsocial_server::path_types::CanonicalItemUrl(\"https://slug.social/~/t/a\".to_string()),\n- b: slugsocial_server::path_types::CanonicalItemUrl(\"https://slug.social/~/t/b\".to_string()),\n+ a: slugsocial_server::path_types::ItemId::parse(\"https://slug.social/~/t/a\").unwrap(),\n+ b: slugsocial_server::path_types::ItemId::parse(\"https://slug.social/~/t/b\").unwrap(),\n ratio_left: -5,\n ratio_right: -3,\n body: \"negative\".to_string(),\n@@ -534,8 +541,8 @@ fn reducer_negative_ratio_clamped_to_zero() {\n });\n assert_eq!(group.idx_to_item.len(), 2);\n // Both edges should exist (negatives clamped to 0, then 0:0 -> 1:1)\n- let a_idx = group.item_to_idx[\"https://slug.social/~/t/a\"];\n- let b_idx = group.item_to_idx[\"https://slug.social/~/t/b\"];\n+ let a_idx = group.item_to_idx[&item_id(\"https://slug.social/~/t/a\")];\n+ let b_idx = group.item_to_idx[&item_id(\"https://slug.social/~/t/b\")];\n assert!(group.edges.contains_key(&(a_idx, b_idx)));\n assert!(group.edges.contains_key(&(b_idx, a_idx)));\n }\n@@ -553,24 +560,24 @@ fn reducer_deep_path_ancestor_materialization_four_levels() {\n let content = state.public();\n let tilde_scope = content\n .item_children\n- .get(\"https://slug.social/~\")\n+ .get(&item_id(\"https://slug.social/~\"))\n .expect(\"~/ scope should have children\");\n- assert!(tilde_scope.contains(\"https://slug.social/~/a\"));\n+ assert!(tilde_scope.contains(&item_id(\"https://slug.social/~/a\")));\n \n- let a_children = content.item_children.get(\"https://slug.social/~/a\").expect(\"a should have children\");\n- assert!(a_children.contains(\"https://slug.social/~/a/b\"));\n+ let a_children = content.item_children.get(&item_id(\"https://slug.social/~/a\")).expect(\"a should have children\");\n+ assert!(a_children.contains(&item_id(\"https://slug.social/~/a/b\")));\n \n- let ab_children = content.item_children.get(\"https://slug.social/~/a/b\").expect(\"a/b should have children\");\n- assert!(ab_children.contains(\"https://slug.social/~/a/b/c\"));\n+ let ab_children = content.item_children.get(&item_id(\"https://slug.social/~/a/b\")).expect(\"a/b should have children\");\n+ assert!(ab_children.contains(&item_id(\"https://slug.social/~/a/b/c\")));\n \n- let abc_children = content.item_children.get(\"https://slug.social/~/a/b/c\").expect(\"a/b/c should have children\");\n- assert!(abc_children.contains(\"https://slug.social/~/a/b/c/d\"));\n+ let abc_children = content.item_children.get(&item_id(\"https://slug.social/~/a/b/c\")).expect(\"a/b/c should have children\");\n+ assert!(abc_children.contains(&item_id(\"https://slug.social/~/a/b/c/d\")));\n \n // Only the leaf should be in items set\n- assert!(content.items.contains(\"https://slug.social/~/a/b/c/d\"));\n- assert!(!content.items.contains(\"https://slug.social/~/a\"));\n- assert!(!content.items.contains(\"https://slug.social/~/a/b\"));\n- assert!(!content.items.contains(\"https://slug.social/~/a/b/c\"));\n+ assert!(content.items.contains(&item_id(\"https://slug.social/~/a/b/c/d\")));\n+ assert!(!content.items.contains(&item_id(\"https://slug.social/~/a\")));\n+ assert!(!content.items.contains(&item_id(\"https://slug.social/~/a/b\")));\n+ assert!(!content.items.contains(&item_id(\"https://slug.social/~/a/b/c\")));\n }\n \n // ============================================================================\n@@ -610,7 +617,7 @@ fn ranking_convergence_tolerance_triggers_early_exit() {\n // Very tight tolerance but huge max_iters — should still converge fast\n let ranked = ranked_items(&mut group, 1_000_000, 1e-15);\n assert_eq!(ranked.len(), 2);\n- assert_eq!(ranked[0].item, \"https://slug.social/~/t/a\");\n+ assert_eq!(ranked[0].item.as_str(), \"https://slug.social/~/t/a\");\n }\n \n // ============================================================================\n@@ -624,14 +631,14 @@ fn test_item_body_overwrite() {\n 1,\n \"~/t/x {first}\\n\",\n ));\n- assert_eq!(state.public().item_bodies.get(\"https://slug.social/~/t/x\"), Some(&\"first\".to_string()));\n+ assert_eq!(state.public().item_bodies.get(&item_id(\"https://slug.social/~/t/x\")), Some(&\"first\".to_string()));\n \n state.apply_event(ingest_event(\n 2,\n \"~/t/x {second}\\n\",\n ));\n assert_eq!(\n- state.public().item_bodies.get(\"https://slug.social/~/t/x\"),\n+ state.public().item_bodies.get(&item_id(\"https://slug.social/~/t/x\")),\n Some(&\"second\".to_string()),\n \"last writer should win for item bodies\"\n );\n@@ -645,9 +652,9 @@ fn test_empty_body_not_stored() {\n \"~/t/blank { }\\n\",\n ));\n let content = state.public();\n- assert!(content.items.contains(\"https://slug.social/~/t/blank\"), \"item should exist\");\n+ assert!(content.items.contains(&item_id(\"https://slug.social/~/t/blank\")), \"item should exist\");\n assert!(\n- !content.item_bodies.contains_key(\"https://slug.social/~/t/blank\"),\n+ !content.item_bodies.contains_key(&item_id(\"https://slug.social/~/t/blank\")),\n \"whitespace-only body should not be stored\"\n );\n }\n@@ -664,7 +671,7 @@ fn test_duplicate_items_across_ingests() {\n \"~/t/dup {second}\\n\",\n ));\n let content = state.public();\n- let count = content.items.iter().filter(|i| *i == \"https://slug.social/~/t/dup\").count();\n+ let count = content.items.iter().filter(|i| i.as_str() == \"https://slug.social/~/t/dup\").count();\n assert_eq!(count, 1, \"items set should deduplicate across ingests\");\n }\n \n@@ -736,7 +743,7 @@ fn test_thread_id_is_used_for_votes_and_indexes() {\n let content = state.public();\n let vote = content\n .item_votes\n- .get(\"https://slug.social/~/t/a\")\n+ .get(&item_id(\"https://slug.social/~/t/a\"))\n .unwrap()\n .front()\n .unwrap();\n@@ -745,7 +752,7 @@ fn test_thread_id_is_used_for_votes_and_indexes() {\n assert!(state\n .public()\n .item_threads\n- .get(\"https://slug.social/~/t/a\")\n+ .get(&item_id(\"https://slug.social/~/t/a\"))\n .is_some_and(|threads| threads.contains(\"first\")));\n }\n \n@@ -757,11 +764,11 @@ fn test_rank_history_created_for_voted_items() {\n \"~/t/a {a}\\n~/t/b {b}\\n~/t/a 3:1 ~/t/b {reason}\\n\",\n ));\n assert!(\n- state.public().rank_history.contains_key(\"https://slug.social/~/t/a\"),\n+ state.public().rank_history.contains_key(&item_id(\"https://slug.social/~/t/a\")),\n \"rank_history should have entry for voted item a\"\n );\n assert!(\n- state.public().rank_history.contains_key(\"https://slug.social/~/t/b\"),\n+ state.public().rank_history.contains_key(&item_id(\"https://slug.social/~/t/b\")),\n \"rank_history should have entry for voted item b\"\n );\n }\n@@ -774,7 +781,7 @@ fn test_rank_history_not_created_for_unvoted_items() {\n \"~/t/c {just a definition}\\n\",\n ));\n assert!(\n- !state.public().rank_history.contains_key(\"https://slug.social/~/t/c\"),\n+ !state.public().rank_history.contains_key(&item_id(\"https://slug.social/~/t/c\")),\n \"rank_history should NOT have entry for item with no votes\"\n );\n }\n@@ -786,7 +793,7 @@ fn test_rank_history_first_entry_delta_zero() {\n 1,\n \"~/t/a {a}\\n~/t/b {b}\\n~/t/a 3:1 ~/t/b {reason}\\n\",\n ));\n- let history_a = state.public().rank_history.get(\"https://slug.social/~/t/a\").unwrap();\n+ let history_a = state.public().rank_history.get(&item_id(\"https://slug.social/~/t/a\")).unwrap();\n assert_eq!(history_a.len(), 1);\n assert_eq!(\n history_a[0].scope_rank_delta, 0,\ndiff --git a/types/src/item_id.rs b/types/src/item_id.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..5fe9fad39c0d58e546c0a0162eba4348d64e0865\n--- /dev/null\n+++ b/types/src/item_id.rs\n@@ -0,0 +1,225 @@\n+//! Structural item identity for the reducer graph and ranking (vs presentation-only strings).\n+\n+use std::cmp::Ordering;\n+use std::fmt;\n+\n+use serde::{Deserialize, Deserializer, Serialize, Serializer};\n+\n+use crate::item_wire::{\n+ canonicalize_item, external_display_dash_prefix, normalize_slug_ontology_storage_url,\n+ SLUG_TILDE_ONTOLOGY_ROOT,\n+};\n+\n+/// Structural key for items in [`slug_types`] and the server reducer.\n+///\n+/// Wire / JSON uses the same single string as the former canonical item URL (via serde).\n+#[derive(Debug, Clone, Hash, PartialEq, Eq)]\n+pub enum ItemId {\n+ /// Tilde ontology root (`~/`); storage [`SLUG_TILDE_ONTOLOGY_ROOT`].\n+ Root,\n+ /// `https://slug.social/~/…` (non-root; normalized trailing path).\n+ Local(String),\n+ /// Normalized `http(s)://…` storage form, including non-`~/` paths on `slug.social`.\n+ Web(String),\n+ /// Raw key material that did not round-trip through [`Self::parse`] (historical edge case).\n+ Opaque(String),\n+}\n+\n+impl ItemId {\n+ pub fn parse(input: &str) -> Option {\n+ let c = normalize_slug_ontology_storage_url(&canonicalize_item(input));\n+ if c.is_empty() {\n+ return None;\n+ }\n+ if c == SLUG_TILDE_ONTOLOGY_ROOT {\n+ return Some(Self::Root);\n+ }\n+ if c.starts_with(\"https://slug.social/~/\") && c != SLUG_TILDE_ONTOLOGY_ROOT {\n+ return Some(Self::Local(c));\n+ }\n+ Some(Self::Web(c))\n+ }\n+\n+ /// Same as the old `ensure_item` fallback: use `s` verbatim as the map key.\n+ pub fn opaque(raw: String) -> Self {\n+ Self::Opaque(raw)\n+ }\n+\n+ pub fn ontology_root() -> Self {\n+ Self::Root\n+ }\n+\n+ /// Collapses legacy slug ontology root spellings so [`std::collections::HashMap`] keys match the graph.\n+ pub fn normalized_storage(self) -> Self {\n+ Self::parse(self.as_str()).unwrap_or(self)\n+ }\n+\n+ pub fn as_str(&self) -> &str {\n+ match self {\n+ ItemId::Root => SLUG_TILDE_ONTOLOGY_ROOT,\n+ ItemId::Local(s) | ItemId::Web(s) | ItemId::Opaque(s) => s,\n+ }\n+ }\n+\n+ pub fn to_storage_string(&self) -> String {\n+ self.as_str().to_string()\n+ }\n+\n+ pub fn tilde_tail(&self) -> Option<&str> {\n+ match self {\n+ ItemId::Root => Some(\"\"),\n+ ItemId::Local(s) => s.strip_prefix(\"https://slug.social/~/\"),\n+ ItemId::Web(s) | ItemId::Opaque(s) => {\n+ if let Some(tail) = s.strip_prefix(\"https://slug.social/~/\") {\n+ return Some(tail);\n+ }\n+ if s == SLUG_TILDE_ONTOLOGY_ROOT || s == \"https://slug.social/~/\" {\n+ return Some(\"\");\n+ }\n+ None\n+ }\n+ }\n+ }\n+\n+ /// HTTP garden tail after `~/` (empty at ontology root), or `None` if not under tilde ontology.\n+ pub fn tilde_http_tail(&self) -> Option {\n+ self.tilde_tail().map(str::to_owned)\n+ }\n+\n+ pub fn last_segment(&self) -> &str {\n+ let s = self.as_str();\n+ s.rsplit('/').find(|x| !x.is_empty()).unwrap_or(s)\n+ }\n+\n+ pub fn parent(&self) -> Option {\n+ match self {\n+ ItemId::Root => None,\n+ ItemId::Local(s) => {\n+ if s == SLUG_TILDE_ONTOLOGY_ROOT || s == \"https://slug.social/~/\" {\n+ return None;\n+ }\n+ let last_slash = s.rfind('/')?;\n+ let parent_str = &s[..last_slash];\n+ if parent_str.is_empty() {\n+ None\n+ } else {\n+ Self::parse(parent_str)\n+ }\n+ }\n+ ItemId::Web(s) | ItemId::Opaque(s) => {\n+ if s == SLUG_TILDE_ONTOLOGY_ROOT || s == \"https://slug.social/~/\" {\n+ return None;\n+ }\n+ if let Some(rest) = s.strip_prefix(\"https://slug.social/~/\") {\n+ if rest.is_empty() {\n+ return None;\n+ }\n+ let last_slash = s.rfind('/')?;\n+ let parent_str = &s[..last_slash];\n+ Self::parse(parent_str)\n+ } else if s.starts_with(\"https://\") {\n+ let rest = s.strip_prefix(\"https://\").unwrap();\n+ Self::parent_http_url(\"https://\", rest)\n+ } else if s.starts_with(\"http://\") {\n+ let rest = s.strip_prefix(\"http://\").unwrap();\n+ Self::parent_http_url(\"http://\", rest)\n+ } else {\n+ None\n+ }\n+ }\n+ }\n+ }\n+\n+ fn parent_http_url(scheme: &'static str, rest: &str) -> Option {\n+ let (host, path) = rest.split_once('/').map_or((rest, \"\"), |(h, p)| (h, p));\n+ let host = host.trim();\n+ let path = path.trim_end_matches('/');\n+ if path.is_empty() {\n+ return None;\n+ }\n+ let parent_path = path.rsplit_once('/').map(|(p, _)| p).unwrap_or(\"\");\n+ if parent_path.is_empty() {\n+ Self::parse(&format!(\"{scheme}{}\", host))\n+ } else {\n+ Self::parse(&format!(\"{scheme}{}/{}\", host, parent_path))\n+ }\n+ }\n+\n+ /// `-/` representation for external `https://…` items, `~/…` for slug ontology, else unchanged.\n+ pub fn display_path(&self) -> String {\n+ if let Some(tail) = self.tilde_tail() {\n+ if tail.is_empty() {\n+ return \"~/\".to_string();\n+ }\n+ return format!(\"~/{}\", tail);\n+ }\n+ let s = self.as_str();\n+ if let Some(tail) = s.strip_prefix(\"https://\") {\n+ if tail.starts_with(\"slug.social\") {\n+ return s.to_string();\n+ }\n+ return external_display_dash_prefix(tail);\n+ }\n+ if let Some(tail) = s.strip_prefix(\"http://\") {\n+ if tail.starts_with(\"slug.social\") {\n+ return s.to_string();\n+ }\n+ return external_display_dash_prefix(tail);\n+ }\n+ s.to_string()\n+ }\n+\n+ pub fn tilde_segments(&self) -> Vec<&str> {\n+ match self.tilde_tail() {\n+ Some(tail) if !tail.is_empty() => std::iter::once(\"~\")\n+ .chain(tail.split('/').filter(|s| !s.is_empty()))\n+ .collect(),\n+ Some(_) => vec![\"~\"],\n+ None => vec![],\n+ }\n+ }\n+\n+ /// Normalized URL string for HTTP fetch boundaries (external identities).\n+ pub fn to_wire_url(&self) -> String {\n+ self.to_storage_string()\n+ }\n+}\n+\n+impl PartialOrd for ItemId {\n+ fn partial_cmp(&self, other: &Self) -> Option {\n+ Some(self.cmp(other))\n+ }\n+}\n+\n+impl Ord for ItemId {\n+ fn cmp(&self, other: &Self) -> Ordering {\n+ self.as_str().cmp(other.as_str())\n+ }\n+}\n+\n+impl fmt::Display for ItemId {\n+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n+ self.as_str().fmt(f)\n+ }\n+}\n+\n+impl Serialize for ItemId {\n+ fn serialize(&self, serializer: S) -> Result\n+ where\n+ S: Serializer,\n+ {\n+ self.as_str().serialize(serializer)\n+ }\n+}\n+\n+impl<'de> Deserialize<'de> for ItemId {\n+ fn deserialize(deserializer: D) -> Result\n+ where\n+ D: Deserializer<'de>,\n+ {\n+ let s = String::deserialize(deserializer)?;\n+ ItemId::parse(&s).ok_or_else(|| {\n+ serde::de::Error::custom(format!(\"invalid item id: {s:?}\"))\n+ })\n+ }\n+}\ndiff --git a/types/src/item_wire.rs b/types/src/item_wire.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..20e3e7df77a74713c146c7e7222a809173723c7d\n--- /dev/null\n+++ b/types/src/item_wire.rs\n@@ -0,0 +1,184 @@\n+//! Wire normalization for item identity strings (`canonicalize_item`, path segments).\n+//! Split from `paths` so [`crate::item_id::ItemId`] can depend on this without import cycles.\n+\n+use crate::url_normalize::{host_preserves_dash_path_case, normalize_http_identity_url};\n+\n+/// Canonical absolute URL for the tilde ontology **root** (`~/` in UI).\n+pub const SLUG_TILDE_ONTOLOGY_ROOT: &str = \"https://slug.social/~\";\n+\n+/// Collapse legacy or parser variants of the ontology root to [`SLUG_TILDE_ONTOLOGY_ROOT`].\n+pub fn normalize_slug_ontology_storage_url(s: &str) -> String {\n+ if s == \"https://slug.social/~/\" {\n+ SLUG_TILDE_ONTOLOGY_ROOT.to_string()\n+ } else {\n+ s.to_string()\n+ }\n+}\n+\n+fn finalize_external_identity_url(s: String) -> String {\n+ if s.starts_with(\"https://slug.social/\") {\n+ return s;\n+ }\n+ let normalized = normalize_http_identity_url(&s).unwrap_or_else(|| s.clone());\n+ strip_redundant_root_slash(&normalized).unwrap_or(normalized)\n+}\n+\n+/// `url::Url` serializes bare hosts with a `/` path; we keep host-only items slash-free for stable\n+/// keys matching the pre-normalizer spellings.\n+fn strip_redundant_root_slash(s: &str) -> Option {\n+ let u = url::Url::parse(s).ok()?;\n+ if u.path() == \"/\" && u.query().is_none() && u.fragment().is_none() {\n+ let scheme = u.scheme();\n+ let host = u.host_str()?;\n+ return Some(match u.port() {\n+ Some(p) => format!(\"{scheme}://{host}:{p}\"),\n+ None => format!(\"{scheme}://{host}\"),\n+ });\n+ }\n+ None\n+}\n+\n+/// Ontology item reference → canonical absolute URL on the slug host.\n+pub fn canonicalize_item(input: &str) -> String {\n+ let s = input.trim();\n+ if s.is_empty() {\n+ return String::new();\n+ }\n+\n+ if let Some(rest) = s.strip_prefix(\"-/\") {\n+ let (host, tail) = rest\n+ .split_once('/')\n+ .map_or((rest, \"\"), |(h, t)| (h, t));\n+ let host = host.trim().to_lowercase();\n+ if host.is_empty() {\n+ return String::new();\n+ }\n+ let preserve_case = host_preserves_dash_path_case(&host);\n+ return if tail.is_empty() {\n+ finalize_external_identity_url(format!(\"https://{}\", host))\n+ } else {\n+ let path = tail\n+ .trim_start_matches('/')\n+ .trim_end_matches('/')\n+ .split('/')\n+ .filter_map(|seg| {\n+ let t = seg.trim();\n+ if t.is_empty() {\n+ None\n+ } else if preserve_case {\n+ Some(t.to_string())\n+ } else {\n+ Some(t.to_lowercase())\n+ }\n+ })\n+ .collect::>()\n+ .join(\"/\");\n+ finalize_external_identity_url(format!(\"https://{}/{}\", host, path))\n+ };\n+ }\n+\n+ if let Some(rest) = s.strip_prefix(\"https://\") {\n+ let (host, tail) = rest.split_once('/').map_or((rest, \"\"), |(h, t)| (h, t));\n+ let host = host.trim().to_lowercase();\n+ return finalize_external_identity_url(if tail.is_empty() {\n+ format!(\"https://{}\", host)\n+ } else {\n+ format!(\"https://{}/{}\", host, tail)\n+ });\n+ }\n+ if let Some(rest) = s.strip_prefix(\"http://\") {\n+ let (host, tail) = rest.split_once('/').map_or((rest, \"\"), |(h, t)| (h, t));\n+ let host = host.trim().to_lowercase();\n+ return finalize_external_identity_url(if tail.is_empty() {\n+ format!(\"http://{}\", host)\n+ } else {\n+ format!(\"http://{}/{}\", host, tail)\n+ });\n+ }\n+\n+ let is_tilde = s.starts_with(\"~/\");\n+ let rest = s.strip_prefix(\"~/\").or_else(|| s.strip_prefix(\"/\")).unwrap_or(s);\n+\n+ let tail = rest\n+ .split('/')\n+ .filter_map(|seg| {\n+ let t = seg.trim();\n+ if t.is_empty() {\n+ None\n+ } else {\n+ Some(t.to_lowercase())\n+ }\n+ })\n+ .collect::>()\n+ .join(\"/\");\n+\n+ if is_tilde {\n+ if tail.is_empty() {\n+ return SLUG_TILDE_ONTOLOGY_ROOT.to_string();\n+ }\n+ format!(\"https://slug.social/~/{}\", tail)\n+ } else if tail.is_empty() {\n+ \"https://slug.social\".to_string()\n+ } else {\n+ format!(\"https://slug.social/{}\", tail)\n+ }\n+}\n+\n+pub fn item_path_segments(input: &str) -> Vec {\n+ let canonical = canonicalize_item(input);\n+ if canonical.is_empty() {\n+ return vec![];\n+ }\n+\n+ if let Some(rest) = canonical.strip_prefix(\"https://\") {\n+ let (host, tail) = rest.split_once('/').map_or((rest, \"\"), |(h, t)| (h, t));\n+ let mut out = vec![format!(\"https://{}\", host)];\n+ out.extend(tail.split('/').filter(|s| !s.is_empty()).map(|s| s.to_string()));\n+ return out;\n+ }\n+ if let Some(rest) = canonical.strip_prefix(\"http://\") {\n+ let (host, tail) = rest.split_once('/').map_or((rest, \"\"), |(h, t)| (h, t));\n+ let mut out = vec![format!(\"http://{}\", host)];\n+ out.extend(tail.split('/').filter(|s| !s.is_empty()).map(|s| s.to_string()));\n+ return out;\n+ }\n+\n+ canonical\n+ .split('/')\n+ .filter(|s| !s.is_empty())\n+ .map(|s| s.to_string())\n+ .collect()\n+}\n+\n+pub fn item_parent_path(input: &str) -> Option {\n+ let segs = item_path_segments(input);\n+ if segs.len() <= 1 {\n+ return None;\n+ }\n+ Some(segs[..segs.len() - 1].join(\"/\"))\n+}\n+\n+pub(crate) fn external_display_dash_prefix(host_and_path: &str) -> String {\n+ let (host, path) = host_and_path\n+ .split_once('/')\n+ .map_or((host_and_path, \"\"), |(h, p)| (h, p));\n+ let host = host.trim().to_lowercase();\n+ let path = path\n+ .trim_end_matches('/')\n+ .split('/')\n+ .filter_map(|seg| {\n+ let t = seg.trim();\n+ if t.is_empty() {\n+ None\n+ } else {\n+ Some(t.to_lowercase())\n+ }\n+ })\n+ .collect::>()\n+ .join(\"/\");\n+ if path.is_empty() {\n+ format!(\"-/{}\", host)\n+ } else {\n+ format!(\"-/{}\", format!(\"{}/{}\", host, path))\n+ }\n+}\ndiff --git a/types/src/lib.rs b/types/src/lib.rs\nindex ceaa574cd32b7e7eb397c9a3191fcbc037b8c606..05bcfcfbf48f1dc7a4c05e32055bd12eb5cfd623 100644\n--- a/types/src/lib.rs\n+++ b/types/src/lib.rs\n@@ -1,14 +1,20 @@\n use serde::{Deserialize, Serialize};\n \n pub mod url_normalize;\n+pub mod item_wire;\n+pub mod item_id;\n pub mod paths;\n pub mod timeago;\n \n+pub use item_id::ItemId;\n+pub use item_wire::{\n+ canonicalize_item, item_parent_path, item_path_segments, normalize_slug_ontology_storage_url,\n+ SLUG_TILDE_ONTOLOGY_ROOT,\n+};\n pub use paths::{\n- canonicalize_item, canonicalize_tag, item_parent_path, item_path_segments, normalize_slug_ontology_storage_url,\n- CanonicalItemUrl, ForumThreadUrl, GardenItemUrl, RelativePath, SLUG_TILDE_ONTOLOGY_ROOT,\n+ canonicalize_tag, ForumThreadUrl, GardenItemUrl, RelativePath,\n room_id_from_route_segment, room_route_segment, ROOM_SHORT_ID_LEN,\n- TildeHttpPathTail, TildeOntologyPath, TildePath, tilde_http_path_to_canonical,\n+ TildeHttpPathTail, TildeOntologyPath, TildePath, tilde_http_path_to_item_id,\n };\n pub use url_normalize::normalize_http_identity_url;\n \ndiff --git a/types/src/paths.rs b/types/src/paths.rs\nindex 5c60e762c2ca74d79c74237097d5bcc02ba74af2..a1d066d83e7fbcefc27707dd682e54dbcec1cc56 100644\n--- a/types/src/paths.rs\n+++ b/types/src/paths.rs\n@@ -3,37 +3,23 @@\n //!\n //! ## String kinds (parse in this module only)\n //!\n-//! - **[`canonicalize_item`] / [`CanonicalItemUrl`]** — graph storage key and DSL form; tilde\n+//! - **[`canonicalize_item`] / [`crate::ItemId`]** — graph storage key and DSL form; tilde\n //! ontology root is always [`SLUG_TILDE_ONTOLOGY_ROOT`] (no `…/~/` trailing slash only).\n //! - **[`TildeHttpPathTail`]** — capture from `GET /~/*path` or `…/r/{short}{slug}/~/…` (the `*path` segment).\n //! - **`-/…` wire form** — external items; see [`canonicalize_item`] dash branch.\n //! - **[`GardenItemUrl`], [`ForumThreadUrl`]** — JSON / browser href surfaces.\n //! - **[`ROOM_SHORT_ID_LEN`] / [`room_route_segment`]** — `/r/{short}{slug}` vs wire `short/slug`.\n \n-use std::borrow::Borrow;\n use std::fmt;\n use std::ops::Deref;\n \n use serde::{Deserialize, Serialize};\n \n-use crate::url_normalize::{host_preserves_dash_path_case, normalize_http_identity_url};\n-\n-// ---------------------------------------------------------------------------\n-// Slug tilde ontology (single storage form for `~/`)\n-// ---------------------------------------------------------------------------\n-\n-/// Canonical absolute URL for the tilde ontology **root** (`~/` in UI). Used as the\n-/// `item_children` parent key for top-level items and must match [`CanonicalItemUrl::ontology_root`].\n-pub const SLUG_TILDE_ONTOLOGY_ROOT: &str = \"https://slug.social/~\";\n-\n-/// Collapse legacy or parser variants of the ontology root to [`SLUG_TILDE_ONTOLOGY_ROOT`].\n-pub fn normalize_slug_ontology_storage_url(s: &str) -> String {\n- if s == \"https://slug.social/~/\" {\n- SLUG_TILDE_ONTOLOGY_ROOT.to_string()\n- } else {\n- s.to_string()\n- }\n-}\n+use crate::item_id::ItemId;\n+pub use crate::item_wire::{\n+ canonicalize_item, item_parent_path, item_path_segments, normalize_slug_ontology_storage_url,\n+ SLUG_TILDE_ONTOLOGY_ROOT,\n+};\n \n // ---------------------------------------------------------------------------\n // Private room HTTP path (`/r/{short}{slug}`; wire id remains `short/slug`)\n@@ -84,310 +70,10 @@ pub fn canonicalize_tag(input: &str) -> String {\n input.trim().trim_start_matches('#').to_lowercase()\n }\n \n-fn finalize_external_identity_url(s: String) -> String {\n- if s.starts_with(\"https://slug.social/\") {\n- return s;\n- }\n- let normalized = normalize_http_identity_url(&s).unwrap_or_else(|| s.clone());\n- strip_redundant_root_slash(&normalized).unwrap_or(normalized)\n-}\n-\n-/// `url::Url` serializes bare hosts with a `/` path; we keep host-only items slash-free for stable\n-/// keys matching the pre-normalizer spellings.\n-fn strip_redundant_root_slash(s: &str) -> Option {\n- let u = url::Url::parse(s).ok()?;\n- if u.path() == \"/\" && u.query().is_none() && u.fragment().is_none() {\n- let scheme = u.scheme();\n- let host = u.host_str()?;\n- return Some(match u.port() {\n- Some(p) => format!(\"{scheme}://{host}:{p}\"),\n- None => format!(\"{scheme}://{host}\"),\n- });\n- }\n- None\n-}\n-\n-/// Ontology item reference → canonical absolute URL on the slug host.\n-pub fn canonicalize_item(input: &str) -> String {\n- let s = input.trim();\n- if s.is_empty() {\n- return String::new();\n- }\n-\n- // External scope: `-/host/path` is the universal alias for `https://host/path`.\n- if let Some(rest) = s.strip_prefix(\"-/\") {\n- let (host, tail) = rest\n- .split_once('/')\n- .map_or((rest, \"\"), |(h, t)| (h, t));\n- let host = host.trim().to_lowercase();\n- if host.is_empty() {\n- return String::new();\n- }\n- let preserve_case = host_preserves_dash_path_case(&host);\n- return if tail.is_empty() {\n- finalize_external_identity_url(format!(\"https://{}\", host))\n- } else {\n- let path = tail\n- .trim_start_matches('/')\n- .trim_end_matches('/')\n- .split('/')\n- .filter_map(|seg| {\n- let t = seg.trim();\n- if t.is_empty() {\n- None\n- } else if preserve_case {\n- Some(t.to_string())\n- } else {\n- Some(t.to_lowercase())\n- }\n- })\n- .collect::>()\n- .join(\"/\");\n- finalize_external_identity_url(format!(\"https://{}/{}\", host, path))\n- };\n- }\n-\n- if let Some(rest) = s.strip_prefix(\"https://\") {\n- let (host, tail) = rest.split_once('/').map_or((rest, \"\"), |(h, t)| (h, t));\n- let host = host.trim().to_lowercase();\n- return finalize_external_identity_url(if tail.is_empty() {\n- format!(\"https://{}\", host)\n- } else {\n- format!(\"https://{}/{}\", host, tail)\n- });\n- }\n- if let Some(rest) = s.strip_prefix(\"http://\") {\n- let (host, tail) = rest.split_once('/').map_or((rest, \"\"), |(h, t)| (h, t));\n- let host = host.trim().to_lowercase();\n- return finalize_external_identity_url(if tail.is_empty() {\n- format!(\"http://{}\", host)\n- } else {\n- format!(\"http://{}/{}\", host, tail)\n- });\n- }\n-\n- let is_tilde = s.starts_with(\"~/\");\n- let rest = s.strip_prefix(\"~/\").or_else(|| s.strip_prefix(\"/\")).unwrap_or(s);\n-\n- let tail = rest\n- .split('/')\n- .filter_map(|seg| {\n- let t = seg.trim();\n- if t.is_empty() {\n- None\n- } else {\n- Some(t.to_lowercase())\n- }\n- })\n- .collect::>()\n- .join(\"/\");\n-\n- if is_tilde {\n- if tail.is_empty() {\n- return SLUG_TILDE_ONTOLOGY_ROOT.to_string();\n- }\n- format!(\"https://slug.social/~/{}\", tail)\n- } else if tail.is_empty() {\n- \"https://slug.social\".to_string()\n- } else {\n- format!(\"https://slug.social/{}\", tail)\n- }\n-}\n-\n-pub fn item_path_segments(input: &str) -> Vec {\n- let canonical = canonicalize_item(input);\n- if canonical.is_empty() {\n- return vec![];\n- }\n-\n- if let Some(rest) = canonical.strip_prefix(\"https://\") {\n- let (host, tail) = rest.split_once('/').map_or((rest, \"\"), |(h, t)| (h, t));\n- let mut out = vec![format!(\"https://{}\", host)];\n- out.extend(tail.split('/').filter(|s| !s.is_empty()).map(|s| s.to_string()));\n- return out;\n- }\n- if let Some(rest) = canonical.strip_prefix(\"http://\") {\n- let (host, tail) = rest.split_once('/').map_or((rest, \"\"), |(h, t)| (h, t));\n- let mut out = vec![format!(\"http://{}\", host)];\n- out.extend(tail.split('/').filter(|s| !s.is_empty()).map(|s| s.to_string()));\n- return out;\n- }\n-\n- canonical\n- .split('/')\n- .filter(|s| !s.is_empty())\n- .map(|s| s.to_string())\n- .collect()\n-}\n-\n-pub fn item_parent_path(input: &str) -> Option {\n- let segs = item_path_segments(input);\n- if segs.len() <= 1 {\n- return None;\n- }\n- Some(segs[..segs.len() - 1].join(\"/\"))\n-}\n-\n-fn external_display_dash_prefix(host_and_path: &str) -> String {\n- let (host, path) = host_and_path\n- .split_once('/')\n- .map_or((host_and_path, \"\"), |(h, p)| (h, p));\n- let host = host.trim().to_lowercase();\n- let path = path\n- .trim_end_matches('/')\n- .split('/')\n- .filter_map(|seg| {\n- let t = seg.trim();\n- if t.is_empty() {\n- None\n- } else {\n- Some(t.to_lowercase())\n- }\n- })\n- .collect::>()\n- .join(\"/\");\n- if path.is_empty() {\n- format!(\"-/{}\", host)\n- } else {\n- format!(\"-/{}\", format!(\"{}/{}\", host, path))\n- }\n-}\n-\n // ---------------------------------------------------------------------------\n // Storage + input path newtypes\n // ---------------------------------------------------------------------------\n \n-/// Canonical item identifier as produced by [`canonicalize_item`].\n-///\n-/// Shared across all scopes; room is not embedded. Usually\n-/// `https://slug.social/~/…` or an external `http(s)://…` URL item.\n-#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]\n-pub struct CanonicalItemUrl(pub String);\n-\n-impl CanonicalItemUrl {\n- pub fn parse(input: &str) -> Option {\n- let c = canonicalize_item(input);\n- if c.is_empty() {\n- None\n- } else {\n- Some(Self(normalize_slug_ontology_storage_url(&c)))\n- }\n- }\n-\n- pub fn as_str(&self) -> &str {\n- &self.0\n- }\n-\n- /// Collapses legacy slug ontology root spellings so [`HashMap`] keys match the reducer graph.\n- pub fn normalized_storage(self) -> Self {\n- Self(normalize_slug_ontology_storage_url(self.as_str()))\n- }\n-\n- pub fn tilde_tail(&self) -> Option<&str> {\n- if let Some(tail) = self.0.strip_prefix(\"https://slug.social/~/\") {\n- return Some(tail);\n- }\n- if self.0 == SLUG_TILDE_ONTOLOGY_ROOT || self.0 == \"https://slug.social/~/\" {\n- return Some(\"\");\n- }\n- None\n- }\n-\n- pub fn last_segment(&self) -> &str {\n- self.0\n- .rsplit('/')\n- .find(|s| !s.is_empty())\n- .unwrap_or(self.0.as_str())\n- }\n-\n- pub fn ontology_root() -> Self {\n- Self(SLUG_TILDE_ONTOLOGY_ROOT.to_string())\n- }\n-\n- pub fn parent(&self) -> Option {\n- if self.tilde_tail().is_some() {\n- if self.tilde_tail().map(|t| t.is_empty()).unwrap_or(true) {\n- return None;\n- }\n- let last_slash = self.0.rfind('/')?;\n- let parent_str = &self.0[..last_slash];\n- if parent_str.is_empty() {\n- None\n- } else {\n- Some(Self(parent_str.to_string()))\n- }\n- } else if let Some(rest) = self.0.strip_prefix(\"https://\") {\n- Self::parent_http_url(\"https://\", rest)\n- } else if let Some(rest) = self.0.strip_prefix(\"http://\") {\n- Self::parent_http_url(\"http://\", rest)\n- } else {\n- None\n- }\n- }\n-\n- fn parent_http_url(scheme: &'static str, rest: &str) -> Option {\n- let (host, path) = rest.split_once('/').map_or((rest, \"\"), |(h, p)| (h, p));\n- let host = host.trim();\n- let path = path.trim_end_matches('/');\n- if path.is_empty() {\n- return None;\n- }\n- let parent_path = path.rsplit_once('/').map(|(p, _)| p).unwrap_or(\"\");\n- if parent_path.is_empty() {\n- Some(Self(format!(\"{scheme}{}\", host)))\n- } else {\n- Some(Self(format!(\"{scheme}{}/{}\", host, parent_path)))\n- }\n- }\n-\n- /// `-/` representation for external `https://…` items, `~/…` for slug ontology, else unchanged.\n- pub fn display_path(&self) -> String {\n- if let Some(tail) = self.tilde_tail() {\n- if tail.is_empty() {\n- return \"~/\".to_string();\n- }\n- return format!(\"~/{}\", tail);\n- }\n- if let Some(tail) = self.0.strip_prefix(\"https://\") {\n- if tail.starts_with(\"slug.social\") {\n- self.0.clone()\n- } else {\n- external_display_dash_prefix(tail)\n- }\n- } else if let Some(tail) = self.0.strip_prefix(\"http://\") {\n- if tail.starts_with(\"slug.social\") {\n- self.0.clone()\n- } else {\n- external_display_dash_prefix(tail)\n- }\n- } else {\n- self.0.clone()\n- }\n- }\n-\n- pub fn tilde_segments(&self) -> Vec<&str> {\n- match self.tilde_tail() {\n- Some(tail) if !tail.is_empty() => {\n- std::iter::once(\"~\")\n- .chain(tail.split('/').filter(|s| !s.is_empty()))\n- .collect()\n- }\n- Some(_) => vec![\"~\"],\n- None => vec![],\n- }\n- }\n-\n- /// `~/…` list label for ontology items (paths index, CLI).\n- pub fn tilde_list_label(&self) -> TildeOntologyPath {\n- TildeOntologyPath::from_stored(self)\n- }\n-\n- /// Absolute href for JSON/RPC and browsers for this stored id in `room`.\n- pub fn json_href(&self, room_wire: &str) -> GardenItemUrl {\n- GardenItemUrl::from_stored(self, room_wire)\n- }\n-}\n-\n /// HTTP route capture: path segment after `~/` in `GET /~/*path` or `…/r/{short}{slug}/~/…` (empty = ontology root).\n #[derive(Debug, Clone, PartialEq, Eq, Hash)]\n pub struct TildeHttpPathTail(pub String);\n@@ -401,13 +87,13 @@ impl TildeHttpPathTail {\n &self.0\n }\n \n- pub fn to_canonical(&self) -> CanonicalItemUrl {\n- tilde_http_path_to_canonical(self.as_str())\n+ pub fn to_item_id(&self) -> ItemId {\n+ tilde_http_path_to_item_id(self.as_str())\n }\n }\n \n-/// Map the router's tilde tail (e.g. `topic/a`, or empty for root) to a [`CanonicalItemUrl`].\n-pub fn tilde_http_path_to_canonical(path_segment: &str) -> CanonicalItemUrl {\n+/// Map the router's tilde tail (e.g. `topic/a`, or empty for root) to an [`ItemId`].\n+pub fn tilde_http_path_to_item_id(path_segment: &str) -> ItemId {\n let p = path_segment.trim_start_matches('/');\n let raw = if p.starts_with(\"http://\") || p.starts_with(\"https://\") {\n p.to_string()\n@@ -416,37 +102,7 @@ pub fn tilde_http_path_to_canonical(path_segment: &str) -> CanonicalItemUrl {\n } else {\n format!(\"~/{}\", p)\n };\n- CanonicalItemUrl::parse(&raw).unwrap_or_else(|| CanonicalItemUrl::ontology_root())\n-}\n-\n-impl fmt::Display for CanonicalItemUrl {\n- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n- self.0.fmt(f)\n- }\n-}\n-\n-impl Borrow for CanonicalItemUrl {\n- fn borrow(&self) -> &str {\n- &self.0\n- }\n-}\n-\n-impl PartialEq for CanonicalItemUrl {\n- fn eq(&self, other: &str) -> bool {\n- self.0 == other\n- }\n-}\n-\n-impl PartialEq<&str> for CanonicalItemUrl {\n- fn eq(&self, other: &&str) -> bool {\n- self.0 == *other\n- }\n-}\n-\n-impl PartialEq for CanonicalItemUrl {\n- fn eq(&self, other: &String) -> bool {\n- &self.0 == other\n- }\n+ ItemId::parse(&raw).unwrap_or_else(ItemId::ontology_root)\n }\n \n #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]\n@@ -468,8 +124,8 @@ impl TildePath {\n &self.0\n }\n \n- pub fn canonicalize(&self) -> Option {\n- CanonicalItemUrl::parse(&self.0)\n+ pub fn canonicalize(&self) -> Option {\n+ ItemId::parse(&self.0)\n }\n }\n \n@@ -496,7 +152,7 @@ impl RelativePath {\n &self.0\n }\n \n- pub fn join_under_ontology_root(&self, root: &CanonicalItemUrl) -> Option {\n+ pub fn join_under_ontology_root(&self, root: &ItemId) -> Option {\n let base = root.tilde_tail()?;\n let joined = if base.is_empty() {\n if self.0.is_empty() {\n@@ -509,7 +165,7 @@ impl RelativePath {\n } else {\n format!(\"~/{}/{}\", base.trim_end_matches('/'), self.0)\n };\n- CanonicalItemUrl::parse(&joined)\n+ ItemId::parse(&joined)\n }\n }\n \n@@ -545,14 +201,17 @@ impl GardenItemUrl {\n self.0\n }\n \n- /// Stored canonical id + RPC `room` field (`\"public\"` or `\"short/slug\"`).\n- pub fn from_stored(stored: &CanonicalItemUrl, room_wire: &str) -> Self {\n- Self(garden_href_string(stored.as_str(), room_wire))\n+ /// Stored item id + RPC `room` field (`\"public\"` or `\"short/slug\"`).\n+ pub fn from_stored(stored: &ItemId, room_wire: &str) -> Self {\n+ Self(garden_href_string(stored, room_wire))\n }\n \n /// Like [`Self::from_stored`] but accepts a string that may already be canonical.\n pub fn from_storage_str(stored: &str, room_wire: &str) -> Self {\n- Self(garden_href_string(stored, room_wire))\n+ let Some(id) = ItemId::parse(stored) else {\n+ return Self(api_path_or_url(stored));\n+ };\n+ Self(garden_href_string(&id, room_wire))\n }\n }\n \n@@ -570,18 +229,15 @@ impl Deref for GardenItemUrl {\n }\n }\n \n-fn garden_href_string(item: &str, room_wire: &str) -> String {\n+fn garden_href_string(c: &ItemId, room_wire: &str) -> String {\n let room = room_wire.trim();\n if room.is_empty() || room == \"public\" {\n- return api_path_or_url(item);\n+ return api_path_or_url(c.as_str());\n }\n let Some(room_seg) = room_route_segment(room) else {\n- return api_path_or_url(item);\n- };\n- let Some(c) = CanonicalItemUrl::parse(item) else {\n- return api_path_or_url(item);\n+ return api_path_or_url(c.as_str());\n };\n- let root = CanonicalItemUrl::ontology_root();\n+ let root = ItemId::ontology_root();\n let item_norm = c.as_str().trim_end_matches('/');\n let root_norm = root.as_str().trim_end_matches('/');\n if let Some(tail) = c.tilde_tail() {\n@@ -600,7 +256,7 @@ fn garden_href_string(item: &str, room_wire: &str) -> String {\n let tail = tail.strip_prefix(\"-/\").unwrap_or(tail.as_str());\n return format!(\"https://slug.social/r/{room_seg}/-/{tail}\");\n }\n- api_path_or_url(item)\n+ api_path_or_url(c.as_str())\n }\n \n /// Forum thread URL for JSON (`/t/…` or `/r/…/t/…` on slug.social).\n@@ -650,7 +306,7 @@ impl Deref for ForumThreadUrl {\n pub struct TildeOntologyPath(pub String);\n \n impl TildeOntologyPath {\n- pub fn from_stored(c: &CanonicalItemUrl) -> Self {\n+ pub fn from_stored(c: &ItemId) -> Self {\n Self(c.display_path())\n }\n \n@@ -679,22 +335,22 @@ mod tests {\n \n #[test]\n fn canonical_parent_deep() {\n- let c = CanonicalItemUrl::parse(\"~/a/b/c\").unwrap();\n+ let c = ItemId::parse(\"~/a/b/c\").unwrap();\n assert_eq!(c.parent().unwrap().as_str(), \"https://slug.social/~/a/b\");\n }\n \n #[test]\n fn canonical_parent_one_level() {\n- let c = CanonicalItemUrl::parse(\"~/a\").unwrap();\n+ let c = ItemId::parse(\"~/a\").unwrap();\n assert_eq!(c.parent().unwrap().as_str(), \"https://slug.social/~\");\n }\n \n #[test]\n fn canonical_parent_root_is_none() {\n- let root = CanonicalItemUrl::parse(\"~/\").unwrap();\n+ let root = ItemId::parse(\"~/\").unwrap();\n assert!(root.parent().is_none());\n assert_eq!(root.as_str(), SLUG_TILDE_ONTOLOGY_ROOT);\n- assert_eq!(root, CanonicalItemUrl::ontology_root());\n+ assert_eq!(root, ItemId::ontology_root());\n }\n \n #[test]\n@@ -705,49 +361,49 @@ mod tests {\n SLUG_TILDE_ONTOLOGY_ROOT.to_string()\n );\n assert_eq!(\n- CanonicalItemUrl::parse(\"https://slug.social/~/\")\n+ ItemId::parse(\"https://slug.social/~/\")\n .unwrap()\n .as_str(),\n SLUG_TILDE_ONTOLOGY_ROOT\n );\n- let legacy = CanonicalItemUrl(\"https://slug.social/~/\".to_string());\n+ let legacy = ItemId::opaque(\"https://slug.social/~/\".to_string());\n assert_eq!(legacy.normalized_storage().as_str(), SLUG_TILDE_ONTOLOGY_ROOT);\n }\n \n #[test]\n fn tilde_http_path_tail_maps_router_segment() {\n assert_eq!(\n- TildeHttpPathTail::new(\"\").to_canonical(),\n- CanonicalItemUrl::ontology_root()\n+ TildeHttpPathTail::new(\"\").to_item_id(),\n+ ItemId::ontology_root()\n );\n assert_eq!(\n- tilde_http_path_to_canonical(\"topic/x\").as_str(),\n+ tilde_http_path_to_item_id(\"topic/x\").as_str(),\n \"https://slug.social/~/topic/x\"\n );\n }\n \n #[test]\n fn display_path_slug_ontology_root() {\n- let r = CanonicalItemUrl::ontology_root();\n+ let r = ItemId::ontology_root();\n assert_eq!(r.display_path(), \"~/\");\n assert_eq!(r.tilde_tail(), Some(\"\"));\n }\n \n #[test]\n fn tilde_segments_deep() {\n- let c = CanonicalItemUrl::parse(\"~/a/b\").unwrap();\n+ let c = ItemId::parse(\"~/a/b\").unwrap();\n assert_eq!(c.tilde_segments(), vec![\"~\", \"a\", \"b\"]);\n }\n \n #[test]\n fn tilde_segments_root() {\n- let c = CanonicalItemUrl::parse(\"~/\").unwrap();\n+ let c = ItemId::parse(\"~/\").unwrap();\n assert_eq!(c.tilde_segments(), vec![\"~\"]);\n }\n \n #[test]\n fn tilde_segments_non_ontology_is_empty() {\n- let c = CanonicalItemUrl::parse(\"https://example.com/foo\").unwrap();\n+ let c = ItemId::parse(\"https://example.com/foo\").unwrap();\n assert_eq!(c.tilde_segments(), Vec::<&str>::new());\n }\n \n@@ -831,28 +487,28 @@ mod tests {\n }\n \n #[test]\n- fn canonical_item_url_parent_external_strips_last_segment() {\n- let c = CanonicalItemUrl::parse(\"https://spotify.com/track/1\").unwrap();\n+ fn item_id_parent_external_strips_last_segment() {\n+ let c = ItemId::parse(\"https://spotify.com/track/1\").unwrap();\n assert_eq!(\n c.parent().unwrap().as_str(),\n \"https://spotify.com/track\"\n );\n assert_eq!(\n- CanonicalItemUrl::parse(\"https://github.com/iss/1\")\n+ ItemId::parse(\"https://github.com/iss/1\")\n .unwrap()\n .parent()\n .unwrap()\n .as_str(),\n \"https://github.com/iss\"\n );\n- assert!(CanonicalItemUrl::parse(\"https://github.com\").unwrap().parent().is_none());\n+ assert!(ItemId::parse(\"https://github.com\").unwrap().parent().is_none());\n }\n \n #[test]\n fn display_path_roundtrips_dash_and_tilde() {\n- let ext = CanonicalItemUrl::parse(\"https://GitHub.com/org/Issue\").unwrap();\n+ let ext = ItemId::parse(\"https://GitHub.com/org/Issue\").unwrap();\n assert_eq!(ext.display_path(), \"-/github.com/org/issue\");\n- let tilde = CanonicalItemUrl::parse(\"~/Rust/Doc\").unwrap();\n+ let tilde = ItemId::parse(\"~/Rust/Doc\").unwrap();\n assert_eq!(tilde.display_path(), \"~/rust/doc\");\n }\n \n","role":"user"}],"model":"~x-ai/grok-latest"}