lib

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

commit 3e202261bd7ac8177ace82089b5d961dad7fd9d0
parent 4d37ba6ec129e04a69cfc8f612d84c61911817fe
Author: triesap <tyson@radroots.org>
Date:   Mon, 15 Jun 2026 14:03:08 -0700

storage: allow sqlite store reopen

- make event-store and outbox schema setup idempotent for existing SQLite files
- add file reopen regression coverage for both stores
- validation: cargo test -p radroots_event_store; cargo test -p radroots_outbox

Diffstat:
MCargo.lock | 2++
Mcrates/event_store/Cargo.toml | 1+
Mcrates/event_store/migrations/0001_event_store.up.sql | 30+++++++++++++++---------------
Mcrates/event_store/src/store.rs | 13+++++++++++++
Mcrates/outbox/Cargo.toml | 1+
Mcrates/outbox/migrations/0001_outbox.up.sql | 16++++++++--------
Mcrates/outbox/src/store.rs | 13+++++++++++++
7 files changed, 53 insertions(+), 23 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -3988,6 +3988,7 @@ dependencies = [ "serde", "serde_json", "sqlx", + "tempfile", "thiserror 1.0.69", "tokio", ] @@ -4197,6 +4198,7 @@ dependencies = [ "serde_json", "sha2", "sqlx", + "tempfile", "thiserror 1.0.69", "tokio", ] diff --git a/crates/event_store/Cargo.toml b/crates/event_store/Cargo.toml @@ -32,4 +32,5 @@ sqlx = { workspace = true, optional = true, features = ["derive"] } thiserror = { workspace = true } [dev-dependencies] +tempfile = { workspace = true } tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/crates/event_store/migrations/0001_event_store.up.sql b/crates/event_store/migrations/0001_event_store.up.sql @@ -1,4 +1,4 @@ -CREATE TABLE nostr_event ( +CREATE TABLE IF NOT EXISTS nostr_event ( seq INTEGER PRIMARY KEY AUTOINCREMENT, event_id TEXT NOT NULL UNIQUE, pubkey TEXT NOT NULL, @@ -17,13 +17,13 @@ CREATE TABLE nostr_event ( updated_at_ms INTEGER NOT NULL ); -CREATE INDEX nostr_event_kind_created_idx ON nostr_event(kind, created_at, event_id); -CREATE INDEX nostr_event_contract_idx ON nostr_event(contract_id, seq); -CREATE INDEX nostr_event_projection_idx ON nostr_event(projection_eligible, seq); -CREATE INDEX nostr_event_verification_contract_idx +CREATE INDEX IF NOT EXISTS nostr_event_kind_created_idx ON nostr_event(kind, created_at, event_id); +CREATE INDEX IF NOT EXISTS nostr_event_contract_idx ON nostr_event(contract_id, seq); +CREATE INDEX IF NOT EXISTS nostr_event_projection_idx ON nostr_event(projection_eligible, seq); +CREATE INDEX IF NOT EXISTS nostr_event_verification_contract_idx ON nostr_event(verification_status, contract_status, seq); -CREATE TABLE nostr_event_tag ( +CREATE TABLE IF NOT EXISTS nostr_event_tag ( event_id TEXT NOT NULL REFERENCES nostr_event(event_id) ON DELETE CASCADE, tag_index INTEGER NOT NULL, tag_name TEXT NOT NULL, @@ -35,10 +35,10 @@ CREATE TABLE nostr_event_tag ( PRIMARY KEY (event_id, tag_index) ); -CREATE INDEX nostr_event_tag_lookup_idx ON nostr_event_tag(tag_name, tag_value, event_id); -CREATE INDEX nostr_event_tag_relay_idx ON nostr_event_tag(relay_indexed, tag_name, tag_value, event_id); +CREATE INDEX IF NOT EXISTS nostr_event_tag_lookup_idx ON nostr_event_tag(tag_name, tag_value, event_id); +CREATE INDEX IF NOT EXISTS nostr_event_tag_relay_idx ON nostr_event_tag(relay_indexed, tag_name, tag_value, event_id); -CREATE TABLE relay_event_seen ( +CREATE TABLE IF NOT EXISTS relay_event_seen ( event_id TEXT NOT NULL REFERENCES nostr_event(event_id) ON DELETE CASCADE, relay_url TEXT NOT NULL, observation_type TEXT NOT NULL, @@ -49,9 +49,9 @@ CREATE TABLE relay_event_seen ( PRIMARY KEY (event_id, relay_url, observation_type) ); -CREATE INDEX relay_event_seen_relay_idx ON relay_event_seen(relay_url, last_seen_at_ms, event_id); +CREATE INDEX IF NOT EXISTS relay_event_seen_relay_idx ON relay_event_seen(relay_url, last_seen_at_ms, event_id); -CREATE TABLE nostr_event_head ( +CREATE TABLE IF NOT EXISTS nostr_event_head ( coordinate_type TEXT NOT NULL, kind INTEGER NOT NULL, pubkey TEXT NOT NULL, @@ -65,17 +65,17 @@ CREATE TABLE nostr_event_head ( ) ); -CREATE UNIQUE INDEX nostr_event_head_replaceable_idx +CREATE UNIQUE INDEX IF NOT EXISTS nostr_event_head_replaceable_idx ON nostr_event_head(kind, pubkey) WHERE coordinate_type = 'replaceable'; -CREATE UNIQUE INDEX nostr_event_head_addressable_idx +CREATE UNIQUE INDEX IF NOT EXISTS nostr_event_head_addressable_idx ON nostr_event_head(kind, pubkey, d_tag) WHERE coordinate_type = 'addressable'; -CREATE INDEX nostr_event_head_event_idx ON nostr_event_head(event_id); +CREATE INDEX IF NOT EXISTS nostr_event_head_event_idx ON nostr_event_head(event_id); -CREATE TABLE projection_cursor ( +CREATE TABLE IF NOT EXISTS projection_cursor ( projection_id TEXT PRIMARY KEY NOT NULL, projection_version INTEGER NOT NULL DEFAULT 1, last_event_seq INTEGER NOT NULL DEFAULT 0, diff --git a/crates/event_store/src/store.rs b/crates/event_store/src/store.rs @@ -888,6 +888,19 @@ mod tests { } #[tokio::test] + async fn file_store_reopens_existing_schema() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let path = tempdir.path().join("event_store.sqlite"); + + let first = RadrootsEventStore::open_file(&path).await.expect("first"); + assert_eq!(first.pragma_foreign_keys().await.expect("foreign_keys"), 1); + drop(first); + + let second = RadrootsEventStore::open_file(&path).await.expect("second"); + assert_eq!(second.pragma_foreign_keys().await.expect("foreign_keys"), 1); + } + + #[tokio::test] async fn migration_can_run_down() { let store = RadrootsEventStore::open_memory().await.expect("open"); store.migrate_down().await.expect("down"); diff --git a/crates/outbox/Cargo.toml b/crates/outbox/Cargo.toml @@ -38,4 +38,5 @@ radroots_nostr = { workspace = true, default-features = false, features = [ "std", "events", ] } +tempfile = { workspace = true } tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/crates/outbox/migrations/0001_outbox.up.sql b/crates/outbox/migrations/0001_outbox.up.sql @@ -1,4 +1,4 @@ -CREATE TABLE outbox_operation ( +CREATE TABLE IF NOT EXISTS outbox_operation ( operation_id INTEGER PRIMARY KEY AUTOINCREMENT, operation_kind TEXT NOT NULL, expected_pubkey TEXT NOT NULL, @@ -9,14 +9,14 @@ CREATE TABLE outbox_operation ( updated_at_ms INTEGER NOT NULL ); -CREATE UNIQUE INDEX outbox_operation_idempotency_idx +CREATE UNIQUE INDEX IF NOT EXISTS outbox_operation_idempotency_idx ON outbox_operation(operation_kind, expected_pubkey, idempotency_key) WHERE idempotency_key IS NOT NULL; -CREATE INDEX outbox_operation_status_idx +CREATE INDEX IF NOT EXISTS outbox_operation_status_idx ON outbox_operation(status, created_at_ms, operation_id); -CREATE TABLE outbox_event ( +CREATE TABLE IF NOT EXISTS outbox_event ( outbox_event_id INTEGER PRIMARY KEY AUTOINCREMENT, operation_id INTEGER NOT NULL REFERENCES outbox_operation(operation_id) ON DELETE CASCADE, event_id TEXT NOT NULL, @@ -39,13 +39,13 @@ CREATE TABLE outbox_event ( updated_at_ms INTEGER NOT NULL ); -CREATE INDEX outbox_event_ready_idx +CREATE INDEX IF NOT EXISTS outbox_event_ready_idx ON outbox_event(state, next_attempt_after_ms, claim_expires_at_ms, created_at_ms, outbox_event_id); -CREATE INDEX outbox_event_event_id_idx +CREATE INDEX IF NOT EXISTS outbox_event_event_id_idx ON outbox_event(event_id); -CREATE TABLE outbox_event_relay_status ( +CREATE TABLE IF NOT EXISTS outbox_event_relay_status ( outbox_event_id INTEGER NOT NULL REFERENCES outbox_event(outbox_event_id) ON DELETE CASCADE, relay_url TEXT NOT NULL, status TEXT NOT NULL CHECK (status IN ('pending', 'accepted', 'failed_retryable', 'failed_terminal')), @@ -56,5 +56,5 @@ CREATE TABLE outbox_event_relay_status ( PRIMARY KEY (outbox_event_id, relay_url) ); -CREATE INDEX outbox_event_relay_status_idx +CREATE INDEX IF NOT EXISTS outbox_event_relay_status_idx ON outbox_event_relay_status(status, relay_url, outbox_event_id); diff --git a/crates/outbox/src/store.rs b/crates/outbox/src/store.rs @@ -1218,6 +1218,19 @@ mod tests { assert!(row.is_none()); } + #[tokio::test] + async fn file_outbox_reopens_existing_schema() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let path = tempdir.path().join("outbox.sqlite"); + + let first = RadrootsOutbox::open_file(&path).await.expect("first"); + assert_eq!(first.pragma_foreign_keys().await.expect("foreign keys"), 1); + drop(first); + + let second = RadrootsOutbox::open_file(&path).await.expect("second"); + assert_eq!(second.pragma_foreign_keys().await.expect("foreign keys"), 1); + } + #[test] fn terminal_and_cancelled_event_states_round_trip() { assert_eq!(