chore: merge feature/task-1.5-ffi-bridge into main

This commit is contained in:
Mike Kell 2026-08-20 20:53:33 -04:00
commit eef3e1558f
21 changed files with 2222 additions and 19 deletions

396
Cargo.lock generated
View File

@ -2,6 +2,15 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 3 version = 3
[[package]]
name = "addr2line"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b"
dependencies = [
"gimli",
]
[[package]] [[package]]
name = "adler2" name = "adler2"
version = "2.0.1" version = "2.0.1"
@ -20,6 +29,50 @@ dependencies = [
"zerocopy", "zerocopy",
] ]
[[package]]
name = "aho-corasick"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
dependencies = [
"memchr",
]
[[package]]
name = "allo-isolate"
version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "449e356a4864c017286dbbec0e12767ea07efba29e3b7d984194c2a7ff3c4550"
dependencies = [
"anyhow",
"atomic",
"backtrace",
]
[[package]]
name = "android_log-sys"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d"
[[package]]
name = "android_logger"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c494134f746c14dc653a35a4ea5aca24ac368529da5370ecf41fe0341c35772f"
dependencies = [
"android_log-sys",
"env_logger",
"log",
"once_cell",
]
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]] [[package]]
name = "argon2" name = "argon2"
version = "0.5.3" version = "0.5.3"
@ -32,6 +85,12 @@ dependencies = [
"password-hash", "password-hash",
] ]
[[package]]
name = "atomic"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba"
[[package]] [[package]]
name = "automerge" name = "automerge"
version = "0.5.12" version = "0.5.12"
@ -55,6 +114,21 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "backtrace"
version = "0.3.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6"
dependencies = [
"addr2line",
"cfg-if",
"libc",
"miniz_oxide",
"object",
"rustc-demangle",
"windows-link",
]
[[package]] [[package]]
name = "base64ct" name = "base64ct"
version = "1.8.3" version = "1.8.3"
@ -94,12 +168,24 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "build-target"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "832133bbabbbaa9fbdba793456a2827627a7d2b8fb96032fa1e7666d7895832b"
[[package]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.20.3" version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytemuck"
version = "1.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
[[package]] [[package]]
name = "byteorder" name = "byteorder"
version = "1.5.0" version = "1.5.0"
@ -148,10 +234,31 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "chronogrid_ffi"
version = "0.1.0"
dependencies = [
"anyhow",
"chronogrid_core",
"chronogrid_crypto",
"flutter_rust_bridge",
"tempfile",
]
[[package]] [[package]]
name = "chronogrid_net" name = "chronogrid_net"
version = "0.1.0" version = "0.1.0"
[[package]]
name = "console_error_panic_hook"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
dependencies = [
"cfg-if",
"wasm-bindgen",
]
[[package]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.9.4" version = "0.9.4"
@ -206,6 +313,25 @@ dependencies = [
"typenum", "typenum",
] ]
[[package]]
name = "dart-sys"
version = "4.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57967e4b200d767d091b961d6ab42cc7d0cc14fe9e052e75d0d3cf9eb732d895"
dependencies = [
"cc",
]
[[package]]
name = "dashmap"
version = "4.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e77a43b28d0668df09411cb0bc9a8c2adc40f9a048afe863e05fd43251e8e39c"
dependencies = [
"cfg-if",
"num_cpus",
]
[[package]] [[package]]
name = "dbus" name = "dbus"
version = "0.9.12" version = "0.9.12"
@ -227,6 +353,17 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "delegate-attr"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a84c9a9c129b98e707ac9a31e204c10c064dd9f949b7da362310ff1a8b137d79"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
@ -244,6 +381,16 @@ version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
[[package]]
name = "env_logger"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580"
dependencies = [
"log",
"regex",
]
[[package]] [[package]]
name = "errno" name = "errno"
version = "0.3.14" version = "0.3.14"
@ -288,12 +435,113 @@ dependencies = [
"miniz_oxide", "miniz_oxide",
] ]
[[package]]
name = "flutter_rust_bridge"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5419323ff3ae8b63ed20b7cd0fd0e3e73fa9234b3b212b87b5586fe76ae52318"
dependencies = [
"allo-isolate",
"android_logger",
"anyhow",
"build-target",
"bytemuck",
"byteorder",
"console_error_panic_hook",
"dart-sys",
"delegate-attr",
"flutter_rust_bridge_macros",
"futures",
"js-sys",
"lazy_static",
"log",
"oslog",
"portable-atomic",
"threadpool",
"tokio",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "flutter_rust_bridge_macros"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c6fd06cacc0dfe931d00f3acdc5ac6c5dd9382b33dd78d71cce8f0a62e9458b"
dependencies = [
"hex",
"md-5",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "futures"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]] [[package]]
name = "futures-core" name = "futures-core"
version = "0.3.34" version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-executor"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
[[package]]
name = "futures-macro"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "futures-sink"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
[[package]] [[package]]
name = "futures-task" name = "futures-task"
version = "0.3.34" version = "0.3.34"
@ -306,8 +554,13 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [ dependencies = [
"futures-channel",
"futures-core", "futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task", "futures-task",
"memchr",
"pin-project-lite", "pin-project-lite",
"slab", "slab",
] ]
@ -353,6 +606,12 @@ dependencies = [
"r-efi", "r-efi",
] ]
[[package]]
name = "gimli"
version = "0.32.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.14.5" version = "0.14.5"
@ -371,6 +630,12 @@ dependencies = [
"hashbrown", "hashbrown",
] ]
[[package]]
name = "hermit-abi"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]] [[package]]
name = "hex" name = "hex"
version = "0.4.3" version = "0.4.3"
@ -426,6 +691,12 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]] [[package]]
name = "leb128" name = "leb128"
version = "0.2.7" version = "0.2.7"
@ -471,6 +742,22 @@ version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "md-5"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [
"cfg-if",
"digest",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"
@ -481,6 +768,25 @@ dependencies = [
"simd-adler32", "simd-adler32",
] ]
[[package]]
name = "num_cpus"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
dependencies = [
"hermit-abi",
"libc",
]
[[package]]
name = "object"
version = "0.37.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.4" version = "1.21.4"
@ -509,6 +815,17 @@ dependencies = [
"vcpkg", "vcpkg",
] ]
[[package]]
name = "oslog"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8343ce955f18e7e68c0207dd0ea776ec453035685395ababd2ea651c569728b3"
dependencies = [
"cc",
"dashmap",
"log",
]
[[package]] [[package]]
name = "password-hash" name = "password-hash"
version = "0.5.0" version = "0.5.0"
@ -532,6 +849,12 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "portable-atomic"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
[[package]] [[package]]
name = "ppv-lite86" name = "ppv-lite86"
version = "0.2.21" version = "0.2.21"
@ -604,6 +927,35 @@ dependencies = [
"rand_core", "rand_core",
] ]
[[package]]
name = "regex"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]] [[package]]
name = "rusqlite" name = "rusqlite"
version = "0.31.0" version = "0.31.0"
@ -618,6 +970,12 @@ dependencies = [
"smallvec", "smallvec",
] ]
[[package]]
name = "rustc-demangle"
version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb"
[[package]] [[package]]
name = "rustix" name = "rustix"
version = "1.1.4" version = "1.1.4"
@ -838,6 +1196,15 @@ dependencies = [
"syn 3.0.3", "syn 3.0.3",
] ]
[[package]]
name = "threadpool"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa"
dependencies = [
"num_cpus",
]
[[package]] [[package]]
name = "tinyvec" name = "tinyvec"
version = "1.12.0" version = "1.12.0"
@ -853,6 +1220,15 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
dependencies = [
"pin-project-lite",
]
[[package]] [[package]]
name = "tracing" name = "tracing"
version = "0.1.44" version = "0.1.44"
@ -945,6 +1321,16 @@ dependencies = [
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.77"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]] [[package]]
name = "wasm-bindgen-macro" name = "wasm-bindgen-macro"
version = "0.2.127" version = "0.2.127"
@ -977,6 +1363,16 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "web-sys"
version = "0.3.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]] [[package]]
name = "windows-link" name = "windows-link"
version = "0.2.1" version = "0.2.1"

View File

@ -3,6 +3,7 @@ resolver = "2"
members = [ members = [
"crates/chronogrid_core", "crates/chronogrid_core",
"crates/chronogrid_crypto", "crates/chronogrid_crypto",
"crates/chronogrid_ffi",
"crates/chronogrid_net", "crates/chronogrid_net",
"crates/chronogrid_ai", "crates/chronogrid_ai",
] ]

View File

@ -34,6 +34,22 @@
- 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. - 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`. - Full ADR at `docs/ARCHITECTURE/ADR/0004-core-automerge-crdt-integration.md`.
## [2026-08-20] - ADL-005: chronogrid_ffi Crate – Flutter Rust Bridge v2 FFI Layer
- **Status**: Accepted
- **Context**: Task 1.5 required generating `flutter_rust_bridge` FFI bindings to bridge all Rust storage, CRDT, and cryptographic methods to the Flutter/Dart presentation layer.
- **Decision**:
- Created `crates/chronogrid_ffi/` crate with `cdylib` + `staticlib` + `lib` targets, depending on `chronogrid_core`, `chronogrid_crypto`, `flutter_rust_bridge = "=2.7.0"`, and `anyhow = "1"`.
- API uses a **serialized-bytes pattern**: CRDT documents cross FFI as `Vec<u8>` (Automerge binary); read functions return DTO structs (`FfiTaskData`, `FfiNoteData`, `FfiDerivedKey`).
- Global `Engine` singleton (`Mutex<Option<Engine>>`) holds the `Database` connection + `Hlc` clock. Thread-safe `with_engine()` / `with_engine_mut()` helpers provide access from FRB's isolate thread pool.
- 38 public FFI functions across 5 API modules: `engine` (init/close/status), `database` (CRDT doc persistence), `crypto` (key derivation + platform-gated keychain), `task_crdt` (18 create/read/set/clear/merge ops), `note_crdt` (8 create/read/set/clear/merge ops).
- All fallible functions return `anyhow::Result<T>`, which FRB v2 maps to Dart exceptions.
- Keychain functions compile on all platforms but return an error on Android (delegated to Flutter KeyStore bridge).
- `flutter_rust_bridge.yaml` configures codegen: Rust input from `api/`, Dart output to `apps/chronogrid_flutter/lib/src/rust/`.
- Flutter `pubspec.yaml` updated with `flutter_rust_bridge: ^2.7.0`, `ffi: ^2.1.0`, `meta: ^1.11.0`.
- 25 unit tests (all passing): engine lifecycle, DB CRDT doc persistence, task/note CRDT create/read/mutate/merge round-trips, crypto derivation, stateless read validation, no-engine error paths.
- Full ADR at `docs/ARCHITECTURE/ADR/0005-ffi-bridge-flutter-rust-bridge.md`.
## [2026-08-09] - ADL-002: chronogrid_crypto Crate – Argon2id KDF & OS Keychain ## [2026-08-09] - ADL-002: chronogrid_crypto Crate – Argon2id KDF & OS Keychain
- **Status**: Accepted - **Status**: Accepted

View File

@ -1,6 +1,12 @@
{ {
"configVersion": 2, "configVersion": 2,
"packages": [ "packages": [
{
"name": "args",
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/args-2.7.0",
"packageUri": "lib/",
"languageVersion": "3.3"
},
{ {
"name": "async", "name": "async",
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/async-2.13.1", "rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/async-2.13.1",
@ -13,6 +19,12 @@
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.1" "languageVersion": "3.1"
}, },
{
"name": "build_cli_annotations",
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/build_cli_annotations-2.1.1",
"packageUri": "lib/",
"languageVersion": "3.8"
},
{ {
"name": "characters", "name": "characters",
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/characters-1.4.1", "rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/characters-1.4.1",
@ -37,24 +49,60 @@
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.3" "languageVersion": "3.3"
}, },
{
"name": "ffi",
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/ffi-2.2.0",
"packageUri": "lib/",
"languageVersion": "3.7"
},
{
"name": "file",
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/file-7.0.1",
"packageUri": "lib/",
"languageVersion": "3.0"
},
{ {
"name": "flutter", "name": "flutter",
"rootUri": "file:///C:/Users/mtkel/AppData/Local/flutter/packages/flutter", "rootUri": "file:///C:/Users/mtkel/AppData/Local/flutter/packages/flutter",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.11" "languageVersion": "3.11"
}, },
{
"name": "flutter_driver",
"rootUri": "file:///C:/Users/mtkel/AppData/Local/flutter/packages/flutter_driver",
"packageUri": "lib/",
"languageVersion": "3.11"
},
{ {
"name": "flutter_lints", "name": "flutter_lints",
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/flutter_lints-5.0.0", "rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/flutter_lints-5.0.0",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.5" "languageVersion": "3.5"
}, },
{
"name": "flutter_rust_bridge",
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/flutter_rust_bridge-2.12.0",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{ {
"name": "flutter_test", "name": "flutter_test",
"rootUri": "file:///C:/Users/mtkel/AppData/Local/flutter/packages/flutter_test", "rootUri": "file:///C:/Users/mtkel/AppData/Local/flutter/packages/flutter_test",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.11" "languageVersion": "3.11"
}, },
{
"name": "fuchsia_remote_debug_protocol",
"rootUri": "file:///C:/Users/mtkel/AppData/Local/flutter/packages/fuchsia_remote_debug_protocol",
"packageUri": "lib/",
"languageVersion": "3.11"
},
{
"name": "integration_test",
"rootUri": "file:///C:/Users/mtkel/AppData/Local/flutter/packages/integration_test",
"packageUri": "lib/",
"languageVersion": "3.11"
},
{ {
"name": "leak_tracker", "name": "leak_tracker",
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/leak_tracker-11.0.2", "rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/leak_tracker-11.0.2",
@ -103,6 +151,18 @@
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.4" "languageVersion": "3.4"
}, },
{
"name": "platform",
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/platform-3.1.6",
"packageUri": "lib/",
"languageVersion": "3.2"
},
{
"name": "process",
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/process-5.0.5",
"packageUri": "lib/",
"languageVersion": "3.5"
},
{ {
"name": "sky_engine", "name": "sky_engine",
"rootUri": "file:///C:/Users/mtkel/AppData/Local/flutter/bin/cache/pkg/sky_engine", "rootUri": "file:///C:/Users/mtkel/AppData/Local/flutter/bin/cache/pkg/sky_engine",
@ -133,6 +193,12 @@
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.1" "languageVersion": "3.1"
}, },
{
"name": "sync_http",
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/sync_http-0.3.1",
"packageUri": "lib/",
"languageVersion": "2.12"
},
{ {
"name": "term_glyph", "name": "term_glyph",
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/term_glyph-1.2.2", "rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/term_glyph-1.2.2",
@ -157,6 +223,18 @@
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.5" "languageVersion": "3.5"
}, },
{
"name": "web",
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/web-1.1.1",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "webdriver",
"rootUri": "file:///C:/Users/mtkel/AppData/Local/Pub/Cache/hosted/pub.dev/webdriver-3.1.0",
"packageUri": "lib/",
"languageVersion": "3.1"
},
{ {
"name": "chronogrid_flutter", "name": "chronogrid_flutter",
"rootUri": "../", "rootUri": "../",

View File

@ -7,11 +7,26 @@
"name": "chronogrid_flutter", "name": "chronogrid_flutter",
"version": "0.1.0+1", "version": "0.1.0+1",
"dependencies": [ "dependencies": [
"flutter" "ffi",
"flutter",
"flutter_rust_bridge",
"meta"
], ],
"devDependencies": [ "devDependencies": [
"flutter_lints", "flutter_lints",
"flutter_test" "flutter_test",
"integration_test"
]
},
{
"name": "integration_test",
"version": "0.0.0",
"dependencies": [
"flutter",
"flutter_driver",
"flutter_test",
"path",
"vm_service"
] ]
}, },
{ {
@ -39,6 +54,11 @@
"vector_math" "vector_math"
] ]
}, },
{
"name": "meta",
"version": "1.19.0",
"dependencies": []
},
{ {
"name": "flutter", "name": "flutter",
"version": "0.0.0", "version": "0.0.0",
@ -51,6 +71,31 @@
"vector_math" "vector_math"
] ]
}, },
{
"name": "vm_service",
"version": "15.2.0",
"dependencies": []
},
{
"name": "path",
"version": "1.9.1",
"dependencies": []
},
{
"name": "flutter_driver",
"version": "0.0.0",
"dependencies": [
"file",
"flutter",
"flutter_test",
"fuchsia_remote_debug_protocol",
"matcher",
"meta",
"path",
"vm_service",
"webdriver"
]
},
{ {
"name": "lints", "name": "lints",
"version": "5.1.1", "version": "5.1.1",
@ -79,6 +124,11 @@
"meta" "meta"
] ]
}, },
{
"name": "vector_math",
"version": "2.4.2",
"dependencies": []
},
{ {
"name": "stack_trace", "name": "stack_trace",
"version": "1.12.1", "version": "1.12.1",
@ -99,11 +149,6 @@
"collection" "collection"
] ]
}, },
{
"name": "path",
"version": "1.9.1",
"dependencies": []
},
{ {
"name": "matcher", "name": "matcher",
"version": "0.12.20", "version": "0.12.20",
@ -147,6 +192,15 @@
"version": "1.4.1", "version": "1.4.1",
"dependencies": [] "dependencies": []
}, },
{
"name": "fuchsia_remote_debug_protocol",
"version": "0.0.0",
"dependencies": [
"meta",
"process",
"vm_service"
]
},
{ {
"name": "async", "name": "async",
"version": "2.13.1", "version": "2.13.1",
@ -205,18 +259,75 @@
] ]
}, },
{ {
"name": "vm_service", "name": "ffi",
"version": "15.2.0", "version": "2.2.0",
"dependencies": [] "dependencies": []
}, },
{ {
"name": "meta", "name": "flutter_rust_bridge",
"version": "1.19.0", "version": "2.12.0",
"dependencies": [
"args",
"async",
"build_cli_annotations",
"meta",
"path",
"web"
]
},
{
"name": "build_cli_annotations",
"version": "2.1.1",
"dependencies": [
"args",
"meta"
]
},
{
"name": "args",
"version": "2.7.0",
"dependencies": [] "dependencies": []
}, },
{ {
"name": "vector_math", "name": "web",
"version": "2.4.2", "version": "1.1.1",
"dependencies": []
},
{
"name": "process",
"version": "5.0.5",
"dependencies": [
"file",
"path",
"platform"
]
},
{
"name": "file",
"version": "7.0.1",
"dependencies": [
"meta",
"path"
]
},
{
"name": "platform",
"version": "3.1.6",
"dependencies": []
},
{
"name": "webdriver",
"version": "3.1.0",
"dependencies": [
"matcher",
"path",
"stack_trace",
"sync_http"
]
},
{
"name": "sync_http",
"version": "0.3.1",
"dependencies": [] "dependencies": []
} }
], ],

View File

@ -1,6 +1,14 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async: async:
dependency: transitive dependency: transitive
description: description:
@ -17,6 +25,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.2" version: "2.1.2"
build_cli_annotations:
dependency: transitive
description:
name: build_cli_annotations
sha256: e563c2e01de8974566a1998410d3f6f03521788160a02503b0b1f1a46c7b3d95
url: "https://pub.dev"
source: hosted
version: "2.1.1"
characters: characters:
dependency: transitive dependency: transitive
description: description:
@ -49,11 +65,32 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.3" version: "1.3.3"
ffi:
dependency: "direct main"
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_driver:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
flutter_lints: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@ -62,11 +99,29 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.0.0" version: "5.0.0"
flutter_rust_bridge:
dependency: "direct main"
description:
name: flutter_rust_bridge
sha256: e87d6b9ee934dcd24a128ccb2bd91905d2d5fe5c06245d6a8f5477d4907a437a
url: "https://pub.dev"
source: hosted
version: "2.12.0"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
fuchsia_remote_debug_protocol:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
integration_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@ -116,7 +171,7 @@ packages:
source: hosted source: hosted
version: "0.13.0" version: "0.13.0"
meta: meta:
dependency: transitive dependency: "direct main"
description: description:
name: meta name: meta
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
@ -131,6 +186,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.1" version: "1.9.1"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
process:
dependency: transitive
description:
name: process
sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744
url: "https://pub.dev"
source: hosted
version: "5.0.5"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@ -168,6 +239,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.1" version: "1.4.1"
sync_http:
dependency: transitive
description:
name: sync_http
sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961"
url: "https://pub.dev"
source: hosted
version: "0.3.1"
term_glyph: term_glyph:
dependency: transitive dependency: transitive
description: description:
@ -200,6 +279,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "15.2.0" version: "15.2.0"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
webdriver:
dependency: transitive
description:
name: webdriver
sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
sdks: sdks:
dart: ">=3.11.0-0 <4.0.0" dart: ">=3.11.0-0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54" flutter: ">=3.18.0-18.0.pre.54"

View File

@ -9,11 +9,16 @@ environment:
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
flutter_rust_bridge: ^2.7.0
ffi: ^2.1.0
meta: ^1.11.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
flutter_lints: ^5.0.0 flutter_lints: ^5.0.0
integration_test:
sdk: flutter
flutter: flutter:
uses-material-design: true uses-material-design: true

View File

@ -0,0 +1,19 @@
[package]
name = "chronogrid_ffi"
description = "Flutter Rust Bridge FFI layer – bridges chronogrid_core and chronogrid_crypto to Dart"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
publish = false
[lib]
crate-type = ["lib", "cdylib", "staticlib"]
[dependencies]
chronogrid_core = { path = "../chronogrid_core" }
chronogrid_crypto = { path = "../chronogrid_crypto" }
flutter_rust_bridge = "=2.7.0"
anyhow = "1"
[dev-dependencies]
tempfile = "3"

View File

@ -0,0 +1,155 @@
//! Cryptographic operations — Argon2id key derivation and OS keychain access.
//!
//! These functions wrap [`chronogrid_crypto`] for FFI consumption.
//! Key derivation is stateless and does **not** require an active engine.
//! Keychain operations are platform-gated: on Android they return an error
//! because credential storage is delegated to the Flutter KeyStore bridge.
use super::types::FfiDerivedKey;
/// Derives a 256-bit master key from a passphrase using Argon2id.
///
/// If `salt` is `None`, a fresh 16-byte random salt is generated.
/// If `salt` is provided, it **must** be exactly 16 bytes.
///
/// Returns the derived key and the salt that was used (needed to re-derive
/// the same key later).
pub fn crypto_derive_master_key(
passphrase: String,
salt: Option<Vec<u8>>,
) -> anyhow::Result<FfiDerivedKey> {
let salt_ref = salt.as_deref();
let derived = chronogrid_crypto::kdf::derive_master_key(&passphrase, salt_ref)
.map_err(|e| anyhow::anyhow!("key derivation failed: {e}"))?;
Ok(FfiDerivedKey {
key: derived.key().to_vec(),
salt: derived.salt().to_vec(),
})
}
/// Generates a cryptographically random 16-byte salt.
///
/// This is a convenience wrapper; [`crypto_derive_master_key`] already
/// generates a salt internally when `salt` is `None`.
pub fn crypto_generate_salt() -> Vec<u8> {
chronogrid_crypto::kdf::generate_salt().to_vec()
}
/// Stores raw key material in the OS credential store.
///
/// The bytes are Base64-encoded internally for credential-manager
/// compatibility.
///
/// # Platform Support
///
/// * **Windows**: Windows Credential Manager (DPAPI)
/// * **Linux**: SecretService D-Bus API (libsecret)
/// * **macOS**: macOS Keychain
/// * **Android**: Returns an error — use Flutter KeyStore bridge instead.
pub fn crypto_store_credential(
service: String,
account: String,
key_material: Vec<u8>,
) -> anyhow::Result<()> {
#[cfg(not(target_os = "android"))]
{
chronogrid_crypto::keychain::store_credential(&service, &account, &key_material)
.map_err(|e| anyhow::anyhow!("keychain store failed: {e}"))
}
#[cfg(target_os = "android")]
{
let _ = (service, account, key_material);
anyhow::bail!(
"OS keychain is not available on Android; \
use the Flutter KeyStore bridge instead"
)
}
}
/// Retrieves raw key material from the OS credential store.
///
/// # Errors
///
/// Returns an error if the credential is not found or the keychain
/// backend fails. On Android, always returns an error.
pub fn crypto_retrieve_credential(
service: String,
account: String,
) -> anyhow::Result<Vec<u8>> {
#[cfg(not(target_os = "android"))]
{
chronogrid_crypto::keychain::retrieve_credential(&service, &account)
.map_err(|e| anyhow::anyhow!("keychain retrieve failed: {e}"))
}
#[cfg(target_os = "android")]
{
let _ = (service, account);
anyhow::bail!(
"OS keychain is not available on Android; \
use the Flutter KeyStore bridge instead"
)
}
}
/// Deletes a credential from the OS credential store.
///
/// Idempotent — succeeds even if the credential does not exist.
/// On Android, always returns an error.
pub fn crypto_delete_credential(
service: String,
account: String,
) -> anyhow::Result<()> {
#[cfg(not(target_os = "android"))]
{
chronogrid_crypto::keychain::delete_credential(&service, &account)
.map_err(|e| anyhow::anyhow!("keychain delete failed: {e}"))
}
#[cfg(target_os = "android")]
{
let _ = (service, account);
anyhow::bail!(
"OS keychain is not available on Android; \
use the Flutter KeyStore bridge instead"
)
}
}
// ── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn derive_key_with_random_salt() {
let dk = crypto_derive_master_key("correct horse battery staple".into(), None)
.expect("derivation should succeed");
assert_eq!(dk.key.len(), 32);
assert_eq!(dk.salt.len(), 16);
}
#[test]
fn derive_key_deterministic_with_same_salt() {
let salt = vec![0xABu8; 16];
let dk1 =
crypto_derive_master_key("hunter2".into(), Some(salt.clone())).unwrap();
let dk2 =
crypto_derive_master_key("hunter2".into(), Some(salt)).unwrap();
assert_eq!(dk1.key, dk2.key, "same passphrase + salt must yield same key");
}
#[test]
fn reject_invalid_salt_length() {
let bad_salt = vec![0u8; 10];
let result = crypto_derive_master_key("pw".into(), Some(bad_salt));
assert!(result.is_err());
}
#[test]
fn generate_salt_returns_16_bytes() {
let salt = crypto_generate_salt();
assert_eq!(salt.len(), 16);
}
}

View File

@ -0,0 +1,116 @@
//! CRDT document persistence — save / load / delete serialized Automerge
//! documents in the encrypted SQLCipher `crdt_docs` table.
//!
//! All functions require an active engine (see [`super::engine::init_engine`]).
use super::state::{with_engine, with_engine_mut};
/// Persists (upserts) a serialized Automerge CRDT document.
///
/// # Arguments
///
/// * `entity_id` — Primary key (typically a UUIDv4 matching a task or note).
/// * `entity_type` — `"task"` or `"note"`.
/// * `doc_bytes` — Serialized Automerge document bytes.
/// * `updated_at` — Timestamp (Unix epoch ms) for the latest mutation.
pub fn db_save_crdt_doc(
entity_id: String,
entity_type: String,
doc_bytes: Vec<u8>,
updated_at: i64,
) -> anyhow::Result<()> {
// Use with_engine_mut to ensure exclusive access during write.
with_engine_mut(|engine| {
engine
.db
.save_crdt_doc(&entity_id, &entity_type, &doc_bytes, updated_at as u64)
.map_err(|e| anyhow::anyhow!("save_crdt_doc failed: {e}"))
})
}
/// Loads the serialized Automerge CRDT document for the given entity.
///
/// Returns `None` if no document has been stored for `entity_id`.
pub fn db_load_crdt_doc(entity_id: String) -> anyhow::Result<Option<Vec<u8>>> {
with_engine(|engine| {
engine
.db
.load_crdt_doc(&entity_id)
.map_err(|e| anyhow::anyhow!("load_crdt_doc failed: {e}"))
})
}
/// Deletes the CRDT document for the given entity.
///
/// Returns `true` if a row was deleted, `false` if none existed.
pub fn db_delete_crdt_doc(entity_id: String) -> anyhow::Result<bool> {
with_engine(|engine| {
engine
.db
.delete_crdt_doc(&entity_id)
.map_err(|e| anyhow::anyhow!("delete_crdt_doc failed: {e}"))
})
}
// ── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::super::engine::{close_engine, init_engine_in_memory};
use super::*;
const TEST_KEY: [u8; 32] = [0xAA; 32];
const NODE: &str = "test-node";
fn setup() {
let _ = close_engine();
init_engine_in_memory(TEST_KEY.to_vec(), NODE.into()).unwrap();
}
fn teardown() {
let _ = close_engine();
}
#[test]
fn save_load_round_trip() {
setup();
let blob = b"fake-automerge-bytes".to_vec();
db_save_crdt_doc("t-001".into(), "task".into(), blob.clone(), 1000).unwrap();
let loaded = db_load_crdt_doc("t-001".into()).unwrap();
assert_eq!(loaded, Some(blob));
teardown();
}
#[test]
fn load_missing_returns_none() {
setup();
let loaded = db_load_crdt_doc("nonexistent".into()).unwrap();
assert_eq!(loaded, None);
teardown();
}
#[test]
fn delete_existing_returns_true() {
setup();
db_save_crdt_doc("t-002".into(), "task".into(), b"data".to_vec(), 1000).unwrap();
assert!(db_delete_crdt_doc("t-002".into()).unwrap());
assert_eq!(db_load_crdt_doc("t-002".into()).unwrap(), None);
teardown();
}
#[test]
fn delete_missing_returns_false() {
setup();
assert!(!db_delete_crdt_doc("nonexistent".into()).unwrap());
teardown();
}
#[test]
fn operations_without_engine_fail() {
let _ = close_engine();
assert!(db_load_crdt_doc("x".into()).is_err());
}
}

View File

@ -0,0 +1,141 @@
//! Engine lifecycle — initialize, query, and tear down the core runtime.
//!
//! The engine must be initialized before any database or CRDT mutation
//! functions can be called. Initialization opens (or creates) the encrypted
//! SQLCipher database and seeds the node's Hybrid Logical Clock.
use chronogrid_core::{Database, Hlc};
use super::state::{Engine, ENGINE};
/// Initializes the engine with a **file-backed** encrypted database.
///
/// # Arguments
///
/// * `db_path` — Filesystem path for the SQLCipher database file.
/// * `key` — 32-byte raw encryption key (from Argon2id derivation).
/// * `node_id` — Unique identifier for this device / peer (e.g. a UUIDv4).
///
/// # Errors
///
/// * If the engine is already initialized.
/// * If the key is not exactly 32 bytes.
/// * If the database cannot be opened or the schema fails to initialize.
pub fn init_engine(db_path: String, key: Vec<u8>, node_id: String) -> anyhow::Result<()> {
let mut guard = ENGINE
.lock()
.map_err(|e| anyhow::anyhow!("engine lock poisoned: {e}"))?;
if guard.is_some() {
anyhow::bail!("engine is already initialized — call close_engine first");
}
let db = Database::open(&db_path, &key)
.map_err(|e| anyhow::anyhow!("failed to open database at '{db_path}': {e}"))?;
let hlc = Hlc::new(node_id);
*guard = Some(Engine { db, hlc });
Ok(())
}
/// Initializes the engine with an **in-memory** database (for testing).
///
/// Semantics are identical to [`init_engine`] except no file is created.
pub fn init_engine_in_memory(key: Vec<u8>, node_id: String) -> anyhow::Result<()> {
let mut guard = ENGINE
.lock()
.map_err(|e| anyhow::anyhow!("engine lock poisoned: {e}"))?;
if guard.is_some() {
anyhow::bail!("engine is already initialized — call close_engine first");
}
let db = Database::open_in_memory(&key)
.map_err(|e| anyhow::anyhow!("failed to open in-memory database: {e}"))?;
let hlc = Hlc::new(node_id);
*guard = Some(Engine { db, hlc });
Ok(())
}
/// Shuts down the engine, closing the database connection.
///
/// After this call, all database and CRDT mutation functions will return
/// errors until [`init_engine`] or [`init_engine_in_memory`] is called again.
///
/// Calling `close_engine` when no engine is active is a no-op.
pub fn close_engine() -> anyhow::Result<()> {
let mut guard = ENGINE
.lock()
.map_err(|e| anyhow::anyhow!("engine lock poisoned: {e}"))?;
// Drop the engine (and its database connection) by replacing with None.
*guard = None;
Ok(())
}
/// Returns `true` if the engine has been initialized and is ready for use.
pub fn is_engine_initialized() -> bool {
ENGINE
.lock()
.map(|guard| guard.is_some())
.unwrap_or(false)
}
// ── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
/// 32-byte test key.
const TEST_KEY: [u8; 32] = [0xAA; 32];
const NODE: &str = "test-node";
/// Helper: ensure the engine is closed before / after each test to avoid
/// cross-test state leakage via the global singleton.
fn reset_engine() {
let _ = close_engine();
}
#[test]
fn init_and_close_lifecycle() {
reset_engine();
assert!(!is_engine_initialized());
init_engine_in_memory(TEST_KEY.to_vec(), NODE.into()).unwrap();
assert!(is_engine_initialized());
close_engine().unwrap();
assert!(!is_engine_initialized());
}
#[test]
fn double_init_is_rejected() {
reset_engine();
init_engine_in_memory(TEST_KEY.to_vec(), NODE.into()).unwrap();
let result = init_engine_in_memory(TEST_KEY.to_vec(), NODE.into());
assert!(result.is_err());
reset_engine();
}
#[test]
fn close_without_init_is_noop() {
reset_engine();
// Should not panic or error.
close_engine().unwrap();
}
#[test]
fn bad_key_length_is_rejected() {
reset_engine();
let bad_key = vec![0u8; 16];
let result = init_engine_in_memory(bad_key, NODE.into());
assert!(result.is_err());
}
}

View File

@ -0,0 +1,29 @@
//! FFI API surface for `flutter_rust_bridge` code generation.
//!
//! Every `pub fn` in the submodules below becomes a callable Dart function
//! after running `flutter_rust_bridge_codegen generate`.
//!
//! ## Module Layout
//!
//! | Module | Responsibility |
//! |---------------|-------------------------------------------------------|
//! | [`types`] | FFI-safe DTO structs (auto-translated to Dart classes)|
//! | [`engine`] | Engine lifecycle (init / close / status) |
//! | [`database`] | CRDT document persistence via SQLCipher |
//! | [`crypto`] | Argon2id key derivation and OS keychain ops |
//! | [`task_crdt`] | Task CRDT create / read / mutate / merge |
//! | [`note_crdt`] | Note CRDT create / read / mutate / merge |
//!
//! ## Internal State
//!
//! The [`state`] module (crate-internal) holds the global engine singleton.
//! API functions acquire the engine lock as needed.
pub(crate) mod state;
pub mod crypto;
pub mod database;
pub mod engine;
pub mod note_crdt;
pub mod task_crdt;
pub mod types;

View File

@ -0,0 +1,287 @@
//! Note CRDT operations — create, read, mutate, and merge note documents.
//!
//! ## Data Flow
//!
//! ```text
//! Dart → note_crdt_create(id, ns, title, content) → Vec<u8>
//! Dart → db_save_crdt_doc(id, "note", bytes, ts)
//! Dart → db_load_crdt_doc(id) → Vec<u8>
//! Dart → note_crdt_read(bytes) → FfiNoteData
//! Dart → note_crdt_set_title(bytes, title) → Vec<u8>
//! ```
//!
//! Mutation functions require an active engine (HLC). Read and merge are
//! stateless.
use chronogrid_core::NoteCrdt;
use super::state::with_engine_mut;
use super::types::FfiNoteData;
// ── Construction ─────────────────────────────────────────────────────────────
/// Creates a new note CRDT document and returns its serialized bytes.
///
/// **Requires active engine** (for HLC).
pub fn note_crdt_create(
id: String,
namespace_id: String,
title: String,
content_markdown: String,
) -> anyhow::Result<Vec<u8>> {
with_engine_mut(|engine| {
let mut note =
NoteCrdt::new(&id, &namespace_id, &title, &content_markdown, &mut engine.hlc)
.map_err(|e| anyhow::anyhow!("note_crdt_create failed: {e}"))?;
Ok(note.save())
})
}
// ── Read ─────────────────────────────────────────────────────────────────────
/// Deserializes a note CRDT document and returns a structured snapshot.
///
/// **Stateless** — does not require an active engine.
pub fn note_crdt_read(doc_bytes: Vec<u8>) -> anyhow::Result<FfiNoteData> {
let note = NoteCrdt::from_bytes(&doc_bytes)
.map_err(|e| anyhow::anyhow!("note_crdt_read: failed to load doc: {e}"))?;
Ok(FfiNoteData {
id: note.entity_id()?.unwrap_or_default(),
namespace_id: note.namespace_id()?.unwrap_or_default(),
project_id: note.project_id()?,
title: note.title()?.unwrap_or_default(),
content_markdown: note.content_markdown()?.unwrap_or_default(),
hlc_timestamp: note.hlc_timestamp()?.unwrap_or_default(),
})
}
// ── Field mutations ──────────────────────────────────────────────────────────
/// Sets the note title. **Requires active engine.**
pub fn note_crdt_set_title(doc_bytes: Vec<u8>, title: String) -> anyhow::Result<Vec<u8>> {
with_engine_mut(|engine| {
let mut note = NoteCrdt::from_bytes(&doc_bytes)
.map_err(|e| anyhow::anyhow!("{e}"))?;
note.set_title(&title, &mut engine.hlc)
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(note.save())
})
}
/// Sets the markdown content body.
pub fn note_crdt_set_content(
doc_bytes: Vec<u8>,
content_markdown: String,
) -> anyhow::Result<Vec<u8>> {
with_engine_mut(|engine| {
let mut note = NoteCrdt::from_bytes(&doc_bytes)
.map_err(|e| anyhow::anyhow!("{e}"))?;
note.set_content_markdown(&content_markdown, &mut engine.hlc)
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(note.save())
})
}
/// Moves the note to a different project.
pub fn note_crdt_set_project_id(
doc_bytes: Vec<u8>,
project_id: String,
) -> anyhow::Result<Vec<u8>> {
with_engine_mut(|engine| {
let mut note = NoteCrdt::from_bytes(&doc_bytes)
.map_err(|e| anyhow::anyhow!("{e}"))?;
note.set_project_id(&project_id, &mut engine.hlc)
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(note.save())
})
}
/// Removes the project association.
pub fn note_crdt_clear_project_id(doc_bytes: Vec<u8>) -> anyhow::Result<Vec<u8>> {
with_engine_mut(|engine| {
let mut note = NoteCrdt::from_bytes(&doc_bytes)
.map_err(|e| anyhow::anyhow!("{e}"))?;
note.clear_project_id(&mut engine.hlc)
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(note.save())
})
}
/// Moves the note to a different namespace.
pub fn note_crdt_set_namespace_id(
doc_bytes: Vec<u8>,
namespace_id: String,
) -> anyhow::Result<Vec<u8>> {
with_engine_mut(|engine| {
let mut note = NoteCrdt::from_bytes(&doc_bytes)
.map_err(|e| anyhow::anyhow!("{e}"))?;
note.set_namespace_id(&namespace_id, &mut engine.hlc)
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(note.save())
})
}
// ── Merge ────────────────────────────────────────────────────────────────────
/// Merges two note CRDT documents and returns the combined document bytes.
///
/// **Stateless** — does not require an active engine.
pub fn note_crdt_merge(doc_a: Vec<u8>, doc_b: Vec<u8>) -> anyhow::Result<Vec<u8>> {
let mut note_a = NoteCrdt::from_bytes(&doc_a)
.map_err(|e| anyhow::anyhow!("note_crdt_merge: failed to load doc_a: {e}"))?;
let mut note_b = NoteCrdt::from_bytes(&doc_b)
.map_err(|e| anyhow::anyhow!("note_crdt_merge: failed to load doc_b: {e}"))?;
note_a
.merge(&mut note_b)
.map_err(|e| anyhow::anyhow!("note_crdt_merge: merge failed: {e}"))?;
Ok(note_a.save())
}
// ── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::super::engine::{close_engine, init_engine_in_memory};
use super::*;
const TEST_KEY: [u8; 32] = [0xAA; 32];
const NODE: &str = "test-node";
fn setup() {
let _ = close_engine();
init_engine_in_memory(TEST_KEY.to_vec(), NODE.into()).unwrap();
}
fn teardown() {
let _ = close_engine();
}
#[test]
fn create_and_read_round_trip() {
setup();
let bytes = note_crdt_create(
"n-001".into(),
"ns-1".into(),
"My Note".into(),
"# Hello World".into(),
)
.unwrap();
let data = note_crdt_read(bytes).unwrap();
assert_eq!(data.id, "n-001");
assert_eq!(data.namespace_id, "ns-1");
assert_eq!(data.title, "My Note");
assert_eq!(data.content_markdown, "# Hello World");
assert!(!data.hlc_timestamp.is_empty());
teardown();
}
#[test]
fn set_all_fields_and_read() {
setup();
let mut bytes = note_crdt_create(
"n-002".into(),
"ns-1".into(),
"Title".into(),
"Body".into(),
)
.unwrap();
bytes = note_crdt_set_title(bytes, "New Title".into()).unwrap();
bytes = note_crdt_set_content(bytes, "## Updated\nBody text".into()).unwrap();
bytes = note_crdt_set_project_id(bytes, "p-001".into()).unwrap();
bytes = note_crdt_set_namespace_id(bytes, "ns-2".into()).unwrap();
let data = note_crdt_read(bytes).unwrap();
assert_eq!(data.title, "New Title");
assert_eq!(data.content_markdown, "## Updated\nBody text");
assert_eq!(data.project_id, Some("p-001".into()));
assert_eq!(data.namespace_id, "ns-2");
teardown();
}
#[test]
fn clear_optional_fields() {
setup();
let mut bytes = note_crdt_create(
"n-003".into(),
"ns-1".into(),
"Title".into(),
"Body".into(),
)
.unwrap();
bytes = note_crdt_set_project_id(bytes, "p-1".into()).unwrap();
bytes = note_crdt_clear_project_id(bytes).unwrap();
let data = note_crdt_read(bytes).unwrap();
assert_eq!(data.project_id, None);
teardown();
}
#[test]
fn merge_concurrent_edits() {
setup();
let bytes_a = note_crdt_create(
"n-004".into(),
"ns-1".into(),
"Note".into(),
"Original".into(),
)
.unwrap();
let bytes_b =
note_crdt_set_content(bytes_a.clone(), "Peer B content".into()).unwrap();
let bytes_a =
note_crdt_set_content(bytes_a, "Peer A content".into()).unwrap();
let merged = note_crdt_merge(bytes_a, bytes_b).unwrap();
let data = note_crdt_read(merged).unwrap();
assert!(
data.content_markdown == "Peer A content"
|| data.content_markdown == "Peer B content",
"merge must resolve to one of the concurrent writes, got: {}",
data.content_markdown
);
teardown();
}
#[test]
fn read_is_stateless() {
let _ = close_engine();
init_engine_in_memory(TEST_KEY.to_vec(), NODE.into()).unwrap();
let bytes = note_crdt_create(
"n-005".into(),
"ns-1".into(),
"Stateless".into(),
"Content".into(),
)
.unwrap();
close_engine().unwrap();
let data = note_crdt_read(bytes).unwrap();
assert_eq!(data.title, "Stateless");
}
#[test]
fn mutation_without_engine_fails() {
let _ = close_engine();
let result = note_crdt_create("x".into(), "y".into(), "z".into(), "w".into());
assert!(result.is_err());
}
}

