tangle


git clone https://radroots.dev/git/tangle.git
Log | Files | Refs | README | LICENSE

commit f8b10791fad18e96e8a1b9bc38cb17a5f0464b65
parent 95621e2c32cbdf5daa3bab398074cef62a360561
Author: triesap <tyson@radroots.org>
Date:   Sat,  6 Jun 2026 13:05:25 -0700

ops: add backup command

- add ops backup CLI dispatch and output reporting
- write raw Nostr JSONL backups with deterministic manifests
- include SHA-256 checksums and SurrealDB export availability state
- cover backup files with runtime, store, and binary tests

Diffstat:
MCargo.lock | 1+
Mcrates/tangle/src/lib.rs | 111++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/tangle/src/main.rs | 20++++++++++++++++++++
Mcrates/tangle/tests/version.rs | 55+++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/tangle_runtime/Cargo.toml | 1+
Mcrates/tangle_runtime/src/lib.rs | 321++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mcrates/tangle_store_surreal/src/lib.rs | 17+++++++++++++++++
7 files changed, 481 insertions(+), 45 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -4181,6 +4181,7 @@ dependencies = [ "http", "serde", "serde_json", + "sha2", "tangle_core", "tangle_nips", "tangle_protocol", diff --git a/crates/tangle/src/lib.rs b/crates/tangle/src/lib.rs @@ -11,7 +11,8 @@ usage: tangle run --config PATH tangle event import --config PATH --input PATH tangle event export --config PATH --output PATH - tangle projection rebuild --config PATH"; + tangle projection rebuild --config PATH + tangle ops backup --config PATH --output DIR"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TangleCommand { @@ -22,6 +23,7 @@ pub enum TangleCommand { EventImport, EventExport, ProjectionRebuild, + OpsBackup, } impl TangleCommand { @@ -34,6 +36,7 @@ impl TangleCommand { Self::EventImport => "event import", Self::EventExport => "event export", Self::ProjectionRebuild => "projection rebuild", + Self::OpsBackup => "ops backup", } } @@ -47,6 +50,7 @@ impl TangleCommand { | Self::EventImport | Self::EventExport | Self::ProjectionRebuild + | Self::OpsBackup ) } } @@ -183,6 +187,15 @@ where } } } + "ops" => { + let Some(nested) = args.next() else { + return Err(TangleCliError::MissingNestedCommand("ops")); + }; + match nested.as_str() { + "backup" => TangleCommand::OpsBackup, + _ => return Err(TangleCliError::UnknownCommand(format!("ops {nested}"))), + } + } _ => return Err(TangleCliError::UnknownCommand(first)), }; let mut config_path = None; @@ -231,7 +244,12 @@ where argument: "--input".to_owned(), }); } - if output_path.is_some() && command != TangleCommand::EventExport { + if output_path.is_some() + && !matches!( + command, + TangleCommand::EventExport | TangleCommand::OpsBackup + ) + { return Err(TangleCliError::UnexpectedArgument { command: command.as_str().to_owned(), argument: "--output".to_owned(), @@ -294,6 +312,18 @@ pub fn projection_rebuild_output(report: tangle_runtime::RuntimeProjectionRebuil ) } +pub fn ops_backup_output(report: &tangle_runtime::RuntimeBackupReport) -> String { + format!( + "backup directory: {}\nraw events: {}\nraw events sha256: {}\nsurrealdb export available: {}\nmanifest: {}\nmanifest sha256: {}", + report.output_dir().display(), + report.raw_event_count(), + report.raw_events_sha256(), + report.surrealdb_export_available(), + report.manifest_path().display(), + report.manifest_sha256() + ) +} + pub async fn migrate_with_config(path: &str) -> Result<String, String> { let config = tangle_runtime::load_runtime_config(path).map_err(|error| error.to_string())?; initialize_tracing(config.tracing_config())?; @@ -339,6 +369,16 @@ pub async fn projection_rebuild_with_config(config_path: &str) -> Result<String, Ok(projection_rebuild_output(report)) } +pub async fn ops_backup_with_config(config_path: &str, output_dir: &str) -> Result<String, String> { + let config = + tangle_runtime::load_runtime_config(config_path).map_err(|error| error.to_string())?; + initialize_tracing(config.tracing_config())?; + let report = tangle_runtime::backup_runtime_database(&config, output_dir) + .await + .map_err(|error| error.to_string())?; + Ok(ops_backup_output(&report)) +} + pub async fn run_with_config(path: &str) -> Result<(), String> { let config = tangle_runtime::load_runtime_config(path).map_err(|error| error.to_string())?; initialize_tracing(config.tracing_config())?; @@ -389,12 +429,15 @@ mod tests { use super::{ PACKAGE_NAME, PACKAGE_VERSION, TangleCliError, TangleCommand, TangleInvocation, event_export_output, event_import_output, initialize_tracing, migrate_output, - parse_tangle_command, parse_tangle_invocation, projection_rebuild_output, - require_config_path, require_input_path, require_output_path, usage_output, version_output, + ops_backup_output, parse_tangle_command, parse_tangle_invocation, + projection_rebuild_output, require_config_path, require_input_path, require_output_path, + usage_output, version_output, }; + use std::path::PathBuf; use tangle_runtime::{ - RuntimeEventExportReport, RuntimeEventImportReport, RuntimeMigrationReport, - RuntimeProjectionRebuildReport, RuntimeTracingConfig, RuntimeTracingFormat, + RuntimeBackupReport, RuntimeEventExportReport, RuntimeEventImportReport, + RuntimeMigrationReport, RuntimeProjectionRebuildReport, RuntimeTracingConfig, + RuntimeTracingFormat, }; #[test] @@ -431,7 +474,7 @@ mod tests { fn usage_output_lists_supported_command_model() { assert_eq!( usage_output(), - "usage:\n tangle [--version]\n tangle migrate --config PATH\n tangle run --config PATH\n tangle event import --config PATH --input PATH\n tangle event export --config PATH --output PATH\n tangle projection rebuild --config PATH" + "usage:\n tangle [--version]\n tangle migrate --config PATH\n tangle run --config PATH\n tangle event import --config PATH --input PATH\n tangle event export --config PATH --output PATH\n tangle projection rebuild --config PATH\n tangle ops backup --config PATH --output DIR" ); } @@ -451,6 +494,7 @@ mod tests { vec!["projection", "rebuild"], TangleCommand::ProjectionRebuild, ), + (vec!["ops", "backup"], TangleCommand::OpsBackup), ]; for (args, expected) in cases { @@ -466,12 +510,35 @@ mod tests { | TangleCommand::EventImport | TangleCommand::EventExport | TangleCommand::ProjectionRebuild + | TangleCommand::OpsBackup ) ); } } #[test] + fn command_model_parses_ops_backup_output_option() { + let invocation = parse_tangle_invocation([ + "ops", + "backup", + "--config", + "runtime.json", + "--output", + "backup-dir", + ]) + .expect("invocation"); + assert_eq!(invocation.command(), TangleCommand::OpsBackup); + assert_eq!( + require_config_path(&invocation).expect("config"), + "runtime.json" + ); + assert_eq!( + require_output_path(&invocation).expect("output"), + "backup-dir" + ); + } + + #[test] fn command_model_parses_export_output_option() { let invocation = parse_tangle_invocation([ "event", @@ -561,6 +628,14 @@ mod tests { TangleCliError::UnknownCommand("projection bad".to_owned()) ); assert_eq!( + parse_tangle_command(["ops"]).expect_err("ops nested"), + TangleCliError::MissingNestedCommand("ops") + ); + assert_eq!( + parse_tangle_command(["ops", "bad"]).expect_err("ops bad"), + TangleCliError::UnknownCommand("ops bad".to_owned()) + ); + assert_eq!( parse_tangle_command(["run", "--extra"]).expect_err("extra"), TangleCliError::UnexpectedArgument { command: "run".to_owned(), @@ -641,4 +716,26 @@ mod tests { "events scanned: 4\nevents rebuilt: 3\nlistings projected: 2\nevents skipped: 1" ); } + + #[test] + fn ops_backup_output_reports_paths_counts_and_checksums() { + let report = RuntimeBackupReport::new( + PathBuf::from("backup"), + PathBuf::from("backup/raw-events.jsonl"), + 3, + "a".repeat(64), + PathBuf::from("backup/manifest.json"), + "b".repeat(64), + false, + ); + + assert_eq!( + ops_backup_output(&report), + format!( + "backup directory: backup\nraw events: 3\nraw events sha256: {}\nsurrealdb export available: false\nmanifest: backup/manifest.json\nmanifest sha256: {}", + "a".repeat(64), + "b".repeat(64) + ) + ); + } } diff --git a/crates/tangle/src/main.rs b/crates/tangle/src/main.rs @@ -68,6 +68,16 @@ fn main() -> ExitCode { ExitCode::from(2) } }, + tangle::TangleCommand::OpsBackup => match run_ops_backup(&invocation) { + Ok(output) => { + println!("{output}"); + ExitCode::SUCCESS + } + Err(error) => { + eprintln!("{error}"); + ExitCode::from(2) + } + }, } } @@ -117,3 +127,13 @@ fn run_projection_rebuild(invocation: &tangle::TangleInvocation) -> Result<Strin .map_err(|error| format!("failed to start runtime: {error}"))?; runtime.block_on(tangle::projection_rebuild_with_config(config_path)) } + +fn run_ops_backup(invocation: &tangle::TangleInvocation) -> Result<String, String> { + let config_path = tangle::require_config_path(invocation).map_err(|error| error.to_string())?; + let output_path = tangle::require_output_path(invocation).map_err(|error| error.to_string())?; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| format!("failed to start runtime: {error}"))?; + runtime.block_on(tangle::ops_backup_with_config(config_path, output_path)) +} diff --git a/crates/tangle/tests/version.rs b/crates/tangle/tests/version.rs @@ -3,7 +3,7 @@ use std::process::Command; use std::time::{Duration, Instant}; use tangle_protocol::event_to_value; -use tangle_store_surreal::{SurrealConnectionConfig, SurrealStore}; +use tangle_store_surreal::{SurrealConnectionConfig, SurrealStore, base_migration_plan}; use tangle_test_support::{FixtureKey, build_fixture_event, valid_public_listing_spec}; #[test] @@ -27,7 +27,7 @@ fn tangle_without_args_reports_usage() { assert!(output.status.success()); assert_eq!( String::from_utf8_lossy(&output.stdout), - "usage:\n tangle [--version]\n tangle migrate --config PATH\n tangle run --config PATH\n tangle event import --config PATH --input PATH\n tangle event export --config PATH --output PATH\n tangle projection rebuild --config PATH\n" + "usage:\n tangle [--version]\n tangle migrate --config PATH\n tangle run --config PATH\n tangle event import --config PATH --input PATH\n tangle event export --config PATH --output PATH\n tangle projection rebuild --config PATH\n tangle ops backup --config PATH --output DIR\n" ); assert!(output.stderr.is_empty()); } @@ -43,7 +43,7 @@ fn tangle_unknown_arg_reports_usage_error() { assert!(output.stdout.is_empty()); assert_eq!( String::from_utf8_lossy(&output.stderr), - "unknown command: --unknown\nusage:\n tangle [--version]\n tangle migrate --config PATH\n tangle run --config PATH\n tangle event import --config PATH --input PATH\n tangle event export --config PATH --output PATH\n tangle projection rebuild --config PATH\n" + "unknown command: --unknown\nusage:\n tangle [--version]\n tangle migrate --config PATH\n tangle run --config PATH\n tangle event import --config PATH --input PATH\n tangle event export --config PATH --output PATH\n tangle projection rebuild --config PATH\n tangle ops backup --config PATH --output DIR\n" ); } @@ -83,9 +83,12 @@ fn tangle_migrate_command_applies_configured_migrations() { std::fs::remove_file(&path).expect("remove config"); assert!(output.status.success()); + let migration_count = base_migration_plan().migrations().len(); assert_eq!( String::from_utf8_lossy(&output.stdout), - "migrations applied: 10\nmigrations already applied: 0\nmigrations total: 10\n" + format!( + "migrations applied: {migration_count}\nmigrations already applied: 0\nmigrations total: {migration_count}\n" + ) ); assert!(output.stderr.is_empty()); } @@ -103,6 +106,7 @@ async fn tangle_event_import_command_imports_canonical_jsonl() { let config_path = root.join("runtime.json"); let input_path = root.join("events.jsonl"); let output_path = root.join("exported.jsonl"); + let backup_path = root.join("backup"); std::fs::create_dir_all(&root).expect("runtime root"); write_rocksdb_config(&config_path, &db_path, "tangle_cli_import"); std::fs::write(&input_path, format!("{}\n", event_to_value(&listing))) @@ -157,6 +161,49 @@ async fn tangle_event_import_command_imports_canonical_jsonl() { format!("{}\n", event_to_value(&listing)) ); + let backup = Command::new(env!("CARGO_BIN_EXE_tangle")) + .args(["ops", "backup", "--config"]) + .arg(&config_path) + .args(["--output"]) + .arg(&backup_path) + .output() + .expect("run tangle ops backup"); + + assert!(backup.status.success()); + assert!(backup.stderr.is_empty()); + let backup_stdout = String::from_utf8_lossy(&backup.stdout); + assert!(backup_stdout.starts_with(&format!( + "backup directory: {}\nraw events: 1\nraw events sha256: ", + backup_path.display() + ))); + assert!(backup_stdout.contains(&format!( + "\nsurrealdb export available: false\nmanifest: {}\nmanifest sha256: ", + backup_path.join("manifest.json").display() + ))); + assert_eq!( + std::fs::read_to_string(backup_path.join("raw-events.jsonl")).expect("backup raw events"), + format!("{}\n", event_to_value(&listing)) + ); + let manifest: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(backup_path.join("manifest.json")).expect("backup manifest"), + ) + .expect("manifest JSON"); + assert_eq!(manifest["format"], "tangle-backup-v1"); + assert_eq!(manifest["database"]["namespace"], "tangle_cli_import"); + assert_eq!(manifest["database"]["database"], "relay"); + assert_eq!(manifest["raw_events"]["path"], "raw-events.jsonl"); + assert_eq!(manifest["raw_events"]["count"], 1); + assert_eq!( + manifest["raw_events"]["sha256"] + .as_str() + .expect("raw sha") + .len(), + 64 + ); + assert_eq!(manifest["surrealdb_export"]["available"], false); + assert!(manifest["surrealdb_export"]["path"].is_null()); + assert!(manifest["surrealdb_export"]["sha256"].is_null()); + let rebuild = Command::new(env!("CARGO_BIN_EXE_tangle")) .args(["projection", "rebuild", "--config"]) .arg(&config_path) diff --git a/crates/tangle_runtime/Cargo.toml b/crates/tangle_runtime/Cargo.toml @@ -12,6 +12,7 @@ axum = { version = "0.8", features = ["ws"] } http = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" +sha2 = "0.10" tangle_core = { path = "../tangle_core" } tangle_nips = { path = "../tangle_nips" } tangle_protocol = { path = "../tangle_protocol" } diff --git a/crates/tangle_runtime/src/lib.rs b/crates/tangle_runtime/src/lib.rs @@ -10,11 +10,12 @@ use axum::{ use core::fmt; use http::{HeaderMap, HeaderValue, StatusCode, header}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use std::{ collections::BTreeSet, fs, net::SocketAddr, - path::Path as FsPath, + path::{Path as FsPath, PathBuf}, sync::{ Arc, atomic::{AtomicU64, Ordering}, @@ -853,6 +854,189 @@ pub async fn export_events_to_path( Ok(RuntimeEventExportReport::new(rows.len() as u64)) } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuntimeBackupReport { + output_dir: PathBuf, + raw_events_path: PathBuf, + raw_event_count: u64, + raw_events_sha256: String, + manifest_path: PathBuf, + manifest_sha256: String, + surrealdb_export_available: bool, +} + +impl RuntimeBackupReport { + pub fn new( + output_dir: PathBuf, + raw_events_path: PathBuf, + raw_event_count: u64, + raw_events_sha256: String, + manifest_path: PathBuf, + manifest_sha256: String, + surrealdb_export_available: bool, + ) -> Self { + Self { + output_dir, + raw_events_path, + raw_event_count, + raw_events_sha256, + manifest_path, + manifest_sha256, + surrealdb_export_available, + } + } + + pub fn output_dir(&self) -> &FsPath { + &self.output_dir + } + + pub fn raw_events_path(&self) -> &FsPath { + &self.raw_events_path + } + + pub fn raw_event_count(&self) -> u64 { + self.raw_event_count + } + + pub fn raw_events_sha256(&self) -> &str { + &self.raw_events_sha256 + } + + pub fn manifest_path(&self) -> &FsPath { + &self.manifest_path + } + + pub fn manifest_sha256(&self) -> &str { + &self.manifest_sha256 + } + + pub fn surrealdb_export_available(&self) -> bool { + self.surrealdb_export_available + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct RuntimeBackupManifestDocument { + format: String, + database: RuntimeBackupDatabaseDocument, + raw_events: RuntimeBackupArtifactDocument, + surrealdb_export: RuntimeBackupOptionalArtifactDocument, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct RuntimeBackupDatabaseDocument { + namespace: String, + database: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct RuntimeBackupArtifactDocument { + path: String, + count: u64, + sha256: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct RuntimeBackupOptionalArtifactDocument { + available: bool, + path: Option<String>, + sha256: Option<String>, +} + +pub async fn backup_runtime_database( + config: &TangleRuntimeConfig, + output_dir: impl AsRef<FsPath>, +) -> Result<RuntimeBackupReport, RuntimeCommandError> { + let output_dir = output_dir.as_ref(); + tracing::info!( + command = "ops backup", + output_dir = output_dir.display().to_string(), + "starting runtime backup" + ); + let store = connect_runtime_store(config).await?; + let report = backup_runtime_store(config, &store, output_dir).await?; + tracing::info!( + command = "ops backup", + raw_event_count = report.raw_event_count(), + raw_events_sha256 = report.raw_events_sha256(), + manifest_sha256 = report.manifest_sha256(), + "finished runtime backup" + ); + Ok(report) +} + +async fn backup_runtime_store( + config: &TangleRuntimeConfig, + store: &SurrealStore, + output_dir: &FsPath, +) -> Result<RuntimeBackupReport, RuntimeCommandError> { + fs::create_dir_all(output_dir).map_err(|error| { + RuntimeCommandError::input(format!( + "failed to create backup directory `{}`: {error}", + output_dir.display() + )) + })?; + store + .apply_plan(&base_migration_plan()) + .await + .map_err(|error| RuntimeCommandError::store(error.to_string()))?; + let rows = store + .backup_raw_events() + .await + .map_err(|error| RuntimeCommandError::store(error.to_string()))?; + let mut raw_events = String::new(); + for row in &rows { + raw_events.push_str(&runtime_row_string(row, "raw_json")?); + raw_events.push('\n'); + } + let raw_events_path = output_dir.join("raw-events.jsonl"); + fs::write(&raw_events_path, raw_events.as_bytes()).map_err(|error| { + RuntimeCommandError::input(format!( + "failed to write backup raw events file `{}`: {error}", + raw_events_path.display() + )) + })?; + let raw_events_sha256 = sha256_hex(raw_events.as_bytes()); + let manifest = RuntimeBackupManifestDocument { + format: "tangle-backup-v1".to_owned(), + database: RuntimeBackupDatabaseDocument { + namespace: config.database_config().namespace().to_owned(), + database: config.database_config().database().to_owned(), + }, + raw_events: RuntimeBackupArtifactDocument { + path: "raw-events.jsonl".to_owned(), + count: rows.len() as u64, + sha256: raw_events_sha256.clone(), + }, + surrealdb_export: RuntimeBackupOptionalArtifactDocument { + available: false, + path: None, + sha256: None, + }, + }; + let mut manifest_json = serde_json::to_vec_pretty(&manifest).map_err(|error| { + RuntimeCommandError::store(format!("failed to serialize backup manifest: {error}")) + })?; + manifest_json.push(b'\n'); + let manifest_path = output_dir.join("manifest.json"); + fs::write(&manifest_path, &manifest_json).map_err(|error| { + RuntimeCommandError::input(format!( + "failed to write backup manifest file `{}`: {error}", + manifest_path.display() + )) + })?; + let manifest_sha256 = sha256_hex(&manifest_json); + Ok(RuntimeBackupReport::new( + output_dir.to_path_buf(), + raw_events_path, + rows.len() as u64, + raw_events_sha256, + manifest_path, + manifest_sha256, + false, + )) +} + fn runtime_row_string( row: &serde_json::Value, field: &'static str, @@ -863,6 +1047,13 @@ fn runtime_row_string( .ok_or_else(|| RuntimeCommandError::store(format!("stored row field `{field}` is invalid"))) } +fn sha256_hex(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct RuntimeProjectionRebuildReport { scanned: u64, @@ -4529,10 +4720,10 @@ mod tests { EventMessageHandler, GracefulShutdownSignal, ListingsHttpState, LiveEventFanout, MetricsHttpState, ReadinessCheckStatus, ReadinessState, RelayConnection, RelayConnectionConfig, RelayConnectionId, RelayInfoDocument, ReqMessageHandler, - RuntimeCommandErrorKind, RuntimeConfigErrorKind, RuntimeTracingFormat, - TANGLE_RELAY_SOFTWARE, TANGLE_RELAY_VERSION, TANGLE_SUPPORTED_NIPS, WebSocketHttpState, - health_router, listing_item_document, listing_projection_query, listings_router, - load_runtime_config, metrics_router, migrate_runtime_database, parse_listing_query, + RuntimeConfigErrorKind, RuntimeTracingFormat, TANGLE_RELAY_SOFTWARE, TANGLE_RELAY_VERSION, + TANGLE_SUPPORTED_NIPS, WebSocketHttpState, backup_runtime_store, health_router, + listing_item_document, listing_projection_query, listings_router, load_runtime_config, + metrics_router, migrate_runtime_database, parse_listing_query, parse_marketplace_search_query, parse_runtime_config_json, relay_info_router, search_document_query, websocket_router, }; @@ -4549,7 +4740,7 @@ mod tests { ClientMessage, EventId, PublicKeyHex, RelayMessage, SubscriptionId, UnixTimestamp, event_to_value, filter_from_value, }; - use tangle_store::StoredEvent; + use tangle_store::{StoreEventOutcome, StoredEvent}; use tangle_store_surreal::{ SurrealConnectionConfig, SurrealConnectionMode, SurrealStore, base_migration_plan, }; @@ -5176,41 +5367,103 @@ mod tests { } #[tokio::test] - async fn runtime_migration_command_rejects_remote_database_modes() { - let config = parse_runtime_config_json( - r#"{ - "server": { - "listen_addr": "127.0.0.1:7301", - "relay_url": "ws://127.0.0.1:7301" - }, - "database": { - "mode": "http", - "endpoint": "http://127.0.0.1:8000", - "namespace": "tangle", - "database": "relay" - }, - "auth": { - "challenge_ttl_seconds": 300 - }, - "limits": { - "message_rate_limit": { - "limit": 120, - "window_seconds": 60 - } + async fn runtime_backup_command_writes_manifest_and_raw_event_jsonl() { + let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); + let root = std::env::temp_dir().join(format!( + "tangle-runtime-backup-{}-{}", + std::process::id(), + &listing.id().as_str()[..8] + )); + let _ = std::fs::remove_dir_all(&root); + let db_path = root.join("db"); + let backup_path = root.join("backup"); + std::fs::create_dir_all(&root).expect("runtime root"); + let config_json = serde_json::json!({ + "server": { + "listen_addr": "127.0.0.1:7301", + "relay_url": "ws://127.0.0.1:7301" + }, + "database": { + "mode": "rocks_db", + "path": db_path.to_str().expect("db path"), + "namespace": "tangle_backup", + "database": "relay" + }, + "auth": { + "challenge_ttl_seconds": 300 + }, + "limits": { + "message_rate_limit": { + "limit": 120, + "window_seconds": 60 } - }"#, + }, + "policy": { + "approved_sellers": [FixtureKey::Seller.public_key().as_str()] + } + }); + let config = parse_runtime_config_json( + &serde_json::to_string(&config_json).expect("runtime config JSON"), ) .expect("runtime config"); - - let error = migrate_runtime_database(&config) + let store = SurrealStore::connect(config.database_config()) + .await + .expect("store"); + store + .apply_plan(&base_migration_plan()) .await - .expect_err("remote unsupported"); + .expect("apply plan"); + assert_eq!( + store + .store_raw_event(&StoredEvent::new( + listing.clone(), + UnixTimestamp::new(1_714_124_500) + )) + .await + .expect("store raw"), + StoreEventOutcome::Inserted + ); + let report = backup_runtime_store(&config, &store, &backup_path) + .await + .expect("backup"); - assert_eq!(error.kind(), RuntimeCommandErrorKind::Unsupported); + assert_eq!(report.output_dir(), backup_path.as_path()); + assert_eq!( + report.raw_events_path(), + backup_path.join("raw-events.jsonl") + ); + assert_eq!(report.raw_event_count(), 1); + assert_eq!(report.raw_events_sha256().len(), 64); + assert_eq!(report.manifest_path(), backup_path.join("manifest.json")); + assert_eq!(report.manifest_sha256().len(), 64); + assert!(!report.surrealdb_export_available()); assert_eq!( - error.message(), - "runtime commands currently support memory or rocksdb SurrealDB configs only" + std::fs::read_to_string(report.raw_events_path()).expect("raw events"), + format!("{}\n", event_to_value(&listing)) + ); + let manifest: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(report.manifest_path()).expect("manifest"), + ) + .expect("manifest JSON"); + assert_eq!(manifest["format"], "tangle-backup-v1"); + assert_eq!(manifest["database"]["namespace"], "tangle_backup"); + assert_eq!(manifest["database"]["database"], "relay"); + assert_eq!(manifest["raw_events"]["path"], "raw-events.jsonl"); + assert_eq!(manifest["raw_events"]["count"], 1); + assert_eq!(manifest["raw_events"]["sha256"], report.raw_events_sha256()); + assert_eq!(manifest["surrealdb_export"]["available"], false); + assert!(manifest["surrealdb_export"]["path"].is_null()); + assert!(manifest["surrealdb_export"]["sha256"].is_null()); + + assert!( + store + .raw_event_row(listing.id()) + .await + .expect("raw row") + .is_some() ); + drop(store); + std::fs::remove_dir_all(&root).expect("remove runtime root"); } #[tokio::test] diff --git a/crates/tangle_store_surreal/src/lib.rs b/crates/tangle_store_surreal/src/lib.rs @@ -1835,6 +1835,17 @@ CREATE type::record('nostr_event', $event_id) CONTENT { response.take(0).map_err(SurrealStoreError::from) } + pub async fn backup_raw_events(&self) -> Result<Vec<serde_json::Value>, SurrealStoreError> { + let mut response = self + .db + .query("SELECT * FROM nostr_event ORDER BY created_at ASC, event_id ASC;") + .await + .map_err(SurrealStoreError::from)? + .check() + .map_err(SurrealStoreError::from)?; + response.take(0).map_err(SurrealStoreError::from) + } + pub async fn index_event_tags(&self, event: &Event) -> Result<(), SurrealStoreError> { self.db .query("DELETE event_tag_index WHERE event_id = $event_id;") @@ -6431,6 +6442,12 @@ mod tests { .expect("deleted rows") .is_empty() ); + let backup_rows = store.backup_raw_events().await.expect("backup rows"); + assert!( + backup_rows + .iter() + .any(|row| row["event_id"] == first.id().as_str()) + ); } #[tokio::test]