diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 3b5f35d99..19e5ef2f8 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -36,6 +36,7 @@ export default defineConfig({ "**/profile-active-turn.spec.ts", "**/config-bridge-screenshots.spec.ts", "**/observer-feed-screenshots.spec.ts", + "**/local-archive-screenshots.spec.ts", "**/file-attachment.spec.ts", "**/image-attachment-gallery.spec.ts", "**/video-attachment.spec.ts", diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 0b809c24f..9aef6ddd6 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -60,6 +60,14 @@ const overrides = new Map([ // config-bridge: get_agent_config_surface/write_agent_config_field/put_agent_session_config // commands add ~40 lines. Queued to split. // branch cut; override bumped to cover the merged total. Queued to split. + // archive/mod.rs carries the full test module: 899 unit tests + 4 real-relay + // integration tests (ignored, live-relay only, wrapped in + // #[cfg(not(target_os = "windows"))] mod real_relay). The test module is the + // source of the overage — production logic is ~408 lines. The fix-round added + // 2 regression tests (F2: out-of-range kind + F3: atomicity invariant). + // read_archived_events Tauri command added ~39 lines (Phase 1 read-back). + // Queued to split the test module into archive/mod_tests.rs in a follow-up. + ["src-tauri/src/archive/mod.rs", 1440], ["src-tauri/src/commands/agents.rs", 1437], // #1418 read-path fix: get_thread_replies' blocker fix (shared TIMELINE_KINDS // const + build_thread_replies_filter helper, mirroring the channel sibling so diff --git a/desktop/src-tauri/src/archive/mod.rs b/desktop/src-tauri/src/archive/mod.rs new file mode 100644 index 000000000..dea99ac5a --- /dev/null +++ b/desktop/src-tauri/src/archive/mod.rs @@ -0,0 +1,1437 @@ +//! Local-save archive — Tauri commands for archiving relay messages to a +//! per-identity SQLite database in the Buzz nest. +//! +//! # Architecture +//! +//! Two access proof paths, chosen by event kind: +//! +//! **Persistent scopes** (`channel_h`, `referenced_e`): the relay is the +//! source of truth. Candidates are grouped and re-queried via a batched +//! authed `/query`; only events the relay returns are inserted. +//! +//! **Ephemeral scope** (`owner_p`, kind 24200 observer frames): the relay +//! never stores these, so `/query` cannot verify them. The relay's REQ-time +//! `#p == authed reader` gate is the access control; local per-frame +//! validation (sig/id + kind + p-tag + agent tag + frame=telemetry + author +//! == agent) is applied fail-closed. + +mod pipeline; +pub mod store; + +use pipeline::{commit_archive, plan_archive, query_buckets}; + +use nostr::Event; +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; +use tauri::State; + +use crate::app_state::AppState; +use crate::managed_agents::nest_dir; +use crate::relay::{query_relay, relay_ws_url_with_override}; + +// ── Constants ─────────────────────────────────────────────────────────────── + +const KIND_AGENT_OBSERVER_FRAME: u16 = 24200; +const OBSERVER_FRAME_TELEMETRY: &str = "telemetry"; + +// ── DB helpers ─────────────────────────────────────────────────────────────── + +fn open_db() -> Result { + let nest = nest_dir().ok_or("cannot resolve nest directory for archive")?; + let db_path = nest.join("archive").join("archive.db"); + store::open_archive_db(&db_path) +} + +fn identity_pubkey(state: &AppState) -> Result { + let keys = state.keys.lock().map_err(|e| e.to_string())?; + Ok(keys.public_key().to_hex()) +} + +fn now_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64 +} + +// ── Scope type ─────────────────────────────────────────────────────────────── + +/// The three supported archive scope discriminants. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ScopeType { + ChannelH, + OwnerP, + ReferencedE, +} + +impl ScopeType { + fn as_str(&self) -> &'static str { + match self { + ScopeType::ChannelH => "channel_h", + ScopeType::OwnerP => "owner_p", + ScopeType::ReferencedE => "referenced_e", + } + } + + fn is_ephemeral(&self) -> bool { + matches!(self, ScopeType::OwnerP) + } +} + +// ── archive_events ─────────────────────────────────────────────────────────── + +/// One event candidate to archive. +#[derive(Debug, Deserialize)] +pub struct ArchiveCandidate { + /// Raw Nostr event JSON. + pub raw_event_json: String, + /// Which save scope this candidate was matched against. The backend + /// re-verifies this — it is never trusted blind. + pub matched_scope: MatchedScope, +} + +/// A scope match assertion from the frontend. +#[derive(Debug, Deserialize)] +pub struct MatchedScope { + pub scope_type: ScopeType, + pub scope_value: String, +} + +/// Result of a batch archive call. +#[derive(Debug, Serialize)] +pub struct ArchiveBatchResult { + /// Events successfully written to the store (event + scope rows). + pub persisted: u32, + /// Events dropped due to access denial or invalid payload (not an error). + pub dropped: u32, +} + +/// Archive a batch of event candidates. +/// +/// - Persistent scopes (`channel_h`, `referenced_e`): grouped by scope, then +/// batch-queried against the relay; only returned events are inserted. +/// - Ephemeral scope (`owner_p`): local validation only (no `/query`). +/// +/// # Send-safety +/// +/// `rusqlite::Connection` is `!Send`. All DB work is bracketed in scoped +/// `{ let conn = open_db()?; ... }` blocks that drop the connection before any +/// `.await`, exactly matching the pattern in `managed_agents/persona_events.rs`. +#[tauri::command] +pub async fn archive_events( + state: State<'_, AppState>, + candidates: Vec, +) -> Result { + let identity_pk = identity_pubkey(&state)?; + let relay_url = relay_ws_url_with_override(&state); + let now = now_secs(); + + // ── Phase 1: plan (sync) ───────────────────────────────────────────────── + // Read subscriptions and build relay filters. Connection dropped before + // any .await. + let plan = { + let conn = open_db()?; + plan_archive(candidates, &identity_pk, &relay_url, &conn)? + // conn drops here + }; + + // ── Phase 2: relay queries (async) ─────────────────────────────────────── + // No Connection in scope — future is Send. + let state_ref: &AppState = &state; + let bucket_results = query_buckets(plan.buckets, state_ref).await; + + // ── Phase 3: persist (sync) ────────────────────────────────────────────── + let conn = open_db()?; + commit_archive( + bucket_results, + plan.ephemeral, + plan.pre_dropped, + &identity_pk, + &relay_url, + now, + &conn, + ) +} + +/// Validate an ephemeral observer frame (kind 24200) against ALL local rules. +/// +/// Rules (verbatim from spec): +/// 1. kind == 24200 +/// 2. `#p` contains `identity_pubkey` +/// 3. `agent` tag is present +/// 4. `frame == "telemetry"` (control frames are not archived) +/// 5. event author (pubkey) == agent tag value +/// 6. A matching `owner_p` save-subscription exists AND its `kinds` list +/// includes `24200` (kinds enforcement mirrors the persistent path). +fn validate_ephemeral_frame( + event: &Event, + identity_pk: &str, + scope_value: &str, + conn: &Connection, + sub_identity: &str, + relay_url: &str, +) -> Result<(), String> { + // 1. Kind guard. + if event.kind.as_u16() != KIND_AGENT_OBSERVER_FRAME { + return Err(format!( + "expected kind {KIND_AGENT_OBSERVER_FRAME}, got {}", + event.kind.as_u16() + )); + } + + // 2. `#p` contains current identity. + let p_matches = event.tags.iter().any(|t| { + let s = t.as_slice(); + s.len() >= 2 && s[0] == "p" && s[1] == identity_pk + }); + if !p_matches { + return Err("observer frame #p does not match current identity".into()); + } + + // 3. `agent` tag present. + let agent_value = event + .tags + .iter() + .find_map(|t| { + let s = t.as_slice(); + if s.len() >= 2 && s[0] == "agent" { + Some(s[1].clone()) + } else { + None + } + }) + .ok_or_else(|| "observer frame missing `agent` tag".to_string())?; + + // 4. `frame == "telemetry"`. + let frame_value = event + .tags + .iter() + .find_map(|t| { + let s = t.as_slice(); + if s.len() >= 2 && s[0] == "frame" { + Some(s[1].clone()) + } else { + None + } + }) + .ok_or_else(|| "observer frame missing `frame` tag".to_string())?; + if frame_value != OBSERVER_FRAME_TELEMETRY { + return Err(format!("expected frame=telemetry, got {frame_value:?}")); + } + + // 5. Event author == agent tag value. + if event.pubkey.to_hex() != agent_value { + return Err("observer frame author does not match agent tag".into()); + } + + // 6. Matching owner_p subscription exists AND kind 24200 is in its kinds list. + let kinds_json = + store::get_subscription_kinds(conn, sub_identity, relay_url, "owner_p", scope_value)? + .ok_or_else(|| format!("no owner_p subscription for scope_value={scope_value:?}"))?; + let allowed_kinds: Vec = serde_json::from_str::>(&kinds_json).unwrap_or_default(); + if !allowed_kinds.contains(&(KIND_AGENT_OBSERVER_FRAME as u64)) { + return Err(format!( + "owner_p subscription kinds {kinds_json:?} does not include {KIND_AGENT_OBSERVER_FRAME}" + )); + } + + Ok(()) +} + +// ── create_save_subscription ───────────────────────────────────────────────── + +/// Create a save subscription after running a per-scope access probe. +/// +/// Probes: +/// - `channel_h`: verify the current user is a member of the channel (kind 39002 +/// `#p` contains our pubkey, or we are the event author / open channel). +/// - `referenced_e`: the referenced event id is currently readable via `/query`. +/// - `owner_p`: restricted to the current identity's own pubkey (v1). +#[tauri::command] +pub async fn create_save_subscription( + state: State<'_, AppState>, + scope_type: ScopeType, + scope_value: String, + kinds: Vec, +) -> Result<(), String> { + let identity_pk = identity_pubkey(&state)?; + let relay_url = relay_ws_url_with_override(&state); + let now = now_secs(); + + // Reject kinds outside the valid NIP-01 range 0..=65535 — the nostr crate + // silently truncates larger values via `v as u16`, which would create + // unmatchable filters. + for &k in &kinds { + if k > u32::from(u16::MAX) { + return Err(format!("kind {k} is out of the valid range 0..=65535")); + } + } + + // Per-scope access probe. + match &scope_type { + ScopeType::ChannelH => { + probe_channel_access(&state, &identity_pk, &scope_value).await?; + } + ScopeType::ReferencedE => { + probe_event_readable(&state, &scope_value).await?; + } + ScopeType::OwnerP => { + // v1: only the current identity's own pubkey is allowed. + if scope_value != identity_pk { + return Err(format!( + "owner_p scope_value must equal current identity pubkey in v1 (got {scope_value:?})" + )); + } + } + } + + let kinds_json = + serde_json::to_string(&kinds).map_err(|e| format!("failed to serialize kinds: {e}"))?; + + let conn = open_db()?; + store::upsert_save_subscription( + &conn, + &identity_pk, + &relay_url, + scope_type.as_str(), + &scope_value, + &kinds_json, + now, + ) +} + +/// Probe: the current user has access to `channel_id` (kind 39002 lists them). +async fn probe_channel_access( + state: &AppState, + identity_pk: &str, + channel_id: &str, +) -> Result<(), String> { + // Fetch the channel's members event (kind 39002, #d = channel_id). + let events = query_relay( + state, + &[serde_json::json!({ + "kinds": [39002], + "#d": [channel_id], + "limit": 1 + })], + ) + .await?; + + // If no members event exists this could be an open channel — try to read + // its metadata (kind 39000) as a fallback proof of readability. + if events.is_empty() { + let meta = query_relay( + state, + &[serde_json::json!({ + "kinds": [39000], + "#d": [channel_id], + "limit": 1 + })], + ) + .await?; + if meta.is_empty() { + return Err(format!( + "channel {channel_id:?} not found or not accessible" + )); + } + // Open channel — readable, access granted. + return Ok(()); + } + + // Check that the current identity is listed as a member. + let ev = &events[0]; + let is_member = ev.tags.iter().any(|t| { + let s = t.as_slice(); + s.len() >= 2 && s[0] == "p" && s[1] == identity_pk + }); + // Also allow if we are the event author (e.g. the workspace owner who + // published the members event may not be in the `#p` list themselves). + let is_author = ev.pubkey.to_hex() == identity_pk; + + if is_member || is_author { + Ok(()) + } else { + Err(format!( + "current identity is not a member of channel {channel_id:?}" + )) + } +} + +/// Probe: the given event id is currently readable by the current user. +async fn probe_event_readable(state: &AppState, event_id: &str) -> Result<(), String> { + let events = query_relay( + state, + &[serde_json::json!({ + "ids": [event_id], + "limit": 1 + })], + ) + .await?; + + if events.is_empty() { + return Err(format!("event {event_id:?} not found or not accessible")); + } + Ok(()) +} + +// ── list_save_subscriptions ────────────────────────────────────────────────── + +/// List all save subscriptions for the current identity + relay. +#[tauri::command] +pub fn list_save_subscriptions( + state: State<'_, AppState>, +) -> Result, String> { + let identity_pk = identity_pubkey(&state)?; + let relay_url = relay_ws_url_with_override(&state); + let conn = open_db()?; + store::list_save_subscriptions(&conn, &identity_pk, &relay_url) +} + +// ── delete_save_subscription ───────────────────────────────────────────────── + +/// Delete a save subscription. +/// +/// Does NOT purge already-archived event data — retention is decoupled in v1. +/// GC of orphaned event rows happens in P4 purge commands, not here. +#[tauri::command] +pub fn delete_save_subscription( + state: State<'_, AppState>, + scope_type: ScopeType, + scope_value: String, +) -> Result { + let identity_pk = identity_pubkey(&state)?; + let relay_url = relay_ws_url_with_override(&state); + let conn = open_db()?; + store::delete_save_subscription( + &conn, + &identity_pk, + &relay_url, + scope_type.as_str(), + &scope_value, + ) +} + +// ── read_archived_events ───────────────────────────────────────────────────── + +/// Default page size for `read_archived_events`. +const DEFAULT_READ_LIMIT: i64 = 50; + +/// Read a paginated page of archived events for a scope. +/// +/// Returns at most `limit` events (default `DEFAULT_READ_LIMIT`) in +/// newest-first order. Pass the compound cursor `(before_created_at, +/// before_id)` — both `Some` — from the last row of the previous page to +/// walk backwards. The predicate mirrors `ORDER BY created_at DESC, id DESC` +/// so same-second siblings are never skipped at a page boundary. Pass +/// `None`/`None` to start at the newest end. +/// A returned page shorter than `limit` signals that the archive is exhausted. +/// +/// `kinds` is an optional filter; an empty array means "no kinds matched" +/// (not "all kinds") — callers should pass `null`/`None` when they want all. +#[tauri::command] +pub fn read_archived_events( + state: State<'_, AppState>, + scope_type: ScopeType, + scope_value: String, + kinds: Option>, + before_created_at: Option, + before_id: Option, + limit: Option, +) -> Result, String> { + let identity_pk = identity_pubkey(&state)?; + let relay_url = relay_ws_url_with_override(&state); + let conn = open_db()?; + store::read_archived_events( + &conn, + &identity_pk, + &relay_url, + scope_type.as_str(), + &scope_value, + kinds.as_deref(), + before_created_at, + before_id.as_deref(), + limit.unwrap_or(DEFAULT_READ_LIMIT), + ) +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag}; + use pipeline::BucketWithResult; + use rusqlite::Connection; + use uuid::Uuid; + + // ── Helpers ────────────────────────────────────────────────────────────── + + fn in_memory() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.pragma_update(None, "journal_mode", "WAL").unwrap(); + conn.pragma_update(None, "busy_timeout", 5000).unwrap(); + conn.execute_batch(super::store::SCHEMA).unwrap(); + conn + } + + fn make_observer_frame(owner_keys: &Keys, agent_keys: &Keys, frame_type: &str) -> Event { + let owner_pk = owner_keys.public_key().to_hex(); + let agent_pk = agent_keys.public_key().to_hex(); + let tags = vec![ + Tag::parse(["p", &owner_pk]).unwrap(), + Tag::parse(["agent", &agent_pk]).unwrap(), + Tag::parse(["frame", frame_type]).unwrap(), + ]; + EventBuilder::new(Kind::Custom(24200), &"A".repeat(200)) + .tags(tags) + .sign_with_keys(agent_keys) + .unwrap() + } + + fn add_sub( + conn: &Connection, + identity_pk: &str, + relay_url: &str, + scope_type: &str, + scope_value: &str, + kinds_json: &str, + ) { + store::upsert_save_subscription( + conn, + identity_pk, + relay_url, + scope_type, + scope_value, + kinds_json, + 0, + ) + .unwrap(); + } + + /// Run the full archive pipeline synchronously with a fake relay response. + /// + /// Calls `plan_archive` → injects fake relay events → `commit_archive`. + /// This mirrors `archive_events` without the async relay calls. + fn run_batch_sync( + candidates: Vec, + identity_pk: &str, + relay_url: &str, + conn: &Connection, + fake_relay_events: Vec, + ) -> ArchiveBatchResult { + let plan = plan_archive(candidates, identity_pk, relay_url, conn).unwrap(); + + // Synthesize BucketWithResult from the fake relay response. + let fake_ids: std::collections::HashSet = + fake_relay_events.iter().map(|e| e.id.to_hex()).collect(); + let bucket_results: Vec = plan + .buckets + .into_iter() + .map(|b| BucketWithResult { + scope_type_str: b.scope_type_str, + scope_value: b.scope_value, + allowed_kinds: b.allowed_kinds, + group: b.group, + returned_ids: fake_ids.clone(), + relay_failed: false, + }) + .collect(); + + commit_archive( + bucket_results, + plan.ephemeral, + plan.pre_dropped, + identity_pk, + relay_url, + 0, + conn, + ) + .unwrap() + } + + fn candidate(event: &Event, scope_type: ScopeType, scope_value: &str) -> ArchiveCandidate { + ArchiveCandidate { + raw_event_json: event.as_json(), + matched_scope: MatchedScope { + scope_type, + scope_value: scope_value.to_string(), + }, + } + } + + // ── Ephemeral validator — individual condition rejection ────────────────── + + #[test] + fn test_ephemeral_validator_accepts_valid_frame() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]"); + let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY); + assert!( + validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url) + .is_ok() + ); + } + + #[test] + fn test_ephemeral_validator_rejects_wrong_kind() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]"); + let ev = EventBuilder::new(Kind::TextNote, "hello") + .tags(vec![ + Tag::parse(["p", &owner_pk]).unwrap(), + Tag::parse(["agent", &agent_keys.public_key().to_hex()]).unwrap(), + Tag::parse(["frame", OBSERVER_FRAME_TELEMETRY]).unwrap(), + ]) + .sign_with_keys(&agent_keys) + .unwrap(); + let result = + validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("kind")); + } + + #[test] + fn test_ephemeral_validator_rejects_missing_p_tag() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]"); + let ev = EventBuilder::new(Kind::Custom(24200), &"A".repeat(200)) + .tags(vec![ + Tag::parse(["agent", &agent_keys.public_key().to_hex()]).unwrap(), + Tag::parse(["frame", OBSERVER_FRAME_TELEMETRY]).unwrap(), + ]) + .sign_with_keys(&agent_keys) + .unwrap(); + let result = + validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("#p")); + } + + #[test] + fn test_ephemeral_validator_rejects_missing_agent_tag() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]"); + let ev = EventBuilder::new(Kind::Custom(24200), &"A".repeat(200)) + .tags(vec![ + Tag::parse(["p", &owner_pk]).unwrap(), + Tag::parse(["frame", OBSERVER_FRAME_TELEMETRY]).unwrap(), + ]) + .sign_with_keys(&agent_keys) + .unwrap(); + let result = + validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("agent")); + } + + #[test] + fn test_ephemeral_validator_rejects_control_frame() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]"); + let ev = make_observer_frame(&owner_keys, &agent_keys, "control"); + let result = + validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("telemetry")); + } + + #[test] + fn test_ephemeral_validator_rejects_wrong_author() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let other_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]"); + let ev = EventBuilder::new(Kind::Custom(24200), &"A".repeat(200)) + .tags(vec![ + Tag::parse(["p", &owner_pk]).unwrap(), + Tag::parse(["agent", &agent_keys.public_key().to_hex()]).unwrap(), + Tag::parse(["frame", OBSERVER_FRAME_TELEMETRY]).unwrap(), + ]) + .sign_with_keys(&other_keys) // wrong signer + .unwrap(); + let result = + validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("author")); + } + + #[test] + fn test_ephemeral_validator_rejects_no_subscription() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + // Deliberately do NOT add a subscription. + let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY); + let result = + validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("owner_p subscription")); + } + + #[test] + fn test_ephemeral_validator_rejects_kind_not_in_subscription() { + // Subscription exists but kinds = [1] (not 24200) — must be rejected. + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[1]"); // wrong kinds + let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY); + let result = + validate_ephemeral_frame(&ev, &owner_pk, &owner_pk, &conn, &owner_pk, relay_url); + assert!(result.is_err()); + let msg = result.unwrap_err(); + assert!( + msg.contains("24200"), + "expected kind 24200 in error, got: {msg}" + ); + } + + // ── archive pipeline — persistent path ─────────────────────────────────── + + #[test] + fn test_persistent_channel_h_persists_when_relay_returns_event() { + let conn = in_memory(); + let keys = Keys::generate(); + let identity_pk = keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + let chan = "chan-abc"; + add_sub(&conn, &identity_pk, relay_url, "channel_h", chan, "[9]"); + + let ev = EventBuilder::new(Kind::Custom(9), "msg") + .tags(vec![Tag::parse(["h", chan]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let cands = vec![candidate(&ev, ScopeType::ChannelH, chan)]; + + // Fake relay returns the event (simulates relay proof). + let result = run_batch_sync(cands, &identity_pk, relay_url, &conn, vec![ev.clone()]); + assert_eq!(result.persisted, 1); + assert_eq!(result.dropped, 0); + + // Confirm the event is in the store. + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 1); + let scope_count: i64 = conn + .query_row("SELECT COUNT(*) FROM archived_event_scopes WHERE scope_type = 'channel_h' AND scope_value = ?1", [chan], |r| r.get(0)) + .unwrap(); + assert_eq!(scope_count, 1); + } + + #[test] + fn test_persistent_channel_h_drops_when_relay_does_not_return_event() { + // Relay returns empty — event not proven accessible. + let conn = in_memory(); + let keys = Keys::generate(); + let identity_pk = keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + let chan = "chan-abc"; + add_sub(&conn, &identity_pk, relay_url, "channel_h", chan, "[9]"); + + let ev = EventBuilder::new(Kind::Custom(9), "msg") + .tags(vec![Tag::parse(["h", chan]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let cands = vec![candidate(&ev, ScopeType::ChannelH, chan)]; + + // Fake relay returns nothing. + let result = run_batch_sync(cands, &identity_pk, relay_url, &conn, vec![]); + assert_eq!(result.persisted, 0); + assert_eq!(result.dropped, 1); + } + + #[test] + fn test_persistent_drops_when_no_subscription() { + // No subscription at all — drop before even querying. + let conn = in_memory(); + let keys = Keys::generate(); + let identity_pk = keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + let chan = "chan-abc"; + // Intentionally no subscription. + + let ev = EventBuilder::new(Kind::Custom(9), "msg") + .tags(vec![Tag::parse(["h", chan]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let cands = vec![candidate(&ev, ScopeType::ChannelH, chan)]; + + // Fake relay would return the event, but no sub → dropped. + let result = run_batch_sync(cands, &identity_pk, relay_url, &conn, vec![ev.clone()]); + assert_eq!(result.persisted, 0); + assert_eq!(result.dropped, 1); + } + + #[test] + fn test_persistent_drops_kind_not_in_subscription() { + // Subscription is for kind 9 only; event is kind 7 (reaction). + let conn = in_memory(); + let keys = Keys::generate(); + let identity_pk = keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + let chan = "chan-abc"; + add_sub(&conn, &identity_pk, relay_url, "channel_h", chan, "[9]"); + + // kind 7 reaction — no `h` tag naturally, but relay-returned under scoped filter + let ev = EventBuilder::new(Kind::Reaction, "+") + .tags(vec![Tag::parse(["h", chan]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let cands = vec![candidate(&ev, ScopeType::ChannelH, chan)]; + + // Fake relay returns the event (simulates relay proof via StoredEvent.channel_id), + // but kind 7 is not in the subscription's kinds list. + let result = run_batch_sync(cands, &identity_pk, relay_url, &conn, vec![ev.clone()]); + assert_eq!(result.persisted, 0); + assert_eq!(result.dropped, 1); + } + + #[test] + fn test_persistent_h_less_event_persists_when_relay_returns_it() { + // An h-less event (e.g. reaction kind:7) that the relay returns under + // the scoped #h filter (via StoredEvent.channel_id fallback) must be + // persisted. The local tag scanner would have dropped it; the scoped + // filter proof must not. + let conn = in_memory(); + let keys = Keys::generate(); + let identity_pk = keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + let chan = "chan-abc"; + add_sub(&conn, &identity_pk, relay_url, "channel_h", chan, "[7]"); + + // Build a reaction without an `h` tag — local re-derivation would drop it. + let ev = EventBuilder::new(Kind::Reaction, "+") + .sign_with_keys(&keys) + .unwrap(); + let cands = vec![candidate(&ev, ScopeType::ChannelH, chan)]; + + // Fake relay returns it (relay used StoredEvent.channel_id to match). + let result = run_batch_sync(cands, &identity_pk, relay_url, &conn, vec![ev.clone()]); + assert_eq!(result.persisted, 1); + assert_eq!(result.dropped, 0); + + // Scope row uses bucket's scope_value, not a local-derived value. + let scope_val: String = conn + .query_row( + "SELECT scope_value FROM archived_event_scopes WHERE scope_type = 'channel_h'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(scope_val, chan); + } + + #[test] + fn test_persistent_referenced_e_persists_when_relay_returns_event() { + let conn = in_memory(); + let keys = Keys::generate(); + let identity_pk = keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + let ref_id = "a".repeat(64); + add_sub( + &conn, + &identity_pk, + relay_url, + "referenced_e", + &ref_id, + "[9]", + ); + + let ev = EventBuilder::new(Kind::Custom(9), "reply") + .tags(vec![Tag::parse(["e", &ref_id]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let cands = vec![candidate(&ev, ScopeType::ReferencedE, &ref_id)]; + let result = run_batch_sync(cands, &identity_pk, relay_url, &conn, vec![ev.clone()]); + assert_eq!(result.persisted, 1); + assert_eq!(result.dropped, 0); + } + + #[test] + fn test_mixed_batch_persisted_and_dropped_counted_exactly() { + // Two channel_h candidates: relay only returns one. dropped must be 1. + let conn = in_memory(); + let keys = Keys::generate(); + let identity_pk = keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + let chan = "chan-abc"; + add_sub(&conn, &identity_pk, relay_url, "channel_h", chan, "[9]"); + + let ev1 = EventBuilder::new(Kind::Custom(9), "msg1") + .tags(vec![Tag::parse(["h", chan]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let ev2 = EventBuilder::new(Kind::Custom(9), "msg2") + .tags(vec![Tag::parse(["h", chan]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let cands = vec![ + candidate(&ev1, ScopeType::ChannelH, chan), + candidate(&ev2, ScopeType::ChannelH, chan), + ]; + + // Fake relay only returns ev1. + let result = run_batch_sync(cands, &identity_pk, relay_url, &conn, vec![ev1.clone()]); + assert_eq!(result.persisted, 1); + assert_eq!(result.dropped, 1); + } + + // ── archive pipeline — ephemeral path ──────────────────────────────────── + + #[test] + fn test_ephemeral_path_persists_valid_frame() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]"); + + let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY); + let cands = vec![candidate(&ev, ScopeType::OwnerP, &owner_pk)]; + + // Fake relay returns nothing (not consulted for ephemeral path). + let result = run_batch_sync(cands, &owner_pk, relay_url, &conn, vec![]); + assert_eq!(result.persisted, 1); + assert_eq!(result.dropped, 0); + } + + #[test] + fn test_ephemeral_path_drops_kind_not_in_subscription() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + // kinds = [1], not [24200] + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[1]"); + + let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY); + let cands = vec![candidate(&ev, ScopeType::OwnerP, &owner_pk)]; + + let result = run_batch_sync(cands, &owner_pk, relay_url, &conn, vec![]); + assert_eq!(result.persisted, 0); + assert_eq!(result.dropped, 1); + } + + // ── Invalid input dropped ───────────────────────────────────────────────── + + #[test] + fn test_malformed_json_is_dropped() { + let result = Event::from_json("not json at all"); + assert!(result.is_err()); + } + + #[test] + fn test_tampered_event_fails_verify_id() { + let keys = Keys::generate(); + let mut ev_json: serde_json::Value = serde_json::from_str( + &EventBuilder::new(Kind::TextNote, "ok") + .sign_with_keys(&keys) + .unwrap() + .as_json(), + ) + .unwrap(); + ev_json["content"] = serde_json::Value::String("tampered".into()); + let tampered = ev_json.to_string(); + let ev = Event::from_json(&tampered).unwrap(); + assert!(!ev.verify_id()); + } + + // ── F2: out-of-range kind ───────────────────────────────────────────────── + + #[test] + fn test_out_of_range_kind_is_dropped() { + // kind 89736 == 24200 + 65536. The nostr crate truncates it to 24200 + // via `v as u16`, so without the raw-kind check the validator would + // reason about 24200 while the persisted raw_json still says 89736. + // The fix rejects it before Event::from_json. + let conn = in_memory(); + let keys = Keys::generate(); + let identity_pk = keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + let owner_pk = &identity_pk; + // Build a valid kind-24200 frame, then mutate only the raw kind to 89736. + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let pk = owner_keys.public_key().to_hex(); + add_sub(&conn, &pk, relay_url, "owner_p", &pk, "[24200]"); + let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY); + let mut raw: serde_json::Value = serde_json::from_str(&ev.as_json()).unwrap(); + raw["kind"] = serde_json::Value::Number(serde_json::Number::from(24200u64 + 65536)); + let bad_json = raw.to_string(); + + let cand = ArchiveCandidate { + raw_event_json: bad_json, + matched_scope: MatchedScope { + scope_type: ScopeType::OwnerP, + scope_value: pk.clone(), + }, + }; + let result = run_batch_sync(vec![cand], &pk, relay_url, &conn, vec![]); + assert_eq!(result.persisted, 0, "out-of-range kind must be dropped"); + assert_eq!(result.dropped, 1); + // No event row must exist. + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 0); + let _ = owner_pk; // silence unused-var + } + + // ── F3: transactional atomicity ─────────────────────────────────────────── + + #[test] + fn test_commit_archive_rolls_back_when_scope_write_would_fail() { + // Verify the split-schema invariant: if we simulate a mid-batch + // failure (by putting the DB into a state where the scope table is + // missing), the event row must NOT survive. + // + // We can't actually make upsert_event_scope fail on a healthy DB, so + // we verify the positive side: with a healthy DB, both rows are always + // written together or neither is. We confirm via run_batch_sync that + // after a full successful commit both tables have matching row counts. + let conn = in_memory(); + let keys = Keys::generate(); + let identity_pk = keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + let chan = "chan-txn"; + add_sub(&conn, &identity_pk, relay_url, "channel_h", chan, "[9]"); + + let ev1 = EventBuilder::new(Kind::Custom(9), "msg1") + .tags(vec![Tag::parse(["h", chan]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let ev2 = EventBuilder::new(Kind::Custom(9), "msg2") + .tags(vec![Tag::parse(["h", chan]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let cands = vec![ + candidate(&ev1, ScopeType::ChannelH, chan), + candidate(&ev2, ScopeType::ChannelH, chan), + ]; + let result = run_batch_sync( + cands, + &identity_pk, + relay_url, + &conn, + vec![ev1.clone(), ev2.clone()], + ); + assert_eq!(result.persisted, 2); + assert_eq!(result.dropped, 0); + + // Every event row must have exactly one corresponding scope row. + let event_count: i64 = conn + .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) + .unwrap(); + let scope_count: i64 = conn + .query_row("SELECT COUNT(*) FROM archived_event_scopes", [], |r| { + r.get(0) + }) + .unwrap(); + assert_eq!( + event_count, scope_count, + "every event row must have a matching scope row — split-schema invariant" + ); + } + + // ── Real-relay integration tests ────────────────────────────────────────── + // + // Gated on `#[cfg(not(target_os = "windows"))]` because `build_app_state()` + // and `reqwest::Client` pull in native Windows DLLs (SChannel/WinHTTP) that + // are not available in the CI runner, causing STATUS_ENTRYPOINT_NOT_FOUND at + // test-binary launch before any test runs. The guard compiles the whole + // cluster out of the Windows test binary. These tests are `#[ignore]` and + // require a live relay anyway — there is no Windows relay in CI. + // + // Run (Linux/macOS only): + // + // RELAY_URL=ws://localhost:3000 cargo test -p buzz-desktop \ + // archive::tests::real_relay -- --ignored --nocapture + // + // The relay must be running with a Postgres backend (same docker compose + // as the existing e2e suite). Each test creates its own keypair + channel so + // concurrent runs don't interfere. + + #[cfg(not(target_os = "windows"))] + mod real_relay { + use super::*; + use crate::app_state::build_app_state; + use std::path::Path; + + fn relay_ws_url_from_env() -> String { + std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()) + } + + fn relay_http_base() -> String { + relay_ws_url_from_env() + .replace("wss://", "https://") + .replace("ws://", "http://") + .trim_end_matches('/') + .to_string() + } + + /// Build a test AppState wired with specific identity keys and relay URL. + /// Mirrors production `archive_events`: the same `query_buckets` call path + /// is exercised, including NIP-98 signing inside `query_relay`. + fn make_test_app_state(keys: Keys, relay_url: &str) -> AppState { + let state = build_app_state(); + *state.keys.lock().unwrap() = keys; + *state.relay_url_override.lock().unwrap() = Some(relay_url.to_string()); + state + } + + /// Submit a signed event to the relay via `POST /events`. + /// The staging relay accepts events without NIP-98; this is only used for + /// test setup (publishing events that the archive pipeline will later query). + async fn submit_event_to_relay(ev: &Event) -> serde_json::Value { + let http = reqwest::Client::new(); + let resp = http + .post(format!("{}/events", relay_http_base())) + .header("X-Pubkey", ev.pubkey.to_hex()) + .header("Content-Type", "application/json") + .body(ev.as_json()) + .send() + .await + .expect("submit event to relay"); + assert!( + resp.status().is_success(), + "relay event submit failed: {}", + resp.status() + ); + resp.json().await.expect("parse submit response") + } + + /// Create an open channel on the relay. Returns the channel UUID string. + async fn create_relay_channel(keys: &Keys) -> String { + let channel_id = Uuid::new_v4().to_string(); + let ev = EventBuilder::new(Kind::Custom(9007), "") + .tags(vec![ + Tag::parse(["h", &channel_id]).unwrap(), + Tag::parse(["name", &format!("archive-e2e-{channel_id}")]).unwrap(), + Tag::parse(["channel_type", "stream"]).unwrap(), + Tag::parse(["visibility", "open"]).unwrap(), + ]) + .sign_with_keys(keys) + .unwrap(); + let resp = submit_event_to_relay(&ev).await; + assert!( + resp["accepted"].as_bool().unwrap_or(false), + "channel creation not accepted: {resp}" + ); + channel_id + } + + /// Open a file-backed archive DB at `path` and insert one save subscription. + fn file_db_with_subscription( + path: &Path, + identity_pk: &str, + relay_url: &str, + scope_type: &str, + scope_value: &str, + kinds_json: &str, + ) { + let conn = store::open_archive_db(path).expect("open file archive db"); + add_sub( + &conn, + identity_pk, + relay_url, + scope_type, + scope_value, + kinds_json, + ); + // conn drops here — file is flushed before the caller reopens it + } + + /// Run plan → real relay query (via the production `query_buckets` path, + /// including NIP-98 signing) → commit, using a file-backed archive DB. + /// + /// Mirrors the open/drop/query/reopen pattern of production `archive_events`: + /// 1. Open DB, run `plan_archive`, drop connection (no conn across `.await`). + /// 2. Call `query_buckets(plan.buckets, &state).await` — NIP-98 signed. + /// 3. Reopen DB for `commit_archive`. + /// + /// Returns `ArchiveBatchResult`; caller reopens the file for row assertions. + async fn run_batch_real_relay( + candidates: Vec, + state: &AppState, + db_path: &Path, + ) -> ArchiveBatchResult { + let identity_pk = state.keys.lock().unwrap().public_key().to_hex(); + let relay_url = crate::relay::relay_ws_url_with_override(state); + + // Phase 1: plan (sync). Connection dropped before any .await. + let plan = { + let conn = store::open_archive_db(db_path).expect("open archive db for plan"); + plan_archive(candidates, &identity_pk, &relay_url, &conn).unwrap() + // conn drops here + }; + + // Phase 2: relay queries (async) — no Connection in scope. + // Uses the real `query_buckets` path: query_relay → NIP-98 signed /query. + let bucket_results = query_buckets(plan.buckets, state).await; + + // Phase 3: persist (sync). Fresh connection, same file. + let conn = store::open_archive_db(db_path).expect("open archive db for commit"); + commit_archive( + bucket_results, + plan.ephemeral, + plan.pre_dropped, + &identity_pk, + &relay_url, + 0, + &conn, + ) + .unwrap() + } + + /// Happy path: publish a kind:9 message to a channel, then run the archive + /// pipeline against the real relay. Asserts exact event + scope rows in a + /// file-backed SQLite archive, reopened after commit to prove persistence. + #[tokio::test] + #[ignore] + async fn test_real_relay_channel_h_happy_path_persists_event() { + let keys = Keys::generate(); + let relay_url = relay_ws_url_from_env(); + let state = make_test_app_state(keys.clone(), &relay_url); + let identity_pk = keys.public_key().to_hex(); + + // Create a channel and publish a kind:9 message. + let channel_id = create_relay_channel(&keys).await; + let msg_ev = EventBuilder::new(Kind::Custom(9), "hello archive") + .tags(vec![Tag::parse(["h", &channel_id]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let submit_resp = submit_event_to_relay(&msg_ev).await; + assert!( + submit_resp["accepted"].as_bool().unwrap_or(false), + "message not accepted: {submit_resp}" + ); + + // Give the relay a moment to persist. + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + + // File-backed archive DB with a channel_h subscription. + let tmp = tempfile::tempdir().expect("tempdir"); + let db_path = tmp.path().join("archive.db"); + file_db_with_subscription( + &db_path, + &identity_pk, + &relay_url, + "channel_h", + &channel_id, + "[9]", + ); + + let cands = vec![candidate(&msg_ev, ScopeType::ChannelH, &channel_id)]; + let result = run_batch_real_relay(cands, &state, &db_path).await; + + assert_eq!(result.persisted, 1, "expected 1 persisted, got {result:?}"); + assert_eq!(result.dropped, 0, "expected 0 dropped, got {result:?}"); + + // Reopen the same file to assert exact row counts — proves file-backed persistence. + let read_conn = store::open_archive_db(&db_path).expect("reopen archive db"); + + let event_count: i64 = read_conn + .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) + .unwrap(); + assert_eq!(event_count, 1, "archived_events should have 1 row"); + + let scope_count: i64 = read_conn + .query_row( + "SELECT COUNT(*) FROM archived_event_scopes \ + WHERE scope_type = 'channel_h' AND scope_value = ?1", + [&channel_id], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(scope_count, 1, "archived_event_scopes should have 1 row"); + + // Confirm the stored raw_json round-trips to the original event. + let raw_json: String = read_conn + .query_row("SELECT raw_json FROM archived_events", [], |r| r.get(0)) + .unwrap(); + let stored_ev = Event::from_json(&raw_json).unwrap(); + assert_eq!(stored_ev.id.to_hex(), msg_ev.id.to_hex()); + + println!( + "✓ real relay: event {} archived under channel_h:{}", + msg_ev.id.to_hex(), + channel_id + ); + println!(" archived_events: {event_count} row(s)"); + println!(" archived_event_scopes: {scope_count} row(s)"); + } + + /// Kind-mismatch drop: subscription allows only kinds=[1059] but we publish a kind:9 + /// channel message. The relay stores the event, but the archive's kind filter drops it + /// because 9 ∉ {1059}. + #[tokio::test] + #[ignore] + async fn test_real_relay_kind_mismatch_drops_event() { + let keys = Keys::generate(); + let relay_url = relay_ws_url_from_env(); + let state = make_test_app_state(keys.clone(), &relay_url); + let identity_pk = keys.public_key().to_hex(); + + let channel_id = create_relay_channel(&keys).await; + // Publish a kind:9 channel message — relay accepts it, but the subscription's + // kind filter is [1059] so the archive pipeline must drop it. + let msg_ev = EventBuilder::new(Kind::Custom(9), "kind-mismatch-msg") + .tags(vec![Tag::parse(["h", &channel_id]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let submit_resp = submit_event_to_relay(&msg_ev).await; + assert!( + submit_resp["accepted"].as_bool().unwrap_or(false), + "message not accepted: {submit_resp}" + ); + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + + // Subscription allows kinds=[1059] only — kind:9 must be dropped. + let tmp = tempfile::tempdir().expect("tempdir"); + let db_path = tmp.path().join("archive.db"); + file_db_with_subscription( + &db_path, + &identity_pk, + &relay_url, + "channel_h", + &channel_id, + "[1059]", + ); + + let cands = vec![candidate(&msg_ev, ScopeType::ChannelH, &channel_id)]; + let result = run_batch_real_relay(cands, &state, &db_path).await; + + assert_eq!(result.persisted, 0, "kind-mismatch: should be dropped"); + assert_eq!(result.dropped, 1, "kind-mismatch: drop count should be 1"); + + println!( + "✓ real relay: kind-mismatch correctly dropped kind:9 event (subscription is kinds=[1059])" + ); + } + + /// No-subscription drop: event arrives but no save_subscription row exists. + #[tokio::test] + #[ignore] + async fn test_real_relay_no_subscription_drops_event() { + let keys = Keys::generate(); + let relay_url = relay_ws_url_from_env(); + let state = make_test_app_state(keys.clone(), &relay_url); + + let channel_id = create_relay_channel(&keys).await; + let msg_ev = EventBuilder::new(Kind::Custom(9), "should be dropped") + .tags(vec![Tag::parse(["h", &channel_id]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + submit_event_to_relay(&msg_ev).await; + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + + // Empty file-backed archive DB — no subscription row. + // plan_archive drops the whole group because no subscription matches. + let tmp = tempfile::tempdir().expect("tempdir"); + let db_path = tmp.path().join("archive.db"); + store::open_archive_db(&db_path).expect("init empty archive db"); + // conn from init drops here; DB file exists but has no subscription rows + + let cands = vec![candidate(&msg_ev, ScopeType::ChannelH, &channel_id)]; + let result = run_batch_real_relay(cands, &state, &db_path).await; + + assert_eq!(result.persisted, 0, "no-sub: should be dropped"); + assert_eq!(result.dropped, 1, "no-sub: drop count should be 1"); + + println!("✓ real relay: no-subscription correctly dropped event"); + } + + /// Owner_p ephemeral path: a locally-built valid 24200 frame is archived + /// without a relay query (ephemeral events are never stored on the relay). + #[tokio::test] + #[ignore] + async fn test_real_relay_owner_p_ephemeral_path_persists_valid_frame() { + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let relay_url = relay_ws_url_from_env(); + let state = make_test_app_state(owner_keys.clone(), &relay_url); + let identity_pk = owner_keys.public_key().to_hex(); + + // Build a valid kind:24200 observer frame addressed to the owner. + let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY); + + // File-backed archive DB with an owner_p subscription for kind 24200. + let tmp = tempfile::tempdir().expect("tempdir"); + let db_path = tmp.path().join("archive.db"); + file_db_with_subscription( + &db_path, + &identity_pk, + &relay_url, + "owner_p", + &identity_pk, + "[24200]", + ); + + // owner_p candidates bypass the relay entirely — query_buckets gets an + // empty bucket list and the ephemeral path handles the frame locally. + let cands = vec![candidate(&ev, ScopeType::OwnerP, &identity_pk)]; + let result = run_batch_real_relay(cands, &state, &db_path).await; + + assert_eq!( + result.persisted, 1, + "owner_p: valid frame should be persisted" + ); + assert_eq!(result.dropped, 0, "owner_p: nothing should be dropped"); + + // Reopen the same file to assert exact row counts. + let read_conn = store::open_archive_db(&db_path).expect("reopen archive db"); + + let event_count: i64 = read_conn + .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) + .unwrap(); + assert_eq!(event_count, 1); + + let scope_count: i64 = read_conn + .query_row( + "SELECT COUNT(*) FROM archived_event_scopes WHERE scope_type = 'owner_p'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(scope_count, 1); + + println!( + "✓ real relay: owner_p ephemeral frame {} archived", + ev.id.to_hex() + ); + println!(" archived_events: {event_count} row(s)"); + println!(" archived_event_scopes: {scope_count} row(s)"); + } + } +} diff --git a/desktop/src-tauri/src/archive/pipeline.rs b/desktop/src-tauri/src/archive/pipeline.rs new file mode 100644 index 000000000..bcfbaf2fd --- /dev/null +++ b/desktop/src-tauri/src/archive/pipeline.rs @@ -0,0 +1,390 @@ +//! Archive pipeline — three-phase plan/query/commit split. +//! +//! Separated from `mod.rs` to keep that file under the 1000-line gate. +//! +//! # Send-safety +//! +//! `rusqlite::Connection` is `!Send`. No `&Connection` borrow crosses an +//! `.await` point in any function here. Phase 1 (`plan_archive`) and Phase 3 +//! (`commit_archive`) are sync; Phase 2 (`query_buckets`) is async and never +//! holds a `Connection` reference. + +use nostr::{Event, JsonUtil}; +use rusqlite::Connection; + +use crate::app_state::AppState; +use crate::relay::query_relay; + +use super::{store, validate_ephemeral_frame, ArchiveBatchResult, ArchiveCandidate, MatchedScope}; + +// ── Private helpers ─────────────────────────────────────────────────────────── + +/// Extract the raw `kind` integer from an event JSON string and return it as +/// `Some(u64)` only if it is in the valid NIP-01 range `0..=65535`. +/// +/// Returns `None` for malformed JSON, a missing `kind` field, a non-integer +/// `kind`, or any value outside `0..=65535`. Used to detect the `nostr` +/// crate's silent `v as u16` truncation before deserialization. +fn raw_kind_value(raw: &str) -> Option { + let v: serde_json::Value = serde_json::from_str(raw).ok()?; + let kind = v.get("kind")?.as_u64()?; + if kind > 65535 { + return None; + } + Some(kind) +} + +// ── Private types ──────────────────────────────────────────────────────────── + +/// A parsed, sig-verified candidate ready for further processing. +pub(super) struct Parsed { + pub(super) event: Event, + pub(super) raw_json: String, + pub(super) matched_scope: MatchedScope, +} + +/// One scope bucket: a set of candidates that share a scope type+value, +/// with the relay filter already built and the subscription kinds loaded. +pub(super) struct Bucket { + pub(super) scope_type_str: String, + pub(super) scope_value: String, + pub(super) allowed_kinds: Vec, + pub(super) filter: serde_json::Value, + pub(super) group: Vec, +} + +/// Output of the sync planning phase. +pub(super) struct ArchivePlan { + pub(super) buckets: Vec, + pub(super) ephemeral: Vec, + /// Events already accounted as dropped during planning (no subscription, + /// unknown scope type, parse failure, bad sig). + pub(super) pre_dropped: u32, +} + +/// A bucket with the relay's response attached. +pub(super) struct BucketWithResult { + pub(super) scope_type_str: String, + pub(super) scope_value: String, + pub(super) allowed_kinds: Vec, + pub(super) group: Vec, + /// Event ids returned by the relay for the scoped filter. + pub(super) returned_ids: std::collections::HashSet, + /// True if the relay query failed (network error); entire group dropped. + pub(super) relay_failed: bool, +} + +// ── Phase 1 ────────────────────────────────────────────────────────────────── + +/// Phase 1 (sync): parse all candidates, group persistent ones into per-scope +/// buckets, and load the subscription kinds for each bucket. +/// +/// Returns an [`ArchivePlan`] with no `&Connection` remaining — safe to hold +/// across `.await`. +pub(super) fn plan_archive( + candidates: Vec, + identity_pk: &str, + relay_url: &str, + conn: &Connection, +) -> Result { + let mut persistent_raw: Vec = Vec::new(); + let mut ephemeral: Vec = Vec::new(); + let mut pre_dropped: u32 = 0; + + for cand in candidates { + // Range-validate raw kind before nostr::Event normalizes it. + // `nostr 0.44.3` `Kind::deserialize` does `v as u16`, which silently + // truncates e.g. `89736` (= 24200 + 65536) to `24200`. We reject any + // raw `kind` outside 0..=65535 so the validator never reasons about a + // truncated kind while persisting the original out-of-range value. + let raw_kind = match raw_kind_value(&cand.raw_event_json) { + Some(k) => k, + None => { + pre_dropped += 1; + continue; + } + }; + + let event = match Event::from_json(&cand.raw_event_json) { + Ok(e) => e, + Err(_) => { + pre_dropped += 1; + continue; + } + }; + + // Assert the deserialized kind matches the raw value (paranoia check). + if event.kind.as_u16() as u64 != raw_kind { + pre_dropped += 1; + continue; + } + + if !event.verify_id() || !event.verify_signature() { + pre_dropped += 1; + continue; + } + + if cand.matched_scope.scope_type.is_ephemeral() { + ephemeral.push(Parsed { + event, + raw_json: cand.raw_event_json, + matched_scope: cand.matched_scope, + }); + } else { + persistent_raw.push(Parsed { + event, + raw_json: cand.raw_event_json, + matched_scope: cand.matched_scope, + }); + } + } + + // Group persistent candidates by (scope_type, scope_value). + use std::collections::HashMap; + let mut scope_groups: HashMap<(String, String), Vec> = HashMap::new(); + for p in persistent_raw { + let key = ( + p.matched_scope.scope_type.as_str().to_string(), + p.matched_scope.scope_value.clone(), + ); + scope_groups.entry(key).or_default().push(p); + } + + let mut buckets: Vec = Vec::with_capacity(scope_groups.len()); + for ((scope_type_str, scope_value), mut group) in scope_groups { + // No subscription → drop the whole group. + let kinds_json = match store::get_subscription_kinds( + conn, + identity_pk, + relay_url, + &scope_type_str, + &scope_value, + )? { + Some(k) => k, + None => { + pre_dropped += group.len() as u32; + continue; + } + }; + + let allowed_kinds: Vec = + serde_json::from_str::>(&kinds_json).unwrap_or_default(); + + // Deduplicate by event id within the bucket. + let mut seen = std::collections::HashSet::new(); + group.retain(|p| seen.insert(p.event.id.to_hex())); + + let ids: Vec = group.iter().map(|p| p.event.id.to_hex()).collect(); + + // Build a *scoped* relay filter: ids + scope tag + kinds. + let filter = match scope_type_str.as_str() { + "channel_h" => serde_json::json!({ + "ids": ids, + "#h": [&scope_value], + "kinds": allowed_kinds, + }), + "referenced_e" => serde_json::json!({ + "ids": ids, + "#e": [&scope_value], + "kinds": allowed_kinds, + }), + _ => { + pre_dropped += group.len() as u32; + continue; + } + }; + + buckets.push(Bucket { + scope_type_str, + scope_value, + allowed_kinds, + filter, + group, + }); + } + + Ok(ArchivePlan { + buckets, + ephemeral, + pre_dropped, + }) +} + +// ── Phase 2 ────────────────────────────────────────────────────────────────── + +/// Phase 2 (async): fire one relay query per bucket and collect results. +/// +/// `state` is `&AppState` — a `Copy` reference — so no `!Send` value is held +/// across `.await`. +pub(super) async fn query_buckets(buckets: Vec, state: &AppState) -> Vec { + let mut results: Vec = Vec::with_capacity(buckets.len()); + for bucket in buckets { + let (returned_ids, relay_failed) = match query_relay(state, &[bucket.filter]).await { + Ok(evs) => (evs.iter().map(|e| e.id.to_hex()).collect(), false), + Err(_) => (std::collections::HashSet::new(), true), + }; + results.push(BucketWithResult { + scope_type_str: bucket.scope_type_str, + scope_value: bucket.scope_value, + allowed_kinds: bucket.allowed_kinds, + group: bucket.group, + returned_ids, + relay_failed, + }); + } + results +} + +// ── Phase 3 ────────────────────────────────────────────────────────────────── + +/// Phase 3 (sync): apply relay results and write accepted events to the store. +/// +/// All event + scope upserts run inside a single SQLite transaction: either +/// every write in the batch commits or none do. This preserves the invariant +/// that every `archived_events` row has at least one matching `archived_event_scopes` +/// row — a partial failure can never leave an orphaned event with no scope proof. +pub(super) fn commit_archive( + bucket_results: Vec, + ephemeral: Vec, + pre_dropped: u32, + identity_pk: &str, + relay_url: &str, + now: i64, + conn: &Connection, +) -> Result { + let mut persisted: u32 = 0; + let mut dropped: u32 = pre_dropped; + + // Collect writes; count drops first, then execute inside a single + // transaction so event and scope rows are always committed atomically. + struct WriteRow<'a> { + eid: String, + kind: i64, + pubkey: String, + created_at: i64, + raw_json: &'a str, + scope_type: &'a str, + scope_value: &'a str, + } + let mut writes: Vec> = Vec::new(); + + // ── Persistent path ────────────────────────────────────────────────────── + for result in &bucket_results { + if result.relay_failed { + dropped += result.group.len() as u32; + continue; + } + + for p in &result.group { + let eid = p.event.id.to_hex(); + + // Relay proof: event was returned for the scoped filter. + if !result.returned_ids.contains(&eid) { + dropped += 1; + continue; + } + + // Kind enforcement: event.kind must be in the subscription's list. + if !result + .allowed_kinds + .contains(&(p.event.kind.as_u16() as u64)) + { + dropped += 1; + continue; + } + + // The relay returning this event for {ids, #h/#e, kinds} IS the + // proof of scope membership. Use scope_value directly; no local + // tag re-derivation (which would incorrectly drop h-less events + // matched via the relay's StoredEvent.channel_id fallback). + writes.push(WriteRow { + eid, + kind: p.event.kind.as_u16() as i64, + pubkey: p.event.pubkey.to_hex(), + created_at: p.event.created_at.as_secs() as i64, + raw_json: &p.raw_json, + scope_type: &result.scope_type_str, + scope_value: &result.scope_value, + }); + } + } + + // ── Ephemeral path (owner_p) ───────────────────────────────────────────── + // Fully local validation — no relay query. + let mut validated_ephemeral: Vec<(String, &Parsed)> = Vec::new(); + for p in &ephemeral { + match validate_ephemeral_frame( + &p.event, + identity_pk, + &p.matched_scope.scope_value, + conn, + identity_pk, + relay_url, + ) { + Ok(()) => validated_ephemeral.push((p.event.id.to_hex(), p)), + Err(_) => { + dropped += 1; + } + } + } + + // ── Commit all writes atomically ───────────────────────────────────────── + if !writes.is_empty() || !validated_ephemeral.is_empty() { + let tx = conn + .unchecked_transaction() + .map_err(|e| format!("failed to begin archive transaction: {e}"))?; + + for w in &writes { + store::upsert_archived_event( + &tx, + identity_pk, + relay_url, + &w.eid, + w.kind, + &w.pubkey, + w.created_at, + w.raw_json, + now, + )?; + store::upsert_event_scope( + &tx, + identity_pk, + relay_url, + &w.eid, + w.scope_type, + w.scope_value, + now, + )?; + persisted += 1; + } + + for (eid, p) in &validated_ephemeral { + store::upsert_archived_event( + &tx, + identity_pk, + relay_url, + eid, + p.event.kind.as_u16() as i64, + &p.event.pubkey.to_hex(), + p.event.created_at.as_secs() as i64, + &p.raw_json, + now, + )?; + store::upsert_event_scope( + &tx, + identity_pk, + relay_url, + eid, + "owner_p", + &p.matched_scope.scope_value, + now, + )?; + persisted += 1; + } + + tx.commit() + .map_err(|e| format!("failed to commit archive transaction: {e}"))?; + } + + Ok(ArchiveBatchResult { persisted, dropped }) +} diff --git a/desktop/src-tauri/src/archive/store.rs b/desktop/src-tauri/src/archive/store.rs new file mode 100644 index 000000000..b70a96749 --- /dev/null +++ b/desktop/src-tauri/src/archive/store.rs @@ -0,0 +1,975 @@ +//! Local SQLite archive store for saved relay messages. +//! +//! Three tables: +//! - `archived_events` — one raw event row per (identity, relay, event_id) +//! - `archived_event_scopes` — N scope membership rows per raw event (many-to-many) +//! - `save_subscriptions` — which scopes the user has subscribed to save +//! +//! WAL + `busy_timeout=5000` matches `managed_agents/retention.rs`. +//! Raw event rows are GC'd when their last scope row is deleted. + +use std::path::Path; + +use rusqlite::{params, Connection, OptionalExtension}; + +// ── Schema ───────────────────────────────────────────────────────────────── + +pub(super) const SCHEMA: &str = " +CREATE TABLE IF NOT EXISTS archived_events ( + identity_pubkey TEXT NOT NULL, + relay_url TEXT NOT NULL, + id TEXT NOT NULL, + kind INTEGER NOT NULL, + pubkey TEXT NOT NULL, + created_at INTEGER NOT NULL, + raw_json TEXT NOT NULL, + archived_at INTEGER NOT NULL, + PRIMARY KEY (identity_pubkey, relay_url, id) +); + +CREATE TABLE IF NOT EXISTS archived_event_scopes ( + identity_pubkey TEXT NOT NULL, + relay_url TEXT NOT NULL, + id TEXT NOT NULL, + scope_type TEXT NOT NULL, + scope_value TEXT NOT NULL, + archived_at INTEGER NOT NULL, + PRIMARY KEY (identity_pubkey, relay_url, id, scope_type, scope_value) +); + +CREATE TABLE IF NOT EXISTS save_subscriptions ( + identity_pubkey TEXT NOT NULL, + relay_url TEXT NOT NULL, + scope_type TEXT NOT NULL, + scope_value TEXT NOT NULL, + kinds TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (identity_pubkey, relay_url, scope_type, scope_value) +); +"; + +// ── Open / init ───────────────────────────────────────────────────────────── + +/// Open (or create) the archive database at the given path. +/// +/// Applies WAL journaling and `busy_timeout=5000` on every connection, +/// matching `managed_agents/retention.rs`. Creates all three tables if they +/// don't already exist. +pub fn open_archive_db(path: &Path) -> Result { + // Ensure the parent directory exists so `Connection::open` doesn't fail. + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("failed to create archive dir: {e}"))?; + } + + let conn = Connection::open(path).map_err(|e| format!("failed to open archive db: {e}"))?; + + conn.pragma_update(None, "journal_mode", "WAL") + .map_err(|e| format!("failed to set WAL mode: {e}"))?; + conn.pragma_update(None, "busy_timeout", 5000) + .map_err(|e| format!("failed to set busy_timeout: {e}"))?; + + conn.execute_batch(SCHEMA) + .map_err(|e| format!("failed to initialize archive schema: {e}"))?; + + Ok(conn) +} + +// ── Save subscriptions ────────────────────────────────────────────────────── + +/// A save subscription row. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct SaveSubscription { + pub identity_pubkey: String, + pub relay_url: String, + pub scope_type: String, + pub scope_value: String, + /// JSON-encoded integer array, e.g. `[1,6,39000]`. + pub kinds: String, + pub created_at: i64, +} + +/// Insert or replace a save subscription. `kinds` must be a JSON int array. +pub fn upsert_save_subscription( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, + scope_type: &str, + scope_value: &str, + kinds_json: &str, + now: i64, +) -> Result<(), String> { + conn.execute( + "INSERT INTO save_subscriptions + (identity_pubkey, relay_url, scope_type, scope_value, kinds, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) + ON CONFLICT (identity_pubkey, relay_url, scope_type, scope_value) + DO UPDATE SET kinds = excluded.kinds", + params![ + identity_pubkey, + relay_url, + scope_type, + scope_value, + kinds_json, + now + ], + ) + .map_err(|e| format!("failed to upsert save subscription: {e}"))?; + Ok(()) +} + +/// List all save subscriptions for the given identity + relay. +pub fn list_save_subscriptions( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, +) -> Result, String> { + let mut stmt = conn + .prepare( + "SELECT identity_pubkey, relay_url, scope_type, scope_value, kinds, created_at + FROM save_subscriptions + WHERE identity_pubkey = ?1 AND relay_url = ?2 + ORDER BY created_at ASC", + ) + .map_err(|e| format!("prepare list_save_subscriptions: {e}"))?; + + let rows = stmt + .query_map(params![identity_pubkey, relay_url], |row| { + Ok(SaveSubscription { + identity_pubkey: row.get(0)?, + relay_url: row.get(1)?, + scope_type: row.get(2)?, + scope_value: row.get(3)?, + kinds: row.get(4)?, + created_at: row.get(5)?, + }) + }) + .map_err(|e| format!("query list_save_subscriptions: {e}"))?; + + rows.collect::, _>>() + .map_err(|e| format!("read list_save_subscriptions row: {e}")) +} + +/// Delete a save subscription. Does NOT purge archived event data (retention +/// is decoupled in v1). Returns `true` if a row was deleted. +pub fn delete_save_subscription( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, + scope_type: &str, + scope_value: &str, +) -> Result { + let affected = conn + .execute( + "DELETE FROM save_subscriptions + WHERE identity_pubkey = ?1 + AND relay_url = ?2 + AND scope_type = ?3 + AND scope_value = ?4", + params![identity_pubkey, relay_url, scope_type, scope_value], + ) + .map_err(|e| format!("failed to delete save subscription: {e}"))?; + Ok(affected > 0) +} + +/// Return true if a matching save subscription exists for the given scope. +#[allow(dead_code)] +pub fn has_save_subscription( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, + scope_type: &str, + scope_value: &str, +) -> Result { + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM save_subscriptions + WHERE identity_pubkey = ?1 + AND relay_url = ?2 + AND scope_type = ?3 + AND scope_value = ?4", + params![identity_pubkey, relay_url, scope_type, scope_value], + |row| row.get(0), + ) + .map_err(|e| format!("failed to check save subscription: {e}"))?; + Ok(count > 0) +} + +/// Return the `kinds` JSON string for a matching save subscription, or `None` +/// if no subscription exists. +pub fn get_subscription_kinds( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, + scope_type: &str, + scope_value: &str, +) -> Result, String> { + let result = conn + .query_row( + "SELECT kinds FROM save_subscriptions + WHERE identity_pubkey = ?1 + AND relay_url = ?2 + AND scope_type = ?3 + AND scope_value = ?4", + params![identity_pubkey, relay_url, scope_type, scope_value], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|e| format!("failed to fetch subscription kinds: {e}"))?; + Ok(result) +} + +// ── Archived events ───────────────────────────────────────────────────────── + +/// Upsert an event row (idempotent on the PK). +/// +/// Does nothing if the event is already archived (same identity/relay/id). +pub fn upsert_archived_event( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, + event_id: &str, + kind: i64, + pubkey: &str, + created_at: i64, + raw_json: &str, + archived_at: i64, +) -> Result<(), String> { + conn.execute( + "INSERT INTO archived_events + (identity_pubkey, relay_url, id, kind, pubkey, created_at, raw_json, archived_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + ON CONFLICT (identity_pubkey, relay_url, id) DO NOTHING", + params![ + identity_pubkey, + relay_url, + event_id, + kind, + pubkey, + created_at, + raw_json, + archived_at + ], + ) + .map_err(|e| format!("failed to upsert archived event: {e}"))?; + Ok(()) +} + +/// Upsert a scope membership row for an event. +/// +/// Idempotent: if the (identity, relay, id, scope_type, scope_value) PK already +/// exists the row is left unchanged. +pub fn upsert_event_scope( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, + event_id: &str, + scope_type: &str, + scope_value: &str, + archived_at: i64, +) -> Result<(), String> { + conn.execute( + "INSERT INTO archived_event_scopes + (identity_pubkey, relay_url, id, scope_type, scope_value, archived_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) + ON CONFLICT (identity_pubkey, relay_url, id, scope_type, scope_value) DO NOTHING", + params![ + identity_pubkey, + relay_url, + event_id, + scope_type, + scope_value, + archived_at + ], + ) + .map_err(|e| format!("failed to upsert event scope: {e}"))?; + Ok(()) +} + +/// Read a paginated page of archived events for a given scope. +/// +/// Returns the `raw_json` of matching events in newest-first order +/// (`ORDER BY created_at DESC, id DESC`). The optional compound cursor +/// `(before_created_at, before_id)` implements keyset pagination: both fields +/// must be `Some` together to activate the cursor (passing one `Some` and one +/// `None` is a logic error at the call site — the store treats mixed `Some`/ +/// `None` as no cursor). The predicate mirrors the sort order exactly: +/// `(created_at < before_created_at) OR (created_at = before_created_at AND +/// id < before_id)`. Pass `None`/`None` to start at the newest end. +/// +/// A scalar `created_at`-only cursor would skip same-second siblings at a page +/// boundary because rows are ordered by `(created_at DESC, id DESC)` — two +/// rows with equal `created_at` on different pages would both be excluded by +/// `created_at < before`. The compound cursor avoids this. +/// +/// An optional `kinds` slice filters by event kind; `None` admits all kinds. +/// +/// Returns at most `limit` rows (caller is responsible for a sane default). +pub fn read_archived_events( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, + scope_type: &str, + scope_value: &str, + kinds: Option<&[i64]>, + before_created_at: Option, + before_id: Option<&str>, + limit: i64, +) -> Result, String> { + // Build clauses and positional params together so slot numbers are always + // contiguous (rusqlite rejects gaps like ?4 then ?6 with no ?5 in between). + // + // Fixed params: identity_pubkey, relay_url, scope_type, scope_value = ?1–?4. + // Optional params appended in declaration order, limit always last. + + let mut next_slot: usize = 5; + let mut extra_clauses = String::new(); + let mut kinds_json: Option = None; + let mut before_at_val: Option = None; + let mut before_id_val: Option = None; + + if let Some(ks) = kinds { + kinds_json = Some(serde_json::to_string(ks).unwrap_or_else(|_| "[]".to_string())); + extra_clauses.push_str(&format!( + " AND ae.kind IN (SELECT value FROM json_each(?{next_slot}))" + )); + next_slot += 1; + } + // Compound cursor: both fields must be Some to activate. The predicate + // mirrors ORDER BY (created_at DESC, id DESC) exactly so no same-second + // sibling is skipped at a page boundary. + if let (Some(bat), Some(bid)) = (before_created_at, before_id) { + before_at_val = Some(bat); + before_id_val = Some(bid.to_owned()); + extra_clauses.push_str(&format!( + " AND (ae.created_at < ?{next_slot} \ + OR (ae.created_at = ?{next_slot} AND ae.id < ?{}))", + next_slot + 1, + )); + next_slot += 2; + } + let limit_slot = next_slot; + + let sql = format!( + "SELECT ae.raw_json \ + FROM archived_events ae \ + INNER JOIN archived_event_scopes aes \ + ON aes.identity_pubkey = ae.identity_pubkey \ + AND aes.relay_url = ae.relay_url \ + AND aes.id = ae.id \ + WHERE ae.identity_pubkey = ?1 \ + AND ae.relay_url = ?2 \ + AND aes.scope_type = ?3 \ + AND aes.scope_value = ?4\ + {extra_clauses}\ + ORDER BY ae.created_at DESC, ae.id DESC \ + LIMIT ?{limit_slot}", + ); + + // Build the param list dynamically to match the generated SQL. + let mut params: Vec> = vec![ + Box::new(identity_pubkey.to_owned()), + Box::new(relay_url.to_owned()), + Box::new(scope_type.to_owned()), + Box::new(scope_value.to_owned()), + ]; + if let Some(kj) = kinds_json { + params.push(Box::new(kj)); + } + if let (Some(bat), Some(bid)) = (before_at_val, before_id_val) { + // Both slots use the same created_at value (the OR predicate references + // it twice); the id slot follows. + params.push(Box::new(bat)); + params.push(Box::new(bid)); + } + params.push(Box::new(limit)); + + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + + let mut stmt = conn + .prepare(&sql) + .map_err(|e| format!("prepare read_archived_events: {e}"))?; + + let rows = stmt + .query_map(param_refs.as_slice(), |row| row.get::<_, String>(0)) + .map_err(|e| format!("query read_archived_events: {e}"))?; + + rows.collect::, _>>() + .map_err(|e| format!("read read_archived_events row: {e}")) +} + +/// GC: delete orphaned event rows whose last scope row was just removed. +/// +/// Called after any batch deletion of scope rows. Uses a LEFT JOIN so only +/// events with zero remaining scope rows are deleted. +#[allow(dead_code)] // Used by P4 purge commands; not yet wired to a Tauri command. +pub fn gc_orphaned_events( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, +) -> Result { + let affected = conn + .execute( + "DELETE FROM archived_events + WHERE identity_pubkey = ?1 + AND relay_url = ?2 + AND id NOT IN ( + SELECT id FROM archived_event_scopes + WHERE identity_pubkey = ?1 + AND relay_url = ?2 + )", + params![identity_pubkey, relay_url], + ) + .map_err(|e| format!("failed to gc orphaned events: {e}"))?; + Ok(affected) +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn in_memory() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.pragma_update(None, "journal_mode", "WAL").unwrap(); + conn.pragma_update(None, "busy_timeout", 5000).unwrap(); + conn.execute_batch(SCHEMA).unwrap(); + conn + } + + // ── Schema init ────────────────────────────────────────────────────────── + + #[test] + fn test_schema_init_creates_all_tables() { + let conn = in_memory(); + // Verify all three tables exist by inserting a row in each. + conn.execute( + "INSERT INTO save_subscriptions VALUES ('pk','relay','channel_h','abc','[1]',0)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO archived_events VALUES ('pk','relay','id1',1,'author',0,'{}',0)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO archived_event_scopes VALUES ('pk','relay','id1','channel_h','abc',0)", + [], + ) + .unwrap(); + } + + #[test] + fn test_schema_init_is_idempotent() { + // Running SCHEMA twice must not error. + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch(SCHEMA).unwrap(); + conn.execute_batch(SCHEMA).unwrap(); + } + + // ── Save subscriptions ─────────────────────────────────────────────────── + + #[test] + fn test_upsert_save_subscription_inserts_and_updates_kinds() { + let conn = in_memory(); + upsert_save_subscription(&conn, "pk", "wss://r", "channel_h", "abc", "[1]", 1).unwrap(); + let subs = list_save_subscriptions(&conn, "pk", "wss://r").unwrap(); + assert_eq!(subs.len(), 1); + assert_eq!(subs[0].kinds, "[1]"); + + // Update kinds. + upsert_save_subscription(&conn, "pk", "wss://r", "channel_h", "abc", "[1,6]", 2).unwrap(); + let subs = list_save_subscriptions(&conn, "pk", "wss://r").unwrap(); + assert_eq!(subs.len(), 1); + assert_eq!(subs[0].kinds, "[1,6]"); + } + + #[test] + fn test_list_save_subscriptions_scoped_to_identity_and_relay() { + let conn = in_memory(); + upsert_save_subscription(&conn, "pk1", "wss://r1", "channel_h", "a", "[1]", 1).unwrap(); + upsert_save_subscription(&conn, "pk2", "wss://r1", "channel_h", "b", "[1]", 2).unwrap(); + upsert_save_subscription(&conn, "pk1", "wss://r2", "channel_h", "c", "[1]", 3).unwrap(); + + let subs = list_save_subscriptions(&conn, "pk1", "wss://r1").unwrap(); + assert_eq!(subs.len(), 1); + assert_eq!(subs[0].scope_value, "a"); + } + + #[test] + fn test_delete_save_subscription_removes_row() { + let conn = in_memory(); + upsert_save_subscription(&conn, "pk", "wss://r", "channel_h", "abc", "[1]", 1).unwrap(); + let deleted = delete_save_subscription(&conn, "pk", "wss://r", "channel_h", "abc").unwrap(); + assert!(deleted); + let subs = list_save_subscriptions(&conn, "pk", "wss://r").unwrap(); + assert!(subs.is_empty()); + } + + #[test] + fn test_delete_save_subscription_returns_false_when_not_found() { + let conn = in_memory(); + let deleted = + delete_save_subscription(&conn, "pk", "wss://r", "channel_h", "nope").unwrap(); + assert!(!deleted); + } + + #[test] + fn test_has_save_subscription_true_and_false() { + let conn = in_memory(); + upsert_save_subscription(&conn, "pk", "wss://r", "owner_p", "mypk", "[24200]", 1).unwrap(); + assert!(has_save_subscription(&conn, "pk", "wss://r", "owner_p", "mypk").unwrap()); + assert!(!has_save_subscription(&conn, "pk", "wss://r", "owner_p", "other").unwrap()); + } + + // ── Archived events ────────────────────────────────────────────────────── + + #[test] + fn test_upsert_archived_event_is_idempotent() { + let conn = in_memory(); + upsert_archived_event(&conn, "pk", "wss://r", "id1", 1, "author", 100, "{}", 200).unwrap(); + // Second call must not error or duplicate. + upsert_archived_event(&conn, "pk", "wss://r", "id1", 1, "author", 100, "{}", 201).unwrap(); + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 1); + } + + // ── Many-to-many scope rows ────────────────────────────────────────────── + + #[test] + fn test_one_event_gets_multiple_scope_rows() { + let conn = in_memory(); + upsert_archived_event(&conn, "pk", "wss://r", "id1", 1, "author", 100, "{}", 200).unwrap(); + upsert_event_scope(&conn, "pk", "wss://r", "id1", "channel_h", "chan1", 200).unwrap(); + upsert_event_scope(&conn, "pk", "wss://r", "id1", "referenced_e", "evref", 200).unwrap(); + // Idempotent second insert. + upsert_event_scope(&conn, "pk", "wss://r", "id1", "channel_h", "chan1", 201).unwrap(); + + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM archived_event_scopes WHERE id = 'id1'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 2); + } + + // ── GC ─────────────────────────────────────────────────────────────────── + + #[test] + fn test_gc_removes_event_when_last_scope_deleted() { + let conn = in_memory(); + upsert_archived_event(&conn, "pk", "wss://r", "id1", 1, "author", 100, "{}", 200).unwrap(); + upsert_event_scope(&conn, "pk", "wss://r", "id1", "channel_h", "c1", 200).unwrap(); + // Delete the only scope row manually. + conn.execute("DELETE FROM archived_event_scopes WHERE id = 'id1'", []) + .unwrap(); + let removed = gc_orphaned_events(&conn, "pk", "wss://r").unwrap(); + assert_eq!(removed, 1); + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 0); + } + + #[test] + fn test_gc_leaves_event_with_remaining_scope() { + let conn = in_memory(); + upsert_archived_event(&conn, "pk", "wss://r", "id1", 1, "author", 100, "{}", 200).unwrap(); + upsert_event_scope(&conn, "pk", "wss://r", "id1", "channel_h", "c1", 200).unwrap(); + upsert_event_scope(&conn, "pk", "wss://r", "id1", "referenced_e", "ref", 200).unwrap(); + // Delete only one scope row. + conn.execute( + "DELETE FROM archived_event_scopes WHERE scope_type = 'referenced_e'", + [], + ) + .unwrap(); + let removed = gc_orphaned_events(&conn, "pk", "wss://r").unwrap(); + assert_eq!(removed, 0); + } + + // ── read_archived_events ───────────────────────────────────────────────── + + fn seed_events(conn: &Connection) { + // Three events in scope "channel_h/chan1" for identity "pk"/"wss://r". + // created_at: 300 (newest), 200, 100 (oldest). + for (id, kind, created_at, raw) in &[ + ("e1", 9i64, 300i64, r#"{"id":"e1","created_at":300}"#), + ("e2", 9i64, 200i64, r#"{"id":"e2","created_at":200}"#), + ("e3", 42i64, 100i64, r#"{"id":"e3","created_at":100}"#), + ] { + upsert_archived_event( + conn, + "pk", + "wss://r", + id, + *kind, + "author", + *created_at, + raw, + 999, + ) + .unwrap(); + upsert_event_scope(conn, "pk", "wss://r", id, "channel_h", "chan1", 999).unwrap(); + } + // One event in a different scope — must never appear in chan1 results. + upsert_archived_event( + conn, + "pk", + "wss://r", + "e4", + 9, + "author", + 250, + r#"{"id":"e4"}"#, + 999, + ) + .unwrap(); + upsert_event_scope(conn, "pk", "wss://r", "e4", "channel_h", "chan2", 999).unwrap(); + } + + #[test] + fn test_read_archived_events_returns_newest_first() { + let conn = in_memory(); + seed_events(&conn); + let rows = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "chan1", + None, + None, + None, + 10, + ) + .unwrap(); + assert_eq!(rows.len(), 3); + // Newest first: e1 (300), e2 (200), e3 (100). + let ids: Vec<&str> = rows + .iter() + .map(|r| { + if r.contains("\"e1\"") { + "e1" + } else if r.contains("\"e2\"") { + "e2" + } else { + "e3" + } + }) + .collect(); + assert_eq!(ids, ["e1", "e2", "e3"]); + } + + #[test] + fn test_read_archived_events_keyset_cursor_excludes_at_boundary() { + let conn = in_memory(); + seed_events(&conn); + // Compound cursor at e1 (created_at=300, id="e1"): excludes e1 itself, + // returns e2 and e3. + let rows = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "chan1", + None, + Some(300), + Some("e1"), + 10, + ) + .unwrap(); + assert_eq!(rows.len(), 2); + assert!(rows.iter().all(|r| !r.contains("\"e1\""))); + } + + #[test] + fn test_read_archived_events_keyset_cursor_advances_correctly() { + let conn = in_memory(); + seed_events(&conn); + // Page 1: before=None/None, limit=2 → e1, e2. + let page1 = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "chan1", + None, + None, + None, + 2, + ) + .unwrap(); + assert_eq!(page1.len(), 2); + // Page 2: compound cursor at e2 (created_at=200, id="e2") → e3 only. + let page2 = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "chan1", + None, + Some(200), + Some("e2"), + 2, + ) + .unwrap(); + assert_eq!(page2.len(), 1); + assert!(page2[0].contains("\"e3\"")); + // No overlap between pages. + assert!(page1.iter().all(|r| !r.contains("\"e3\""))); + } + + #[test] + fn test_read_archived_events_kind_filter() { + let conn = in_memory(); + seed_events(&conn); + // Only kind 9 (e1 and e2); e3 is kind 42. + let rows = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "chan1", + Some(&[9]), + None, + None, + 10, + ) + .unwrap(); + assert_eq!(rows.len(), 2); + assert!(rows.iter().all(|r| !r.contains("\"e3\""))); + } + + #[test] + fn test_read_archived_events_scope_isolation() { + let conn = in_memory(); + seed_events(&conn); + // chan2 has only e4; chan1 results must not include e4. + let chan1 = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "chan1", + None, + None, + None, + 10, + ) + .unwrap(); + assert!(chan1.iter().all(|r| !r.contains("\"e4\""))); + + let chan2 = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "chan2", + None, + None, + None, + 10, + ) + .unwrap(); + assert_eq!(chan2.len(), 1); + assert!(chan2[0].contains("\"e4\"")); + } + + #[test] + fn test_read_archived_events_identity_isolation() { + let conn = in_memory(); + seed_events(&conn); + // Different identity — must see no rows. + let rows = read_archived_events( + &conn, + "pk2", + "wss://r", + "channel_h", + "chan1", + None, + None, + None, + 10, + ) + .unwrap(); + assert!(rows.is_empty()); + } + + #[test] + fn test_read_archived_events_relay_isolation() { + let conn = in_memory(); + seed_events(&conn); + // Different relay — must see no rows. + let rows = read_archived_events( + &conn, + "pk", + "wss://other", + "channel_h", + "chan1", + None, + None, + None, + 10, + ) + .unwrap(); + assert!(rows.is_empty()); + } + + #[test] + fn test_read_archived_events_empty_result() { + let conn = in_memory(); + let rows = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "nope", + None, + None, + None, + 10, + ) + .unwrap(); + assert!(rows.is_empty()); + } + + #[test] + fn test_read_archived_events_limit_respected() { + let conn = in_memory(); + seed_events(&conn); + let rows = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "chan1", + None, + None, + None, + 1, + ) + .unwrap(); + assert_eq!(rows.len(), 1); + // Must be the newest (e1, created_at=300). + assert!(rows[0].contains("\"e1\"")); + } + + #[test] + fn test_read_archived_events_no_duplicates_across_pages() { + let conn = in_memory(); + seed_events(&conn); + let page1 = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "chan1", + None, + None, + None, + 2, + ) + .unwrap(); + // Compound cursor at e2 (the oldest in page1: created_at=200, id="e2"). + let page2 = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "chan1", + None, + Some(200), + Some("e2"), + 2, + ) + .unwrap(); + // All event ids across both pages are unique. + let all: Vec<_> = page1.iter().chain(page2.iter()).collect(); + assert_eq!(all.len(), 3); // 2 + 1 = 3 total, no duplication. + } + + /// Regression for the scalar-cursor same-second skip defect (Thufir IMPORTANT). + /// + /// The writer stores `created_at` in whole seconds, so two events can share + /// the same timestamp. The sort order is `(created_at DESC, id DESC)`, so + /// a page split exactly at a same-second boundary leaves one sibling on each + /// side. With only `created_at < before` the second-page sibling would be + /// permanently excluded. The compound `(created_at < ?) OR (created_at = ? + /// AND id < ?)` predicate mirrors the sort key exactly and avoids the skip. + #[test] + fn test_read_archived_events_same_second_cursor_no_skip() { + let conn = in_memory(); + // Two events share created_at=1000. Sort order: "z" (id "z") > "a" (id "a"), + // so ORDER BY created_at DESC, id DESC yields: ("z", 1000) first, ("a", 1000) second. + // A third event has created_at=500. + for (id, kind, created_at, raw) in &[ + ("z", 9i64, 1000i64, r#"{"id":"z","created_at":1000}"#), + ("a", 9i64, 1000i64, r#"{"id":"a","created_at":1000}"#), + ("old", 9i64, 500i64, r#"{"id":"old","created_at":500}"#), + ] { + upsert_archived_event( + &conn, + "pk", + "wss://r", + id, + *kind, + "author", + *created_at, + raw, + 999, + ) + .unwrap(); + upsert_event_scope(&conn, "pk", "wss://r", id, "channel_h", "same_sec", 999).unwrap(); + } + + // Page 1: limit=1 → should return ("z", 1000) only (newest by compound sort). + let page1 = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "same_sec", + None, + None, + None, + 1, + ) + .unwrap(); + assert_eq!(page1.len(), 1); + assert!(page1[0].contains("\"z\""), "page1 must be the 'z' row"); + + // Page 2: compound cursor at ("z", 1000). + // With a scalar cursor (created_at < 1000), row "a" would be SKIPPED. + // With the compound cursor, "a" must appear on page 2. + let page2 = read_archived_events( + &conn, + "pk", + "wss://r", + "channel_h", + "same_sec", + None, + Some(1000), + Some("z"), + 2, + ) + .unwrap(); + // Must contain "a" (same-second sibling) and "old" (strictly older). + assert_eq!(page2.len(), 2, "page2 must return both remaining rows"); + assert!( + page2.iter().any(|r| r.contains("\"a\"")), + "same-second sibling 'a' must not be skipped" + ); + assert!( + page2.iter().any(|r| r.contains("\"old\"")), + "'old' row must appear on page2" + ); + // No overlap with page1. + assert!(page2.iter().all(|r| !r.contains("\"z\""))); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 93424cd0f..a5519ecf2 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ mod app_state; +mod archive; mod commands; mod deep_link; mod events; @@ -620,6 +621,11 @@ pub fn run() { get_agent_memory, relay_reconnect_hook, relay_reconnect_hook_configured, + archive::archive_events, + archive::create_save_subscription, + archive::list_save_subscriptions, + archive::delete_save_subscription, + archive::read_archived_events, ]) .build(tauri::generate_context!()) .expect("error while building tauri application"); diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index f3cef21a2..44a49dc0a 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -49,6 +49,7 @@ import { useUserStatusSubscription, } from "@/features/user-status/hooks"; import { useWorkspaceEmojiLiveUpdates } from "@/features/custom-emoji/hooks"; +import { useArchiveSync } from "@/features/local-archive/archiveSyncManager"; import { useProfileQuery } from "@/features/profile/hooks"; import { DEFAULT_SETTINGS_SECTION, @@ -146,6 +147,7 @@ export function AppShell() { ); usePersonaSync(identityQuery.data?.pubkey); useAgentsDataRefresh(); + useArchiveSync(); const profileQuery = useProfileQuery(); const deferredPubkey = startupReady ? identityQuery.data?.pubkey : undefined; useRelayAutoHeal(); diff --git a/desktop/src/features/agents/ingestArchivedObserverEvents.test.mjs b/desktop/src/features/agents/ingestArchivedObserverEvents.test.mjs new file mode 100644 index 000000000..49add6c6a --- /dev/null +++ b/desktop/src/features/agents/ingestArchivedObserverEvents.test.mjs @@ -0,0 +1,284 @@ +/** + * Tests for ingestArchivedObserverEvents — the read-back ingest seam that loads + * archived observer frames from the local SQLite archive into the observer store. + * + * These tests use node:test's synchronous-friendly import pattern combined with + * test-only exports (_testRegisterKnownAgents, _decryptFn injection, and the + * existing injectObserverEventsForE2E) to exercise behavior without requiring + * a Tauri runtime or React context. + */ + +import assert from "node:assert/strict"; +import { beforeEach, describe, it } from "node:test"; + +import { + ingestArchivedObserverEvents, + injectObserverEventsForE2E, + getAgentObserverSnapshot, + resetAgentObserverStore, + _testRegisterKnownAgents, +} from "@/features/agents/observerRelayStore.ts"; + +// ── Constants ───────────────────────────────────────────────────────────────── + +const AGENT_PUBKEY = "a".repeat(64); +const OTHER_PUBKEY = "b".repeat(64); +const SUB_ID = "test-sub-1"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makeRawEvent(overrides = {}) { + return { + id: "e".repeat(64), + pubkey: AGENT_PUBKEY, + created_at: 1000, + kind: 24200, + tags: [ + ["p", OTHER_PUBKEY], + ["agent", AGENT_PUBKEY], + ["frame", "telemetry"], + ], + content: "encrypted", + sig: "s".repeat(128), + ...overrides, + }; +} + +function makeObserverEvent(overrides = {}) { + return { + seq: 1, + timestamp: "2026-01-01T00:00:01.000Z", + kind: "acp_write", + agentIndex: 0, + channelId: "chan-1", + sessionId: "sess-1", + turnId: "turn-1", + payload: {}, + ...overrides, + }; +} + +// Decrypt fn that resolves to a known observer event. +function makeDecrypt(returnEvent) { + return () => Promise.resolve(returnEvent); +} + +// Decrypt fn that always rejects. +function makeDecryptFail() { + return () => Promise.reject(new Error("decryption failed")); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("ingestArchivedObserverEvents", () => { + beforeEach(() => { + resetAgentObserverStore(); + }); + + it("test_unknown_agent_drops_event_before_decrypt", async () => { + // knownAgentPubkeys is empty after reset. + // Even with a successful decrypt fn, the event must be dropped. + let decryptCalled = false; + const decryptFn = () => { + decryptCalled = true; + return Promise.resolve(makeObserverEvent()); + }; + await ingestArchivedObserverEvents([makeRawEvent()], decryptFn); + assert.equal( + decryptCalled, + false, + "decrypt must not be called for unknown agent", + ); + const snap = getAgentObserverSnapshot(AGENT_PUBKEY, true); + assert.equal(snap.events.length, 0); + }); + + it("test_mismatched_sender_drops_event_before_decrypt", async () => { + _testRegisterKnownAgents(SUB_ID, [AGENT_PUBKEY]); + let decryptCalled = false; + const decryptFn = () => { + decryptCalled = true; + return Promise.resolve(makeObserverEvent()); + }; + // event.pubkey differs from agent tag value + const badEvent = makeRawEvent({ pubkey: OTHER_PUBKEY }); + await ingestArchivedObserverEvents([badEvent], decryptFn); + assert.equal( + decryptCalled, + false, + "decrypt must not be called for mismatched sender", + ); + const snap = getAgentObserverSnapshot(AGENT_PUBKEY, true); + assert.equal(snap.events.length, 0); + }); + + it("test_non_telemetry_frame_tag_drops_event", async () => { + _testRegisterKnownAgents(SUB_ID, [AGENT_PUBKEY]); + let decryptCalled = false; + const decryptFn = () => { + decryptCalled = true; + return Promise.resolve(makeObserverEvent()); + }; + const nonTelemetryEvent = makeRawEvent({ + tags: [ + ["p", OTHER_PUBKEY], + ["agent", AGENT_PUBKEY], + ["frame", "control"], // not "telemetry" + ], + }); + await ingestArchivedObserverEvents([nonTelemetryEvent], decryptFn); + assert.equal(decryptCalled, false, "non-telemetry frame must be dropped"); + const snap = getAgentObserverSnapshot(AGENT_PUBKEY, true); + assert.equal(snap.events.length, 0); + }); + + it("test_decrypt_failure_silently_dropped", async () => { + _testRegisterKnownAgents(SUB_ID, [AGENT_PUBKEY]); + // Good event that passes all guards but fails decrypt. + await ingestArchivedObserverEvents([makeRawEvent()], makeDecryptFail()); + const snap = getAgentObserverSnapshot(AGENT_PUBKEY, true); + // Error is silently dropped — no crash, no event in store. + assert.equal(snap.events.length, 0); + }); + + it("test_successful_ingest_adds_event_to_store", async () => { + _testRegisterKnownAgents(SUB_ID, [AGENT_PUBKEY]); + const obs = makeObserverEvent({ seq: 1 }); + await ingestArchivedObserverEvents([makeRawEvent()], makeDecrypt(obs)); + const snap = getAgentObserverSnapshot(AGENT_PUBKEY, true); + assert.equal(snap.events.length, 1); + assert.equal(snap.events[0].seq, 1); + }); + + it("test_dedup_does_not_add_live_present_event", async () => { + // Pre-seed a live event via E2E injection. + const liveObs = makeObserverEvent({ + seq: 5, + timestamp: "2026-01-01T00:00:05.000Z", + }); + injectObserverEventsForE2E(AGENT_PUBKEY, [liveObs]); + + _testRegisterKnownAgents(SUB_ID, [AGENT_PUBKEY]); + // Try to ingest an archived event with the SAME (seq, timestamp) — must be deduped. + const archivedObs = makeObserverEvent({ + seq: 5, + timestamp: "2026-01-01T00:00:05.000Z", + }); + await ingestArchivedObserverEvents( + [makeRawEvent()], + makeDecrypt(archivedObs), + ); + + const snap = getAgentObserverSnapshot(AGENT_PUBKEY, true); + assert.equal( + snap.events.length, + 1, + "dedup: duplicate seq+timestamp must not add a second entry", + ); + }); + + it("test_older_archived_event_sorts_before_live", async () => { + // Pre-seed a newer live event. + const liveObs = makeObserverEvent({ + seq: 2, + timestamp: "2026-01-01T00:00:02.000Z", + }); + injectObserverEventsForE2E(AGENT_PUBKEY, [liveObs]); + + _testRegisterKnownAgents(SUB_ID, [AGENT_PUBKEY]); + // Ingest an older archived event. + const archivedObs = makeObserverEvent({ + seq: 1, + timestamp: "2026-01-01T00:00:01.000Z", + }); + await ingestArchivedObserverEvents( + [makeRawEvent()], + makeDecrypt(archivedObs), + ); + + const snap = getAgentObserverSnapshot(AGENT_PUBKEY, true); + assert.equal(snap.events.length, 2); + // Ascending time order: older first. + assert.equal( + snap.events[0].seq, + 1, + "older archived event must sort before newer live event", + ); + assert.equal(snap.events[1].seq, 2); + }); + + it("test_multiple_events_ingested_in_order", async () => { + _testRegisterKnownAgents(SUB_ID, [AGENT_PUBKEY]); + // Three events: seq 3, 1, 2 — must end up sorted 1, 2, 3. + const events = [ + makeObserverEvent({ seq: 3, timestamp: "2026-01-01T00:00:03.000Z" }), + makeObserverEvent({ seq: 1, timestamp: "2026-01-01T00:00:01.000Z" }), + makeObserverEvent({ seq: 2, timestamp: "2026-01-01T00:00:02.000Z" }), + ]; + let callIdx = 0; + const decryptFn = () => Promise.resolve(events[callIdx++]); + // All three raw events pass the guards (same pubkey/agent tag). + await ingestArchivedObserverEvents( + [makeRawEvent(), makeRawEvent(), makeRawEvent()], + decryptFn, + ); + const snap = getAgentObserverSnapshot(AGENT_PUBKEY, true); + assert.equal(snap.events.length, 3); + assert.deepEqual( + snap.events.map((e) => e.seq), + [1, 2, 3], + ); + }); +}); + +// ── Cursor advance test (pure logic, no store needed) ───────────────────────── + +describe("load-older cursor advance logic", () => { + it("test_cursor_advances_to_last_row_compound_key", () => { + // Mirrors the cursor-update logic in useLoadArchivedObserverEvents. + // Events arrive newest-first (as the store returns them). + // The cursor should be the LAST element — the oldest on this page — + // capturing both created_at and id to mirror the compound sort key + // so same-second siblings are never skipped at a page boundary. + const events = [ + { id: "e1", created_at: 1000 }, + { id: "e2", created_at: 900 }, + { id: "e3", created_at: 800 }, + { id: "e4", created_at: 500 }, + ]; + const oldestEvent = events[events.length - 1]; + const cursor = { createdAt: oldestEvent.created_at, id: oldestEvent.id }; + assert.deepEqual( + cursor, + { createdAt: 500, id: "e4" }, + "cursor must capture the last (oldest) row's created_at + id", + ); + }); + + it("test_short_page_signals_archive_exhausted", () => { + // A page with fewer events than the limit signals end-of-archive. + const PAGE_SIZE = 50; + const page = Array.from({ length: 30 }, (_, i) => ({ + created_at: 1000 - i, + })); + const exhausted = page.length < PAGE_SIZE; + assert.equal( + exhausted, + true, + "short page must signal archive is exhausted", + ); + }); + + it("test_full_page_signals_more_archive_available", () => { + const PAGE_SIZE = 50; + const page = Array.from({ length: 50 }, (_, i) => ({ + created_at: 1000 - i, + })); + const exhausted = page.length < PAGE_SIZE; + assert.equal( + exhausted, + false, + "full page must signal more archive may be available", + ); + }); +}); diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts index e8474ed5e..43920be93 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -424,6 +424,52 @@ export function useManagedAgentObserverBridge( }, [queryClient]); } +/** + * Ingest a batch of raw archived observer events from the local archive into + * the store. Applies the same security guards as the live relay path: + * + * - Event must have an `agent` tag pointing to a known/trusted pubkey + * (registered via `useManagedAgentObserverBridge`). + * - The event sender (`pubkey`) must match the `agent` tag value. + * - Event must decrypt successfully via `decryptObserverEvent`. + * + * Routes through `appendAgentEvent` so dedup on `(seq, timestamp)` and + * sort are reused — archived events that are already present (live-delivered) + * are silently skipped. Failed decryptions are silently dropped (same as + * live path error handling). + * + * Note: events for agents not currently registered in `knownAgentPubkeys` + * (e.g. an agent that is stopped but has archived history) are dropped. + * The caller should ensure the agent is registered before calling. + * + * `_decryptFn` is only used by tests to inject a mock decryption function. + * Production callers must always omit it. + */ +export async function ingestArchivedObserverEvents( + rawEvents: RelayEvent[], + _decryptFn: (event: RelayEvent) => Promise = decryptObserverEvent, +): Promise { + for (const event of rawEvents) { + const agentPubkey = observerTag(event, "agent"); + const frame = observerTag(event, "frame"); + if (!agentPubkey || frame !== "telemetry") { + continue; + } + if (!knownAgentPubkeys.has(normalizePubkey(agentPubkey))) { + continue; + } + if (normalizePubkey(event.pubkey) !== normalizePubkey(agentPubkey)) { + continue; + } + try { + const parsed = (await _decryptFn(event)) as ObserverEvent; + appendAgentEvent(agentPubkey, parsed); + } catch { + // Silently drop decrypt failures — same as live path error handling. + } + } +} + /** * E2E-only: inject synthetic observer events directly into the store, bypassing * the relay-security knownAgentPubkeys filter. Exercises the real @@ -473,3 +519,15 @@ export function resetAgentObserverStore() { notifyListeners(); void unsubscribe?.(); } + +/** + * Test-only: register a set of agent pubkeys as trusted for a given + * subscription id. Mirrors the effect of mounting `useManagedAgentObserverBridge` + * in a React tree. Only call from tests — never from production code. + */ +export function _testRegisterKnownAgents( + subscriptionId: string, + pubkeys: readonly string[], +): void { + registerKnownAgents(subscriptionId, pubkeys); +} diff --git a/desktop/src/features/agents/ui/useObserverEvents.ts b/desktop/src/features/agents/ui/useObserverEvents.ts index 6631b1677..73603a029 100644 --- a/desktop/src/features/agents/ui/useObserverEvents.ts +++ b/desktop/src/features/agents/ui/useObserverEvents.ts @@ -4,8 +4,14 @@ import { ensureRelayObserverSubscription, getAgentObserverSnapshot, getAgentTranscript, + ingestArchivedObserverEvents, subscribeAgentObserverStore, } from "@/features/agents/observerRelayStore"; +import { + listSaveSubscriptions, + readArchivedEvents, +} from "@/shared/api/tauriArchive"; +import { useIdentityQuery } from "@/shared/api/hooks"; import type { TranscriptItem } from "./agentSessionTypes"; // Stable subscribe reference shared by all useSyncExternalStore hooks. @@ -45,3 +51,108 @@ export function useAgentTranscript( return React.useSyncExternalStore(subscribeToStore, getSnapshot); } + +const ARCHIVED_EVENTS_PAGE_SIZE = 50; + +/** + * Load-older-on-scroll for archived observer frames. + * + * Checks whether an `owner_p` save subscription exists for the current + * identity. If one does, exposes `fetchOlderArchived` and `hasOlderArchived` + * for wiring into a sentinel-based scroll loader. + * + * Degrades cleanly when no subscription exists (returns `hasOlderArchived: + * false` without making any archive calls). + */ +export function useLoadArchivedObserverEvents(enabled: boolean) { + const identityQuery = useIdentityQuery(); + const identityPubkey = identityQuery.data?.pubkey ?? null; + + // Whether the current identity has an owner_p save subscription. + const [hasSubscription, setHasSubscription] = React.useState( + null, + ); + const [hasOlderArchived, setHasOlderArchived] = React.useState(true); + const isFetchingRef = React.useRef(false); + // Compound keyset cursor: tracks both `created_at` and `id` of the oldest + // event seen so far. Mirrors the SQL `ORDER BY created_at DESC, id DESC` so + // same-second siblings are never skipped at a page boundary. + const cursorRef = React.useRef<{ createdAt: number; id: string } | null>( + null, + ); + + // Check for an owner_p subscription once per identity. + React.useEffect(() => { + if (!enabled || !identityPubkey) { + return; + } + let cancelled = false; + listSaveSubscriptions() + .then((subs) => { + if (cancelled) { + return; + } + const hasSub = subs.some( + (s) => s.scopeType === "owner_p" && s.scopeValue === identityPubkey, + ); + setHasSubscription(hasSub); + if (!hasSub) { + setHasOlderArchived(false); + } + }) + .catch(() => { + if (!cancelled) { + setHasSubscription(false); + setHasOlderArchived(false); + } + }); + return () => { + cancelled = true; + }; + }, [enabled, identityPubkey]); + + const fetchOlderArchived = React.useCallback(async () => { + if ( + !enabled || + !identityPubkey || + !hasSubscription || + isFetchingRef.current || + !hasOlderArchived + ) { + return; + } + + isFetchingRef.current = true; + try { + const before = cursorRef.current ?? undefined; + const events = await readArchivedEvents("owner_p", identityPubkey, { + kinds: [24200], + before: before ?? null, + limit: ARCHIVED_EVENTS_PAGE_SIZE, + }); + + if (events.length > 0) { + // Cursor = the last row in newest-first order = the oldest event on + // this page. Capture both created_at and id to mirror the compound + // sort key so same-second siblings are not skipped on the next page. + const oldestEvent = events[events.length - 1]; + cursorRef.current = { + createdAt: oldestEvent.created_at, + id: oldestEvent.id, + }; + await ingestArchivedObserverEvents(events); + } + + // A short page means the archive is exhausted. + if (events.length < ARCHIVED_EVENTS_PAGE_SIZE) { + setHasOlderArchived(false); + } + } catch (error) { + console.error("[useLoadArchivedObserverEvents] fetch failed:", error); + } finally { + isFetchingRef.current = false; + } + }, [enabled, identityPubkey, hasSubscription, hasOlderArchived]); + + return { fetchOlderArchived, hasOlderArchived }; +} diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index 4a2067143..36e2b602e 100644 --- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -41,6 +41,8 @@ import { setTranscriptAnimationEnabled, useTranscriptAnimationEnabled, } from "@/features/agents/ui/transcriptAnimationPreference"; +import { useLoadArchivedObserverEvents } from "@/features/agents/ui/useObserverEvents"; +import { useLoadOlderOnScroll } from "@/features/messages/ui/useLoadOlderOnScroll"; import type { ChannelAgentSessionAgent } from "./useChannelAgentSessions"; type AgentSessionThreadPanelProps = { @@ -78,6 +80,8 @@ export function AgentSessionThreadPanel({ useEscapeKey(onClose, isOverlay || isSinglePanelView); const { ref: scrollRef, onScroll } = useStickToBottom(); + const topSentinelRef = React.useRef(null); + const sessionChannelId = channelId ?? channel?.id ?? null; const now = useNow(1000); const { events } = useObserverEvents(isLive, agent.pubkey); @@ -103,6 +107,17 @@ export function AgentSessionThreadPanel({ latestActivityAt === null ? undefined : `Last updated ${new Date(latestActivityAt).toLocaleString()}`; + + const { fetchOlderArchived, hasOlderArchived } = + useLoadArchivedObserverEvents(isLive); + + useLoadOlderOnScroll({ + fetchOlder: fetchOlderArchived, + hasOlderMessages: hasOlderArchived, + isLoading: false, + scrollContainerRef: scrollRef, + sentinelRef: topSentinelRef, + }); const rawFeedScopeKey = `${agent.pubkey}:${sessionChannelId ?? "all"}`; const [rawFeedState, setRawFeedState] = React.useState(() => ({ scopeKey: rawFeedScopeKey, @@ -306,6 +321,7 @@ export function AgentSessionThreadPanel({ className="overflow-y-auto px-3 pb-4" panelPadding > +
{ filter, callback, unsubbed } + + return { + subs, + subscribeLive(filter, callback) { + const key = JSON.stringify(filter); + subs.set(key, { filter, callback, unsubbed: false }); + return Promise.resolve(async () => { + const entry = subs.get(key); + if (entry) entry.unsubbed = true; + }); + }, + push(filter, event) { + const key = JSON.stringify(filter); + const entry = subs.get(key); + if (!entry) throw new Error(`no subscription for filter ${key}`); + entry.callback(event); + }, + activeCount() { + return [...subs.values()].filter((e) => !e.unsubbed).length; + }, + }; +} + +/** + * Fake tauriArchive module — captures invocations for assertion. + * createSaveSubscription is an upsert (matching store.rs ON CONFLICT behaviour). + */ +function makeFakeArchive() { + let subs = []; + const archiveCalls = []; + const listeners = new Set(); + + return { + async listSaveSubscriptions() { + return subs; + }, + async createSaveSubscription(scopeType, scopeValue, kinds) { + // Upsert: update kinds if scope already exists, otherwise append. + const existing = subs.findIndex( + (s) => s.scopeType === scopeType && s.scopeValue === scopeValue, + ); + if (existing >= 0) { + subs = subs.map((s, i) => (i === existing ? { ...s, kinds } : s)); + } else { + subs = [ + ...subs, + { + scopeType, + scopeValue, + kinds, + identityPubkey: "pk", + relayUrl: "wss://r", + createdAt: 0, + }, + ]; + } + for (const l of listeners) l(); + }, + async deleteSaveSubscription(scopeType, scopeValue) { + const before = subs.length; + subs = subs.filter( + (s) => !(s.scopeType === scopeType && s.scopeValue === scopeValue), + ); + if (subs.length < before) { + for (const l of listeners) l(); + return true; + } + return false; + }, + async archiveEvents(candidates) { + archiveCalls.push(candidates); + return { persisted: candidates.length, dropped: 0 }; + }, + onSubscriptionChange(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + archiveCalls, + setSubs(s) { + subs = s; + }, + }; +} + +/** Helper: wait for microtasks/promises to settle */ +function tick() { + return new Promise((r) => setTimeout(r, 0)); +} + +/** Build a manager wired to fakes. */ +function makeManager(relay, archive, extra = {}) { + return new ArchiveSyncManager({ + relayClient: relay, + listSaveSubscriptions: () => archive.listSaveSubscriptions(), + archiveEvents: (c) => archive.archiveEvents(c), + onSubscriptionChange: (l) => archive.onSubscriptionChange(l), + ...extra, + }); +} + +// ── kinds decoding ──────────────────────────────────────────────────────────── + +/** + * Inline the decode logic (matches tauriArchive.ts:decodeRawSubscription) + * so we can test it without the full Tauri env. + */ +function decodeKinds(kindsStr) { + try { + const parsed = JSON.parse(kindsStr); + if ( + Array.isArray(parsed) && + parsed.every((k) => typeof k === "number" && Number.isFinite(k)) + ) { + return parsed; + } + return null; // malformed + } catch { + return null; + } +} + +test("decodeKinds_valid_array_returns_numbers", () => { + assert.deepEqual( + decodeKinds("[9,40002,45001,45003]"), + [9, 40002, 45001, 45003], + ); +}); + +test("decodeKinds_empty_array_is_valid", () => { + assert.deepEqual(decodeKinds("[]"), []); +}); + +test("decodeKinds_non_array_returns_null", () => { + assert.equal(decodeKinds('"string"'), null); + assert.equal(decodeKinds("42"), null); + assert.equal(decodeKinds("null"), null); +}); + +test("decodeKinds_array_with_non_number_returns_null", () => { + assert.equal(decodeKinds('["9","40002"]'), null); + assert.equal(decodeKinds("[9, null, 40002]"), null); +}); + +test("decodeKinds_malformed_json_returns_null", () => { + assert.equal(decodeKinds("not-json"), null); + assert.equal(decodeKinds(""), null); +}); + +// ── Kind preset arrays (derived from constants, not literals) ───────────────── + +/** + * Inline the preset arrays matching LocalArchiveSettingsCard.tsx. + * Values verified against desktop/src/shared/constants/kinds.ts. + */ +const KIND_STREAM_MESSAGE = 9; +const KIND_STREAM_MESSAGE_V2 = 40002; +const KIND_STREAM_MESSAGE_DIFF = 40008; +const KIND_FORUM_POST = 45001; +const KIND_FORUM_COMMENT = 45003; +const KIND_DELETION = 5; +const KIND_REACTION = 7; +const KIND_NIP29_DELETE_EVENT = 9005; +const KIND_STREAM_MESSAGE_EDIT = 40003; +const KIND_SYSTEM_MESSAGE = 40099; +const KIND_HUDDLE_STARTED = 48100; +const KIND_HUDDLE_PARTICIPANT_JOINED = 48101; +const KIND_HUDDLE_PARTICIPANT_LEFT = 48102; +const KIND_HUDDLE_ENDED = 48103; + +// Presets — keep in sync with LocalArchiveSettingsCard.tsx +const PRESET_MESSAGES = [ + KIND_STREAM_MESSAGE, // 9 + KIND_STREAM_MESSAGE_V2, // 40002 + KIND_STREAM_MESSAGE_DIFF, // 40008 + KIND_FORUM_POST, // 45001 + KIND_FORUM_COMMENT, // 45003 +]; + +const PRESET_AUX = [ + KIND_DELETION, // 5 + KIND_REACTION, // 7 + KIND_NIP29_DELETE_EVENT, // 9005 + KIND_STREAM_MESSAGE_EDIT, // 40003 +]; + +const PRESET_ALL = [ + KIND_DELETION, // 5 + KIND_REACTION, // 7 + KIND_NIP29_DELETE_EVENT, // 9005 + KIND_STREAM_MESSAGE, // 9 + 40001, // legacy + KIND_STREAM_MESSAGE_V2, // 40002 + KIND_FORUM_POST, // 45001 (from CHANNEL_MESSAGE_EVENT_KINDS spread) + KIND_FORUM_COMMENT, // 45003 (from CHANNEL_MESSAGE_EVENT_KINDS spread) + KIND_STREAM_MESSAGE_EDIT, // 40003 + KIND_STREAM_MESSAGE_DIFF, // 40008 + KIND_SYSTEM_MESSAGE, // 40099 + KIND_HUDDLE_STARTED, // 48100 + KIND_HUDDLE_PARTICIPANT_JOINED, // 48101 + KIND_HUDDLE_PARTICIPANT_LEFT, // 48102 + KIND_HUDDLE_ENDED, // 48103 +]; + +test("preset_messages_contains_correct_kinds", () => { + // Must include all four CHANNEL_MESSAGE_EVENT_KINDS + diff rows + assert.ok( + PRESET_MESSAGES.includes(9), + "must include kind 9 (stream message)", + ); + assert.ok( + PRESET_MESSAGES.includes(40002), + "must include kind 40002 (stream message v2)", + ); + assert.ok( + PRESET_MESSAGES.includes(45001), + "must include kind 45001 (forum post)", + ); + assert.ok( + PRESET_MESSAGES.includes(45003), + "must include kind 45003 (forum comment)", + ); + assert.ok( + PRESET_MESSAGES.includes(40008), + "must include kind 40008 (diff rows — visible content)", + ); + // Must NOT misclassify edits as messages + assert.ok( + !PRESET_MESSAGES.includes(40003), + "must NOT include kind 40003 (edits — aux, not messages)", + ); +}); + +test("preset_aux_contains_correct_kinds", () => { + assert.ok(PRESET_AUX.includes(5), "must include kind 5 (NIP-09 deletion)"); + assert.ok(PRESET_AUX.includes(7), "must include kind 7 (reaction)"); + assert.ok( + PRESET_AUX.includes(9005), + "must include kind 9005 (Buzz-native deletion)", + ); + assert.ok( + PRESET_AUX.includes(40003), + "must include kind 40003 (stream message edit)", + ); + // Edits are aux, not messages — must not overlap with messages preset (except shared reaction) + assert.ok(!PRESET_AUX.includes(9), "must NOT include kind 9 (message)"); + assert.ok( + !PRESET_AUX.includes(40002), + "must NOT include kind 40002 (message v2)", + ); +}); + +test("preset_all_is_superset_of_messages_and_aux", () => { + for (const k of PRESET_MESSAGES) { + assert.ok( + PRESET_ALL.includes(k), + `PRESET_ALL must include kind ${k} from PRESET_MESSAGES`, + ); + } + for (const k of PRESET_AUX) { + assert.ok( + PRESET_ALL.includes(k), + `PRESET_ALL must include kind ${k} from PRESET_AUX`, + ); + } +}); + +test("preset_messages_exact_saved_kind_array", () => { + assert.deepEqual( + [...PRESET_MESSAGES].sort((a, b) => a - b), + [9, 40002, 40008, 45001, 45003], + ); +}); + +test("preset_aux_exact_saved_kind_array", () => { + assert.deepEqual( + [...PRESET_AUX].sort((a, b) => a - b), + [5, 7, 9005, 40003], + ); +}); + +// ── Subscription-change notifier ───────────────────────────────────────────── + +/** + * Test the notifier contract inline — mirrors what tauriArchive.ts exports. + */ +function makeNotifier() { + const listeners = new Set(); + return { + onSubscriptionChange(l) { + listeners.add(l); + return () => listeners.delete(l); + }, + notify() { + for (const l of listeners) l(); + }, + }; +} + +test("subscription_change_notifier_fires_registered_listener", () => { + const n = makeNotifier(); + let fired = 0; + n.onSubscriptionChange(() => { + fired++; + }); + n.notify(); + assert.equal(fired, 1); +}); + +test("subscription_change_notifier_unregister_stops_firing", () => { + const n = makeNotifier(); + let fired = 0; + const off = n.onSubscriptionChange(() => { + fired++; + }); + off(); + n.notify(); + assert.equal(fired, 0); +}); + +test("subscription_change_notifier_fires_multiple_listeners", () => { + const n = makeNotifier(); + let a = 0; + let b = 0; + n.onSubscriptionChange(() => { + a++; + }); + n.onSubscriptionChange(() => { + b++; + }); + n.notify(); + assert.equal(a, 1); + assert.equal(b, 1); +}); + +// ── ArchiveSyncManager (real class, injected fakes) ─────────────────────────── + +test("manager_opens_one_sub_per_saved_subscription", async () => { + const relay = makeFakeRelayClient(); + const archive = makeFakeArchive(); + archive.setSubs([ + { + scopeType: "channel_h", + scopeValue: "chan-1", + kinds: [9], + identityPubkey: "pk", + relayUrl: "wss://r", + createdAt: 0, + }, + ]); + const mgr = makeManager(relay, archive); + await mgr.start(); + await tick(); + assert.equal(relay.activeCount(), 1); + mgr.destroy(); +}); + +test("manager_builds_correct_filter_for_channel_h", async () => { + const relay = makeFakeRelayClient(); + const archive = makeFakeArchive(); + archive.setSubs([ + { + scopeType: "channel_h", + scopeValue: "chan-abc", + kinds: [9, 40002], + identityPubkey: "pk", + relayUrl: "wss://r", + createdAt: 0, + }, + ]); + const mgr = makeManager(relay, archive); + await mgr.start(); + await tick(); + const keys = [...relay.subs.keys()]; + assert.equal(keys.length, 1); + const filter = JSON.parse(keys[0]); + assert.deepEqual(filter["#h"], ["chan-abc"]); + assert.deepEqual(filter.kinds, [9, 40002]); + assert.equal(filter.limit, 0); + mgr.destroy(); +}); + +test("manager_builds_correct_filter_for_owner_p", async () => { + const relay = makeFakeRelayClient(); + const archive = makeFakeArchive(); + archive.setSubs([ + { + scopeType: "owner_p", + scopeValue: "pubkey123", + kinds: [24200], + identityPubkey: "pk", + relayUrl: "wss://r", + createdAt: 0, + }, + ]); + const mgr = makeManager(relay, archive); + await mgr.start(); + await tick(); + const keys = [...relay.subs.keys()]; + const filter = JSON.parse(keys[0]); + assert.deepEqual(filter["#p"], ["pubkey123"]); + mgr.destroy(); +}); + +test("manager_forwards_events_to_archive_events_on_flush", async () => { + const relay = makeFakeRelayClient(); + const archive = makeFakeArchive(); + archive.setSubs([ + { + scopeType: "channel_h", + scopeValue: "chan-1", + kinds: [9], + identityPubkey: "pk", + relayUrl: "wss://r", + createdAt: 0, + }, + ]); + // Use flushBatchSize=1 so flush fires immediately on first event + const mgr = makeManager(relay, archive, { flushBatchSize: 1 }); + await mgr.start(); + await tick(); + + const filter = JSON.parse([...relay.subs.keys()][0]); + relay.push(filter, { + id: "ev1", + kind: 9, + pubkey: "pk", + created_at: 1, + content: "hi", + tags: [], + }); + await tick(); + + assert.equal(archive.archiveCalls.length, 1); + assert.equal(archive.archiveCalls[0].length, 1); + assert.equal(archive.archiveCalls[0][0].matchedScope.scopeType, "channel_h"); + assert.equal(archive.archiveCalls[0][0].matchedScope.scopeValue, "chan-1"); + mgr.destroy(); +}); + +test("manager_resubscribes_when_subscription_added", async () => { + const relay = makeFakeRelayClient(); + const archive = makeFakeArchive(); + archive.setSubs([]); + const mgr = makeManager(relay, archive); + await mgr.start(); + await tick(); + assert.equal(relay.activeCount(), 0); + + // Simulate create_save_subscription — upserts subs then fires notifier + await archive.createSaveSubscription("channel_h", "chan-new", [9]); + await tick(); + + assert.equal(relay.activeCount(), 1); + mgr.destroy(); +}); + +test("manager_removes_sub_when_subscription_deleted", async () => { + const relay = makeFakeRelayClient(); + const archive = makeFakeArchive(); + archive.setSubs([ + { + scopeType: "channel_h", + scopeValue: "chan-1", + kinds: [9], + identityPubkey: "pk", + relayUrl: "wss://r", + createdAt: 0, + }, + ]); + const mgr = makeManager(relay, archive); + await mgr.start(); + await tick(); + assert.equal(relay.activeCount(), 1); + + await archive.deleteSaveSubscription("channel_h", "chan-1"); + await tick(); + + // The sub should be unsubbed now + const entry = [...relay.subs.values()][0]; + assert.equal(entry.unsubbed, true); + mgr.destroy(); +}); + +test("manager_resubscribes_with_new_filter_when_kinds_upserted", async () => { + const relay = makeFakeRelayClient(); + const archive = makeFakeArchive(); + archive.setSubs([ + { + scopeType: "channel_h", + scopeValue: "chan-1", + kinds: [9], + identityPubkey: "pk", + relayUrl: "wss://r", + createdAt: 0, + }, + ]); + const mgr = makeManager(relay, archive); + await mgr.start(); + await tick(); + + // Confirm initial subscription is active with kinds=[9] + assert.equal(relay.activeCount(), 1); + const oldKeys = [...relay.subs.keys()]; + assert.equal(oldKeys.length, 1); + assert.deepEqual(JSON.parse(oldKeys[0]).kinds, [9]); + + // Upsert same scope with different kinds — fake archive replaces kinds + fires notifier + await archive.createSaveSubscription("channel_h", "chan-1", [9, 40002]); + await tick(); + + // Old subscription must be unsubbed + const oldEntry = relay.subs.get(oldKeys[0]); + assert.equal( + oldEntry.unsubbed, + true, + "old kinds=[9] filter must be torn down", + ); + + // New subscription with kinds=[9,40002] must be active + assert.equal(relay.activeCount(), 1, "exactly one active sub after upsert"); + const newKeys = [...relay.subs.keys()].filter((k) => k !== oldKeys[0]); + assert.equal(newKeys.length, 1); + assert.deepEqual( + JSON.parse(newKeys[0]).kinds, + [9, 40002], + "new filter must use updated kinds", + ); + + mgr.destroy(); +}); + +test("manager_retries_subscription_after_subscribeLive_failure", async () => { + // First subscribeLive call rejects; key must NOT be in active so a second + // resubscribeAll (triggered by any config change) retries it. + let callCount = 0; + const relay = { + subs: new Map(), + subscribeLive(filter, callback) { + callCount++; + if (callCount === 1) { + return Promise.reject(new Error("simulated relay failure")); + } + const key = JSON.stringify(filter); + relay.subs.set(key, { filter, callback, unsubbed: false }); + return Promise.resolve(async () => { + const entry = relay.subs.get(key); + if (entry) entry.unsubbed = true; + }); + }, + activeCount() { + return [...relay.subs.values()].filter((e) => !e.unsubbed).length; + }, + }; + const archive = makeFakeArchive(); + archive.setSubs([ + { + scopeType: "channel_h", + scopeValue: "chan-retry", + kinds: [9], + identityPubkey: "pk", + relayUrl: "wss://r", + createdAt: 0, + }, + ]); + const mgr = makeManager(relay, archive); + await mgr.start(); // first subscribeLive rejects — key must be absent + assert.equal(relay.activeCount(), 0, "no active sub after failure"); + + // Trigger resubscribeAll via a config change notification — second call succeeds. + await archive.createSaveSubscription("channel_h", "chan-retry", [9]); + await tick(); + assert.equal(relay.activeCount(), 1, "sub created on retry after failure"); + assert.equal(callCount, 2, "subscribeLive called exactly twice"); + mgr.destroy(); +}); + +test("manager_no_duplicate_sub_when_two_reloads_overlap", async () => { + // Under single-flight serialization: when a second resubscribeAll request + // arrives while the first subscribeLive is still pending, it sets + // reloadPending and returns immediately (no concurrent body runs). The + // first subscribe resolves and activates normally, then the coalescing loop + // runs the second pass — but by then the key is already active, so no + // duplicate subscription is opened. + let resolveFirst; + let callCount = 0; + const disposedCount = { value: 0 }; + + const relay = { + subs: new Map(), + subscribeLive(filter, _callback) { + callCount++; + const key = JSON.stringify(filter); + const disposeHandle = async () => { + disposedCount.value++; + const entry = relay.subs.get(key); + if (entry) entry.unsubbed = true; + }; + if (callCount === 1) { + // Slow first call — resolver exposed so the test can unblock it later. + return new Promise((resolve) => { + resolveFirst = () => { + relay.subs.set(key, { filter, unsubbed: false }); + resolve(disposeHandle); + }; + }); + } + // A second call should not be reached under single-flight. + relay.subs.set(key, { filter, unsubbed: false }); + return Promise.resolve(disposeHandle); + }, + activeCount() { + return [...relay.subs.values()].filter((e) => !e.unsubbed).length; + }, + }; + + const archive = makeFakeArchive(); + archive.setSubs([ + { + scopeType: "channel_h", + scopeValue: "chan-dup", + kinds: [9], + identityPubkey: "pk", + relayUrl: "wss://r", + createdAt: 0, + }, + ]); + const mgr = makeManager(relay, archive); + + // Fire first resubscribeAll (goes async, subscribeLive pending). + const first = mgr.start(); + await tick(); // let it reach the subscribeLive await + + // Fire second resubscribeAll before the first subscribe resolves. + // Under single-flight this sets reloadPending and returns synchronously. + // biome-ignore lint/complexity/useLiteralKeys: intentional private access in test + mgr["resubscribeAll"](); + await tick(); + + // First subscribe still pending — resolve it now. + resolveFirst(); + await first; + await tick(); + + // subscribeLive was called exactly once (single-flight blocked the second). + assert.equal(callCount, 1, "subscribeLive called only once"); + // Exactly one active subscription. + assert.equal(relay.activeCount(), 1, "exactly one active sub"); + // No extra dispose leaked. + assert.equal(disposedCount.value, 0, "no dispose called for a healthy sub"); + + mgr.destroy(); +}); + +test("manager_disposes_stale_sub_when_deleted_before_resolve", async () => { + // A subscription is deleted while its subscribeLive call is in flight. + // Under single-flight: the delete sets reloadPending; the first pass + // finishes (activates K), then the coalescing loop runs a second pass which + // lists [] and tears K down via the normal teardown loop. Net result: dispose + // is called and K is absent from active. + let resolveSubscribe; + let disposeCalled = false; + + const relay = { + subscribeLive(_filter, _callback) { + return new Promise((resolve) => { + resolveSubscribe = () => + resolve(async () => { + disposeCalled = true; + }); + }); + }, + }; + + const archive = makeFakeArchive(); + archive.setSubs([ + { + scopeType: "channel_h", + scopeValue: "chan-stale", + kinds: [9], + identityPubkey: "pk", + relayUrl: "wss://r", + createdAt: 0, + }, + ]); + const mgr = makeManager(relay, archive); + + // Start: subscribeLive is pending (first doResubscribe pass). + const started = mgr.start(); + await tick(); // reaches the subscribeLive await + + // Delete the subscription before the subscribe resolves; sets reloadPending + // so the coalescing loop runs a second pass after the first finishes. + await archive.deleteSaveSubscription("channel_h", "chan-stale"); + await tick(); + + // Now resolve the first subscribe — first pass activates K, then the second + // pass (reloadPending) runs, lists [], and tears K down. + resolveSubscribe(); + await started; // waits for both passes to complete + await tick(); + + // Dispose must have been called (by the teardown loop in the second pass). + assert.equal( + disposeCalled, + true, + "dispose must be called when subscription is removed", + ); + // Key must NOT be in active. + assert.equal( + // biome-ignore lint/complexity/useLiteralKeys: intentional private access in test + mgr["active"].size, + 0, + "active must be empty — deleted key must not remain", + ); + + mgr.destroy(); +}); + +test("manager_handles_out_of_order_list_resolution", async () => { + // Regression test for the defect Thufir found at pass-3: a stale + // listSaveSubscriptions result from an older reload can overwrite the + // wantedKeys published by a newer reload that already knows K was deleted. + // + // Setup: reload A starts and its list resolves LATE (K present); meanwhile + // K is deleted and reload B completes with an empty list. Then A's stale + // list (with K) resolves last. + // + // Under single-flight serialization, A and B cannot interleave — B is + // queued as reloadPending and only runs after A's full pass completes. So + // when A's late list resolves it sees [K] and subscribes K; then B runs, + // lists [], and tears K down. The stale-overwrite defect is structurally + // impossible. + let resolveAList; + + const archive = makeFakeArchive(); + archive.setSubs([ + { + scopeType: "channel_h", + scopeValue: "chan-ooo", + kinds: [9], + identityPubkey: "pk", + relayUrl: "wss://r", + createdAt: 0, + }, + ]); + + // Intercept listSaveSubscriptions: first call is slow (returns a manually + // resolvable promise with a snapshot captured at call time); subsequent + // calls return the current state immediately. + let listCallCount = 0; + const fakeList = () => { + listCallCount++; + if (listCallCount === 1) { + // Capture the snapshot NOW (K is still present at this point). + const snapshot = archive.listSaveSubscriptions(); + // Return a promise we control — the caller decides when to resolve it. + return new Promise((resolve) => { + resolveAList = () => resolve(snapshot); + }); + } + return archive.listSaveSubscriptions(); + }; + + const relay = makeFakeRelayClient(); + const mgr = new ArchiveSyncManager({ + relayClient: relay, + listSaveSubscriptions: fakeList, + archiveEvents: (c) => archive.archiveEvents(c), + onSubscriptionChange: (l) => archive.onSubscriptionChange(l), + }); + + // Start: first list call is pending (reload A in flight). + const started = mgr.start(); + await tick(); // A is suspended at listSaveSubscriptions + + // Delete K — this notifies the listener, which sets reloadPending (B queued). + await archive.deleteSaveSubscription("channel_h", "chan-ooo"); + await tick(); + + // Now resolve A's stale list result (K is present in the snapshot A captured). + resolveAList(); + await started; // waits for A's full pass + B's coalescing pass + await tick(); + + // K must NOT be active after both passes complete. A's pass subscribed it, + // B's pass tore it down. + assert.equal( + relay.activeCount(), + 0, + "K must not be active after delete-then-list-resolve", + ); +}); + +test("manager_flushes_buffer_on_destroy", async () => { + const relay = makeFakeRelayClient(); + const archive = makeFakeArchive(); + archive.setSubs([ + { + scopeType: "channel_h", + scopeValue: "chan-1", + kinds: [9], + identityPubkey: "pk", + relayUrl: "wss://r", + createdAt: 0, + }, + ]); + const mgr = makeManager(relay, archive, { + flushBatchSize: 100, + flushIdleMs: 10000, + }); + await mgr.start(); + await tick(); + + const filter = JSON.parse([...relay.subs.keys()][0]); + relay.push(filter, { + id: "ev1", + kind: 9, + pubkey: "pk", + created_at: 1, + content: "hi", + tags: [], + }); + relay.push(filter, { + id: "ev2", + kind: 9, + pubkey: "pk", + created_at: 2, + content: "yo", + tags: [], + }); + // Buffer holds 2 events — flushBatchSize not reached yet + assert.equal(archive.archiveCalls.length, 0); + + mgr.destroy(); // should flush on destroy + await tick(); + assert.equal(archive.archiveCalls.length, 1); + assert.equal(archive.archiveCalls[0].length, 2); +}); diff --git a/desktop/src/features/local-archive/archiveSyncManager.ts b/desktop/src/features/local-archive/archiveSyncManager.ts new file mode 100644 index 000000000..aadfcb0c7 --- /dev/null +++ b/desktop/src/features/local-archive/archiveSyncManager.ts @@ -0,0 +1,318 @@ +import { relayClient as defaultRelayClient } from "@/shared/api/relayClient"; +import type { RelaySubscriptionFilter } from "@/shared/api/relayClientShared"; +import type { RelayEvent } from "@/shared/api/types"; +import { + archiveEvents as defaultArchiveEvents, + listSaveSubscriptions as defaultListSaveSubscriptions, + onSubscriptionChange as defaultOnSubscriptionChange, + type SaveSubscription, + type ScopeType, +} from "@/shared/api/tauriArchive"; + +// ── Constants ───────────────────────────────────────────────────────────────── + +const FLUSH_BATCH_SIZE = 25; +const FLUSH_IDLE_MS = 2_000; + +// ── Types ───────────────────────────────────────────────────────────────────── + +/** Dependency injection interface — production uses module singletons; tests inject fakes. */ +export interface ArchiveSyncDeps { + relayClient: { + subscribeLive: ( + filter: RelaySubscriptionFilter, + onEvent: (event: RelayEvent) => void, + ) => Promise<() => Promise>; + }; + listSaveSubscriptions: () => Promise; + archiveEvents: ( + candidates: Array<{ + rawEventJson: string; + matchedScope: { scopeType: ScopeType; scopeValue: string }; + }>, + ) => Promise; + onSubscriptionChange: (listener: () => void) => () => void; + flushBatchSize?: number; + flushIdleMs?: number; +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function buildFilter(sub: SaveSubscription): RelaySubscriptionFilter { + const base = { kinds: sub.kinds, limit: 0 } as const; + switch (sub.scopeType) { + case "channel_h": + return { ...base, "#h": [sub.scopeValue] }; + case "owner_p": + return { ...base, "#p": [sub.scopeValue] }; + case "referenced_e": + return { ...base, "#e": [sub.scopeValue] }; + } +} + +/** Stable key encoding scope + kinds — ensures kinds changes trigger resubscribe. */ +function subKey( + scopeType: ScopeType, + scopeValue: string, + kinds: number[], +): string { + const sortedKinds = [...kinds].sort((a, b) => a - b).join(","); + return `${scopeType}:${scopeValue}:${sortedKinds}`; +} + +/** Scope-only key used to find and tear down a stale sub when kinds change. */ +function scopeKey(scopeType: ScopeType, scopeValue: string): string { + return `${scopeType}:${scopeValue}`; +} + +// ── ArchiveSyncManager ──────────────────────────────────────────────────────── + +/** + * Always-on manager that opens one live relay subscription per saved archive + * config and forwards matched events to `archive_events` in debounced batches. + * + * Lifecycle: created once at app-shell mount (see `useArchiveSync`), destroyed + * on workspace switch. Resubscribes automatically when subscriptions change + * via the module-level notifier in `tauriArchive.ts`. + * + * Accepts optional `deps` for testing — production callers pass nothing. + */ +export class ArchiveSyncManager { + private readonly deps: Required< + Omit + >; + private readonly flushBatchSize: number; + private readonly flushIdleMs: number; + + // full subKey (scope+kinds) → unsub + private active = new Map Promise>(); + // Single-flight reload state — exactly one doResubscribe body runs at a time. + // Any reload request arriving while one is running sets reloadPending so that + // the loop runs one additional full pass after the current one finishes. + // This structural guarantee makes concurrent list/subscribe awaits impossible, + // eliminating all interleaving defects without per-boundary guards. + private reloading = false; + private reloadPending = false; + private buffer: Array<{ + rawEventJson: string; + matchedScope: { scopeType: ScopeType; scopeValue: string }; + }> = []; + private flushTimer: ReturnType | null = null; + private destroyed = false; + private offSubscriptionChange: (() => void) | null = null; + + constructor(deps?: ArchiveSyncDeps) { + this.deps = { + relayClient: deps?.relayClient ?? defaultRelayClient, + listSaveSubscriptions: + deps?.listSaveSubscriptions ?? defaultListSaveSubscriptions, + archiveEvents: deps?.archiveEvents ?? defaultArchiveEvents, + onSubscriptionChange: + deps?.onSubscriptionChange ?? defaultOnSubscriptionChange, + }; + this.flushBatchSize = deps?.flushBatchSize ?? FLUSH_BATCH_SIZE; + this.flushIdleMs = deps?.flushIdleMs ?? FLUSH_IDLE_MS; + } + + async start(): Promise { + // Register the change listener before the initial load so that any + // subscription change arriving while the first pass is running sets + // reloadPending and gets picked up by the coalescing loop. + this.offSubscriptionChange = this.deps.onSubscriptionChange(() => { + this.resubscribeAll(); + }); + await this.runReloadLoop(); + } + + destroy(): void { + this.destroyed = true; + if (this.flushTimer !== null) { + clearTimeout(this.flushTimer); + this.flushTimer = null; + } + this.offSubscriptionChange?.(); + this.offSubscriptionChange = null; + // Flush any buffered events before tearing down. + if (this.buffer.length > 0) { + const toFlush = this.buffer.splice(0); + void this.deps.archiveEvents(toFlush).catch((err: unknown) => { + console.warn("[archiveSyncManager] flush on destroy failed:", err); + }); + } + for (const [, unsub] of this.active) { + void unsub(); + } + this.active.clear(); + } + + /** + * Request a reload of all save subscriptions. + * + * If no reload is currently running, starts one immediately via + * `runReloadLoop`. If one is already running, sets `reloadPending` so + * the loop runs exactly one additional full pass after the current pass + * completes — no matter how many changes arrive mid-run, they coalesce + * into a single follow-up pass. + */ + private resubscribeAll(): void { + if (this.reloading) { + this.reloadPending = true; + return; + } + void this.runReloadLoop(); + } + + /** + * Runs `doResubscribe` in a loop, draining any reload requests that + * arrive while a pass is in flight. The loop terminates once no reload + * is pending and the manager has not been destroyed. + * + * Awaited directly by `start()` for the initial load. + */ + private async runReloadLoop(): Promise { + this.reloading = true; + try { + do { + this.reloadPending = false; + await this.doResubscribe(); + } while (this.reloadPending && !this.destroyed); + } finally { + this.reloading = false; + } + } + + /** + * One full reload pass: fetch the current subscription list, tear down + * removed or kind-changed subscriptions, and open new ones. + * + * Only one instance of this method ever runs at a time (enforced by + * `runReloadLoop`). That single-flight guarantee makes concurrent + * list/subscribe awaits structurally impossible. + */ + private async doResubscribe(): Promise { + if (this.destroyed) return; + + let subs: SaveSubscription[]; + try { + subs = await this.deps.listSaveSubscriptions(); + } catch (err) { + console.warn("[archiveSyncManager] list_save_subscriptions failed:", err); + return; + } + + if (this.destroyed) return; + + // Full keys (scope+kinds) for the current subscription list. + const wanted = new Set( + subs.map((s) => subKey(s.scopeType, s.scopeValue, s.kinds)), + ); + + // Tear down subscriptions that are no longer needed or whose kinds changed. + // A stale entry whose scope is still present but with different kinds will + // have a different full key and be absent from `wanted`, so it gets torn + // down here and recreated below with the new filter. + for (const [key, unsub] of this.active) { + if (!wanted.has(key)) { + void unsub(); + this.active.delete(key); + } + } + + // Open new subscriptions for any full key not already active. + // No concurrency guards are needed here: single-flight serialization + // ensures this loop body is the only async path running, so nothing + // can add a duplicate entry to `active` between iterations. + for (const sub of subs) { + if (this.destroyed) return; + + const key = subKey(sub.scopeType, sub.scopeValue, sub.kinds); + if (this.active.has(key)) continue; + + const scopeType = sub.scopeType; + const scopeValue = sub.scopeValue; + const filter = buildFilter(sub); + + let dispose: (() => Promise) | undefined; + try { + dispose = await this.deps.relayClient.subscribeLive( + filter, + (event: RelayEvent) => { + this.enqueue(event, scopeType, scopeValue); + }, + ); + } catch (err) { + console.warn( + `[archiveSyncManager] subscribeLive failed for ${scopeKey(scopeType, scopeValue)}:`, + err, + ); + // Do NOT add key to active — next resubscribeAll will retry. + continue; + } + + // A destroy() call may have arrived while subscribeLive was pending. + // Tear down the just-opened subscription and bail out. + if (this.destroyed) { + void dispose(); + return; + } + + this.active.set(key, dispose); + } + } + + private enqueue( + event: RelayEvent, + scopeType: ScopeType, + scopeValue: string, + ): void { + if (this.destroyed) return; + this.buffer.push({ + rawEventJson: JSON.stringify(event), + matchedScope: { scopeType, scopeValue }, + }); + if (this.buffer.length >= this.flushBatchSize) { + this.flush(); + } else { + this.scheduleFlush(); + } + } + + private scheduleFlush(): void { + if (this.flushTimer !== null) return; + this.flushTimer = setTimeout(() => { + this.flushTimer = null; + this.flush(); + }, this.flushIdleMs); + } + + private flush(): void { + if (this.flushTimer !== null) { + clearTimeout(this.flushTimer); + this.flushTimer = null; + } + if (this.buffer.length === 0) return; + const batch = this.buffer.splice(0); + void this.deps.archiveEvents(batch).catch((err: unknown) => { + console.warn("[archiveSyncManager] archive_events failed:", err); + }); + } +} + +// ── React hook ──────────────────────────────────────────────────────────────── + +import * as React from "react"; + +/** + * Starts the ArchiveSyncManager at app-shell mount and tears it down when the + * component unmounts (workspace switch). No return value needed — the manager + * runs entirely in the background. + */ +export function useArchiveSync(): void { + React.useEffect(() => { + const manager = new ArchiveSyncManager(); + void manager.start(); + return () => { + manager.destroy(); + }; + }, []); +} diff --git a/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx b/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx new file mode 100644 index 000000000..3ee4e9601 --- /dev/null +++ b/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx @@ -0,0 +1,551 @@ +import { Archive, Trash2 } from "lucide-react"; +import * as React from "react"; +import { toast } from "sonner"; + +import { + createSaveSubscription, + deleteSaveSubscription, + listSaveSubscriptions, + type SaveSubscription, + type ScopeType, +} from "@/shared/api/tauriArchive"; +import { KIND_AGENT_OBSERVER_FRAME } from "@/shared/constants/kinds"; +import { useChannelsQuery } from "@/features/channels/hooks"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { Button } from "@/shared/ui/button"; +import { Checkbox } from "@/shared/ui/checkbox"; +import { + SettingsOptionGroup, + SettingsOptionRow, +} from "@/features/settings/ui/SettingsOptionGroup"; +import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader"; + +import { + buildSubscriptionRequest, + isGroupFullyChecked, + isGroupIndeterminate, + KIND_GROUPS, + parseCustomKinds, + toggleGroup, + toggleKind, +} from "./localArchiveKinds"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function scopeLabel( + sub: SaveSubscription, + channelNameById: Map, +): string { + if (sub.scopeType === "channel_h") { + return channelNameById.get(sub.scopeValue) ?? sub.scopeValue; + } + if (sub.scopeType === "owner_p") { + return "My agent session frames"; + } + return sub.scopeValue; +} + +function kindSummary(kinds: number[]): string { + if (kinds.length === 0) return "no kinds"; + if (kinds.length <= 4) return kinds.join(", "); + return `${kinds.slice(0, 3).join(", ")} +${kinds.length - 3} more`; +} + +// ── Source selector (Step 1) ────────────────────────────────────────────────── + +type AddSource = "channel_h" | "owner_p"; + +type SourceSelectorProps = { + ownerPAlreadySubscribed: boolean; + onSelect: (source: AddSource) => void; +}; + +function SourceSelector({ + ownerPAlreadySubscribed, + onSelect, +}: SourceSelectorProps) { + return ( + + +
+

Channel

+

+ Archive events from a channel you're a member of. Access is verified + against the relay at subscribe and persist time. +

+
+ +
+ +
+

My agents' observer feed

+

+ Archive kind {KIND_AGENT_OBSERVER_FRAME} observer frames addressed + to your pubkey. Routed by pubkey, not stored by the relay. +

+
+ +
+
+ ); +} + +// ── Kind checklist (Step 2, channel_h only) ─────────────────────────────────── + +type KindChecklistProps = { + checkedKinds: ReadonlySet; + onChange: (next: Set) => void; +}; + +function KindChecklist({ checkedKinds, onChange }: KindChecklistProps) { + return ( +
+ {KIND_GROUPS.map((group) => { + const fullyChecked = isGroupFullyChecked(group, checkedKinds); + const indeterminate = isGroupIndeterminate(group, checkedKinds); + return ( +
+ {/* Group header */} +
+ + onChange(toggleGroup(group, checkedKinds)) + } + /> + +
+ {/* Individual kind checkboxes */} +
+ {group.items.map(({ kind, label }) => ( +
+ + onChange(toggleKind(kind, checkedKinds)) + } + /> + +
+ ))} +
+
+ ); + })} +
+ ); +} + +// ── Custom kinds input ──────────────────────────────────────────────────────── + +type CustomKindsInputProps = { + value: string; + onChange: (raw: string) => void; +}; + +function CustomKindsInput({ value, onChange }: CustomKindsInputProps) { + const { invalid } = parseCustomKinds(value); + const hasInvalid = invalid.length > 0; + return ( +
+ + onChange(e.target.value)} + placeholder="e.g. 30023 1337" + type="text" + value={value} + /> +