View File

@ -0,0 +1,58 @@
//! Internal engine state — not part of the public FFI API.
//!
//! The [`Engine`] struct bundles an open [`Database`] connection and a
//! [`Hlc`] (Hybrid Logical Clock) for the local node. A single global
//! instance is held behind a [`Mutex`] so that FRB's thread-pool workers
//! can safely share access.
use std::sync::Mutex;
use chronogrid_core::{Database, Hlc};
/// Runtime engine holding the encrypted database and the node's HLC.
pub(crate) struct Engine {
/// Encrypted SQLCipher database connection with ChronoGrid schema.
pub(crate) db: Database,
/// Hybrid Logical Clock for causally ordered CRDT mutations.
pub(crate) hlc: Hlc,
}
/// Global engine singleton.
///
/// `None` before [`super::engine::init_engine`] is called, `Some` while the
/// engine is active, and `None` again after [`super::engine::close_engine`].
pub(crate) static ENGINE: Mutex<Option<Engine>> = Mutex::new(None);
// ── Convenience helpers ──────────────────────────────────────────────────────
/// Acquires the engine lock and calls `f` with a shared reference.
///
/// Returns `Err` if the engine has not been initialized or the lock is
/// poisoned.
pub(crate) fn with_engine<T>(
f: impl FnOnce(&Engine) -> anyhow::Result<T>,
) -> anyhow::Result<T> {
let guard = ENGINE
.lock()
.map_err(|e| anyhow::anyhow!("engine lock poisoned: {e}"))?;
let engine = guard
.as_ref()
.ok_or_else(|| anyhow::anyhow!("engine not initialized — call init_engine first"))?;
f(engine)
}
/// Acquires the engine lock and calls `f` with a mutable reference.
///
/// Returns `Err` if the engine has not been initialized or the lock is
/// poisoned.
pub(crate) fn with_engine_mut<T>(
f: impl FnOnce(&mut Engine) -> anyhow::Result<T>,
) -> anyhow::Result<T> {
let mut guard = ENGINE
.lock()
.map_err(|e| anyhow::anyhow!("engine lock poisoned: {e}"))?;
let engine = guard
.as_mut()
.ok_or_else(|| anyhow::anyhow!("engine not initialized — call init_engine first"))?;
f(engine)
}

