// file: kb_app_demo/src/demo_backfill.rs // version: 9 //! Tauri commands and UI payloads for bounded HTTP transaction backfills. use tauri::Emitter; // rust-rules: trait-import use ts_rs::TS; // rust-rules: derive-import /// One endpoint role capable of discovering and hydrating transaction history. #[derive(Clone, Debug, serde::Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts( export, export_to = "../frontend/ts/bindings/kb_app_demo/demo_backfill/DemoBackfillRoleOption.ts" )] pub(crate) struct DemoBackfillRoleOption { /// Stable endpoint role code. pub(crate) role: std::string::String, /// Providers exposing this role. pub(crate) providers: std::vec::Vec, } /// Initial options shown by the backfill demo. #[derive(Clone, Debug, serde::Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts( export, export_to = "../frontend/ts/bindings/kb_app_demo/demo_backfill/DemoBackfillOptionsPayload.ts" )] pub(crate) struct DemoBackfillOptionsPayload { /// Selectable endpoint roles. pub(crate) roles: std::vec::Vec, /// Preferred role when configured. pub(crate) default_role: std::option::Option, /// Default transaction commitment. pub(crate) default_commitment: std::string::String, /// Default history page size. pub(crate) default_page_size: u16, /// Default maximum number of history pages. pub(crate) default_max_pages: u32, /// Default hydration concurrency requested by the operator. pub(crate) default_max_concurrent_requests: u32, /// Default retries after the initial attempt. pub(crate) default_max_retries: u32, /// Whether one campaign is currently running. pub(crate) running: bool, } /// UI request for one bounded backfill campaign. #[derive(Clone, Debug, serde::Deserialize, TS)] #[serde(rename_all = "camelCase")] #[ts( export, export_to = "../frontend/ts/bindings/kb_app_demo/demo_backfill/DemoBackfillRequest.ts" )] pub(crate) struct DemoBackfillRequest { /// Endpoint role used for history and transaction requests. pub(crate) role: std::string::String, /// Commitment used by standard Solana HTTP requests. pub(crate) commitment: std::string::String, /// Source mode: explicit_signatures, program, token or pool. pub(crate) mode: std::string::String, /// Newline-separated signatures for explicit mode. pub(crate) signatures_text: std::option::Option, /// Program, token mint or pool address for address history modes. pub(crate) address: std::option::Option, /// Optional anchor transaction signature. It is required only for newer-history scans. pub(crate) anchor_signature: std::option::Option, /// Direction relative to the anchor, or from the latest entry when `before` has no anchor. pub(crate) direction: std::option::Option, /// Maximum number of address-history signatures to hydrate. pub(crate) limit: u32, /// Maximum RPC page size. pub(crate) page_size: u16, /// Maximum number of history pages inspected. pub(crate) max_pages: u32, /// Operator concurrency cap. pub(crate) max_concurrent_requests: u32, /// Retries after the initial HTTP attempt. pub(crate) max_retries: u32, } /// One progress event emitted to the backfill window. #[derive(Clone, Debug, serde::Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts( export, export_to = "../frontend/ts/bindings/kb_app_demo/demo_backfill/DemoBackfillProgressPayload.ts" )] pub(crate) struct DemoBackfillProgressPayload { /// RFC 3339 timestamp. pub(crate) timestamp: std::string::String, /// Stable severity code. pub(crate) level: std::string::String, /// Human-readable message. pub(crate) message: std::string::String, /// Completed candidate count when known. #[ts(type = "number | null")] pub(crate) completed: std::option::Option, /// Total candidate count when known. #[ts(type = "number | null")] pub(crate) total: std::option::Option, } /// Final UI-safe summary for one backfill campaign. #[derive(Clone, Debug, serde::Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts( export, export_to = "../frontend/ts/bindings/kb_app_demo/demo_backfill/DemoBackfillSummaryPayload.ts" )] pub(crate) struct DemoBackfillSummaryPayload { /// Unique capture session identifier. pub(crate) capture_session_id: std::string::String, /// Stable filter code. pub(crate) filter_code: std::string::String, /// Endpoint role. pub(crate) role: std::string::String, /// Provider used for hydration. pub(crate) provider: std::string::String, /// Endpoint code used for hydration. pub(crate) endpoint_code: std::string::String, /// Number of history pages fetched. #[ts(type = "number")] pub(crate) pages_fetched: u64, /// Number of unique candidates selected. #[ts(type = "number")] pub(crate) candidates_selected: u64, /// Number of candidates admitted to the bounded execution queue. #[ts(type = "number")] pub(crate) candidates_started: u64, /// Number of candidates that reached a terminal outcome. #[ts(type = "number")] pub(crate) candidates_completed: u64, /// Number of admitted candidates interrupted before a terminal outcome. #[ts(type = "number")] pub(crate) candidates_cancelled: u64, /// Number of selected candidates never admitted after cancellation. #[ts(type = "number")] pub(crate) candidates_not_started: u64, /// Number of transactions received and normalized. #[ts(type = "number")] pub(crate) transactions_received: u64, /// Number of canonical rows inserted. #[ts(type = "number")] pub(crate) canonical_inserted: u64, /// Number of canonical inserts skipped by idempotence. #[ts(type = "number")] pub(crate) canonical_skipped: u64, /// Number of signatures skipped before hydration because they already existed. #[ts(type = "number")] pub(crate) existing_skipped: u64, /// Number of missing transactions after retries. #[ts(type = "number")] pub(crate) missing: u64, /// Number of failed candidates. #[ts(type = "number")] pub(crate) failed: u64, /// Number of observation rows inserted. #[ts(type = "number")] pub(crate) observations_inserted: u64, /// Number of source attempts. #[ts(type = "number")] pub(crate) attempts: u64, /// Whether the operator cancelled the campaign. pub(crate) cancelled: bool, /// Optional cursor for continuing an older-history scan. pub(crate) resume_before_signature: std::option::Option, /// Campaign start time. pub(crate) started_at: std::string::String, /// Campaign completion time. pub(crate) finished_at: std::string::String, } pub(crate) struct DemoBackfillObserver<'a> { pub(crate) app_handle: tauri::AppHandle, pub(crate) cancel_requested: &'a std::sync::atomic::AtomicBool, } impl kb_pipeline::BackfillObserver for crate::DemoBackfillObserver<'_> { fn on_progress(&self, event: &kb_pipeline::BackfillProgressEvent) { let payload = crate::DemoBackfillProgressPayload { timestamp: event.timestamp.clone(), level: event.level.code().to_string(), message: event.message.clone(), completed: event.completed, total: event.total, }; let emit_result = self.app_handle.emit_to("demo_backfill", "demo-backfill-progress", payload); if let std::result::Result::Err(error) = emit_result { tracing::warn!( target: crate::TRACING_TARGET, "cannot emit backfill progress: {error}" ); } } fn is_cancelled(&self) -> bool { return self.cancel_requested.load(std::sync::atomic::Ordering::Relaxed); } } pub(crate) struct DemoBackfillRunGuard<'a> { pub(crate) running: &'a std::sync::atomic::AtomicBool, } impl std::ops::Drop for crate::DemoBackfillRunGuard<'_> { fn drop(&mut self) { self.running.store(false, std::sync::atomic::Ordering::Release); } } pub(crate) fn build_role_options( snapshots: std::vec::Vec, ) -> std::vec::Vec { let mut role_methods = std::collections::BTreeMap::< std::string::String, ( std::collections::BTreeSet, std::collections::BTreeSet, ), >::new(); for snapshot in snapshots { for role in snapshot.roles { if !role.enabled { continue; } let entry = role_methods.entry(role.role).or_default(); for request_kind in role.request_kinds { entry.0.insert(request_kind); } entry.1.insert(snapshot.provider.clone()); } } let mut output = std::vec::Vec::new(); for (role, (request_kinds, providers)) in role_methods { let supports_all = request_kinds.contains("*"); if !supports_all && (!request_kinds.contains("get_signatures_for_address") || !request_kinds.contains("get_transaction")) { continue; } output.push(DemoBackfillRoleOption { role, providers: providers.into_iter().collect(), }); } return output; } pub(crate) fn build_demo_backfill_pipeline_request( request: DemoBackfillRequest, ) -> std::result::Result { let source_result = match request.mode.trim() { "explicit_signatures" => explicit_source(request.signatures_text.as_deref()), "program" => address_source( kb_pipeline::BackfillAddressKind::Program, request.address.as_deref(), request.anchor_signature.as_deref(), request.direction.as_deref(), request.limit, ), "token" => address_source( kb_pipeline::BackfillAddressKind::Token, request.address.as_deref(), request.anchor_signature.as_deref(), request.direction.as_deref(), request.limit, ), "pool" => address_source( kb_pipeline::BackfillAddressKind::Pool, request.address.as_deref(), request.anchor_signature.as_deref(), request.direction.as_deref(), request.limit, ), _ => std::result::Result::Err("unsupported backfill mode".to_string()), }; let source = match source_result { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let pipeline_request = kb_pipeline::BackfillRequest { role: request.role, commitment: request.commitment, source, page_size: request.page_size, max_pages: request.max_pages, max_concurrent_requests: request.max_concurrent_requests, max_retries: request.max_retries, }; let validation_result = pipeline_request.validate(); if let std::result::Result::Err(error) = validation_result { return std::result::Result::Err(error.to_string()); } return std::result::Result::Ok(pipeline_request); } fn explicit_source( signatures_text: std::option::Option<&str>, ) -> std::result::Result { let text = match signatures_text { std::option::Option::Some(value) => value, std::option::Option::None => "", }; let signatures = text .lines() .map(str::trim) .filter(|value| return !value.is_empty()) .map(str::to_string) .collect::>(); if signatures.is_empty() { return std::result::Result::Err( "the signatures textarea must contain at least one signature".to_string(), ); } return std::result::Result::Ok(kb_pipeline::BackfillSource::ExplicitSignatures(signatures)); } fn address_source( kind: kb_pipeline::BackfillAddressKind, address: std::option::Option<&str>, anchor_signature: std::option::Option<&str>, direction: std::option::Option<&str>, limit: u32, ) -> std::result::Result { let address_text = match address { std::option::Option::Some(value) => value, std::option::Option::None => "", }; let address_value = address_text.trim(); if address_value.is_empty() { return std::result::Result::Err("address is required".to_string()); } let direction_text = match direction { std::option::Option::Some(value) => value, std::option::Option::None => "", }; let direction_value = match direction_text.trim() { "before" => kb_pipeline::BackfillDirection::Before, "after" => kb_pipeline::BackfillDirection::After, _ => { return std::result::Result::Err("direction must be before or after".to_string()); }, }; let anchor_value = anchor_signature .map(str::trim) .filter(|value| return !value.is_empty()) .map(str::to_string); if direction_value == kb_pipeline::BackfillDirection::After && anchor_value.is_none() { return std::result::Result::Err( "anchor signature is required for newer-history backfill".to_string(), ); } let limit_value = match usize::try_from(limit) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => { return std::result::Result::Err(format!("signature limit conversion failed: {error}")); }, }; return std::result::Result::Ok(kb_pipeline::BackfillSource::AddressHistory { kind, address: address_value.to_string(), anchor_signature: anchor_value, direction: direction_value, limit: limit_value, }); } pub(crate) fn demo_backfill_summary_payload( summary: kb_pipeline::BackfillSummary, ) -> DemoBackfillSummaryPayload { return crate::DemoBackfillSummaryPayload { capture_session_id: summary.capture_session_id, filter_code: summary.filter_code, role: summary.role, provider: summary.provider, endpoint_code: summary.endpoint_code, pages_fetched: summary.pages_fetched, candidates_selected: summary.candidates_selected, candidates_started: summary.candidates_started, candidates_completed: summary.candidates_completed, candidates_cancelled: summary.candidates_cancelled, candidates_not_started: summary.candidates_not_started, transactions_received: summary.transactions_received, canonical_inserted: summary.canonical_inserted, canonical_skipped: summary.canonical_skipped, existing_skipped: summary.existing_skipped, missing: summary.missing, failed: summary.failed, observations_inserted: summary.observations_inserted, attempts: summary.attempts, cancelled: summary.cancelled, resume_before_signature: summary.resume_before_signature, started_at: summary.started_at, finished_at: summary.finished_at, }; } #[cfg(test)] mod tests { #[test] fn explicit_source_splits_lines_and_ignores_empty_rows() { let result = super::explicit_source(std::option::Option::Some(" first \n\n second\n")); let source = match result { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => panic!("source failed: {error}"), }; assert_eq!( source, kb_pipeline::BackfillSource::ExplicitSignatures(std::vec![ "first".to_string(), "second".to_string(), ]) ); } #[test] fn older_history_accepts_missing_anchor_and_uses_latest_filter() { let source_result = super::address_source( kb_pipeline::BackfillAddressKind::Program, std::option::Option::Some("11111111111111111111111111111111"), std::option::Option::Some(" "), std::option::Option::Some("before"), 100, ); let source = match source_result { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => panic!("source failed: {error}"), }; let request = kb_pipeline::BackfillRequest { role: "history_backfill".to_string(), commitment: "confirmed".to_string(), source, page_size: 100, max_pages: 1, max_concurrent_requests: 1, max_retries: 0, }; assert!(request.validate().is_ok()); assert_eq!(request.filter_code(), "program_latest"); } #[test] fn newer_history_rejects_missing_anchor() { let source_result = super::address_source( kb_pipeline::BackfillAddressKind::Program, std::option::Option::Some("11111111111111111111111111111111"), std::option::Option::None, std::option::Option::Some("after"), 100, ); assert!(source_result.is_err()); } }