5.2 KiB
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_corebuild without thep2pfeature) - 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:
[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 databaseSwarmHandle— cloneable handle to the running libp2p swarmtokio::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 }→ callsmerge_remote_deltas()NetEvent::SyncComplete/NetEvent::SyncError→ logged viatracing
Delta Merge Flow
merge_remote_deltas(deltas: Vec<DeltaPacket>) -> Result<Vec<String>>:
- For each
DeltaPacket, extractentity_idandentity_type - Load existing
crdt_docsrow from database (or create new empty Automerge doc) - Merge the incoming Automerge changeset bytes via
automerge::AutoCommit::load_incremental() - Persist the merged doc bytes back to
crdt_docs - Update the corresponding SQL entity row (
tasks,notes,time_blocks, etc.) with the merged scalar values - Return the list of affected
entity_idstrings 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
SwarmManagercomputes the delta by comparing localVectorClockwith 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
CoreErrorgains a newSync(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, verifiescrdt_docsupdatedtest_merge_remote_deltas_note— merges a note delta, verifiescrdt_docsupdatedtest_merge_empty_deltas— empty delta list returns empty affected IDstest_sync_engine_new— engine constructs without panicking
Tests are gated behind #[cfg(all(test, feature = "p2p"))].
Consequences
Positive
chronogrid_corewithout--features p2premains a lightweight, zero-network-dependency crate suitable for FFI and embedded use- The
SyncEngineprovides 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
p2pfeature adds significant compile-time overhead (libp2p + OpenSSL) on first build SwarmHandle::new_for_test()requireschronogrid_netto 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
SyncEnginealive 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.