View File

@ -0,0 +1,445 @@
//! Task CRDT operations — create, read, mutate, and merge task documents.
//!
//! ## Data Flow
//!
//! ```text
//! Dart → task_crdt_create(id, ns, title) → Vec<u8> (serialized Automerge doc)
//! Dart → db_save_crdt_doc(id, "task", bytes, ts) (persist to SQLCipher)
//! Dart → db_load_crdt_doc(id) → Vec<u8> (reload from DB)
//! Dart → task_crdt_read(bytes) → FfiTaskData (structured snapshot)
//! Dart → task_crdt_set_title(bytes, title) → Vec<u8>(mutated doc bytes)
//! ```
//!
//! Mutation functions (create, set_*) require an active engine because they
//! advance the node's Hybrid Logical Clock. Read and merge functions are
//! stateless.
use chronogrid_core::TaskCrdt;
use super::state::with_engine_mut;
use super::types::FfiTaskData;
// ── Construction ─────────────────────────────────────────────────────────────
/// Creates a new task CRDT document and returns its serialized bytes.
///
/// The document is initialized with `status = "backlog"`,
/// `priority_score = 0.0`, and `is_archived = false`.
///
/// **Requires active engine** (for HLC).
pub fn task_crdt_create(
id: String,
namespace_id: String,
title: String,
) -> anyhow::Result<Vec<u8>> {
with_engine_mut(|engine| {
let mut task = TaskCrdt::new(&id, &namespace_id, &title, &mut engine.hlc)
.map_err(|e| anyhow::anyhow!("task_crdt_create failed: {e}"))?;
Ok(task.save())
})
}
// ── Read ─────────────────────────────────────────────────────────────────────
/// Deserializes a task CRDT document and returns a structured snapshot.
///
/// **Stateless** — does not require an active engine.
pub fn task_crdt_read(doc_bytes: Vec<u8>) -> anyhow::Result<FfiTaskData> {
let task = TaskCrdt::from_bytes(&doc_bytes)
.map_err(|e| anyhow::anyhow!("task_crdt_read: failed to load doc: {e}"))?;
Ok(FfiTaskData {
id: task.entity_id()?.unwrap_or_default(),
namespace_id: task.namespace_id()?.unwrap_or_default(),
project_id: task.project_id()?,
title: task.title()?.unwrap_or_default(),
description_markdown: task.description_markdown()?,
status: task.status()?.unwrap_or_else(|| "backlog".into()),
priority_score: task.priority_score()?.unwrap_or(0.0),
eisenhower_quadrant: task.eisenhower_quadrant()?,
due_date: task.due_date()?.map(|v| v as i64),
estimated_duration: task.estimated_duration()?,
is_archived: task.is_archived()?.unwrap_or(false),
completed_at: task.completed_at()?.map(|v| v as i64),
hlc_timestamp: task.hlc_timestamp()?.unwrap_or_default(),
})
}
// ── Field mutations ──────────────────────────────────────────────────────────
//
// Each setter loads the doc from bytes, applies the mutation with an
// advancing HLC, re-serializes, and returns the updated bytes.
/// Sets the task title. **Requires active engine.**
pub fn task_crdt_set_title(doc_bytes: Vec<u8>, title: String) -> anyhow::Result<Vec<u8>> {
with_engine_mut(|engine| {
let mut task = TaskCrdt::from_bytes(&doc_bytes)
.map_err(|e| anyhow::anyhow!("{e}"))?;
task.set_title(&title, &mut engine.hlc)
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(task.save())
})
}
/// Sets the task status (`backlog`, `in_progress`, `review`, `completed`).
pub fn task_crdt_set_status(doc_bytes: Vec<u8>, status: String) -> anyhow::Result<Vec<u8>> {
with_engine_mut(|engine| {
let mut task = TaskCrdt::from_bytes(&doc_bytes)
.map_err(|e| anyhow::anyhow!("{e}"))?;
task.set_status(&status, &mut engine.hlc)
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(task.save())
})
}
/// Sets the markdown description.
pub fn task_crdt_set_description(
doc_bytes: Vec<u8>,
description: String,
) -> anyhow::Result<Vec<u8>> {
with_engine_mut(|engine| {
let mut task = TaskCrdt::from_bytes(&doc_bytes)
.map_err(|e| anyhow::anyhow!("{e}"))?;
task.set_description_markdown(&description, &mut engine.hlc)
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(task.save())
})
}
/// Clears the markdown description.
pub fn task_crdt_clear_description(doc_bytes: Vec<u8>) -> anyhow::Result<Vec<u8>> {
with_engine_mut(|engine| {
let mut task = TaskCrdt::from_bytes(&doc_bytes)
.map_err(|e| anyhow::anyhow!("{e}"))?;
task.clear_description(&mut engine.hlc)
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(task.save())
})
}
/// Sets the computed priority score.
pub fn task_crdt_set_priority_score(
doc_bytes: Vec<u8>,
score: f64,
) -> anyhow::Result<Vec<u8>> {
with_engine_mut(|engine| {
let mut task = TaskCrdt::from_bytes(&doc_bytes)
.map_err(|e| anyhow::anyhow!("{e}"))?;
task.set_priority_score(score, &mut engine.hlc)
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(task.save())
})
}
/// Sets the Eisenhower quadrant (1–4).
pub fn task_crdt_set_eisenhower_quadrant(
doc_bytes: Vec<u8>,
quadrant: i64,
) -> anyhow::Result<Vec<u8>> {
with_engine_mut(|engine| {
let mut task = TaskCrdt::from_bytes(&doc_bytes)
.map_err(|e| anyhow::anyhow!("{e}"))?;
task.set_eisenhower_quadrant(quadrant, &mut engine.hlc)
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(task.save())
})
}
/// Clears the Eisenhower quadrant.
pub fn task_crdt_clear_eisenhower_quadrant(doc_bytes: Vec<u8>) -> anyhow::Result<Vec<u8>> {
with_engine_mut(|engine| {
let mut task = TaskCrdt::from_bytes(&doc_bytes)
.map_err(|e| anyhow::anyhow!("{e}"))?;
task.clear_eisenhower_quadrant(&mut engine.hlc)
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(task.save())
})
}
/// Sets the due date (Unix epoch milliseconds).
pub fn task_crdt_set_due_date(doc_bytes: Vec<u8>, due_ms: i64) -> anyhow::Result<Vec<u8>> {
with_engine_mut(|engine| {
let mut task = TaskCrdt::from_bytes(&doc_bytes)
.map_err(|e| anyhow::anyhow!("{e}"))?;
task.set_due_date(due_ms as u64, &mut engine.hlc)
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(task.save())
})
}
/// Clears the due date.
pub fn task_crdt_clear_due_date(doc_bytes: Vec<u8>) -> anyhow::Result<Vec<u8>> {
with_engine_mut(|engine| {
let mut task = TaskCrdt::from_bytes(&doc_bytes)
.map_err(|e| anyhow::anyhow!("{e}"))?;
task.clear_due_date(&mut engine.hlc)
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(task.save())
})
}
/// Sets the estimated duration in minutes.
pub fn task_crdt_set_estimated_duration(
doc_bytes: Vec<u8>,
minutes: i64,
) -> anyhow::Result<Vec<u8>> {
with_engine_mut(|engine| {
let mut task = TaskCrdt::from_bytes(&doc_bytes)
.map_err(|e| anyhow::anyhow!("{e}"))?;
task.set_estimated_duration(minutes, &mut engine.hlc)
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(task.save())
})
}
/// Sets the archived flag.
pub fn task_crdt_set_is_archived(
doc_bytes: Vec<u8>,
archived: bool,
) -> anyhow::Result<Vec<u8>> {
with_engine_mut(|engine| {
let mut task = TaskCrdt::from_bytes(&doc_bytes)
.map_err(|e| anyhow::anyhow!("{e}"))?;
task.set_is_archived(archived, &mut engine.hlc)
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(task.save())
})
}
/// Sets the completion timestamp (Unix epoch milliseconds).
pub fn task_crdt_set_completed_at(
doc_bytes: Vec<u8>,
ts_ms: i64,
) -> anyhow::Result<Vec<u8>> {
with_engine_mut(|engine| {
let mut task = TaskCrdt::from_bytes(&doc_bytes)
.map_err(|e| anyhow::anyhow!("{e}"))?;
task.set_completed_at(ts_ms as u64, &mut engine.hlc)
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(task.save())
})
}
/// Clears the completion timestamp.
pub fn task_crdt_clear_completed_at(doc_bytes: Vec<u8>) -> anyhow::Result<Vec<u8>> {
with_engine_mut(|engine| {
let mut task = TaskCrdt::from_bytes(&doc_bytes)
.map_err(|e| anyhow::anyhow!("{e}"))?;
task.clear_completed_at(&mut engine.hlc)
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(task.save())
})
}
/// Moves the task to a different project.
pub fn task_crdt_set_project_id(
doc_bytes: Vec<u8>,
project_id: String,
) -> anyhow::Result<Vec<u8>> {
with_engine_mut(|engine| {
let mut task = TaskCrdt::from_bytes(&doc_bytes)
.map_err(|e| anyhow::anyhow!("{e}"))?;
task.set_project_id(&project_id, &mut engine.hlc)
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(task.save())
})
}
/// Removes the project association.
pub fn task_crdt_clear_project_id(doc_bytes: Vec<u8>) -> anyhow::Result<Vec<u8>> {
with_engine_mut(|engine| {
let mut task = TaskCrdt::from_bytes(&doc_bytes)
.map_err(|e| anyhow::anyhow!("{e}"))?;
task.clear_project_id(&mut engine.hlc)
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(task.save())
})
}
/// Moves the task to a different namespace.
pub fn task_crdt_set_namespace_id(
doc_bytes: Vec<u8>,
namespace_id: String,
) -> anyhow::Result<Vec<u8>> {
with_engine_mut(|engine| {
let mut task = TaskCrdt::from_bytes(&doc_bytes)
.map_err(|e| anyhow::anyhow!("{e}"))?;
task.set_namespace_id(&namespace_id, &mut engine.hlc)
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(task.save())
})
}
// ── Merge ────────────────────────────────────────────────────────────────────
/// Merges two task CRDT documents and returns the combined document bytes.
///
/// **Stateless** — does not require an active engine. Automerge resolves
/// concurrent writes deterministically via its built-in LWW semantics.
pub fn task_crdt_merge(doc_a: Vec<u8>, doc_b: Vec<u8>) -> anyhow::Result<Vec<u8>> {
let mut task_a = TaskCrdt::from_bytes(&doc_a)
.map_err(|e| anyhow::anyhow!("task_crdt_merge: failed to load doc_a: {e}"))?;
let mut task_b = TaskCrdt::from_bytes(&doc_b)
.map_err(|e| anyhow::anyhow!("task_crdt_merge: failed to load doc_b: {e}"))?;
task_a
.merge(&mut task_b)
.map_err(|e| anyhow::anyhow!("task_crdt_merge: merge failed: {e}"))?;
Ok(task_a.save())
}
// ── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::super::engine::{close_engine, init_engine_in_memory};
use super::*;
const TEST_KEY: [u8; 32] = [0xAA; 32];
const NODE: &str = "test-node";
fn setup() {
let _ = close_engine();
init_engine_in_memory(TEST_KEY.to_vec(), NODE.into()).unwrap();
}
fn teardown() {
let _ = close_engine();
}
#[test]
fn create_and_read_round_trip() {
setup();
let bytes = task_crdt_create("t-001".into(), "ns-1".into(), "My Task".into())
.unwrap();
let data = task_crdt_read(bytes).unwrap();
assert_eq!(data.id, "t-001");
assert_eq!(data.namespace_id, "ns-1");
assert_eq!(data.title, "My Task");
assert_eq!(data.status, "backlog");
assert!((data.priority_score - 0.0).abs() < f64::EPSILON);
assert!(!data.is_archived);
assert!(!data.hlc_timestamp.is_empty());
teardown();
}
#[test]
fn set_all_fields_and_read() {
setup();
let mut bytes =
task_crdt_create("t-002".into(), "ns-1".into(), "Initial".into()).unwrap();
bytes = task_crdt_set_title(bytes, "Updated".into()).unwrap();
bytes = task_crdt_set_status(bytes, "in_progress".into()).unwrap();
bytes = task_crdt_set_description(bytes, "# Desc".into()).unwrap();
bytes = task_crdt_set_priority_score(bytes, 0.75).unwrap();
bytes = task_crdt_set_eisenhower_quadrant(bytes, 2).unwrap();
bytes = task_crdt_set_due_date(bytes, 1_700_000_000_000).unwrap();
bytes = task_crdt_set_estimated_duration(bytes, 60).unwrap();
bytes = task_crdt_set_is_archived(bytes, true).unwrap();
bytes = task_crdt_set_completed_at(bytes, 1_700_100_000_000).unwrap();
bytes = task_crdt_set_project_id(bytes, "p-001".into()).unwrap();
bytes = task_crdt_set_namespace_id(bytes, "ns-2".into()).unwrap();
let data = task_crdt_read(bytes).unwrap();
assert_eq!(data.title, "Updated");
assert_eq!(data.status, "in_progress");
assert_eq!(data.description_markdown, Some("# Desc".into()));
assert!((data.priority_score - 0.75).abs() < f64::EPSILON);
assert_eq!(data.eisenhower_quadrant, Some(2));
assert_eq!(data.due_date, Some(1_700_000_000_000));
assert_eq!(data.estimated_duration, Some(60));
assert!(data.is_archived);
assert_eq!(data.completed_at, Some(1_700_100_000_000));
assert_eq!(data.project_id, Some("p-001".into()));
assert_eq!(data.namespace_id, "ns-2");
teardown();
}
#[test]
fn clear_optional_fields() {
setup();
let mut bytes =
task_crdt_create("t-003".into(), "ns-1".into(), "Task".into()).unwrap();
bytes = task_crdt_set_description(bytes, "desc".into()).unwrap();
bytes = task_crdt_clear_description(bytes).unwrap();
bytes = task_crdt_set_eisenhower_quadrant(bytes, 3).unwrap();
bytes = task_crdt_clear_eisenhower_quadrant(bytes).unwrap();
bytes = task_crdt_set_due_date(bytes, 123).unwrap();
bytes = task_crdt_clear_due_date(bytes).unwrap();
bytes = task_crdt_set_completed_at(bytes, 456).unwrap();
bytes = task_crdt_clear_completed_at(bytes).unwrap();
bytes = task_crdt_set_project_id(bytes, "p-1".into()).unwrap();
bytes = task_crdt_clear_project_id(bytes).unwrap();
let data = task_crdt_read(bytes).unwrap();
assert_eq!(data.description_markdown, None);
assert_eq!(data.eisenhower_quadrant, None);
assert_eq!(data.due_date, None);
assert_eq!(data.completed_at, None);
assert_eq!(data.project_id, None);
teardown();
}
#[test]
fn merge_concurrent_edits() {
setup();
let bytes_a =
task_crdt_create("t-004".into(), "ns-1".into(), "Original".into()).unwrap();
// Simulate a concurrent peer by creating a second copy.
// (In production, forks would come from network sync.)
let bytes_b = task_crdt_set_title(bytes_a.clone(), "Peer B Title".into()).unwrap();
let bytes_a = task_crdt_set_title(bytes_a, "Peer A Title".into()).unwrap();
let merged = task_crdt_merge(bytes_a, bytes_b).unwrap();
let data = task_crdt_read(merged).unwrap();
// Automerge picks a deterministic winner.
assert!(
data.title == "Peer A Title" || data.title == "Peer B Title",
"merge must resolve to one of the concurrent writes, got: {}",
data.title
);
teardown();
}
#[test]
fn read_is_stateless() {
// Ensure read works without engine.
let _ = close_engine();
// We need bytes from a previous engine session.
init_engine_in_memory(TEST_KEY.to_vec(), NODE.into()).unwrap();
let bytes =
task_crdt_create("t-005".into(), "ns-1".into(), "Stateless".into()).unwrap();
close_engine().unwrap();
// Read after engine shutdown should still work.
let data = task_crdt_read(bytes).unwrap();
assert_eq!(data.title, "Stateless");
}
#[test]
fn mutation_without_engine_fails() {
let _ = close_engine();
let result = task_crdt_create("x".into(), "y".into(), "z".into());
assert!(result.is_err());
}
}

