app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 4a44b40aadfa4adf29ae1a177625595b4504cb0f
parent aca53d581effdd201e010dc7927ffb09e8ff6093
Author: triesap <tyson@radroots.org>
Date:   Sat, 23 May 2026 06:55:51 +0000

sqlite: map signed listing lifecycle status

- parse signed listing status and window evidence before projecting app lifecycle state
- keep CLI local work listing imports as app drafts until relay publication is confirmed
- skip unknown acknowledged listing statuses instead of silently publishing them
- cover active, window, archived, sold, unknown, and draft interop imports

Diffstat:
Mcrates/shared/sqlite/src/local_interop.rs | 204+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
1 file changed, 196 insertions(+), 8 deletions(-)

diff --git a/crates/shared/sqlite/src/local_interop.rs b/crates/shared/sqlite/src/local_interop.rs @@ -336,7 +336,7 @@ impl<'a> AppLocalInteropRepository<'a> { farm_id, title, subtitle, - status: product_status_for_record(record), + status: ProductStatus::Draft, unit_label, price_minor_units, price_currency, @@ -471,12 +471,15 @@ impl<'a> AppLocalInteropRepository<'a> { .and_then(|content| string_at(content, &["inventory_available"])) .or_else(|| tag_index_value(tags, "inventory", 1)) .and_then(|quantity| parse_u32_quantity(quantity.as_str())); + let Some(status) = signed_listing_product_status(record, content.as_ref(), tags) else { + return Ok(None); + }; self.upsert_product(ProductProjection { product_id, farm_id, title, subtitle, - status: product_status_for_record(record), + status, unit_label, price_minor_units, price_currency, @@ -802,13 +805,75 @@ fn parse_u32_quantity(value: &str) -> Option<u32> { whole.parse::<u32>().ok() } -fn product_status_for_record(record: &LocalEventRecord) -> ProductStatus { - if record.status == LocalRecordStatus::Published - && record.outbox_status == PublishOutboxStatus::Acknowledged +fn signed_listing_product_status( + record: &LocalEventRecord, + content: Option<&Value>, + tags: Option<&Value>, +) -> Option<ProductStatus> { + if record.status != LocalRecordStatus::Published + || record.outbox_status != PublishOutboxStatus::Acknowledged { - ProductStatus::Published - } else { - ProductStatus::Draft + return Some(ProductStatus::Draft); + } + match signed_listing_lifecycle(content, tags)? { + SignedListingLifecycle::Active | SignedListingLifecycle::Window => { + Some(ProductStatus::Published) + } + SignedListingLifecycle::Archived => Some(ProductStatus::Archived), + SignedListingLifecycle::Sold => Some(ProductStatus::Paused), + } +} + +fn signed_listing_lifecycle( + content: Option<&Value>, + tags: Option<&Value>, +) -> Option<SignedListingLifecycle> { + content + .and_then(lifecycle_from_content) + .or_else(|| lifecycle_from_tags(tags)) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum SignedListingLifecycle { + Active, + Window, + Archived, + Sold, +} + +fn lifecycle_from_content(content: &Value) -> Option<SignedListingLifecycle> { + string_at(content, &["status"]) + .or_else(|| string_at(content, &["availability", "status"])) + .or_else(|| string_at(content, &["availability", "amount", "status"])) + .or_else(|| string_at(content, &["availability", "amount", "kind"])) + .or_else(|| string_at(content, &["availability", "amount", "value"])) + .and_then(|status| parse_listing_lifecycle(status.as_str())) + .or_else(|| { + matches!( + string_at(content, &["availability", "kind"]).as_deref(), + Some("window") + ) + .then_some(SignedListingLifecycle::Window) + }) +} + +fn lifecycle_from_tags(tags: Option<&Value>) -> Option<SignedListingLifecycle> { + tag_index_value(tags, "status", 1) + .and_then(|status| parse_listing_lifecycle(status.as_str())) + .or_else(|| { + tag_index_value(tags, "radroots:availability_start", 1) + .or_else(|| tag_index_value(tags, "expires_at", 1)) + .map(|_| SignedListingLifecycle::Window) + }) +} + +fn parse_listing_lifecycle(value: &str) -> Option<SignedListingLifecycle> { + match value.trim().to_ascii_lowercase().as_str() { + "active" | "available" | "published" => Some(SignedListingLifecycle::Active), + "window" => Some(SignedListingLifecycle::Window), + "archived" => Some(SignedListingLifecycle::Archived), + "sold" => Some(SignedListingLifecycle::Sold), + _ => None, } } @@ -922,6 +987,56 @@ mod tests { } } + fn signed_listing_record( + record_id: &str, + farm_key: &str, + listing_key: &str, + status_tag: &str, + ) -> LocalEventRecordInput { + LocalEventRecordInput { + record_id: record_id.to_owned(), + family: LocalRecordFamily::SignedEvent, + status: LocalRecordStatus::Published, + source_runtime: SourceRuntime::Cli, + created_at_ms: 1100, + inserted_at_ms: 1101, + owner_account_id: Some("seller-account".to_owned()), + owner_pubkey: Some("seller-pubkey".to_owned()), + farm_id: Some(farm_key.to_owned()), + listing_addr: Some(format!("30402:seller-pubkey:{listing_key}")), + local_work_json: None, + event_id: Some(format!("event-{record_id}")), + event_kind: Some(KIND_LISTING), + event_pubkey: Some("seller-pubkey".to_owned()), + event_created_at: Some(1100), + event_tags_json: Some(json!([ + ["d", listing_key], + ["a", format!("30340:seller-pubkey:{farm_key}")], + ["key", "eggs"], + ["title", "Relay Eggs"], + ["summary", "Published eggs"], + ["radroots:bin", "bin-1", "1", "each"], + ["radroots:price", "bin-1", "8", "USD", "1", "each"], + ["inventory", "9"], + ["status", status_tag] + ])), + event_content: Some("# Relay Eggs\n\nPublished eggs".to_owned()), + event_sig: Some("signature".to_owned()), + raw_event_json: Some(json!({ + "id": format!("event-{record_id}"), + "kind": KIND_LISTING, + "pubkey": "seller-pubkey", + "content": "# Relay Eggs\n\nPublished eggs" + })), + outbox_status: PublishOutboxStatus::Acknowledged, + relay_set_fingerprint: Some("relay-set".to_owned()), + relay_delivery_json: Some(json!({ + "state": "acknowledged", + "acknowledged_relays": ["ws://127.0.0.1:1234/"] + })), + } + } + #[test] fn imports_cli_local_work_into_app_farm_and_product_projection() { let app_store = @@ -1041,6 +1156,10 @@ mod tests { 600 ); assert_eq!(products.rows[0].stock.quantity, Some(10)); + assert_eq!( + products.rows[0].status, + radroots_app_models::ProductStatus::Draft + ); } #[test] @@ -1190,4 +1309,73 @@ mod tests { assert_eq!(product.2, Some(800)); assert_eq!(product.3, Some(9)); } + + #[test] + fn maps_acknowledged_signed_listing_lifecycle_statuses() { + for (status_tag, expected_product_status) in [ + ("active", "published"), + ("window", "published"), + ("archived", "archived"), + ("sold", "paused"), + ] { + let app_store = + AppSqliteStore::open(DatabaseTarget::InMemory).expect("open app sqlite store"); + let events = local_events_store(); + let farm_key = "AAAAAAAAAAAAAAAAAAAAAA"; + let listing_key = "BBBBBBBBBBBBBBBBBBBBBB"; + events + .append_record(&signed_listing_record( + status_tag, + farm_key, + listing_key, + status_tag, + )) + .expect("append signed listing"); + + let report = app_store + .import_shared_local_events_from_store(&events) + .expect("import signed listing"); + let product_status: String = app_store + .connection() + .query_row("SELECT status FROM products", [], |row| row.get(0)) + .expect("load product status"); + + assert_eq!(report.imported_records, 1); + assert_eq!(report.skipped_records, 0); + assert_eq!(product_status, expected_product_status); + } + } + + #[test] + fn unknown_acknowledged_signed_listing_status_is_not_published() { + let app_store = + AppSqliteStore::open(DatabaseTarget::InMemory).expect("open app sqlite store"); + let events = local_events_store(); + let farm_key = "AAAAAAAAAAAAAAAAAAAAAA"; + let listing_key = "BBBBBBBBBBBBBBBBBBBBBB"; + events + .append_record(&signed_listing_record( + "unknown-status", + farm_key, + listing_key, + "unknown-status", + )) + .expect("append signed listing"); + + let report = app_store + .import_shared_local_events_from_store(&events) + .expect("import signed listing"); + let imported = app_store + .load_local_interop_records() + .expect("load imported records"); + let product_count: i64 = app_store + .connection() + .query_row("SELECT COUNT(*) FROM products", [], |row| row.get(0)) + .expect("product count"); + + assert_eq!(report.imported_records, 0); + assert_eq!(report.skipped_records, 1); + assert_eq!(imported[0].projected_kind, "unsupported"); + assert_eq!(product_count, 0); + } }