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:
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]