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:
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,
+ })
+}