app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit eed2d2ddbdb626781cfcfe690016fdf2bbedd11e
parent 7a93c82c8c69aaab94c44d96e9e5348b51a764b0
Author: triesap <triesap@radroots.dev>
Date:   Mon, 19 Jan 2026 23:35:34 +0000

app: add log entry load and dump helpers

- add log entry prefix and datastore load helper

- add jsonl dump helper and retention pruning

- add log retention constant for entry cap

- add unit tests for load dump and prune helpers

Diffstat:
Mapp/src/lib.rs | 5+++++
Mapp/src/logging.rs | 286+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 291 insertions(+), 0 deletions(-)

diff --git a/app/src/lib.rs b/app/src/lib.rs @@ -53,9 +53,13 @@ pub use logging::{ app_log_entry_store, app_log_buffer_drain, app_log_buffer_push, + app_log_entries_dump, + app_log_entries_load, + app_log_entries_prune, app_log_error_emit, app_log_error_store, app_log_entry_key, + app_log_entry_prefix, app_log_debug_emit, app_log_info_emit, app_log_metadata, @@ -71,6 +75,7 @@ pub use logging::{ AppLoggingError, AppLoggingResult, APP_LOG_BUFFER_MAX_ENTRIES, + APP_LOG_MAX_ENTRIES, }; pub use notifications::{AppNotifications, AppNotificationsError, AppNotificationsResult}; pub use tangle::{AppTangleClient, AppTangleClientStub, AppTangleError, AppTangleResult}; diff --git a/app/src/logging.rs b/app/src/logging.rs @@ -56,6 +56,7 @@ static LOG_META: OnceLock<AppLogMetadata> = OnceLock::new(); static LOG_BUFFER: OnceLock<Mutex<Vec<AppLogEntry>>> = OnceLock::new(); pub const APP_LOG_BUFFER_MAX_ENTRIES: usize = 512; +pub const APP_LOG_MAX_ENTRIES: usize = 2000; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum AppLogLevel { @@ -328,6 +329,66 @@ pub fn app_log_buffer_drain() -> Vec<AppLogEntry> { entries.drain(..).collect() } +pub fn app_log_entry_prefix(key_maps: &AppKeyMapConfig) -> AppLogResult<String> { + let param = app_datastore_param_key(key_maps, "log_entry")?; + Ok(param("")) +} + +pub async fn app_log_entries_load<T: RadrootsClientDatastore>( + datastore: &T, + key_maps: &AppKeyMapConfig, +) -> AppLogResult<Vec<AppLogEntry>> { + let entries = datastore.entries().await.map_err(AppLogError::Datastore)?; + let prefix = app_log_entry_prefix(key_maps)?; + let mut out = Vec::new(); + for entry in entries { + if !entry.key.starts_with(&prefix) { + continue; + } + let Some(value) = entry.value else { + continue; + }; + if let Ok(parsed) = serde_json::from_str::<AppLogEntry>(&value) { + out.push(parsed); + } + } + Ok(out) +} + +pub fn app_log_entries_dump(entries: &[AppLogEntry]) -> String { + let mut out = String::new(); + for (idx, entry) in entries.iter().enumerate() { + if idx > 0 { + out.push('\n'); + } + match serde_json::to_string(entry) { + Ok(line) => out.push_str(&line), + Err(_) => out.push_str("{\"error\":\"log_entry_encode_failed\"}"), + } + } + out +} + +pub async fn app_log_entries_prune<T: RadrootsClientDatastore>( + datastore: &T, + key_maps: &AppKeyMapConfig, + max_entries: usize, +) -> AppLogResult<usize> { + let mut entries = app_log_entries_load(datastore, key_maps).await?; + if entries.len() <= max_entries { + return Ok(0); + } + entries.sort_by_key(|entry| entry.timestamp_ms); + let prune_count = entries.len().saturating_sub(max_entries); + let mut removed = 0; + for entry in entries.into_iter().take(prune_count) { + let key = app_log_entry_key(key_maps, &entry.id)?; + let _ = datastore.del(&key).await.map_err(AppLogError::Datastore)?; + removed += 1; + } + Ok(removed) +} + #[derive(Debug)] pub enum AppLoggingError { Logging(radroots_log::Error), @@ -381,14 +442,19 @@ pub fn app_logging_init(meta: Option<AppLogMetadata>) -> AppLoggingResult<()> { #[cfg(test)] mod tests { use super::{ + app_log_entries_dump, + app_log_entries_load, + app_log_entries_prune, app_log_entry_error, app_log_entry_new, app_log_entry_key, + app_log_entry_prefix, app_log_buffer_drain, app_log_buffer_push, app_log_metadata, app_log_timestamp_ms, AppLogLevel, + AppLogEntry, AppLogMetadata, }; use crate::{ @@ -396,6 +462,146 @@ mod tests { AppConfigError, APP_DATASTORE_KEY_LOG_ENTRY, }; + use async_trait::async_trait; + use radroots_app_core::backup::RadrootsClientBackupDatastorePayload; + use radroots_app_core::datastore::{ + RadrootsClientDatastore, + RadrootsClientDatastoreEntries, + RadrootsClientDatastoreEntry, + RadrootsClientDatastoreError, + RadrootsClientDatastoreResult, + }; + use radroots_app_core::idb::{RadrootsClientIdbConfig, IDB_CONFIG_DATASTORE}; + use serde::{de::DeserializeOwned, Serialize}; + use std::sync::Mutex; + + struct TestDatastore { + entries: Mutex<Vec<RadrootsClientDatastoreEntry>>, + } + + impl TestDatastore { + fn new(entries: Vec<RadrootsClientDatastoreEntry>) -> Self { + Self { + entries: Mutex::new(entries), + } + } + + fn len(&self) -> usize { + self.entries.lock().unwrap_or_else(|err| err.into_inner()).len() + } + } + + #[async_trait(?Send)] + impl RadrootsClientDatastore for TestDatastore { + fn get_config(&self) -> RadrootsClientIdbConfig { + IDB_CONFIG_DATASTORE + } + + fn get_store_id(&self) -> &str { + "test" + } + + async fn init(&self) -> RadrootsClientDatastoreResult<()> { + Ok(()) + } + + async fn set(&self, _key: &str, _value: &str) -> RadrootsClientDatastoreResult<String> { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn get(&self, _key: &str) -> RadrootsClientDatastoreResult<String> { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn set_obj<T>( + &self, + _key: &str, + _value: &T, + ) -> RadrootsClientDatastoreResult<T> + where + T: Serialize + DeserializeOwned + Clone, + { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn update_obj<T>( + &self, + _key: &str, + _value: &T, + ) -> RadrootsClientDatastoreResult<T> + where + T: Serialize + DeserializeOwned + Clone, + { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn get_obj<T>(&self, _key: &str) -> RadrootsClientDatastoreResult<T> + where + T: DeserializeOwned, + { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn del_obj(&self, _key: &str) -> RadrootsClientDatastoreResult<String> { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn del(&self, key: &str) -> RadrootsClientDatastoreResult<String> { + let mut entries = self.entries.lock().unwrap_or_else(|err| err.into_inner()); + entries.retain(|entry| entry.key != key); + Ok(key.to_string()) + } + + async fn del_pref(&self, _key_prefix: &str) -> RadrootsClientDatastoreResult<Vec<String>> { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn set_param( + &self, + _key: &str, + _key_param: &str, + _value: &str, + ) -> RadrootsClientDatastoreResult<String> { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn get_param( + &self, + _key: &str, + _key_param: &str, + ) -> RadrootsClientDatastoreResult<String> { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn keys(&self) -> RadrootsClientDatastoreResult<Vec<String>> { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn entries(&self) -> RadrootsClientDatastoreResult<RadrootsClientDatastoreEntries> { + Ok(self + .entries + .lock() + .unwrap_or_else(|err| err.into_inner()) + .clone()) + } + + async fn reset(&self) -> RadrootsClientDatastoreResult<()> { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn export_backup( + &self, + ) -> RadrootsClientDatastoreResult<RadrootsClientBackupDatastorePayload> { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn import_backup( + &self, + _payload: RadrootsClientBackupDatastorePayload, + ) -> RadrootsClientDatastoreResult<()> { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + } #[test] fn log_metadata_defaults_populated() { @@ -455,4 +661,84 @@ mod tests { assert_eq!(drained[0].id, entry.id); assert!(app_log_buffer_drain().is_empty()); } + + #[test] + fn log_entry_prefix_uses_log_key() { + let key_maps = app_key_maps_default(); + let prefix = app_log_entry_prefix(&key_maps).expect("prefix"); + assert_eq!(prefix, format!("{APP_DATASTORE_KEY_LOG_ENTRY}:")); + } + + #[test] + fn log_entries_dump_serializes_jsonl() { + let entries = vec![AppLogEntry { + id: String::from("a"), + timestamp_ms: 1, + level: AppLogLevel::Info, + code: String::from("code"), + message: String::from("hello"), + context: None, + metadata: AppLogMetadata::default(), + }]; + let dump = app_log_entries_dump(&entries); + assert!(dump.contains("\"code\":\"code\"")); + assert_eq!(dump.lines().count(), 1); + } + + #[test] + fn log_entries_load_filters_by_prefix() { + let key_maps = app_key_maps_default(); + let entry = AppLogEntry { + id: String::from("a"), + timestamp_ms: 1, + level: AppLogLevel::Info, + code: String::from("code"), + message: String::from("hello"), + context: None, + metadata: AppLogMetadata::default(), + }; + let key = app_log_entry_key(&key_maps, &entry.id).expect("key"); + let entries = vec![ + RadrootsClientDatastoreEntry::new( + key, + Some(serde_json::to_string(&entry).expect("json")), + ), + RadrootsClientDatastoreEntry::new(String::from("other"), Some(String::from("{}"))), + ]; + let datastore = TestDatastore::new(entries); + let loaded = futures::executor::block_on(app_log_entries_load(&datastore, &key_maps)) + .expect("load"); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].id, "a"); + } + + #[test] + fn log_entries_prune_enforces_limit() { + let key_maps = app_key_maps_default(); + let entries = (0..3) + .map(|idx| AppLogEntry { + id: format!("id-{idx}"), + timestamp_ms: idx, + level: AppLogLevel::Info, + code: String::from("code"), + message: String::from("hello"), + context: None, + metadata: AppLogMetadata::default(), + }) + .collect::<Vec<_>>(); + let mut stored = Vec::new(); + for entry in entries { + let key = app_log_entry_key(&key_maps, &entry.id).expect("key"); + stored.push(RadrootsClientDatastoreEntry::new( + key, + Some(serde_json::to_string(&entry).expect("json")), + )); + } + let datastore = TestDatastore::new(stored); + let removed = + futures::executor::block_on(app_log_entries_prune(&datastore, &key_maps, 2)) + .expect("prune"); + assert_eq!(removed, 1); + assert_eq!(datastore.len(), 2); + } }