Detailed changes
@@ -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"
@@ -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"
@@ -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([¤t], |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(())
}
@@ -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();
}
}