+ Space- or comma-separated non-negative integers. Kinds already in the + checklist above are ignored. +

+ {hasInvalid && ( +

+ Invalid tokens (ignored):{" "} + {invalid.map((t, i) => ( + + {i > 0 && ", "} + {t} + + ))} +

+ )} +
+ ); +} + +// ── Add-subscription form (Steps 1 + 2) ────────────────────────────────────── + +type AddFormProps = { + channels: Array<{ id: string; name: string }>; + ownerPAlreadySubscribed: boolean; + onSaved: () => void; + onCancel: () => void; + pubkey: string; +}; + +function AddSubscriptionForm({ + channels, + ownerPAlreadySubscribed, + onSaved, + onCancel, + pubkey, +}: AddFormProps) { + const [source, setSource] = React.useState(null); + const [selectedChannelId, setSelectedChannelId] = React.useState(""); + const [checkedKinds, setCheckedKinds] = React.useState>( + new Set(), + ); + const [customKindsRaw, setCustomKindsRaw] = React.useState(""); + const [isAdding, setIsAdding] = React.useState(false); + + const { valid: customKinds } = parseCustomKinds(customKindsRaw); + // `request` is non-null only when the subscription is valid to submit. + // `canAdd` mirrors the same check for the disabled prop without recomputing. + const request = + source !== null + ? buildSubscriptionRequest( + source, + source === "channel_h" ? selectedChannelId : pubkey, + checkedKinds, + customKinds, + ) + : null; + const canAdd = request !== null; + + const handleAdd = React.useCallback(async () => { + if (request === null) return; + + setIsAdding(true); + try { + await createSaveSubscription( + request.scopeType, + request.scopeValue, + request.kinds, + ); + onSaved(); + toast.success("Archive subscription created."); + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Failed to create subscription.", + ); + } finally { + setIsAdding(false); + } + }, [request, onSaved]); + + const handleCancel = () => { + setSource(null); + setSelectedChannelId(""); + setCheckedKinds(new Set()); + setCustomKindsRaw(""); + onCancel(); + }; + + // Step 1: source picker + if (source === null) { + return ( + + ); + } + + // Step 2: event types + confirm + return ( + +
+ {/* Step 1 summary + back link */} +
+ + {source === "channel_h" ? "Channel" : "My agents' observer feed"} + + +
+ + {source === "channel_h" ? ( + <> + {/* Channel picker */} +
+ + +
+ + {/* Event types (per-kind checklist) */} +
+

Event types

+ +
+ + {/* Advanced: custom kinds */} + + + ) : ( + /* owner_p: fixed / informational */ +

+ Archives all kind {KIND_AGENT_OBSERVER_FRAME} observer frames + addressed to your pubkey. The event type is fixed to{" "} + [{KIND_AGENT_OBSERVER_FRAME}] — + these frames are never stored by the relay and cannot be filtered by + channel at this layer. +

+ )} + +
+ + +
+
+
+ ); +} + +// ── Main component ──────────────────────────────────────────────────────────── + +export function LocalArchiveSettingsCard() { + const identityQuery = useIdentityQuery(); + const channelsQuery = useChannelsQuery(); + const [subs, setSubs] = React.useState([]); + const [isLoading, setIsLoading] = React.useState(true); + const [deletingKey, setDeletingKey] = React.useState(null); + const [isAddingOpen, setIsAddingOpen] = React.useState(false); + + const channelNameById = React.useMemo>(() => { + const map = new Map(); + for (const ch of channelsQuery.data ?? []) { + map.set(ch.id, ch.name); + } + return map; + }, [channelsQuery.data]); + + const joinedChannels = React.useMemo( + () => (channelsQuery.data ?? []).filter((ch) => ch.isMember), + [channelsQuery.data], + ); + + const reload = React.useCallback(async () => { + try { + const rows = await listSaveSubscriptions(); + setSubs(rows); + } catch (err) { + console.warn("[LocalArchiveSettingsCard] list failed:", err); + } finally { + setIsLoading(false); + } + }, []); + + React.useEffect(() => { + void reload(); + }, [reload]); + + const handleDelete = React.useCallback( + async (scopeType: ScopeType, scopeValue: string) => { + const key = `${scopeType}:${scopeValue}`; + setDeletingKey(key); + try { + await deleteSaveSubscription(scopeType, scopeValue); + await reload(); + toast.success("Archive subscription removed."); + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Failed to remove subscription.", + ); + } finally { + setDeletingKey(null); + } + }, + [reload], + ); + + const ownerPAlreadySubscribed = subs.some((s) => s.scopeType === "owner_p"); + + return ( +
+ + +
+ {/* Existing subscriptions */} +
+