View File

@ -0,0 +1,70 @@
//! FFI-safe Data Transfer Object (DTO) structs.
//!
//! These structs use only primitive types that `flutter_rust_bridge` can
//! translate directly into Dart classes. They serve as the serialization
//! boundary between the Rust CRDT layer and the Flutter presentation layer.
/// Derived master key + salt returned from Argon2id key derivation.
///
/// On the Dart side this becomes a class with `Uint8List key` and
/// `Uint8List salt` fields.
pub struct FfiDerivedKey {
/// 32-byte (256-bit) derived master key.
pub key: Vec<u8>,
/// 16-byte salt used during derivation.
pub salt: Vec<u8>,
}
/// Read-only snapshot of a task entity's CRDT state.
///
/// Returned by [`super::task_crdt::task_crdt_read`] after deserializing an
/// Automerge document. All fields mirror the `tasks` table in
/// `docs/API_CONTRACTS/schema.sql`.
pub struct FfiTaskData {
/// UUIDv4 entity identifier.
pub id: String,
/// Owning namespace ID.
pub namespace_id: String,
/// Optional project association.
pub project_id: Option<String>,
/// Task title (required).
pub title: String,
/// Optional markdown description.
pub description_markdown: Option<String>,
/// Workflow status: `backlog`, `in_progress`, `review`, `completed`.
pub status: String,
/// Calculated urgency-importance vector score.
pub priority_score: f64,
/// Eisenhower matrix quadrant (1–4), if assigned.
pub eisenhower_quadrant: Option<i64>,
/// Due date as Unix epoch milliseconds, if set.
pub due_date: Option<i64>,
/// Estimated duration in minutes, if set.
pub estimated_duration: Option<i64>,
/// Whether the task is archived.
pub is_archived: bool,
/// Completion timestamp (Unix epoch ms), if completed.
pub completed_at: Option<i64>,
/// Hybrid Logical Clock timestamp string.
pub hlc_timestamp: String,
}
/// Read-only snapshot of a note entity's CRDT state.
///
/// Returned by [`super::note_crdt::note_crdt_read`] after deserializing an
/// Automerge document. All fields mirror the `notes` table in
/// `docs/API_CONTRACTS/schema.sql`.
pub struct FfiNoteData {
/// UUIDv4 entity identifier.
pub id: String,
/// Owning namespace ID.
pub namespace_id: String,
/// Optional project association.
pub project_id: Option<String>,
/// Note title (required).
pub title: String,
/// Markdown content body.
pub content_markdown: String,
/// Hybrid Logical Clock timestamp string.
pub hlc_timestamp: String,
}

