lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

commit 27344b0843f4166b78ca2cb631568dbd1a500a9b
parent 4b0d109eb681a27b566e2c8d0454052c99979aa3
Author: triesap <tyson@radroots.org>
Date:   Sun, 28 Dec 2025 17:01:19 +0000

tangle-db-wasm: add locked export snapshot flow


- Add wasm export lock with bypass guard to prevent concurrent writes
- Expose raw db byte export via `radroots-sql-wasm-bridge`/sql-wasm-core bindings
- Implement manifest-based snapshot export with schema hash and table counts
- Add wasm-bindgen-test coverage for snapshot JS object shape

Diffstat:
MCargo.lock | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCargo.toml | 1+
Msql-core/src/executor_wasm.rs | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msql-core/src/lib.rs | 2++
Msql-wasm-bridge/src/lib.rs | 7+++++++
Msql-wasm-core/src/lib.rs | 5+++++
Mtangle-db-wasm/Cargo.toml | 5+++++
Mtangle-db-wasm/src/lib.rs | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mtangle-db/Cargo.toml | 2++
Mtangle-db/src/backup.rs | 24++++++++++++++----------
Atangle-db/src/export.rs | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtangle-db/src/lib.rs | 2++
Mtangle-events/src/emit.rs | 4+++-
Atangle-events/src/event_state.rs | 33+++++++++++++++++++++++++++++++++
Mtangle-events/src/geo.rs | 4+++-
Mtangle-events/src/ingest.rs | 21+++++----------------
Mtangle-events/src/lib.rs | 3+++
Atangle-events/src/sync_state.rs | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
18 files changed, 445 insertions(+), 33 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1302,6 +1302,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" [[package]] +name = "minicov" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" +dependencies = [ + "cc", + "walkdir", +] + +[[package]] name = "minimal-lexical" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1876,11 +1886,13 @@ dependencies = [ name = "radroots-tangle-db" version = "0.1.0" dependencies = [ + "hex", "radroots-sql-core", "radroots-tangle-db-schema", "radroots-types", "serde", "serde_json", + "sha2", ] [[package]] @@ -1897,14 +1909,17 @@ dependencies = [ name = "radroots-tangle-db-wasm" version = "0.1.0" dependencies = [ + "js-sys", "radroots-sql-core", "radroots-sql-wasm-core", "radroots-tangle-db", "radroots-tangle-db-schema", + "radroots-tangle-events", "serde", "serde-wasm-bindgen", "serde_json", "wasm-bindgen", + "wasm-bindgen-test", ] [[package]] @@ -2244,6 +2259,15 @@ dependencies = [ ] [[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] name = "scrypt" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3014,6 +3038,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] name = "want" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3109,6 +3143,30 @@ dependencies = [ ] [[package]] +name = "wasm-bindgen-test" +version = "0.3.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66c8d5e33ca3b6d9fa3b4676d774c5778031d27a578c2b007f905acf816152c3" +dependencies = [ + "js-sys", + "minicov", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17d5042cc5fa009658f9a7333ef24291b1291a25b6382dd68862a7f3b969f69b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] name = "web-sys" version = "0.3.77" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml @@ -90,4 +90,5 @@ uuid = { version = "1.16.0", features = ["v4", "v7"] } uniffi = { version = "0.29.4" } wasm-bindgen = { version = "0.2" } wasm-bindgen-futures = { version = "0.4" } +wasm-bindgen-test = { version = "0.3" } rusqlite = { version = "0.31", default-features = false } diff --git a/sql-core/src/executor_wasm.rs b/sql-core/src/executor_wasm.rs @@ -1,4 +1,46 @@ use crate::{ExecOutcome, SqlExecutor, error::SqlError}; +use std::cell::Cell; +use std::sync::atomic::{AtomicBool, Ordering}; + +const EXPORT_LOCK_ERR: &str = "tangle db export in progress"; + +static EXPORT_LOCK_ACTIVE: AtomicBool = AtomicBool::new(false); + +thread_local! { + static EXPORT_LOCK_BYPASS: Cell<bool> = Cell::new(false); +} + +pub fn export_lock_begin() -> Result<(), SqlError> { + let was_active = EXPORT_LOCK_ACTIVE.swap(true, Ordering::SeqCst); + if was_active { + return Err(SqlError::InvalidArgument(EXPORT_LOCK_ERR.to_string())); + } + Ok(()) +} + +pub fn export_lock_end() { + EXPORT_LOCK_ACTIVE.store(false, Ordering::SeqCst); +} + +pub fn export_lock_active() -> bool { + EXPORT_LOCK_ACTIVE.load(Ordering::SeqCst) +} + +pub fn with_export_lock_bypass<T>(f: impl FnOnce() -> T) -> T { + EXPORT_LOCK_BYPASS.with(|flag| { + let prev = flag.replace(true); + let out = f(); + flag.set(prev); + out + }) +} + +fn export_lock_blocked() -> bool { + if !EXPORT_LOCK_ACTIVE.load(Ordering::SeqCst) { + return false; + } + EXPORT_LOCK_BYPASS.with(|flag| !flag.get()) +} pub struct WasmSqlExecutor; @@ -16,6 +58,9 @@ impl Default for WasmSqlExecutor { impl SqlExecutor for WasmSqlExecutor { fn exec(&self, sql: &str, params_json: &str) -> Result<ExecOutcome, SqlError> { + if export_lock_blocked() { + return Err(SqlError::InvalidArgument(EXPORT_LOCK_ERR.to_string())); + } let js = radroots_sql_wasm_bridge::exec(sql, params_json); let v: serde_json::Value = serde_wasm_bindgen::from_value(js) .map_err(|e| SqlError::SerializationError(e.to_string()))?; @@ -39,16 +84,25 @@ impl SqlExecutor for WasmSqlExecutor { } fn begin(&self) -> Result<(), SqlError> { + if export_lock_blocked() { + return Err(SqlError::InvalidArgument(EXPORT_LOCK_ERR.to_string())); + } radroots_sql_wasm_bridge::begin_tx(); Ok(()) } fn commit(&self) -> Result<(), SqlError> { + if export_lock_blocked() { + return Err(SqlError::InvalidArgument(EXPORT_LOCK_ERR.to_string())); + } radroots_sql_wasm_bridge::commit_tx(); Ok(()) } fn rollback(&self) -> Result<(), SqlError> { + if export_lock_blocked() { + return Err(SqlError::InvalidArgument(EXPORT_LOCK_ERR.to_string())); + } radroots_sql_wasm_bridge::rollback_tx(); Ok(()) } diff --git a/sql-core/src/lib.rs b/sql-core/src/lib.rs @@ -10,6 +10,8 @@ pub mod migrations; mod executor_wasm; #[cfg(all(feature = "web", target_arch = "wasm32"))] pub use executor_wasm::WasmSqlExecutor; +#[cfg(all(feature = "web", target_arch = "wasm32"))] +pub use executor_wasm::{export_lock_active, export_lock_begin, export_lock_end, with_export_lock_bypass}; #[cfg(feature = "native")] mod executor_sqlite; diff --git a/sql-wasm-bridge/src/lib.rs b/sql-wasm-bridge/src/lib.rs @@ -10,6 +10,9 @@ extern "C" { #[wasm_bindgen(js_name = __radroots_sql_wasm_query)] fn js_query(sql: &str, params_json: &str) -> JsValue; + + #[wasm_bindgen(js_name = __radroots_sql_wasm_export_bytes)] + fn js_export_bytes() -> JsValue; } const SAVEPOINT: &str = "radroots_schema_tx"; @@ -22,6 +25,10 @@ pub fn query(sql: &str, params_json: &str) -> JsValue { js_query(sql, params_json) } +pub fn export_bytes() -> JsValue { + js_export_bytes() +} + pub fn begin_tx() { let _ = js_exec(&format!("savepoint {}", SAVEPOINT), "[]"); } diff --git a/sql-wasm-core/src/lib.rs b/sql-wasm-core/src/lib.rs @@ -39,6 +39,11 @@ pub fn query_sql(sql: &str, params_json: &str) -> JsValue { } #[cfg(target_arch = "wasm32")] +pub fn export_bytes() -> JsValue { + radroots_sql_wasm_bridge::export_bytes() +} + +#[cfg(target_arch = "wasm32")] #[wasm_bindgen(js_name = begin_tx)] pub fn begin_tx() { radroots_sql_wasm_bridge::begin_tx() diff --git a/tangle-db-wasm/Cargo.toml b/tangle-db-wasm/Cargo.toml @@ -14,7 +14,12 @@ radroots-sql-core = { workspace = true, features = ["web"] } radroots-sql-wasm-core = { workspace = true } radroots-tangle-db = { workspace = true } radroots-tangle-db-schema = { workspace = true } +radroots-tangle-events = { workspace = true } +js-sys = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } serde-wasm-bindgen = { workspace = true } wasm-bindgen = { workspace = true } + +[dev-dependencies] +wasm-bindgen-test = { workspace = true } diff --git a/tangle-db-wasm/src/lib.rs b/tangle-db-wasm/src/lib.rs @@ -1,8 +1,16 @@ #![cfg(target_arch = "wasm32")] -use radroots_sql_core::WasmSqlExecutor; +use radroots_sql_core::{ + WasmSqlExecutor, + export_lock_begin, + export_lock_end, + with_export_lock_bypass, +}; use radroots_sql_wasm_core::{err_js, parse_json}; use radroots_tangle_db::migrations; +use radroots_tangle_db::{export_manifest, TangleDbExportManifestRs}; +use radroots_tangle_events::radroots_tangle_sync_status; +use wasm_bindgen::JsValue; use wasm_bindgen::prelude::*; use radroots_tangle_db_schema::farm::{ @@ -152,19 +160,103 @@ pub fn tangle_db_reset_database() -> Result<(), JsValue> { migrations::run_all_down(&exec).map_err(err_js) } -#[wasm_bindgen(js_name = tangle_db_export_backup)] -pub fn tangle_db_export_backup() -> Result<JsValue, JsValue> { +#[wasm_bindgen(js_name = tangle_db_export_json)] +pub fn tangle_db_export_json() -> Result<JsValue, JsValue> { let exec = WasmSqlExecutor::new(); let dump = radroots_tangle_db::backup::export_database_backup(&exec).map_err(err_js)?; value_to_js(dump) } -#[wasm_bindgen(js_name = tangle_db_import_backup)] -pub fn tangle_db_import_backup(dump_json: &str) -> Result<(), JsValue> { +#[wasm_bindgen(js_name = tangle_db_import_json)] +pub fn tangle_db_import_json(dump_json: &str) -> Result<(), JsValue> { let exec = WasmSqlExecutor::new(); radroots_tangle_db::backup::restore_database_backup_json(&exec, dump_json).map_err(err_js) } +#[wasm_bindgen(js_name = tangle_db_export_begin)] +pub fn tangle_db_export_begin() -> Result<JsValue, JsValue> { + export_lock_begin().map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let result = with_export_lock_bypass(|| export_snapshot(&exec)); + match result { + Ok(value) => Ok(value), + Err(err) => { + export_lock_end(); + Err(err) + } + } +} + +#[wasm_bindgen(js_name = tangle_db_export_finish)] +pub fn tangle_db_export_finish() -> Result<(), JsValue> { + export_lock_end(); + Ok(()) +} + +fn export_snapshot(exec: &WasmSqlExecutor) -> Result<JsValue, JsValue> { + let status = radroots_tangle_sync_status(exec).map_err(|err| { + err_js(radroots_sql_core::SqlError::InvalidArgument(err.to_string())) + })?; + if status.pending_count > 0 { + return Err(err_js(radroots_sql_core::SqlError::InvalidArgument( + format!( + "tangle db export requires synced state (pending {}/{})", + status.pending_count, status.expected_count + ), + ))); + } + let manifest = export_manifest(exec).map_err(err_js)?; + export_snapshot_value(manifest) +} + +fn export_snapshot_value(manifest: TangleDbExportManifestRs) -> Result<JsValue, JsValue> { + let bytes_js = radroots_sql_wasm_core::export_bytes(); + export_snapshot_value_with_bytes(manifest, bytes_js) +} + +fn export_snapshot_value_with_bytes( + manifest: TangleDbExportManifestRs, + bytes_js: JsValue, +) -> Result<JsValue, JsValue> { + let manifest_js = serde_wasm_bindgen::to_value(&manifest) + .map_err(|err| err_js(radroots_sql_core::SqlError::SerializationError(err.to_string())))?; + let obj = js_sys::Object::new(); + js_sys::Reflect::set(&obj, &JsValue::from_str("manifest_rs"), &manifest_js) + .map_err(|_| err_js(radroots_sql_core::SqlError::Internal))?; + js_sys::Reflect::set(&obj, &JsValue::from_str("db_bytes"), &bytes_js) + .map_err(|_| err_js(radroots_sql_core::SqlError::Internal))?; + Ok(JsValue::from(obj)) +} + +#[cfg(all(test, target_arch = "wasm32"))] +mod tests { + use super::export_snapshot_value_with_bytes; + use js_sys::{Reflect, Uint8Array}; + use wasm_bindgen::JsValue; + + #[wasm_bindgen_test::wasm_bindgen_test] + fn export_snapshot_value_includes_fields() { + let manifest = radroots_tangle_db::TangleDbExportManifestRs { + export_version: "1".to_string(), + tangle_db_version: "0.0.0".to_string(), + backup_format_version: "0.0.0".to_string(), + schema_hash: "hash".to_string(), + schema: Vec::new(), + migrations: Vec::new(), + table_counts: Vec::new(), + }; + let bytes = Uint8Array::new_with_length(2); + let js = export_snapshot_value_with_bytes(manifest, JsValue::from(bytes)) + .expect("snapshot"); + let manifest_rs = Reflect::get(&js, &JsValue::from_str("manifest_rs")) + .expect("manifest_rs"); + let db_bytes = Reflect::get(&js, &JsValue::from_str("db_bytes")) + .expect("db_bytes"); + assert!(manifest_rs.is_object()); + assert!(db_bytes.is_object()); + } +} + #[wasm_bindgen(js_name = tangle_db_farm_create)] pub fn tangle_db_farm_create(opts_json: &str) -> Result<JsValue, JsValue> { let opts: IFarmCreate = parse_json(opts_json).map_err(err_js)?; diff --git a/tangle-db/Cargo.toml b/tangle-db/Cargo.toml @@ -21,3 +21,5 @@ radroots-tangle-db-schema = { workspace = true } radroots-types = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +hex = { workspace = true } +sha2 = { workspace = true } diff --git a/tangle-db/src/backup.rs b/tangle-db/src/backup.rs @@ -41,14 +41,7 @@ pub struct DatabaseBackup { pub fn export_database_backup<E: SqlExecutor>(executor: &E) -> Result<DatabaseBackup, SqlError> { let schema = load_schema(executor)?; let data = read_tables_for_backup(executor, &schema)?; - let migrations = crate::migrations::MIGRATIONS - .iter() - .map(|m| MigrationBackup { - name: m.name.to_string(), - up_sql: m.up_sql.to_string(), - down_sql: m.down_sql.to_string(), - }) - .collect(); + let migrations = export_migrations(); Ok(DatabaseBackup { format_version: DATABASE_BACKUP_VERSION.to_string(), tangle_db_version: TANGLE_DB_VERSION.to_string(), @@ -214,7 +207,7 @@ fn insert_row<E: SqlExecutor>( Ok(()) } -fn load_schema<E: SqlExecutor>(executor: &E) -> Result<Vec<SchemaEntry>, SqlError> { +pub(crate) fn load_schema<E: SqlExecutor>(executor: &E) -> Result<Vec<SchemaEntry>, SqlError> { let json = executor.query_raw( "select type, name, tbl_name as table_name, sql from sqlite_master where name not like 'sqlite_%' order by type, name", "[]", @@ -243,6 +236,17 @@ fn load_schema<E: SqlExecutor>(executor: &E) -> Result<Vec<SchemaEntry>, SqlErro .collect()) } +pub(crate) fn export_migrations() -> Vec<MigrationBackup> { + crate::migrations::MIGRATIONS + .iter() + .map(|m| MigrationBackup { + name: m.name.to_string(), + up_sql: m.up_sql.to_string(), + down_sql: m.down_sql.to_string(), + }) + .collect() +} + fn read_tables_for_backup<E: SqlExecutor>( executor: &E, schema: &[SchemaEntry], @@ -260,7 +264,7 @@ fn read_tables_for_backup<E: SqlExecutor>( Ok(data) } -fn escape_identifier(name: &str) -> String { +pub(crate) fn escape_identifier(name: &str) -> String { let mut escaped = String::with_capacity(name.len() + 2); escaped.push('"'); for c in name.chars() { diff --git a/tangle-db/src/export.rs b/tangle-db/src/export.rs @@ -0,0 +1,80 @@ +use radroots_sql_core::{SqlExecutor, error::SqlError, utils}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::backup::{ + DATABASE_BACKUP_VERSION, + TANGLE_DB_VERSION, + MigrationBackup, + SchemaEntry, + escape_identifier, + export_migrations, + load_schema, +}; + +pub const TANGLE_DB_EXPORT_VERSION: &str = "1"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TableCount { + pub name: String, + pub row_count: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TangleDbExportManifestRs { + pub export_version: String, + pub tangle_db_version: String, + pub backup_format_version: String, + pub schema_hash: String, + pub schema: Vec<SchemaEntry>, + pub migrations: Vec<MigrationBackup>, + pub table_counts: Vec<TableCount>, +} + +pub fn export_manifest<E: SqlExecutor>(executor: &E) -> Result<TangleDbExportManifestRs, SqlError> { + let schema = load_schema(executor)?; + let migrations = export_migrations(); + let table_counts = load_table_counts(executor, &schema)?; + let schema_hash = schema_hash(&schema)?; + Ok(TangleDbExportManifestRs { + export_version: TANGLE_DB_EXPORT_VERSION.to_string(), + tangle_db_version: TANGLE_DB_VERSION.to_string(), + backup_format_version: DATABASE_BACKUP_VERSION.to_string(), + schema_hash, + schema, + migrations, + table_counts, + }) +} + +fn load_table_counts<E: SqlExecutor>( + executor: &E, + schema: &[SchemaEntry], +) -> Result<Vec<TableCount>, SqlError> { + #[derive(Deserialize)] + struct CountRow { + count: u64, + } + let mut counts = Vec::new(); + for entry in schema.iter().filter(|s| s.object_type == "table") { + let sql = format!( + "select count(1) as count from {}", + escape_identifier(&entry.name) + ); + let json = executor.query_raw(&sql, "[]")?; + let rows: Vec<CountRow> = utils::parse_json(&json)?; + let row_count = rows.first().map(|row| row.count).unwrap_or(0); + counts.push(TableCount { + name: entry.name.clone(), + row_count, + }); + } + Ok(counts) +} + +fn schema_hash(schema: &[SchemaEntry]) -> Result<String, SqlError> { + let json = serde_json::to_string(schema).map_err(SqlError::from)?; + let mut hasher = Sha256::new(); + hasher.update(json.as_bytes()); + Ok(hex::encode(hasher.finalize())) +} diff --git a/tangle-db/src/lib.rs b/tangle-db/src/lib.rs @@ -213,9 +213,11 @@ use radroots_tangle_db_schema::trade_product_media::{ }; pub mod backup; +pub mod export; pub mod migrations; pub mod models; pub use backup::{DatabaseBackup, MigrationBackup, SchemaEntry}; +pub use export::{TANGLE_DB_EXPORT_VERSION, TableCount, TangleDbExportManifestRs, export_manifest}; pub use models::*; pub struct TangleSql<E: SqlExecutor> { diff --git a/tangle-events/src/emit.rs b/tangle-events/src/emit.rs @@ -1,5 +1,7 @@ #[cfg(not(feature = "std"))] -use alloc::{collections::BTreeMap, format, string::{String, ToString}, vec::Vec}; +use alloc::format; +#[cfg(not(feature = "std"))] +use alloc::{collections::BTreeMap, string::{String, ToString}, vec::Vec}; #[cfg(feature = "std")] use std::collections::BTreeMap; diff --git a/tangle-events/src/event_state.rs b/tangle-events/src/event_state.rs @@ -0,0 +1,33 @@ +#[cfg(not(feature = "std"))] +use alloc::format; +#[cfg(not(feature = "std"))] +use alloc::{string::{String, ToString}, vec::Vec}; +#[cfg(feature = "std")] +use std::{string::String, vec::Vec}; + +use sha2::{Digest, Sha256}; + +use crate::error::RadrootsTangleEventsError; + +pub fn event_state_key(kind: u32, pubkey: &str, d_tag: &str) -> String { + format!("{kind}:{pubkey}:{d_tag}") +} + +pub fn event_content_hash( + content: &str, + tags: &[Vec<String>], +) -> Result<String, RadrootsTangleEventsError> { + let tags_json = serde_json::to_string(tags) + .map_err(|_| RadrootsTangleEventsError::InvalidData("tags serialization failed".to_string()))?; + let mut hasher = Sha256::new(); + hasher.update(content.as_bytes()); + hasher.update(tags_json.as_bytes()); + Ok(hex::encode(hasher.finalize())) +} + +pub fn tag_value<'a>(tags: &'a [Vec<String>], key: &str) -> Option<&'a str> { + tags.iter() + .find(|tag| tag.get(0).map(|v| v.as_str()) == Some(key)) + .and_then(|tag| tag.get(1)) + .map(|value| value.as_str()) +} diff --git a/tangle-events/src/geo.rs b/tangle-events/src/geo.rs @@ -1,5 +1,7 @@ #[cfg(not(feature = "std"))] -use alloc::{string::String, vec, vec::Vec}; +use alloc::{string::String, vec::Vec}; +#[cfg(not(feature = "std"))] +use alloc::vec; use radroots_events::farm::{RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon}; diff --git a/tangle-events/src/ingest.rs b/tangle-events/src/ingest.rs @@ -1,5 +1,7 @@ #[cfg(not(feature = "std"))] -use alloc::{format, string::{String, ToString}, vec::Vec}; +use alloc::format; +#[cfg(not(feature = "std"))] +use alloc::{string::{String, ToString}, vec::Vec}; #[cfg(feature = "std")] use base64::engine::general_purpose::URL_SAFE_NO_PAD; @@ -115,9 +117,9 @@ use radroots_tangle_db::{ plot_tag, }; use serde_json::Value; -use sha2::{Digest, Sha256}; use crate::error::RadrootsTangleEventsError; +use crate::event_state::{event_content_hash, event_state_key}; const ROLE_PRIMARY: &str = "primary"; const ROLE_MEMBER: &str = "member"; const ROLE_OWNER: &str = "owner"; @@ -527,7 +529,7 @@ fn event_state_decision<E: SqlExecutor>( d_tag: &str, ) -> Result<EventStateDecision, RadrootsTangleEventsError> { let key = event_state_key(event.kind, &event.author, d_tag); - let content_hash = hash_content(&event.content, &event.tags)?; + let content_hash = event_content_hash(&event.content, &event.tags)?; let existing = nostr_event_state::find_one( exec, &INostrEventStateFindOne::On(INostrEventStateFindOneArgs { @@ -548,19 +550,6 @@ fn event_state_decision<E: SqlExecutor>( Ok(EventStateDecision { apply: true, content_hash }) } -fn event_state_key(kind: u32, pubkey: &str, d_tag: &str) -> String { - format!("{kind}:{pubkey}:{d_tag}") -} - -fn hash_content(content: &str, tags: &[Vec<String>]) -> Result<String, RadrootsTangleEventsError> { - let tags_json = serde_json::to_string(tags) - .map_err(|_| RadrootsTangleEventsError::InvalidData("tags serialization failed".to_string()))?; - let mut hasher = Sha256::new(); - hasher.update(content.as_bytes()); - hasher.update(tags_json.as_bytes()); - Ok(hex::encode(hasher.finalize())) -} - fn find_farm_by_ref<E: SqlExecutor>( exec: &E, pubkey: &str, diff --git a/tangle-events/src/lib.rs b/tangle-events/src/lib.rs @@ -6,9 +6,11 @@ extern crate alloc; pub mod error; mod canonical; +mod event_state; mod geo; pub mod emit; pub mod ingest; +pub mod sync_state; pub mod types; pub use error::RadrootsTangleEventsError; @@ -27,6 +29,7 @@ pub use ingest::{ RadrootsTangleIngestOutcome, RadrootsTangleIdFactory, }; +pub use sync_state::{radroots_tangle_sync_status, RadrootsTangleSyncStatus}; pub use types::{ RADROOTS_TANGLE_TRANSFER_VERSION, RadrootsTangleEventDraft, diff --git a/tangle-events/src/sync_state.rs b/tangle-events/src/sync_state.rs @@ -0,0 +1,71 @@ +#[cfg(not(feature = "std"))] +use alloc::{collections::BTreeMap, string::{String, ToString}}; +#[cfg(feature = "std")] +use std::collections::BTreeMap; + +use radroots_events::kinds::is_nip51_list_set_kind; +use radroots_sql_core::SqlExecutor; +use radroots_tangle_db_schema::farm::IFarmFindMany; +use radroots_tangle_db_schema::nostr_event_state::INostrEventStateFindMany; + +use crate::error::RadrootsTangleEventsError; +use crate::event_state::{event_content_hash, event_state_key, tag_value}; +use crate::types::RadrootsTangleFarmSelector; + +#[derive(Clone, Debug)] +pub struct RadrootsTangleSyncStatus { + pub expected_count: usize, + pub pending_count: usize, +} + +pub fn radroots_tangle_sync_status<E: SqlExecutor>( + exec: &E, +) -> Result<RadrootsTangleSyncStatus, RadrootsTangleEventsError> { + let farms = radroots_tangle_db::farm::find_many(exec, &IFarmFindMany { filter: None })? + .results; + let mut expected: BTreeMap<String, String> = BTreeMap::new(); + + for farm in farms { + let selector = RadrootsTangleFarmSelector { + id: Some(farm.id), + d_tag: None, + pubkey: None, + }; + let bundle = crate::emit::radroots_tangle_sync_all_with_options(exec, &selector, None)?; + for event in bundle.events { + let d_tag = tag_value(&event.tags, "d").unwrap_or(""); + if is_nip51_list_set_kind(event.kind) && d_tag.is_empty() { + return Err(RadrootsTangleEventsError::InvalidData( + "list set d tag missing".to_string(), + )); + } + let key = event_state_key(event.kind, &event.author, d_tag); + let content_hash = event_content_hash(&event.content, &event.tags)?; + expected.entry(key).or_insert(content_hash); + } + } + + let states = radroots_tangle_db::nostr_event_state::find_many( + exec, + &INostrEventStateFindMany { filter: None }, + )? + .results; + + let mut state_map: BTreeMap<String, String> = BTreeMap::new(); + for state in states { + state_map.insert(state.key, state.content_hash); + } + + let mut pending = 0; + for (key, content_hash) in expected.iter() { + match state_map.get(key) { + Some(existing) if existing == content_hash => {} + _ => pending += 1, + } + } + + Ok(RadrootsTangleSyncStatus { + expected_count: expected.len(), + pending_count: pending, + }) +}