chronogrid/docs/ARCHITECTURE/ADR/0012-core-sync-engine-p2p-i...

118 lines
5.2 KiB
Markdown

# ADR-0012: chronogrid_core — SyncEngine P2P Integration (Task 2.6)
## Status
Accepted
## Date
2026-08-22
## Context
Task 2.6 required wiring the P2P sync layer from `chronogrid_net` into `chronogrid_core` so that CRDT delta packets received from remote peers are automatically merged into the local SQLCipher database, and local CRDT mutations are broadcast to connected peers.
The integration must:
- Remain optional (not break the existing `chronogrid_core` build without the `p2p` feature)
- Bridge `NetEvent::DeltasReceived` → Automerge merge → SQL row update
- Bridge local CRDT mutations → delta computation → `SwarmHandle::trigger_sync()`
- Provide `merge_remote_deltas()` returning affected entity IDs for UI invalidation
## Decision
### Feature Gate
`chronogrid_net` is added as an **optional dependency** in `chronogrid_core/Cargo.toml` under the `p2p` feature:
```toml
[features]
p2p = ["dep:chronogrid_net", "dep:tokio", "dep:tracing"]
[dependencies]
chronogrid_net = { path = "../chronogrid_net", optional = true }
tokio = { version = "1", features = ["sync", "rt"], optional = true }
tracing = { version = "0.1", optional = true }
```
This ensures that `cargo build --package chronogrid_core` (without `--features p2p`) compiles cleanly with no libp2p or OpenSSL dependencies, keeping the base crate lightweight for FFI and test builds.
### SyncEngine Architecture
A new `core::sync` module is created with a single `SyncEngine` struct:
```
crates/chronogrid_core/src/sync/mod.rs
```
`SyncEngine` owns:
- `Arc<Mutex<Database>>` — shared reference to the encrypted SQLCipher database
- `SwarmHandle` — cloneable handle to the running libp2p swarm
- `tokio::sync::broadcast::Receiver<NetEvent>` — event stream from the swarm
The engine runs a background Tokio task (`tokio::spawn`) that loops on `event_rx.recv()`:
- `NetEvent::DeltasReceived { from_peer, deltas }` → calls `merge_remote_deltas()`
- `NetEvent::SyncComplete` / `NetEvent::SyncError` → logged via `tracing`
### Delta Merge Flow
`merge_remote_deltas(deltas: Vec<DeltaPacket>) -> Result<Vec<String>>`:
1. For each `DeltaPacket`, extract `entity_id` and `entity_type`
2. Load existing `crdt_docs` row from database (or create new empty Automerge doc)
3. Merge the incoming Automerge changeset bytes via `automerge::AutoCommit::load_incremental()`
4. Persist the merged doc bytes back to `crdt_docs`
5. Update the corresponding SQL entity row (`tasks`, `notes`, `time_blocks`, etc.) with the merged scalar values
6. Return the list of affected `entity_id` strings for UI cache invalidation
### Local Mutation → Broadcast Flow
`SyncEngine::notify_local_change(entity_id: &str)`:
- Calls `swarm_handle.trigger_sync()` to initiate delta exchange with all connected peers
- The `SwarmManager` computes the delta by comparing local `VectorClock` with each peer's last-known clock
### Error Handling
- Merge errors are logged via `tracing::warn!` and do not crash the engine
- Individual delta failures are isolated — one bad packet does not block others
- `CoreError` gains a new `Sync(String)` variant for sync-specific errors
### Test Strategy
Unit tests use `Database::open_in_memory()` and a mock `SwarmHandle` (created via `SwarmHandle::new_for_test()`) to avoid requiring a live libp2p swarm:
- `test_merge_remote_deltas_task` — merges a task delta, verifies `crdt_docs` updated
- `test_merge_remote_deltas_note` — merges a note delta, verifies `crdt_docs` updated
- `test_merge_empty_deltas` — empty delta list returns empty affected IDs
- `test_sync_engine_new` — engine constructs without panicking
Tests are gated behind `#[cfg(all(test, feature = "p2p"))]`.
## Consequences
### Positive
- `chronogrid_core` without `--features p2p` remains a lightweight, zero-network-dependency crate suitable for FFI and embedded use
- The `SyncEngine` provides a clean boundary between network events and database mutations
- `merge_remote_deltas()` returns affected entity IDs, enabling targeted UI cache invalidation in Phase 3
- Automerge's deterministic conflict resolution handles concurrent edits without application-level logic
### Negative / Trade-offs
- The `p2p` feature adds significant compile-time overhead (libp2p + OpenSSL) on first build
- `SwarmHandle::new_for_test()` requires `chronogrid_net` to expose a test-only constructor, adding a small surface area to the net crate's public API
- The background Tokio task requires the caller to hold the `SyncEngine` alive for the duration of the session
## Alternatives Considered
### Alternative 1: Separate `chronogrid_sync` Crate
A dedicated crate could own the bridge logic. Rejected because it adds workspace complexity without benefit — the sync engine is tightly coupled to `Database` internals and belongs in `chronogrid_core`.
### Alternative 2: Callback-Based Integration
Instead of a background task, expose a `process_event(NetEvent)` method for the caller to drive. Rejected because it pushes event-loop management into the daemon/FFI layer, violating the single-responsibility principle.
### Alternative 3: Always-On Dependency
Make `chronogrid_net` a non-optional dependency. Rejected because it would force OpenSSL compilation for all `chronogrid_core` consumers (FFI, tests, daemon), significantly increasing build times.