View File

@ -0,0 +1,52 @@
//! # chronogrid_ffi
//!
//! Flutter Rust Bridge (FRB v2) FFI layer for **ChronoGrid P2P**.
//!
//! This crate exposes the storage, CRDT, and cryptographic capabilities of
//! [`chronogrid_core`] and [`chronogrid_crypto`] through a flat, FFI-friendly
//! API surface that `flutter_rust_bridge_codegen` can translate into Dart
//! bindings.
//!
//! ## Architecture
//!
//! ```text
//! ┌─────────────────────┐
//! │ Flutter / Dart UI │
//! └────────┬────────────┘
//! │ generated Dart bindings
//! ┌────────▼────────────┐
//! │ chronogrid_ffi │ ← this crate (C-ABI cdylib)
//! └────────┬────────────┘
//! │ Rust crate deps
//! ┌────────▼────────────┐
//! │ chronogrid_core │ SQLCipher + Automerge CRDT
//! │ chronogrid_crypto │ Argon2id KDF + OS Keychain
//! └─────────────────────┘
//! ```
//!
//! ## Engine Pattern
//!
//! A global [`api::state::Engine`] singleton (behind a `Mutex`) holds the open
//! database connection and the node's Hybrid Logical Clock. All mutating CRDT
//! and database operations acquire the lock, perform work, and release it.
//! This is thread-safe and compatible with FRB's Dart isolate thread pool.
//!
//! ## Code Generation
//!
//! After modifying the `api/` module, run:
//!
//! ```bash
//! flutter_rust_bridge_codegen generate
//! ```
//!
//! This produces `src/frb_generated.rs` (Rust glue) and Dart binding files in
//! the Flutter project. The generated Rust module is included below only when
//! the file exists (i.e., after the first codegen run).
pub mod api;
// The FRB codegen produces this module. It does not exist until
// `flutter_rust_bridge_codegen generate` has been run at least once.
// Uncomment the line below after running codegen:
//
// mod frb_generated;

