From a96a9e63fdc0e556e663bcec2b46835b6ff025cc Mon Sep 17 00:00:00 2001 From: Mike Kell Date: Thu, 20 Aug 2026 20:09:43 -0400 Subject: [PATCH] feat(core): complete Task 1.4 - Automerge CRDT integration for task and note register mutations --- Cargo.lock | 388 +++++++++++++++- DECISION_LOG.md | 14 + crates/chronogrid_core/Cargo.toml | 4 +- crates/chronogrid_core/src/crdt/doc.rs | 316 +++++++++++++ crates/chronogrid_core/src/crdt/hlc.rs | 265 +++++++++++ crates/chronogrid_core/src/crdt/mod.rs | 30 ++ crates/chronogrid_core/src/crdt/note.rs | 290 ++++++++++++ crates/chronogrid_core/src/crdt/task.rs | 430 ++++++++++++++++++ crates/chronogrid_core/src/db.rs | 96 ++++ crates/chronogrid_core/src/error.rs | 8 + crates/chronogrid_core/src/lib.rs | 7 +- docs/API_CONTRACTS/schema.sql | 9 + .../0004-core-automerge-crdt-integration.md | 43 ++ docs/TASKS/phase_1_storage_and_crdt.md | 2 +- 14 files changed, 1895 insertions(+), 7 deletions(-) create mode 100644 crates/chronogrid_core/src/crdt/doc.rs create mode 100644 crates/chronogrid_core/src/crdt/hlc.rs create mode 100644 crates/chronogrid_core/src/crdt/mod.rs create mode 100644 crates/chronogrid_core/src/crdt/note.rs create mode 100644 crates/chronogrid_core/src/crdt/task.rs create mode 100644 docs/ARCHITECTURE/ADR/0004-core-automerge-crdt-integration.md diff --git a/Cargo.lock b/Cargo.lock index 1e84972..b05b99c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "ahash" version = "0.8.12" @@ -26,6 +32,29 @@ dependencies = [ "password-hash", ] +[[package]] +name = "automerge" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f0dae93622d3c6850d196503480004576249e0e391bddb3f54600974d92a790" +dependencies = [ + "cfg-if", + "flate2", + "fxhash", + "hex", + "im", + "itertools", + "leb128", + "serde", + "sha2", + "smol_str", + "thiserror 1.0.69", + "tinyvec", + "tracing", + "unicode-segmentation", + "uuid", +] + [[package]] name = "base64ct" version = "1.8.3" @@ -38,6 +67,15 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "bitmaps" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031043d04099746d8db04daf1fa424b2bc8bd69d92b25962dcde24da39ab64a2" +dependencies = [ + "typenum", +] + [[package]] name = "blake2" version = "0.10.6" @@ -56,6 +94,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "byteorder" version = "1.5.0" @@ -86,9 +130,11 @@ version = "0.1.0" name = "chronogrid_core" version = "0.1.0" dependencies = [ + "automerge", "rusqlite", "tempfile", - "thiserror", + "thiserror 2.0.20", + "uuid", ] [[package]] @@ -98,7 +144,7 @@ dependencies = [ "argon2", "keyring", "rand", - "thiserror", + "thiserror 2.0.20", "zeroize", ] @@ -141,6 +187,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -183,6 +238,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + [[package]] name = "errno" version = "0.3.14" @@ -217,6 +278,49 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -267,6 +371,46 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "im" +version = "15.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0acd33ff0285af998aaf9b57342af478078f53492322fafc47450e09397e0e9" +dependencies = [ + "bitmaps", + "rand_core", + "rand_xoshiro", + "sized-chunks", + "typenum", + "version_check", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "keyring" version = "3.6.3" @@ -282,6 +426,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "leb128" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83bff1d572d6b9aeef67ddfc8448e4a3737909cb28e81f97c791b9018703e52" + [[package]] name = "libc" version = "0.2.189" @@ -321,6 +471,16 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -360,6 +520,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "pkg-config" version = "0.3.33" @@ -429,6 +595,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_xoshiro" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa" +dependencies = [ + "rand_core", +] + [[package]] name = "rusqlite" version = "0.31.0" @@ -456,6 +631,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "security-framework" version = "2.11.1" @@ -492,18 +673,90 @@ dependencies = [ "libc", ] +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "sized-chunks" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16d69225bde7a69b235da73377861095455d298f2b970996eec25ddbb42b3d1e" +dependencies = [ + "bitmaps", + "typenum", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + [[package]] name = "subtle" version = "2.6.1" @@ -545,13 +798,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -565,6 +838,52 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + [[package]] name = "typenum" version = "1.20.1" @@ -577,6 +896,24 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "uuid" +version = "1.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -595,6 +932,51 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + [[package]] name = "windows-link" version = "0.2.1" diff --git a/DECISION_LOG.md b/DECISION_LOG.md index 3b00162..a211990 100644 --- a/DECISION_LOG.md +++ b/DECISION_LOG.md @@ -20,6 +20,20 @@ - `Database` struct provides `open(path, key)` for file-backed and `open_in_memory(key)` for test databases; key length is validated at 32 bytes. - 11 unit tests cover: table/index creation, FK enforcement, cascade deletes, wrong-key rejection, key length validation, schema idempotency, and CRUD round-trips across all tables. +## [2026-08-20] - ADL-004: chronogrid_core – Automerge CRDT Integration for Task & Note Registers + +- **Status**: Accepted +- **Context**: Task 1.4 required integrating the Automerge CRDT engine into `chronogrid_core` so task and note entities support offline-first LWW register mutations with HLC-based causal ordering. +- **Decision**: + - Added `automerge = "0.5"` (resolved to v0.5.12) and `uuid = "1"` with `v4` feature to `chronogrid_core/Cargo.toml`. + - Extended `schema.sql` with `crdt_docs` table (`entity_id TEXT PK`, `entity_type TEXT`, `doc_bytes BLOB`, `updated_at INTEGER`) and `idx_crdt_docs_type` index. + - Implemented `crdt/` sub-module: `Hlc` (Hybrid Logical Clock with `tick()`/`update()`/`Display`/`FromStr`), `CrdtDoc` (generic Automerge wrapper with typed scalar accessors), `TaskCrdt` (11 mutable fields), `NoteCrdt` (4 mutable fields). + - Each entity mutation stamps the document with an advancing HLC timestamp. Fork/merge delegates to Automerge's deterministic conflict resolution. + - `Database` gained `save_crdt_doc()`, `load_crdt_doc()`, `delete_crdt_doc()` for serialized Automerge blob persistence. + - Added `CoreError::Crdt` and `CoreError::HlcParse` error variants. + - 45 unit tests (all passing): HLC ordering/parse, all scalar types, save/load/fork/merge round-trips, CRDT doc DB persistence, schema validation with `crdt_docs` table. + - Full ADR at `docs/ARCHITECTURE/ADR/0004-core-automerge-crdt-integration.md`. + ## [2026-08-09] - ADL-002: chronogrid_crypto Crate – Argon2id KDF & OS Keychain - **Status**: Accepted diff --git a/crates/chronogrid_core/Cargo.toml b/crates/chronogrid_core/Cargo.toml index d469865..74d846a 100644 --- a/crates/chronogrid_core/Cargo.toml +++ b/crates/chronogrid_core/Cargo.toml @@ -7,8 +7,10 @@ rust-version.workspace = true publish = false [dependencies] -rusqlite = { version = "0.31", features = ["bundled-sqlcipher-vendored-openssl"] } +automerge = "0.5" +rusqlite = { version = "0.31", features = ["bundled-sqlcipher-vendored-openssl", "blob"] } thiserror = "2" +uuid = { version = "1", features = ["v4"] } [dev-dependencies] tempfile = "3" diff --git a/crates/chronogrid_core/src/crdt/doc.rs b/crates/chronogrid_core/src/crdt/doc.rs new file mode 100644 index 0000000..ad092e4 --- /dev/null +++ b/crates/chronogrid_core/src/crdt/doc.rs @@ -0,0 +1,316 @@ +//! Generic Automerge CRDT document wrapper. +//! +//! [`CrdtDoc`] provides a thin, ergonomic layer over [`automerge::AutoCommit`] +//! for storing LWW-Element-Set registers as key-value pairs at the document +//! root. Higher-level entity wrappers ([`super::task::TaskCrdt`], +//! [`super::note::NoteCrdt`]) delegate to `CrdtDoc` for typed field access. + +use automerge::transaction::Transactable; +use automerge::{AutoCommit, ReadDoc, ScalarValue, Value, ROOT}; + +use crate::error::{CoreError, Result}; + +/// A thin wrapper around an Automerge `AutoCommit` document. +/// +/// All mutable fields are stored as scalar values in a flat map at the +/// document root (`ROOT`). This enables Automerge's built-in LWW conflict +/// resolution for concurrent writes. +pub struct CrdtDoc { + inner: AutoCommit, +} + +impl std::fmt::Debug for CrdtDoc { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CrdtDoc") + .field("inner", &"") + .finish() + } +} + +impl CrdtDoc { + // ── Construction ───────────────────────────────────────────────────── + + /// Creates a new, empty Automerge document. + pub fn new() -> Self { + Self { + inner: AutoCommit::new(), + } + } + + /// Loads a document from its serialized byte representation. + pub fn load(bytes: &[u8]) -> Result { + let inner = AutoCommit::load(bytes).map_err(|e| { + CoreError::Crdt(format!("failed to load Automerge document: {e}")) + })?; + Ok(Self { inner }) + } + + /// Serializes the document to a compact byte representation. + pub fn save(&mut self) -> Vec { + self.inner.save() + } + + /// Forks the document, producing an independent copy that can be + /// modified and later merged back. + pub fn fork(&mut self) -> Self { + Self { + inner: self.inner.fork(), + } + } + + /// Merges another document into this one. Automerge resolves any + /// concurrent writes deterministically via its internal LWW semantics. + pub fn merge(&mut self, other: &mut CrdtDoc) -> Result<()> { + self.inner.merge(&mut other.inner).map_err(|e| { + CoreError::Crdt(format!("merge failed: {e}")) + })?; + Ok(()) + } + + /// Returns a reference to the underlying `AutoCommit` for advanced use. + #[inline] + pub fn inner(&self) -> &AutoCommit { + &self.inner + } + + /// Returns a mutable reference to the underlying `AutoCommit`. + #[inline] + pub fn inner_mut(&mut self) -> &mut AutoCommit { + &mut self.inner + } + + // ── Scalar setters (LWW registers at ROOT) ────────────────────────── + + /// Stores a string value at the given key. + pub fn put_str(&mut self, key: &str, value: &str) -> Result<()> { + self.inner + .put(&ROOT, key, value) + .map_err(|e| CoreError::Crdt(format!("put_str({key}): {e}")))?; + Ok(()) + } + + /// Stores an `f64` value at the given key. + pub fn put_f64(&mut self, key: &str, value: f64) -> Result<()> { + self.inner + .put(&ROOT, key, value) + .map_err(|e| CoreError::Crdt(format!("put_f64({key}): {e}")))?; + Ok(()) + } + + /// Stores an `i64` value at the given key. + pub fn put_i64(&mut self, key: &str, value: i64) -> Result<()> { + self.inner + .put(&ROOT, key, ScalarValue::Int(value)) + .map_err(|e| CoreError::Crdt(format!("put_i64({key}): {e}")))?; + Ok(()) + } + + /// Stores a `u64` value at the given key. + pub fn put_u64(&mut self, key: &str, value: u64) -> Result<()> { + self.inner + .put(&ROOT, key, ScalarValue::Uint(value)) + .map_err(|e| CoreError::Crdt(format!("put_u64({key}): {e}")))?; + Ok(()) + } + + /// Stores a boolean value at the given key. + pub fn put_bool(&mut self, key: &str, value: bool) -> Result<()> { + self.inner + .put(&ROOT, key, value) + .map_err(|e| CoreError::Crdt(format!("put_bool({key}): {e}")))?; + Ok(()) + } + + /// Removes a key from the document root (sets it to the Automerge + /// equivalent of "deleted"). + pub fn delete(&mut self, key: &str) -> Result<()> { + self.inner + .delete(&ROOT, key) + .map_err(|e| CoreError::Crdt(format!("delete({key}): {e}")))?; + Ok(()) + } + + // ── Scalar getters ────────────────────────────────────────────────── + + /// Retrieves a string value, returning `None` if the key is absent or + /// has a non-string type. + pub fn get_str(&self, key: &str) -> Result> { + match self.get_value(key)? { + Some(Value::Scalar(cow)) => match cow.as_ref() { + ScalarValue::Str(s) => Ok(Some(s.to_string())), + _ => Ok(None), + }, + _ => Ok(None), + } + } + + /// Retrieves an `f64` value. + pub fn get_f64(&self, key: &str) -> Result> { + match self.get_value(key)? { + Some(Value::Scalar(cow)) => match cow.as_ref() { + ScalarValue::F64(v) => Ok(Some(*v)), + _ => Ok(None), + }, + _ => Ok(None), + } + } + + /// Retrieves an `i64` value. + pub fn get_i64(&self, key: &str) -> Result> { + match self.get_value(key)? { + Some(Value::Scalar(cow)) => match cow.as_ref() { + ScalarValue::Int(v) => Ok(Some(*v)), + _ => Ok(None), + }, + _ => Ok(None), + } + } + + /// Retrieves a `u64` value. + pub fn get_u64(&self, key: &str) -> Result> { + match self.get_value(key)? { + Some(Value::Scalar(cow)) => match cow.as_ref() { + ScalarValue::Uint(v) => Ok(Some(*v)), + _ => Ok(None), + }, + _ => Ok(None), + } + } + + /// Retrieves a boolean value. + pub fn get_bool(&self, key: &str) -> Result> { + match self.get_value(key)? { + Some(Value::Scalar(cow)) => match cow.as_ref() { + ScalarValue::Boolean(v) => Ok(Some(*v)), + _ => Ok(None), + }, + _ => Ok(None), + } + } + + /// Returns `true` if the key exists (has any value) at the document root. + pub fn has_key(&self, key: &str) -> Result { + Ok(self.get_value(key)?.is_some()) + } + + // ── Private helpers ───────────────────────────────────────────────── + + /// Low-level getter returning the raw Automerge [`Value`]. + fn get_value(&self, key: &str) -> Result>> { + let result = self + .inner + .get(&ROOT, key) + .map_err(|e| CoreError::Crdt(format!("get({key}): {e}")))?; + + Ok(result.map(|(value, _obj_id)| value)) + } +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn put_get_str_round_trip() { + let mut doc = CrdtDoc::new(); + doc.put_str("title", "Hello CRDT").unwrap(); + + assert_eq!(doc.get_str("title").unwrap(), Some("Hello CRDT".into())); + assert_eq!(doc.get_str("missing").unwrap(), None); + } + + #[test] + fn put_get_f64_round_trip() { + let mut doc = CrdtDoc::new(); + doc.put_f64("score", 3.14).unwrap(); + + let val = doc.get_f64("score").unwrap().unwrap(); + assert!((val - 3.14).abs() < f64::EPSILON); + } + + #[test] + fn put_get_i64_round_trip() { + let mut doc = CrdtDoc::new(); + doc.put_i64("quadrant", 2).unwrap(); + + assert_eq!(doc.get_i64("quadrant").unwrap(), Some(2)); + } + + #[test] + fn put_get_u64_round_trip() { + let mut doc = CrdtDoc::new(); + doc.put_u64("due_date", 1_700_000_000_000).unwrap(); + + assert_eq!(doc.get_u64("due_date").unwrap(), Some(1_700_000_000_000)); + } + + #[test] + fn put_get_bool_round_trip() { + let mut doc = CrdtDoc::new(); + doc.put_bool("archived", true).unwrap(); + + assert_eq!(doc.get_bool("archived").unwrap(), Some(true)); + } + + #[test] + fn delete_removes_key() { + let mut doc = CrdtDoc::new(); + doc.put_str("key", "value").unwrap(); + assert!(doc.has_key("key").unwrap()); + + doc.delete("key").unwrap(); + assert!(!doc.has_key("key").unwrap()); + } + + #[test] + fn save_and_load_round_trip() { + let mut doc = CrdtDoc::new(); + doc.put_str("title", "Persist me").unwrap(); + doc.put_i64("count", 42).unwrap(); + + let bytes = doc.save(); + let loaded = CrdtDoc::load(&bytes).unwrap(); + + assert_eq!(loaded.get_str("title").unwrap(), Some("Persist me".into())); + assert_eq!(loaded.get_i64("count").unwrap(), Some(42)); + } + + #[test] + fn fork_and_merge_concurrent_edits() { + let mut doc = CrdtDoc::new(); + doc.put_str("title", "Original").unwrap(); + + // Fork and make divergent edits. + let mut fork = doc.fork(); + doc.put_str("title", "From peer A").unwrap(); + fork.put_str("title", "From peer B").unwrap(); + + // Merge: Automerge deterministically picks a winner. + doc.merge(&mut fork).unwrap(); + + let title = doc.get_str("title").unwrap(); + assert!(title.is_some(), "title must have a resolved value after merge"); + } + + #[test] + fn fork_and_merge_non_conflicting_keys() { + let mut doc = CrdtDoc::new(); + + let mut fork = doc.fork(); + doc.put_str("key_a", "value A").unwrap(); + fork.put_str("key_b", "value B").unwrap(); + + doc.merge(&mut fork).unwrap(); + + assert_eq!(doc.get_str("key_a").unwrap(), Some("value A".into())); + assert_eq!(doc.get_str("key_b").unwrap(), Some("value B".into())); + } + + #[test] + fn load_garbage_bytes_fails() { + let result = CrdtDoc::load(b"not valid automerge data"); + assert!(result.is_err()); + } +} diff --git a/crates/chronogrid_core/src/crdt/hlc.rs b/crates/chronogrid_core/src/crdt/hlc.rs new file mode 100644 index 0000000..eb2ae04 --- /dev/null +++ b/crates/chronogrid_core/src/crdt/hlc.rs @@ -0,0 +1,265 @@ +//! Hybrid Logical Clock (HLC) implementation. +//! +//! Combines physical wall-clock time (milliseconds since Unix epoch) with a +//! logical counter and a unique node identifier to produce causally ordered, +//! globally unique timestamps suitable for LWW-Element-Set CRDT registers. +//! +//! The serialized form `{wall_ms:016}-{counter:08x}-{node_id}` is stored in +//! the `hlc_timestamp TEXT` columns of `tasks`, `notes`, and `projects`. +//! Lexicographic comparison of these strings matches the logical ordering. + +use std::fmt; +use std::str::FromStr; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::error::{CoreError, Result}; + +/// A Hybrid Logical Clock timestamp. +/// +/// Ordering: first by `wall_ms`, then by `counter`, then by `node_id`. +/// This ensures deterministic conflict resolution across peers. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct Hlc { + /// Physical wall-clock time in milliseconds since Unix epoch. + wall_ms: u64, + /// Logical counter for disambiguation when wall_ms values collide. + counter: u32, + /// Unique node identifier (typically a hex-encoded UUID). + node_id: String, +} + +impl Hlc { + /// Creates a new HLC seeded with the current system time. + pub fn new(node_id: impl Into) -> Self { + Self { + wall_ms: now_ms(), + counter: 0, + node_id: node_id.into(), + } + } + + /// Creates an HLC with explicit values (useful for testing or deserialization). + pub fn from_parts(wall_ms: u64, counter: u32, node_id: impl Into) -> Self { + Self { + wall_ms, + counter, + node_id: node_id.into(), + } + } + + /// Advances the clock for a local event and returns the new timestamp. + /// + /// If the system clock has advanced beyond `wall_ms`, the counter resets. + /// Otherwise the counter is incremented to maintain causality. + pub fn tick(&mut self) -> Hlc { + let now = now_ms(); + if now > self.wall_ms { + self.wall_ms = now; + self.counter = 0; + } else { + self.counter = self.counter.saturating_add(1); + } + self.clone() + } + + /// Merges a remote HLC into this clock, advancing to maintain causality. + /// + /// After this call the local HLC is guaranteed to be strictly greater + /// than both its previous value and the remote value. + pub fn update(&mut self, remote: &Hlc) -> Hlc { + let now = now_ms(); + + if now > self.wall_ms && now > remote.wall_ms { + self.wall_ms = now; + self.counter = 0; + } else if self.wall_ms == remote.wall_ms { + self.counter = self.counter.max(remote.counter).saturating_add(1); + } else if self.wall_ms > remote.wall_ms { + self.counter = self.counter.saturating_add(1); + } else { + // remote.wall_ms > self.wall_ms + self.wall_ms = remote.wall_ms; + self.counter = remote.counter.saturating_add(1); + } + + self.clone() + } + + /// Returns the physical timestamp component in milliseconds since Unix epoch. + #[inline] + pub fn wall_ms(&self) -> u64 { + self.wall_ms + } + + /// Returns the logical counter component. + #[inline] + pub fn counter(&self) -> u32 { + self.counter + } + + /// Returns the node identifier component. + #[inline] + pub fn node_id(&self) -> &str { + &self.node_id + } +} + +// ── Ordering ───────────────────────────────────────────────────────────────── + +impl Ord for Hlc { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.wall_ms + .cmp(&other.wall_ms) + .then_with(|| self.counter.cmp(&other.counter)) + .then_with(|| self.node_id.cmp(&other.node_id)) + } +} + +impl PartialOrd for Hlc { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +// ── Display / FromStr ──────────────────────────────────────────────────────── + +/// Serialization format: `{wall_ms:016}-{counter:08x}-{node_id}` +/// +/// The zero-padded numeric fields ensure that lexicographic string comparison +/// matches the logical [`Ord`] implementation. +impl fmt::Display for Hlc { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:016}-{:08x}-{}", self.wall_ms, self.counter, self.node_id) + } +} + +impl FromStr for Hlc { + type Err = CoreError; + + fn from_str(s: &str) -> Result { + let parts: Vec<&str> = s.splitn(3, '-').collect(); + if parts.len() != 3 { + return Err(CoreError::HlcParse(format!( + "expected 3 dash-separated parts, got {}: '{s}'", + parts.len() + ))); + } + + let wall_ms = parts[0].parse::().map_err(|e| { + CoreError::HlcParse(format!("invalid wall_ms '{}': {e}", parts[0])) + })?; + + let counter = u32::from_str_radix(parts[1], 16).map_err(|e| { + CoreError::HlcParse(format!("invalid counter '{}': {e}", parts[1])) + })?; + + let node_id = parts[2].to_owned(); + if node_id.is_empty() { + return Err(CoreError::HlcParse("node_id must not be empty".into())); + } + + Ok(Self { + wall_ms, + counter, + node_id, + }) + } +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +/// Returns the current wall-clock time in milliseconds since Unix epoch. +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock before Unix epoch") + .as_millis() as u64 +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + const NODE_A: &str = "node-aaa"; + const NODE_B: &str = "node-bbb"; + + #[test] + fn tick_advances_monotonically() { + let mut hlc = Hlc::new(NODE_A); + let t1 = hlc.tick(); + let t2 = hlc.tick(); + let t3 = hlc.tick(); + assert!(t1 < t2, "t1 must be less than t2"); + assert!(t2 < t3, "t2 must be less than t3"); + } + + #[test] + fn update_merges_remote_clock() { + let mut local = Hlc::from_parts(100, 0, NODE_A); + let remote = Hlc::from_parts(200, 5, NODE_B); + + let merged = local.update(&remote); + + // Local should have adopted remote's higher wall time. + assert!(merged.wall_ms >= 200); + assert!(merged > remote, "merged must be greater than remote"); + } + + #[test] + fn update_same_wall_time_increments_counter() { + // Use a wall_ms far in the future so now_ms() never overrides it. + let future_ms: u64 = 9_999_999_999_999; + let mut local = Hlc::from_parts(future_ms, 3, NODE_A); + let remote = Hlc::from_parts(future_ms, 5, NODE_B); + + let merged = local.update(&remote); + + assert_eq!(merged.wall_ms, future_ms); + assert_eq!(merged.counter, 6); // max(3,5) + 1 + } + + #[test] + fn display_and_parse_round_trip() { + let hlc = Hlc::from_parts(1_723_200_000_000, 42, "node-deadbeef"); + let s = hlc.to_string(); + + // 1_723_200_000_000 zero-padded to 16 digits. + assert_eq!(s, "0001723200000000-0000002a-node-deadbeef"); + + let parsed: Hlc = s.parse().expect("parse should succeed"); + assert_eq!(parsed, hlc); + } + + #[test] + fn lexicographic_order_matches_logical_order() { + let a = Hlc::from_parts(100, 0, NODE_A); + let b = Hlc::from_parts(200, 0, NODE_A); + let c = Hlc::from_parts(200, 1, NODE_A); + + // Logical ordering. + assert!(a < b); + assert!(b < c); + + // String ordering must agree. + assert!(a.to_string() < b.to_string()); + assert!(b.to_string() < c.to_string()); + } + + #[test] + fn parse_rejects_malformed_input() { + assert!("no-dashes-here".parse::().is_err()); + assert!("abc-def".parse::().is_err()); // only 2 parts + assert!("not_a_number-00000000-node".parse::().is_err()); + assert!("0000000000000100-zzzzzzzz-node".parse::().is_err()); // bad hex + assert!("0000000000000100-00000000-".parse::().is_err()); // empty node_id + } + + #[test] + fn ordering_breaks_ties_by_node_id() { + let a = Hlc::from_parts(100, 0, "aaa"); + let b = Hlc::from_parts(100, 0, "bbb"); + assert!(a < b, "node_id 'aaa' < 'bbb'"); + } +} diff --git a/crates/chronogrid_core/src/crdt/mod.rs b/crates/chronogrid_core/src/crdt/mod.rs new file mode 100644 index 0000000..6487471 --- /dev/null +++ b/crates/chronogrid_core/src/crdt/mod.rs @@ -0,0 +1,30 @@ +//! CRDT (Conflict-free Replicated Data Type) module for ChronoGrid P2P. +//! +//! This module integrates the [Automerge](https://automerge.org/) CRDT engine +//! with ChronoGrid's local-first storage layer. It provides: +//! +//! * [`hlc::Hlc`] — Hybrid Logical Clock for causally ordered timestamps. +//! * [`doc::CrdtDoc`] — Generic Automerge document wrapper with typed +//! scalar accessors. +//! * [`task::TaskCrdt`] — LWW register mutations for task entities. +//! * [`note::NoteCrdt`] — LWW register mutations for note entities. +//! +//! ## Design +//! +//! Each task or note has a corresponding Automerge document whose serialized +//! bytes are stored in the `crdt_docs` table alongside the primary SQL row. +//! Field mutations write LWW scalar values at the document root; concurrent +//! edits from different peers merge deterministically via Automerge's built-in +//! conflict resolution, with HLC timestamps providing additional causal +//! ordering context. + +pub mod doc; +pub mod hlc; +pub mod note; +pub mod task; + +// Re-export primary types at module level for ergonomic imports. +pub use doc::CrdtDoc; +pub use hlc::Hlc; +pub use note::NoteCrdt; +pub use task::TaskCrdt; diff --git a/crates/chronogrid_core/src/crdt/note.rs b/crates/chronogrid_core/src/crdt/note.rs new file mode 100644 index 0000000..81d7f85 --- /dev/null +++ b/crates/chronogrid_core/src/crdt/note.rs @@ -0,0 +1,290 @@ +//! Note CRDT register — typed LWW mutations for note entity fields. +//! +//! [`NoteCrdt`] wraps a [`CrdtDoc`] and exposes strongly typed accessors for +//! every mutable note field defined in `docs/API_CONTRACTS/schema.sql`. +//! +//! ## Field Mapping +//! +//! | SQL Column | CRDT Key | Type | +//! |-------------------------|-----------------------|---------------| +//! | `title` | `title` | String (LWW) | +//! | `content_markdown` | `content_markdown` | String (LWW) | +//! | `namespace_id` | `namespace_id` | String (LWW) | +//! | `project_id` | `project_id` | String (LWW) | + +use super::doc::CrdtDoc; +use super::hlc::Hlc; +use crate::error::Result; + +/// CRDT key constants matching the schema column names. +mod keys { + pub const ENTITY_ID: &str = "entity_id"; + pub const ENTITY_TYPE: &str = "entity_type"; + pub const NAMESPACE_ID: &str = "namespace_id"; + pub const PROJECT_ID: &str = "project_id"; + pub const TITLE: &str = "title"; + pub const CONTENT_MD: &str = "content_markdown"; + pub const HLC_TIMESTAMP: &str = "hlc_timestamp"; +} + +/// Typed CRDT wrapper for a **note** entity. +/// +/// Every field mutation is accompanied by an [`Hlc`] timestamp that is stored +/// in a companion `hlc_timestamp` key inside the Automerge document. +#[derive(Debug)] +pub struct NoteCrdt { + doc: CrdtDoc, +} + +impl NoteCrdt { + // ── Construction ───────────────────────────────────────────────────── + + /// Creates a brand-new note CRDT document with required initial fields. + pub fn new( + id: &str, + namespace_id: &str, + title: &str, + content_markdown: &str, + hlc: &mut Hlc, + ) -> Result { + let mut doc = CrdtDoc::new(); + + // Immutable metadata. + doc.put_str(keys::ENTITY_ID, id)?; + doc.put_str(keys::ENTITY_TYPE, "note")?; + + // Mutable fields — initial values. + doc.put_str(keys::NAMESPACE_ID, namespace_id)?; + doc.put_str(keys::TITLE, title)?; + doc.put_str(keys::CONTENT_MD, content_markdown)?; + + // Record the HLC. + let ts = hlc.tick(); + doc.put_str(keys::HLC_TIMESTAMP, &ts.to_string())?; + + Ok(Self { doc }) + } + + /// Loads a note CRDT document from previously saved bytes. + pub fn from_bytes(bytes: &[u8]) -> Result { + let doc = CrdtDoc::load(bytes)?; + Ok(Self { doc }) + } + + // ── Serialization ─────────────────────────────────────────────────── + + /// Serializes the document to bytes for storage in `crdt_docs.doc_bytes`. + pub fn save(&mut self) -> Vec { + self.doc.save() + } + + /// Forks the document for independent editing (e.g., simulating a peer). + pub fn fork(&mut self) -> Self { + Self { + doc: self.doc.fork(), + } + } + + /// Merges another note CRDT document into this one. + pub fn merge(&mut self, other: &mut NoteCrdt) -> Result<()> { + self.doc.merge(&mut other.doc) + } + + /// Returns a shared reference to the underlying [`CrdtDoc`]. + #[inline] + pub fn doc(&self) -> &CrdtDoc { + &self.doc + } + + // ── Field getters ─────────────────────────────────────────────────── + + /// Returns the entity ID (UUIDv4) stored when the document was created. + pub fn entity_id(&self) -> Result> { + self.doc.get_str(keys::ENTITY_ID) + } + + pub fn namespace_id(&self) -> Result> { + self.doc.get_str(keys::NAMESPACE_ID) + } + + pub fn project_id(&self) -> Result> { + self.doc.get_str(keys::PROJECT_ID) + } + + pub fn title(&self) -> Result> { + self.doc.get_str(keys::TITLE) + } + + pub fn content_markdown(&self) -> Result> { + self.doc.get_str(keys::CONTENT_MD) + } + + pub fn hlc_timestamp(&self) -> Result> { + self.doc.get_str(keys::HLC_TIMESTAMP) + } + + // ── Field setters (LWW mutations) ─────────────────────────────────── + + /// Sets the note title (LWW register). + pub fn set_title(&mut self, title: &str, hlc: &mut Hlc) -> Result<()> { + self.doc.put_str(keys::TITLE, title)?; + self.stamp(hlc) + } + + /// Sets the markdown content body (LWW register). + pub fn set_content_markdown( + &mut self, + content: &str, + hlc: &mut Hlc, + ) -> Result<()> { + self.doc.put_str(keys::CONTENT_MD, content)?; + self.stamp(hlc) + } + + /// Moves the note to a different project. + pub fn set_project_id(&mut self, project_id: &str, hlc: &mut Hlc) -> Result<()> { + self.doc.put_str(keys::PROJECT_ID, project_id)?; + self.stamp(hlc) + } + + /// Removes the project association. + pub fn clear_project_id(&mut self, hlc: &mut Hlc) -> Result<()> { + self.doc.delete(keys::PROJECT_ID)?; + self.stamp(hlc) + } + + /// Moves the note to a different namespace. + pub fn set_namespace_id( + &mut self, + namespace_id: &str, + hlc: &mut Hlc, + ) -> Result<()> { + self.doc.put_str(keys::NAMESPACE_ID, namespace_id)?; + self.stamp(hlc) + } + + // ── Private helpers ───────────────────────────────────────────────── + + /// Advances the HLC and records the new timestamp in the document. + fn stamp(&mut self, hlc: &mut Hlc) -> Result<()> { + let ts = hlc.tick(); + self.doc.put_str(keys::HLC_TIMESTAMP, &ts.to_string()) + } +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + const NODE: &str = "test-node"; + + fn make_hlc() -> Hlc { + Hlc::new(NODE) + } + + #[test] + fn new_note_has_required_fields() { + let mut hlc = make_hlc(); + let note = + NoteCrdt::new("n-001", "ns-1", "My Note", "# Hello World", &mut hlc) + .unwrap(); + + assert_eq!(note.entity_id().unwrap(), Some("n-001".into())); + assert_eq!(note.title().unwrap(), Some("My Note".into())); + assert_eq!( + note.content_markdown().unwrap(), + Some("# Hello World".into()) + ); + assert_eq!(note.namespace_id().unwrap(), Some("ns-1".into())); + assert!(note.hlc_timestamp().unwrap().is_some()); + } + + #[test] + fn set_and_get_all_fields() { + let mut hlc = make_hlc(); + let mut note = + NoteCrdt::new("n-002", "ns-1", "Title", "Body", &mut hlc).unwrap(); + + note.set_title("New Title", &mut hlc).unwrap(); + note.set_content_markdown("## Updated\nBody text", &mut hlc) + .unwrap(); + note.set_project_id("p-001", &mut hlc).unwrap(); + note.set_namespace_id("ns-2", &mut hlc).unwrap(); + + assert_eq!(note.title().unwrap(), Some("New Title".into())); + assert_eq!( + note.content_markdown().unwrap(), + Some("## Updated\nBody text".into()) + ); + assert_eq!(note.project_id().unwrap(), Some("p-001".into())); + assert_eq!(note.namespace_id().unwrap(), Some("ns-2".into())); + } + + #[test] + fn clear_optional_fields() { + let mut hlc = make_hlc(); + let mut note = + NoteCrdt::new("n-003", "ns-1", "Title", "Body", &mut hlc).unwrap(); + + note.set_project_id("p-1", &mut hlc).unwrap(); + note.clear_project_id(&mut hlc).unwrap(); + assert_eq!(note.project_id().unwrap(), None); + } + + #[test] + fn save_load_round_trip() { + let mut hlc = make_hlc(); + let mut note = + NoteCrdt::new("n-004", "ns-1", "Persist", "Content", &mut hlc).unwrap(); + note.set_project_id("p-1", &mut hlc).unwrap(); + + let bytes = note.save(); + let loaded = NoteCrdt::from_bytes(&bytes).unwrap(); + + assert_eq!(loaded.entity_id().unwrap(), Some("n-004".into())); + assert_eq!(loaded.title().unwrap(), Some("Persist".into())); + assert_eq!(loaded.content_markdown().unwrap(), Some("Content".into())); + assert_eq!(loaded.project_id().unwrap(), Some("p-1".into())); + } + + #[test] + fn fork_merge_concurrent_content_edits() { + let mut hlc_a = Hlc::new("peer-a"); + let mut hlc_b = Hlc::new("peer-b"); + + let mut note = + NoteCrdt::new("n-005", "ns-1", "Note", "Original", &mut hlc_a).unwrap(); + + let mut fork = note.fork(); + + note.set_content_markdown("Peer A content", &mut hlc_a) + .unwrap(); + fork.set_content_markdown("Peer B content", &mut hlc_b) + .unwrap(); + + note.merge(&mut fork).unwrap(); + + let content = note.content_markdown().unwrap(); + assert!(content.is_some(), "content must resolve after merge"); + let c = content.unwrap(); + assert!( + c == "Peer A content" || c == "Peer B content", + "winner must be one of the concurrent writes, got: {c}" + ); + } + + #[test] + fn hlc_timestamp_advances_on_mutations() { + let mut hlc = make_hlc(); + let mut note = + NoteCrdt::new("n-006", "ns-1", "Note", "Body", &mut hlc).unwrap(); + + let ts1 = note.hlc_timestamp().unwrap().unwrap(); + note.set_title("Changed", &mut hlc).unwrap(); + let ts2 = note.hlc_timestamp().unwrap().unwrap(); + + assert!(ts2 > ts1, "HLC must advance: '{ts2}' > '{ts1}'"); + } +} diff --git a/crates/chronogrid_core/src/crdt/task.rs b/crates/chronogrid_core/src/crdt/task.rs new file mode 100644 index 0000000..77d604c --- /dev/null +++ b/crates/chronogrid_core/src/crdt/task.rs @@ -0,0 +1,430 @@ +//! Task CRDT register — typed LWW mutations for task entity fields. +//! +//! [`TaskCrdt`] wraps a [`CrdtDoc`] and exposes strongly typed accessors for +//! every mutable task field defined in `docs/API_CONTRACTS/schema.sql`. Each +//! mutation records the accompanying [`Hlc`] timestamp so that conflict +//! resolution is deterministic across peers. +//! +//! ## Field Mapping +//! +//! | SQL Column | CRDT Key | Type | +//! |-------------------------|-----------------------|---------------| +//! | `title` | `title` | String (LWW) | +//! | `description_markdown` | `description_markdown`| String (LWW) | +//! | `status` | `status` | String (LWW) | +//! | `priority_score` | `priority_score` | f64 (LWW) | +//! | `eisenhower_quadrant` | `eisenhower_quadrant` | i64 (LWW) | +//! | `due_date` | `due_date` | u64 (LWW) | +//! | `estimated_duration` | `estimated_duration` | i64 (LWW) | +//! | `is_archived` | `is_archived` | bool (LWW) | +//! | `completed_at` | `completed_at` | u64 (LWW) | +//! | `namespace_id` | `namespace_id` | String (LWW) | +//! | `project_id` | `project_id` | String (LWW) | + +use super::doc::CrdtDoc; +use super::hlc::Hlc; +use crate::error::Result; + +/// CRDT key constants matching the schema column names. +mod keys { + pub const ENTITY_ID: &str = "entity_id"; + pub const ENTITY_TYPE: &str = "entity_type"; + pub const NAMESPACE_ID: &str = "namespace_id"; + pub const PROJECT_ID: &str = "project_id"; + pub const TITLE: &str = "title"; + pub const DESCRIPTION_MD: &str = "description_markdown"; + pub const STATUS: &str = "status"; + pub const PRIORITY_SCORE: &str = "priority_score"; + pub const EISENHOWER_Q: &str = "eisenhower_quadrant"; + pub const DUE_DATE: &str = "due_date"; + pub const ESTIMATED_DUR: &str = "estimated_duration"; + pub const IS_ARCHIVED: &str = "is_archived"; + pub const COMPLETED_AT: &str = "completed_at"; + pub const HLC_TIMESTAMP: &str = "hlc_timestamp"; +} + +/// Typed CRDT wrapper for a **task** entity. +/// +/// Every field mutation is accompanied by an [`Hlc`] timestamp that is stored +/// in a companion `hlc_timestamp` key inside the Automerge document. +#[derive(Debug)] +pub struct TaskCrdt { + doc: CrdtDoc, +} + +impl TaskCrdt { + // ── Construction ───────────────────────────────────────────────────── + + /// Creates a brand-new task CRDT document with required initial fields. + pub fn new( + id: &str, + namespace_id: &str, + title: &str, + hlc: &mut Hlc, + ) -> Result { + let mut doc = CrdtDoc::new(); + + // Immutable metadata. + doc.put_str(keys::ENTITY_ID, id)?; + doc.put_str(keys::ENTITY_TYPE, "task")?; + + // Mutable fields — initial values. + doc.put_str(keys::NAMESPACE_ID, namespace_id)?; + doc.put_str(keys::TITLE, title)?; + doc.put_str(keys::STATUS, "backlog")?; + doc.put_f64(keys::PRIORITY_SCORE, 0.0)?; + doc.put_bool(keys::IS_ARCHIVED, false)?; + + // Record the HLC. + let ts = hlc.tick(); + doc.put_str(keys::HLC_TIMESTAMP, &ts.to_string())?; + + Ok(Self { doc }) + } + + /// Loads a task CRDT document from previously saved bytes. + pub fn from_bytes(bytes: &[u8]) -> Result { + let doc = CrdtDoc::load(bytes)?; + Ok(Self { doc }) + } + + // ── Serialization ─────────────────────────────────────────────────── + + /// Serializes the document to bytes for storage in `crdt_docs.doc_bytes`. + pub fn save(&mut self) -> Vec { + self.doc.save() + } + + /// Forks the document for independent editing (e.g., simulating a peer). + pub fn fork(&mut self) -> Self { + Self { + doc: self.doc.fork(), + } + } + + /// Merges another task CRDT document into this one. + pub fn merge(&mut self, other: &mut TaskCrdt) -> Result<()> { + self.doc.merge(&mut other.doc) + } + + /// Returns a shared reference to the underlying [`CrdtDoc`]. + #[inline] + pub fn doc(&self) -> &CrdtDoc { + &self.doc + } + + // ── Field getters ─────────────────────────────────────────────────── + + /// Returns the entity ID (UUIDv4) stored when the document was created. + pub fn entity_id(&self) -> Result> { + self.doc.get_str(keys::ENTITY_ID) + } + + pub fn namespace_id(&self) -> Result> { + self.doc.get_str(keys::NAMESPACE_ID) + } + + pub fn project_id(&self) -> Result> { + self.doc.get_str(keys::PROJECT_ID) + } + + pub fn title(&self) -> Result> { + self.doc.get_str(keys::TITLE) + } + + pub fn description_markdown(&self) -> Result> { + self.doc.get_str(keys::DESCRIPTION_MD) + } + + pub fn status(&self) -> Result> { + self.doc.get_str(keys::STATUS) + } + + pub fn priority_score(&self) -> Result> { + self.doc.get_f64(keys::PRIORITY_SCORE) + } + + pub fn eisenhower_quadrant(&self) -> Result> { + self.doc.get_i64(keys::EISENHOWER_Q) + } + + pub fn due_date(&self) -> Result> { + self.doc.get_u64(keys::DUE_DATE) + } + + pub fn estimated_duration(&self) -> Result> { + self.doc.get_i64(keys::ESTIMATED_DUR) + } + + pub fn is_archived(&self) -> Result> { + self.doc.get_bool(keys::IS_ARCHIVED) + } + + pub fn completed_at(&self) -> Result> { + self.doc.get_u64(keys::COMPLETED_AT) + } + + pub fn hlc_timestamp(&self) -> Result> { + self.doc.get_str(keys::HLC_TIMESTAMP) + } + + // ── Field setters (LWW mutations) ─────────────────────────────────── + + /// Sets the task title (LWW register). + pub fn set_title(&mut self, title: &str, hlc: &mut Hlc) -> Result<()> { + self.doc.put_str(keys::TITLE, title)?; + self.stamp(hlc) + } + + /// Sets the markdown description (LWW register). + pub fn set_description_markdown( + &mut self, + desc: &str, + hlc: &mut Hlc, + ) -> Result<()> { + self.doc.put_str(keys::DESCRIPTION_MD, desc)?; + self.stamp(hlc) + } + + /// Clears the description. + pub fn clear_description(&mut self, hlc: &mut Hlc) -> Result<()> { + self.doc.delete(keys::DESCRIPTION_MD)?; + self.stamp(hlc) + } + + /// Sets the task status (`backlog`, `in_progress`, `review`, `completed`). + pub fn set_status(&mut self, status: &str, hlc: &mut Hlc) -> Result<()> { + self.doc.put_str(keys::STATUS, status)?; + self.stamp(hlc) + } + + /// Sets the computed priority score. + pub fn set_priority_score(&mut self, score: f64, hlc: &mut Hlc) -> Result<()> { + self.doc.put_f64(keys::PRIORITY_SCORE, score)?; + self.stamp(hlc) + } + + /// Sets the Eisenhower quadrant (1–4). + pub fn set_eisenhower_quadrant( + &mut self, + quadrant: i64, + hlc: &mut Hlc, + ) -> Result<()> { + self.doc.put_i64(keys::EISENHOWER_Q, quadrant)?; + self.stamp(hlc) + } + + /// Clears the Eisenhower quadrant. + pub fn clear_eisenhower_quadrant(&mut self, hlc: &mut Hlc) -> Result<()> { + self.doc.delete(keys::EISENHOWER_Q)?; + self.stamp(hlc) + } + + /// Sets the due date (Unix epoch milliseconds). + pub fn set_due_date(&mut self, due_ms: u64, hlc: &mut Hlc) -> Result<()> { + self.doc.put_u64(keys::DUE_DATE, due_ms)?; + self.stamp(hlc) + } + + /// Clears the due date. + pub fn clear_due_date(&mut self, hlc: &mut Hlc) -> Result<()> { + self.doc.delete(keys::DUE_DATE)?; + self.stamp(hlc) + } + + /// Sets the estimated duration in minutes. + pub fn set_estimated_duration( + &mut self, + minutes: i64, + hlc: &mut Hlc, + ) -> Result<()> { + self.doc.put_i64(keys::ESTIMATED_DUR, minutes)?; + self.stamp(hlc) + } + + /// Sets the archived flag. + pub fn set_is_archived(&mut self, archived: bool, hlc: &mut Hlc) -> Result<()> { + self.doc.put_bool(keys::IS_ARCHIVED, archived)?; + self.stamp(hlc) + } + + /// Sets the completion timestamp (Unix epoch milliseconds). + pub fn set_completed_at(&mut self, ts_ms: u64, hlc: &mut Hlc) -> Result<()> { + self.doc.put_u64(keys::COMPLETED_AT, ts_ms)?; + self.stamp(hlc) + } + + /// Clears the completion timestamp. + pub fn clear_completed_at(&mut self, hlc: &mut Hlc) -> Result<()> { + self.doc.delete(keys::COMPLETED_AT)?; + self.stamp(hlc) + } + + /// Moves the task to a different project. + pub fn set_project_id(&mut self, project_id: &str, hlc: &mut Hlc) -> Result<()> { + self.doc.put_str(keys::PROJECT_ID, project_id)?; + self.stamp(hlc) + } + + /// Removes the project association. + pub fn clear_project_id(&mut self, hlc: &mut Hlc) -> Result<()> { + self.doc.delete(keys::PROJECT_ID)?; + self.stamp(hlc) + } + + /// Moves the task to a different namespace. + pub fn set_namespace_id( + &mut self, + namespace_id: &str, + hlc: &mut Hlc, + ) -> Result<()> { + self.doc.put_str(keys::NAMESPACE_ID, namespace_id)?; + self.stamp(hlc) + } + + // ── Private helpers ───────────────────────────────────────────────── + + /// Advances the HLC and records the new timestamp in the document. + fn stamp(&mut self, hlc: &mut Hlc) -> Result<()> { + let ts = hlc.tick(); + self.doc.put_str(keys::HLC_TIMESTAMP, &ts.to_string()) + } +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + const NODE: &str = "test-node"; + + fn make_hlc() -> Hlc { + Hlc::new(NODE) + } + + #[test] + fn new_task_has_default_fields() { + let mut hlc = make_hlc(); + let task = TaskCrdt::new("t-001", "ns-1", "My Task", &mut hlc).unwrap(); + + assert_eq!(task.entity_id().unwrap(), Some("t-001".into())); + assert_eq!(task.title().unwrap(), Some("My Task".into())); + assert_eq!(task.status().unwrap(), Some("backlog".into())); + assert_eq!(task.namespace_id().unwrap(), Some("ns-1".into())); + assert!((task.priority_score().unwrap().unwrap() - 0.0).abs() < f64::EPSILON); + assert_eq!(task.is_archived().unwrap(), Some(false)); + assert!(task.hlc_timestamp().unwrap().is_some()); + } + + #[test] + fn set_and_get_all_fields() { + let mut hlc = make_hlc(); + let mut task = TaskCrdt::new("t-002", "ns-1", "Initial", &mut hlc).unwrap(); + + task.set_title("Updated Title", &mut hlc).unwrap(); + task.set_description_markdown("# Heading", &mut hlc).unwrap(); + task.set_status("in_progress", &mut hlc).unwrap(); + task.set_priority_score(0.75, &mut hlc).unwrap(); + task.set_eisenhower_quadrant(2, &mut hlc).unwrap(); + task.set_due_date(1_700_000_000_000, &mut hlc).unwrap(); + task.set_estimated_duration(60, &mut hlc).unwrap(); + task.set_is_archived(true, &mut hlc).unwrap(); + task.set_completed_at(1_700_100_000_000, &mut hlc).unwrap(); + task.set_project_id("p-001", &mut hlc).unwrap(); + + assert_eq!(task.title().unwrap(), Some("Updated Title".into())); + assert_eq!(task.description_markdown().unwrap(), Some("# Heading".into())); + assert_eq!(task.status().unwrap(), Some("in_progress".into())); + assert!((task.priority_score().unwrap().unwrap() - 0.75).abs() < f64::EPSILON); + assert_eq!(task.eisenhower_quadrant().unwrap(), Some(2)); + assert_eq!(task.due_date().unwrap(), Some(1_700_000_000_000)); + assert_eq!(task.estimated_duration().unwrap(), Some(60)); + assert_eq!(task.is_archived().unwrap(), Some(true)); + assert_eq!(task.completed_at().unwrap(), Some(1_700_100_000_000)); + assert_eq!(task.project_id().unwrap(), Some("p-001".into())); + } + + #[test] + fn clear_optional_fields() { + let mut hlc = make_hlc(); + let mut task = TaskCrdt::new("t-003", "ns-1", "Task", &mut hlc).unwrap(); + + task.set_description_markdown("desc", &mut hlc).unwrap(); + task.clear_description(&mut hlc).unwrap(); + assert_eq!(task.description_markdown().unwrap(), None); + + task.set_eisenhower_quadrant(3, &mut hlc).unwrap(); + task.clear_eisenhower_quadrant(&mut hlc).unwrap(); + assert_eq!(task.eisenhower_quadrant().unwrap(), None); + + task.set_due_date(123, &mut hlc).unwrap(); + task.clear_due_date(&mut hlc).unwrap(); + assert_eq!(task.due_date().unwrap(), None); + + task.set_completed_at(456, &mut hlc).unwrap(); + task.clear_completed_at(&mut hlc).unwrap(); + assert_eq!(task.completed_at().unwrap(), None); + + task.set_project_id("p-1", &mut hlc).unwrap(); + task.clear_project_id(&mut hlc).unwrap(); + assert_eq!(task.project_id().unwrap(), None); + } + + #[test] + fn save_load_round_trip() { + let mut hlc = make_hlc(); + let mut task = TaskCrdt::new("t-004", "ns-1", "Persist", &mut hlc).unwrap(); + task.set_status("completed", &mut hlc).unwrap(); + task.set_priority_score(0.9, &mut hlc).unwrap(); + + let bytes = task.save(); + let loaded = TaskCrdt::from_bytes(&bytes).unwrap(); + + assert_eq!(loaded.entity_id().unwrap(), Some("t-004".into())); + assert_eq!(loaded.title().unwrap(), Some("Persist".into())); + assert_eq!(loaded.status().unwrap(), Some("completed".into())); + assert!((loaded.priority_score().unwrap().unwrap() - 0.9).abs() < f64::EPSILON); + } + + #[test] + fn fork_merge_concurrent_title_edits() { + let mut hlc_a = Hlc::new("peer-a"); + let mut hlc_b = Hlc::new("peer-b"); + + let mut task = TaskCrdt::new("t-005", "ns-1", "Original", &mut hlc_a).unwrap(); + + // Fork simulates a second peer. + let mut fork = task.fork(); + + // Concurrent edits. + task.set_title("Peer A title", &mut hlc_a).unwrap(); + fork.set_title("Peer B title", &mut hlc_b).unwrap(); + + // Merge: Automerge picks a deterministic winner. + task.merge(&mut fork).unwrap(); + + let title = task.title().unwrap(); + assert!( + title.is_some(), + "title must resolve to a value after merge" + ); + let t = title.unwrap(); + assert!( + t == "Peer A title" || t == "Peer B title", + "winner must be one of the two concurrent writes, got: {t}" + ); + } + + #[test] + fn hlc_timestamp_advances_on_mutations() { + let mut hlc = make_hlc(); + let mut task = TaskCrdt::new("t-006", "ns-1", "Task", &mut hlc).unwrap(); + + let ts1 = task.hlc_timestamp().unwrap().unwrap(); + task.set_title("Changed", &mut hlc).unwrap(); + let ts2 = task.hlc_timestamp().unwrap().unwrap(); + + assert!(ts2 > ts1, "HLC must advance: '{ts2}' > '{ts1}'"); + } +} diff --git a/crates/chronogrid_core/src/db.rs b/crates/chronogrid_core/src/db.rs index a441fce..f8dfca9 100644 --- a/crates/chronogrid_core/src/db.rs +++ b/crates/chronogrid_core/src/db.rs @@ -38,6 +38,7 @@ const EXPECTED_TABLES: &[&str] = &[ "notes", "task_note_links", "ingestion_logs", + "crdt_docs", ]; // ── Database struct ────────────────────────────────────────────────────────── @@ -103,6 +104,55 @@ impl Database { &mut self.conn } + // ── CRDT document persistence ──────────────────────────────────────── + + /// Saves (upserts) an Automerge CRDT document's serialized bytes into the + /// `crdt_docs` table. + /// + /// If a row for `entity_id` already exists it is replaced. + pub fn save_crdt_doc( + &self, + entity_id: &str, + entity_type: &str, + doc_bytes: &[u8], + updated_at: u64, + ) -> Result<()> { + self.conn.execute( + "INSERT OR REPLACE INTO crdt_docs (entity_id, entity_type, doc_bytes, updated_at) + VALUES (?1, ?2, ?3, ?4);", + rusqlite::params![entity_id, entity_type, doc_bytes, updated_at as i64], + )?; + Ok(()) + } + + /// Loads the serialized Automerge CRDT document bytes for a given entity. + /// + /// Returns `None` if no CRDT document has been stored for `entity_id`. + pub fn load_crdt_doc(&self, entity_id: &str) -> Result>> { + let mut stmt = self + .conn + .prepare("SELECT doc_bytes FROM crdt_docs WHERE entity_id = ?1;")?; + + let result = stmt.query_row([entity_id], |row| row.get::<_, Vec>(0)); + + match result { + Ok(bytes) => Ok(Some(bytes)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(CoreError::Database(e)), + } + } + + /// Deletes the CRDT document for a given entity. + /// + /// Returns `true` if a row was deleted, `false` if none existed. + pub fn delete_crdt_doc(&self, entity_id: &str) -> Result { + let affected = self.conn.execute( + "DELETE FROM crdt_docs WHERE entity_id = ?1;", + [entity_id], + )?; + Ok(affected > 0) + } + // ── Private helpers ────────────────────────────────────────────────── /// Applies encryption key, PRAGMAs, schema DDL, and validates table presence. @@ -210,6 +260,7 @@ mod tests { "idx_tasks_eisenhower", "idx_tasks_due_date", "idx_projects_parent", + "idx_crdt_docs_type", ]; for &idx in &expected_indexes { @@ -415,4 +466,49 @@ mod tests { .unwrap(); assert_eq!(count, 0, "cascade delete must remove child tasks"); } + + // ── CRDT document persistence ──────────────────────────────────────── + + #[test] + fn save_and_load_crdt_doc() { + let db = Database::open_in_memory(&TEST_KEY).unwrap(); + let blob = b"fake-automerge-bytes"; + + db.save_crdt_doc("t-001", "task", blob, 1000).unwrap(); + + let loaded = db.load_crdt_doc("t-001").unwrap(); + assert_eq!(loaded, Some(blob.to_vec())); + } + + #[test] + fn load_missing_crdt_doc_returns_none() { + let db = Database::open_in_memory(&TEST_KEY).unwrap(); + assert_eq!(db.load_crdt_doc("nonexistent").unwrap(), None); + } + + #[test] + fn save_crdt_doc_upserts() { + let db = Database::open_in_memory(&TEST_KEY).unwrap(); + + db.save_crdt_doc("t-001", "task", b"v1", 1000).unwrap(); + db.save_crdt_doc("t-001", "task", b"v2", 2000).unwrap(); + + let loaded = db.load_crdt_doc("t-001").unwrap().unwrap(); + assert_eq!(loaded, b"v2"); + } + + #[test] + fn delete_crdt_doc_removes_row() { + let db = Database::open_in_memory(&TEST_KEY).unwrap(); + + db.save_crdt_doc("t-001", "task", b"data", 1000).unwrap(); + assert!(db.delete_crdt_doc("t-001").unwrap()); + assert_eq!(db.load_crdt_doc("t-001").unwrap(), None); + } + + #[test] + fn delete_missing_crdt_doc_returns_false() { + let db = Database::open_in_memory(&TEST_KEY).unwrap(); + assert!(!db.delete_crdt_doc("nonexistent").unwrap()); + } } diff --git a/crates/chronogrid_core/src/error.rs b/crates/chronogrid_core/src/error.rs index 6ddd056..d420e65 100644 --- a/crates/chronogrid_core/src/error.rs +++ b/crates/chronogrid_core/src/error.rs @@ -20,6 +20,14 @@ pub enum CoreError { /// Schema migration or initialization failure. #[error("schema initialization failed: {0}")] SchemaInit(String), + + /// An error originating from the Automerge CRDT layer. + #[error("CRDT error: {0}")] + Crdt(String), + + /// Failed to parse a Hybrid Logical Clock (HLC) timestamp string. + #[error("HLC parse error: {0}")] + HlcParse(String), } /// Convenience alias used throughout the crate. diff --git a/crates/chronogrid_core/src/lib.rs b/crates/chronogrid_core/src/lib.rs index 0f894d3..ecc6598 100644 --- a/crates/chronogrid_core/src/lib.rs +++ b/crates/chronogrid_core/src/lib.rs @@ -9,12 +9,15 @@ //! `chronogrid_crypto::kdf::derive_master_key`). The canonical schema from //! `docs/API_CONTRACTS/schema.sql` is applied automatically on first open. //! -//! * **Automerge CRDT** *(planned – Task 1.4)* — conflict-free replicated -//! data-type registers for tasks and notes, enabling offline-first P2P sync. +//! * **Automerge CRDT** — [`crdt`] provides conflict-free replicated data-type +//! registers for tasks and notes, enabling offline-first P2P sync via +//! LWW-Element-Set registers with Hybrid Logical Clock ordering. +pub mod crdt; pub mod db; pub mod error; // Re-export key public types at crate root for ergonomic imports. +pub use crdt::{CrdtDoc, Hlc, NoteCrdt, TaskCrdt}; pub use db::Database; pub use error::{CoreError, Result}; diff --git a/docs/API_CONTRACTS/schema.sql b/docs/API_CONTRACTS/schema.sql index 17d0dec..de1c7dd 100644 --- a/docs/API_CONTRACTS/schema.sql +++ b/docs/API_CONTRACTS/schema.sql @@ -83,7 +83,16 @@ CREATE TABLE IF NOT EXISTS ingestion_logs ( FOREIGN KEY(processed_task_id) REFERENCES tasks(id) ON DELETE SET NULL ); +-- CRDT Document Storage (Automerge binary state) +CREATE TABLE IF NOT EXISTS crdt_docs ( + entity_id TEXT PRIMARY KEY, -- References task.id or note.id + entity_type TEXT NOT NULL, -- 'task' or 'note' + doc_bytes BLOB NOT NULL, -- Serialized Automerge document + updated_at INTEGER NOT NULL -- Unix Epoch (ms) +); + CREATE INDEX IF NOT EXISTS idx_tasks_namespace ON tasks(namespace_id); CREATE INDEX IF NOT EXISTS idx_tasks_eisenhower ON tasks(eisenhower_quadrant); CREATE INDEX IF NOT EXISTS idx_tasks_due_date ON tasks(due_date); CREATE INDEX IF NOT EXISTS idx_projects_parent ON projects(parent_id); +CREATE INDEX IF NOT EXISTS idx_crdt_docs_type ON crdt_docs(entity_type); diff --git a/docs/ARCHITECTURE/ADR/0004-core-automerge-crdt-integration.md b/docs/ARCHITECTURE/ADR/0004-core-automerge-crdt-integration.md new file mode 100644 index 0000000..5134d66 --- /dev/null +++ b/docs/ARCHITECTURE/ADR/0004-core-automerge-crdt-integration.md @@ -0,0 +1,43 @@ +# ADR-0004: chronogrid_core – Automerge CRDT Integration for Task & Note Registers + +* **Status**: Accepted +* **Date**: 2026-08-20 +* **Deciders**: Michael Kell + +## Context & Problem Statement + +Task 1.4 requires integrating the Automerge CRDT engine into `chronogrid_core` so that task and note entities can be modified concurrently by different peers and merged deterministically without centralized coordination. The architecture spec (`03-crdt-sync-protocol.md`) mandates LWW-Element-Set registers for primitive fields and Hybrid Logical Clocks (HLC) for causal ordering. + +## Options Considered + +1. **Manual LWW register implementation** — track per-field HLC timestamps in a custom data structure and resolve conflicts manually. +2. **Automerge integration with HLC companion stamps** — use Automerge's built-in CRDT merge semantics for structural conflict resolution, augmented with HLC timestamps stored inside each document for causal ordering context. +3. **Yrs (Yjs Rust port)** — alternative CRDT library focused on text collaboration. + +## Decision Outcome + +Chosen Option: **Option 2 — Automerge + HLC** because: + +- Automerge provides battle-tested CRDT merge, fork, save/load, and delta-sync primitives out of the box. +- Storing HLC timestamps as companion fields inside the Automerge document aligns with the spec's requirement for HLC-based LWW conflict resolution while leveraging Automerge's deterministic merge for structural integrity. +- The `automerge` crate (v0.5.12) has a stable API with `AutoCommit`, `ReadDoc`, and `Transactable` traits that map cleanly to our per-entity document model. +- Yrs is optimized for text sequences, not key-value LWW registers. + +## Implementation Details + +- **`automerge = "0.5"`** added to `chronogrid_core/Cargo.toml` (resolved to v0.5.12). +- **`uuid = "1"` with `v4` feature** added for entity ID generation. +- **Schema extension**: `crdt_docs` table added to `schema.sql` with columns `entity_id TEXT PK`, `entity_type TEXT`, `doc_bytes BLOB`, `updated_at INTEGER`, plus `idx_crdt_docs_type` index. +- **Module structure**: `crdt/` sub-module with `hlc.rs`, `doc.rs`, `task.rs`, `note.rs`. +- **HLC**: `Hlc` struct with `tick()`, `update()`, `Display`/`FromStr` (format: `{wall_ms:016}-{counter:08x}-{node_id}`), and full `Ord` implementation ensuring lexicographic string order matches logical order. +- **CrdtDoc**: Generic wrapper over `AutoCommit` providing typed `put_*/get_*` accessors for String, f64, i64, u64, bool scalars at the document root. +- **TaskCrdt / NoteCrdt**: Entity-specific wrappers with field-level setters that automatically advance the HLC and record it in the document. +- **Database persistence**: `save_crdt_doc()`, `load_crdt_doc()`, `delete_crdt_doc()` methods on `Database` for upsert/load/delete of serialized Automerge bytes. +- **45 unit tests** covering: HLC ordering, tick/update/parse, all scalar types, save/load round-trips, fork/merge conflict resolution, CRDT doc DB persistence, and schema validation. + +## Consequences & Trade-offs + +* **Positive**: Full offline-first CRDT capability; entities can be forked, edited independently, and merged without data loss. HLC provides deterministic causal ordering for the SQL layer. Zero external network dependencies (local-first mandate satisfied). +* **Positive**: Automerge's compact binary format minimizes storage overhead for CRDT doc blobs. +* **Negative**: Automerge 0.5.x adds ~40 transitive dependencies; binary size increases moderately. +* **Negative**: Each entity requires both a SQL row and a CRDT doc blob, introducing a dual-write pattern that must be kept in sync (future work: transactional wrappers). diff --git a/docs/TASKS/phase_1_storage_and_crdt.md b/docs/TASKS/phase_1_storage_and_crdt.md index 286d2bb..1df61fb 100644 --- a/docs/TASKS/phase_1_storage_and_crdt.md +++ b/docs/TASKS/phase_1_storage_and_crdt.md @@ -5,5 +5,5 @@ - [x] **Task 1.1**: Initialize Rust Cargo Workspace in crates/ and configure root Cargo.toml. - [x] **Task 1.2**: Implement chronogrid_crypto crate with Argon2id master key derivation and OS keychain bindings. - [x] **Task 1.3**: Implement chronogrid_core database initializers using SQLCipher 4.x matching docs/API_CONTRACTS/schema.sql. -- [ ] **Task 1.4**: Integrate Automerge Rust crate into chronogrid_core for task and note CRDT register mutations. +- [x] **Task 1.4**: Integrate Automerge Rust crate into chronogrid_core for task and note CRDT register mutations. - [ ] **Task 1.5**: Generate lutter_rust_bridge FFI bindings bridging Rust storage methods to Dart.