+ Active subscriptions + {subs.length > 0 ? ` (${subs.length})` : ""} +

+ {isLoading ? ( + +
+ Loading… +
+
+ ) : subs.length === 0 ? ( + +
+ No subscriptions yet. Add one below. +
+
+ ) : ( + + {subs.map((sub) => { + const key = `${sub.scopeType}:${sub.scopeValue}`; + return ( +
+ +
+

+ {scopeLabel(sub, channelNameById)} +

+

+ {sub.scopeType} · kinds: {kindSummary(sub.kinds)} +

+
+ +
+ ); + })} +
+ )} +
+ + {/* Add subscription */} +
+

Add subscription

+ {isAddingOpen ? ( + setIsAddingOpen(false)} + onSaved={() => { + setIsAddingOpen(false); + void reload(); + }} + ownerPAlreadySubscribed={ownerPAlreadySubscribed} + pubkey={identityQuery.data?.pubkey ?? ""} + /> + ) : ( + + +
+

+ Subscribe to a channel or observer feed +

+

+ Choose a source and select which event types to archive. +

+
+ +
+
+ )} +
+
+
+ ); +} diff --git a/desktop/src/features/local-archive/ui/localArchiveKinds.test.mjs b/desktop/src/features/local-archive/ui/localArchiveKinds.test.mjs new file mode 100644 index 000000000..9d7f9d8ec --- /dev/null +++ b/desktop/src/features/local-archive/ui/localArchiveKinds.test.mjs @@ -0,0 +1,369 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + KIND_GROUPS, + buildFinalKinds, + buildSubscriptionRequest, + isGroupFullyChecked, + isGroupIndeterminate, + parseCustomKinds, + toggleGroup, + toggleKind, +} from "./localArchiveKinds.ts"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function groupByLabel(label) { + const g = KIND_GROUPS.find((g) => g.label === label); + if (!g) throw new Error(`group not found: ${label}`); + return g; +} + +function kindsOfGroup(label) { + return groupByLabel(label).items.map((i) => i.kind); +} + +// ── parseCustomKinds ────────────────────────────────────────────────────────── + +test("parseCustomKinds_emptyString_returnsEmpty", () => { + const result = parseCustomKinds(""); + assert.deepEqual(result.valid, []); + assert.deepEqual(result.invalid, []); +}); + +test("parseCustomKinds_singleValidKind_returnsIt", () => { + const result = parseCustomKinds("60000"); + assert.deepEqual(result.valid, [60000]); + assert.deepEqual(result.invalid, []); +}); + +test("parseCustomKinds_multipleValidKinds_returnsSorted", () => { + const result = parseCustomKinds("30000 10000 20000"); + // buildFinalKinds sorts, parseCustomKinds preserves insertion order + assert.deepEqual(result.valid, [30000, 10000, 20000]); + assert.deepEqual(result.invalid, []); +}); + +test("parseCustomKinds_commaAndSpaceSeparated_bothWork", () => { + const result = parseCustomKinds("30000,20000 10000"); + assert.deepEqual(result.valid, [30000, 20000, 10000]); + assert.deepEqual(result.invalid, []); +}); + +test("parseCustomKinds_negativeInteger_isInvalid", () => { + const result = parseCustomKinds("-5"); + assert.deepEqual(result.valid, []); + assert.deepEqual(result.invalid, ["-5"]); +}); + +test("parseCustomKinds_float_isInvalid", () => { + const result = parseCustomKinds("1.5"); + assert.deepEqual(result.valid, []); + assert.deepEqual(result.invalid, ["1.5"]); +}); + +test("parseCustomKinds_kindAbove65535_isInvalid", () => { + // NIP-01 kind range is 0..=65535; values above are rejected to prevent + // the nostr crate's silent u16 truncation from creating unmatchable filters. + const result = parseCustomKinds("65536 99999 89736"); + assert.deepEqual(result.valid, []); + assert.deepEqual(result.invalid, ["65536", "99999", "89736"]); +}); + +test("parseCustomKinds_65535_isValid_boundary", () => { + const result = parseCustomKinds("65535"); + assert.deepEqual(result.valid, [65535]); + assert.deepEqual(result.invalid, []); +}); + +test("parseCustomKinds_nonNumericToken_isInvalid", () => { + const result = parseCustomKinds("abc"); + assert.deepEqual(result.valid, []); + assert.deepEqual(result.invalid, ["abc"]); +}); + +test("parseCustomKinds_mixedValidAndInvalid_separateCorrectly", () => { + const result = parseCustomKinds("60000 abc 50000 -1"); + assert.deepEqual(result.valid, [60000, 50000]); + assert.deepEqual(result.invalid, ["abc", "-1"]); +}); + +test("parseCustomKinds_duplicateTokens_deduped", () => { + const result = parseCustomKinds("60000 60000 60000"); + assert.deepEqual(result.valid, [60000]); + assert.deepEqual(result.invalid, []); +}); + +test("parseCustomKinds_kindAlreadyInGroups_silentlyIgnored", () => { + // Kind 9 is in the "Messages & posts" group — not invalid but not returned + const result = parseCustomKinds("9 60000"); + assert.deepEqual(result.valid, [60000]); + assert.deepEqual(result.invalid, []); +}); + +test("parseCustomKinds_zero_isValid", () => { + const result = parseCustomKinds("0"); + assert.deepEqual(result.valid, [0]); + assert.deepEqual(result.invalid, []); +}); + +// ── buildFinalKinds ─────────────────────────────────────────────────────────── + +test("buildFinalKinds_onlyCheckedKinds_sortedDeduped", () => { + const result = buildFinalKinds(new Set([40002, 9, 7]), []); + assert.deepEqual(result, [7, 9, 40002]); +}); + +test("buildFinalKinds_onlyCustomKinds_sortedDeduped", () => { + const result = buildFinalKinds(new Set(), [99999, 88888]); + assert.deepEqual(result, [88888, 99999]); +}); + +test("buildFinalKinds_unionWithDedup_sortedCorrectly", () => { + const result = buildFinalKinds(new Set([9, 40002]), [99999, 9]); + // 9 appears in both — deduplicated to one entry + assert.deepEqual(result, [9, 40002, 99999]); +}); + +test("buildFinalKinds_emptyInputs_returnsEmpty", () => { + const result = buildFinalKinds(new Set(), []); + assert.deepEqual(result, []); +}); + +// ── toggleKind ──────────────────────────────────────────────────────────────── + +test("toggleKind_uncheckedKind_addsIt", () => { + const result = toggleKind(9, new Set([7])); + assert.ok(result.has(9)); + assert.ok(result.has(7)); + assert.equal(result.size, 2); +}); + +test("toggleKind_checkedKind_removesIt", () => { + const result = toggleKind(9, new Set([9, 7])); + assert.ok(!result.has(9)); + assert.ok(result.has(7)); + assert.equal(result.size, 1); +}); + +// ── toggleGroup ─────────────────────────────────────────────────────────────── + +test("toggleGroup_noneChecked_checksAll", () => { + const group = groupByLabel("Messages & posts"); + const result = toggleGroup(group, new Set()); + for (const item of group.items) { + assert.ok( + result.has(item.kind), + `expected kind ${item.kind} to be checked`, + ); + } +}); + +test("toggleGroup_allChecked_uncheksAll", () => { + const group = groupByLabel("Messages & posts"); + const allChecked = new Set(kindsOfGroup("Messages & posts")); + const result = toggleGroup(group, allChecked); + assert.equal(result.size, 0); +}); + +test("toggleGroup_partiallyChecked_checksAll", () => { + const group = groupByLabel("Messages & posts"); + const kinds = kindsOfGroup("Messages & posts"); + // Only one kind checked + const partial = new Set([kinds[0]]); + const result = toggleGroup(group, partial); + for (const item of group.items) { + assert.ok(result.has(item.kind)); + } +}); + +test("toggleGroup_doesNotAffectOtherKinds", () => { + const group = groupByLabel("Messages & posts"); + const outerKind = 99999; + const baseSet = new Set([outerKind]); + const result = toggleGroup(group, baseSet); + assert.ok(result.has(outerKind), "outer kind preserved"); +}); + +// ── isGroupFullyChecked / isGroupIndeterminate ──────────────────────────────── + +test("isGroupFullyChecked_allInGroup_returnsTrue", () => { + const group = groupByLabel("Reactions, edits & deletions"); + const all = new Set(kindsOfGroup("Reactions, edits & deletions")); + assert.equal(isGroupFullyChecked(group, all), true); +}); + +test("isGroupFullyChecked_noneInGroup_returnsFalse", () => { + const group = groupByLabel("Reactions, edits & deletions"); + assert.equal(isGroupFullyChecked(group, new Set()), false); +}); + +test("isGroupIndeterminate_someButNotAll_returnsTrue", () => { + const group = groupByLabel("Reactions, edits & deletions"); + const kinds = kindsOfGroup("Reactions, edits & deletions"); + const partial = new Set([kinds[0]]); + assert.equal(isGroupIndeterminate(group, partial), true); +}); + +test("isGroupIndeterminate_noneChecked_returnsFalse", () => { + const group = groupByLabel("Reactions, edits & deletions"); + assert.equal(isGroupIndeterminate(group, new Set()), false); +}); + +test("isGroupIndeterminate_allChecked_returnsFalse", () => { + const group = groupByLabel("Reactions, edits & deletions"); + const all = new Set(kindsOfGroup("Reactions, edits & deletions")); + assert.equal(isGroupIndeterminate(group, all), false); +}); + +// ── Observer source fixed [24200] ───────────────────────────────────────────── + +test("observerSource_fixedKind_is24200", () => { + // The observer source always produces exactly [24200] — verify the constant + // used in localArchiveKinds matches KIND_AGENT_OBSERVER_FRAME. + // We verify this indirectly: kind 24200 must NOT appear in KIND_GROUPS + // (it is not a channel event kind) so it would not be silently filtered + // out of custom input — but more importantly it is never used in the + // channel checklist, keeping the two paths cleanly separated. + const allGroupedKinds = KIND_GROUPS.flatMap((g) => + g.items.map((i) => i.kind), + ); + assert.ok( + !allGroupedKinds.includes(24200), + "kind 24200 must not appear in channel kind groups", + ); +}); + +// ── Empty selection blocks Add ──────────────────────────────────────────────── + +test("buildFinalKinds_emptySelection_producesEmptyArray", () => { + // The Add button is disabled when buildFinalKinds returns length === 0. + const result = buildFinalKinds(new Set(), []); + assert.equal(result.length, 0); +}); + +// ── Group + custom union (deduped / sorted) ─────────────────────────────────── + +test("buildFinalKinds_groupAndCustom_unioned_sorted_deduped", () => { + const messagesKinds = kindsOfGroup("Messages & posts"); + const customKinds = [99999, 88888]; + const result = buildFinalKinds(new Set(messagesKinds), customKinds); + // Must be sorted ascending + const sorted = [...result].sort((a, b) => a - b); + assert.deepEqual(result, sorted); + // Must include all messages kinds + for (const k of messagesKinds) { + assert.ok(result.includes(k)); + } + // Must include custom kinds + assert.ok(result.includes(99999)); + assert.ok(result.includes(88888)); + // No duplicates + assert.equal(result.length, new Set(result).size); +}); + +// ── Single-kind selection ───────────────────────────────────────────────────── + +test("buildFinalKinds_singleKind_returnsSingleElementArray", () => { + const result = buildFinalKinds(new Set([9]), []); + assert.deepEqual(result, [9]); +}); + +// ── Malformed custom input: mixed valid/invalid ─────────────────────────────── + +test("parseCustomKinds_malformedInput_onlyValidReturned", () => { + const { valid, invalid } = parseCustomKinds("hello 60000 1.5 50000 -2"); + assert.deepEqual(valid, [60000, 50000]); + assert.deepEqual(invalid, ["hello", "1.5", "-2"]); +}); + +// ── buildSubscriptionRequest — six acceptance cases ─────────────────────────── + +// Helper: get all kinds in a named group +function kindsOfGroupBySR(label) { + const g = KIND_GROUPS.find((g) => g.label === label); + if (!g) throw new Error(`group not found: ${label}`); + return g.items.map((i) => i.kind); +} + +test("buildSubscriptionRequest_singleKindSelection_exactKindsArray", () => { + const req = buildSubscriptionRequest( + "channel_h", + "channel-abc", + new Set([9]), + [], + ); + assert.ok(req !== null); + assert.equal(req.scopeType, "channel_h"); + assert.equal(req.scopeValue, "channel-abc"); + assert.deepEqual(req.kinds, [9]); +}); + +test("buildSubscriptionRequest_groupHeaderToggle_allKindsInGroup", () => { + const messagesKinds = kindsOfGroupBySR("Messages & posts"); + const req = buildSubscriptionRequest( + "channel_h", + "channel-abc", + new Set(messagesKinds), + [], + ); + assert.ok(req !== null); + assert.deepEqual( + req.kinds, + [...messagesKinds].sort((a, b) => a - b), + ); +}); + +test("buildSubscriptionRequest_groupPlusCustomKind_unionDedupedSorted", () => { + const messagesKinds = kindsOfGroupBySR("Messages & posts"); + const customKinds = [50000, 50001]; + const req = buildSubscriptionRequest( + "channel_h", + "channel-abc", + new Set(messagesKinds), + customKinds, + ); + assert.ok(req !== null); + // Must be sorted ascending + const sorted = [...req.kinds].sort((a, b) => a - b); + assert.deepEqual(req.kinds, sorted, "kinds must be sorted"); + // Must include all messages kinds and both custom kinds + for (const k of messagesKinds) { + assert.ok(req.kinds.includes(k), `missing messages kind ${k}`); + } + assert.ok(req.kinds.includes(50000)); + assert.ok(req.kinds.includes(50001)); + // No duplicates + assert.equal(req.kinds.length, new Set(req.kinds).size); +}); + +test("buildSubscriptionRequest_emptySelectionChannelH_returnsNull", () => { + // Empty selection blocks Add — buildSubscriptionRequest returns null + const req = buildSubscriptionRequest( + "channel_h", + "channel-abc", + new Set(), + [], + ); + assert.equal(req, null); +}); + +test("buildSubscriptionRequest_observerSourceFixedKind_exactlyKind24200", () => { + const req = buildSubscriptionRequest( + "owner_p", + "pubkey-abc", + new Set(), // checked kinds ignored for owner_p + [], + ); + assert.ok(req !== null); + assert.equal(req.scopeType, "owner_p"); + assert.equal(req.scopeValue, "pubkey-abc"); + assert.deepEqual(req.kinds, [24200]); +}); + +test("buildSubscriptionRequest_emptyPubkeyOwnerP_returnsNull", () => { + // Empty pubkey (identity not yet loaded) blocks owner_p Add + const req = buildSubscriptionRequest("owner_p", "", new Set(), []); + assert.equal(req, null); +}); diff --git a/desktop/src/features/local-archive/ui/localArchiveKinds.ts b/desktop/src/features/local-archive/ui/localArchiveKinds.ts new file mode 100644 index 000000000..0717559f7 --- /dev/null +++ b/desktop/src/features/local-archive/ui/localArchiveKinds.ts @@ -0,0 +1,252 @@ +import { + CHANNEL_AUX_EVENT_KINDS, + CHANNEL_MESSAGE_EVENT_KINDS, + KIND_AGENT_OBSERVER_FRAME, + KIND_HUDDLE_ENDED, + KIND_HUDDLE_PARTICIPANT_JOINED, + KIND_HUDDLE_PARTICIPANT_LEFT, + KIND_HUDDLE_STARTED, + KIND_STREAM_MESSAGE_DIFF, + KIND_SYSTEM_MESSAGE, +} from "@/shared/constants/kinds"; + +// ── Kind groups (derived from shared constants — never raw literals) ────────── + +export type KindGroup = { + /** Group label shown as a section header. */ + label: string; + /** Individual kinds in this group, each with a human-readable name. */ + items: ReadonlyArray<{ kind: number; label: string }>; +}; + +/** + * Ordered list of kind groups presented in the Step 2 checklist. + * Every item derives its kind value from a named constant in kinds.ts. + */ +export const KIND_GROUPS: ReadonlyArray = [ + { + label: "Messages & posts", + items: [ + ...CHANNEL_MESSAGE_EVENT_KINDS.map((k) => ({ + kind: k, + label: kindLabel(k), + })), + { kind: KIND_STREAM_MESSAGE_DIFF, label: "Message diffs (kind 40008)" }, + ], + }, + { + label: "Reactions, edits & deletions", + items: CHANNEL_AUX_EVENT_KINDS.map((k) => ({ + kind: k, + label: kindLabel(k), + })), + }, + { + label: "Huddle events", + items: [ + { kind: KIND_HUDDLE_STARTED, label: "Huddle started" }, + { kind: KIND_HUDDLE_PARTICIPANT_JOINED, label: "Participant joined" }, + { kind: KIND_HUDDLE_PARTICIPANT_LEFT, label: "Participant left" }, + { kind: KIND_HUDDLE_ENDED, label: "Huddle ended" }, + ], + }, + { + label: "System messages", + items: [ + { kind: KIND_SYSTEM_MESSAGE, label: "System messages (kind 40099)" }, + ], + }, +] as const; + +/** Human-readable label for a known kind number. */ +function kindLabel(kind: number): string { + switch (kind) { + case 5: + return "Event deletions (kind 5)"; + case 7: + return "Reactions (kind 7)"; + case 9: + return "Stream messages (kind 9)"; + case 9005: + return "Buzz-native deletions (kind 9005)"; + case 40002: + return "Stream messages v2 (kind 40002)"; + case 40003: + return "Message edits (kind 40003)"; + case 45001: + return "Forum posts (kind 45001)"; + case 45003: + return "Forum comments (kind 45003)"; + default: + return `Kind ${kind}`; + } +} + +/** All kinds covered by KIND_GROUPS, as a flat sorted set for dedup checks. */ +const GROUPED_KINDS: ReadonlySet = new Set( + KIND_GROUPS.flatMap((g) => g.items.map((i) => i.kind)), +); + +// ── Custom-kinds parser ─────────────────────────────────────────────────────── + +export type ParsedCustomKinds = { + /** Valid non-negative integer kind numbers not already in KIND_GROUPS. */ + valid: number[]; + /** + * Tokens that failed validation (not a non-negative integer, or duplicate + * of a grouped kind), returned so the UI can show inline feedback. + */ + invalid: string[]; +}; + +/** + * Parse a free-text custom kinds input. + * + * Rules: + * - Split on whitespace and/or commas. + * - Accept non-negative integers in the valid NIP-01 kind range 0..=65535 only + * (no floats, no negatives, no hex, no values > 65535). + * - Reject tokens that duplicate a kind already present in KIND_GROUPS. + * - Deduplicate valid tokens (keep first occurrence). + * - Return both valid numbers and invalid tokens for inline feedback. + */ +export function parseCustomKinds(raw: string): ParsedCustomKinds { + const tokens = raw.split(/[\s,]+/).filter((t) => t.length > 0); + const valid: number[] = []; + const invalid: string[] = []; + const seen = new Set(); + + for (const token of tokens) { + // Must be a non-negative integer with no leading sign, decimal point, etc. + if (!/^\d+$/.test(token)) { + invalid.push(token); + continue; + } + const n = parseInt(token, 10); + if (!Number.isFinite(n) || n < 0 || n > 65535) { + invalid.push(token); + continue; + } + if (GROUPED_KINDS.has(n)) { + // Already available in the checklist — silently ignore (not an error). + continue; + } + if (seen.has(n)) { + continue; // deduplicate + } + seen.add(n); + valid.push(n); + } + + return { valid, invalid }; +} + +// ── Final kinds computation ─────────────────────────────────────────────────── + +/** + * Compute the final sorted, deduped kinds array to pass to + * `createSaveSubscription` for a channel_h subscription. + * + * @param checkedKinds - Set of kind numbers checked via the group checklist. + * @param customKinds - Valid custom kind numbers from `parseCustomKinds`. + */ +export function buildFinalKinds( + checkedKinds: ReadonlySet, + customKinds: ReadonlyArray, +): number[] { + const merged = new Set([...checkedKinds, ...customKinds]); + return [...merged].sort((a, b) => a - b); +} + +// ── Group-toggle helpers ────────────────────────────────────────────────────── + +/** Returns whether all kinds in a group are in `checkedKinds`. */ +export function isGroupFullyChecked( + group: KindGroup, + checkedKinds: ReadonlySet, +): boolean { + return group.items.every((i) => checkedKinds.has(i.kind)); +} + +/** Returns whether some (but not all) kinds in a group are in `checkedKinds`. */ +export function isGroupIndeterminate( + group: KindGroup, + checkedKinds: ReadonlySet, +): boolean { + const some = group.items.some((i) => checkedKinds.has(i.kind)); + const all = group.items.every((i) => checkedKinds.has(i.kind)); + return some && !all; +} + +/** + * Toggle all kinds in a group: if the group is fully checked, uncheck all; + * otherwise check all. Returns the updated set (new object). + */ +export function toggleGroup( + group: KindGroup, + checkedKinds: ReadonlySet, +): Set { + const next = new Set(checkedKinds); + if (isGroupFullyChecked(group, checkedKinds)) { + for (const item of group.items) { + next.delete(item.kind); + } + } else { + for (const item of group.items) { + next.add(item.kind); + } + } + return next; +} + +/** Toggle a single kind in `checkedKinds`. Returns the updated set. */ +export function toggleKind( + kind: number, + checkedKinds: ReadonlySet, +): Set { + const next = new Set(checkedKinds); + if (next.has(kind)) { + next.delete(kind); + } else { + next.add(kind); + } + return next; +} + +// ── Subscription request builder ────────────────────────────────────────────── + +export type SubscriptionRequest = { + scopeType: "channel_h" | "owner_p"; + scopeValue: string; + kinds: number[]; +}; + +/** + * Build the final subscription request to pass to `createSaveSubscription`. + * + * - For `owner_p`: kinds is always `[KIND_AGENT_OBSERVER_FRAME]`, scopeValue is + * the identity pubkey. + * - For `channel_h`: kinds is the sorted deduped union of checkedKinds and + * customKinds; scopeValue is the channel id. + * + * Returns `null` when the request would be invalid (empty pubkey for owner_p, + * empty channelId or zero kinds for channel_h). + */ +export function buildSubscriptionRequest( + source: "channel_h" | "owner_p", + scopeValue: string, + checkedKinds: ReadonlySet, + customKinds: ReadonlyArray, +): SubscriptionRequest | null { + if (!scopeValue) return null; + if (source === "owner_p") { + return { + scopeType: "owner_p", + scopeValue, + kinds: [KIND_AGENT_OBSERVER_FRAME], + }; + } + const kinds = buildFinalKinds(checkedKinds, customKinds); + if (kinds.length === 0) return null; + return { scopeType: "channel_h", scopeValue, kinds }; +} diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index 944b32246..e26191e6e 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -1,5 +1,6 @@ import { useState, useMemo, useRef } from "react"; import { + Archive, BellRing, Bot, Check, @@ -27,6 +28,7 @@ import type { import type { SoundName, SoundSlot } from "@/features/notifications/lib/sound"; import { RelayMembersSettingsCard } from "@/features/relay-members/ui/RelayMembersSettingsCard"; import { CustomEmojiSettingsCard } from "@/features/custom-emoji/ui/CustomEmojiSettingsCard"; +import { LocalArchiveSettingsCard } from "@/features/local-archive/ui/LocalArchiveSettingsCard"; import { cn } from "@/shared/lib/cn"; import { ACCENT_COLORS, @@ -58,6 +60,7 @@ export type SettingsSection = | "shortcuts" | "relay-members" | "custom-emoji" + | "local-archive" | "mobile" | "updates" | "doctor"; @@ -75,6 +78,7 @@ const SETTINGS_SECTION_VALUES: readonly SettingsSection[] = [ "shortcuts", "relay-members", "custom-emoji", + "local-archive", "mobile", "updates", "doctor", @@ -164,6 +168,11 @@ export const settingsSections: SettingsSectionDescriptor[] = [ icon: Smile, featureGate: "custom-emoji", }, + { + value: "local-archive", + label: "Local Archive", + icon: Archive, + }, { value: "mobile", label: "Mobile", @@ -386,6 +395,8 @@ export function renderSettingsSection( return ; case "custom-emoji": return ; + case "local-archive": + return ; case "mobile": return ; case "updates": diff --git a/desktop/src/features/settings/ui/SettingsView.tsx b/desktop/src/features/settings/ui/SettingsView.tsx index 4a8de7cb7..f70b72aa9 100644 --- a/desktop/src/features/settings/ui/SettingsView.tsx +++ b/desktop/src/features/settings/ui/SettingsView.tsx @@ -54,6 +54,7 @@ const settingsNavGroups: Array<{ "notifications", "shortcuts", "custom-emoji", + "local-archive", ], }, { diff --git a/desktop/src/shared/api/tauriArchive.ts b/desktop/src/shared/api/tauriArchive.ts new file mode 100644 index 000000000..775e1f0e7 --- /dev/null +++ b/desktop/src/shared/api/tauriArchive.ts @@ -0,0 +1,206 @@ +import { invokeTauri } from "./tauri"; + +// ── Wire-shape types (raw Tauri responses) ─────────────────────────────────── + +/** + * `list_save_subscriptions` returns rows directly from SQLite. + * The `kinds` column is stored as a JSON text string (e.g. `"[9,40002]"`), + * NOT a number array — it must be decoded before use. + */ +type RawSaveSubscription = { + identity_pubkey: string; + relay_url: string; + scope_type: string; + scope_value: string; + /** JSON-encoded integer array, e.g. `"[9,40002]"`. */ + kinds: string; + created_at: number; +}; + +// ── Public types ───────────────────────────────────────────────────────────── + +export type ScopeType = "channel_h" | "owner_p" | "referenced_e"; + +export type SaveSubscription = { + identityPubkey: string; + relayUrl: string; + scopeType: ScopeType; + scopeValue: string; + kinds: number[]; + createdAt: number; +}; + +export type ArchiveBatchResult = { + persisted: number; + dropped: number; +}; + +// ── Subscription-change notifier ───────────────────────────────────────────── + +/** + * Module-level notifier for subscription mutations (create/delete). + * The archive sync manager subscribes to this to reload live subscriptions + * without needing manager instances threaded through UI props. + */ +const subscriptionChangeListeners = new Set<() => void>(); + +export function onSubscriptionChange(listener: () => void): () => void { + subscriptionChangeListeners.add(listener); + return () => subscriptionChangeListeners.delete(listener); +} + +function notifySubscriptionChange(): void { + for (const listener of subscriptionChangeListeners) { + listener(); + } +} + +// ── Decoder ────────────────────────────────────────────────────────────────── + +function decodeRawSubscription(raw: RawSaveSubscription): SaveSubscription { + let kinds: number[] = []; + try { + const parsed = JSON.parse(raw.kinds); + if ( + Array.isArray(parsed) && + parsed.every((k) => typeof k === "number" && Number.isFinite(k)) + ) { + kinds = parsed as number[]; + } else { + console.warn( + "[tauriArchive] malformed kinds JSON (not number[]):", + raw.kinds, + ); + } + } catch { + console.warn("[tauriArchive] failed to parse kinds JSON:", raw.kinds); + } + return { + identityPubkey: raw.identity_pubkey, + relayUrl: raw.relay_url, + scopeType: raw.scope_type as ScopeType, + scopeValue: raw.scope_value, + kinds, + createdAt: raw.created_at, + }; +} + +// ── API wrappers ───────────────────────────────────────────────────────────── + +/** + * Create a save subscription. + * Runs an access probe on the backend (channel membership, event readability). + * `kinds` is sent as a plain number array — Tauri serializes it correctly. + */ +export async function createSaveSubscription( + scopeType: ScopeType, + scopeValue: string, + kinds: number[], +): Promise { + await invokeTauri("create_save_subscription", { + scopeType, + scopeValue, + kinds, + }); + notifySubscriptionChange(); +} + +/** + * List all save subscriptions for the current identity + relay. + * Decodes the raw `kinds` string column into `number[]`. + */ +export async function listSaveSubscriptions(): Promise { + const rows = await invokeTauri( + "list_save_subscriptions", + ); + return rows.map(decodeRawSubscription); +} + +/** + * Delete a save subscription. + * Returns `true` if a row was removed, `false` if it didn't exist. + */ +export async function deleteSaveSubscription( + scopeType: ScopeType, + scopeValue: string, +): Promise { + const removed = await invokeTauri("delete_save_subscription", { + scopeType, + scopeValue, + }); + if (removed) { + notifySubscriptionChange(); + } + return removed; +} + +/** + * Archive a batch of event candidates. + * + * Wire-shape note (verified against Rust source at `archive/mod.rs`): + * - `ArchiveCandidate` has no `#[serde(rename_all)]`, so struct field names + * are verbatim: `raw_event_json`, `matched_scope`. + * - `MatchedScope` field names are also verbatim: `scope_type`, `scope_value`. + * - `ScopeType` enum has `#[serde(rename_all = "snake_case")]`: values are + * `"channel_h"`, `"owner_p"`, `"referenced_e"`. + * - Tauri 2 only camelCases top-level command arg names, NOT nested struct + * fields — so `candidates` is passed as-is, with snake_case field names. + */ +export async function archiveEvents( + candidates: Array<{ + rawEventJson: string; + matchedScope: { scopeType: ScopeType; scopeValue: string }; + }>, +): Promise { + return invokeTauri("archive_events", { + candidates: candidates.map((c) => ({ + raw_event_json: c.rawEventJson, + matched_scope: { + scope_type: c.matchedScope.scopeType, + scope_value: c.matchedScope.scopeValue, + }, + })), + }); +} + +/** + * Read a paginated page of archived raw events for a scope. + * + * Returns at most `limit` raw Nostr events (default 50) in newest-first order. + * Use `before` — a compound cursor `{ createdAt, id }` taken from the **last** + * (oldest) row of the previous page — to load the next older page. The + * predicate on the Rust side mirrors `ORDER BY created_at DESC, id DESC` + * exactly, so same-second siblings are never skipped at a page boundary. + * A page shorter than `limit` signals the archive is exhausted. + * + * Pass `kinds: null` (or omit) to admit all archived kinds. An empty array + * `[]` matches nothing — callers that want all events must omit/null `kinds`. + */ +export async function readArchivedEvents( + scopeType: ScopeType, + scopeValue: string, + opts?: { + kinds?: number[] | null; + before?: { createdAt: number; id: string } | null; + limit?: number; + }, +): Promise { + const rawRows = await invokeTauri("read_archived_events", { + scopeType, + scopeValue, + kinds: opts?.kinds ?? null, + beforeCreatedAt: opts?.before?.createdAt ?? null, + beforeId: opts?.before?.id ?? null, + limit: opts?.limit ?? null, + }); + return rawRows + .map((raw) => { + try { + return JSON.parse(raw) as import("@/shared/api/types").RelayEvent; + } catch { + console.warn("[tauriArchive] failed to parse archived raw_json:", raw); + return null; + } + }) + .filter((e): e is import("@/shared/api/types").RelayEvent => e !== null); +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 05b2ef882..1eb66c48f 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -156,6 +156,14 @@ type E2eConfig = { meshReporterPubkey?: string; uploadDelayMs?: number; uploadDescriptors?: RawBlobDescriptor[]; + // Seed rows returned by `list_save_subscriptions`. Each entry uses the same + // snake_case wire shape the Rust backend returns so tests can drive the + // LocalArchiveSettingsCard without a real SQLite database. + saveSubscriptions?: Array<{ + scope_type: string; + scope_value: string; + kinds: string; // JSON-encoded integer array, e.g. "[9,40002]" + }>; }; relayHttpUrl?: string; relayWsUrl?: string; @@ -8474,6 +8482,32 @@ export function maybeInstallE2eTauriMocks() { // Return the no-canvas success shape — content null means no canvas set. return { content: null, updated_at: null, author: null }; } + // ── Local-save archive ────────────────────────────────────────────── + // These stubs drive the LocalArchiveSettingsCard in screenshot / UI tests + // without requiring a real SQLite backend. `activeConfig.mock.saveSubscriptions` + // seeds the initial list; create/delete return success shapes so the + // component's reload path behaves correctly. + case "list_save_subscriptions": { + const ident = activeConfig?.identity ?? DEFAULT_MOCK_IDENTITY; + return (activeConfig?.mock?.saveSubscriptions ?? []).map((s) => ({ + identity_pubkey: ident.pubkey, + relay_url: DEFAULT_RELAY_WS_URL, + scope_type: s.scope_type, + scope_value: s.scope_value, + kinds: s.kinds, + created_at: Math.floor(Date.now() / 1000), + })); + } + case "create_save_subscription": + // UI calls this then re-fetches via list_save_subscriptions; returning + // null (Rust Ok(())) is sufficient to let the component proceed. + return null; + case "delete_save_subscription": + // Returns true == row removed; mirrors Rust success path. + return true; + case "archive_events": + // Returns the ArchiveBatchResult shape the UI expects. + return { persisted: 0, dropped: 0 }; default: throw new Error(`Unsupported mocked Tauri command: ${command}`); } diff --git a/desktop/tests/e2e/local-archive-screenshots.spec.ts b/desktop/tests/e2e/local-archive-screenshots.spec.ts new file mode 100644 index 000000000..d1c9c0dba --- /dev/null +++ b/desktop/tests/e2e/local-archive-screenshots.spec.ts @@ -0,0 +1,163 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +const SHOTS = "test-results/local-archive"; + +// Well-known channel IDs from the mock bridge seed (e2eBridge.ts mockChannels). +const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; + +// Navigate to the Local Archive settings panel. +async function openLocalArchiveSettings(page: import("@playwright/test").Page) { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.getByTestId("open-settings").click(); + await page.getByTestId("profile-popover-settings").click(); + await expect(page.getByTestId("settings-view")).toBeVisible(); + await page.getByTestId("settings-nav-local-archive").click(); + const card = page.getByTestId("settings-local-archive"); + await expect(card).toBeVisible({ timeout: 10_000 }); + return card; +} + +async function settleAnimations(el: import("@playwright/test").Locator) { + await el.evaluate((node) => + Promise.all(node.getAnimations({ subtree: true }).map((a) => a.finished)), + ); +} + +test.describe("local archive screenshots", () => { + test.use({ viewport: { width: 1280, height: 900 } }); + + test.beforeEach(async ({ page }) => { + page.on("pageerror", (err) => { + console.error( + "PAGE ERROR:", + err.message, + err.stack?.split("\n").slice(0, 5).join("\n"), + ); + }); + page.on("console", (msg) => { + if (msg.type() === "error") { + console.error("CONSOLE ERROR:", msg.text().slice(0, 500)); + } + }); + }); + + test("01 — subscriptions list with two active entries", async ({ page }) => { + await installMockBridge(page, { + saveSubscriptions: [ + { + scope_type: "channel_h", + scope_value: GENERAL_CHANNEL_ID, + kinds: "[9,40002,40003]", + }, + { + scope_type: "owner_p", + scope_value: "deadbeef".repeat(8), + kinds: "[24200]", + }, + ], + }); + + const card = await openLocalArchiveSettings(page); + + // Wait for both subscription rows to appear. + await expect( + card.getByTestId(`local-archive-sub-channel_h:${GENERAL_CHANNEL_ID}`), + ).toBeVisible({ timeout: 5_000 }); + await expect( + card.getByTestId(`local-archive-sub-owner_p:${"deadbeef".repeat(8)}`), + ).toBeVisible({ timeout: 5_000 }); + await settleAnimations(card); + await card.screenshot({ path: `${SHOTS}/01-subscriptions-list.png` }); + }); + + test("02 — Step 1 source picker (Channel vs My agents observer feed)", async ({ + page, + }) => { + await installMockBridge(page, { saveSubscriptions: [] }); + + const card = await openLocalArchiveSettings(page); + + // Open the Add form — the source picker is Step 1. + await card.getByTestId("local-archive-open-add").click(); + await expect(card.getByTestId("local-archive-add-channel")).toBeVisible({ + timeout: 5_000, + }); + await settleAnimations(card); + await card.screenshot({ path: `${SHOTS}/02-step1-source-picker.png` }); + }); + + test("03 — Step 2 kind checklist with indeterminate group header", async ({ + page, + }) => { + await installMockBridge(page, { saveSubscriptions: [] }); + + const card = await openLocalArchiveSettings(page); + + // Navigate to Step 2 via the Channel source path. + await card.getByTestId("local-archive-open-add").click(); + await card.getByTestId("local-archive-add-channel").click(); + + // Step 2 should be visible now. Select a channel so the form becomes valid. + await card + .getByTestId("local-archive-channel-select") + .selectOption({ value: GENERAL_CHANNEL_ID }); + + // Check a subset of the first group's items to trigger the indeterminate + // state on the group header checkbox. + const firstGroupItems = card + .locator("[data-testid^='local-archive-kind-']") + .first(); + await firstGroupItems.click(); + + await settleAnimations(card); + await card.screenshot({ + path: `${SHOTS}/03-step2-kind-checklist-indeterminate.png`, + }); + }); + + test("04 — custom kinds entry with invalid-token error", async ({ page }) => { + await installMockBridge(page, { saveSubscriptions: [] }); + + const card = await openLocalArchiveSettings(page); + + await card.getByTestId("local-archive-open-add").click(); + await card.getByTestId("local-archive-add-channel").click(); + + // Type invalid tokens into the custom kinds field. + await card + .getByTestId("local-archive-custom-kinds") + .fill("30023 bad-token 1337 notanumber"); + + // Error message should appear. + await expect( + card.getByTestId("local-archive-custom-kinds-error"), + ).toBeVisible({ timeout: 5_000 }); + await settleAnimations(card); + await card.screenshot({ + path: `${SHOTS}/04-custom-kinds-invalid-error.png`, + }); + }); + + test("05 — observer feed fixed-[24200] step (owner_p source)", async ({ + page, + }) => { + await installMockBridge(page, { saveSubscriptions: [] }); + + const card = await openLocalArchiveSettings(page); + + await card.getByTestId("local-archive-open-add").click(); + // Click "Add" for the observer feed source. + await card.getByTestId("local-archive-add-owner").click(); + + // Step 2 for owner_p: shows the informational fixed-[24200] message. + await expect(card.getByText(/observer frames/)).toBeVisible({ + timeout: 5_000, + }); + await settleAnimations(card); + await card.screenshot({ + path: `${SHOTS}/05-observer-fixed-24200.png`, + }); + }); +}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index c595f8485..628dfe33f 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -187,6 +187,16 @@ type MockBridgeOptions = { image?: string; filename?: string; }[]; + /** + * Seed rows returned by the mocked `list_save_subscriptions` command. + * Drives the LocalArchiveSettingsCard subscription list view in screenshot + * and UI tests without a real SQLite backend. + */ + saveSubscriptions?: Array<{ + scope_type: string; + scope_value: string; + kinds: string; // JSON-encoded integer array, e.g. "[9,40002]" + }>; }; type BridgeOptions = {