View File

@ -0,0 +1,90 @@
# ADR-0005: FFI Bridge via Flutter Rust Bridge v2
- **Status**: Accepted
- **Date**: 2026-08-20
- **Task**: 1.5 — Generate flutter_rust_bridge FFI bindings bridging Rust storage methods to Dart
## Context
Tasks 1.1–1.4 established the Rust core engine (`chronogrid_core` with SQLCipher + Automerge CRDT) and the cryptographic primitives (`chronogrid_crypto` with Argon2id KDF + OS keychain). The Flutter presentation layer needs a type-safe, zero-copy FFI bridge to invoke these Rust functions from Dart.
The project's architectural mandate (`.clinerules`) requires: *"FFI Bridge: Use `flutter_rust_bridge` C-ABI generation ONLY."*
## Decision
### 1. New Crate: `chronogrid_ffi`
A dedicated bridge crate (`crates/chronogrid_ffi/`) was created with:
- **`[lib] crate-type = ["lib", "cdylib", "staticlib"]`** — produces a shared library for Flutter's FFI loader on all platforms, plus a static library for iOS.
- **Dependencies**: `chronogrid_core`, `chronogrid_crypto`, `flutter_rust_bridge = "=2.7.0"`, `anyhow = "1"`.
### 2. API Surface Design — Serialized CRDT Bytes + Engine Singleton
Rather than passing opaque Rust pointers across FFI, the API uses a **serialized-bytes pattern**:
- **CRDT documents** are serialized to/from `Vec<u8>` (Automerge binary format).
- **Mutation functions** accept `doc_bytes: Vec<u8>`, apply the edit, and return updated `Vec<u8>`.
- **Read functions** (`task_crdt_read`, `note_crdt_read`) deserialize bytes into FFI-safe DTO structs (`FfiTaskData`, `FfiNoteData`).
- **Merge functions** are stateless — they accept two document byte arrays and return the merged result.
This avoids opaque pointer lifetime management, ensures Dart never holds stale Rust references, and leverages Automerge's natural serialization boundary.
### 3. Engine Singleton (`Mutex<Option<Engine>>`)
A global `Engine` struct holds:
- `db: Database` — the open SQLCipher connection.
- `hlc: Hlc` — the node's Hybrid Logical Clock.
The engine is stored in a `static Mutex<Option<Engine>>` with helper functions `with_engine()` and `with_engine_mut()` for safe concurrent access from FRB's thread pool.
- `init_engine(path, key, node_id)` / `init_engine_in_memory(key, node_id)` — opens the database and seeds the HLC.
- `close_engine()` — drops the connection.
- `is_engine_initialized()` — status check.
### 4. Module Structure
| Module | Functions | Category |
|----------------|-----------|----------|
| `engine` | 4 | Lifecycle (init/close/status) |
| `database` | 3 | CRDT doc persistence (save/load/delete) |
| `crypto` | 5 | Key derivation + keychain (platform-gated) |
| `task_crdt` | 18 | Task CRDT create/read/set_*/clear_*/merge |
| `note_crdt` | 8 | Note CRDT create/read/set_*/clear_*/merge |
| `types` | 3 structs | `FfiDerivedKey`, `FfiTaskData`, `FfiNoteData` |
**Total**: 38 public FFI functions + 3 DTO structs.
### 5. Error Handling
All fallible functions return `anyhow::Result<T>`, which FRB v2 automatically maps to Dart exceptions with the error message string. Internal `CoreError` and `CryptoError` variants are converted via their `Display` impls.
### 6. Platform Gating
Keychain functions (`crypto_store_credential`, `crypto_retrieve_credential`, `crypto_delete_credential`) compile on all platforms but return an error on Android, where credential storage is delegated to the Flutter KeyStore bridge.
### 7. Code Generation Config
`flutter_rust_bridge.yaml` at workspace root configures the codegen:
- **Rust input**: `crates/chronogrid_ffi/src/api/`
- **Rust output**: `crates/chronogrid_ffi/src/frb_generated.rs`
- **Dart output**: `apps/chronogrid_flutter/lib/src/rust/`
After running `flutter_rust_bridge_codegen generate`, the `frb_generated` module must be uncommented in `lib.rs`.
### 8. Flutter Side
`pubspec.yaml` updated with `flutter_rust_bridge: ^2.7.0`, `ffi: ^2.1.0`, and `meta: ^1.11.0`.
## Testing
- **25 unit tests** covering: engine lifecycle (init/close/double-init/bad-key), database CRDT doc persistence (save/load/delete/missing), task CRDT (create/read/set-all-fields/clear-optionals/merge/stateless-read/no-engine-error), note CRDT (create/read/set-all-fields/clear-optionals/merge/stateless-read/no-engine-error), and crypto (derive/deterministic/invalid-salt/generate-salt).
- All 25 tests pass with `--test-threads=1` (required due to global engine singleton).
- Verification pipeline (`scripts/verify_agent_work.ps1`) passes all checks.
## Consequences
- The Dart presentation layer can invoke all core Rust functionality via generated type-safe bindings after a single `flutter_rust_bridge_codegen generate` run.
- CRDT documents flow as opaque byte arrays across FFI, maintaining the local-first zero-cloud mandate.
- The engine singleton pattern means only one database can be open at a time per process, which matches the single-user design.
- Future phases can extend the API by adding new `pub fn` entries to the `api/` modules and re-running codegen.

