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:
Generated
+3
-1
@@ -6810,6 +6810,8 @@ dependencies = [
|
||||
"zodia-core",
|
||||
"zodia-crypto",
|
||||
"zodia-net",
|
||||
"zodia-ops",
|
||||
"zodia-pipeline",
|
||||
"zodia-store",
|
||||
"zodia-sync",
|
||||
]
|
||||
@@ -6942,5 +6944,5 @@ dependencies = [
|
||||
"tokio",
|
||||
"tracing",
|
||||
"zodia-core",
|
||||
"zodia-store",
|
||||
"zodia-ops",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -191,6 +191,44 @@ impl ZodiaStore {
|
||||
Ok(log_id)
|
||||
}
|
||||
|
||||
/// Persist an interpretation whose authentication is provided by an
|
||||
/// outer container (e.g. the p2panda operation header signature from a
|
||||
/// LogSync-replicated op) — no Zodia-level `author_sig` is required or
|
||||
/// stored. The caller is responsible for asserting the row's
|
||||
/// authenticity before invoking this.
|
||||
///
|
||||
/// Returns the derived `log_id`. `INSERT OR IGNORE` semantics: duplicate
|
||||
/// log_ids are silently dropped.
|
||||
///
|
||||
/// Rows inserted this way leave the `author_sig` column NULL, which
|
||||
/// means they don't participate in Tier-1 `community_for_keys` re-sharing
|
||||
/// (that path filters on `author_sig IS NOT NULL`). Re-sharing happens
|
||||
/// via LogSync instead, which carries the original p2panda header.
|
||||
pub async fn insert_from_op(
|
||||
&self,
|
||||
interp_key: &str,
|
||||
body: &str,
|
||||
author_pk: &[u8; 32],
|
||||
) -> Result<bool, StoreError> {
|
||||
let log_id = derive_log_id(interp_key, body);
|
||||
let kind = kind_from_key_str(interp_key);
|
||||
let now = unix_secs() as i64;
|
||||
let result = sqlx::query(
|
||||
"INSERT OR IGNORE INTO interpretations
|
||||
(log_id, interp_key, interp_kind, body, author_pk, received_at, is_baseline)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0)",
|
||||
)
|
||||
.bind(log_id.as_slice())
|
||||
.bind(interp_key)
|
||||
.bind(kind)
|
||||
.bind(body)
|
||||
.bind(author_pk.as_slice())
|
||||
.bind(now)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// Verify and insert a community interpretation received from a peer.
|
||||
///
|
||||
/// Returns `Ok(true)` if newly inserted, `Ok(false)` if already present,
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ description = "Offline-first interpretation sync built on p2panda LogSync"
|
||||
|
||||
[dependencies]
|
||||
zodia-core.workspace = true
|
||||
zodia-store.workspace = true
|
||||
zodia-ops.workspace = true
|
||||
serde.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
+45
-153
@@ -1,15 +1,19 @@
|
||||
//! Offline-first interpretation sync for the Zodia community index.
|
||||
//! Offline-first interpretation sync driver.
|
||||
//!
|
||||
//! # Design
|
||||
//! `ZodiaSyncNode` owns the `p2panda-net::LogSync` machinery and exposes
|
||||
//! two simple channels to the app layer:
|
||||
//!
|
||||
//! Each user maintains an append-only p2panda log (log id `0`) of the
|
||||
//! interpretations they have authored. When two peers share the same sync
|
||||
//! topic they perform a **set-reconciliation catch-up** (exchanging log
|
||||
//! heights) and then enter **live mode** where newly published operations
|
||||
//! are gossip-broadcast immediately.
|
||||
//! * `inbound_ops` — every received `Operation<()>`, raw. The app feeds
|
||||
//! these into a `zodia-pipeline::ZodiaPipeline` for decoding, ordering,
|
||||
//! access-control, materialisation.
|
||||
//! * `publish(op: InterpOp)` — encode the canonical Zodia op into a body,
|
||||
//! build + sign the p2panda header, persist to the local log, broadcast
|
||||
//! via LogSync.
|
||||
//!
|
||||
//! `ZodiaSyncNode` wraps `p2panda-net`'s `LogSync` and translates between the
|
||||
//! p2panda operation layer and `ZodiaStore`'s application-level records.
|
||||
//! The Zodia-level "author_sig over BLAKE3(key||body)" that the old
|
||||
//! `InterpPayload` carried is gone: the p2panda header signature IS the
|
||||
//! authentication for LogSync-replicated ops, and `zodia-store::insert_from_op`
|
||||
//! trusts that the caller (the pipeline) verified the chain.
|
||||
//!
|
||||
//! # Storage
|
||||
//!
|
||||
@@ -28,76 +32,31 @@ use p2panda_store::logs::LogStore;
|
||||
use p2panda_store::operations::OperationStore;
|
||||
use p2panda_store::{SqliteStore, SqliteStoreBuilder};
|
||||
use p2panda_sync::protocols::TopicLogSyncEvent;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use zodia_store::{StoreError, ZodiaStore};
|
||||
use zodia_ops::InterpOp;
|
||||
|
||||
// ── log id ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Each author has exactly one log containing all their interpretations.
|
||||
const INTERP_LOG_ID: u64 = 0;
|
||||
|
||||
// ── payload ───────────────────────────────────────────────────────────────────
|
||||
// ── errors ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// CBOR-encoded body of a p2panda interpretation operation.
|
||||
///
|
||||
/// The `author_sig` is the Zodia-level ed25519 signature over
|
||||
/// `BLAKE3(interp_key || body)` — the same payload verified by
|
||||
/// `ZodiaStore::insert_received`. Because the p2panda header already carries
|
||||
/// an ed25519 signature from the same key pair, this is redundant but lets us
|
||||
/// feed received operations directly into `ZodiaStore::insert_received`
|
||||
/// without modifying its verification contract.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InterpPayload {
|
||||
pub interp_key: String,
|
||||
pub body: String,
|
||||
/// ed25519 signature, 64 bytes.
|
||||
pub author_sig: Vec<u8>,
|
||||
}
|
||||
|
||||
impl InterpPayload {
|
||||
fn encode(&self) -> Vec<u8> {
|
||||
let mut buf = Vec::new();
|
||||
ciborium::into_writer(self, &mut buf).expect("ciborium encode infallible");
|
||||
buf
|
||||
}
|
||||
|
||||
fn decode(bytes: &[u8]) -> Option<Self> {
|
||||
ciborium::from_reader(bytes).ok()
|
||||
}
|
||||
}
|
||||
|
||||
// ── sync node ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Error type for sync operations.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum SyncError {
|
||||
#[error("store error: {0}")]
|
||||
Store(#[from] StoreError),
|
||||
#[error("p2panda store: {0}")]
|
||||
PandaStore(String),
|
||||
#[error("p2panda sync: {0}")]
|
||||
Sync(String),
|
||||
#[error("payload encode/decode failed")]
|
||||
Payload,
|
||||
}
|
||||
|
||||
/// A decoded interpretation that arrived via LogSync from a remote peer.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReceivedInterp {
|
||||
pub interp_key: String,
|
||||
pub body: String,
|
||||
pub author_pk: [u8; 32],
|
||||
pub author_sig: [u8; 64],
|
||||
}
|
||||
// ── sync node ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// The live sync handle.
|
||||
///
|
||||
/// Keeps the p2panda LogSync machinery alive and mediates between the
|
||||
/// application and the sync layer.
|
||||
/// The live sync handle. Keeps the p2panda LogSync machinery alive and
|
||||
/// exposes a raw `Operation<()>` channel for the app's pipeline to consume.
|
||||
pub struct ZodiaSyncNode {
|
||||
/// p2panda signing key — same bytes as the Zodia identity `SigningKey`.
|
||||
signing_key: SigningKey,
|
||||
@@ -105,8 +64,9 @@ pub struct ZodiaSyncNode {
|
||||
sync_store: SqliteStore,
|
||||
/// LogSync handle for our single sync topic.
|
||||
handle: p2panda_net::sync::SyncHandle<Operation<()>, TopicLogSyncEvent<()>>,
|
||||
/// Interpretations received from remote peers, ready for the app to consume.
|
||||
pub received: mpsc::Receiver<ReceivedInterp>,
|
||||
/// Raw operations received from remote peers, ready for the app's
|
||||
/// `ZodiaPipeline` to consume.
|
||||
pub inbound_ops: mpsc::Receiver<Operation<()>>,
|
||||
}
|
||||
|
||||
impl ZodiaSyncNode {
|
||||
@@ -115,15 +75,12 @@ impl ZodiaSyncNode {
|
||||
/// * `signing_key` — the local identity p2panda `SigningKey`
|
||||
/// * `endpoint` — clone of `ZodiaNetwork`'s iroh endpoint
|
||||
/// * `gossip` — clone of `ZodiaNetwork`'s gossip engine
|
||||
/// * `zodia_store` — shared handle to the application `ZodiaStore` for
|
||||
/// persisting received interpretations
|
||||
/// * `sync_topic` — the sync topic (use `Topic::from(topic_key_global().0)`)
|
||||
/// * `store_dir` — directory in which the sync-store SQLite file lives
|
||||
pub async fn spawn(
|
||||
signing_key: SigningKey,
|
||||
endpoint: Endpoint,
|
||||
gossip: Gossip,
|
||||
zodia_store: ZodiaStore,
|
||||
sync_topic: Topic,
|
||||
store_dir: &Path,
|
||||
) -> Result<Self, SyncError> {
|
||||
@@ -147,16 +104,19 @@ impl ZodiaSyncNode {
|
||||
.await
|
||||
.map_err(|e| SyncError::Sync(format!("{e:?}")))?;
|
||||
|
||||
let (recv_tx, recv_rx) = mpsc::channel(256);
|
||||
let (op_tx, op_rx) = mpsc::channel::<Operation<()>>(256);
|
||||
|
||||
// ── subscription background task ──────────────────────────────────────
|
||||
//
|
||||
// The task is intentionally thin: forward every received operation
|
||||
// to the channel and log non-operation lifecycle events. All
|
||||
// decoding / verification / storage decisions happen downstream in
|
||||
// the app's `ZodiaPipeline`.
|
||||
let mut subscription = handle
|
||||
.subscribe()
|
||||
.await
|
||||
.map_err(|e| SyncError::Sync(format!("{e:?}")))?;
|
||||
|
||||
let zodia_store_bg = zodia_store.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
while let Some(result) = subscription.next().await {
|
||||
let from_sync = match result {
|
||||
@@ -167,76 +127,24 @@ impl ZodiaSyncNode {
|
||||
}
|
||||
};
|
||||
|
||||
let remote_tag = hex::encode(&from_sync.remote.as_bytes()[..4]);
|
||||
match from_sync.event {
|
||||
TopicLogSyncEvent::OperationReceived { operation, .. } => {
|
||||
let author_pk_bytes: [u8; 32] =
|
||||
*operation.header.verifying_key.as_bytes();
|
||||
|
||||
let body_bytes = match &operation.body {
|
||||
Some(b) => b.to_bytes(),
|
||||
None => {
|
||||
debug!("sync: operation without body, skipping");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let payload = match InterpPayload::decode(&body_bytes) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
warn!("sync: failed to decode InterpPayload");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if payload.author_sig.len() != 64 {
|
||||
warn!("sync: author_sig wrong length ({})", payload.author_sig.len());
|
||||
continue;
|
||||
}
|
||||
let mut sig_arr = [0u8; 64];
|
||||
sig_arr.copy_from_slice(&payload.author_sig);
|
||||
|
||||
let interp_key = payload.interp_key.clone();
|
||||
let body_text = payload.body.clone();
|
||||
|
||||
match zodia_store_bg
|
||||
.insert_received(&interp_key, &body_text, &author_pk_bytes, &sig_arr)
|
||||
.await
|
||||
{
|
||||
Ok(true) => {
|
||||
debug!(key = %payload.interp_key, "sync: new interpretation received");
|
||||
let _ = recv_tx.send(ReceivedInterp {
|
||||
interp_key: payload.interp_key,
|
||||
body: payload.body,
|
||||
author_pk: author_pk_bytes,
|
||||
author_sig: sig_arr,
|
||||
}).await;
|
||||
}
|
||||
Ok(false) => {
|
||||
debug!(key = %payload.interp_key, "sync: duplicate, skipped");
|
||||
}
|
||||
Err(StoreError::InvalidSignature) => {
|
||||
warn!(key = %payload.interp_key, "sync: invalid sig, discarded");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(key = %payload.interp_key, "sync: store error: {e}");
|
||||
}
|
||||
// `Box<Operation<()>>` → owned `Operation<()>`.
|
||||
let op = *operation;
|
||||
if op_tx.send(op).await.is_err() {
|
||||
debug!("inbound_ops channel closed, stopping subscription pump");
|
||||
break;
|
||||
}
|
||||
}
|
||||
TopicLogSyncEvent::SyncStarted { .. } => {
|
||||
debug!(
|
||||
remote = %hex::encode(&from_sync.remote.as_bytes()[..4]),
|
||||
"sync: catch-up started"
|
||||
);
|
||||
debug!(remote = %remote_tag, "sync: catch-up started");
|
||||
}
|
||||
TopicLogSyncEvent::SyncFinished { .. } => {
|
||||
debug!(
|
||||
remote = %hex::encode(&from_sync.remote.as_bytes()[..4]),
|
||||
"sync: catch-up finished"
|
||||
);
|
||||
debug!(remote = %remote_tag, "sync: catch-up finished");
|
||||
}
|
||||
TopicLogSyncEvent::Failed { error } => {
|
||||
warn!(remote = %hex::encode(&from_sync.remote.as_bytes()[..4]),
|
||||
"sync session failed: {error}");
|
||||
warn!(remote = %remote_tag, "sync session failed: {error}");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -247,30 +155,17 @@ impl ZodiaSyncNode {
|
||||
signing_key,
|
||||
sync_store,
|
||||
handle,
|
||||
received: recv_rx,
|
||||
inbound_ops: op_rx,
|
||||
})
|
||||
}
|
||||
|
||||
/// Publish a locally authored interpretation to the p2panda log.
|
||||
/// Publish a locally authored `InterpOp` to the p2panda log.
|
||||
///
|
||||
/// Callers must have already inserted the entry into `ZodiaStore` via
|
||||
/// `insert_signed`. This method adds the operation to the p2panda log
|
||||
/// so that it will be propagated to peers via gossip and catch-up sync.
|
||||
///
|
||||
/// `author_sig` is the Zodia-level ed25519 signature already stored in
|
||||
/// `ZodiaStore` — 64 bytes.
|
||||
pub async fn publish(
|
||||
&mut self,
|
||||
interp_key: &str,
|
||||
body: &str,
|
||||
author_sig: &[u8; 64],
|
||||
) -> Result<(), SyncError> {
|
||||
let payload = InterpPayload {
|
||||
interp_key: interp_key.to_owned(),
|
||||
body: body.to_owned(),
|
||||
author_sig: author_sig.to_vec(),
|
||||
};
|
||||
let payload_bytes = payload.encode();
|
||||
/// Encodes the op, builds + signs a p2panda header, persists locally
|
||||
/// (so the next publish picks up the right backlink and crash-mid-publish
|
||||
/// recovers), then broadcasts via LogSync.
|
||||
pub async fn publish(&mut self, op: InterpOp) -> Result<(), SyncError> {
|
||||
let payload_bytes = op.encode();
|
||||
|
||||
// Determine the next sequence number + backlink from our log tip.
|
||||
let latest: Option<Operation<()>> = self.sync_store
|
||||
@@ -279,8 +174,8 @@ impl ZodiaSyncNode {
|
||||
.map_err(|e| SyncError::PandaStore(e.to_string()))?;
|
||||
|
||||
let (seq_num, backlink) = match latest {
|
||||
Some(op) => (op.header.seq_num + 1, Some(op.header.hash())),
|
||||
None => (0, None),
|
||||
Some(prev) => (prev.header.seq_num + 1, Some(prev.header.hash())),
|
||||
None => (0, None),
|
||||
};
|
||||
|
||||
let body_op = Body::new(&payload_bytes);
|
||||
@@ -305,14 +200,11 @@ impl ZodiaSyncNode {
|
||||
body: Some(body_op),
|
||||
};
|
||||
|
||||
// Persist locally so the next publish picks up the right backlink and
|
||||
// peers that catch us mid-publish can complete the log.
|
||||
self.sync_store
|
||||
.insert_operation(&op_hash, &operation, &INTERP_LOG_ID)
|
||||
.await
|
||||
.map_err(|e| SyncError::PandaStore(e.to_string()))?;
|
||||
|
||||
// Broadcast to connected peers via gossip.
|
||||
self.handle
|
||||
.publish(operation)
|
||||
.await
|
||||
|
||||
Reference in New Issue
Block a user