phase A wire-up: LogSync → ZodiaPipeline → AppMsg
Plumbs the inbound LogSync stream through ZodiaPipeline. The op flow
end-to-end:
remote peer → LogSync sub → node.inbound_ops → pipeline.process(op)
→ pipeline.next() → AppMsg::SyncStateEvent → store.insert_from_op
→ recent_interps refresh + UI bump
Publish goes the other way: AppMsg::ShareInterp now packages the entry
as InterpOp::Author and forwards it to node.publish(op), which CBOR-
encodes, signs the p2panda header, persists to the operation log, and
as InterpOp::Author and forwards it to node.publish(op), which CBOR-
encodes, signs the p2panda header, persists to the operation log, and
broadcasts via LogSync.
Changes:
* zodia-sync: drop InterpPayload (legacy Zodia-sig wrapper), drop
ReceivedInterp, drop the ZodiaStore dep entirely. Expose raw
Operation<()> on `inbound_ops` and accept InterpOp on publish.
The subscription task is now a thin forwarder — decoding lives in
the pipeline.
* zodia-store: add insert_from_op(key, body, author_pk) that trusts
its caller (the pipeline) for authentication and leaves author_sig
NULL. Tier-1 community_for_keys still filters on
author_sig IS NOT NULL so re-sharing is unaffected.
* app: SyncInterpReceived → SyncStateEvent(StateEvent); the
SyncPublishMsg::Publish payload becomes a plain InterpOp; the pump
task moves from tokio::spawn to glib::MainContext::default()
.spawn_local because ZodiaPipeline is !Send (single-threaded by
design).
* sync/Cargo.toml: zodia-store dep removed, zodia-ops dep added.
Wire-format break: peers on this branch encode InterpOp::Author instead
of the legacy InterpPayload struct. Old peers' bodies will produce
StateEvent::Skipped { MalformedOp } and be dropped silently. That's
acceptable for a feature branch; the release note will flag it when
this lands.
Workspace builds clean. 8/8 unit tests pass across zodia-ops +
zodia-pipeline.
This commit is contained in:
@@ -17,6 +17,8 @@ zodia-config.workspace = true
|
||||
zodia-store.workspace = true
|
||||
zodia-av.workspace = true
|
||||
zodia-sync.workspace = true
|
||||
zodia-ops.workspace = true
|
||||
zodia-pipeline.workspace = true
|
||||
p2panda-core = "0.6"
|
||||
ciborium.workspace = true
|
||||
blake3.workspace = true
|
||||
|
||||
+71
-39
@@ -30,7 +30,9 @@ use zodia_crypto::{ecies_decrypt, ecies_encrypt};
|
||||
use zodia_net::{ChannelMsg, ConsentBlob, DirectChannel, InterpEntry,
|
||||
NetworkConfig, PeerId, PeerStatus, RelayPayload, ZodiaNetEvent, ZodiaNetwork};
|
||||
use zodia_store::{StoreError, ZodiaStore, BaselineStore};
|
||||
use zodia_sync::{ReceivedInterp, ZodiaSyncNode};
|
||||
use zodia_sync::ZodiaSyncNode;
|
||||
use zodia_ops::InterpOp;
|
||||
use zodia_pipeline::{StateEvent, ZodiaPipeline};
|
||||
|
||||
use relm4::factory::FactoryVecDeque;
|
||||
|
||||
@@ -127,8 +129,10 @@ pub enum AppMsg {
|
||||
GoingOffline,
|
||||
/// User submitted a new interpretation — broadcast it to all live peers.
|
||||
ShareInterp(InterpEntry),
|
||||
/// A new community interpretation arrived via p2panda LogSync.
|
||||
SyncInterpReceived(ReceivedInterp),
|
||||
/// A typed state event from the inbound `ZodiaPipeline`. Replaces
|
||||
/// the legacy `SyncInterpReceived` path: now everything that arrives
|
||||
/// over LogSync flows through the pipeline first.
|
||||
SyncStateEvent(StateEvent),
|
||||
/// User tapped the affirm button on a community interpretation row.
|
||||
AffirmInterp { log_id: [u8; 32] },
|
||||
/// User submitted a fresh community interpretation from a detail page.
|
||||
@@ -847,27 +851,47 @@ impl AsyncComponent for AppModel {
|
||||
}
|
||||
// Slow path: publish to the p2panda log for offline catch-up sync.
|
||||
if let Some(tx) = &self.sync_publish_tx {
|
||||
if entry.author_sig.len() == 64 {
|
||||
let mut sig = [0u8; 64];
|
||||
sig.copy_from_slice(&entry.author_sig);
|
||||
let _ = tx.try_send(SyncPublishMsg::Publish {
|
||||
interp_key: entry.interp_key,
|
||||
body: entry.body,
|
||||
author_sig: sig,
|
||||
});
|
||||
}
|
||||
let op = InterpOp::Author {
|
||||
interp_key: entry.interp_key,
|
||||
body: entry.body,
|
||||
};
|
||||
let _ = tx.try_send(SyncPublishMsg::Publish(op));
|
||||
}
|
||||
}
|
||||
AppMsg::SyncInterpReceived(interp) => {
|
||||
debug!(
|
||||
key = %interp.interp_key,
|
||||
author = %hex::encode(&interp.author_pk[..4]),
|
||||
"new interpretation received via sync"
|
||||
);
|
||||
// Reload activity feed and trigger a network view refresh.
|
||||
self.recent_interps = self.store
|
||||
.recent_community_interps(12).await.unwrap_or_default();
|
||||
self.network_changed_token += 1;
|
||||
AppMsg::SyncStateEvent(event) => {
|
||||
match event {
|
||||
StateEvent::InterpAuthored { author, interp_key, body, .. } => {
|
||||
let author_pk: [u8; 32] = *author.as_bytes();
|
||||
match self.store
|
||||
.insert_from_op(&interp_key, &body, &author_pk)
|
||||
.await
|
||||
{
|
||||
Ok(true) => {
|
||||
debug!(
|
||||
key = %interp_key,
|
||||
author = %hex::encode(&author_pk[..4]),
|
||||
"interp authored via sync — stored"
|
||||
);
|
||||
self.recent_interps = self.store
|
||||
.recent_community_interps(12).await.unwrap_or_default();
|
||||
self.network_changed_token += 1;
|
||||
}
|
||||
Ok(false) => {} // duplicate, nothing to do
|
||||
Err(e) => warn!("sync insert_from_op failed: {e}"),
|
||||
}
|
||||
}
|
||||
StateEvent::AffirmAdded { .. } => {
|
||||
// Phase B will wire affirmations into a store projection.
|
||||
// Today: log + bump so the UI knows something changed.
|
||||
self.network_changed_token += 1;
|
||||
}
|
||||
StateEvent::ResponseAdded { .. } => {
|
||||
// Phase C will wire response threading. No-op for now.
|
||||
}
|
||||
StateEvent::Skipped { reason } => {
|
||||
debug!(?reason, "sync op skipped");
|
||||
}
|
||||
}
|
||||
}
|
||||
AppMsg::AffirmInterp { log_id } => {
|
||||
let author_pk = self.identity.public_key();
|
||||
@@ -1555,14 +1579,21 @@ async fn try_spawn_network(
|
||||
|
||||
/// Message type for sending publish requests to the background sync task.
|
||||
pub(crate) enum SyncPublishMsg {
|
||||
Publish { interp_key: String, body: String, author_sig: [u8; 64] },
|
||||
Publish(InterpOp),
|
||||
}
|
||||
|
||||
/// Spawn the LogSync background task and return a channel for publishing.
|
||||
/// Spawn the LogSync background pump and return a channel for publishing.
|
||||
///
|
||||
/// Opens a second connection to the same SQLite file so the sync task can
|
||||
/// call `insert_received` without conflicting with the main-thread store
|
||||
/// (WAL mode allows concurrent readers + one writer).
|
||||
/// Architecture:
|
||||
/// - `ZodiaSyncNode` exposes raw `Operation<()>` on `inbound_ops`.
|
||||
/// - A `ZodiaPipeline` decodes / materialises each op into `StateEvent`s.
|
||||
/// - We dispatch each `StateEvent` back to the model as `AppMsg::SyncStateEvent`.
|
||||
/// - Outbound publishes (`SyncPublishMsg::Publish(InterpOp)`) go straight
|
||||
/// to `node.publish`.
|
||||
///
|
||||
/// The pipeline is `!Send` (p2panda-stream is single-threaded by design),
|
||||
/// so the pump task runs on glib's main-thread context via
|
||||
/// `spawn_future_local`, not on tokio's multi-thread runtime.
|
||||
async fn try_spawn_sync(
|
||||
config: &LocalConfig,
|
||||
net: &ZodiaNetwork,
|
||||
@@ -1570,12 +1601,6 @@ async fn try_spawn_sync(
|
||||
) -> Option<tokio::sync::mpsc::Sender<SyncPublishMsg>> {
|
||||
use zodia_core::topic_key_global;
|
||||
|
||||
let store_path = config.data_dir().join("interpretations.db");
|
||||
let sync_store = match ZodiaStore::open(&store_path).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => { warn!("sync store open failed: {e}"); return None; }
|
||||
};
|
||||
|
||||
let signing_key = config.identity.to_panda_key();
|
||||
let topic = p2panda_core::Topic::from(topic_key_global().0);
|
||||
|
||||
@@ -1583,7 +1608,6 @@ async fn try_spawn_sync(
|
||||
signing_key,
|
||||
net.endpoint(),
|
||||
net.gossip(),
|
||||
sync_store,
|
||||
topic,
|
||||
config.data_dir(),
|
||||
).await {
|
||||
@@ -1594,21 +1618,29 @@ async fn try_spawn_sync(
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel::<SyncPublishMsg>(32);
|
||||
let sender_bg = sender.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
glib::MainContext::default().spawn_local(async move {
|
||||
let mut node = node;
|
||||
let pipeline = ZodiaPipeline::new();
|
||||
loop {
|
||||
tokio::select! {
|
||||
Some(msg) = rx.recv() => {
|
||||
match msg {
|
||||
SyncPublishMsg::Publish { interp_key, body, author_sig } => {
|
||||
if let Err(e) = node.publish(&interp_key, &body, &author_sig).await {
|
||||
SyncPublishMsg::Publish(op) => {
|
||||
if let Err(e) = node.publish(op).await {
|
||||
warn!("sync publish: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(interp) = node.received.recv() => {
|
||||
sender_bg.input(AppMsg::SyncInterpReceived(interp));
|
||||
Some(op) = node.inbound_ops.recv() => {
|
||||
if pipeline.process(op).await.is_err() {
|
||||
warn!("pipeline closed unexpectedly");
|
||||
break;
|
||||
}
|
||||
match pipeline.next().await {
|
||||
Ok(event) => sender_bg.input(AppMsg::SyncStateEvent(event)),
|
||||
Err(e) => warn!("pipeline next: {e}"),
|
||||
}
|
||||
}
|
||||
else => break,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user