View File

@ -6,4 +6,4 @@
- [x] **Task 1.2**: Implement chronogrid_crypto crate with Argon2id master key derivation and OS keychain bindings. - [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. - [x] **Task 1.3**: Implement chronogrid_core database initializers using SQLCipher 4.x matching docs/API_CONTRACTS/schema.sql.
- [x] **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. - [x] **Task 1.5**: Generate flutter_rust_bridge FFI bindings bridging Rust storage methods to Dart.

30
flutter_rust_bridge.yaml Normal file
View File

@ -0,0 +1,30 @@
# ==============================================================================
# Flutter Rust Bridge v2 – Code Generation Configuration
# ==============================================================================
#
# This file configures `flutter_rust_bridge_codegen generate` to scan the
# Rust FFI API surface and produce both Dart bindings and Rust glue code.
#
# Run code generation after modifying any `pub fn` in the api/ module:
#
# flutter_rust_bridge_codegen generate
#
# Prerequisites:
# cargo install flutter_rust_bridge_codegen@2.7.0
#
# The generated files are:
# Rust: crates/chronogrid_ffi/src/frb_generated.rs
# crates/chronogrid_ffi/src/frb_generated.io.rs
# Dart: apps/chronogrid_flutter/lib/src/rust/frb_generated.dart
# apps/chronogrid_flutter/lib/src/rust/frb_generated.io.dart
# apps/chronogrid_flutter/lib/src/rust/api/*.dart
# ==============================================================================
rust_input: crates/chronogrid_ffi/src/api/
rust_root: crates/chronogrid_ffi/
rust_output: crates/chronogrid_ffi/src/frb_generated.rs
dart_output: apps/chronogrid_flutter/lib/src/rust/
# Additional settings
c_output: apps/chronogrid_flutter/lib/src/rust/frb_generated.h

View File

@ -7,8 +7,8 @@ $ErrorActionPreference = "Continue"
Write-Host "--- RUNNING AGENT VERIFICATION PIPELINE ---" -ForegroundColor Cyan Write-Host "--- RUNNING AGENT VERIFICATION PIPELINE ---" -ForegroundColor Cyan
# 1. Check Rust Compilation & Formatting # 1. Check Rust Compilation for chronogrid_core
Write-Host "1. Testing Rust Crates..." -ForegroundColor Yellow Write-Host "1. Checking chronogrid_core..." -ForegroundColor Yellow
Set-Location "$PSScriptRoot\..\crates\chronogrid_core" Set-Location "$PSScriptRoot\..\crates\chronogrid_core"
cargo check --quiet cargo check --quiet
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
@ -16,8 +16,17 @@ if ($LASTEXITCODE -ne 0) {
Exit 1 Exit 1
} }
# 2. Check Flutter Code Formatting & Dependencies # 2. Check Rust Compilation for chronogrid_ffi
Write-Host "2. Checking Flutter Shell..." -ForegroundColor Yellow Write-Host "2. Checking chronogrid_ffi..." -ForegroundColor Yellow
Set-Location "$PSScriptRoot\..\crates\chronogrid_ffi"
cargo check --quiet
if ($LASTEXITCODE -ne 0) {
Write-Host "FAILED: Rust compilation errors detected in chronogrid_ffi." -ForegroundColor Red
Exit 1
}
# 3. Check Flutter Code Formatting & Dependencies
Write-Host "3. Checking Flutter Shell..." -ForegroundColor Yellow
Set-Location "$PSScriptRoot\..\apps\chronogrid_flutter" Set-Location "$PSScriptRoot\..\apps\chronogrid_flutter"
flutter analyze flutter analyze
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {