build loro storage layer primitives

Amolith created

Change summary

Cargo.lock     | 859 ++++++++++++++++++++++++++++++++++++++++++++++++++-
Cargo.toml     |   3 
src/db.rs      | 807 ++++++++++++++++++++++++++++++++++--------------
src/migrate.rs | 203 -----------
4 files changed, 1,412 insertions(+), 460 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -2,6 +2,19 @@
 # It is not intended for manual editing.
 version = 4
 
+[[package]]
+name = "ahash"
+version = "0.8.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
+dependencies = [
+ "cfg-if",
+ "getrandom 0.3.4",
+ "once_cell",
+ "version_check",
+ "zerocopy",
+]
+
 [[package]]
 name = "aho-corasick"
 version = "1.1.4"
@@ -76,6 +89,33 @@ version = "1.0.102"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
 
+[[package]]
+name = "append-only-bytes"
+version = "0.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac436601d6bdde674a0d7fb593e829ffe7b3387c351b356dd20e2d40f5bf3ee5"
+
+[[package]]
+name = "arbitrary"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
+dependencies = [
+ "derive_arbitrary",
+]
+
+[[package]]
+name = "arrayvec"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
+
+[[package]]
+name = "arref"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ccd462b64c3c72f1be8305905a85d85403d768e8690c9b8bd3b9009a5761679"
+
 [[package]]
 name = "assert_cmd"
 version = "2.1.2"
@@ -91,6 +131,15 @@ dependencies = [
  "wait-timeout",
 ]
 
+[[package]]
+name = "atomic-polyfill"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4"
+dependencies = [
+ "critical-section",
+]
+
 [[package]]
 name = "autocfg"
 version = "1.5.0"
@@ -103,6 +152,24 @@ version = "2.11.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
 
+[[package]]
+name = "bitmaps"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "031043d04099746d8db04daf1fa424b2bc8bd69d92b25962dcde24da39ab64a2"
+dependencies = [
+ "typenum",
+]
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
 [[package]]
 name = "bstr"
 version = "1.12.1"
@@ -120,6 +187,18 @@ version = "3.20.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
 
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "bytes"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+
 [[package]]
 name = "cc"
 version = "1.2.56"
@@ -175,10 +254,10 @@ version = "4.5.55"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
 dependencies = [
- "heck",
+ "heck 0.5.0",
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -187,6 +266,15 @@ version = "1.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
 
+[[package]]
+name = "cobs"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
+dependencies = [
+ "thiserror 2.0.18",
+]
+
 [[package]]
 name = "colorchoice"
 version = "1.0.4"
@@ -210,6 +298,21 @@ version = "0.8.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
 
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "critical-section"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
+
 [[package]]
 name = "crossterm"
 version = "0.29.0"
@@ -233,12 +336,84 @@ dependencies = [
  "winapi",
 ]
 
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "darling"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
+dependencies = [
+ "darling_core",
+ "darling_macro",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
+dependencies = [
+ "fnv",
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
+dependencies = [
+ "darling_core",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "derive_arbitrary"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "diff"
+version = "0.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
+
 [[package]]
 name = "difflib"
 version = "0.4.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
 
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
 [[package]]
 name = "document-features"
 version = "0.2.12"
@@ -248,6 +423,66 @@ dependencies = [
  "litrs",
 ]
 
+[[package]]
+name = "either"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
+
+[[package]]
+name = "embedded-io"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced"
+
+[[package]]
+name = "embedded-io"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
+
+[[package]]
+name = "ensure-cov"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33753185802e107b8fa907192af1f0eca13b1fb33327a59266d650fef29b2b4e"
+
+[[package]]
+name = "enum-as-inner"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9720bba047d567ffc8a3cba48bf19126600e249ab7f128e9233e6376976a116"
+dependencies = [
+ "heck 0.4.1",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "enum-as-inner"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc"
+dependencies = [
+ "heck 0.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "enum_dispatch"
+version = "0.3.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd"
+dependencies = [
+ "once_cell",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
 [[package]]
 name = "equivalent"
 version = "1.0.2"
@@ -264,18 +499,6 @@ dependencies = [
  "windows-sys",
 ]
 
-[[package]]
-name = "fallible-iterator"
-version = "0.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
-
-[[package]]
-name = "fallible-streaming-iterator"
-version = "0.1.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
-
 [[package]]
 name = "fastrand"
 version = "2.3.0"
@@ -297,12 +520,82 @@ dependencies = [
  "num-traits",
 ]
 
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
 [[package]]
 name = "foldhash"
 version = "0.1.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
 
+[[package]]
+name = "generator"
+version = "0.8.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "libc",
+ "log",
+ "rustversion",
+ "windows-link",
+ "windows-result",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "generic-btree"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a0c1bce85c110ab718fd139e0cc89c51b63bd647b14a767e24bdfc77c83df79b"
+dependencies = [
+ "arref",
+ "heapless 0.9.2",
+ "itertools 0.11.0",
+ "loro-thunderdome",
+ "proc-macro2",
+ "rustc-hash",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "wasi",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+ "wasip2",
+]
+
 [[package]]
 name = "getrandom"
 version = "0.4.1"
@@ -316,6 +609,24 @@ dependencies = [
  "wasip3",
 ]
 
+[[package]]
+name = "hash32"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67"
+dependencies = [
+ "byteorder",
+]
+
+[[package]]
+name = "hash32"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606"
+dependencies = [
+ "byteorder",
+]
+
 [[package]]
 name = "hashbrown"
 version = "0.15.5"
@@ -332,14 +643,45 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
 
 [[package]]
-name = "hashlink"
-version = "0.10.0"
+name = "heapless"
+version = "0.7.17"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
+checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f"
 dependencies = [
- "hashbrown 0.15.5",
+ "atomic-polyfill",
+ "hash32 0.2.1",
+ "rustc_version",
+ "serde",
+ "spin",
+ "stable_deref_trait",
+]
+
+[[package]]
+name = "heapless"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad"
+dependencies = [
+ "hash32 0.3.1",
+ "stable_deref_trait",
+]
+
+[[package]]
+name = "heapless"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2af2455f757db2b292a9b1768c4b70186d443bcb3b316252d6b540aec1cd89ed"
+dependencies = [
+ "hash32 0.3.1",
+ "stable_deref_trait",
 ]
 
+[[package]]
+name = "heck"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
+
 [[package]]
 name = "heck"
 version = "0.5.0"
@@ -376,6 +718,27 @@ version = "2.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
 
+[[package]]
+name = "ident_case"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+
+[[package]]
+name = "im"
+version = "15.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0acd33ff0285af998aaf9b57342af478078f53492322fafc47450e09397e0e9"
+dependencies = [
+ "bitmaps",
+ "rand_core 0.6.4",
+ "rand_xoshiro",
+ "serde",
+ "sized-chunks",
+ "typenum",
+ "version_check",
+]
+
 [[package]]
 name = "indexmap"
 version = "2.13.0"
@@ -394,6 +757,24 @@ version = "1.70.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
 
+[[package]]
+name = "itertools"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itertools"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
+dependencies = [
+ "either",
+]
+
 [[package]]
 name = "itoa"
 version = "1.0.17"
@@ -410,6 +791,18 @@ dependencies = [
  "wasm-bindgen",
 ]
 
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+
+[[package]]
+name = "leb128"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67"
+
 [[package]]
 name = "leb128fmt"
 version = "0.1.0"
@@ -422,17 +815,6 @@ version = "0.2.182"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
 
-[[package]]
-name = "libsqlite3-sys"
-version = "0.32.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fbb8270bb4060bd76c6e96f20c52d80620f1d82a3470885694e41e0f81ef6fe7"
-dependencies = [
- "cc",
- "pkg-config",
- "vcpkg",
-]
-
 [[package]]
 name = "linux-raw-sys"
 version = "0.12.1"
@@ -460,18 +842,274 @@ version = "0.4.29"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
 
+[[package]]
+name = "loom"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca"
+dependencies = [
+ "cfg-if",
+ "generator",
+ "scoped-tls",
+ "serde",
+ "serde_json",
+ "tracing",
+ "tracing-subscriber",
+]
+
+[[package]]
+name = "loro"
+version = "1.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d75216d8f99725531a30f7b00901ee154a4f8a9b7f125bfe032e197d4c7ffb8c"
+dependencies = [
+ "enum-as-inner 0.6.1",
+ "generic-btree",
+ "loro-common",
+ "loro-delta",
+ "loro-internal",
+ "loro-kv-store",
+ "rustc-hash",
+ "tracing",
+]
+
+[[package]]
+name = "loro-common"
+version = "1.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70363ea05a9c507fd9d58b65dc414bf515f636d69d8ab53e50ecbe8d27eef90c"
+dependencies = [
+ "arbitrary",
+ "enum-as-inner 0.6.1",
+ "leb128",
+ "loro-rle",
+ "nonmax",
+ "rustc-hash",
+ "serde",
+ "serde_columnar",
+ "serde_json",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "loro-delta"
+version = "1.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8eafa788a72c1cbf0b7dc08a862cd7cc31b96d99c2ef749cdc94c2330f9494d3"
+dependencies = [
+ "arrayvec",
+ "enum-as-inner 0.5.1",
+ "generic-btree",
+ "heapless 0.8.0",
+]
+
+[[package]]
+name = "loro-internal"
+version = "1.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f447044ec3d3ba572623859add3334bd87b84340ee5fdf00315bfee0e3ad3e3f"
+dependencies = [
+ "append-only-bytes",
+ "arref",
+ "bytes",
+ "either",
+ "ensure-cov",
+ "enum-as-inner 0.6.1",
+ "enum_dispatch",
+ "generic-btree",
+ "getrandom 0.2.17",
+ "im",
+ "itertools 0.12.1",
+ "leb128",
+ "loom",
+ "loro-common",
+ "loro-delta",
+ "loro-kv-store",
+ "loro-rle",
+ "loro_fractional_index",
+ "md5",
+ "nonmax",
+ "num",
+ "num-traits",
+ "once_cell",
+ "parking_lot",
+ "pest",
+ "pest_derive",
+ "postcard",
+ "pretty_assertions",
+ "rand 0.8.5",
+ "rustc-hash",
+ "serde",
+ "serde_columnar",
+ "serde_json",
+ "smallvec",
+ "thiserror 1.0.69",
+ "thread_local",
+ "tracing",
+ "wasm-bindgen",
+ "xxhash-rust",
+]
+
+[[package]]
+name = "loro-kv-store"
+version = "1.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78beebc933a33c26495c9a98f05b38bc0a4c0a337ecfbd3146ce1f9437eec71f"
+dependencies = [
+ "bytes",
+ "ensure-cov",
+ "loro-common",
+ "lz4_flex",
+ "once_cell",
+ "quick_cache",
+ "rustc-hash",
+ "tracing",
+ "xxhash-rust",
+]
+
+[[package]]
+name = "loro-rle"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76400c3eea6bb39b013406acce964a8db39311534e308286c8d8721baba8ee20"
+dependencies = [
+ "append-only-bytes",
+ "num",
+ "smallvec",
+]
+
+[[package]]
+name = "loro-thunderdome"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f3d053a135388e6b1df14e8af1212af5064746e9b87a06a345a7a779ee9695a"
+
+[[package]]
+name = "loro_fractional_index"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "427c8ea186958094052b971fe7e322a934b034c3bf62f0458ccea04fcd687ba1"
+dependencies = [
+ "once_cell",
+ "rand 0.8.5",
+ "serde",
+]
+
+[[package]]
+name = "lz4_flex"
+version = "0.11.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08ab2867e3eeeca90e844d1940eab391c9dc5228783db2ed999acbc0a9ed375a"
+dependencies = [
+ "twox-hash",
+]
+
+[[package]]
+name = "matchers"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
+dependencies = [
+ "regex-automata",
+]
+
+[[package]]
+name = "md5"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"
+
 [[package]]
 name = "memchr"
 version = "2.8.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
 
+[[package]]
+name = "nonmax"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51"
+
 [[package]]
 name = "normalize-line-endings"
 version = "0.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be"
 
+[[package]]
+name = "nu-ansi-term"
+version = "0.50.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "num"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
+dependencies = [
+ "num-bigint",
+ "num-complex",
+ "num-integer",
+ "num-iter",
+ "num-rational",
+ "num-traits",
+]
+
+[[package]]
+name = "num-bigint"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-complex"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-integer"
+version = "0.1.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-iter"
+version = "0.1.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
+dependencies = [
+ "autocfg",
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-rational"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
+dependencies = [
+ "num-bigint",
+ "num-integer",
+ "num-traits",
+]
+
 [[package]]
 name = "num-traits"
 version = "0.2.19"
@@ -517,10 +1155,75 @@ dependencies = [
 ]
 
 [[package]]
-name = "pkg-config"
-version = "0.3.32"
+name = "pest"
+version = "2.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662"
+dependencies = [
+ "memchr",
+ "ucd-trie",
+]
+
+[[package]]
+name = "pest_derive"
+version = "2.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77"
+dependencies = [
+ "pest",
+ "pest_generator",
+]
+
+[[package]]
+name = "pest_generator"
+version = "2.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f"
+dependencies = [
+ "pest",
+ "pest_meta",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "pest_meta"
+version = "2.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220"
+dependencies = [
+ "pest",
+ "sha2",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "postcard"
+version = "1.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
+dependencies = [
+ "cobs",
+ "embedded-io 0.4.0",
+ "embedded-io 0.6.1",
+ "heapless 0.7.17",
+ "serde",
+]
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
 
 [[package]]
 name = "predicates"
@@ -552,6 +1255,16 @@ dependencies = [
  "termtree",
 ]
 
+[[package]]
+name = "pretty_assertions"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d"
+dependencies = [
+ "diff",
+ "yansi",
+]
+
 [[package]]
 name = "prettyplease"
 version = "0.2.37"
@@ -559,7 +1272,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
 dependencies = [
  "proc-macro2",
- "syn",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -571,6 +1284,18 @@ dependencies = [
  "unicode-ident",
 ]
 
+[[package]]
+name = "quick_cache"
+version = "0.6.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ada44a88ef953a3294f6eb55d2007ba44646015e18613d2f213016379203ef3"
+dependencies = [
+ "ahash",
+ "equivalent",
+ "hashbrown 0.16.1",
+ "parking_lot",
+]
+
 [[package]]
 name = "quote"
 version = "1.0.44"
@@ -586,6 +1311,74 @@ version = "5.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
 
+[[package]]
+name = "rand"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
+dependencies = [
+ "libc",
+ "rand_chacha 0.3.1",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
+dependencies = [
+ "rand_chacha 0.9.0",
+ "rand_core 0.9.5",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.9.5",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom 0.2.17",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom 0.3.4",
+]
+
+[[package]]
+name = "rand_xoshiro"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa"
+dependencies = [
+ "rand_core 0.6.4",
+]
+
 [[package]]
 name = "redox_syscall"
 version = "0.5.18"

Cargo.toml 🔗

@@ -13,9 +13,10 @@ anyhow = "1"
 chrono = { version = "0.4", default-features = false, features = ["clock"] }
 clap = { version = "4", features = ["derive"] }
 comfy-table = "7.2.2"
-rusqlite = { version = "0.34", features = ["bundled"] }
 serde = { version = "1", features = ["derive"] }
 serde_json = "1"
+loro = "1"
+ulid = "1"
 
 [dev-dependencies]
 assert_cmd = "2"

src/db.rs 🔗

@@ -1,257 +1,371 @@
-use anyhow::{bail, Result};
-use rusqlite::Connection;
+use anyhow::{anyhow, bail, Context, Result};
+use loro::{ExportMode, LoroDoc, PeerID};
 use serde::Serialize;
-use std::hash::{DefaultHasher, Hash, Hasher};
+use serde_json::Value;
+use std::fmt;
+use std::fs::{self, File, OpenOptions};
+use std::io::Write;
 use std::path::{Path, PathBuf};
-use std::sync::atomic::{AtomicU64, Ordering};
-use std::time::SystemTime;
+use ulid::Ulid;
 
 const TD_DIR: &str = ".td";
-const DB_FILE: &str = "tasks.db";
+const PROJECTS_DIR: &str = "projects";
+const CHANGES_DIR: &str = "changes";
+const BASE_FILE: &str = "base.loro";
+const TMP_SUFFIX: &str = ".tmp";
+const SCHEMA_VERSION: u32 = 1;
 
-/// A task record.
-#[derive(Debug, Serialize)]
-pub struct Task {
-    pub id: String,
-    pub title: String,
-    pub description: String,
-    #[serde(rename = "type")]
-    pub task_type: String,
-    pub priority: i32,
-    pub status: String,
-    pub effort: i32,
-    pub parent: String,
-    pub created: String,
-    pub updated: String,
-}
-
-static ID_COUNTER: AtomicU64 = AtomicU64::new(0);
-
-/// Generate a short unique ID like `td-a1b2c3`.
-pub fn gen_id() -> String {
-    let mut hasher = DefaultHasher::new();
-    SystemTime::now()
-        .duration_since(SystemTime::UNIX_EPOCH)
-        .unwrap()
-        .as_nanos()
-        .hash(&mut hasher);
-    std::process::id().hash(&mut hasher);
-    ID_COUNTER.fetch_add(1, Ordering::Relaxed).hash(&mut hasher);
-    format!("td-{:06x}", hasher.finish() & 0xffffff)
-}
-
-/// A task with its labels and blockers.
-#[derive(Debug, Serialize)]
-pub struct TaskDetail {
-    #[serde(flatten)]
-    pub task: Task,
-    pub labels: Vec<String>,
-    pub blockers: Vec<String>,
+/// Current UTC time in ISO 8601 format.
+pub fn now_utc() -> String {
+    chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
 }
 
-/// A work log entry attached to a task.
-#[derive(Debug, Serialize)]
-pub struct LogEntry {
-    pub id: i64,
-    pub task_id: String,
-    pub timestamp: String,
-    pub body: String,
-}
-
-/// Parse a priority label to its integer value.
-///
-/// Accepts "low" (3), "medium" (2), or "high" (1).
-pub fn parse_priority(s: &str) -> anyhow::Result<i32> {
-    match s {
-        "high" => Ok(1),
-        "medium" => Ok(2),
-        "low" => Ok(3),
-        _ => bail!("invalid priority '{s}': expected low, medium, or high"),
+/// Lifecycle state for a task.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum Status {
+    Open,
+    InProgress,
+    Closed,
+}
+
+impl Status {
+    fn as_str(self) -> &'static str {
+        match self {
+            Status::Open => "open",
+            Status::InProgress => "in_progress",
+            Status::Closed => "closed",
+        }
+    }
+
+    fn parse(raw: &str) -> Result<Self> {
+        match raw {
+            "open" => Ok(Self::Open),
+            "in_progress" => Ok(Self::InProgress),
+            "closed" => Ok(Self::Closed),
+            _ => bail!("invalid status '{raw}'"),
+        }
     }
 }
 
-/// Convert a priority integer back to its label.
-pub fn priority_label(val: i32) -> &'static str {
-    match val {
-        1 => "high",
-        2 => "medium",
-        3 => "low",
-        _ => "unknown",
+/// Priority for task ordering.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum Priority {
+    High,
+    Medium,
+    Low,
+}
+
+impl Priority {
+    fn as_str(self) -> &'static str {
+        match self {
+            Priority::High => "high",
+            Priority::Medium => "medium",
+            Priority::Low => "low",
+        }
+    }
+
+    fn parse(raw: &str) -> Result<Self> {
+        match raw {
+            "high" => Ok(Self::High),
+            "medium" => Ok(Self::Medium),
+            "low" => Ok(Self::Low),
+            _ => bail!("invalid priority '{raw}'"),
+        }
     }
 }
 
-/// Parse an effort label to its integer value.
-///
-/// Accepts "low" (1), "medium" (2), or "high" (3).
-pub fn parse_effort(s: &str) -> anyhow::Result<i32> {
-    match s {
-        "low" => Ok(1),
-        "medium" => Ok(2),
-        "high" => Ok(3),
-        _ => bail!("invalid effort '{s}': expected low, medium, or high"),
+/// Estimated effort for a task.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum Effort {
+    Low,
+    Medium,
+    High,
+}
+
+impl Effort {
+    fn as_str(self) -> &'static str {
+        match self {
+            Effort::Low => "low",
+            Effort::Medium => "medium",
+            Effort::High => "high",
+        }
+    }
+
+    fn parse(raw: &str) -> Result<Self> {
+        match raw {
+            "low" => Ok(Self::Low),
+            "medium" => Ok(Self::Medium),
+            "high" => Ok(Self::High),
+            _ => bail!("invalid effort '{raw}'"),
+        }
     }
 }
 
-/// Convert an effort integer back to its label.
-pub fn effort_label(val: i32) -> &'static str {
-    match val {
-        1 => "low",
-        2 => "medium",
-        3 => "high",
-        _ => "unknown",
+/// A stable task identifier backed by a ULID.
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
+#[serde(transparent)]
+pub struct TaskId(String);
+
+impl TaskId {
+    pub fn new(id: Ulid) -> Self {
+        Self(id.to_string())
+    }
+
+    pub fn parse(raw: &str) -> Result<Self> {
+        let id = Ulid::from_string(raw).with_context(|| format!("invalid task id '{raw}'"))?;
+        Ok(Self::new(id))
+    }
+
+    pub fn as_str(&self) -> &str {
+        &self.0
+    }
+
+    pub fn short(&self) -> String {
+        self.0.chars().take(7).collect()
     }
 }
 
-/// Current UTC time in ISO 8601 format.
-pub fn now_utc() -> String {
-    chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
+impl fmt::Display for TaskId {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "{}", self.short())
+    }
 }
 
-/// Read a single `Task` row from a query result.
-pub fn row_to_task(row: &rusqlite::Row) -> rusqlite::Result<Task> {
-    Ok(Task {
-        id: row.get("id")?,
-        title: row.get("title")?,
-        description: row.get("description")?,
-        task_type: row.get("type")?,
-        priority: row.get("priority")?,
-        status: row.get("status")?,
-        effort: row.get("effort")?,
-        parent: row.get("parent")?,
-        created: row.get("created")?,
-        updated: row.get("updated")?,
-    })
+/// A task log entry embedded in a task record.
+#[derive(Debug, Clone, Serialize)]
+pub struct LogEntry {
+    pub id: TaskId,
+    pub timestamp: String,
+    pub message: String,
 }
 
-/// Load labels for a task.
-pub fn load_labels(conn: &Connection, task_id: &str) -> Result<Vec<String>> {
-    let mut stmt = conn.prepare("SELECT label FROM labels WHERE task_id = ?1")?;
-    let labels = stmt
-        .query_map([task_id], |r| r.get(0))?
-        .collect::<rusqlite::Result<Vec<String>>>()?;
-    Ok(labels)
-}
-
-/// Load blockers for a task.
-pub fn load_blockers(conn: &Connection, task_id: &str) -> Result<Vec<String>> {
-    let mut stmt = conn.prepare("SELECT blocker_id FROM blockers WHERE task_id = ?1")?;
-    let blockers = stmt
-        .query_map([task_id], |r| r.get(0))?
-        .collect::<rusqlite::Result<Vec<String>>>()?;
-    Ok(blockers)
-}
-
-/// Load log entries for a task in chronological order.
-pub fn load_logs(conn: &Connection, task_id: &str) -> Result<Vec<LogEntry>> {
-    let mut stmt = conn.prepare(
-        "SELECT id, task_id, timestamp, body
-         FROM task_logs
-         WHERE task_id = ?1
-         ORDER BY timestamp ASC, id ASC",
-    )?;
-    let logs = stmt
-        .query_map([task_id], |r| {
-            Ok(LogEntry {
-                id: r.get("id")?,
-                task_id: r.get("task_id")?,
-                timestamp: r.get("timestamp")?,
-                body: r.get("body")?,
-            })
-        })?
-        .collect::<rusqlite::Result<Vec<LogEntry>>>()?;
-    Ok(logs)
-}
-
-/// Load blockers for a task, partitioned by whether they are resolved.
-///
-/// Returns `(open, resolved)` where open blockers have a non-closed status
-/// and resolved blockers are closed.
-pub fn load_blockers_partitioned(
-    conn: &Connection,
-    task_id: &str,
-) -> Result<(Vec<String>, Vec<String>)> {
-    let mut stmt = conn.prepare(
-        "SELECT b.blocker_id, COALESCE(t.status, 'open')
-         FROM blockers b
-         LEFT JOIN tasks t ON b.blocker_id = t.id
-         WHERE b.task_id = ?1",
-    )?;
-    let mut open = Vec::new();
-    let mut resolved = Vec::new();
-    let rows: Vec<(String, String)> = stmt
-        .query_map([task_id], |r| Ok((r.get(0)?, r.get(1)?)))?
-        .collect::<rusqlite::Result<_>>()?;
-    for (id, status) in rows {
-        if status == "closed" {
-            resolved.push(id);
-        } else {
-            open.push(id);
+/// Hydrated task data from the CRDT document.
+#[derive(Debug, Clone, Serialize)]
+pub struct Task {
+    pub id: TaskId,
+    pub title: String,
+    pub description: String,
+    #[serde(rename = "type")]
+    pub task_type: String,
+    pub priority: Priority,
+    pub status: Status,
+    pub effort: Effort,
+    pub parent: Option<TaskId>,
+    pub created_at: String,
+    pub updated_at: String,
+    pub deleted_at: Option<String>,
+    pub labels: Vec<String>,
+    pub blockers: Vec<TaskId>,
+    pub logs: Vec<LogEntry>,
+}
+
+/// Result type for partitioning blockers by task state.
+#[derive(Debug, Default, Clone, Serialize)]
+pub struct BlockerPartition {
+    pub open: Vec<TaskId>,
+    pub resolved: Vec<TaskId>,
+}
+
+/// Storage wrapper around one project's Loro document and disk layout.
+#[derive(Debug, Clone)]
+pub struct Store {
+    root: PathBuf,
+    project: String,
+    doc: LoroDoc,
+}
+
+impl Store {
+    /// Create a new store rooted at the current project path.
+    pub fn init(root: &Path) -> Result<Self> {
+        let project = project_name(root)?;
+        let project_dir = project_dir(root, &project);
+        fs::create_dir_all(project_dir.join(CHANGES_DIR))?;
+
+        let doc = LoroDoc::new();
+        let peer_id = load_or_create_device_peer_id()?;
+        doc.set_peer_id(peer_id)?;
+
+        doc.get_map("tasks");
+        let meta = doc.get_map("meta");
+        meta.insert("schema_version", SCHEMA_VERSION as i64)?;
+        meta.insert("project_id", Ulid::new().to_string())?;
+        meta.insert("created_at", now_utc())?;
+
+        let snapshot = doc
+            .export(ExportMode::Snapshot)
+            .context("failed to export initial loro snapshot")?;
+        atomic_write_file(&project_dir.join(BASE_FILE), &snapshot)?;
+
+        Ok(Self {
+            root: root.to_path_buf(),
+            project,
+            doc,
+        })
+    }
+
+    /// Open an existing store and replay deltas.
+    pub fn open(root: &Path) -> Result<Self> {
+        let project = project_name(root)?;
+        let project_dir = project_dir(root, &project);
+        let base_path = project_dir.join(BASE_FILE);
+
+        if !base_path.exists() {
+            bail!("not initialized. Run 'td init'");
+        }
+
+        let base = fs::read(&base_path)
+            .with_context(|| format!("failed to read loro snapshot '{}'", base_path.display()))?;
+
+        let doc = LoroDoc::from_snapshot(&base).context("failed to load loro snapshot")?;
+        doc.set_peer_id(load_or_create_device_peer_id()?)?;
+
+        let mut deltas = collect_delta_paths(&project_dir)?;
+        deltas.sort_by_key(|path| {
+            path.file_stem()
+                .and_then(|s| s.to_str())
+                .and_then(|s| Ulid::from_string(s).ok())
+        });
+
+        for delta_path in deltas {
+            let bytes = fs::read(&delta_path)
+                .with_context(|| format!("failed to read loro delta '{}'", delta_path.display()))?;
+            doc.import(&bytes).with_context(|| {
+                format!("failed to import loro delta '{}'", delta_path.display())
+            })?;
         }
+
+        Ok(Self {
+            root: root.to_path_buf(),
+            project,
+            doc,
+        })
     }
-    Ok((open, resolved))
-}
 
-/// Check whether `from` can reach `to` by following blocker edges.
-///
-/// Returns `true` if there is a path from `from` to `to` in the blocker
-/// graph (i.e. adding an edge `to → from` would create a cycle).
-/// Uses a visited-set so it terminates even if the graph already contains
-/// a cycle from bad data.
-pub fn would_cycle(conn: &Connection, from: &str, to: &str) -> Result<bool> {
-    use std::collections::{HashSet, VecDeque};
+    pub fn root(&self) -> &Path {
+        &self.root
+    }
 
-    if from == to {
-        return Ok(true);
+    pub fn project_name(&self) -> &str {
+        &self.project
     }
 
-    let mut visited = HashSet::new();
-    let mut queue = VecDeque::new();
-    queue.push_back(from.to_string());
-    visited.insert(from.to_string());
+    pub fn doc(&self) -> &LoroDoc {
+        &self.doc
+    }
 
-    let mut stmt = conn.prepare("SELECT blocker_id FROM blockers WHERE task_id = ?1")?;
+    /// Export all current state to a fresh base snapshot.
+    pub fn write_snapshot(&self) -> Result<PathBuf> {
+        let out = project_dir(&self.root, &self.project).join(BASE_FILE);
+        let bytes = self
+            .doc
+            .export(ExportMode::Snapshot)
+            .context("failed to export loro snapshot")?;
+        atomic_write_file(&out, &bytes)?;
+        Ok(out)
+    }
 
-    while let Some(current) = queue.pop_front() {
-        let neighbors: Vec<String> = stmt
-            .query_map([&current], |r| r.get(0))?
-            .collect::<rusqlite::Result<_>>()?;
+    /// Apply a local mutation and persist only the resulting delta.
+    pub fn apply_and_persist<F>(&self, mutator: F) -> Result<PathBuf>
+    where
+        F: FnOnce(&LoroDoc) -> Result<()>,
+    {
+        let before = self.doc.oplog_vv();
+        mutator(&self.doc)?;
+        self.doc.commit();
 
-        for neighbor in neighbors {
-            if neighbor == to {
-                return Ok(true);
-            }
-            if visited.insert(neighbor.clone()) {
-                queue.push_back(neighbor);
+        let delta = self
+            .doc
+            .export(ExportMode::updates(&before))
+            .context("failed to export loro update delta")?;
+
+        let filename = format!("{}.loro", Ulid::new());
+        let path = project_dir(&self.root, &self.project)
+            .join(CHANGES_DIR)
+            .join(filename);
+        atomic_write_file(&path, &delta)?;
+        Ok(path)
+    }
+
+    /// Return hydrated tasks, excluding tombstones.
+    pub fn list_tasks(&self) -> Result<Vec<Task>> {
+        self.list_tasks_inner(false)
+    }
+
+    /// Return hydrated tasks, including tombstoned rows.
+    pub fn list_tasks_unfiltered(&self) -> Result<Vec<Task>> {
+        self.list_tasks_inner(true)
+    }
+
+    /// Find a task by exact ULID string.
+    pub fn get_task(&self, id: &TaskId, include_deleted: bool) -> Result<Option<Task>> {
+        let tasks = if include_deleted {
+            self.list_tasks_unfiltered()?
+        } else {
+            self.list_tasks()?
+        };
+        Ok(tasks.into_iter().find(|task| task.id == *id))
+    }
+
+    fn list_tasks_inner(&self, include_deleted: bool) -> Result<Vec<Task>> {
+        let root = serde_json::to_value(self.doc.get_deep_value())?;
+        let tasks_obj = root
+            .get("tasks")
+            .and_then(Value::as_object)
+            .ok_or_else(|| anyhow!("missing root tasks map"))?;
+
+        let mut tasks = Vec::with_capacity(tasks_obj.len());
+        for (task_id_raw, task_json) in tasks_obj {
+            let task = hydrate_task(task_id_raw, task_json)?;
+            if include_deleted || task.deleted_at.is_none() {
+                tasks.push(task);
             }
         }
+
+        tasks.sort_by(|a, b| a.id.as_str().cmp(b.id.as_str()));
+        Ok(tasks)
     }
 
-    Ok(false)
+    /// Return current schema version from root meta map.
+    pub fn schema_version(&self) -> Result<u32> {
+        let root = serde_json::to_value(self.doc.get_deep_value())?;
+        let meta = root
+            .get("meta")
+            .and_then(Value::as_object)
+            .ok_or_else(|| anyhow!("missing root meta map"))?;
+        let n = meta
+            .get("schema_version")
+            .and_then(Value::as_u64)
+            .ok_or_else(|| anyhow!("invalid or missing meta.schema_version"))?;
+        Ok(n as u32)
+    }
 }
 
-/// Check whether a task with the given ID exists.
-pub fn task_exists(conn: &Connection, id: &str) -> Result<bool> {
-    let count: i32 = conn.query_row("SELECT COUNT(*) FROM tasks WHERE id = ?1", [id], |r| {
-        r.get(0)
-    })?;
-    Ok(count > 0)
-}
-
-/// Load a full task with labels and blockers.
-pub fn load_task_detail(conn: &Connection, id: &str) -> Result<TaskDetail> {
-    let task = conn.query_row(
-        "SELECT id, title, description, type, priority, status, effort, parent, created, updated
-         FROM tasks WHERE id = ?1",
-        [id],
-        row_to_task,
-    )?;
-    let labels = load_labels(conn, id)?;
-    let blockers = load_blockers(conn, id)?;
-    Ok(TaskDetail {
-        task,
-        labels,
-        blockers,
-    })
+/// Generate a new task ULID.
+pub fn gen_id() -> TaskId {
+    TaskId::new(Ulid::new())
+}
+
+/// Parse a priority string value.
+pub fn parse_priority(s: &str) -> Result<Priority> {
+    Priority::parse(s)
+}
+
+/// Parse an effort string value.
+pub fn parse_effort(s: &str) -> Result<Effort> {
+    Effort::parse(s)
+}
+
+/// Convert a priority value to its storage label.
+pub fn priority_label(p: Priority) -> &'static str {
+    p.as_str()
+}
+
+/// Convert an effort value to its storage label.
+pub fn effort_label(e: Effort) -> &'static str {
+    e.as_str()
 }
 
 /// Walk up from `start` looking for a `.td/` directory.
@@ -267,29 +381,250 @@ pub fn find_root(start: &Path) -> Result<PathBuf> {
     }
 }
 
-/// Create the `.td/` directory and initialise the database via migrations.
-pub fn init(root: &Path) -> Result<Connection> {
-    let td = root.join(TD_DIR);
-    std::fs::create_dir_all(&td)?;
-    let mut conn = Connection::open(td.join(DB_FILE))?;
-    conn.execute_batch("PRAGMA foreign_keys = ON;")?;
-    crate::migrate::migrate_up(&mut conn)?;
-    Ok(conn)
+/// Return the path to the `.td/` directory under `root`.
+pub fn td_dir(root: &Path) -> PathBuf {
+    root.join(TD_DIR)
+}
+
+/// Initialize on-disk project storage and return the opened store.
+pub fn init(root: &Path) -> Result<Store> {
+    fs::create_dir_all(td_dir(root))?;
+    Store::init(root)
+}
+
+/// Open an existing project's storage.
+pub fn open(root: &Path) -> Result<Store> {
+    Store::open(root)
+}
+
+fn hydrate_task(task_id_raw: &str, value: &Value) -> Result<Task> {
+    let obj = value
+        .as_object()
+        .ok_or_else(|| anyhow!("task '{task_id_raw}' is not an object"))?;
+
+    let id = TaskId::parse(task_id_raw)?;
+
+    let title = get_required_string(obj, "title")?;
+    let description = get_required_string(obj, "description")?;
+    let task_type = get_required_string(obj, "type")?;
+    let status = Status::parse(&get_required_string(obj, "status")?)?;
+    let priority = Priority::parse(&get_required_string(obj, "priority")?)?;
+    let effort = Effort::parse(&get_required_string(obj, "effort")?)?;
+    let parent = match obj.get("parent").and_then(Value::as_str) {
+        Some("") | None => None,
+        Some(raw) => Some(TaskId::parse(raw)?),
+    };
+
+    let created_at = get_required_string(obj, "created_at")?;
+    let updated_at = get_required_string(obj, "updated_at")?;
+    let deleted_at = obj
+        .get("deleted_at")
+        .and_then(Value::as_str)
+        .map(str::to_owned)
+        .filter(|s| !s.is_empty());
+
+    let labels = obj
+        .get("labels")
+        .and_then(Value::as_object)
+        .map(|m| m.keys().cloned().collect())
+        .unwrap_or_else(Vec::new);
+
+    let blockers = obj
+        .get("blockers")
+        .and_then(Value::as_object)
+        .map(|m| {
+            m.keys()
+                .map(|raw| TaskId::parse(raw))
+                .collect::<Result<Vec<_>>>()
+        })
+        .transpose()?
+        .unwrap_or_else(Vec::new);
+
+    let mut logs = obj
+        .get("logs")
+        .and_then(Value::as_object)
+        .map(|logs| {
+            logs.iter()
+                .map(|(log_id_raw, payload)| {
+                    let payload_obj = payload.as_object().ok_or_else(|| {
+                        anyhow!("log '{log_id_raw}' on task '{task_id_raw}' is not an object")
+                    })?;
+                    Ok(LogEntry {
+                        id: TaskId::parse(log_id_raw)?,
+                        timestamp: get_required_string(payload_obj, "timestamp")?,
+                        message: get_required_string(payload_obj, "message")?,
+                    })
+                })
+                .collect::<Result<Vec<_>>>()
+        })
+        .transpose()?
+        .unwrap_or_else(Vec::new);
+
+    logs.sort_by(|a, b| a.id.as_str().cmp(b.id.as_str()));
+
+    Ok(Task {
+        id,
+        title,
+        description,
+        task_type,
+        priority,
+        status,
+        effort,
+        parent,
+        created_at,
+        updated_at,
+        deleted_at,
+        labels,
+        blockers,
+        logs,
+    })
 }
 
-/// Open an existing database, applying any pending migrations.
-pub fn open(root: &Path) -> Result<Connection> {
-    let path = root.join(TD_DIR).join(DB_FILE);
-    if !path.exists() {
-        bail!("not initialized. Run 'td init'");
+fn get_required_string(map: &serde_json::Map<String, Value>, key: &str) -> Result<String> {
+    map.get(key)
+        .and_then(Value::as_str)
+        .map(str::to_owned)
+        .ok_or_else(|| anyhow!("missing or non-string key '{key}'"))
+}
+
+fn collect_delta_paths(project_dir: &Path) -> Result<Vec<PathBuf>> {
+    let mut paths = Vec::new();
+
+    collect_changes_from_dir(&project_dir.join(CHANGES_DIR), &mut paths)?;
+
+    for entry in fs::read_dir(project_dir)? {
+        let entry = entry?;
+        let path = entry.path();
+        if !path.is_dir() {
+            continue;
+        }
+        let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
+            continue;
+        };
+        if name.starts_with("changes.compacting.") {
+            collect_changes_from_dir(&path, &mut paths)?;
+        }
     }
-    let mut conn = Connection::open(path)?;
-    conn.execute_batch("PRAGMA foreign_keys = ON;")?;
-    crate::migrate::migrate_up(&mut conn)?;
-    Ok(conn)
+
+    Ok(paths)
 }
 
-/// Return the path to the `.td/` directory under `root`.
-pub fn td_dir(root: &Path) -> PathBuf {
-    root.join(TD_DIR)
+fn collect_changes_from_dir(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
+    if !dir.exists() {
+        return Ok(());
+    }
+
+    for entry in fs::read_dir(dir)? {
+        let entry = entry?;
+        let path = entry.path();
+        if !path.is_file() {
+            continue;
+        }
+
+        let Some(filename) = path.file_name().and_then(|n| n.to_str()) else {
+            continue;
+        };
+        if filename.ends_with(TMP_SUFFIX) {
+            continue;
+        }
+        if !filename.ends_with(".loro") {
+            continue;
+        }
+
+        let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
+            continue;
+        };
+        if Ulid::from_string(stem).is_err() {
+            continue;
+        }
+
+        out.push(path);
+    }
+
+    Ok(())
+}
+
+fn project_name(root: &Path) -> Result<String> {
+    root.file_name()
+        .and_then(|n| n.to_str())
+        .map(str::to_owned)
+        .ok_or_else(|| {
+            anyhow!(
+                "could not infer project name from path '{}'",
+                root.display()
+            )
+        })
+}
+
+fn project_dir(root: &Path, project: &str) -> PathBuf {
+    td_dir(root).join(PROJECTS_DIR).join(project)
+}
+
+fn load_or_create_device_peer_id() -> Result<PeerID> {
+    let home = std::env::var("HOME").context("HOME is not set")?;
+    let path = PathBuf::from(home)
+        .join(".local")
+        .join("share")
+        .join("td")
+        .join("device_id");
+
+    if let Some(parent) = path.parent() {
+        fs::create_dir_all(parent)?;
+    }
+
+    let device_ulid = if path.exists() {
+        let content = fs::read_to_string(&path)
+            .with_context(|| format!("failed reading device id from '{}'", path.display()))?;
+        Ulid::from_string(content.trim()).context("invalid persisted device id ULID")?
+    } else {
+        let id = Ulid::new();
+        atomic_write_file(&path, id.to_string().as_bytes())?;
+        id
+    };
+
+    Ok((device_ulid.to_u128() & u64::MAX as u128) as u64)
+}
+
+fn atomic_write_file(path: &Path, bytes: &[u8]) -> Result<()> {
+    let parent = path
+        .parent()
+        .ok_or_else(|| anyhow!("cannot atomically write root path '{}'", path.display()))?;
+    fs::create_dir_all(parent)?;
+
+    let tmp_name = format!(
+        "{}.{}{}",
+        path.file_name().and_then(|n| n.to_str()).unwrap_or("write"),
+        Ulid::new(),
+        TMP_SUFFIX
+    );
+    let tmp_path = parent.join(tmp_name);
+
+    {
+        let mut file = OpenOptions::new()
+            .create_new(true)
+            .write(true)
+            .open(&tmp_path)
+            .with_context(|| format!("failed to open temp file '{}'", tmp_path.display()))?;
+        file.write_all(bytes)?;
+        file.sync_all()?;
+    }
+
+    fs::rename(&tmp_path, path).with_context(|| {
+        format!(
+            "failed to atomically rename '{}' to '{}'",
+            tmp_path.display(),
+            path.display()
+        )
+    })?;
+
+    sync_dir(parent)?;
+    Ok(())
+}
+
+fn sync_dir(path: &Path) -> Result<()> {
+    let dir =
+        File::open(path).with_context(|| format!("failed opening dir '{}'", path.display()))?;
+    dir.sync_all()
+        .with_context(|| format!("failed fsync on dir '{}'", path.display()))?;
+    Ok(())
 }

src/migrate.rs 🔗

@@ -1,152 +1,18 @@
-//! Versioned schema migrations for the task database.
+//! Loro-backed storage does not use SQL schema migrations.
 //!
-//! Each migration has up/down SQL and optional post-hook functions that run
-//! inside the same transaction. Schema version is tracked via SQLite's built-in
-//! `PRAGMA user_version`.
+//! The old SQLite migration flow has been replaced by document-level metadata
+//! (`meta.schema_version`) in the Loro snapshot. This module remains only as a
+//! compatibility shim for call sites that still invoke migration entry points.
 
-use anyhow::{bail, Context, Result};
-use rusqlite::Connection;
+use anyhow::Result;
 
-/// A single schema migration step.
-struct Migration {
-    up_sql: &'static str,
-    down_sql: &'static str,
-    post_hook_up: Option<fn(&rusqlite::Transaction) -> Result<()>>,
-    post_hook_down: Option<fn(&rusqlite::Transaction) -> Result<()>>,
-}
-
-/// All migrations in order.  The array index is the version the database will
-/// be at *after* the migration runs (1-indexed: migration 0 brings the DB to
-/// version 1).
-static MIGRATIONS: &[Migration] = &[
-    // 0 → 1: initial schema
-    Migration {
-        up_sql: include_str!("migrations/0001_initial_schema.up.sql"),
-        down_sql: include_str!("migrations/0001_initial_schema.down.sql"),
-        post_hook_up: None,
-        post_hook_down: None,
-    },
-    // 1 → 2: add effort column (integer-backed, default medium)
-    Migration {
-        up_sql: include_str!("migrations/0002_add_effort.up.sql"),
-        down_sql: include_str!("migrations/0002_add_effort.down.sql"),
-        post_hook_up: None,
-        post_hook_down: None,
-    },
-    Migration {
-        up_sql: include_str!("migrations/0003_blocker_fk.up.sql"),
-        down_sql: include_str!("migrations/0003_blocker_fk.down.sql"),
-        post_hook_up: None,
-        post_hook_down: None,
-    },
-    Migration {
-        up_sql: include_str!("migrations/0004_task_logs.up.sql"),
-        down_sql: include_str!("migrations/0004_task_logs.down.sql"),
-        post_hook_up: None,
-        post_hook_down: None,
-    },
-    Migration {
-        up_sql: include_str!("migrations/0005_cascade_fks.up.sql"),
-        down_sql: include_str!("migrations/0005_cascade_fks.down.sql"),
-        post_hook_up: None,
-        post_hook_down: None,
-    },
-];
-
-/// Read the current schema version from the database.
-fn get_version(conn: &Connection) -> Result<u32> {
-    let v: u32 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
-    Ok(v)
-}
-
-/// Set the schema version inside an open transaction.
-fn set_version(tx: &rusqlite::Transaction, version: u32) -> Result<()> {
-    // PRAGMA cannot be parameterised, but the value is a u32 we control.
-    tx.pragma_update(None, "user_version", version)?;
+/// No-op compatibility function for legacy call sites.
+pub fn migrate_up<T>(_conn: &mut T) -> Result<()> {
     Ok(())
 }
 
-/// Apply all pending up-migrations to bring the database to the latest version.
-pub fn migrate_up(conn: &mut Connection) -> Result<()> {
-    let current = get_version(conn)?;
-    let latest = MIGRATIONS.len() as u32;
-
-    if current > latest {
-        bail!(
-            "database is at version {current} but this binary only knows up to {latest}; \
-             upgrade td or use a matching version"
-        );
-    }
-
-    for (idx, m) in MIGRATIONS.iter().enumerate().skip(current as usize) {
-        let target_version = (idx + 1) as u32;
-
-        let tx = conn
-            .transaction()
-            .context("failed to begin migration transaction")?;
-
-        if !m.up_sql.is_empty() {
-            tx.execute_batch(m.up_sql)
-                .with_context(|| format!("migration {target_version} up SQL failed"))?;
-        }
-
-        if let Some(hook) = m.post_hook_up {
-            hook(&tx)
-                .with_context(|| format!("migration {target_version} post-hook (up) failed"))?;
-        }
-
-        set_version(&tx, target_version)?;
-
-        tx.commit()
-            .with_context(|| format!("failed to commit migration {target_version}"))?;
-    }
-
-    Ok(())
-}
-
-/// Roll back migrations down to `target_version` (inclusive — the database
-/// will be at `target_version` when this returns).
-pub fn migrate_down(conn: &mut Connection, target_version: u32) -> Result<()> {
-    let current = get_version(conn)?;
-
-    if target_version >= current {
-        bail!("target version {target_version} is not below current version {current}");
-    }
-
-    if target_version > MIGRATIONS.len() as u32 {
-        bail!("target version {target_version} exceeds known migrations");
-    }
-
-    // Walk backwards: if we're at version 3 and want version 1, we undo
-    // migration index 2 (v3→v2) then index 1 (v2→v1).
-    for (idx, m) in MIGRATIONS
-        .iter()
-        .enumerate()
-        .rev()
-        .filter(|(i, _)| *i >= target_version as usize && *i < current as usize)
-    {
-        let from_version = (idx + 1) as u32;
-
-        let tx = conn
-            .transaction()
-            .context("failed to begin down-migration transaction")?;
-
-        if let Some(hook) = m.post_hook_down {
-            hook(&tx)
-                .with_context(|| format!("migration {from_version} post-hook (down) failed"))?;
-        }
-
-        if !m.down_sql.is_empty() {
-            tx.execute_batch(m.down_sql)
-                .with_context(|| format!("migration {from_version} down SQL failed"))?;
-        }
-
-        set_version(&tx, idx as u32)?;
-
-        tx.commit()
-            .with_context(|| format!("failed to commit down-migration {from_version}"))?;
-    }
-
+/// No-op compatibility function for legacy call sites.
+pub fn migrate_down<T>(_conn: &mut T, _target_version: u32) -> Result<()> {
     Ok(())
 }
 
@@ -155,52 +21,9 @@ mod tests {
     use super::*;
 
     #[test]
-    fn migrate_up_from_empty() {
-        let mut conn = Connection::open_in_memory().unwrap();
-        migrate_up(&mut conn).unwrap();
-
-        let version = get_version(&conn).unwrap();
-        assert_eq!(version, MIGRATIONS.len() as u32);
-
-        // Verify tables exist by querying them.
-        conn.execute_batch("SELECT id FROM tasks LIMIT 0").unwrap();
-        conn.execute_batch("SELECT task_id FROM labels LIMIT 0")
-            .unwrap();
-        conn.execute_batch("SELECT task_id FROM blockers LIMIT 0")
-            .unwrap();
-        conn.execute_batch("SELECT task_id FROM task_logs LIMIT 0")
-            .unwrap();
-    }
-
-    #[test]
-    fn migrate_up_is_idempotent() {
-        let mut conn = Connection::open_in_memory().unwrap();
-        migrate_up(&mut conn).unwrap();
-        // Running again should be a no-op, not an error.
-        migrate_up(&mut conn).unwrap();
-        assert_eq!(get_version(&conn).unwrap(), MIGRATIONS.len() as u32);
-    }
-
-    #[test]
-    fn migrate_down_to_zero() {
-        let mut conn = Connection::open_in_memory().unwrap();
-        migrate_up(&mut conn).unwrap();
-        migrate_down(&mut conn, 0).unwrap();
-        assert_eq!(get_version(&conn).unwrap(), 0);
-
-        // Tables should be gone.
-        let result = conn.execute_batch("SELECT id FROM tasks LIMIT 0");
-        assert!(result.is_err());
-    }
-
-    #[test]
-    fn rejects_future_version() {
-        let mut conn = Connection::open_in_memory().unwrap();
-        conn.pragma_update(None, "user_version", 999).unwrap();
-        let err = migrate_up(&mut conn).unwrap_err();
-        assert!(
-            err.to_string().contains("999"),
-            "error should mention the version: {err}"
-        );
+    fn compatibility_noops_succeed() {
+        let mut placeholder = ();
+        migrate_up(&mut placeholder).unwrap();
+        migrate_down(&mut placeholder, 0).unwrap();
     }
 }